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> targets = buildTargets(recipients, buildChangeWord(alertType, subject, body)); + Map resend = buildResendIfEnabled(alertType, subject, body); + try { + logPreparedMessage(baseUrl, account, senderProfile, templateCode, recipients.size()); + sendMessage(baseUrl, account, apiKey, senderProfile, templateCode, duplicateFlag, targets, resend); + } catch (IOException e) { + logger.warn(String.format("Failed to send Ppurio Kakao alert for subject [%s]", subject), e); + } + } + + protected void sendMessage(String baseUrl, String account, String apiKey, String senderProfile, String templateCode, + String duplicateFlag, List> targets, Map resend) throws IOException { + String token = getToken(baseUrl, account, apiKey); + Map requestBody = buildRequestBody(account, senderProfile, templateCode, duplicateFlag, targets, resend); + String responseBody = postJson(buildEndpoint(baseUrl, KAKAO_PATH), "Bearer " + token, requestBody); + logger.info(String.format("Ppurio Kakao alert request accepted [refKey=%s, response=%s]", + requestBody.get("refKey"), responseBody)); + } + + protected String getToken(String baseUrl, String account, String apiKey) throws IOException { + String responseBody = postJson(buildEndpoint(baseUrl, TOKEN_PATH), buildAuthorizationHeader(account, apiKey), null); + String token = parseToken(responseBody); + if (StringUtils.isBlank(token)) { + throw new IOException(String.format("Ppurio token response does not contain token: [%s]", responseBody)); + } + return token; + } + + protected String parseToken(String responseBody) throws IOException { + try { + JsonElement jsonElement = new JsonParser().parse(responseBody); + if (jsonElement == null || !jsonElement.isJsonObject()) { + throw new IOException(String.format("Ppurio token response is not a JSON object: [%s]", responseBody)); + } + JsonObject jsonObject = jsonElement.getAsJsonObject(); + JsonElement tokenElement = jsonObject.get("token"); + if (tokenElement == null || tokenElement.isJsonNull()) { + return null; + } + return tokenElement.getAsString(); + } catch (RuntimeException e) { + throw new IOException(String.format("Failed to parse Ppurio token response: [%s]", responseBody), e); + } + } + + protected String postJson(String endpointUrl, String authorizationHeader, Object requestBody) throws IOException { + HttpURLConnection connection = null; + try { + URL url = new URL(endpointUrl); + connection = (HttpURLConnection) url.openConnection(); + configureHttpsConnection(connection); + connection.setConnectTimeout(PpurioAlertConfigKeys.CONNECT_TIMEOUT_MS.value()); + connection.setReadTimeout(PpurioAlertConfigKeys.READ_TIMEOUT_MS.value()); + connection.setRequestMethod("POST"); + connection.setDoOutput(true); + connection.setRequestProperty("Content-Type", "application/json; charset=UTF-8"); + connection.setRequestProperty("Authorization", authorizationHeader); + + byte[] payloadBytes = requestBody == null ? new byte[0] : gson.toJson(requestBody).getBytes(StandardCharsets.UTF_8); + + try (OutputStream outputStream = connection.getOutputStream()) { + outputStream.write(payloadBytes); + } + + int responseCode = connection.getResponseCode(); + if (responseCode < 200 || responseCode >= 300) { + String responseBody = readResponse(connection); + throw new IOException(String.format("Unexpected Ppurio response code [%s], response [%s]", responseCode, responseBody)); + } + return readResponse(connection); + } finally { + if (connection != null) { + connection.disconnect(); + } + } + } + + protected void configureHttpsConnection(HttpURLConnection connection) throws IOException { + if (connection instanceof HttpsURLConnection) { + ((HttpsURLConnection)connection).setSSLSocketFactory(getPpurioPinnedSslSocketFactory()); + } + } + + protected SSLSocketFactory getPpurioPinnedSslSocketFactory() throws IOException { + String certificatePath = getEffectivePinnedCertificatePath(getConfiguredPinnedCertificatePath()); + ensurePinnedCertificateFile(certificatePath); + String certificateSource = getPinnedCertificateSource(certificatePath); + if (ppurioPinnedSslSocketFactory == null || !StringUtils.equals(certificateSource, ppurioPinnedCertificateSource)) { + synchronized (sslSocketFactoryLock) { + if (ppurioPinnedSslSocketFactory == null || !StringUtils.equals(certificateSource, ppurioPinnedCertificateSource)) { + SSLSocketFactory sslSocketFactory = buildPpurioPinnedSslSocketFactory(); + ppurioPinnedSslSocketFactory = sslSocketFactory; + ppurioPinnedCertificateSource = certificateSource; + } + } + } + return ppurioPinnedSslSocketFactory; + } + + protected SSLSocketFactory buildPpurioPinnedSslSocketFactory() throws IOException { + try { + CertificateFactory certificateFactory = CertificateFactory.getInstance("X.509"); + Certificate pinnedIntermediate = loadPinnedIntermediateCertificate(certificateFactory); + KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType()); + keyStore.load(null, null); + keyStore.setCertificateEntry(PPURIO_PINNED_INTERMEDIATE_ALIAS, pinnedIntermediate); + + TrustManagerFactory trustManagerFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); + trustManagerFactory.init(keyStore); + + SSLContext sslContext = SSLContext.getInstance("TLS"); + sslContext.init(null, trustManagerFactory.getTrustManagers(), null); + return sslContext.getSocketFactory(); + } catch (GeneralSecurityException e) { + throw new IOException("Failed to initialize Ppurio pinned TLS trust store", e); + } + } + + protected Certificate loadPinnedIntermediateCertificate(CertificateFactory certificateFactory) throws IOException, GeneralSecurityException { + return loadPinnedIntermediateCertificate(certificateFactory, getEffectivePinnedCertificatePath(getConfiguredPinnedCertificatePath())); + } + + protected Certificate loadPinnedIntermediateCertificate(CertificateFactory certificateFactory, String certificatePath) throws IOException, GeneralSecurityException { + String effectiveCertificatePath = getEffectivePinnedCertificatePath(certificatePath); + ensurePinnedCertificateFile(effectiveCertificatePath); + try (InputStream inputStream = openPinnedIntermediateCertificateInputStream(effectiveCertificatePath)) { + return certificateFactory.generateCertificate(inputStream); + } + } + + protected InputStream openPinnedIntermediateCertificateInputStream(String certificatePath) throws IOException { + return Files.newInputStream(Paths.get(getEffectivePinnedCertificatePath(certificatePath))); + } + + protected String getPinnedCertificateSource(String certificatePath) throws IOException { + String effectiveCertificatePath = getEffectivePinnedCertificatePath(certificatePath); + return effectiveCertificatePath + "#" + Files.getLastModifiedTime(Paths.get(effectiveCertificatePath)).toMillis(); + } + + protected void ensurePinnedCertificateFile() throws IOException { + ensurePinnedCertificateFile(getEffectivePinnedCertificatePath(getConfiguredPinnedCertificatePath())); + } + + protected void ensurePinnedCertificateFile(String certificatePath) throws IOException { + String effectiveCertificatePath = getEffectivePinnedCertificatePath(certificatePath); + Path path = Paths.get(effectiveCertificatePath); + String configuredCertificatePem = getConfiguredPinnedCertificatePem(); + boolean hasConfiguredCertificatePem = StringUtils.isNotBlank(configuredCertificatePem); + if (Files.exists(path) && isPinnedCertificateFileUsable(path) && !hasConfiguredCertificatePem) { + return; + } + + String certificatePem = getPinnedCertificatePemForFile(); + if (hasConfiguredCertificatePem && Files.exists(path) && + StringUtils.equals(normalizePinnedCertificatePem(new String(Files.readAllBytes(path), StandardCharsets.US_ASCII)), certificatePem)) { + return; + } + validatePinnedCertificatePem(certificatePem); + + Path parent = path.getParent(); + if (parent != null) { + Files.createDirectories(parent); + } + Files.write(path, certificatePem.getBytes(StandardCharsets.US_ASCII)); + logger.info(String.format("Created or updated Ppurio pinned intermediate certificate file [%s]", effectiveCertificatePath)); + } + + protected boolean isPinnedCertificateFileUsable(Path path) throws IOException { + if (Files.size(path) == 0) { + logger.warn(String.format("Ppurio pinned intermediate certificate file [%s] is empty and will be recreated", path)); + return false; + } + + try (InputStream inputStream = Files.newInputStream(path)) { + CertificateFactory.getInstance("X.509").generateCertificate(inputStream); + return true; + } catch (GeneralSecurityException e) { + logger.warn(String.format("Ppurio pinned intermediate certificate file [%s] is not a valid X.509 certificate and will be recreated", path), e); + return false; + } + } + + protected String getEffectivePinnedCertificatePath(String certificatePath) { + if (StringUtils.isNotBlank(certificatePath)) { + return StringUtils.trim(certificatePath); + } + return PpurioAlertConfigKeys.DEFAULT_PINNED_CERTIFICATE_PATH; + } + + protected String getConfiguredPinnedCertificatePath() { + return PpurioAlertConfigKeys.PINNED_CERTIFICATE_PATH.value(); + } + + protected String getConfiguredPinnedCertificatePem() { + return PpurioAlertConfigKeys.PINNED_CERTIFICATE_PEM.value(); + } + + protected String getPinnedCertificatePemForFile() { + String configuredCertificatePem = getConfiguredPinnedCertificatePem(); + if (StringUtils.isNotBlank(configuredCertificatePem)) { + return normalizePinnedCertificatePem(configuredCertificatePem); + } + return getDefaultPinnedIntermediateCertificatePem(); + } + + protected String normalizePinnedCertificatePem(String certificatePem) { + return StringUtils.replace(StringUtils.trim(certificatePem), "\\n", "\n") + "\n"; + } + + protected void validatePinnedCertificatePem(String certificatePem) throws IOException { + try (InputStream inputStream = new ByteArrayInputStream(certificatePem.getBytes(StandardCharsets.US_ASCII))) { + CertificateFactory.getInstance("X.509").generateCertificate(inputStream); + } catch (GeneralSecurityException e) { + throw new IOException("Configured Ppurio pinned intermediate certificate PEM is not a valid X.509 certificate", e); + } + } + + protected String getDefaultPinnedIntermediateCertificatePem() { + return DEFAULT_PINNED_INTERMEDIATE_CERTIFICATE_PEM; + } + + protected Map buildRequestBody(String account, String senderProfile, String templateCode, + String duplicateFlag, List> targets, Map resend) { + Map root = new LinkedHashMap<>(); + root.put("account", account); + root.put("messageType", ALIMTALK_MESSAGE_TYPE); + root.put("senderProfile", senderProfile); + root.put("templateCode", templateCode); + root.put("duplicateFlag", StringUtils.defaultIfBlank(duplicateFlag, "Y")); + root.put("targetCount", targets.size()); + root.put("targets", targets); + root.put("isResend", resend == null ? RESEND_DISABLED_VALUE : RESEND_ENABLED_VALUE); + if (resend != null) { + root.put("resend", resend); + } + root.put("refKey", makeRefKey()); + return root; + } + + protected List> buildTargets(List recipients, Map changeWord) { + List> targets = new ArrayList<>(); + String targetName = PpurioAlertConfigKeys.TARGET_NAME.value(); + for (String recipient : recipients) { + Map target = new LinkedHashMap<>(); + target.put("to", recipient); + if (StringUtils.isNotBlank(targetName)) { + target.put("name", targetName); + } + target.put("changeWord", new LinkedHashMap<>(changeWord)); + targets.add(target); + } + return targets; + } + + protected Map buildChangeWord(AlertManager.AlertType alertType, String subject, String body) { + Map changeWord = new LinkedHashMap<>(); + changeWord.put("var1", renderChangeWordValue(PpurioAlertConfigKeys.CHANGE_WORD_VAR1.value(), alertType, subject, true)); + changeWord.put("var2", renderChangeWordValue(PpurioAlertConfigKeys.CHANGE_WORD_VAR2.value(), alertType, subject)); + return changeWord; + } + + protected String renderChangeWordValue(String template, AlertManager.AlertType alertType, String subject) { + return truncateUtf8Bytes(renderTemplate(template, alertType, subject), PPURIO_CHANGE_WORD_MAX_BYTES); + } + + protected String renderChangeWordValue(String template, AlertManager.AlertType alertType, String subject, boolean appendTruncationSuffix) { + String value = renderTemplate(template, alertType, subject); + if (appendTruncationSuffix) { + return truncateUtf8BytesAndCharsWithSuffix(value, PPURIO_CHANGE_WORD_MAX_BYTES, + PPURIO_CHANGE_WORD_MAX_CHARS, TRUNCATION_SUFFIX); + } + return truncateUtf8Bytes(value, PPURIO_CHANGE_WORD_MAX_BYTES); + } + + protected Map buildResend(AlertManager.AlertType alertType, String subject, String body) { + Map resend = new LinkedHashMap<>(); + resend.put("messageType", PpurioAlertConfigKeys.RESEND_MESSAGE_TYPE.value()); + resend.put("from", normalizePhoneNumber(getConfiguredResendFrom())); + resend.put("subject", renderResendSubject(alertType, subject)); + resend.put("content", renderResendContent(alertType, subject)); + return resend; + } + + protected Map buildResendIfEnabled(AlertManager.AlertType alertType, String subject, String body) { + if (!PpurioAlertConfigKeys.RESEND_ENABLED.value()) { + return null; + } + + String resendFrom = normalizePhoneNumber(getConfiguredResendFrom()); + if (!isValidPhoneNumber(resendFrom)) { + logger.warn("Ppurio Alert integration resend is enabled but kakao.ppurio.resend.from is not a valid phone number; sending Kakao alert without resend fallback"); + return null; + } + return buildResend(alertType, subject, body); + } + + protected String normalizePhoneNumber(String phoneNumber) { + return StringUtils.defaultString(phoneNumber).replaceAll("[^0-9]", ""); + } + + protected boolean isValidPhoneNumber(String phoneNumber) { + return StringUtils.isNotBlank(phoneNumber) && phoneNumber.matches("\\d{8,11}"); + } + + protected String getConfiguredResendFrom() { + return PpurioAlertConfigKeys.RESEND_FROM.value(); + } + + protected String getConfiguredResendSubjectTemplate() { + return PpurioAlertConfigKeys.RESEND_SUBJECT_TEMPLATE.value(); + } + + protected String renderResendSubject(AlertManager.AlertType alertType, String subject) { + return truncateUtf8Bytes(renderTemplate(getConfiguredResendSubjectTemplate(), alertType, subject), PPURIO_RESEND_SUBJECT_MAX_BYTES); + } + + protected String renderResendContent(AlertManager.AlertType alertType, String subject) { + return truncateUtf8Bytes(renderTemplate(PpurioAlertConfigKeys.RESEND_CONTENT_TEMPLATE.value(), alertType, subject), PPURIO_RESEND_CONTENT_MAX_BYTES); + } + + protected String renderTemplate(String template, AlertManager.AlertType alertType, String subject) { + return StringUtils.defaultString(template) + .replace("${alertType}", safeValue(alertType == null ? null : alertType.getName())) + .replace("${subject}", safeValue(subject)); + } + + protected String truncateUtf8Bytes(String value, int maxBytes) { + if (value == null || value.getBytes(StandardCharsets.UTF_8).length <= maxBytes) { + return value; + } + + StringBuilder builder = new StringBuilder(); + int bytes = 0; + for (int i = 0; i < value.length();) { + int codePoint = value.codePointAt(i); + String character = new String(Character.toChars(codePoint)); + int characterBytes = character.getBytes(StandardCharsets.UTF_8).length; + if (bytes + characterBytes > maxBytes) { + break; + } + builder.append(character); + bytes += characterBytes; + i += Character.charCount(codePoint); + } + return builder.toString(); + } + + protected String truncateUtf8BytesWithSuffix(String value, int maxBytes, String suffix) { + if (value == null || value.getBytes(StandardCharsets.UTF_8).length <= maxBytes) { + return value; + } + + String safeSuffix = StringUtils.defaultString(suffix); + int suffixBytes = safeSuffix.getBytes(StandardCharsets.UTF_8).length; + if (suffixBytes >= maxBytes) { + return truncateUtf8Bytes(safeSuffix, maxBytes); + } + + return truncateUtf8Bytes(value, maxBytes - suffixBytes) + safeSuffix; + } + + protected String truncateUtf8BytesAndCharsWithSuffix(String value, int maxBytes, int maxChars, + String suffix) { + if (value == null || (value.getBytes(StandardCharsets.UTF_8).length <= maxBytes && + getCodePointCount(value) <= maxChars)) { + return value; + } + + String safeSuffix = StringUtils.defaultString(suffix); + int suffixBytes = safeSuffix.getBytes(StandardCharsets.UTF_8).length; + int suffixChars = getCodePointCount(safeSuffix); + if (suffixBytes >= maxBytes || suffixChars >= maxChars) { + return truncateUtf8Bytes(safeSuffix, maxBytes); + } + + StringBuilder builder = new StringBuilder(); + int bytes = 0; + int chars = 0; + int maxValueBytes = maxBytes - suffixBytes; + int maxValueChars = maxChars - suffixChars; + for (int i = 0; i < value.length();) { + int codePoint = value.codePointAt(i); + String character = new String(Character.toChars(codePoint)); + int characterBytes = character.getBytes(StandardCharsets.UTF_8).length; + if (bytes + characterBytes > maxValueBytes || chars + 1 > maxValueChars) { + break; + } + builder.append(character); + bytes += characterBytes; + chars++; + i += Character.charCount(codePoint); + } + return builder.toString() + safeSuffix; + } + + protected int getCodePointCount(String value) { + String safeValue = StringUtils.defaultString(value); + return safeValue.codePointCount(0, safeValue.length()); + } + + protected String buildAuthorizationHeader(String account, String apiKey) { + String token = account + ":" + apiKey; + return "Basic " + Base64.getEncoder().encodeToString(token.getBytes(StandardCharsets.UTF_8)); + } + + protected List parseRecipients(String recipientsConfig) { + List recipients = new ArrayList<>(); + if (StringUtils.isBlank(recipientsConfig)) { + return recipients; + } + for (String recipient : recipientsConfig.split(",")) { + String trimmed = StringUtils.trim(recipient); + if (StringUtils.isNotBlank(trimmed)) { + recipients.add(trimmed); + } + } + return recipients; + } + + protected String readResponse(HttpURLConnection connection) throws IOException { + InputStream stream = connection.getErrorStream() != null ? connection.getErrorStream() : connection.getInputStream(); + if (stream == null) { + return ""; + } + try (InputStream inputStream = stream) { + return new String(inputStream.readAllBytes(), StandardCharsets.UTF_8); + } + } + + protected String safeValue(Object value) { + return value == null ? "-" : String.valueOf(value); + } + + protected String buildEndpoint(String baseUrl, String path) { + return StringUtils.removeEnd(StringUtils.trim(baseUrl), "/") + path; + } + + protected String makeRefKey() { + StringBuilder builder = new StringBuilder(REF_KEY_LENGTH); + for (int i = 0; i < REF_KEY_LENGTH; i++) { + builder.append(REF_KEY_CHARS[secureRandom.nextInt(REF_KEY_CHARS.length)]); + } + return builder.toString(); + } + + protected void logPreparedMessage(String baseUrl, String account, String senderProfile, String templateCode, int targetCount) { + logger.info(String.format( + "Prepared Ppurio Kakao alert request [baseUrl=%s, account=%s, senderProfile=%s, templateCode=%s, targetCount=%s]", + baseUrl, + maskValue(account), + maskValue(senderProfile), + templateCode, + targetCount)); + } + + protected String maskValue(String value) { + if (StringUtils.isBlank(value)) { + return "-"; + } + if (value.length() <= 4) { + return "****"; + } + return value.substring(0, 2) + "****" + value.substring(value.length() - 2); + } + + @Override + public String getConfigComponentName() { + return AlertDeliveryHelper.class.getSimpleName(); + } + + @Override + public ConfigKey[] getConfigKeys() { + return new ConfigKey[] { + PpurioAlertConfigKeys.ALERT_KAKAO_ENABLED, + PpurioAlertConfigKeys.BASE_URL, + PpurioAlertConfigKeys.PINNED_CERTIFICATE_PATH, + PpurioAlertConfigKeys.PINNED_CERTIFICATE_PEM, + PpurioAlertConfigKeys.ACCOUNT, + PpurioAlertConfigKeys.API_KEY, + PpurioAlertConfigKeys.SENDER_PROFILE, + PpurioAlertConfigKeys.TEMPLATE_CODE, + PpurioAlertConfigKeys.TARGET_NAME, + PpurioAlertConfigKeys.DUPLICATE_FLAG, + PpurioAlertConfigKeys.RECIPIENTS, + PpurioAlertConfigKeys.CHANGE_WORD_VAR1, + PpurioAlertConfigKeys.CHANGE_WORD_VAR2, + PpurioAlertConfigKeys.RESEND_ENABLED, + PpurioAlertConfigKeys.RESEND_MESSAGE_TYPE, + PpurioAlertConfigKeys.RESEND_FROM, + PpurioAlertConfigKeys.RESEND_SUBJECT_TEMPLATE, + PpurioAlertConfigKeys.RESEND_CONTENT_TEMPLATE, + PpurioAlertConfigKeys.CONNECT_TIMEOUT_MS, + PpurioAlertConfigKeys.READ_TIMEOUT_MS + }; + } +} diff --git a/plugins/integrations/ppurio-alerts/src/main/resources/META-INF/cloudstack/ppurio-alerts/module.properties b/plugins/integrations/ppurio-alerts/src/main/resources/META-INF/cloudstack/ppurio-alerts/module.properties new file mode 100644 index 000000000000..59571e437913 --- /dev/null +++ b/plugins/integrations/ppurio-alerts/src/main/resources/META-INF/cloudstack/ppurio-alerts/module.properties @@ -0,0 +1,18 @@ +# 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. +name=ppurio-alerts +parent=api diff --git a/plugins/integrations/ppurio-alerts/src/main/resources/META-INF/cloudstack/ppurio-alerts/spring-ppurio-alerts-context.xml b/plugins/integrations/ppurio-alerts/src/main/resources/META-INF/cloudstack/ppurio-alerts/spring-ppurio-alerts-context.xml new file mode 100644 index 000000000000..2871fccd376b --- /dev/null +++ b/plugins/integrations/ppurio-alerts/src/main/resources/META-INF/cloudstack/ppurio-alerts/spring-ppurio-alerts-context.xml @@ -0,0 +1,26 @@ + + + + + + diff --git a/plugins/integrations/ppurio-alerts/src/test/java/org/apache/cloudstack/alert/ppurio/PpurioAlertDeliveryHelperTest.java b/plugins/integrations/ppurio-alerts/src/test/java/org/apache/cloudstack/alert/ppurio/PpurioAlertDeliveryHelperTest.java new file mode 100644 index 000000000000..acd4d6e1230d --- /dev/null +++ b/plugins/integrations/ppurio-alerts/src/test/java/org/apache/cloudstack/alert/ppurio/PpurioAlertDeliveryHelperTest.java @@ -0,0 +1,277 @@ +// 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 static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.cert.CertificateFactory; +import java.util.Arrays; +import java.util.Base64; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.junit.Test; + +import com.cloud.alert.AlertManager; + +public class PpurioAlertDeliveryHelperTest { + private final PpurioAlertDeliveryHelper helper = new PpurioAlertDeliveryHelper(); + + @Test + public void parseRecipientsTrimsConfiguredNumbersAndSkipsBlanks() { + List recipients = helper.parseRecipients(" 01011112222, ,01033334444,, 01055556666 "); + + assertEquals(3, recipients.size()); + assertEquals("01011112222", recipients.get(0)); + assertEquals("01033334444", recipients.get(1)); + assertEquals("01055556666", recipients.get(2)); + } + + @Test + public void buildResendUsesDefaultTemplateAndSafeValues() { + Map resend = helper.buildResend(AlertManager.AlertType.ALERT_TYPE_CPU, "subject", null); + String message = resend.get("content"); + + assertEquals("SMS", resend.get("messageType")); + assertEquals("MOLD 경보", resend.get("subject")); + assertTrue(resend.get("subject").getBytes(StandardCharsets.UTF_8).length <= 30); + assertEquals("MOLD 경보: subject", message); + assertTrue(message.getBytes(StandardCharsets.UTF_8).length <= 90); + } + + @Test + public void buildResendTruncatesSubjectToPpurioByteLimit() { + PpurioAlertDeliveryHelper helper = helperWithResendSubjectTemplate("${subject}"); + StringBuilder longSubject = new StringBuilder(); + for (int i = 0; i < 20; i++) { + longSubject.append("가"); + } + + Map resend = helper.buildResend(AlertManager.AlertType.ALERT_TYPE_CPU, longSubject.toString(), null); + String subject = resend.get("subject"); + + assertTrue(subject.getBytes(StandardCharsets.UTF_8).length <= 30); + assertEquals(10, subject.length()); + } + + @Test + public void buildResendNormalizesFromPhoneNumber() { + PpurioAlertDeliveryHelper helper = helperWithResendFrom("010-1111-2222"); + + Map resend = helper.buildResend(AlertManager.AlertType.ALERT_TYPE_CPU, "subject", null); + + assertEquals("01011112222", resend.get("from")); + } + + @Test + public void buildResendIfEnabledSkipsInvalidFromPhoneNumber() { + PpurioAlertDeliveryHelper helper = helperWithResendFrom("123123"); + + assertNull(helper.buildResendIfEnabled(AlertManager.AlertType.ALERT_TYPE_CPU, "subject", null)); + } + + @Test + public void buildAuthorizationHeaderUsesBasicAccountAndAuthKeyToken() { + String header = helper.buildAuthorizationHeader("account", "apiKey"); + + String expectedToken = Base64.getEncoder().encodeToString("account:apiKey".getBytes(StandardCharsets.UTF_8)); + assertEquals("Basic " + expectedToken, header); + } + + @Test + public void getPpurioPinnedSslSocketFactoryLoadsPinnedIntermediateCertificate() throws Exception { + Path certificateFile = createTempCertificatePath(); + PpurioAlertDeliveryHelper helper = helperWithPinnedCertificatePath(certificateFile.toString()); + + assertNotNull(helper.getPpurioPinnedSslSocketFactory()); + assertTrue(Files.exists(certificateFile)); + } + + @Test + public void ensurePinnedCertificateFileCreatesDefaultPemFile() throws Exception { + Path certificateFile = createTempCertificatePath(); + PpurioAlertDeliveryHelper helper = helperWithPinnedCertificatePath(certificateFile.toString()); + + helper.ensurePinnedCertificateFile(); + + assertTrue(Files.exists(certificateFile)); + assertTrue(new String(Files.readAllBytes(certificateFile), StandardCharsets.US_ASCII).contains("BEGIN CERTIFICATE")); + assertNotNull(helper.loadPinnedIntermediateCertificate(CertificateFactory.getInstance("X.509"), certificateFile.toString())); + } + + @Test + public void ensurePinnedCertificateFileRecreatesEmptyPemFile() throws Exception { + Path certificateFile = createTempCertificatePath(); + Files.createFile(certificateFile); + PpurioAlertDeliveryHelper helper = helperWithPinnedCertificatePath(certificateFile.toString()); + + helper.ensurePinnedCertificateFile(); + + assertTrue(Files.size(certificateFile) > 0); + assertNotNull(helper.loadPinnedIntermediateCertificate(CertificateFactory.getInstance("X.509"), certificateFile.toString())); + } + + @Test + public void ensurePinnedCertificateFileUsesConfiguredPemWithEscapedNewlines() throws Exception { + Path certificateFile = createTempCertificatePath(); + Files.write(certificateFile, "not a certificate".getBytes(StandardCharsets.US_ASCII)); + String configuredPem = helper.getDefaultPinnedIntermediateCertificatePem().replace("\n", "\\n"); + PpurioAlertDeliveryHelper configuredHelper = helperWithPinnedCertificatePathAndPem(certificateFile.toString(), configuredPem); + + configuredHelper.ensurePinnedCertificateFile(); + + assertNotNull(configuredHelper.loadPinnedIntermediateCertificate(CertificateFactory.getInstance("X.509"), certificateFile.toString())); + } + + @Test + public void parseTokenReturnsTokenFromPpurioTokenResponse() throws Exception { + String responseBody = "{\"token\":\"token-value\",\"type\":\"Bearer\",\"expired\":\"20260508150548\"}"; + + assertEquals("token-value", helper.parseToken(responseBody)); + } + + @Test(expected = IOException.class) + public void parseTokenThrowsIOExceptionForMalformedResponse() throws Exception { + helper.parseToken(""); + } + + @SuppressWarnings("unchecked") + @Test + public void buildTargetsIncludeConfiguredKakaoChangeWords() { + Map changeWord = helper.buildChangeWord(AlertManager.AlertType.ALERT_TYPE_CPU, "subject", "body"); + List> targets = helper.buildTargets(Arrays.asList("01033334444"), changeWord); + + assertEquals("subject", changeWord.get("var1")); + assertEquals("ALERT.CPU", changeWord.get("var2")); + assertFalse(changeWord.containsKey("var3")); + + assertEquals(1, targets.size()); + assertEquals("01033334444", targets.get(0).get("to")); + assertEquals(PpurioAlertConfigKeys.TARGET_NAME.value(), targets.get(0).get("name")); + Map targetChangeWord = (Map)targets.get(0).get("changeWord"); + assertEquals("subject", targetChangeWord.get("var1")); + assertEquals("ALERT.CPU", targetChangeWord.get("var2")); + assertFalse(targetChangeWord.containsKey("var3")); + } + + @Test + public void buildChangeWordTruncatesVar1ToPpurioByteLimitWithSuffix() { + StringBuilder longSubject = new StringBuilder(); + for (int i = 0; i < 40; i++) { + longSubject.append("가"); + } + + Map changeWord = helper.buildChangeWord(AlertManager.AlertType.ALERT_TYPE_CPU, longSubject.toString(), "body"); + String var1 = changeWord.get("var1"); + + assertTrue(var1.getBytes(StandardCharsets.UTF_8).length <= 100); + assertTrue(var1.endsWith("...")); + assertEquals(35, var1.length()); + } + + @Test + public void buildChangeWordTruncatesVar1ToPpurioCharacterLimitWithSuffix() { + StringBuilder longSubject = new StringBuilder(); + for (int i = 0; i < 60; i++) { + longSubject.append("1"); + } + + Map changeWord = helper.buildChangeWord(AlertManager.AlertType.ALERT_TYPE_CPU, longSubject.toString(), "body"); + String var1 = changeWord.get("var1"); + + assertTrue(var1.getBytes(StandardCharsets.UTF_8).length <= 100); + assertTrue(var1.endsWith("...")); + assertEquals(50, var1.length()); + } + + @Test + public void buildRequestBodyIncludesPpurioAlimtalkFieldsAndResend() { + Map changeWord = helper.buildChangeWord(AlertManager.AlertType.ALERT_TYPE_CPU, "subject", "body"); + List> targets = helper.buildTargets(Arrays.asList("01033334444"), changeWord); + Map resend = new LinkedHashMap<>(); + resend.put("messageType", "SMS"); + resend.put("from", "01011112222"); + resend.put("subject", "sms subject"); + resend.put("content", "sms content"); + + Map request = helper.buildRequestBody("account", "@senderProfile", "templateCode", "Y", targets, resend); + + assertEquals("account", request.get("account")); + assertEquals("ALT", request.get("messageType")); + assertEquals("@senderProfile", request.get("senderProfile")); + assertEquals("templateCode", request.get("templateCode")); + assertEquals("Y", request.get("duplicateFlag")); + assertEquals(1, request.get("targetCount")); + assertEquals(targets, request.get("targets")); + assertEquals("Y", request.get("isResend")); + assertEquals(resend, request.get("resend")); + assertNotNull(request.get("refKey")); + assertEquals(32, String.valueOf(request.get("refKey")).length()); + } + + private Path createTempCertificatePath() throws IOException { + Path certificateFile = Files.createTempFile("ppurio-pinned-certificate", ".pem"); + certificateFile.toFile().deleteOnExit(); + Files.deleteIfExists(certificateFile); + return certificateFile; + } + + private PpurioAlertDeliveryHelper helperWithPinnedCertificatePath(final String certificatePath) { + return helperWithPinnedCertificatePathAndPem(certificatePath, null); + } + + private PpurioAlertDeliveryHelper helperWithResendFrom(final String resendFrom) { + return new PpurioAlertDeliveryHelper() { + @Override + protected String getConfiguredResendFrom() { + return resendFrom; + } + }; + } + + private PpurioAlertDeliveryHelper helperWithResendSubjectTemplate(final String resendSubjectTemplate) { + return new PpurioAlertDeliveryHelper() { + @Override + protected String getConfiguredResendSubjectTemplate() { + return resendSubjectTemplate; + } + }; + } + + private PpurioAlertDeliveryHelper helperWithPinnedCertificatePathAndPem(final String certificatePath, final String certificatePem) { + return new PpurioAlertDeliveryHelper() { + @Override + protected String getConfiguredPinnedCertificatePath() { + return certificatePath; + } + + @Override + protected String getConfiguredPinnedCertificatePem() { + return certificatePem; + } + }; + } +} diff --git a/plugins/integrations/wall-alerts/src/main/java/org/apache/cloudstack/wallAlerts/config/WallConfigKeys.java b/plugins/integrations/wall-alerts/src/main/java/org/apache/cloudstack/wallAlerts/config/WallConfigKeys.java index 36248766abb8..776e2a75c45d 100644 --- a/plugins/integrations/wall-alerts/src/main/java/org/apache/cloudstack/wallAlerts/config/WallConfigKeys.java +++ b/plugins/integrations/wall-alerts/src/main/java/org/apache/cloudstack/wallAlerts/config/WallConfigKeys.java @@ -24,4 +24,12 @@ private WallConfigKeys() {} public static final ConfigKey READ_TIMEOUT_MS = new ConfigKey<>("Advanced", Integer.class, "wall.read.timeout.ms", "10000", "HTTP read timeout in milliseconds.", true, WALL_ALERT_ENABLED.key()); + + public static final ConfigKey BACKGROUND_POLL_INTERVAL_SECONDS = + new ConfigKey<>("Advanced", Integer.class, "wall.alerts.background.poll.interval.seconds", "0", + "Interval in seconds for background Wall alert evaluation. Set to 0 or less to disable background evaluation.", false, WALL_ALERT_ENABLED.key()); + + public static final ConfigKey ALERT_THROTTLE_SECONDS = + new ConfigKey<>("Advanced", Integer.class, "wall.alerts.throttle.seconds", "14400", + "Minimum seconds between duplicate Wall alert deliveries for the same rule UID.", true, WALL_ALERT_ENABLED.key()); } diff --git a/plugins/integrations/wall-alerts/src/main/java/org/apache/cloudstack/wallAlerts/service/WallAlertsServiceImpl.java b/plugins/integrations/wall-alerts/src/main/java/org/apache/cloudstack/wallAlerts/service/WallAlertsServiceImpl.java index 4503d96aac5f..bf03018a8efa 100644 --- a/plugins/integrations/wall-alerts/src/main/java/org/apache/cloudstack/wallAlerts/service/WallAlertsServiceImpl.java +++ b/plugins/integrations/wall-alerts/src/main/java/org/apache/cloudstack/wallAlerts/service/WallAlertsServiceImpl.java @@ -1,6 +1,7 @@ package org.apache.cloudstack.wallAlerts.service; import com.cloud.alert.AlertManager; +import com.cloud.utils.concurrency.NamedThreadFactory; import com.cloud.utils.component.ManagerBase; import com.fasterxml.jackson.databind.JsonNode; @@ -43,7 +44,10 @@ import java.util.Map; import java.util.Iterator; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executors; import java.util.concurrent.Semaphore; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; import java.util.regex.Pattern; import java.util.regex.PatternSyntaxException; import java.util.regex.Matcher; @@ -65,20 +69,25 @@ public class WallAlertsServiceImpl extends ManagerBase implements WallAlertsServ private AlertManager alertMgr; // UID별 중복 전송 방지 캐시 private final Map recentAlertSentAtMs = new ConcurrentHashMap<>(); - // 중복 억제 TTL (예: 5분) - private static final long DEFAULT_WALL_ALERT_THROTTLE_MS = 300_000L; @Inject private WallApiClient wallApiClient; + private ScheduledExecutorService wallAlertPollExecutor; private static final ZoneId KST = ZoneId.of("Asia/Seoul"); private static final DateTimeFormatter KST_YMD_HM = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"); @Override - public boolean start() { return true; } + public boolean start() { + startWallAlertBackgroundPoller(); + return true; + } @Override - public boolean stop() { return true; } + public boolean stop() { + stopWallAlertBackgroundPoller(); + return true; + } @Override public String getName() { return "WallAlertsService"; } @@ -97,6 +106,54 @@ public class WallAlertsServiceImpl extends ManagerBase implements WallAlertsServ private static final Semaphore DS_QUERY_SEM = new Semaphore(3); private static final ObjectMapper JSON = new ObjectMapper(); + private void startWallAlertBackgroundPoller() { + final int intervalSeconds = Math.max(0, WallConfigKeys.BACKGROUND_POLL_INTERVAL_SECONDS.value()); + if (intervalSeconds <= 0) { + LOG.info("Wall Alerts background evaluation is disabled by wall.alerts.background.poll.interval.seconds"); + return; + } + if (wallAlertPollExecutor != null && !wallAlertPollExecutor.isShutdown()) { + return; + } + + wallAlertPollExecutor = Executors.newScheduledThreadPool(1, new NamedThreadFactory("Wall-Alerts-Poller")); + final int initialDelaySeconds = Math.min(10, intervalSeconds); + wallAlertPollExecutor.scheduleWithFixedDelay(new Runnable() { + @Override + public void run() { + evaluateWallAlertsInBackground(); + } + }, initialDelaySeconds, intervalSeconds, TimeUnit.SECONDS); + LOG.info(String.format("Started Wall Alerts background evaluation every %s seconds", intervalSeconds)); + } + + private void stopWallAlertBackgroundPoller() { + if (wallAlertPollExecutor != null) { + wallAlertPollExecutor.shutdownNow(); + wallAlertPollExecutor = null; + LOG.info("Stopped Wall Alerts background evaluation"); + } + } + + private void evaluateWallAlertsInBackground() { + if (!WallConfigKeys.WALL_ALERT_ENABLED.value()) { + return; + } + try { + invalidateRulesCache(); + listWallAlertRules(new ListWallAlertRulesCmd()); + } catch (ServerApiException e) { + LOG.warn("[WallAlerts] background evaluation skipped: " + e.getDescription()); + } catch (Throwable t) { + LOG.warn("[WallAlerts] background evaluation failed: " + t.getMessage(), t); + } + } + + private long getWallAlertThrottleMs() { + final int throttleSeconds = Math.max(0, WallConfigKeys.ALERT_THROTTLE_SECONDS.value()); + return TimeUnit.SECONDS.toMillis(throttleSeconds); + } + @Override public ListResponse listWallAlertRules(final ListWallAlertRulesCmd cmd) { @@ -1024,7 +1081,8 @@ private void maybeSendWallAlert(final String uid, final String targetInfo) { try { final Long last = recentAlertSentAtMs.get(uid); - if (last != null && now - last < DEFAULT_WALL_ALERT_THROTTLE_MS) { + final long throttleMs = getWallAlertThrottleMs(); + if (last != null && throttleMs > 0L && now - last < throttleMs) { // TTL 내 중복 전송 방지 return; } @@ -1806,7 +1864,9 @@ public ConfigKey[] getConfigKeys() { WallConfigKeys.WALL_BASE_URL, WallConfigKeys.WALL_API_TOKEN, WallConfigKeys.CONNECT_TIMEOUT_MS, - WallConfigKeys.READ_TIMEOUT_MS + WallConfigKeys.READ_TIMEOUT_MS, + WallConfigKeys.BACKGROUND_POLL_INTERVAL_SECONDS, + WallConfigKeys.ALERT_THROTTLE_SECONDS }; } diff --git a/plugins/pom.xml b/plugins/pom.xml index ccb611b4b4df..a839f428c685 100755 --- a/plugins/pom.xml +++ b/plugins/pom.xml @@ -96,6 +96,7 @@ hypervisors/xenserver integrations/cloudian + integrations/ppurio-alerts integrations/prometheus integrations/wall-alerts integrations/kubernetes-service diff --git a/server/src/main/java/com/cloud/alert/AlertManagerImpl.java b/server/src/main/java/com/cloud/alert/AlertManagerImpl.java index 834a40e1f288..64073823c896 100644 --- a/server/src/main/java/com/cloud/alert/AlertManagerImpl.java +++ b/server/src/main/java/com/cloud/alert/AlertManagerImpl.java @@ -52,6 +52,7 @@ import org.apache.commons.lang3.math.NumberUtils; import org.apache.logging.log4j.Logger; import org.apache.logging.log4j.LogManager; +import org.springframework.beans.factory.NoSuchBeanDefinitionException; import com.cloud.alert.dao.AlertDao; import com.cloud.api.ApiDBUtils; @@ -85,6 +86,7 @@ import com.cloud.service.dao.ServiceOfferingDao; import com.cloud.storage.StorageManager; import com.cloud.utils.Pair; +import com.cloud.utils.component.ComponentContext; import com.cloud.utils.component.ManagerBase; import com.cloud.utils.concurrency.NamedThreadFactory; import com.cloud.utils.db.SearchCriteria; @@ -758,7 +760,7 @@ public void sendPersistentAlert(final AlertType alertType, sendPersistentAlert(alertType, zone, pod, cluster, subject, body); } - // ---------- 아래는 private 헬퍼(전용 Persist Only) ---------- + // ---------- 아래는 private 헬퍼(전용 Persistent Alert 처리) ---------- private void sendPersistentAlert(final AlertType alertType, final com.cloud.dc.DataCenter dataCenter, final com.cloud.dc.Pod pod, @@ -771,9 +773,7 @@ private void sendPersistentAlert(final AlertType alertType, final Long podId = (pod == null ? null : pod.getId()); final Long clusterId = (cluster == null ? null : cluster.getId()); - // 이벤트 버스 전파 - com.cloud.event.AlertGenerator.publishAlertOnEventBus( - alertType.getName(), zoneId, podId, subject, content); + publishAlertOnEventBus(alertType, zoneId, podId, subject, content); // 마지막 알림 조회(항상 zoneId를 사용해 NPE 방지) com.cloud.alert.AlertVO last = @@ -790,6 +790,7 @@ private void sendPersistentAlert(final AlertType alertType, if (logger.isDebugEnabled()) { logger.debug("Duplicate persist suppressed for subject='" + subject + "', only bumped lastSent/sentCount."); } + sendAlertDeliveries(alertType, zoneId, podId, clusterId, subject, content); return; } @@ -806,12 +807,20 @@ private void sendPersistentAlert(final AlertType alertType, newAlert.setName(alertType.getName()); _alertDao.persist(newAlert); - // persist-only: 이메일 송신 없음 + sendAlertDeliveries(alertType, zoneId, podId, clusterId, subject, content); } catch (Throwable t) { logger.warn("sendPersistentAlert failed: " + String.valueOf(t.getMessage()), t); } } + protected void publishAlertOnEventBus(AlertType alertType, long dataCenterId, Long podId, String subject, String content) { + try { + AlertGenerator.publishAlertOnEventBus(alertType.getName(), dataCenterId, podId, subject, content); + } catch (Exception e) { + logger.warn(String.format("Failed to publish alert on event bus for type [%s] and subject [%s]", alertType, subject), e); + } + } + private List getCapacityTypesAtZoneLevel() { List dataCenterCapacityTypes = new ArrayList(); @@ -896,9 +905,21 @@ public void sendAlert(AlertType alertType, DataCenter dataCenter, Pod pod, Clust _alertDao.persist(newAlert); } else { logger.debug("Have already sent: " + alert.getSentCount() + " emails for alert type '" + alertType + "' -- skipping send email"); + logger.info(String.format("Skipping external alert delivery due to duplicate unresolved alert [alertType=%s, subject=%s, alertId=%s, sentCount=%s].", + alertType, subject, alert.getId(), alert.getSentCount())); return; } + sendAlertDeliveries(alertType, dcId, podId, clusterId, subject, content); + } + + protected void sendAlertDeliveries(AlertType alertType, long dataCenterId, Long podId, Long clusterId, String subject, String content) { + _executor.execute(() -> sendExternalAlertDeliveries(alertType, dataCenterId, podId, clusterId, subject, content)); + + if (mailSender == null) { + logger.warn(String.format("No mail sender configured, skipping sending alert with subject [%s] and content [%s].", subject, content)); + return; + } if (ArrayUtils.isEmpty(recipients)) { logger.warn(String.format("No recipients set in global setting 'alert.email.addresses', " + "skipping sending alert with subject [%s] and content [%s].", subject, content)); @@ -909,7 +930,7 @@ public void sendAlert(AlertType alertType, DataCenter dataCenter, Pod pod, Clust mailProps.setSender(new MailAddress(senderAddress)); mailProps.setSubject(subject); mailProps.setContent(content); - mailProps.setContentType("text/plain"); + mailProps.setContentType("text/plain; charset=UTF-8"); Set addresses = new HashSet<>(); for (String recipient : recipients) { @@ -923,12 +944,19 @@ public void sendAlert(AlertType alertType, DataCenter dataCenter, Pod pod, Clust } protected void sendMessage(SMTPMailProperties mailProps) { - _executor.execute(new Runnable() { - @Override - public void run() { - mailSender.sendMail(mailProps); - } - }); + _executor.execute(() -> mailSender.sendMail(mailProps)); + } + + protected void sendExternalAlertDeliveries(AlertType alertType, long dataCenterId, Long podId, Long clusterId, String subject, String content) { + try { + // logger.debug(String.format("Dispatching external alert delivery [alertType=%s, dataCenterId=%s, podId=%s, clusterId=%s, subject=%s].", alertType, dataCenterId, podId, clusterId, subject)); + AlertDeliveryHelper alertDeliveryHelper = ComponentContext.getDelegateComponentOfType(AlertDeliveryHelper.class); + alertDeliveryHelper.sendAlert(alertType, dataCenterId, podId, clusterId, subject, content); + } catch (NoSuchBeanDefinitionException ignored) { + logger.debug("No AlertDeliveryHelper bean found"); + } catch (Exception e) { + logger.warn(String.format("Failed to deliver alert via external helper for type [%s] and subject [%s]", alertType, subject), e); + } } private static String formatPercent(double percentage) { diff --git a/server/src/test/java/com/cloud/alert/AlertManagerImplTest.java b/server/src/test/java/com/cloud/alert/AlertManagerImplTest.java index e04c5e181e74..a34e068deddf 100644 --- a/server/src/test/java/com/cloud/alert/AlertManagerImplTest.java +++ b/server/src/test/java/com/cloud/alert/AlertManagerImplTest.java @@ -83,6 +83,17 @@ private void sendMessage (){ } } + private void prepareZoneAndPod(long zoneId, Long podId) { + DataCenterVO zone = Mockito.mock(DataCenterVO.class); + Mockito.when(zone.getId()).thenReturn(zoneId); + Mockito.when(_dcDao.findById(zoneId)).thenReturn(zone); + if (podId != null) { + HostPodVO pod = Mockito.mock(HostPodVO.class); + Mockito.when(pod.getId()).thenReturn(podId); + Mockito.when(_podDao.findById(podId)).thenReturn(pod); + } + } + @Test public void sendAlertTestSendMail() { Mockito.doReturn(null).when(alertDaoMock).getLastAlert(Mockito.anyShort(), Mockito.anyLong(), @@ -119,4 +130,63 @@ public void sendAlertTestWarnLogging() { Mockito.verify(alertManagerImplMock.logger, Mockito.times(2)).warn(Mockito.anyString()); Mockito.verify(alertManagerImplMock, Mockito.never()).sendMessage(Mockito.any()); } + + @Test + public void sendPersistentAlertSendsDeliveryForNewWallAlert() { + prepareZoneAndPod(1L, 2L); + Mockito.doReturn(null).when(alertDaoMock).getLastAlert(Mockito.anyShort(), Mockito.anyLong(), + Mockito.anyLong(), Mockito.isNull()); + Mockito.doReturn(null).when(alertDaoMock).persist(Mockito.any()); + Mockito.doNothing().when(alertManagerImplMock).sendAlertDeliveries( + Mockito.eq(AlertManager.AlertType.ALERT_TYPE_WALL_RULE), + Mockito.eq(1L), + Mockito.eq(2L), + Mockito.isNull(), + Mockito.eq("subject"), + Mockito.eq("body")); + + alertManagerImplMock.sendPersistentAlert(AlertManager.AlertType.ALERT_TYPE_WALL_RULE, 1L, 2L, "subject", "body"); + + Mockito.verify(alertDaoMock).persist(Mockito.any(AlertVO.class)); + Mockito.verify(alertManagerImplMock).sendAlertDeliveries( + Mockito.eq(AlertManager.AlertType.ALERT_TYPE_WALL_RULE), + Mockito.eq(1L), + Mockito.eq(2L), + Mockito.isNull(), + Mockito.eq("subject"), + Mockito.eq("body")); + } + + @Test + public void sendPersistentAlertSendsDeliveryForDuplicateWallAlert() { + prepareZoneAndPod(1L, 2L); + AlertVO update = new AlertVO(); + Mockito.doReturn(alertVOMock).when(alertDaoMock).getLastAlert(Mockito.anyShort(), Mockito.anyLong(), + Mockito.anyLong(), Mockito.isNull()); + Mockito.doReturn(null).when(alertVOMock).getResolved(); + Mockito.doReturn("subject").when(alertVOMock).getSubject(); + Mockito.doReturn(3).when(alertVOMock).getSentCount(); + Mockito.doReturn(10L).when(alertVOMock).getId(); + Mockito.doReturn(update).when(alertDaoMock).createForUpdate(); + Mockito.doReturn(true).when(alertDaoMock).update(Mockito.eq(10L), Mockito.same(update)); + Mockito.doNothing().when(alertManagerImplMock).sendAlertDeliveries( + Mockito.eq(AlertManager.AlertType.ALERT_TYPE_WALL_RULE), + Mockito.eq(1L), + Mockito.eq(2L), + Mockito.isNull(), + Mockito.eq("subject"), + Mockito.eq("body")); + + alertManagerImplMock.sendPersistentAlert(AlertManager.AlertType.ALERT_TYPE_WALL_RULE, 1L, 2L, "subject", "body"); + + Mockito.verify(alertDaoMock).update(Mockito.eq(10L), Mockito.same(update)); + Mockito.verify(alertDaoMock, Mockito.never()).persist(Mockito.any(AlertVO.class)); + Mockito.verify(alertManagerImplMock).sendAlertDeliveries( + Mockito.eq(AlertManager.AlertType.ALERT_TYPE_WALL_RULE), + Mockito.eq(1L), + Mockito.eq(2L), + Mockito.isNull(), + Mockito.eq("subject"), + Mockito.eq("body")); + } } diff --git a/utils/src/main/java/org/apache/cloudstack/utils/mailing/SMTPMailSender.java b/utils/src/main/java/org/apache/cloudstack/utils/mailing/SMTPMailSender.java index b354772fde08..7cd9d5686c22 100644 --- a/utils/src/main/java/org/apache/cloudstack/utils/mailing/SMTPMailSender.java +++ b/utils/src/main/java/org/apache/cloudstack/utils/mailing/SMTPMailSender.java @@ -20,6 +20,7 @@ import java.io.UnsupportedEncodingException; +import java.nio.charset.StandardCharsets; import java.util.Date; import java.util.Map; import java.util.Properties; @@ -196,7 +197,7 @@ protected SMTPMessage createMessage(SMTPMailProperties mailProps) throws Messagi setMailRecipients(message, mailProps.getRecipients(), mailProps.getSubject()); - message.setSubject(mailProps.getSubject()); + message.setSubject(mailProps.getSubject(), StandardCharsets.UTF_8.name()); message.setSentDate(mailProps.getSentDate() != null ? mailProps.getSentDate() : new Date()); message.setContent(mailProps.getContent(), mailProps.getContentType()); message.saveChanges();