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:
parent
73a0f1dd31
commit
b438ea4758
27
common/src/main/java/io/netty/util/Mapping.java
Normal file
27
common/src/main/java/io/netty/util/Mapping.java
Normal 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);
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
186
handler/src/main/java/io/netty/handler/ssl/SniHandler.java
Normal file
186
handler/src/main/java/io/netty/handler/ssl/SniHandler.java
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
45
handler/src/main/java/io/netty/handler/ssl/SslConstants.java
Normal file
45
handler/src/main/java/io/netty/handler/ssl/SslConstants.java
Normal 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() {
|
||||
}
|
||||
}
|
120
handler/src/test/java/io/netty/handler/ssl/SniHandlerTest.java
Normal file
120
handler/src/test/java/io/netty/handler/ssl/SniHandlerTest.java
Normal 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));
|
||||
}
|
||||
|
||||
}
|
Loading…
Reference in New Issue
Block a user