diff --git a/engine/components-api/src/main/java/com/cloud/alert/AlertDeliveryHelper.java b/engine/components-api/src/main/java/com/cloud/alert/AlertDeliveryHelper.java
new file mode 100644
index 000000000000..b4e76444e682
--- /dev/null
+++ b/engine/components-api/src/main/java/com/cloud/alert/AlertDeliveryHelper.java
@@ -0,0 +1,23 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+package com.cloud.alert;
+
+import com.cloud.utils.component.Adapter;
+
+public interface AlertDeliveryHelper extends Adapter {
+ void sendAlert(AlertManager.AlertType alertType, long dataCenterId, Long podId, Long clusterId, String subject, String body);
+}
diff --git a/plugins/integrations/ppurio-alerts/pom.xml b/plugins/integrations/ppurio-alerts/pom.xml
new file mode 100644
index 000000000000..41e77befd187
--- /dev/null
+++ b/plugins/integrations/ppurio-alerts/pom.xml
@@ -0,0 +1,58 @@
+
+
+
+ 4.0.0
+ cloud-plugin-integrations-ppurio-alerts
+ Apache CloudStack Plugin - Ppurio Alerts
+
+ org.apache.cloudstack
+ cloudstack-plugins
+ 4.21.0.0-SNAPSHOT
+ ../../pom.xml
+
+
+
+ org.apache.cloudstack
+ cloud-api
+ ${project.version}
+
+
+ org.apache.cloudstack
+ cloud-utils
+ ${project.version}
+
+
+ org.apache.cloudstack
+ cloud-engine-components-api
+ ${project.version}
+
+
+ org.apache.logging.log4j
+ log4j-api
+
+
+ org.springframework
+ spring-context
+ ${org.springframework.version}
+
+
+
diff --git a/plugins/integrations/ppurio-alerts/src/main/java/org/apache/cloudstack/alert/ppurio/PpurioAlertConfigKeys.java b/plugins/integrations/ppurio-alerts/src/main/java/org/apache/cloudstack/alert/ppurio/PpurioAlertConfigKeys.java
new file mode 100644
index 000000000000..a6adccc5e2ee
--- /dev/null
+++ b/plugins/integrations/ppurio-alerts/src/main/java/org/apache/cloudstack/alert/ppurio/PpurioAlertConfigKeys.java
@@ -0,0 +1,107 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+package org.apache.cloudstack.alert.ppurio;
+
+import org.apache.cloudstack.framework.config.ConfigKey;
+
+public class PpurioAlertConfigKeys {
+ public static final String DEFAULT_PINNED_CERTIFICATE_PATH = "/var/cloudstack/management/ppurio-sectigo-rsa-domain-validation-secure-server-ca.pem";
+
+ public static final ConfigKey ALERT_KAKAO_ENABLED = new ConfigKey<>(ConfigKey.CATEGORY_ALERT, Boolean.class,
+ "kakao.ppurio.enabled", "false",
+ "Enable Kakao AlimTalk delivery for Alerts through the Ppurio integration module.", true);
+
+ public static final ConfigKey BASE_URL = new ConfigKey<>(ConfigKey.CATEGORY_ALERT, String.class,
+ "kakao.ppurio.baseUrl", "https://message.ppurio.com",
+ "Ppurio message API base URL.", true);
+
+ public static final ConfigKey PINNED_CERTIFICATE_PATH = new ConfigKey<>(ConfigKey.CATEGORY_ALERT, String.class,
+ "kakao.ppurio.pinnedCertificatePath", DEFAULT_PINNED_CERTIFICATE_PATH,
+ "Filesystem path to the Ppurio pinned intermediate certificate PEM. The default file is created when Ppurio alert delivery is enabled.", true);
+
+ public static final ConfigKey PINNED_CERTIFICATE_PEM = new ConfigKey<>(ConfigKey.CATEGORY_ALERT, String.class,
+ "kakao.ppurio.pinnedCertificatePem", "",
+ "Optional PEM content used to create the Ppurio pinned intermediate certificate file. Leave blank to use the built-in default certificate. Escaped \\n line breaks are supported.", true);
+
+ public static final ConfigKey ACCOUNT = new ConfigKey<>(ConfigKey.CATEGORY_ALERT, String.class,
+ "kakao.ppurio.account", "",
+ "Ppurio account identifier used to issue API tokens.", true);
+
+ public static final ConfigKey API_KEY = new ConfigKey<>("Secure", String.class,
+ "kakao.ppurio.apiKey", "",
+ "Ppurio API key used to issue API tokens.", true);
+
+ public static final ConfigKey SENDER_PROFILE = new ConfigKey<>(ConfigKey.CATEGORY_ALERT, String.class,
+ "kakao.ppurio.senderProfile", "",
+ "Ppurio Kakao sender profile.", true);
+
+ public static final ConfigKey TEMPLATE_CODE = new ConfigKey<>(ConfigKey.CATEGORY_ALERT, String.class,
+ "kakao.ppurio.templateCode", "",
+ "Ppurio Kakao AlimTalk template code.", true);
+
+ public static final ConfigKey TARGET_NAME = new ConfigKey<>(ConfigKey.CATEGORY_ALERT, String.class,
+ "kakao.ppurio.targetName", "Ablestack Alert",
+ "Default target name sent to Ppurio for Alert Kakao AlimTalk recipients.", true);
+
+ public static final ConfigKey DUPLICATE_FLAG = new ConfigKey<>(ConfigKey.CATEGORY_ALERT, String.class,
+ "kakao.ppurio.duplicateFlag", "Y",
+ "Ppurio duplicateFlag value for Kakao AlimTalk requests.", true);
+
+ public static final ConfigKey RECIPIENTS = new ConfigKey<>(ConfigKey.CATEGORY_ALERT, String.class,
+ "kakao.ppurio.recipients", "",
+ "Comma-separated recipient phone numbers for Alert Kakao AlimTalk delivery.", true);
+
+ public static final ConfigKey CHANGE_WORD_VAR1 = new ConfigKey<>(ConfigKey.CATEGORY_ALERT, String.class,
+ "kakao.ppurio.changeWord.var1", "${subject}",
+ "Template for Ppurio Kakao AlimTalk changeWord var1, used as the alert title. Supported placeholders: ${alertType}, ${subject}", true);
+
+ public static final ConfigKey CHANGE_WORD_VAR2 = new ConfigKey<>(ConfigKey.CATEGORY_ALERT, String.class,
+ "kakao.ppurio.changeWord.var2", "${alertType}",
+ "Template for Ppurio Kakao AlimTalk changeWord var2, used as the alert type. Supported placeholders: ${alertType}, ${subject}", true);
+
+ public static final ConfigKey RESEND_ENABLED = new ConfigKey<>(ConfigKey.CATEGORY_ALERT, Boolean.class,
+ "kakao.ppurio.resend.enabled", "false",
+ "Enable Ppurio fallback resend settings for failed Kakao AlimTalk delivery.", true);
+
+ public static final ConfigKey RESEND_MESSAGE_TYPE = new ConfigKey<>(ConfigKey.CATEGORY_ALERT, String.class,
+ "kakao.ppurio.resend.messageType", "SMS",
+ "Ppurio fallback resend message type.", true);
+
+ public static final ConfigKey RESEND_FROM = new ConfigKey<>(ConfigKey.CATEGORY_ALERT, String.class,
+ "kakao.ppurio.resend.from", "",
+ "Sender phone number used for Ppurio fallback resend.", true);
+
+ public static final ConfigKey RESEND_SUBJECT_TEMPLATE = new ConfigKey<>(ConfigKey.CATEGORY_ALERT, String.class,
+ "kakao.ppurio.resend.subjectTemplate", "MOLD 경보",
+ "Subject template for Ppurio fallback resend. Supported placeholders: ${alertType}, ${subject}. Rendered subject is limited to 30 bytes.", true);
+
+ public static final ConfigKey RESEND_CONTENT_TEMPLATE = new ConfigKey<>(ConfigKey.CATEGORY_ALERT, String.class,
+ "kakao.ppurio.resend.contentTemplate",
+ "MOLD 경보: ${subject}",
+ "Content template for Ppurio fallback resend. Supported placeholders: ${alertType}, ${subject}. Rendered content is limited to 90 bytes.", true);
+
+ public static final ConfigKey CONNECT_TIMEOUT_MS = new ConfigKey<>(ConfigKey.CATEGORY_ALERT, Integer.class,
+ "kakao.ppurio.connectTimeoutMs", "5000",
+ "Connection timeout in milliseconds for the Ppurio API.", true);
+
+ public static final ConfigKey READ_TIMEOUT_MS = new ConfigKey<>(ConfigKey.CATEGORY_ALERT, Integer.class,
+ "kakao.ppurio.readTimeoutMs", "10000",
+ "Read timeout in milliseconds for the Ppurio API.", true);
+
+ private PpurioAlertConfigKeys() {
+ }
+}
diff --git a/plugins/integrations/ppurio-alerts/src/main/java/org/apache/cloudstack/alert/ppurio/PpurioAlertDeliveryHelper.java b/plugins/integrations/ppurio-alerts/src/main/java/org/apache/cloudstack/alert/ppurio/PpurioAlertDeliveryHelper.java
new file mode 100644
index 000000000000..2ab7e7bc722b
--- /dev/null
+++ b/plugins/integrations/ppurio-alerts/src/main/java/org/apache/cloudstack/alert/ppurio/PpurioAlertDeliveryHelper.java
@@ -0,0 +1,671 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+package org.apache.cloudstack.alert.ppurio;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.net.HttpURLConnection;
+import java.net.URL;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.security.GeneralSecurityException;
+import java.security.KeyStore;
+import java.security.SecureRandom;
+import java.security.cert.Certificate;
+import java.security.cert.CertificateFactory;
+import java.util.ArrayList;
+import java.util.Base64;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import javax.net.ssl.HttpsURLConnection;
+import javax.net.ssl.SSLContext;
+import javax.net.ssl.SSLSocketFactory;
+import javax.net.ssl.TrustManagerFactory;
+
+import org.apache.cloudstack.framework.config.ConfigKey;
+import org.apache.cloudstack.framework.config.Configurable;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+import org.springframework.context.ApplicationContext;
+import org.springframework.context.ApplicationContextAware;
+
+import com.cloud.alert.AlertDeliveryHelper;
+import com.cloud.alert.AlertManager;
+import com.cloud.utils.component.AdapterBase;
+import com.cloud.utils.component.ComponentContext;
+import com.google.gson.Gson;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
+
+public class PpurioAlertDeliveryHelper extends AdapterBase implements AlertDeliveryHelper, Configurable, ApplicationContextAware {
+ private static final Logger logger = LogManager.getLogger(PpurioAlertDeliveryHelper.class);
+ private static final Gson gson = new Gson();
+ private static final String TOKEN_PATH = "/v1/token";
+ private static final String KAKAO_PATH = "/v1/kakao";
+ private static final String ALIMTALK_MESSAGE_TYPE = "ALT";
+ private static final String RESEND_ENABLED_VALUE = "Y";
+ private static final String RESEND_DISABLED_VALUE = "N";
+ private static final String PPURIO_PINNED_INTERMEDIATE_ALIAS = "ppurio-sectigo-rsa-dv-ca";
+ private static final String TRUNCATION_SUFFIX = "...";
+ private static final String DEFAULT_PINNED_INTERMEDIATE_CERTIFICATE_PEM = "-----BEGIN CERTIFICATE-----\n"
+ + "MIIGEzCCA/ugAwIBAgIQfVtRJrR2uhHbdBYLvFMNpzANBgkqhkiG9w0BAQwFADCB\n"
+ + "iDELMAkGA1UEBhMCVVMxEzARBgNVBAgTCk5ldyBKZXJzZXkxFDASBgNVBAcTC0pl\n"
+ + "cnNleSBDaXR5MR4wHAYDVQQKExVUaGUgVVNFUlRSVVNUIE5ldHdvcmsxLjAsBgNV\n"
+ + "BAMTJVVTRVJUcnVzdCBSU0EgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkwHhcNMTgx\n"
+ + "MTAyMDAwMDAwWhcNMzAxMjMxMjM1OTU5WjCBjzELMAkGA1UEBhMCR0IxGzAZBgNV\n"
+ + "BAgTEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4GA1UEBxMHU2FsZm9yZDEYMBYGA1UE\n"
+ + "ChMPU2VjdGlnbyBMaW1pdGVkMTcwNQYDVQQDEy5TZWN0aWdvIFJTQSBEb21haW4g\n"
+ + "VmFsaWRhdGlvbiBTZWN1cmUgU2VydmVyIENBMIIBIjANBgkqhkiG9w0BAQEFAAOC\n"
+ + "AQ8AMIIBCgKCAQEA1nMz1tc8INAA0hdFuNY+B6I/x0HuMjDJsGz99J/LEpgPLT+N\n"
+ + "TQEMgg8Xf2Iu6bhIefsWg06t1zIlk7cHv7lQP6lMw0Aq6Tn/2YHKHxYyQdqAJrkj\n"
+ + "eocgHuP/IJo8lURvh3UGkEC0MpMWCRAIIz7S3YcPb11RFGoKacVPAXJpz9OTTG0E\n"
+ + "oKMbgn6xmrntxZ7FN3ifmgg0+1YuWMQJDgZkW7w33PGfKGioVrCSo1yfu4iYCBsk\n"
+ + "Haswha6vsC6eep3BwEIc4gLw6uBK0u+QDrTBQBbwb4VCSmT3pDCg/r8uoydajotY\n"
+ + "uK3DGReEY+1vVv2Dy2A0xHS+5p3b4eTlygxfFQIDAQABo4IBbjCCAWowHwYDVR0j\n"
+ + "BBgwFoAUU3m/WqorSs9UgOHYm8Cd8rIDZsswHQYDVR0OBBYEFI2MXsRUrYrhd+mb\n"
+ + "+ZsF4bgBjWHhMA4GA1UdDwEB/wQEAwIBhjASBgNVHRMBAf8ECDAGAQH/AgEAMB0G\n"
+ + "A1UdJQQWMBQGCCsGAQUFBwMBBggrBgEFBQcDAjAbBgNVHSAEFDASMAYGBFUdIAAw\n"
+ + "CAYGZ4EMAQIBMFAGA1UdHwRJMEcwRaBDoEGGP2h0dHA6Ly9jcmwudXNlcnRydXN0\n"
+ + "LmNvbS9VU0VSVHJ1c3RSU0FDZXJ0aWZpY2F0aW9uQXV0aG9yaXR5LmNybDB2Bggr\n"
+ + "BgEFBQcBAQRqMGgwPwYIKwYBBQUHMAKGM2h0dHA6Ly9jcnQudXNlcnRydXN0LmNv\n"
+ + "bS9VU0VSVHJ1c3RSU0FBZGRUcnVzdENBLmNydDAlBggrBgEFBQcwAYYZaHR0cDov\n"
+ + "L29jc3AudXNlcnRydXN0LmNvbTANBgkqhkiG9w0BAQwFAAOCAgEAMr9hvQ5Iw0/H\n"
+ + "ukdN+Jx4GQHcEx2Ab/zDcLRSmjEzmldS+zGea6TvVKqJjUAXaPgREHzSyrHxVYbH\n"
+ + "7rM2kYb2OVG/Rr8PoLq0935JxCo2F57kaDl6r5ROVm+yezu/Coa9zcV3HAO4OLGi\n"
+ + "H19+24rcRki2aArPsrW04jTkZ6k4Zgle0rj8nSg6F0AnwnJOKf0hPHzPE/uWLMUx\n"
+ + "RP0T7dWbqWlod3zu4f+k+TY4CFM5ooQ0nBnzvg6s1SQ36yOoeNDT5++SR2RiOSLv\n"
+ + "xvcRviKFxmZEJCaOEDKNyJOuB56DPi/Z+fVGjmO+wea03KbNIaiGCpXZLoUmGv38\n"
+ + "sbZXQm2V0TP2ORQGgkE49Y9Y3IBbpNV9lXj9p5v//cWoaasm56ekBYdbqbe4oyAL\n"
+ + "l6lFhd2zi+WJN44pDfwGF/Y4QA5C5BIG+3vzxhFoYt/jmPQT2BVPi7Fp2RBgvGQq\n"
+ + "6jG35LWjOhSbJuMLe/0CjraZwTiXWTb2qHSihrZe68Zk6s+go/lunrotEbaGmAhY\n"
+ + "LcmsJWTyXnW0OMGuf1pGg+pRyrbxmRE1a6Vqe8YAsOf4vmSyrcjC8azjUeqkk+B5\n"
+ + "yOGBQMkKW+ESPMFgKuOXwIlCypTPRpgSabuY0MLTDXJLR27lk8QyKGOHQ+SwMj4K\n"
+ + "00u/I5sUKUErmgQfky3xxzlIPK1aEn8=\n"
+ + "-----END CERTIFICATE-----\n";
+ private static final int PPURIO_CHANGE_WORD_MAX_BYTES = 100;
+ private static final int PPURIO_CHANGE_WORD_MAX_CHARS = 50;
+ private static final int PPURIO_RESEND_SUBJECT_MAX_BYTES = 30;
+ private static final int PPURIO_RESEND_CONTENT_MAX_BYTES = 90;
+ private static final int REF_KEY_LENGTH = 32;
+ private static final char[] REF_KEY_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789".toCharArray();
+ private static final SecureRandom secureRandom = new SecureRandom();
+ private static final Object sslSocketFactoryLock = new Object();
+
+ private static volatile SSLSocketFactory ppurioPinnedSslSocketFactory;
+ private static volatile String ppurioPinnedCertificateSource;
+
+ private ApplicationContext applicationContext;
+
+ @Override
+ public boolean start() {
+ if (PpurioAlertConfigKeys.ALERT_KAKAO_ENABLED.value()) {
+ try {
+ ensurePinnedCertificateFile();
+ } catch (IOException e) {
+ logger.warn("Failed to prepare Ppurio pinned intermediate certificate file", e);
+ }
+ }
+ if (applicationContext == null) {
+ logger.warn("Unable to register Ppurio AlertDeliveryHelper delegate context because application context is null");
+ return true;
+ }
+ ComponentContext.addDelegateContext(AlertDeliveryHelper.class, applicationContext);
+ logger.info("Registered Ppurio AlertDeliveryHelper delegate context");
+ return true;
+ }
+
+ @Override
+ public boolean stop() {
+ ComponentContext.removeDelegateContext(AlertDeliveryHelper.class);
+ return true;
+ }
+
+ @Override
+ public void setApplicationContext(ApplicationContext applicationContext) {
+ this.applicationContext = applicationContext;
+ }
+
+ @Override
+ public void sendAlert(AlertManager.AlertType alertType, long dataCenterId, Long podId, Long clusterId, String subject, String body) {
+ // logger.debug(String.format("Received Ppurio Kakao alert delivery request [alertType=%s, dataCenterId=%s, podId=%s, clusterId=%s, subject=%s].", alertType, dataCenterId, podId, clusterId, subject));
+ if (!PpurioAlertConfigKeys.ALERT_KAKAO_ENABLED.value()) {
+ //logger.info(String.format("Skipped Ppurio Kakao alert delivery because kakao.ppurio.enabled is false [alertType=%s, dataCenterId=%s, podId=%s, clusterId=%s, subject=%s].", alertType, dataCenterId, podId, clusterId, subject));
+ return;
+ }
+
+ List recipients = parseRecipients(PpurioAlertConfigKeys.RECIPIENTS.value());
+ if (recipients.isEmpty()) {
+ logger.warn("Ppurio Alert integration is enabled but no recipients are configured in kakao.ppurio.recipients");
+ return;
+ }
+
+ String baseUrl = PpurioAlertConfigKeys.BASE_URL.value();
+ String account = PpurioAlertConfigKeys.ACCOUNT.value();
+ String apiKey = PpurioAlertConfigKeys.API_KEY.value();
+ String senderProfile = PpurioAlertConfigKeys.SENDER_PROFILE.value();
+ String templateCode = PpurioAlertConfigKeys.TEMPLATE_CODE.value();
+ String duplicateFlag = PpurioAlertConfigKeys.DUPLICATE_FLAG.value();
+
+ if (StringUtils.isAnyBlank(baseUrl, account, apiKey, senderProfile, templateCode)) {
+ logger.warn("Ppurio Alert integration is enabled but one or more required settings are blank");
+ return;
+ }
+
+ List