From 029725fc351cbbe0ae707ff0be281b857b876e84 Mon Sep 17 00:00:00 2001 From: Tim Vernum Date: Fri, 23 Aug 2019 10:13:51 +1000 Subject: [PATCH] Add SSL/TLS settings for watcher email (#45836) This change adds a new SSL context xpack.notification.email.ssl.* that supports the standard SSL configuration settings (truststore, verification_mode, etc). This SSL context is used when configuring outbound SMTP properties for watcher email notifications. Backport of: #45272 --- .../settings/notification-settings.asciidoc | 13 +- .../xpack/core/ssl/SSLService.java | 2 + .../xpack/core/watcher/WatcherField.java | 2 + x-pack/plugin/watcher/build.gradle | 4 + .../elasticsearch/xpack/watcher/Watcher.java | 5 +- .../notification/NotificationService.java | 2 +- .../watcher/notification/email/Account.java | 14 +- .../notification/email/EmailService.java | 25 ++- .../actions/email/EmailMessageIdTests.java | 4 +- .../watcher/actions/email/EmailSslTests.java | 148 ++++++++++++++++++ .../NotificationServiceTests.java | 1 - .../notification/email/AccountTests.java | 14 +- .../notification/email/AccountsTests.java | 18 ++- .../notification/email/EmailServiceTests.java | 5 +- .../notification/email/ProfileTests.java | 6 +- .../email/support/EmailServer.java | 49 +++++- .../AbstractWatcherIntegrationTestCase.java | 5 +- .../xpack/watcher/actions/email/test-smtp.p12 | Bin 0 -> 3477 bytes 18 files changed, 279 insertions(+), 38 deletions(-) create mode 100644 x-pack/plugin/watcher/src/test/java/org/elasticsearch/xpack/watcher/actions/email/EmailSslTests.java create mode 100644 x-pack/plugin/watcher/src/test/resources/org/elasticsearch/xpack/watcher/actions/email/test-smtp.p12 diff --git a/docs/reference/settings/notification-settings.asciidoc b/docs/reference/settings/notification-settings.asciidoc index a2eb84bc211..97e51131606 100644 --- a/docs/reference/settings/notification-settings.asciidoc +++ b/docs/reference/settings/notification-settings.asciidoc @@ -76,7 +76,7 @@ corresponding endpoints are whitelisted as well. [[ssl-notification-settings]] :ssl-prefix: xpack.http -:component: {watcher} +:component: {watcher} HTTP :verifies: :server!: :ssl-context: watcher @@ -215,6 +215,15 @@ HTML feature groups>>. Set to `false` to completely disable HTML sanitation. Not recommended. Defaults to `true`. +[[ssl-notification-smtp-settings]] +:ssl-prefix: xpack.notification.email +:component: {watcher} Email +:verifies: +:server!: +:ssl-context: watcher-email + +include::ssl-settings.asciidoc[] + [float] [[slack-notification-settings]] ==== Slack Notification Settings @@ -334,4 +343,4 @@ The default event type. Valid values: `trigger`,`resolve`, `acknowledge`. `attach_payload`:: Whether or not to provide the watch payload as context for the event by default. Valid values: `true`, `false`. --- \ No newline at end of file +-- diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/ssl/SSLService.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/ssl/SSLService.java index 539205e251f..3a9e9892d08 100644 --- a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/ssl/SSLService.java +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/ssl/SSLService.java @@ -19,6 +19,7 @@ import org.elasticsearch.env.Environment; import org.elasticsearch.xpack.core.XPackSettings; import org.elasticsearch.xpack.core.common.socket.SocketAccess; import org.elasticsearch.xpack.core.ssl.cert.CertificateInfo; +import org.elasticsearch.xpack.core.watcher.WatcherField; import javax.net.ssl.HostnameVerifier; import javax.net.ssl.KeyManagerFactory; @@ -420,6 +421,7 @@ public class SSLService { sslSettingsMap.put("xpack.http.ssl", settings.getByPrefix("xpack.http.ssl.")); sslSettingsMap.putAll(getRealmsSSLSettings(settings)); sslSettingsMap.putAll(getMonitoringExporterSettings(settings)); + sslSettingsMap.put(WatcherField.EMAIL_NOTIFICATION_SSL_PREFIX, settings.getByPrefix(WatcherField.EMAIL_NOTIFICATION_SSL_PREFIX)); sslSettingsMap.forEach((key, sslSettings) -> loadConfiguration(key, sslSettings, sslContextHolders)); diff --git a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/watcher/WatcherField.java b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/watcher/WatcherField.java index b7ad6ee423d..4a8a7d39e0d 100644 --- a/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/watcher/WatcherField.java +++ b/x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/watcher/WatcherField.java @@ -15,5 +15,7 @@ public final class WatcherField { public static final Setting ENCRYPTION_KEY_SETTING = SecureSetting.secureFile("xpack.watcher.encryption_key", null); + public static final String EMAIL_NOTIFICATION_SSL_PREFIX = "xpack.notification.email.ssl."; + private WatcherField() {} } diff --git a/x-pack/plugin/watcher/build.gradle b/x-pack/plugin/watcher/build.gradle index bfd447adc26..3aaee650c85 100644 --- a/x-pack/plugin/watcher/build.gradle +++ b/x-pack/plugin/watcher/build.gradle @@ -67,6 +67,10 @@ thirdPartyAudit { ) } +forbiddenPatterns { + exclude '**/*.p12' +} + // pulled in as external dependency to work on java 9 rootProject.globalInfo.ready { if (project.runtimeJavaVersion <= JavaVersion.VERSION_1_8) { diff --git a/x-pack/plugin/watcher/src/main/java/org/elasticsearch/xpack/watcher/Watcher.java b/x-pack/plugin/watcher/src/main/java/org/elasticsearch/xpack/watcher/Watcher.java index ee4ebec0b0b..9cd1c811c92 100644 --- a/x-pack/plugin/watcher/src/main/java/org/elasticsearch/xpack/watcher/Watcher.java +++ b/x-pack/plugin/watcher/src/main/java/org/elasticsearch/xpack/watcher/Watcher.java @@ -269,11 +269,12 @@ public class Watcher extends Plugin implements ActionPlugin, ScriptPlugin, Reloa new WatcherIndexTemplateRegistry(environment.settings(), clusterService, threadPool, client, xContentRegistry); + final SSLService sslService = getSslService(); // http client - httpClient = new HttpClient(settings, getSslService(), cryptoService, clusterService); + httpClient = new HttpClient(settings, sslService, cryptoService, clusterService); // notification - EmailService emailService = new EmailService(settings, cryptoService, clusterService.getClusterSettings()); + EmailService emailService = new EmailService(settings, cryptoService, sslService, clusterService.getClusterSettings()); JiraService jiraService = new JiraService(settings, httpClient, clusterService.getClusterSettings()); SlackService slackService = new SlackService(settings, httpClient, clusterService.getClusterSettings()); PagerDutyService pagerDutyService = new PagerDutyService(settings, httpClient, clusterService.getClusterSettings()); diff --git a/x-pack/plugin/watcher/src/main/java/org/elasticsearch/xpack/watcher/notification/NotificationService.java b/x-pack/plugin/watcher/src/main/java/org/elasticsearch/xpack/watcher/notification/NotificationService.java index c6c041a6571..083390c98b9 100644 --- a/x-pack/plugin/watcher/src/main/java/org/elasticsearch/xpack/watcher/notification/NotificationService.java +++ b/x-pack/plugin/watcher/src/main/java/org/elasticsearch/xpack/watcher/notification/NotificationService.java @@ -95,7 +95,7 @@ public abstract class NotificationService { final Settings completeSettings = completeSettingsBuilder.build(); // obtain account names and create accounts final Set accountNames = getAccountNames(completeSettings); - this.accounts = createAccounts(completeSettings, accountNames, this::createAccount); + this.accounts = createAccounts(completeSettings, accountNames, (name, accountSettings) -> createAccount(name, accountSettings)); this.defaultAccount = findDefaultAccountOrNull(completeSettings, this.accounts); } diff --git a/x-pack/plugin/watcher/src/main/java/org/elasticsearch/xpack/watcher/notification/email/Account.java b/x-pack/plugin/watcher/src/main/java/org/elasticsearch/xpack/watcher/notification/email/Account.java index b6a6e259ecc..2079b2bbfb6 100644 --- a/x-pack/plugin/watcher/src/main/java/org/elasticsearch/xpack/watcher/notification/email/Account.java +++ b/x-pack/plugin/watcher/src/main/java/org/elasticsearch/xpack/watcher/notification/email/Account.java @@ -7,6 +7,7 @@ package org.elasticsearch.xpack.watcher.notification.email; import org.apache.logging.log4j.Logger; import org.elasticsearch.SpecialPermission; +import org.elasticsearch.common.Nullable; import org.elasticsearch.common.settings.SecureSetting; import org.elasticsearch.common.settings.SecureString; import org.elasticsearch.common.settings.Setting; @@ -22,6 +23,8 @@ import javax.mail.Session; import javax.mail.Transport; import javax.mail.internet.InternetAddress; import javax.mail.internet.MimeMessage; +import javax.net.SocketFactory; +import javax.net.ssl.SSLSocketFactory; import java.security.AccessController; import java.security.PrivilegedAction; import java.security.PrivilegedActionException; @@ -184,7 +187,7 @@ public class Account { final Smtp smtp; final EmailDefaults defaults; - Config(String name, Settings settings) { + Config(String name, Settings settings, @Nullable SSLSocketFactory sslSocketFactory) { this.name = name; profile = Profile.resolve(settings.get("profile"), Profile.STANDARD); defaults = new EmailDefaults(name, settings.getAsSettings("email_defaults")); @@ -193,6 +196,9 @@ public class Account { String msg = "missing required email account setting for account [" + name + "]. 'smtp.host' must be configured"; throw new SettingsException(msg); } + if (sslSocketFactory != null) { + smtp.setSocketFactory(sslSocketFactory); + } } public Session createSession() { @@ -220,7 +226,7 @@ public class Account { /** * Finds a setting, and then a secure setting if the setting is null, or returns null if one does not exist. This differs * from other getSetting calls in that it allows for null whereas the other methods throw an exception. - * + *

* Note: if your setting was not previously secure, than the string reference that is in the setting object is still * insecure. This is only constructing a new SecureString with the char[] of the insecure setting. */ @@ -274,6 +280,10 @@ public class Account { settings.put(newKey, TimeValue.parseTimeValue(value, currentKey).millis()); } } + + public void setSocketFactory(SocketFactory socketFactory) { + this.properties.put("mail.smtp.ssl.socketFactory", socketFactory); + } } /** diff --git a/x-pack/plugin/watcher/src/main/java/org/elasticsearch/xpack/watcher/notification/email/EmailService.java b/x-pack/plugin/watcher/src/main/java/org/elasticsearch/xpack/watcher/notification/email/EmailService.java index de7161dcdd1..5b9705b9f38 100644 --- a/x-pack/plugin/watcher/src/main/java/org/elasticsearch/xpack/watcher/notification/email/EmailService.java +++ b/x-pack/plugin/watcher/src/main/java/org/elasticsearch/xpack/watcher/notification/email/EmailService.java @@ -15,15 +15,20 @@ import org.elasticsearch.common.settings.Setting; import org.elasticsearch.common.settings.Setting.Property; import org.elasticsearch.common.settings.Settings; import org.elasticsearch.common.unit.TimeValue; +import org.elasticsearch.xpack.core.ssl.SSLConfiguration; +import org.elasticsearch.xpack.core.ssl.SSLConfigurationSettings; +import org.elasticsearch.xpack.core.ssl.SSLService; import org.elasticsearch.xpack.core.watcher.crypto.CryptoService; import org.elasticsearch.xpack.watcher.notification.NotificationService; import javax.mail.MessagingException; - +import javax.net.ssl.SSLSocketFactory; import java.util.ArrayList; import java.util.Arrays; import java.util.List; +import static org.elasticsearch.xpack.core.watcher.WatcherField.EMAIL_NOTIFICATION_SSL_PREFIX; + /** * A component to store email credentials and handle sending email notifications. */ @@ -101,13 +106,17 @@ public class EmailService extends NotificationService { Setting.affixKeySetting("xpack.notification.email.account.", "smtp.wait_on_quit", (key) -> Setting.boolSetting(key, true, Property.Dynamic, Property.NodeScope)); + private static final SSLConfigurationSettings SSL_SETTINGS = SSLConfigurationSettings.withPrefix(EMAIL_NOTIFICATION_SSL_PREFIX); + private static final Logger logger = LogManager.getLogger(EmailService.class); private final CryptoService cryptoService; + private final SSLService sslService; - public EmailService(Settings settings, @Nullable CryptoService cryptoService, ClusterSettings clusterSettings) { + public EmailService(Settings settings, @Nullable CryptoService cryptoService, SSLService sslService, ClusterSettings clusterSettings) { super("email", settings, clusterSettings, EmailService.getDynamicSettings(), EmailService.getSecureSettings()); this.cryptoService = cryptoService; + this.sslService = sslService; // ensure logging of setting changes clusterSettings.addSettingsUpdateConsumer(SETTING_DEFAULT_ACCOUNT, (s) -> {}); clusterSettings.addAffixUpdateConsumer(SETTING_PROFILE, (s, o) -> {}, (s, o) -> {}); @@ -132,10 +141,19 @@ public class EmailService extends NotificationService { @Override protected Account createAccount(String name, Settings accountSettings) { - Account.Config config = new Account.Config(name, accountSettings); + Account.Config config = new Account.Config(name, accountSettings, getSmtpSslSocketFactory()); return new Account(config, cryptoService, logger); } + @Nullable + private SSLSocketFactory getSmtpSslSocketFactory() { + final SSLConfiguration sslConfiguration = sslService.getSSLConfiguration(EMAIL_NOTIFICATION_SSL_PREFIX); + if (sslConfiguration == null) { + return null; + } + return sslService.sslSocketFactory(sslConfiguration); + } + public EmailSent send(Email email, Authentication auth, Profile profile, String accountName) throws MessagingException { Account account = getAccount(accountName); if (account == null) { @@ -189,6 +207,7 @@ public class EmailService extends NotificationService { public static List> getSettings() { List> allSettings = new ArrayList>(EmailService.getDynamicSettings()); allSettings.addAll(EmailService.getSecureSettings()); + allSettings.addAll(SSL_SETTINGS.getAllSettings()); return allSettings; } diff --git a/x-pack/plugin/watcher/src/test/java/org/elasticsearch/xpack/watcher/actions/email/EmailMessageIdTests.java b/x-pack/plugin/watcher/src/test/java/org/elasticsearch/xpack/watcher/actions/email/EmailMessageIdTests.java index 495ac99fb9e..a7d9862fd7a 100644 --- a/x-pack/plugin/watcher/src/test/java/org/elasticsearch/xpack/watcher/actions/email/EmailMessageIdTests.java +++ b/x-pack/plugin/watcher/src/test/java/org/elasticsearch/xpack/watcher/actions/email/EmailMessageIdTests.java @@ -10,6 +10,7 @@ import org.elasticsearch.common.settings.MockSecureSettings; import org.elasticsearch.common.settings.Setting; import org.elasticsearch.common.settings.Settings; import org.elasticsearch.test.ESTestCase; +import org.elasticsearch.xpack.core.ssl.SSLService; import org.elasticsearch.xpack.core.watcher.execution.WatchExecutionContext; import org.elasticsearch.xpack.core.watcher.watch.Payload; import org.elasticsearch.xpack.watcher.common.text.TextTemplateEngine; @@ -30,6 +31,7 @@ import java.util.List; import java.util.Set; import static org.hamcrest.Matchers.hasSize; +import static org.mockito.Mockito.mock; public class EmailMessageIdTests extends ESTestCase { @@ -56,7 +58,7 @@ public class EmailMessageIdTests extends ESTestCase { Set> registeredSettings = new HashSet<>(ClusterSettings.BUILT_IN_CLUSTER_SETTINGS); registeredSettings.addAll(EmailService.getSettings()); ClusterSettings clusterSettings = new ClusterSettings(settings, registeredSettings); - emailService = new EmailService(settings, null, clusterSettings); + emailService = new EmailService(settings, null, mock(SSLService.class), clusterSettings); EmailTemplate emailTemplate = EmailTemplate.builder().from("from@example.org").to("to@example.org") .subject("subject").textBody("body").build(); emailAction = new EmailAction(emailTemplate, null, null, null, null, null); diff --git a/x-pack/plugin/watcher/src/test/java/org/elasticsearch/xpack/watcher/actions/email/EmailSslTests.java b/x-pack/plugin/watcher/src/test/java/org/elasticsearch/xpack/watcher/actions/email/EmailSslTests.java new file mode 100644 index 00000000000..c4b0b657b9d --- /dev/null +++ b/x-pack/plugin/watcher/src/test/java/org/elasticsearch/xpack/watcher/actions/email/EmailSslTests.java @@ -0,0 +1,148 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +package org.elasticsearch.xpack.watcher.actions.email; + +import org.apache.http.ssl.SSLContextBuilder; +import org.elasticsearch.common.settings.ClusterSettings; +import org.elasticsearch.common.settings.MockSecureSettings; +import org.elasticsearch.common.settings.Setting; +import org.elasticsearch.common.settings.Settings; +import org.elasticsearch.env.TestEnvironment; +import org.elasticsearch.test.ESTestCase; +import org.elasticsearch.xpack.core.ssl.SSLService; +import org.elasticsearch.xpack.core.watcher.execution.WatchExecutionContext; +import org.elasticsearch.xpack.core.watcher.watch.Payload; +import org.elasticsearch.xpack.watcher.common.text.TextTemplateEngine; +import org.elasticsearch.xpack.watcher.notification.email.EmailService; +import org.elasticsearch.xpack.watcher.notification.email.EmailTemplate; +import org.elasticsearch.xpack.watcher.notification.email.HtmlSanitizer; +import org.elasticsearch.xpack.watcher.notification.email.support.EmailServer; +import org.elasticsearch.xpack.watcher.test.MockTextTemplateEngine; +import org.elasticsearch.xpack.watcher.test.WatcherTestUtils; +import org.hamcrest.Matchers; +import org.junit.After; +import org.junit.Before; + +import javax.mail.MessagingException; +import javax.mail.internet.MimeMessage; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLException; +import java.io.IOException; +import java.io.InputStream; +import java.security.GeneralSecurityException; +import java.security.KeyStore; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import static org.hamcrest.Matchers.hasSize; + +public class EmailSslTests extends ESTestCase { + + private EmailServer server; + private TextTemplateEngine textTemplateEngine = new MockTextTemplateEngine(); + private HtmlSanitizer htmlSanitizer = new HtmlSanitizer(Settings.EMPTY); + + @Before + public void startSmtpServer() throws GeneralSecurityException, IOException { + final KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType()); + final char[] keystorePassword = "test-smtp".toCharArray(); + try (InputStream is = getDataInputStream("test-smtp.p12")) { + keyStore.load(is, keystorePassword); + } + final SSLContext sslContext = new SSLContextBuilder().loadKeyMaterial(keyStore, keystorePassword).build(); + server = EmailServer.localhost(logger, sslContext); + } + + @After + public void stopSmtpServer() { + server.stop(); + } + + public void testFailureSendingMessageToSmtpServerWithUntrustedCertificateAuthority() throws Exception { + final Settings.Builder settings = Settings.builder(); + final MockSecureSettings secureSettings = new MockSecureSettings(); + final ExecutableEmailAction emailAction = buildEmailAction(settings, secureSettings); + final WatchExecutionContext ctx = WatcherTestUtils.createWatchExecutionContext(); + final MessagingException exception = expectThrows(MessagingException.class, + () -> emailAction.execute("my_action_id", ctx, Payload.EMPTY)); + final List allCauses = getAllCauses(exception); + assertThat(allCauses, Matchers.hasItem(Matchers.instanceOf(SSLException.class))); + } + + public void testCanSendMessageToSmtpServerUsingTrustStore() throws Exception { + List messages = new ArrayList<>(); + server.addListener(messages::add); + try { + final Settings.Builder settings = Settings.builder() + .put("xpack.notification.email.ssl.truststore.path", getDataPath("test-smtp.p12")); + final MockSecureSettings secureSettings = new MockSecureSettings(); + secureSettings.setString("xpack.notification.email.ssl.truststore.secure_password", "test-smtp"); + + ExecutableEmailAction emailAction = buildEmailAction(settings, secureSettings); + + WatchExecutionContext ctx = WatcherTestUtils.createWatchExecutionContext(); + emailAction.execute("my_action_id", ctx, Payload.EMPTY); + + assertThat(messages, hasSize(1)); + } finally { + server.clearListeners(); + } + } + + public void testCanSendMessageToSmtpServerByDisablingVerification() throws Exception { + List messages = new ArrayList<>(); + server.addListener(messages::add); + try { + final Settings.Builder settings = Settings.builder().put("xpack.notification.email.ssl.verification_mode", "none"); + final MockSecureSettings secureSettings = new MockSecureSettings(); + ExecutableEmailAction emailAction = buildEmailAction(settings, secureSettings); + + WatchExecutionContext ctx = WatcherTestUtils.createWatchExecutionContext(); + emailAction.execute("my_action_id", ctx, Payload.EMPTY); + + assertThat(messages, hasSize(1)); + } finally { + server.clearListeners(); + } + } + + private ExecutableEmailAction buildEmailAction(Settings.Builder baseSettings, MockSecureSettings secureSettings) { + secureSettings.setString("xpack.notification.email.account.test.smtp.secure_password", EmailServer.PASSWORD); + Settings settings = baseSettings + .put("path.home", createTempDir()) + .put("xpack.notification.email.account.test.smtp.auth", true) + .put("xpack.notification.email.account.test.smtp.user", EmailServer.USERNAME) + .put("xpack.notification.email.account.test.smtp.port", server.port()) + .put("xpack.notification.email.account.test.smtp.host", "localhost") + .setSecureSettings(secureSettings) + .build(); + + Set> registeredSettings = new HashSet<>(ClusterSettings.BUILT_IN_CLUSTER_SETTINGS); + registeredSettings.addAll(EmailService.getSettings()); + ClusterSettings clusterSettings = new ClusterSettings(settings, registeredSettings); + SSLService sslService = new SSLService(settings, TestEnvironment.newEnvironment(settings)); + final EmailService emailService = new EmailService(settings, null, sslService, clusterSettings); + EmailTemplate emailTemplate = EmailTemplate.builder().from("from@example.org").to("to@example.org") + .subject("subject").textBody("body").build(); + final EmailAction emailAction = new EmailAction(emailTemplate, null, null, null, null, null); + return new ExecutableEmailAction(emailAction, logger, emailService, textTemplateEngine, htmlSanitizer, Collections.emptyMap()); + } + + private List getAllCauses(Exception exception) { + final List allCauses = new ArrayList<>(); + Throwable cause = exception.getCause(); + while (cause != null) { + allCauses.add(cause); + cause = cause.getCause(); + } + return allCauses; + } + +} + diff --git a/x-pack/plugin/watcher/src/test/java/org/elasticsearch/xpack/watcher/notification/NotificationServiceTests.java b/x-pack/plugin/watcher/src/test/java/org/elasticsearch/xpack/watcher/notification/NotificationServiceTests.java index 0fa05e900e5..9790540f44d 100644 --- a/x-pack/plugin/watcher/src/test/java/org/elasticsearch/xpack/watcher/notification/NotificationServiceTests.java +++ b/x-pack/plugin/watcher/src/test/java/org/elasticsearch/xpack/watcher/notification/NotificationServiceTests.java @@ -13,7 +13,6 @@ import org.elasticsearch.common.settings.Setting; import org.elasticsearch.common.settings.Settings; import org.elasticsearch.common.settings.SettingsException; import org.elasticsearch.test.ESTestCase; -import org.elasticsearch.xpack.watcher.notification.NotificationService; import java.io.IOException; import java.io.InputStream; diff --git a/x-pack/plugin/watcher/src/test/java/org/elasticsearch/xpack/watcher/notification/email/AccountTests.java b/x-pack/plugin/watcher/src/test/java/org/elasticsearch/xpack/watcher/notification/email/AccountTests.java index 5e87a4305fe..38509feaca4 100644 --- a/x-pack/plugin/watcher/src/test/java/org/elasticsearch/xpack/watcher/notification/email/AccountTests.java +++ b/x-pack/plugin/watcher/src/test/java/org/elasticsearch/xpack/watcher/notification/email/AccountTests.java @@ -141,7 +141,7 @@ public class AccountTests extends ESTestCase { Settings settings = builder.build(); - Account.Config config = new Account.Config(accountName, settings); + Account.Config config = new Account.Config(accountName, settings, null); assertThat(config.profile, is(profile)); assertThat(config.defaults, equalTo(emailDefaults)); @@ -165,7 +165,7 @@ public class AccountTests extends ESTestCase { .put("smtp.port", server.port()) .put("smtp.user", EmailServer.USERNAME) .setSecureSettings(secureSettings) - .build()), null, logger); + .build(), null), null, logger); Email email = Email.builder() .id("_id") @@ -202,7 +202,7 @@ public class AccountTests extends ESTestCase { .put("smtp.port", server.port()) .put("smtp.user", EmailServer.USERNAME) .setSecureSettings(secureSettings) - .build()), null, logger); + .build(), null), null, logger); Email email = Email.builder() .id("_id") @@ -240,7 +240,7 @@ public class AccountTests extends ESTestCase { Account account = new Account(new Account.Config("default", Settings.builder() .put("smtp.host", "localhost") .put("smtp.port", server.port()) - .build()), null, logger); + .build(), null), null, logger); Email email = Email.builder() .id("_id") @@ -264,7 +264,7 @@ public class AccountTests extends ESTestCase { Account account = new Account(new Account.Config("default", Settings.builder() .put("smtp.host", "localhost") .put("smtp.port", server.port()) - .build()), null, logger); + .build(), null), null, logger); Properties mailProperties = account.getConfig().smtp.properties; assertThat(mailProperties.get("mail.smtp.connectiontimeout"), is(String.valueOf(TimeValue.timeValueMinutes(2).millis()))); @@ -279,7 +279,7 @@ public class AccountTests extends ESTestCase { .put("smtp.connection_timeout", TimeValue.timeValueMinutes(4)) .put("smtp.write_timeout", TimeValue.timeValueMinutes(6)) .put("smtp.timeout", TimeValue.timeValueMinutes(8)) - .build()), null, logger); + .build(), null), null, logger); Properties mailProperties = account.getConfig().smtp.properties; @@ -294,7 +294,7 @@ public class AccountTests extends ESTestCase { .put("smtp.host", "localhost") .put("smtp.port", server.port()) .put("smtp.connection_timeout", 4000) - .build()), null, logger); + .build(), null), null, logger); }); } diff --git a/x-pack/plugin/watcher/src/test/java/org/elasticsearch/xpack/watcher/notification/email/AccountsTests.java b/x-pack/plugin/watcher/src/test/java/org/elasticsearch/xpack/watcher/notification/email/AccountsTests.java index 7060dcab0eb..99e010faa4f 100644 --- a/x-pack/plugin/watcher/src/test/java/org/elasticsearch/xpack/watcher/notification/email/AccountsTests.java +++ b/x-pack/plugin/watcher/src/test/java/org/elasticsearch/xpack/watcher/notification/email/AccountsTests.java @@ -9,6 +9,7 @@ import org.elasticsearch.common.settings.ClusterSettings; import org.elasticsearch.common.settings.Settings; import org.elasticsearch.common.settings.SettingsException; import org.elasticsearch.test.ESTestCase; +import org.elasticsearch.xpack.core.ssl.SSLService; import java.util.HashSet; @@ -16,13 +17,14 @@ import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.is; import static org.hamcrest.Matchers.isOneOf; import static org.hamcrest.Matchers.notNullValue; +import static org.mockito.Mockito.mock; public class AccountsTests extends ESTestCase { public void testSingleAccount() throws Exception { Settings.Builder builder = Settings.builder() .put("default_account", "account1"); addAccountSettings("account1", builder); - EmailService service = new EmailService(builder.build(), null, + EmailService service = new EmailService(builder.build(), null, mock(SSLService.class), new ClusterSettings(Settings.EMPTY, new HashSet<>(EmailService.getSettings()))); Account account = service.getAccount("account1"); assertThat(account, notNullValue()); @@ -35,7 +37,7 @@ public class AccountsTests extends ESTestCase { public void testSingleAccountNoExplicitDefault() throws Exception { Settings.Builder builder = Settings.builder(); addAccountSettings("account1", builder); - EmailService service = new EmailService(builder.build(), null, + EmailService service = new EmailService(builder.build(), null, mock(SSLService.class), new ClusterSettings(Settings.EMPTY, new HashSet<>(EmailService.getSettings()))); Account account = service.getAccount("account1"); assertThat(account, notNullValue()); @@ -51,7 +53,7 @@ public class AccountsTests extends ESTestCase { addAccountSettings("account1", builder); addAccountSettings("account2", builder); - EmailService service = new EmailService(builder.build(), null, + EmailService service = new EmailService(builder.build(), null, mock(SSLService.class), new ClusterSettings(Settings.EMPTY, new HashSet<>(EmailService.getSettings()))); Account account = service.getAccount("account1"); assertThat(account, notNullValue()); @@ -70,7 +72,7 @@ public class AccountsTests extends ESTestCase { addAccountSettings("account1", builder); addAccountSettings("account2", builder); - EmailService service = new EmailService(builder.build(), null, + EmailService service = new EmailService(builder.build(), null, mock(SSLService.class), new ClusterSettings(Settings.EMPTY, new HashSet<>(EmailService.getSettings()))); Account account = service.getAccount("account1"); assertThat(account, notNullValue()); @@ -88,13 +90,14 @@ public class AccountsTests extends ESTestCase { addAccountSettings("account1", builder); addAccountSettings("account2", builder); ClusterSettings clusterSettings = new ClusterSettings(Settings.EMPTY, new HashSet<>(EmailService.getSettings())); - SettingsException e = expectThrows(SettingsException.class, () -> new EmailService(builder.build(), null, clusterSettings)); + SettingsException e = expectThrows(SettingsException.class, + () -> new EmailService(builder.build(), null, mock(SSLService.class), clusterSettings)); assertThat(e.getMessage(), is("could not find default account [unknown]")); } public void testNoAccount() throws Exception { Settings.Builder builder = Settings.builder(); - EmailService service = new EmailService(builder.build(), null, + EmailService service = new EmailService(builder.build(), null, mock(SSLService.class), new ClusterSettings(Settings.EMPTY, new HashSet<>(EmailService.getSettings()))); expectThrows(IllegalArgumentException.class, () -> service.getAccount(null)); } @@ -102,7 +105,8 @@ public class AccountsTests extends ESTestCase { public void testNoAccountWithDefaultAccount() throws Exception { Settings settings = Settings.builder().put("xpack.notification.email.default_account", "unknown").build(); ClusterSettings clusterSettings = new ClusterSettings(Settings.EMPTY, new HashSet<>(EmailService.getSettings())); - SettingsException e = expectThrows(SettingsException.class, () -> new EmailService(settings, null, clusterSettings)); + SettingsException e = expectThrows(SettingsException.class, + () -> new EmailService(settings, null, mock(SSLService.class), clusterSettings)); assertThat(e.getMessage(), is("could not find default account [unknown]")); } diff --git a/x-pack/plugin/watcher/src/test/java/org/elasticsearch/xpack/watcher/notification/email/EmailServiceTests.java b/x-pack/plugin/watcher/src/test/java/org/elasticsearch/xpack/watcher/notification/email/EmailServiceTests.java index 88bc500f10a..e6a61cdad52 100644 --- a/x-pack/plugin/watcher/src/test/java/org/elasticsearch/xpack/watcher/notification/email/EmailServiceTests.java +++ b/x-pack/plugin/watcher/src/test/java/org/elasticsearch/xpack/watcher/notification/email/EmailServiceTests.java @@ -8,6 +8,7 @@ package org.elasticsearch.xpack.watcher.notification.email; import org.elasticsearch.common.settings.ClusterSettings; import org.elasticsearch.common.settings.Settings; import org.elasticsearch.test.ESTestCase; +import org.elasticsearch.xpack.core.ssl.SSLService; import org.elasticsearch.xpack.core.watcher.common.secret.Secret; import org.junit.Before; @@ -32,7 +33,7 @@ public class EmailServiceTests extends ESTestCase { public void init() throws Exception { account = mock(Account.class); service = new EmailService(Settings.builder().put("xpack.notification.email.account.account1.foo", "bar").build(), null, - new ClusterSettings(Settings.EMPTY, new HashSet<>(EmailService.getSettings()))) { + mock(SSLService.class), new ClusterSettings(Settings.EMPTY, new HashSet<>(EmailService.getSettings()))) { @Override protected Account createAccount(String name, Settings accountSettings) { return account; @@ -70,7 +71,7 @@ public class EmailServiceTests extends ESTestCase { .put("xpack.notification.email.account.account5.smtp.wait_on_quit", true) .put("xpack.notification.email.account.account5.smtp.ssl.trust", "host1,host2,host3") .build(); - EmailService emailService = new EmailService(settings, null, + EmailService emailService = new EmailService(settings, null, mock(SSLService.class), new ClusterSettings(Settings.EMPTY, new HashSet<>(EmailService.getSettings()))); Account account1 = emailService.getAccount("account1"); diff --git a/x-pack/plugin/watcher/src/test/java/org/elasticsearch/xpack/watcher/notification/email/ProfileTests.java b/x-pack/plugin/watcher/src/test/java/org/elasticsearch/xpack/watcher/notification/email/ProfileTests.java index 8ab3e38550d..da8f788f94f 100644 --- a/x-pack/plugin/watcher/src/test/java/org/elasticsearch/xpack/watcher/notification/email/ProfileTests.java +++ b/x-pack/plugin/watcher/src/test/java/org/elasticsearch/xpack/watcher/notification/email/ProfileTests.java @@ -8,6 +8,7 @@ package org.elasticsearch.xpack.watcher.notification.email; import org.elasticsearch.common.settings.ClusterSettings; import org.elasticsearch.common.settings.Settings; import org.elasticsearch.test.ESTestCase; +import org.elasticsearch.xpack.core.ssl.SSLService; import javax.mail.BodyPart; import javax.mail.Part; @@ -19,6 +20,7 @@ import java.util.HashSet; import static org.hamcrest.Matchers.instanceOf; import static org.hamcrest.Matchers.is; +import static org.mockito.Mockito.mock; public class ProfileTests extends ESTestCase { @@ -40,7 +42,7 @@ public class ProfileTests extends ESTestCase { .put("xpack.notification.email.account.foo.smtp.host", "_host") .build(); - EmailService service = new EmailService(settings, null, + EmailService service = new EmailService(settings, null, mock(SSLService.class), new ClusterSettings(Settings.EMPTY, new HashSet<>(EmailService.getSettings()))); Session session = service.getAccount("foo").getConfig().createSession(); MimeMessage mimeMessage = Profile.STANDARD.toMimeMessage(email, session); @@ -62,4 +64,4 @@ public class ProfileTests extends ESTestCase { assertThat("Expected to find an inline attachment in mime message, but didnt", foundInlineAttachment, is(true)); } -} \ No newline at end of file +} diff --git a/x-pack/plugin/watcher/src/test/java/org/elasticsearch/xpack/watcher/notification/email/support/EmailServer.java b/x-pack/plugin/watcher/src/test/java/org/elasticsearch/xpack/watcher/notification/email/support/EmailServer.java index 4195b251392..dc49e23ca7d 100644 --- a/x-pack/plugin/watcher/src/test/java/org/elasticsearch/xpack/watcher/notification/email/support/EmailServer.java +++ b/x-pack/plugin/watcher/src/test/java/org/elasticsearch/xpack/watcher/notification/email/support/EmailServer.java @@ -6,6 +6,7 @@ package org.elasticsearch.xpack.watcher.notification.email.support; import org.apache.logging.log4j.Logger; +import org.elasticsearch.common.Nullable; import org.subethamail.smtp.auth.EasyAuthenticationHandlerFactory; import org.subethamail.smtp.helper.SimpleMessageListener; import org.subethamail.smtp.helper.SimpleMessageListenerAdapter; @@ -14,8 +15,13 @@ import org.subethamail.smtp.server.SMTPServer; import javax.mail.MessagingException; import javax.mail.Session; import javax.mail.internet.MimeMessage; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLSocket; +import javax.net.ssl.SSLSocketFactory; import java.io.IOException; import java.io.InputStream; +import java.net.InetSocketAddress; +import java.net.Socket; import java.security.AccessController; import java.security.PrivilegedAction; import java.util.List; @@ -37,8 +43,8 @@ public class EmailServer { private final List listeners = new CopyOnWriteArrayList<>(); private final SMTPServer server; - public EmailServer(String host, final Logger logger) { - server = new SMTPServer(new SimpleMessageListenerAdapter(new SimpleMessageListener() { + public EmailServer(String host, @Nullable SSLContext sslContext, final Logger logger) { + final SimpleMessageListenerAdapter listener = new SimpleMessageListenerAdapter(new SimpleMessageListener() { @Override public boolean accept(String from, String recipient) { return true; @@ -49,9 +55,9 @@ public class EmailServer { try { Session session = Session.getInstance(new Properties()); MimeMessage msg = new MimeMessage(session, data); - for (Listener listener : listeners) { + for (Listener listener1 : listeners) { try { - listener.on(msg); + listener1.on(msg); } catch (Exception e) { logger.error("Unexpected failure", e); fail(e.getMessage()); @@ -61,12 +67,33 @@ public class EmailServer { throw new RuntimeException("could not create mime message", me); } } - }), new EasyAuthenticationHandlerFactory((user, passwd) -> { + }); + final EasyAuthenticationHandlerFactory authentication = new EasyAuthenticationHandlerFactory((user, passwd) -> { assertThat(user, is(USERNAME)); assertThat(passwd, is(PASSWORD)); - })); + }); + server = new SMTPServer(listener, authentication) { + @Override + public SSLSocket createSSLSocket(Socket socket) throws IOException { + if (sslContext == null) { + return super.createSSLSocket(socket); + } else { + SSLSocketFactory factory = sslContext.getSocketFactory(); + InetSocketAddress remoteAddress = (InetSocketAddress) socket.getRemoteSocketAddress(); + SSLSocket sslSocket = (SSLSocket) factory.createSocket(socket, remoteAddress.getHostString(), socket.getPort(), true); + sslSocket.setUseClientMode(false); + sslSocket.setEnabledCipherSuites(sslSocket.getSupportedCipherSuites()); + return sslSocket; + } + } + }; server.setHostName(host); server.setPort(0); + if (sslContext != null) { + server.setEnableTLS(true); + server.setRequireTLS(true); + server.setHideTLS(false); + } } /** @@ -93,8 +120,16 @@ public class EmailServer { listeners.add(listener); } + public void clearListeners() { + this.listeners.clear(); + } + public static EmailServer localhost(final Logger logger) { - EmailServer server = new EmailServer("localhost", logger); + return localhost(logger, null); + } + + public static EmailServer localhost(final Logger logger, @Nullable SSLContext sslContext) { + EmailServer server = new EmailServer("localhost", sslContext, logger); server.start(); return server; } diff --git a/x-pack/plugin/watcher/src/test/java/org/elasticsearch/xpack/watcher/test/AbstractWatcherIntegrationTestCase.java b/x-pack/plugin/watcher/src/test/java/org/elasticsearch/xpack/watcher/test/AbstractWatcherIntegrationTestCase.java index 65d7589ff8b..ddea3e9e0e4 100644 --- a/x-pack/plugin/watcher/src/test/java/org/elasticsearch/xpack/watcher/test/AbstractWatcherIntegrationTestCase.java +++ b/x-pack/plugin/watcher/src/test/java/org/elasticsearch/xpack/watcher/test/AbstractWatcherIntegrationTestCase.java @@ -45,6 +45,7 @@ import org.elasticsearch.test.transport.MockTransportService; import org.elasticsearch.xpack.core.XPackClient; import org.elasticsearch.xpack.core.XPackSettings; import org.elasticsearch.xpack.core.security.SecurityField; +import org.elasticsearch.xpack.core.ssl.SSLService; import org.elasticsearch.xpack.core.watcher.WatcherState; import org.elasticsearch.xpack.core.watcher.client.WatcherClient; import org.elasticsearch.xpack.core.watcher.execution.ExecutionState; @@ -96,6 +97,7 @@ import static org.hamcrest.Matchers.greaterThanOrEqualTo; import static org.hamcrest.Matchers.notNullValue; import static org.hamcrest.core.Is.is; import static org.hamcrest.core.IsNot.not; +import static org.mockito.Mockito.mock; @ClusterScope(scope = SUITE, numClientNodes = 0, transportClientRatio = 0, maxNumDataNodes = 3) public abstract class AbstractWatcherIntegrationTestCase extends ESIntegTestCase { @@ -574,7 +576,8 @@ public abstract class AbstractWatcherIntegrationTestCase extends ESIntegTestCase public static class NoopEmailService extends EmailService { public NoopEmailService() { - super(Settings.EMPTY, null, new ClusterSettings(Settings.EMPTY, new HashSet<>(EmailService.getSettings()))); + super(Settings.EMPTY, null, mock(SSLService.class), + new ClusterSettings(Settings.EMPTY, new HashSet<>(EmailService.getSettings()))); } @Override diff --git a/x-pack/plugin/watcher/src/test/resources/org/elasticsearch/xpack/watcher/actions/email/test-smtp.p12 b/x-pack/plugin/watcher/src/test/resources/org/elasticsearch/xpack/watcher/actions/email/test-smtp.p12 new file mode 100644 index 0000000000000000000000000000000000000000..b0a748c73c3411f873fde28999e256c38565bcfb GIT binary patch literal 3477 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