diff --git a/common/src/main/java/io/netty/util/Mapping.java b/common/src/main/java/io/netty/util/Mapping.java new file mode 100644 index 0000000000..2af3ffdf79 --- /dev/null +++ b/common/src/main/java/io/netty/util/Mapping.java @@ -0,0 +1,27 @@ +/* + * Copyright 2014 The Netty Project + * + * The Netty Project 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 io.netty.util; + +/** + * An mapping which maintains a relationship from type of IN to type of OUT. + */ +public interface Mapping { + + /** + * Returns mapped value of input. + */ + OUT map(IN input); +} diff --git a/handler/src/main/java/io/netty/handler/ssl/DomainNameMapping.java b/handler/src/main/java/io/netty/handler/ssl/DomainNameMapping.java new file mode 100644 index 0000000000..ff4c818445 --- /dev/null +++ b/handler/src/main/java/io/netty/handler/ssl/DomainNameMapping.java @@ -0,0 +1,126 @@ +/* + * Copyright 2014 The Netty Project + * + * The Netty Project 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 io.netty.handler.ssl; + +import io.netty.util.Mapping; +import io.netty.util.internal.logging.InternalLogger; +import io.netty.util.internal.logging.InternalLoggerFactory; + +import java.net.IDN; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.regex.Pattern; + +/** + *

This class maps a domain name to a configured {@link SslContext}.

+ * + *

DNS wildcard is supported as hostname, so you can use {@code *.netty.io} to match both {@code netty.io} + * and {@code downloads.netty.io}.

+ */ +public class DomainNameMapping implements Mapping { + + private static final InternalLogger logger = + InternalLoggerFactory.getInstance(DomainNameMapping.class); + + private static final Pattern DNS_WILDCARD_PATTERN = Pattern.compile("^\\*\\..*"); + + private final Map userProvidedContexts; + + private final SslContext defaultContext; + + /** + * Create a default, order-sensitive mapping. If your hostnames are in conflict, the mapping + * will choose the one you add first. + * + * @param defaultContext default {@link SslContext} when the nothing matches input. + */ + public DomainNameMapping(SslContext defaultContext) { + this(4, defaultContext); + } + + /** + * Create a default, order-sensitive mapping. If your hostnames are in conflict, the mapping + * will choose the one you add first. + * + * @param initialCapacity initial capacity for internal map + * @param defaultContext default {@link SslContext} when the handler fails to detect SNI extension + */ + public DomainNameMapping(int initialCapacity, SslContext defaultContext) { + if (defaultContext == null) { + throw new NullPointerException("defaultContext"); + } + userProvidedContexts = new LinkedHashMap(initialCapacity); + this.defaultContext = defaultContext; + } + + /** + * Add a {@link SslContext} to the handler. + * + * DNS wildcard is supported as hostname. + * For example, you can use {@code *.netty.io} to match {@code netty.io} and {@code downloads.netty.io}. + * + * @param hostname hostname for the certificate. + * @param context the {@link SslContext} + */ + public DomainNameMapping addContext(String hostname, SslContext context) { + if (hostname == null) { + throw new NullPointerException("hostname"); + } + + if (context == null) { + throw new NullPointerException("context"); + } + + userProvidedContexts.put(normalizeHostname(hostname), context); + return this; + } + + /** + *

Simple function to match DNS wildcard. + *

+ */ + private static boolean matches(String hostNameTemplate, String hostName) { + // note that inputs are converted and lowercased already + if (DNS_WILDCARD_PATTERN.matcher(hostNameTemplate).matches()) { + return hostNameTemplate.substring(2).equals(hostName) || + hostName.endsWith(hostNameTemplate.substring(1)); + } else { + return hostNameTemplate.equals(hostName); + } + } + + /** + * IDNA ASCII conversion and case normalization + */ + static String normalizeHostname(String hostname) { + return IDN.toASCII(hostname, IDN.ALLOW_UNASSIGNED).toLowerCase(); + } + + @Override + public SslContext map(String hostname) { + if (hostname != null) { + for (Map.Entry entry : userProvidedContexts.entrySet()) { + if (matches(entry.getKey(), hostname)) { + return entry.getValue(); + } + } + } + if (logger.isDebugEnabled()) { + logger.debug("Using default SslContext"); + } + return defaultContext; + } +} diff --git a/handler/src/main/java/io/netty/handler/ssl/SniHandler.java b/handler/src/main/java/io/netty/handler/ssl/SniHandler.java new file mode 100644 index 0000000000..c567a7c136 --- /dev/null +++ b/handler/src/main/java/io/netty/handler/ssl/SniHandler.java @@ -0,0 +1,186 @@ +/* + * Copyright 2014 The Netty Project + * + * The Netty Project 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 io.netty.handler.ssl; + +import io.netty.buffer.ByteBuf; +import io.netty.buffer.ByteBufUtil; +import io.netty.channel.ChannelHandlerContext; +import io.netty.handler.codec.ByteToMessageDecoder; +import io.netty.util.CharsetUtil; +import io.netty.util.internal.logging.InternalLogger; +import io.netty.util.internal.logging.InternalLoggerFactory; + +import java.util.List; + +/** + *

Enables SNI + * (Server Name Indication) extension for server side SSL. For clients + * support SNI, the server could have multiple host name bound on a single IP. + * The client will send host name in the handshake data so server could decide + * which certificate to choose for the host name.

+ */ +public class SniHandler extends ByteToMessageDecoder { + + private static final InternalLogger logger = + InternalLoggerFactory.getInstance(SniHandler.class); + + private final DomainNameMapping mapping; + private String hostname; + private boolean handshaken; + private SslContext defaultContext; + private SslContext selectedContext; + + /** + * Create a SNI detection handler with configured {@link SslContext} + * maintained by {@link DomainNameMapping} + * + * @param mapping the mapping of domain name to {@link SslContext} + */ + public SniHandler(DomainNameMapping mapping) { + if (mapping == null) { + throw new NullPointerException("mapping"); + } + + this.mapping = mapping; + handshaken = false; + } + + /** + * @return the selected hostname + */ + public String hostname() { + return hostname; + } + + /** + * @return the selected sslcontext + */ + public SslContext sslContext() { + return selectedContext; + } + + @Override + protected void decode(ChannelHandlerContext ctx, ByteBuf in, List out) throws Exception { + if (!handshaken && in.readableBytes() >= 5) { + String hostname = sniHostNameFromHandshakeInfo(in); + + if (hostname != null) { + if (logger.isDebugEnabled()) { + logger.debug("Using hostname: {}", hostname); + } + + // toASCII conversion and case normalization + this.hostname = DomainNameMapping.normalizeHostname(hostname); + } + + // the mapping will return default context when this.hostname is null + selectedContext = mapping.map(this.hostname); + } + + if (handshaken) { + SslHandler sslHandler = selectedContext.newHandler(ctx.alloc()); + ctx.pipeline().replace(this, SslHandler.class.getName(), sslHandler); + } + } + + private String sniHostNameFromHandshakeInfo(ByteBuf in) { + int readerIndex = in.readerIndex(); + try { + int command = in.getUnsignedByte(readerIndex); + + // tls, but not handshake command + switch (command) { + case SslConstants.SSL_CONTENT_TYPE_CHANGE_CIPHER_SPEC: + case SslConstants.SSL_CONTENT_TYPE_ALERT: + case SslConstants.SSL_CONTENT_TYPE_APPLICATION_DATA: + return null; + case SslConstants.SSL_CONTENT_TYPE_HANDSHAKE: + break; + default: + //not tls or sslv3, do not try sni + handshaken = true; + return null; + } + + int majorVersion = in.getUnsignedByte(readerIndex + 1); + + // SSLv3 or TLS + if (majorVersion == 3) { + + int packetLength = in.getUnsignedShort(readerIndex + 3) + 5; + + if (in.readableBytes() >= packetLength) { + // decode the ssl client hello packet + // we have to skip some var-length fields + int offset = readerIndex + 43; + + int sessionIdLength = in.getUnsignedByte(offset); + offset += sessionIdLength + 1; + + int cipherSuitesLength = in.getUnsignedShort(offset); + offset += cipherSuitesLength + 2; + + int compressionMethodLength = in.getUnsignedByte(offset); + offset += compressionMethodLength + 1; + + int extensionsLength = in.getUnsignedShort(offset); + offset += 2; + int extensionsLimit = offset + extensionsLength; + + while (offset < extensionsLimit) { + int extensionType = in.getUnsignedShort(offset); + offset += 2; + + int extensionLength = in.getUnsignedShort(offset); + offset += 2; + + // SNI + if (extensionType == 0) { + handshaken = true; + int serverNameType = in.getUnsignedByte(offset + 2); + if (serverNameType == 0) { + int serverNameLength = in.getUnsignedShort(offset + 3); + return in.toString(offset + 5, serverNameLength, + CharsetUtil.UTF_8); + } else { + // invalid enum value + return null; + } + } + + offset += extensionLength; + } + + handshaken = true; + return null; + } else { + // client hello incomplete + return null; + } + } else { + handshaken = true; + return null; + } + } catch (Throwable e) { + // unexpected encoding, ignore sni and use default + if (logger.isDebugEnabled()) { + logger.debug("Unexpected client hello packet: " + ByteBufUtil.hexDump(in), e); + } + handshaken = true; + return null; + } + } +} diff --git a/handler/src/main/java/io/netty/handler/ssl/SslConstants.java b/handler/src/main/java/io/netty/handler/ssl/SslConstants.java new file mode 100644 index 0000000000..ec93859869 --- /dev/null +++ b/handler/src/main/java/io/netty/handler/ssl/SslConstants.java @@ -0,0 +1,45 @@ +/* + * Copyright 2014 The Netty Project + * + * The Netty Project 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 io.netty.handler.ssl; + +/** + * Constants for SSL packets. + */ +final class SslConstants { + + /** + * change cipher spec + */ + public static final int SSL_CONTENT_TYPE_CHANGE_CIPHER_SPEC = 20; + + /** + * alert + */ + public static final int SSL_CONTENT_TYPE_ALERT = 21; + + /** + * handshake + */ + public static final int SSL_CONTENT_TYPE_HANDSHAKE = 22; + + /** + * application data + */ + public static final int SSL_CONTENT_TYPE_APPLICATION_DATA = 23; + + private SslConstants() { + } +} diff --git a/handler/src/test/java/io/netty/handler/ssl/SniHandlerTest.java b/handler/src/test/java/io/netty/handler/ssl/SniHandlerTest.java new file mode 100644 index 0000000000..0ed84d111b --- /dev/null +++ b/handler/src/test/java/io/netty/handler/ssl/SniHandlerTest.java @@ -0,0 +1,120 @@ +/* + * Copyright 2014 The Netty Project + * + * The Netty Project 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 io.netty.handler.ssl; + +import io.netty.buffer.Unpooled; +import io.netty.channel.embedded.EmbeddedChannel; +import io.netty.handler.codec.DecoderException; +import org.junit.Test; + +import javax.xml.bind.DatatypeConverter; + +import java.io.File; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.CoreMatchers.nullValue; +import static org.junit.Assert.assertThat; +import static org.junit.Assert.fail; + +public class SniHandlerTest { + + private static SslContext makeSslContext() throws Exception { + File keyFile = new File(SniHandlerTest.class.getResource("test_encrypted.pem").getFile()); + File crtFile = new File(SniHandlerTest.class.getResource("test.crt").getFile()); + + return new JdkSslServerContext(crtFile, keyFile, "12345"); + } + + @Test + public void testServerNameParsing() throws Exception { + SslContext nettyContext = makeSslContext(); + SslContext leanContext = makeSslContext(); + SslContext leanContext2 = makeSslContext(); + + DomainNameMapping mapping = new DomainNameMapping(nettyContext); + mapping.addContext("*.netty.io", nettyContext); + + // input with custom cases + mapping.addContext("*.LEANCLOUD.CN", leanContext); + + // a hostname conflict with previous one, since we are using order-sensitive config, the engine won't + // be used with the handler. + mapping.addContext("chat4.leancloud.cn", leanContext2); + + SniHandler handler = new SniHandler(mapping); + EmbeddedChannel ch = new EmbeddedChannel(handler); + + // hex dump of a client hello packet, which contains hostname "CHAT4。LEANCLOUD。CN" + String tlsHandshakeMessageHex1 = "16030100"; + // part 2 + String tlsHandshakeMessageHex = "bd010000b90303a74225676d1814ba57faff3b366" + + "3656ed05ee9dbb2a4dbb1bb1c32d2ea5fc39e0000000100008c0000001700150000164348" + + "415434E380824C45414E434C4F5544E38082434E000b000403000102000a00340032000e0" + + "00d0019000b000c00180009000a0016001700080006000700140015000400050012001300" + + "0100020003000f0010001100230000000d0020001e0601060206030501050205030401040" + + "20403030103020303020102020203000f00010133740000"; + + try { + // Push the handshake message. + // Decode should fail because SNI error + ch.writeInbound(Unpooled.wrappedBuffer(DatatypeConverter.parseHexBinary(tlsHandshakeMessageHex1))); + ch.writeInbound(Unpooled.wrappedBuffer(DatatypeConverter.parseHexBinary(tlsHandshakeMessageHex))); + fail(); + } catch (DecoderException e) { + // expected + } + + assertThat(ch.finish(), is(false)); + assertThat(handler.hostname(), is("chat4.leancloud.cn")); + assertThat(handler.sslContext(), is(leanContext)); + } + + @Test + public void testFallbackToDefaultContext() throws Exception { + SslContext nettyContext = makeSslContext(); + SslContext leanContext = makeSslContext(); + SslContext leanContext2 = makeSslContext(); + + DomainNameMapping mapping = new DomainNameMapping(nettyContext); + mapping.addContext("*.netty.io", nettyContext); + + // input with custom cases + mapping.addContext("*.LEANCLOUD.CN", leanContext); + + // a hostname conflict with previous one, since we are using order-sensitive config, the engine won't + // be used with the handler. + mapping.addContext("chat4.leancloud.cn", leanContext2); + + SniHandler handler = new SniHandler(mapping); + EmbeddedChannel ch = new EmbeddedChannel(handler); + + // invalid + byte[] message = new byte[] {22, 3, 1, 0, 0}; + + try { + // Push the handshake message. + ch.writeInbound(Unpooled.wrappedBuffer(message)); + } catch (Exception e) { + // expected + } + + assertThat(ch.finish(), is(false)); + assertThat(handler.hostname(), nullValue()); + assertThat(handler.sslContext(), is(nettyContext)); + } + +}