Skip to content

Commit

Permalink
Http2WebSocketServerBuilder:
Browse files Browse the repository at this point in the history
do not mask outbound frames payload;
by default only allow masked inbound frames;
force allowExtensions on WebSocketDecoderConfig if compression is enabled.

Http2WebSocketClientBuilder:
by default only allow non-masked inbound frames;
force allowExtensions on WebSocketDecoderConfig if compression is enabled.
  • Loading branch information
mostroverkhov committed Jun 1, 2021
1 parent 0ad3b49 commit 87250c2
Show file tree
Hide file tree
Showing 9 changed files with 58 additions and 105 deletions.
25 changes: 13 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,14 +30,14 @@ EchoWebSocketHandler http1WebSocketHandler = new EchoWebSocketHandler();
Http2WebSocketAcceptor.Subprotocol
.accept("echo.jauntsdn.com", response);
return ctx.executor()
.newSucceededFuture(echoWebSocketHandler);
.newSucceededFuture(http1WebSocketHandler);
}
break;
case "/echo_all":
if (subprotocols.isEmpty()
&& acceptUserAgent(request, response)) {
return ctx.executor()
.newSucceededFuture(echoWebSocketHandler);
.newSucceededFuture(http1WebSocketHandler);
}
break;
}
Expand Down Expand Up @@ -82,7 +82,7 @@ EchoWebSocketHandler http1WebSocketHandler = new EchoWebSocketHandler();
Http2WebSocketClientHandshaker handShaker = Http2WebSocketClientHandshaker.create(channel);
Http2Headers headers =
new DefaultHttp2Headers().set("user-agent", "jauntsdn-websocket-http2-client/1.0.1");
new DefaultHttp2Headers().set("user-agent", "jauntsdn-websocket-http2-client/1.1.1");
ChannelFuture handshakeFuture =
/*http1 websocket handler*/
handShaker.handshake("/echo", headers, new EchoWebSocketHandler());
Expand All @@ -98,7 +98,8 @@ Runnable demo is available in `netty-websocket-http2-example` module -

### websocket handshake only API
Intended for intermediaries/proxies.
Only verifies whether http2 stream is valid websocket, then passes it down the pipeline.
Only verifies whether http2 stream is valid websocket, then passes it down the pipeline as `POST` request with `x-protocol=websocket` header.

```groovy
Http2WebSocketServerHandler http2webSocketHandler =
Http2WebSocketServerBuilder.buildHandshakeOnly();
Expand Down Expand Up @@ -159,7 +160,7 @@ EchoWebSocketHandler http1WebsocketHandler = new EchoWebSocketHandler();
ChannelFuture handshake =
handShaker.handshake("/echo", "subprotocol", headers, http1WebsocketHandler);
```
On a server It is responsibility of `Http2WebSocketAcceptor` to select supported protocol with
On a server It is responsibility of `Http2WebSocketAcceptor` to select supported subprotocol with
```groovy
Http2WebSocketAcceptor.Subprotocol.accept(subprotocol, response);
```
Expand Down Expand Up @@ -244,17 +245,17 @@ the results are as follows (measured over time spans of 5 seconds):

### examples

`netty-websocket-http2-example` module contains demos showcasing both API styles,
with this library/browser as clients.
`netty-websocket-http2-example` module contains demos showcasing both API styles, with this library/browser as clients.

* `channelserver, channelclient` packages for websocket subchannel API demos.
* `handshakeserver, channelclient` packages for handshake only API demo.
* `lwsclient` package for client demo that runs against [https://libwebsockets.org/testserver/](https://libwebsockets.org/testserver/) which hosts websocket-over-http2
server implemented with [libwebsockets](https://github.com/warmcat/libwebsockets) - popular C-based networking library.

### browser example
Both example servers have web page at `https://localhost:8099` that sends pings to
`/echo` endpoint.
The only browser with http2 websockets protocol support is `Mozilla Firefox`.
`Channelserver` example serves web page at `https://www.localhost:8099` that sends pings to `/echo` endpoint.

Currently only `Mozilla Firefox` and latest `Google Chrome` support websockets-over-http2.

### build & binaries
```
Expand All @@ -268,7 +269,7 @@ repositories {
}
dependencies {
implementation 'com.jauntsdn.netty:netty-websocket-http2:1.1.0'
implementation 'com.jauntsdn.netty:netty-websocket-http2:1.1.1'
}
```

Expand All @@ -286,4 +287,4 @@ 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.
limitations under the License.
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,10 @@ protected void initChannel(SocketChannel ch) {
Http2WebSocketClientBuilder.create()
.streamWeight(16)
.decoderConfig(
WebSocketDecoderConfig.newBuilder().allowExtensions(true).build())
WebSocketDecoderConfig.newBuilder()
.expectMaskedFrames(false)
.allowMaskMismatch(true)
.build())
.handshakeTimeoutMillis(15_000)
.compression(true)
.build();
Expand All @@ -90,7 +93,7 @@ protected void initChannel(SocketChannel ch) {
Http2WebSocketClientHandshaker handShaker = Http2WebSocketClientHandshaker.create(channel);

Http2Headers headers =
new DefaultHttp2Headers().set("user-agent", "jauntsdn-websocket-http2-client/1.0.1");
new DefaultHttp2Headers().set("user-agent", "jauntsdn-websocket-http2-client/1.1.2");
ChannelFuture handshake =
handShaker.handshake("/echo", "echo.jauntsdn.com", headers, new EchoWebSocketHandler());

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,12 +57,14 @@ public class Main {
public static void main(String[] args) throws Exception {
String host = System.getProperty("HOST", "localhost");
int port = Integer.parseInt(System.getProperty("PORT", "8099"));
String advertiseAddress = System.getProperty("ADVERTISE_ADDRESS", "www.localhost:8099");
String echoPath = System.getProperty("PING", "echo");
String keyStoreFile = System.getProperty("KEYSTORE", "localhost.p12");
String keyStorePassword = System.getProperty("KEYSTORE_PASS", "localhost");

logger.info("\n==> Channel per websocket server\n");
logger.info("\n==> Bind address: {}:{}", host, port);
logger.info("\n==> Advertise address: {}", advertiseAddress);
logger.info("\n==> Keystore file: {}", keyStoreFile);

SslContext sslContext = Security.serverSslContext(keyStoreFile, keyStorePassword);
Expand All @@ -79,14 +81,14 @@ public static void main(String[] args) throws Exception {
logger.info("\n==> Server is listening on {}:{}", host, port);

logger.info("\n==> Echo path: {}", echoPath);
logger.info("\n==> Modern browser (Mozilla Firefox) demo: https://{}:{}", host, port);
logger.info("\n==> Modern browser (Firefox, latest Chrome) demo: https://{}", advertiseAddress);

server.closeFuture().sync();
}

private static class ConnectionAcceptor extends ChannelInitializer<SocketChannel> {
private static final List<String> SUPPORTED_USER_AGENTS =
Arrays.asList("Firefox/", "jauntsdn-websocket-http2-client/");
Arrays.asList("Firefox/", "Chrome/", "jauntsdn-websocket-http2-client/");
private final SslContext sslContext;

ConnectionAcceptor(SslContext sslContext) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,23 +20,21 @@
import com.jauntsdn.netty.handler.codec.http2.websocketx.Http2WebSocketServerBuilder;
import com.jauntsdn.netty.handler.codec.http2.websocketx.example.Security;
import io.netty.bootstrap.ServerBootstrap;
import io.netty.buffer.ByteBuf;
import io.netty.buffer.ByteBufAllocator;
import io.netty.buffer.Unpooled;
import io.netty.channel.*;
import io.netty.channel.Channel;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.ChannelInitializer;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.SocketChannel;
import io.netty.channel.socket.nio.NioServerSocketChannel;
import io.netty.handler.codec.http.QueryStringDecoder;
import io.netty.handler.codec.http2.*;
import io.netty.handler.ssl.*;
import io.netty.handler.ssl.SslContext;
import io.netty.handler.ssl.SslHandler;
import io.netty.util.AsciiString;
import io.netty.util.collection.IntObjectHashMap;
import io.netty.util.collection.IntObjectMap;
import java.io.BufferedInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.concurrent.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

Expand Down Expand Up @@ -68,7 +66,6 @@ public static void main(String[] args) throws Exception {
logger.info("\n==> Server is listening on {}:{}", host, port);

logger.info("\n==> Echo path: {}", echoPath);
logger.info("\n==> Modern browser (Mozilla Firefox) demo: https://{}:{}", host, port);

server.closeFuture().sync();
}
Expand Down Expand Up @@ -112,7 +109,6 @@ private static class Http2StreamsHandler extends Http2ChannelDuplexHandler {
AsciiString.of("echo.jauntsdn.com"));

private final IntObjectMap<Http2FrameStream> echos = new IntObjectHashMap<>();
private final ExecutorService executorService = Executors.newCachedThreadPool();
private int receiveBytes;
private final int receiveWindowBytes;

Expand All @@ -131,14 +127,6 @@ public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception
String query = requestHeaders.get(":path").toString();
String path = new QueryStringDecoder(query).path();

if ("GET".contentEquals(method)) {
if (path.isEmpty() || path.equals("/")) {
path = "index.html";
}
serveResource(ctx, stream, path);
return;
}

if (!"POST".contentEquals(method)) {
ctx.write(new DefaultHttp2HeadersFrame(HEADERS_405, true).stream(stream));
return;
Expand Down Expand Up @@ -215,52 +203,6 @@ public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
ctx.close();
}

private void serveResource(
ChannelHandlerContext ctx, Http2FrameStream frameStream, String resource) {
executorService.execute(
() -> {
String classPathResource = "web/" + resource;
try (InputStream resourceStream =
Main.class.getClassLoader().getResourceAsStream(classPathResource)) {
if (resourceStream == null) {
ctx.write(new DefaultHttp2HeadersFrame(HEADERS_404, true).stream(frameStream));
return;
}
Http2Headers responseHeaders = new DefaultHttp2Headers(true).status("200");

String contentType;
if (resource.endsWith(".html")) {
contentType = "text/html";
} else if (resource.endsWith(".js")) {
contentType = "text/javascript";
} else {
contentType = "application/octet-stream";
}
responseHeaders.set("content-type", contentType);

ctx.write(new DefaultHttp2HeadersFrame(responseHeaders, false).stream(frameStream));

int bufferSize = 16384;
InputStream bufferedResourceStream =
new BufferedInputStream(resourceStream, bufferSize);
byte[] buffer = new byte[bufferSize];
int bytesRead;
/*keep it simple for the demo*/
while ((bytesRead = bufferedResourceStream.read(buffer)) != -1) {
ByteBuf response = ByteBufAllocator.DEFAULT.buffer(bytesRead);
response.writeBytes(buffer, 0, bytesRead);
ctx.write(new DefaultHttp2DataFrame(response, false).stream(frameStream));
}
ctx.writeAndFlush(
new DefaultHttp2DataFrame(Unpooled.EMPTY_BUFFER, true).stream(frameStream));
} catch (Exception e) {
ctx.fireExceptionCaught(
new RuntimeException(
"Error while reading classpath resource: " + classPathResource, e));
}
});
}

@Override
public void channelInactive(ChannelHandlerContext ctx) throws Exception {
echos.clear();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,8 +73,8 @@ protected void initChannel(SocketChannel ch) {
Http2WebSocketClientBuilder.create()
.decoderConfig(
WebSocketDecoderConfig.newBuilder()
.allowExtensions(true)
.allowMaskMismatch(true)
.expectMaskedFrames(false)
.allowMaskMismatch(false)
.build())
.handshakeTimeoutMillis(15_000)
.compression(true)
Expand All @@ -90,7 +90,7 @@ protected void initChannel(SocketChannel ch) {
Http2WebSocketClientHandshaker handShaker = Http2WebSocketClientHandshaker.create(channel);

Http2Headers headers =
new DefaultHttp2Headers().set("user-agent", "jauntsdn-websocket-http2-client/1.0.1");
new DefaultHttp2Headers().set("user-agent", "jauntsdn-websocket-http2-client/1.1.2");
ChannelFuture handshake =
handShaker.handshake(
"/", "dumb-increment-protocol", headers, new WebSocketDumbIncrementHandler());
Expand Down
14 changes: 8 additions & 6 deletions netty-websocket-http2-example/src/main/resources/web/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,22 @@
</head>
<body style="font-family: 'Roboto Slab', sans-serif;">
<div id="header"
style="border-top: 5px solid #2196f3;
min-height: 50px;position: fixed;
style="border-top: 3px solid #2196f3;
min-height: 40px;position: fixed;
border-bottom: 1px solid #e7e7e7;
background-color: #f8f8f8;
background-color: #ffffff;
top: 0;
right: 0;
left: 0;
z-index: 1030;">
<a style="font-size:14px;text-decoration: none;
<a style="font-size:16px;
font-family: Raleway
text-decoration: none;
display:block;color: #777;
padding: 5px;
margin-left: 100px;
height:50px;line-height: 50px;" href="https://jauntsdn.com/post/netty-websocket-http2/">
continue on jauntsdn.com
height:40px;line-height: 40px;" href="https://jauntsdn.com/post/netty-websocket-http2/">
Details on jauntsdn.com
</a>

</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
const address = 'wss://localhost:8099/echo';
const address = 'wss://www.localhost:8099/echo';
const subprotocol = 'echo.jauntsdn.com';

const startMessage = 'start';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -145,12 +145,17 @@ public Http2WebSocketClientHandler build() {
boolean hasCompression = compressionHandshaker != null;
WebSocketDecoderConfig config = webSocketDecoderConfig;
if (config == null) {
config = WebSocketDecoderConfig.newBuilder().allowExtensions(hasCompression).build();
config =
WebSocketDecoderConfig.newBuilder()
/*align with the spec and strictness of some browsers*/
.expectMaskedFrames(false)
.allowMaskMismatch(false)
.allowExtensions(hasCompression)
.build();
} else {
boolean isAllowExtensions = config.allowExtensions();
if (!isAllowExtensions && hasCompression) {
throw new IllegalStateException(
"websocket compression is enabled while extensions are disabled");
config = config.toBuilder().allowExtensions(true).build();
}
}
short weight = streamWeight;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
/** Builder for {@link Http2WebSocketServerHandler} */
public final class Http2WebSocketServerBuilder {
private static final Logger logger = LoggerFactory.getLogger(Http2WebSocketServerBuilder.class);
private static final boolean MASK_PAYLOAD = false;

private static final Http2WebSocketAcceptor REJECT_REQUESTS_ACCEPTOR =
(context, path, subprotocols, request, response) ->
Expand All @@ -40,7 +41,7 @@ public final class Http2WebSocketServerBuilder {
+ subprotocols));

private WebSocketDecoderConfig webSocketDecoderConfig;
private boolean isEncoderMaskPayload = true;

private PerMessageDeflateServerExtensionHandshaker perMessageDeflateServerExtensionHandshaker;
private long closedWebSocketRemoveTimeoutMillis = 30_000;
private boolean isSingleWebSocketPerConnection;
Expand Down Expand Up @@ -98,14 +99,6 @@ public Http2WebSocketServerBuilder decoderConfig(WebSocketDecoderConfig webSocke
Preconditions.requireNonNull(webSocketDecoderConfig, "webSocketDecoderConfig");
return this;
}
/**
* @param isEncoderMaskPayload enables websocket frames encoder payload masking
* @return this {@link Http2WebSocketServerBuilder} instance
*/
public Http2WebSocketServerBuilder encoderMaskPayload(boolean isEncoderMaskPayload) {
this.isEncoderMaskPayload = isEncoderMaskPayload;
return this;
}

/**
* @param closedWebSocketRemoveTimeoutMillis delay until websockets handler forgets closed
Expand Down Expand Up @@ -199,17 +192,22 @@ public Http2WebSocketServerHandler build() {
boolean hasCompression = perMessageDeflateServerExtensionHandshaker != null;
WebSocketDecoderConfig config = webSocketDecoderConfig;
if (config == null) {
config = WebSocketDecoderConfig.newBuilder().allowExtensions(hasCompression).build();
config =
WebSocketDecoderConfig.newBuilder()
/*align with the spec and strictness of some browsers*/
.expectMaskedFrames(true)
.allowMaskMismatch(false)
.allowExtensions(hasCompression)
.build();
} else {
boolean isAllowExtensions = config.allowExtensions();
if (!isAllowExtensions && hasCompression) {
throw new IllegalStateException(
"websocket compression is enabled while extensions are disabled");
config = config.toBuilder().allowExtensions(true).build();
}
}
return new Http2WebSocketServerHandler(
config,
isEncoderMaskPayload,
MASK_PAYLOAD,
closedWebSocketRemoveTimeoutMillis,
perMessageDeflateServerExtensionHandshaker,
acceptor,
Expand Down

0 comments on commit 87250c2

Please sign in to comment.