Added support for SSL Server Name Indication.

Motivation:

When we need to host multiple server name with a single IP, it requires
the server to support Server Name Indication extension to serve clients
with proper certificate. So the SniHandler will host multiple
SslContext(s) and append SslHandler for requested hostname.

Modification:

* Added SniHandler to host multiple certifications in a single server
* Test case

Result:

User could use SniHandler to host multiple certifcates at a time.
It's server-side only.
This commit is contained in:
Sun Ning 2014-11-18 22:38:50 +08:00 committed by Norman Maurer
parent a5b7169eb4
commit bd63697687
5 changed files with 504 additions and 0 deletions

View File

@ -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<IN, OUT> {
/**
* Returns mapped value of input.
*/
OUT map(IN input);
}

View File

@ -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;
/**
* <p>This class maps a domain name to a configured {@link SslContext}.</p>
*
* <p>DNS wildcard is supported as hostname, so you can use {@code *.netty.io} to match both {@code netty.io}
* and {@code downloads.netty.io}.</p>
*/
public class DomainNameMapping implements Mapping<String, SslContext> {
private static final InternalLogger logger =
InternalLoggerFactory.getInstance(DomainNameMapping.class);
private static final Pattern DNS_WILDCARD_PATTERN = Pattern.compile("^\\*\\..*");
private final Map<String, SslContext> 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<String, SslContext>(initialCapacity);
this.defaultContext = defaultContext;
}
/**
* Add a {@link SslContext} to the handler.
*
* <a href="http://en.wikipedia.org/wiki/Wildcard_DNS_record">DNS wildcard</a> 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;
}
/**
* <p>Simple function to match <a href="http://en.wikipedia.org/wiki/Wildcard_DNS_record">DNS wildcard</a>.
* </p>
*/
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<String, SslContext> entry : userProvidedContexts.entrySet()) {
if (matches(entry.getKey(), hostname)) {
return entry.getValue();
}
}
}
if (logger.isDebugEnabled()) {
logger.debug("Using default SslContext");
}
return defaultContext;
}
}

View File

@ -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;
/**
* <p>Enables <a href="https://tools.ietf.org/html/rfc3546#section-3.1">SNI
* (Server Name Indication)</a> 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. </p>
*/
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<Object> 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;
}
}
}

View File

@ -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() {
}
}

View File

@ -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));
}
}