This is an automated email from the ASF dual-hosted git repository. btellier pushed a commit to branch MaxConnectionLifespanHandler in repository https://gitbox.apache.org/repos/asf/james-project.git
commit 7637b363b081222fc5f1860e703984d253955a26 Author: Benoit TELLIER <btell...@linagora.com> AuthorDate: Thu Jun 19 17:30:54 2025 +0200 [ENAHNCEMENT] Implement a MaxConnectionLifespanHandler SMTP handler --- .../servers/partials/configure/smtp-hooks.adoc | 18 +++++ .../james/protocols/api/ProtocolSession.java | 9 +++ .../james/protocols/api/ProtocolSessionImpl.java | 10 +++ .../james/protocols/api/ProtocolTransport.java | 5 ++ .../protocols/netty/NettyProtocolTransport.java | 6 ++ .../netty/MaxConnectionLifespanHandler.java | 70 ++++++++++++++++ .../MaxConnectionLifespanHandlerTest.java | 92 ++++++++++++++++++++++ .../src/test/resources/smtpserver-logout.xml | 53 +++++++++++++ 8 files changed, 263 insertions(+) diff --git a/docs/modules/servers/partials/configure/smtp-hooks.adoc b/docs/modules/servers/partials/configure/smtp-hooks.adoc index f7d8fa7c18..f73df41cca 100644 --- a/docs/modules/servers/partials/configure/smtp-hooks.adoc +++ b/docs/modules/servers/partials/configure/smtp-hooks.adoc @@ -369,6 +369,24 @@ These hooks are designed to support the SMTP service extension, REQUIRETLS, and </smtpserver> .... +== MaxConnectionLifespanHandler + +[source,xml] +.... +<smtpserver enabled="true"> + <handlerchain> + <handler class="org.apache.james.smtpserver.netty.MaxConnectionLifespanHandler"> + <duration>5m</duration> + </handler> + <handler class="org.apache.james.smtpserver.CoreCmdHandlerLoader"/> + </handlerchain> +</smtpserver> +.... + +will close SMTP connections after 5 minutes. + +This is useful for instance to force re-authentication and enforce password changes. + == DKIM checks hooks Hook for verifying DKIM signatures of incoming mails. diff --git a/protocols/api/src/main/java/org/apache/james/protocols/api/ProtocolSession.java b/protocols/api/src/main/java/org/apache/james/protocols/api/ProtocolSession.java index 142203977a..b41c054fe7 100644 --- a/protocols/api/src/main/java/org/apache/james/protocols/api/ProtocolSession.java +++ b/protocols/api/src/main/java/org/apache/james/protocols/api/ProtocolSession.java @@ -21,6 +21,7 @@ package org.apache.james.protocols.api; import java.net.InetSocketAddress; import java.nio.charset.Charset; +import java.time.Duration; import java.util.Map; import java.util.Objects; import java.util.Optional; @@ -252,4 +253,12 @@ public interface ProtocolSession extends CommandDetectionSession { * Pop the last command handler */ void popLineHandler(); + + default void schedule(Runnable runnable, Duration waitDelay) { + throw new RuntimeException("Not supported"); + } + + default void close() { + throw new RuntimeException("Not supported"); + } } diff --git a/protocols/api/src/main/java/org/apache/james/protocols/api/ProtocolSessionImpl.java b/protocols/api/src/main/java/org/apache/james/protocols/api/ProtocolSessionImpl.java index ee711356b0..155fe481b5 100644 --- a/protocols/api/src/main/java/org/apache/james/protocols/api/ProtocolSessionImpl.java +++ b/protocols/api/src/main/java/org/apache/james/protocols/api/ProtocolSessionImpl.java @@ -23,6 +23,7 @@ import static java.nio.charset.StandardCharsets.US_ASCII; import java.net.InetSocketAddress; import java.nio.charset.Charset; +import java.time.Duration; import java.util.HashMap; import java.util.Map; import java.util.Optional; @@ -237,4 +238,13 @@ public class ProtocolSessionImpl implements ProtocolSession { transport.pushLineHandler(overrideCommandHandler, this); } + @Override + public void schedule(Runnable runnable, Duration waitDelay) { + transport.schedule(runnable, waitDelay); + } + + @Override + public void close() { + transport.writeResponse(Response.DISCONNECT, this); + } } diff --git a/protocols/api/src/main/java/org/apache/james/protocols/api/ProtocolTransport.java b/protocols/api/src/main/java/org/apache/james/protocols/api/ProtocolTransport.java index db4cc17917..3162cadb1b 100644 --- a/protocols/api/src/main/java/org/apache/james/protocols/api/ProtocolTransport.java +++ b/protocols/api/src/main/java/org/apache/james/protocols/api/ProtocolTransport.java @@ -20,6 +20,7 @@ package org.apache.james.protocols.api; import java.net.InetSocketAddress; +import java.time.Duration; import java.util.Optional; import javax.net.ssl.SSLSession; @@ -97,4 +98,8 @@ public interface ProtocolTransport { * Return <code>true</code> if the channel is readable */ boolean isReadable(); + + default void schedule(Runnable runnable, Duration waitDelay) { + throw new RuntimeException("Not supported"); + } } diff --git a/protocols/netty/src/main/java/org/apache/james/protocols/netty/NettyProtocolTransport.java b/protocols/netty/src/main/java/org/apache/james/protocols/netty/NettyProtocolTransport.java index 60b3191e92..621449c50c 100644 --- a/protocols/netty/src/main/java/org/apache/james/protocols/netty/NettyProtocolTransport.java +++ b/protocols/netty/src/main/java/org/apache/james/protocols/netty/NettyProtocolTransport.java @@ -24,7 +24,9 @@ import java.io.IOException; import java.io.InputStream; import java.net.InetSocketAddress; import java.nio.channels.FileChannel; +import java.time.Duration; import java.util.Optional; +import java.util.concurrent.TimeUnit; import javax.net.ssl.SSLEngine; import javax.net.ssl.SSLSession; @@ -184,4 +186,8 @@ public class NettyProtocolTransport extends AbstractProtocolTransport { } + @Override + public void schedule(Runnable runnable, Duration waitDelay) { + channel.eventLoop().schedule(runnable, waitDelay.toMillis(), TimeUnit.MILLISECONDS); + } } diff --git a/server/protocols/protocols-smtp/src/main/java/org/apache/james/smtpserver/netty/MaxConnectionLifespanHandler.java b/server/protocols/protocols-smtp/src/main/java/org/apache/james/smtpserver/netty/MaxConnectionLifespanHandler.java new file mode 100644 index 0000000000..a491725d92 --- /dev/null +++ b/server/protocols/protocols-smtp/src/main/java/org/apache/james/smtpserver/netty/MaxConnectionLifespanHandler.java @@ -0,0 +1,70 @@ +/**************************************************************** + * 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.james.smtpserver.netty; + +import java.time.Duration; +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +import org.apache.commons.configuration2.Configuration; +import org.apache.commons.configuration2.ex.ConfigurationException; +import org.apache.commons.configuration2.ex.ConfigurationRuntimeException; +import org.apache.james.protocols.api.Response; +import org.apache.james.protocols.api.handler.ConnectHandler; +import org.apache.james.protocols.smtp.SMTPSession; +import org.apache.james.util.DurationParser; + +public class MaxConnectionLifespanHandler implements ConnectHandler<SMTPSession> { + public static final Response NOOP = new Response() { + + @Override + public String getRetCode() { + return ""; + } + + @Override + public List<CharSequence> getLines() { + return Collections.emptyList(); + } + + @Override + public boolean isEndSession() { + return false; + } + + }; + + private Optional<Duration> connectionLifespan = Optional.empty(); + + @Override + public void init(Configuration config) throws ConfigurationException { + connectionLifespan = Optional.of(DurationParser.parse(Optional.ofNullable(config.getString("duration", null)) + .orElseThrow(() -> new ConfigurationRuntimeException("'duration' configuration property is compulsary")))); + } + + @Override + public Response onConnect(SMTPSession session) { + connectionLifespan.ifPresent(duration -> + session.schedule(session::close, duration)); + + return NOOP; + } +} diff --git a/server/protocols/protocols-smtp/src/test/java/org/apache/james/smtpserver/MaxConnectionLifespanHandlerTest.java b/server/protocols/protocols-smtp/src/test/java/org/apache/james/smtpserver/MaxConnectionLifespanHandlerTest.java new file mode 100644 index 0000000000..1dbadf65b8 --- /dev/null +++ b/server/protocols/protocols-smtp/src/test/java/org/apache/james/smtpserver/MaxConnectionLifespanHandlerTest.java @@ -0,0 +1,92 @@ +/**************************************************************** + * 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.james.smtpserver; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.apache.james.smtpserver.SMTPServerTestSystem.BOB; +import static org.apache.james.smtpserver.SMTPServerTestSystem.PASSWORD; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.util.Base64; + +import org.apache.commons.net.smtp.SMTPClient; +import org.apache.commons.net.smtp.SMTPConnectionClosedException; +import org.apache.james.server.core.configuration.Configuration; +import org.apache.james.server.core.configuration.FileConfigurationProvider; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class MaxConnectionLifespanHandlerTest { + protected Configuration configuration; + + private final SMTPServerTestSystem testSystem = new SMTPServerTestSystem(); + + @BeforeEach + void setUp() throws Exception { + testSystem.preSetUp(); + } + + @AfterEach + void tearDown() { + testSystem.smtpServer.destroy(); + } + + @Test + void shouldLogoutAfterLifespan() throws Exception { + testSystem.smtpServer.configure(FileConfigurationProvider.getConfig( + ClassLoader.getSystemResourceAsStream("smtpserver-logout.xml"))); + testSystem.smtpServer.init(); + + SMTPClient smtpProtocol = new SMTPClient(); + InetSocketAddress bindedAddress = testSystem.getBindedAddress(); + smtpProtocol.connect(bindedAddress.getAddress().getHostAddress(), bindedAddress.getPort()); + authenticate(smtpProtocol); + + Thread.sleep(2000); + + assertThatThrownBy(() -> smtpProtocol.sendCommand("EHLO localhost")).isInstanceOf(SMTPConnectionClosedException.class); + } + + @Test + void shouldNotLogoutBeforeLifespan() throws Exception { + testSystem.smtpServer.configure(FileConfigurationProvider.getConfig( + ClassLoader.getSystemResourceAsStream("smtpserver-logout.xml"))); + testSystem.smtpServer.init(); + + SMTPClient smtpProtocol = new SMTPClient(); + InetSocketAddress bindedAddress = testSystem.getBindedAddress(); + smtpProtocol.connect(bindedAddress.getAddress().getHostAddress(), bindedAddress.getPort()); + authenticate(smtpProtocol); + + assertThatCode(() -> smtpProtocol.sendCommand("EHLO localhost")).doesNotThrowAnyException(); + } + + private void authenticate(SMTPClient smtpProtocol) throws IOException { + smtpProtocol.sendCommand("AUTH PLAIN"); + smtpProtocol.sendCommand(Base64.getEncoder().encodeToString(("\0" + BOB.asString() + "\0" + PASSWORD + "\0").getBytes(UTF_8))); + assertThat(smtpProtocol.getReplyCode()) + .as("authenticated") + .isEqualTo(235); + } +} diff --git a/server/protocols/protocols-smtp/src/test/resources/smtpserver-logout.xml b/server/protocols/protocols-smtp/src/test/resources/smtpserver-logout.xml new file mode 100644 index 0000000000..c57b8a7033 --- /dev/null +++ b/server/protocols/protocols-smtp/src/test/resources/smtpserver-logout.xml @@ -0,0 +1,53 @@ +<?xml version="1.0"?> + +<!-- + 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. + --> + +<!-- Read https://james.apache.org/server/config-smtp-lmtp.html#SMTP_Configuration for further details --> + +<smtpserver enabled="true"> + <bind>0.0.0.0:0</bind> + <connectionBacklog>200</connectionBacklog> + <tls socketTLS="false" startTLS="false"> + <keystore>file://conf/keystore</keystore> + <secret>james72laBalle</secret> + <provider>org.bouncycastle.jce.provider.BouncyCastleProvider</provider> + <algorithm>SunX509</algorithm> + </tls> + <connectiontimeout>360</connectiontimeout> + <connectionLimit>0</connectionLimit> + <connectionLimitPerIP>0</connectionLimitPerIP> + <auth> + <announce>forUnauthorizedAddresses</announce> + <requireSSL>false</requireSSL> + </auth> + <verifyIdentity>true</verifyIdentity> + <maxmessagesize>0</maxmessagesize> + <addressBracketsEnforcement>true</addressBracketsEnforcement> + <smtpGreeting>Apache JAMES awesome SMTP Server</smtpGreeting> + <handlerchain> + <handler class="org.apache.james.smtpserver.netty.MaxConnectionLifespanHandler"> + <duration>1s</duration> + </handler> + <handler class="org.apache.james.smtpserver.CoreCmdHandlerLoader"/> + </handlerchain> + <gracefulShutdown>false</gracefulShutdown> +</smtpserver> + + --------------------------------------------------------------------- To unsubscribe, e-mail: notifications-unsubscr...@james.apache.org For additional commands, e-mail: notifications-h...@james.apache.org