From cd771f143256c5058a484fc31b06b3387f25b939 Mon Sep 17 00:00:00 2001 From: "selva.andre" Date: Thu, 22 May 2014 23:53:58 +0200 Subject: [PATCH] Added implementation of SSL support (ssl+mqtt or mqtts) --- .../moquette/server/netty/NettyAcceptor.java | 89 +++++++++++- broker/src/main/resources/log4j.properties | 14 +- .../server/ServerIntegrationSSLTest.java | 134 ++++++++++++++++++ broker/src/test/resources/clientkeystore.jks | Bin 0 -> 3218 bytes .../src/test/resources/config/moquette.conf | 20 +++ .../test/resources/config/password_file.conf | 1 + broker/src/test/resources/serverkeystore.jks | Bin 0 -> 3216 bytes 7 files changed, 248 insertions(+), 10 deletions(-) create mode 100644 broker/src/test/java/org/dna/mqtt/moquette/server/ServerIntegrationSSLTest.java create mode 100644 broker/src/test/resources/clientkeystore.jks create mode 100644 broker/src/test/resources/config/moquette.conf create mode 100644 broker/src/test/resources/config/password_file.conf create mode 100644 broker/src/test/resources/serverkeystore.jks diff --git a/broker/src/main/java/org/dna/mqtt/moquette/server/netty/NettyAcceptor.java b/broker/src/main/java/org/dna/mqtt/moquette/server/netty/NettyAcceptor.java index ee529a3b..6abe4dc8 100644 --- a/broker/src/main/java/org/dna/mqtt/moquette/server/netty/NettyAcceptor.java +++ b/broker/src/main/java/org/dna/mqtt/moquette/server/netty/NettyAcceptor.java @@ -18,12 +18,18 @@ import io.netty.handler.codec.http.HttpRequestDecoder; import io.netty.handler.codec.http.HttpResponseEncoder; import io.netty.handler.codec.http.websocketx.BinaryWebSocketFrame; import io.netty.handler.codec.http.websocketx.WebSocketServerProtocolHandler; +import io.netty.handler.ssl.SslHandler; import io.netty.handler.timeout.IdleStateHandler; import java.io.IOException; +import java.io.InputStream; +import java.security.KeyStore; import java.util.List; import java.util.Properties; +import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLEngine; import org.dna.mqtt.commons.Constants; import org.dna.mqtt.moquette.messaging.spi.IMessaging; import org.dna.mqtt.moquette.parser.netty.MQTTDecoder; @@ -76,6 +82,7 @@ public class NettyAcceptor implements ServerAcceptor { initializePlainTCPTransport(messaging, props); initializeWebSocketTransport(messaging, props); + initializeSSLTCPTransport(messaging, props); } private void initializePlainTCPTransport(IMessaging messaging, Properties props) throws IOException { @@ -117,9 +124,6 @@ public class NettyAcceptor implements ServerAcceptor { } private void initializeWebSocketTransport(IMessaging messaging, Properties props) throws IOException { -// m_bossGroup = new NioEventLoopGroup(); -// m_workerGroup = new NioEventLoopGroup(); - final NettyMQTTHandler handler = new NettyMQTTHandler(); handler.setMessaging(messaging); @@ -157,6 +161,85 @@ public class NettyAcceptor implements ServerAcceptor { LOG.error(null, ex); } } + + + private void initializeSSLTCPTransport(IMessaging messaging, Properties props) throws IOException { + String sslPortProp = props.getProperty("ssl_port"); + if (sslPortProp == null) { + //Do nothing no SSL configured + LOG.info("SSL is disabled"); + return; + } + final String jksPath = props.getProperty("jks_path"); + if (jksPath == null || jksPath.isEmpty()) { + //key_store_password or key_manager_password are empty + LOG.warn("You have configured the SSL port but not the jks_path, SSL not started"); + return; + } + + //if we have the port also the jks then keyStorePassword and keyManagerPassword + //has to be defined + final String keyStorePassword = props.getProperty("key_store_password"); + final String keyManagerPassword = props.getProperty("key_manager_password"); + if (keyStorePassword == null || keyStorePassword.isEmpty()) { + //key_store_password or key_manager_password are empty + LOG.warn("You have configured the SSL port but not the key_store_password, SSL not started"); + return; + } + if (keyManagerPassword == null || keyManagerPassword.isEmpty()) { + //key_manager_password or key_manager_password are empty + LOG.warn("You have configured the SSL port but not the key_manager_password, SSL not started"); + return; + } + + int sslPort = Integer.parseInt(sslPortProp); + + final NettyMQTTHandler handler = new NettyMQTTHandler(); + handler.setMessaging(messaging); + + ServerBootstrap b = new ServerBootstrap(); + b.group(m_bossGroup, m_workerGroup) + .channel(NioServerSocketChannel.class) + .childHandler(new ChannelInitializer() { + @Override + public void initChannel(SocketChannel ch) throws Exception { + InputStream jksInputStream = getClass().getClassLoader().getResourceAsStream(jksPath); + SSLContext serverContext = SSLContext.getInstance("TLS"); + final KeyStore ks = KeyStore.getInstance("JKS"); + ks.load(jksInputStream, keyStorePassword.toCharArray()); + final KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); + kmf.init(ks, keyManagerPassword.toCharArray()); + serverContext.init(kmf.getKeyManagers(), null, null); + + SSLEngine engine = serverContext.createSSLEngine(); + engine.setUseClientMode(false); + final SslHandler sslHandler = new SslHandler(engine); + + ChannelPipeline pipeline = ch.pipeline(); + pipeline.addLast("ssl", sslHandler); + //pipeline.addFirst("metrics", new BytesMetricsHandler(m_metricsCollector)); + pipeline.addFirst("idleStateHandler", new IdleStateHandler(0, 0, Constants.DEFAULT_CONNECT_TIMEOUT)); + pipeline.addAfter("idleStateHandler", "idleEventHandler", new MoquetteIdleTimoutHandler()); + //pipeline.addLast("logger", new LoggingHandler("Netty", LogLevel.ERROR)); + pipeline.addLast("decoder", new MQTTDecoder()); + pipeline.addLast("encoder", new MQTTEncoder()); + pipeline.addLast("metrics", new MessageMetricsHandler(m_metricsCollector)); + pipeline.addLast("handler", handler); + } + }) + .option(ChannelOption.SO_BACKLOG, 128) + .option(ChannelOption.SO_REUSEADDR, true) + .childOption(ChannelOption.SO_KEEPALIVE, true); + try { + // Bind and start to accept incoming connections. +// ChannelFuture f = b.bind(Constants.PORT); + ChannelFuture f = b.bind(props.getProperty("host"), sslPort); + LOG.info("Server binded"); + f.sync(); + } catch (InterruptedException ex) { + LOG.error(null, ex); + } + } public void close() { if (m_workerGroup == null) { diff --git a/broker/src/main/resources/log4j.properties b/broker/src/main/resources/log4j.properties index c018fbce..bcce4891 100644 --- a/broker/src/main/resources/log4j.properties +++ b/broker/src/main/resources/log4j.properties @@ -4,23 +4,23 @@ log4j.rootLogger=ERROR, stdout, file log4j.logger.org.dna.mqtt=INFO log4j.logger.org.dna.mqtt.moquette.server.ServerIntegrationFuseTest=DEBUG -log4j.logger.org.dna.mqtt.moquette.messaging.spi.impl.ProtocolProcessor=INFO -#log4j.logger.org.dna.mqtt.moquette.messaging.spi.impl.ProtocolProcessor=DEBUG +#log4j.logger.org.dna.mqtt.moquette.messaging.spi.impl.ProtocolProcessor=INFO +log4j.logger.org.dna.mqtt.moquette.messaging.spi.impl.ProtocolProcessor=DEBUG #log4j.logger.org.dna.mqtt.moquette.messaging.spi.impl.ProtocolProcessor=TRACE log4j.logger.org.dna.mqtt.moquette.messaging.spi.impl.SimpleMessaging=WARN #Protocol parsing log4j.logger.org.dna.mqtt.moquette.server.netty.NettyMQTTHandler=WARN #log4j.logger.org.dna.mqtt.moquette.server.netty.NettyMQTTHandler=DEBUG log4j.logger.org.dna.mqtt.moquette.parser.netty=WARN -#Storage server -#log4j.logger.org.dna.mqtt.moquette.messaging.spi.impl.subscriptions.SubscriptionsStore=DEBUG -#log4j.logger.org.dna.mqtt.moquette.messaging.spi.impl.HawtDBStorageService=DEBUG +#Storage service +log4j.logger.org.dna.mqtt.moquette.messaging.spi.impl.subscriptions.SubscriptionsStore=DEBUG +log4j.logger.org.dna.mqtt.moquette.messaging.spi.impl.HawtDBStorageService=DEBUG # stdout appender is set to be a ConsoleAppender. log4j.appender.stdout=org.apache.log4j.ConsoleAppender -log4j.appender.stdout.Threshold=WARN +#log4j.appender.stdout.Threshold=WARN #log4j.appender.stdout.Threshold=INFO -#log4j.appender.stdout.Threshold=DEBUG +log4j.appender.stdout.Threshold=DEBUG #log4j.appender.stdout.Threshold=TRACE log4j.appender.stdout.layout=org.apache.log4j.PatternLayout log4j.appender.stdout.layout.ConversionPattern=%-4r [%t] %-5p %c{1} %x - %m%n diff --git a/broker/src/test/java/org/dna/mqtt/moquette/server/ServerIntegrationSSLTest.java b/broker/src/test/java/org/dna/mqtt/moquette/server/ServerIntegrationSSLTest.java new file mode 100644 index 00000000..e9a0577c --- /dev/null +++ b/broker/src/test/java/org/dna/mqtt/moquette/server/ServerIntegrationSSLTest.java @@ -0,0 +1,134 @@ +package org.dna.mqtt.moquette.server; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.security.KeyManagementException; +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.security.UnrecoverableKeyException; +import java.security.cert.CertificateException; +import javax.net.SocketFactory; +import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLServerSocketFactory; +import javax.net.ssl.SSLSocketFactory; +import javax.net.ssl.TrustManager; +import javax.net.ssl.TrustManagerFactory; +import org.eclipse.paho.client.mqttv3.IMqttClient; +import org.eclipse.paho.client.mqttv3.MqttClient; +import org.eclipse.paho.client.mqttv3.MqttClientPersistence; +import org.eclipse.paho.client.mqttv3.MqttConnectOptions; +import org.eclipse.paho.client.mqttv3.persist.MqttDefaultFilePersistence; +import org.junit.After; +import static org.junit.Assert.assertFalse; +import org.junit.Before; +import org.junit.BeforeClass; +import org.junit.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Check that Moquette could also handle SSL. + * + * @author andrea + */ +public class ServerIntegrationSSLTest { + private static final Logger LOG = LoggerFactory.getLogger(ServerIntegrationSSLTest.class); + + Server m_server; + static MqttClientPersistence s_dataStore; + + IMqttClient m_client; + TestCallback m_callback; + + @BeforeClass + public static void beforeTests() { + String tmpDir = System.getProperty("java.io.tmpdir"); + s_dataStore = new MqttDefaultFilePersistence(tmpDir); + } + + protected void startServer() throws IOException { + String file = getClass().getResource("/").getPath(); + System.setProperty("moquette.path", file); + m_server = new Server(); + m_server.startServer(); + } + + @Before + public void setUp() throws Exception { + File dbFile = new File(Server.STORAGE_FILE_PATH); + assertFalse(String.format("The DB storagefile %s already exists", Server.STORAGE_FILE_PATH), dbFile.exists()); + + startServer(); + + m_client = new MqttClient("ssl://localhost:8883", "TestClient", s_dataStore); +// m_client = new MqttClient("ssl://test.mosquitto.org:8883", "TestClient", s_dataStore); + + m_callback = new TestCallback(); + m_client.setCallback(m_callback); + } + + @After + public void tearDown() throws Exception { + if (m_client.isConnected()) { + m_client.disconnect(); + } + + m_server.stopServer(); + File dbFile = new File(Server.STORAGE_FILE_PATH); + if (dbFile.exists()) { + dbFile.delete(); + } + assertFalse(dbFile.exists()); + } + + @Test + public void checkSupportSSL() throws Exception { + LOG.info("*** checkSupportSSL ***"); + SSLSocketFactory ssf = configureSSLSocketFactory(); + + MqttConnectOptions options = new MqttConnectOptions(); + options.setSocketFactory(ssf); + m_client.connect(options); + m_client.subscribe("/topic", 0); + m_client.disconnect(); + } + + /** + * keystore generated into test/resources with command: + * + * keytool -keystore clientkeystore.jks -alias testclient -genkey -keyalg RSA + * -> mandatory to put the name surname + * -> password is passw0rd + * -> type yes at the end + * + * to generate the crt file from the keystore + * -- keytool -certreq -alias testclient -keystore clientkeystore.jks -file testclient.csr + * + * keytool -export -alias testclient -keystore clientkeystore.jks -file testclient.crt + * + * to import an existing certificate: + * keytool -keystore clientkeystore.jks -import -alias testclient -file testclient.crt -trustcacerts + */ + private SSLSocketFactory configureSSLSocketFactory() throws KeyManagementException, NoSuchAlgorithmException, UnrecoverableKeyException, IOException, CertificateException, KeyStoreException { + KeyStore ks = KeyStore.getInstance("JKS"); + InputStream jksInputStream = getClass().getClassLoader().getResourceAsStream("clientkeystore.jks"); + ks.load(jksInputStream, "passw0rd".toCharArray()); + + KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); + kmf.init(ks, "passw0rd".toCharArray()); + + TrustManagerFactory tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); + tmf.init(ks); + + SSLContext sc = SSLContext.getInstance("TLS"); + TrustManager[] trustManagers = tmf.getTrustManagers(); + sc.init(kmf.getKeyManagers(), trustManagers, null); + + SSLSocketFactory ssf = sc.getSocketFactory(); + return ssf; + } +} diff --git a/broker/src/test/resources/clientkeystore.jks b/broker/src/test/resources/clientkeystore.jks new file mode 100644 index 0000000000000000000000000000000000000000..422f4b24a2b8a3fa7ef742ca5cdb1a0b7854246d GIT binary patch literal 3218 zcmc(hX*d+@8pmhG*tg0$lYI#zW~>=Clx!(R7=wI`V-2V^Hbzk@M|KHDVdwzQ!004mgI9!3a zfWQEpe=yD;000`xw>Aj@SkJ0zA&>w7OEQAMLPQWiet9e)AP59@eM5#Kc-c5%@uu;s zyg(o~D*zFswgg+S6c+2%%2AYWCgGgfcyXi z0mua+0D*wa@)w`X3V0*3sDnL8BnyyMomvG$*!w284wEaE>3mUE-o4~oS%S>Pe)Q2V zMc3Q7IOK9B-g(Wpp8PQtW360Wt04}$YIyD{fIg_M#^uVAZl*lUW$0gZB6(S!1{4$7 zXW+7bX3s*Cr{&10OXvG3Rg3d_MZ$I}>B;3KD&2lr5gH@Wj5#QD8hWnn`*1Z`jlPt9 zeb3=jp|Pcv+JeZ^Htfww8KBXnP5}l;eZ(LOm*rM6dpH-Q?ht_h zfXbuF3NkOPBQw!*g1x%(zL<}~p@Q*|(@Qpl{&VKY2dk>M54pZGZwFjC5;Q7@#U94+ zJkFV#e*T3$Mly%pBN?1I(!bfLnh7N=w6uBOe67~PyD`R|)^Tz32ZmQ5QREY6r{7A! zoY=_1-Tig$nFR>Ads%nf<0pxSQTb>?%3_DWyGI-EfUXd8tEk9!F_#v8Jw{4x?DRFl zr%@(_>XwB+#w zR+@Cm#w&8r()J%0V+7qUH}@_AE?4R*J{qIAJ57pX{LKUr53Ga~H{_gH0XWHI^qEqu z*I*!VJa!h{_>h?8)x0l}6oLrU9D^ONv0-?KgtnG%*?f@kSA$DH!3W`x_!sxc@fkNF zG(U-(_0Z}!{YqYiOuM=fViRw+L_X&g(!)`jwN zR5W|>V(@f#XeaY{BIbIlidg zD&Kj*!;P~;L!b-!DX>FxAj7)CfwxJwpRFI@=)JpsFVwWqh5PwKm3y;EKQLUx&oA;! zKF#wqdr9&_`QqFvfo1fke9xOL@LE=tX;gGvAC|6+tu3YDP?>LITYo{wlYQ_A+ZpdJ)YKkKB9cx z9|&`0^F^RHtW$Aice;MywR3>yhaGC9SV!a@>AK>=RMvJ_NwW(|V$ z_>v8WBlHXV;UdMd)JVO7^%d`ER8^|{6^@ysCnw40Yl3==&pLq@6Aa!M4Kq=~vf`PH z%z+`Q*S&E_g2^|)`e>s7Bj$~(!xf(IImNejP83QhQIs~^a~Gf57JdGT)YqKt9*d{( zdn%B$@A99*%g#r+d>l7_T{+a8eJfLj*dxEyONVhM5AN_AZ>%9jth~4(mF~-Xpz|%XT5zeaB(JDzgf6Gt0Sil9uahtk|Cqge$_glgAG^{rm4S3FC3=@>ntjlDia9`^Em_DrNqm+?sfbn#aYcx84^c;R znc&_WjQaSdr{&-gHJ4O{#!3#DmGbyZ^m0{%$>G8j*zwzZ_OFJPkG*H zIabmjmtUe3m3Q2%&zNjzl_p7Yq*DvYN@t5x9B)VFvJ&r9r8F7_WeUk;wLbI*)gCnP zsbAQa&e_N$=bG)I={cW4Ii>1&;v5Gq@?M8*y+);(TND%O5kyF zHr4Ev(YLFWk9;MY@U$OKSSztn%(tzIO^Y1rUN-`tVCuIn4C|hOWLd{a!xL+aSS%zm49K;kEEmzN_1efnhDfH$AH9kp*iu zQ5I##MeU9Re@UyMPb4{VQijlJ*P6zi-UUwgeR}ys|MuF=eIKq7E8eD>z9b(}eNReg z)C>|rSJWIxp!5rIYuhE)C5h;!uTH_(GPvWDDk1nve{4}fao6fQlPxwB7oex*zA z=i`B+%%fR!@vfxAR5ac6=-!kqW%{@2skk~UoXwLO(2;UvJ>A5!;&BCQ_oi5diN1#s zOsE+#6s5NFVLY^#&pz?$2hiQ#sC5a2$aj9#pV&N>VZi!7kk+M&f HBpUt!;(l9# literal 0 HcmV?d00001 diff --git a/broker/src/test/resources/config/moquette.conf b/broker/src/test/resources/config/moquette.conf new file mode 100644 index 00000000..d10166e6 --- /dev/null +++ b/broker/src/test/resources/config/moquette.conf @@ -0,0 +1,20 @@ +############################################## +# Moquette configuration file. +# +# The synthax is equals to mosquitto.conf +# +############################################## + +port 1883 + +websocket_port 8080 + +host 0.0.0.0 + +#Password file +password_file password_file.conf + +ssl_port 8883 +jks_path serverkeystore.jks +key_store_password passw0rdsrv +key_manager_password passw0rdsrv diff --git a/broker/src/test/resources/config/password_file.conf b/broker/src/test/resources/config/password_file.conf new file mode 100644 index 00000000..de74f4d7 --- /dev/null +++ b/broker/src/test/resources/config/password_file.conf @@ -0,0 +1 @@ +testuser:passwd \ No newline at end of file diff --git a/broker/src/test/resources/serverkeystore.jks b/broker/src/test/resources/serverkeystore.jks new file mode 100644 index 0000000000000000000000000000000000000000..f3120450319b1b9baedc4d62bb1a05172d36c249 GIT binary patch literal 3216 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 z