From b5bc00d2f43aa8ee413acd1d85ef276bccfd7eba Mon Sep 17 00:00:00 2001 From: Vadim Date: Mon, 4 Nov 2024 19:24:16 +0500 Subject: [PATCH] websocket protocol support (#73) * websocket protocol support * echo server client * added copyright header to all new files --------- Co-authored-by: Vadim Yelisseyev --- src/one/nio/http/Response.java | 1 + src/one/nio/ws/WebSocketHeaders.java | 74 ++++++ src/one/nio/ws/WebSocketServer.java | 76 ++++++ src/one/nio/ws/WebSocketServerConfig.java | 39 +++ src/one/nio/ws/WebSocketSession.java | 244 ++++++++++++++++++ .../ws/exception/CannotAcceptException.java | 29 +++ .../nio/ws/exception/HandshakeException.java | 27 ++ .../nio/ws/exception/ProtocolException.java | 29 +++ src/one/nio/ws/exception/TooBigException.java | 29 +++ .../nio/ws/exception/VersionException.java | 27 ++ .../nio/ws/exception/WebSocketException.java | 35 +++ src/one/nio/ws/extension/Extension.java | 35 +++ .../nio/ws/extension/ExtensionRequest.java | 46 ++++ .../ws/extension/ExtensionRequestParser.java | 99 +++++++ .../nio/ws/extension/PerMessageDeflate.java | 231 +++++++++++++++++ src/one/nio/ws/frame/Frame.java | 105 ++++++++ src/one/nio/ws/frame/FrameReader.java | 157 +++++++++++ src/one/nio/ws/frame/FrameWriter.java | 62 +++++ src/one/nio/ws/frame/Opcode.java | 58 +++++ src/one/nio/ws/message/BinaryMessage.java | 45 ++++ src/one/nio/ws/message/CloseMessage.java | 65 +++++ src/one/nio/ws/message/Message.java | 38 +++ src/one/nio/ws/message/MessageReader.java | 143 ++++++++++ src/one/nio/ws/message/MessageWriter.java | 46 ++++ src/one/nio/ws/message/PingMessage.java | 31 +++ src/one/nio/ws/message/PongMessage.java | 32 +++ src/one/nio/ws/message/TextMessage.java | 41 +++ test/one/nio/ws/EchoServerTest.java | 51 ++++ test/one/nio/ws/ExtensionRequestTest.java | 44 ++++ test/one/nio/ws/echo.html | 87 +++++++ 30 files changed, 2026 insertions(+) create mode 100644 src/one/nio/ws/WebSocketHeaders.java create mode 100644 src/one/nio/ws/WebSocketServer.java create mode 100644 src/one/nio/ws/WebSocketServerConfig.java create mode 100644 src/one/nio/ws/WebSocketSession.java create mode 100644 src/one/nio/ws/exception/CannotAcceptException.java create mode 100644 src/one/nio/ws/exception/HandshakeException.java create mode 100644 src/one/nio/ws/exception/ProtocolException.java create mode 100644 src/one/nio/ws/exception/TooBigException.java create mode 100644 src/one/nio/ws/exception/VersionException.java create mode 100644 src/one/nio/ws/exception/WebSocketException.java create mode 100644 src/one/nio/ws/extension/Extension.java create mode 100644 src/one/nio/ws/extension/ExtensionRequest.java create mode 100644 src/one/nio/ws/extension/ExtensionRequestParser.java create mode 100644 src/one/nio/ws/extension/PerMessageDeflate.java create mode 100644 src/one/nio/ws/frame/Frame.java create mode 100644 src/one/nio/ws/frame/FrameReader.java create mode 100644 src/one/nio/ws/frame/FrameWriter.java create mode 100644 src/one/nio/ws/frame/Opcode.java create mode 100644 src/one/nio/ws/message/BinaryMessage.java create mode 100644 src/one/nio/ws/message/CloseMessage.java create mode 100644 src/one/nio/ws/message/Message.java create mode 100644 src/one/nio/ws/message/MessageReader.java create mode 100644 src/one/nio/ws/message/MessageWriter.java create mode 100644 src/one/nio/ws/message/PingMessage.java create mode 100644 src/one/nio/ws/message/PongMessage.java create mode 100644 src/one/nio/ws/message/TextMessage.java create mode 100644 test/one/nio/ws/EchoServerTest.java create mode 100644 test/one/nio/ws/ExtensionRequestTest.java create mode 100644 test/one/nio/ws/echo.html diff --git a/src/one/nio/http/Response.java b/src/one/nio/http/Response.java index b148023..99a1624 100755 --- a/src/one/nio/http/Response.java +++ b/src/one/nio/http/Response.java @@ -59,6 +59,7 @@ public class Response { public static final String UNSUPPORTED_MEDIA_TYPE = "415 Unsupported Media Type"; public static final String REQUESTED_RANGE_NOT_SATISFIABLE = "416 Requested Range Not Satisfiable"; public static final String EXPECTATION_FAILED = "417 Expectation Failed"; + public static final String UPGRADE_REQUIRED = "426 Upgrade Required"; public static final String INTERNAL_ERROR = "500 Internal Server Error"; public static final String NOT_IMPLEMENTED = "501 Not Implemented"; public static final String BAD_GATEWAY = "502 Bad Gateway"; diff --git a/src/one/nio/ws/WebSocketHeaders.java b/src/one/nio/ws/WebSocketHeaders.java new file mode 100644 index 0000000..4c84ac4 --- /dev/null +++ b/src/one/nio/ws/WebSocketHeaders.java @@ -0,0 +1,74 @@ +/* + * Copyright 2015 Odnoklassniki Ltd, Mail.Ru Group + * + * Licensed 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 one.nio.ws; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; + +import one.nio.http.Request; +import one.nio.util.Base64; + +/** + * @author Vadim Yelisseyev + */ +public class WebSocketHeaders { + public final static String CONNECTION = "Connection: "; + public final static String UPGRADE = "Upgrade: "; + public final static String KEY = "Sec-WebSocket-Key: "; + public final static String VERSION = "Sec-WebSocket-Version: "; + public final static String ACCEPT = "Sec-WebSocket-Accept: "; + public final static String EXTENSIONS = "Sec-WebSocket-Extensions: "; + public final static String PROTOCOL = "Sec-WebSocket-Protocol: "; + + private static final String ACCEPT_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; + private static final ThreadLocal SHA1 = ThreadLocal.withInitial(() -> { + try { + return MessageDigest.getInstance("SHA1"); + } catch (NoSuchAlgorithmException e) { + throw new InternalError("SHA-1 not supported on this platform"); + } + }); + + public static boolean isUpgradableRequest(Request request) { + final String upgradeHeader = request.getHeader(WebSocketHeaders.UPGRADE); + final String connectionHeader = request.getHeader(WebSocketHeaders.CONNECTION); + return upgradeHeader != null && upgradeHeader.toLowerCase().contains("websocket") && + connectionHeader != null && connectionHeader.toLowerCase().contains("upgrade"); + } + + public static String createVersionHeader(String version) { + return VERSION + version; + } + + public static String createAcceptHeader(Request request) { + return ACCEPT + generateHash(request); + } + + private static String generateHash(Request request) { + String key = request.getHeader(WebSocketHeaders.KEY); + String acceptSeed = key + ACCEPT_GUID; + byte[] sha1 = sha1(acceptSeed.getBytes(StandardCharsets.ISO_8859_1)); + return new String(Base64.encode(sha1)); + } + + private static byte[] sha1(byte[] data) { + MessageDigest digest = SHA1.get(); + digest.reset(); + return digest.digest(data); + } +} diff --git a/src/one/nio/ws/WebSocketServer.java b/src/one/nio/ws/WebSocketServer.java new file mode 100644 index 0000000..334753a --- /dev/null +++ b/src/one/nio/ws/WebSocketServer.java @@ -0,0 +1,76 @@ +/* + * Copyright 2015 Odnoklassniki Ltd, Mail.Ru Group + * + * Licensed 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 one.nio.ws; + +import java.io.IOException; + +import one.nio.http.HttpServer; +import one.nio.http.HttpSession; +import one.nio.http.Request; +import one.nio.net.Socket; +import one.nio.ws.message.BinaryMessage; +import one.nio.ws.message.CloseMessage; +import one.nio.ws.message.PingMessage; +import one.nio.ws.message.PongMessage; +import one.nio.ws.message.TextMessage; + +/** + * @author Vadim Yelisseyev + */ +public class WebSocketServer extends HttpServer { + private final WebSocketServerConfig config; + + public WebSocketServer(WebSocketServerConfig config, Object... routers) throws IOException { + super(config, routers); + this.config = config; + } + + @Override + public WebSocketSession createSession(Socket socket) { + return new WebSocketSession(socket, this, config); + } + + @Override + public void handleRequest(Request request, HttpSession session) throws IOException { + if (config.isWebSocketURI(request.getURI())) { + ((WebSocketSession) session).handshake(request); + return; + } + + super.handleRequest(request, session); + } + + public void handleMessage(WebSocketSession session, PingMessage message) throws IOException { + session.sendMessage(PongMessage.EMPTY); + } + + public void handleMessage(WebSocketSession session, PongMessage message) throws IOException { + // nothing by default + } + + public void handleMessage(WebSocketSession session, TextMessage message) throws IOException { + // nothing by default + } + + public void handleMessage(WebSocketSession session, BinaryMessage message) throws IOException { + // nothing by default + } + + public void handleMessage(WebSocketSession session, CloseMessage message) throws IOException { + session.close(CloseMessage.NORMAL); + } +} diff --git a/src/one/nio/ws/WebSocketServerConfig.java b/src/one/nio/ws/WebSocketServerConfig.java new file mode 100644 index 0000000..4a27739 --- /dev/null +++ b/src/one/nio/ws/WebSocketServerConfig.java @@ -0,0 +1,39 @@ +/* + * Copyright 2015 Odnoklassniki Ltd, Mail.Ru Group + * + * Licensed 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 one.nio.ws; + +import java.util.Collections; +import java.util.Objects; +import java.util.Set; + +import one.nio.http.HttpServerConfig; + +/** + * @author Vadim Yelisseyev + */ +public class WebSocketServerConfig extends HttpServerConfig { + public String websocketBaseUri = "/"; + public Set supportedProtocols = Collections.emptySet(); + + public boolean isWebSocketURI(String uri) { + return Objects.equals(websocketBaseUri, uri); + } + + public boolean isSupportedProtocol(String protocol) { + return supportedProtocols.contains(protocol); + } +} diff --git a/src/one/nio/ws/WebSocketSession.java b/src/one/nio/ws/WebSocketSession.java new file mode 100644 index 0000000..f907eec --- /dev/null +++ b/src/one/nio/ws/WebSocketSession.java @@ -0,0 +1,244 @@ +/* + * Copyright 2015 Odnoklassniki Ltd, Mail.Ru Group + * + * Licensed 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 one.nio.ws; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +import one.nio.http.HttpSession; +import one.nio.http.Request; +import one.nio.http.Response; +import one.nio.net.Socket; +import one.nio.ws.exception.HandshakeException; +import one.nio.ws.exception.VersionException; +import one.nio.ws.exception.WebSocketException; +import one.nio.ws.extension.Extension; +import one.nio.ws.extension.ExtensionRequest; +import one.nio.ws.extension.ExtensionRequestParser; +import one.nio.ws.extension.PerMessageDeflate; +import one.nio.ws.message.BinaryMessage; +import one.nio.ws.message.CloseMessage; +import one.nio.ws.message.Message; +import one.nio.ws.message.MessageReader; +import one.nio.ws.message.MessageWriter; +import one.nio.ws.message.PingMessage; +import one.nio.ws.message.PongMessage; +import one.nio.ws.message.TextMessage; + +/** + * @author Vadim Yelisseyev + */ +public class WebSocketSession extends HttpSession { + public final static String VERSION_13 = "13"; + + private final WebSocketServer server; + private final WebSocketServerConfig config; + private final List extensions; + + private MessageReader reader; + private MessageWriter writer; + + public WebSocketSession(Socket socket, WebSocketServer server, WebSocketServerConfig config) { + super(socket, server); + this.server = server; + this.config = config; + this.extensions = new ArrayList<>(); + } + + @Override + public int checkStatus(long currentTime, long keepAlive) { + if (currentTime - lastAccessTime < keepAlive) { + return ACTIVE; + } + + try { + if (wasSelected) { + sendMessage(PingMessage.EMPTY); + } + + return ACTIVE; + } catch (IOException e) { + return STALE; + } + } + + @Override + protected void processRead(byte[] buffer) throws IOException { + if (reader == null) { + super.processRead(buffer); + } else { + final Message message = reader.read(); + if (message != null) { + handleMessage(this, message); + } + } + } + + public void handshake(Request request) throws IOException { + try { + validateRequest(request); + Response response = createResponse(request); + reader = new MessageReader(this, extensions); + writer = new MessageWriter(this, extensions); + sendResponse(response); + } catch (VersionException e) { + log.debug("Unsupported version", e); + Response response = new Response(Response.UPGRADE_REQUIRED, Response.EMPTY); + response.addHeader(WebSocketHeaders.createVersionHeader(VERSION_13)); + sendResponse(response); + } catch (HandshakeException e) { + log.debug("Handshake error", e); + sendError(Response.BAD_REQUEST, e.getMessage()); + } + } + + public void sendMessage(Message message) throws IOException { + if (writer == null) { + throw new IllegalStateException("websocket message was sent before handshake"); + } + writer.write(message); + } + + protected void handleMessage(WebSocketSession session, Message message) throws IOException { + switch (message.opcode()) { + case PING: + server.handleMessage(session, (PingMessage) message); + break; + case PONG: + server.handleMessage(session, (PongMessage) message); + break; + case TEXT: + server.handleMessage(session, (TextMessage) message); + break; + case BINARY: + server.handleMessage(session, (BinaryMessage) message); + break; + case CLOSE: + server.handleMessage(session, (CloseMessage) message); + break; + default: + throw new IllegalArgumentException("unexpected message with opcode: " + message.opcode()); + } + } + + @Override + public void handleException(Throwable e) { + if (e instanceof WebSocketException) { + log.error("Cannot process session from {}", getRemoteHost(), e); + close(((WebSocketException) e).code()); + return; + } + super.handleException(e); + } + + public void close(short code) { + try { + sendMessage(new CloseMessage(code)); + } catch (Exception e) { + log.warn("error while sending closing frame", e); + } finally { + close(); + } + } + + @Override + public void close() { + closeExtensions(); + super.close(); + } + + protected void validateRequest(Request request) { + final String version = request.getHeader(WebSocketHeaders.VERSION); + if (!VERSION_13.equals(version)) { + throw new VersionException(version); + } + if (request.getMethod() != Request.METHOD_GET) { + throw new HandshakeException("only GET method supported"); + } + if (request.getHeader(WebSocketHeaders.KEY) == null) { + throw new HandshakeException("missing websocket key"); + } + if (!WebSocketHeaders.isUpgradableRequest(request)) { + throw new HandshakeException("missing upgrade header"); + } + } + + protected Response createResponse(Request request) { + Response response = new Response(Response.SWITCHING_PROTOCOLS, Response.EMPTY); + response.addHeader("Upgrade: websocket"); + response.addHeader("Connection: Upgrade"); + response.addHeader(WebSocketHeaders.createAcceptHeader(request)); + processExtensions(request, response); + processProtocol(request, response); + return response; + } + + protected void processProtocol(Request request, Response response) { + final String protocols = request.getHeader(WebSocketHeaders.PROTOCOL); + if (protocols != null) { + for (String protocol : protocols.split(",")) { + if (config.isSupportedProtocol(protocol)) { + response.addHeader(WebSocketHeaders.PROTOCOL + protocol); + break; + } + } + } + } + + protected void processExtensions(Request request, Response response) { + final String extensionsHeader = request.getHeader(WebSocketHeaders.EXTENSIONS); + if (extensionsHeader == null || extensionsHeader.isEmpty()) { + return; + } + final List extensionRequests = ExtensionRequestParser.parse(extensionsHeader); + if (extensionRequests.isEmpty()) { + return; + } + final StringBuilder responseHeaderBuilder = new StringBuilder(WebSocketHeaders.EXTENSIONS); + for (ExtensionRequest extensionRequest : extensionRequests) { + Extension extension = createExtension(extensionRequest); + if (extension != null) { + extensions.add(extension); + if (extensions.size() > 1) { + responseHeaderBuilder.append(','); + } + extension.appendResponseHeaderValue(responseHeaderBuilder); + } + } + if (!extensions.isEmpty()) { + response.addHeader(responseHeaderBuilder.toString()); + } + } + + protected Extension createExtension(ExtensionRequest request) { + if (PerMessageDeflate.NAME.equals(request.getName())) { + return PerMessageDeflate.negotiate(request.getParameters()); + } + return null; + } + + private void closeExtensions() { + for (Extension extension : extensions) { + try { + extension.close(); + } catch (Exception e) { + log.warn("error while closing extension", e); + } + } + } +} diff --git a/src/one/nio/ws/exception/CannotAcceptException.java b/src/one/nio/ws/exception/CannotAcceptException.java new file mode 100644 index 0000000..7ac7abc --- /dev/null +++ b/src/one/nio/ws/exception/CannotAcceptException.java @@ -0,0 +1,29 @@ +/* + * Copyright 2015 Odnoklassniki Ltd, Mail.Ru Group + * + * Licensed 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 one.nio.ws.exception; + +import one.nio.ws.message.CloseMessage; + +/** + * @author Vadim Yelisseyev + */ +public class CannotAcceptException extends WebSocketException { + + public CannotAcceptException(String message) { + super(CloseMessage.CANNOT_ACCEPT, message); + } +} diff --git a/src/one/nio/ws/exception/HandshakeException.java b/src/one/nio/ws/exception/HandshakeException.java new file mode 100644 index 0000000..ea59b32 --- /dev/null +++ b/src/one/nio/ws/exception/HandshakeException.java @@ -0,0 +1,27 @@ +/* + * Copyright 2015 Odnoklassniki Ltd, Mail.Ru Group + * + * Licensed 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 one.nio.ws.exception; + +/** + * @author Vadim Yelisseyev + */ +public class HandshakeException extends IllegalArgumentException { + + public HandshakeException(String s) { + super(s); + } +} diff --git a/src/one/nio/ws/exception/ProtocolException.java b/src/one/nio/ws/exception/ProtocolException.java new file mode 100644 index 0000000..e0a392b --- /dev/null +++ b/src/one/nio/ws/exception/ProtocolException.java @@ -0,0 +1,29 @@ +/* + * Copyright 2015 Odnoklassniki Ltd, Mail.Ru Group + * + * Licensed 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 one.nio.ws.exception; + +import one.nio.ws.message.CloseMessage; + +/** + * @author Vadim Yelisseyev + */ +public class ProtocolException extends WebSocketException { + + public ProtocolException(String message) { + super(CloseMessage.PROTOCOL_ERROR, message); + } +} diff --git a/src/one/nio/ws/exception/TooBigException.java b/src/one/nio/ws/exception/TooBigException.java new file mode 100644 index 0000000..7da198e --- /dev/null +++ b/src/one/nio/ws/exception/TooBigException.java @@ -0,0 +1,29 @@ +/* + * Copyright 2015 Odnoklassniki Ltd, Mail.Ru Group + * + * Licensed 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 one.nio.ws.exception; + +import one.nio.ws.message.CloseMessage; + +/** + * @author Vadim Yelisseyev + */ +public class TooBigException extends WebSocketException { + + public TooBigException(String message) { + super(CloseMessage.TOO_BIG, message); + } +} diff --git a/src/one/nio/ws/exception/VersionException.java b/src/one/nio/ws/exception/VersionException.java new file mode 100644 index 0000000..6f37f39 --- /dev/null +++ b/src/one/nio/ws/exception/VersionException.java @@ -0,0 +1,27 @@ +/* + * Copyright 2015 Odnoklassniki Ltd, Mail.Ru Group + * + * Licensed 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 one.nio.ws.exception; + +/** + * @author Vadim Yelisseyev + */ +public class VersionException extends HandshakeException { + + public VersionException(String version) { + super("Unsupported websocket version " + version); + } +} diff --git a/src/one/nio/ws/exception/WebSocketException.java b/src/one/nio/ws/exception/WebSocketException.java new file mode 100644 index 0000000..e8e9007 --- /dev/null +++ b/src/one/nio/ws/exception/WebSocketException.java @@ -0,0 +1,35 @@ +/* + * Copyright 2015 Odnoklassniki Ltd, Mail.Ru Group + * + * Licensed 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 one.nio.ws.exception; + +import java.io.IOException; + +/** + * @author Vadim Yelisseyev + */ +public class WebSocketException extends IOException { + private final short code; + + public WebSocketException(short code, String message) { + super(message); + this.code = code; + } + + public short code() { + return code; + } +} diff --git a/src/one/nio/ws/extension/Extension.java b/src/one/nio/ws/extension/Extension.java new file mode 100644 index 0000000..9ddf15d --- /dev/null +++ b/src/one/nio/ws/extension/Extension.java @@ -0,0 +1,35 @@ +/* + * Copyright 2015 Odnoklassniki Ltd, Mail.Ru Group + * + * Licensed 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 one.nio.ws.extension; + +import java.io.IOException; + +import one.nio.ws.frame.Frame; + +/** + * @author Vadim Yelisseyev + */ +public interface Extension { + + void appendResponseHeaderValue(StringBuilder builder); + + void transformInput(Frame frame) throws IOException; + + void transformOutput(Frame frame) throws IOException; + + void close(); +} diff --git a/src/one/nio/ws/extension/ExtensionRequest.java b/src/one/nio/ws/extension/ExtensionRequest.java new file mode 100644 index 0000000..99672ac --- /dev/null +++ b/src/one/nio/ws/extension/ExtensionRequest.java @@ -0,0 +1,46 @@ +/* + * Copyright 2015 Odnoklassniki Ltd, Mail.Ru Group + * + * Licensed 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 one.nio.ws.extension; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +/** + * @author Vadim Yelisseyev + */ +public class ExtensionRequest { + private final String name; + private final Map parameters; + + public ExtensionRequest(String name) { + this.name = name; + this.parameters = new HashMap<>(); + } + + public String getName() { + return name; + } + + public void addParameter(String name, String value) { + this.parameters.put(name, value); + } + + public Map getParameters() { + return Collections.unmodifiableMap(parameters); + } +} diff --git a/src/one/nio/ws/extension/ExtensionRequestParser.java b/src/one/nio/ws/extension/ExtensionRequestParser.java new file mode 100644 index 0000000..3aa95aa --- /dev/null +++ b/src/one/nio/ws/extension/ExtensionRequestParser.java @@ -0,0 +1,99 @@ +/* + * Copyright 2015 Odnoklassniki Ltd, Mail.Ru Group + * + * Licensed 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 one.nio.ws.extension; + +import java.util.ArrayList; +import java.util.List; + +/** + * The relevant ABNF for the Sec-WebSocket-Extensions is as follows: + * extension-list = 1#extension + * extension = extension-token *( ";" extension-param ) + * extension-token = registered-token + * registered-token = token + * extension-param = token [ "=" (token | quoted-string) ] + * ; When using the quoted-string syntax variant, the value + * ; after quoted-string unescaping MUST conform to the + * ; 'token' ABNF. + * The limiting of parameter values to tokens or "quoted tokens" makes + * the parsing of the header significantly simpler and allows a number + * of short-cuts to be taken. + * + * @author Vadim Yelisseyev + */ +public class ExtensionRequestParser { + + public static List parse(String header) { + final List result = new ArrayList<>(); + + // split the header into array of extensions using ',' as a separator + for (String unparsedExtension : header.split(",")) { + // split the extension into the registered name and parameter/value pairs + final String[] unparsedParameters = unparsedExtension.split(";"); + final ExtensionRequest request = new ExtensionRequest(unparsedParameters[0].trim()); + + for (int i = 1; i < unparsedParameters.length; i++) { + int equalsPos = unparsedParameters[i].indexOf('='); + String name; + String value; + if (equalsPos == -1) { + name = unparsedParameters[i].trim(); + value = null; + } else { + name = unparsedParameters[i].substring(0, equalsPos).trim(); + value = unparsedParameters[i].substring(equalsPos + 1).trim(); + int len = value.length(); + if (len > 1) { + if (value.charAt(0) == '\"' && value.charAt(len - 1) == '\"') { + value = value.substring(1, value.length() - 1); + } + } + } + + // Make sure value doesn't contain any of the delimiters since that would indicate something went wrong + if (containsDelims(name) || containsDelims(value)) { + throw new IllegalArgumentException("An illegal extension parameter was specified with name [" + name + "] and value [" + value + "]"); + } + + request.addParameter(name, value); + } + + result.add(request); + } + + return result; + } + + private static boolean containsDelims(String input) { + if (input == null || input.isEmpty()) { + return false; + } + for (int i = 0; i < input.length(); i++) { + switch (input.charAt(i)) { + case ',': + case ';': + case '\"': + case '=': + return true; + default: + // NO_OP + } + } + return false; + } + +} diff --git a/src/one/nio/ws/extension/PerMessageDeflate.java b/src/one/nio/ws/extension/PerMessageDeflate.java new file mode 100644 index 0000000..db3087c --- /dev/null +++ b/src/one/nio/ws/extension/PerMessageDeflate.java @@ -0,0 +1,231 @@ +/* + * Copyright 2015 Odnoklassniki Ltd, Mail.Ru Group + * + * Licensed 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 one.nio.ws.extension; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.Arrays; +import java.util.Map; +import java.util.zip.DataFormatException; +import java.util.zip.Deflater; +import java.util.zip.Inflater; + +import one.nio.ws.exception.HandshakeException; +import one.nio.ws.frame.Frame; + +/** + * @author Vadim Yelisseyev + */ +public class PerMessageDeflate implements Extension { + private static final String SERVER_NO_CONTEXT_TAKEOVER = "server_no_context_takeover"; + private static final String CLIENT_NO_CONTEXT_TAKEOVER = "client_no_context_takeover"; + + // according to rfc7692 4 octets of 0x00 0x00 0xff 0xff must be at the tail end of the payload of the message + // https://datatracker.ietf.org/doc/html/rfc7692#section-7.2.2 + private static final byte[] EOM_BYTES = new byte[] {0, 0, -1, -1}; + // the deflate extension requires the RSV1 bit, so RSV1 is 4 (0b100) + private static final int RSV_BITMASK = 0b100; + + private static final int INPUT_BUFFER_SIZE = Integer.getInteger("one.nio.ws.permessage-deflate.INPUT_BUFFER_SIZE", 2048); + private static final int OUTPUT_BUFFER_SIZE = Integer.getInteger("one.nio.ws.permessage-deflate.OUTPUT_BUFFER_SIZE", 2048); + + public static final String NAME = "permessage-deflate"; + + private final boolean clientContextTakeover; + private final boolean serverContextTakeover; + + private final Inflater inflater = new Inflater(true); + private final byte[] inputBuffer = new byte[INPUT_BUFFER_SIZE]; + private boolean skipDecompression = false; + + private final Deflater deflater = new Deflater(Deflater.DEFAULT_COMPRESSION, true); + private final byte[] outputBuffer = new byte[OUTPUT_BUFFER_SIZE]; + + public static PerMessageDeflate negotiate(Map parameters) { + boolean clientContextTakeover = true; + boolean serverContextTakeover = true; + + for (Map.Entry parameter : parameters.entrySet()) { + final String name = parameter.getKey(); + + if (SERVER_NO_CONTEXT_TAKEOVER.equals(name)) { + if (serverContextTakeover) { + serverContextTakeover = false; + } else { + throw new HandshakeException("Duplicate definition of the server_no_context_takeover extension parameter"); + } + } + if (CLIENT_NO_CONTEXT_TAKEOVER.equals(name)) { + if (clientContextTakeover) { + clientContextTakeover = false; + } else { + throw new HandshakeException("Duplicate definition of the client_no_context_takeover extension parameter"); + } + } + } + + return new PerMessageDeflate(clientContextTakeover, serverContextTakeover); + } + + private PerMessageDeflate(boolean clientContextTakeover, boolean serverContextTakeover) { + this.clientContextTakeover = clientContextTakeover; + this.serverContextTakeover = serverContextTakeover; + } + + @Override + public void appendResponseHeaderValue(StringBuilder builder) { + builder.append(NAME); + + if (!clientContextTakeover) { + builder.append("; ").append(CLIENT_NO_CONTEXT_TAKEOVER); + } + + if (!serverContextTakeover) { + builder.append("; ").append(SERVER_NO_CONTEXT_TAKEOVER); + } + } + + @Override + public void transformInput(Frame frame) throws IOException { + if (frame.isControl()) { + // Control frames are never compressed and may appear in the middle of fragmented frames. + // Pass them straight through. + return; + } + + if (!frame.getOpcode().isContinuation()) { + // First frame in new message + skipDecompression = (frame.getRsv() & RSV_BITMASK) == 0; + } + + if (skipDecompression) { + // Pass uncompressed frames straight through. + return; + } + + frame.setPayload(decompress(frame.isFin(), frame.getPayload())); + frame.setRsv(frame.getRsv() & ~RSV_BITMASK); + } + + @Override + public void transformOutput(Frame frame) throws IOException { + if (frame.isControl()) { + // Control frames are never compressed and may appear in the middle of fragmented frames + // Pass them straight through + return; + } + + if (frame.getPayloadLength() == 0) { + // Zero length messages can't be compressed so pass them straight through + return; + } + + frame.setPayload(compress(frame.getPayload())); + frame.setRsv(frame.getRsv() + RSV_BITMASK); + } + + @Override + public void close() { + inflater.end(); + deflater.end(); + } + + private byte[] decompress(boolean fin, byte[] payload) throws IOException { + boolean usedEomBytes = false; + + if (payload == null || payload.length == 0) { + return payload; + } + + try (ByteArrayOutputStream out = new ByteArrayOutputStream()) { + inflater.setInput(payload); + while (true) { + int uncompressedBytes; + + try { + uncompressedBytes = inflater.inflate(inputBuffer); + } catch (DataFormatException e) { + throw new IOException("Failed to decompress WebSocket frame", e); + } + + if (uncompressedBytes > 0) { + out.write(inputBuffer, 0, uncompressedBytes); + } else { + if (inflater.needsInput() && !usedEomBytes) { + if (fin) { + inflater.setInput(EOM_BYTES); + usedEomBytes = true; + } else { + break; + } + } else { + if (fin && !clientContextTakeover) { + inflater.reset(); + } + break; + } + } + } + + return out.toByteArray(); + } + } + + private byte[] compress(byte[] payload) throws IOException { + try (ByteArrayOutputStream out = new ByteArrayOutputStream()) { + deflater.setInput(payload, 0, payload.length); + + int compressedLength; + do { + compressedLength = deflater.deflate(outputBuffer, 0, outputBuffer.length, Deflater.SYNC_FLUSH); + out.write(outputBuffer, 0, compressedLength); + } while (compressedLength > 0); + + byte[] result = out.toByteArray(); + + // https://tools.ietf.org/html/rfc7692#section-7.2.1 states that if the final fragment's compressed + // payload ends with 0x00 0x00 0xff 0xff, they should be removed. + // To simulate removal, we just pass 4 bytes less to the new payload + // if the frame is final and outputBytes ends with 0x00 0x00 0xff 0xff. + if (endsWithTail(result)) { + result = Arrays.copyOf(result, result.length - EOM_BYTES.length); + } + + if (!serverContextTakeover) { + deflater.reset(); + } + + return result; + } + } + + private boolean endsWithTail(byte[] payload){ + if(payload.length < 4) { + return false; + } + + int length = payload.length; + + for (int i = 0; i < EOM_BYTES.length; i++) { + if (EOM_BYTES[i] != payload[length - EOM_BYTES.length + i]) { + return false; + } + } + + return true; + } +} diff --git a/src/one/nio/ws/frame/Frame.java b/src/one/nio/ws/frame/Frame.java new file mode 100644 index 0000000..d6eefe4 --- /dev/null +++ b/src/one/nio/ws/frame/Frame.java @@ -0,0 +1,105 @@ +/* + * Copyright 2015 Odnoklassniki Ltd, Mail.Ru Group + * + * Licensed 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 one.nio.ws.frame; + +import java.nio.ByteBuffer; + +/** + * @author Vadim Yelisseyev + */ +public class Frame { + private final boolean fin; + private final Opcode opcode; + private int rsv; + private int payloadLength; + private byte[] mask; + private byte[] payload; + + public Frame(boolean fin, Opcode opcode, int rsv, int payloadLength) { + this.fin = fin; + this.opcode = opcode; + this.rsv = rsv; + this.payloadLength = payloadLength; + } + + public Frame(Opcode opcode, byte[] payload) { + this.fin = true; + this.rsv = 0; + this.opcode = opcode; + this.payload = payload; + this.payloadLength = payload.length; + } + + public boolean isFin() { + return fin; + } + + public int getRsv() { + return rsv; + } + + public void setRsv(int rsv) { + this.rsv = rsv; + } + + public Opcode getOpcode() { + return opcode; + } + + public boolean isControl() { + return opcode.isControl(); + } + + public int getPayloadLength() { + return payloadLength; + } + + public byte[] getMask() { + return mask; + } + + public void setMask(byte[] mask) { + this.mask = mask; + } + + public byte[] getPayload() { + return payload; + } + + public void setPayload(byte[] payload) { + this.payload = payload; + this.payloadLength = payload.length; + } + + public void unmask() { + if (mask == null) { + return; + } + final ByteBuffer buffer = ByteBuffer.wrap(payload); + final int intMask = ByteBuffer.wrap(mask).getInt(); + while (buffer.remaining() >= 4) { + int pos = buffer.position(); + buffer.putInt(pos, buffer.getInt() ^ intMask); + } + while (buffer.hasRemaining()) { + int pos = buffer.position(); + buffer.put(pos, (byte) (buffer.get() ^ mask[pos % 4])); + } + setPayload(buffer.array()); + this.mask = null; + } +} diff --git a/src/one/nio/ws/frame/FrameReader.java b/src/one/nio/ws/frame/FrameReader.java new file mode 100644 index 0000000..cf1d01d --- /dev/null +++ b/src/one/nio/ws/frame/FrameReader.java @@ -0,0 +1,157 @@ +/* + * Copyright 2015 Odnoklassniki Ltd, Mail.Ru Group + * + * Licensed 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 one.nio.ws.frame; + +import java.io.IOException; +import java.util.Arrays; + +import one.nio.net.Session; +import one.nio.ws.exception.CannotAcceptException; +import one.nio.ws.exception.ProtocolException; +import one.nio.ws.exception.TooBigException; +import one.nio.ws.exception.WebSocketException; + +/** + * Websocket frame reader + * https://datatracker.ietf.org/doc/html/rfc6455#section-5.2 + * + * @author Vadim Yelisseyev + */ +public class FrameReader { + // The smallest valid full WebSocket message is 2 bytes, + // such as this close message sent from the server with no payload: 138, 0. + // Yet the longest possible header is 14 bytes + // which would represent a message sent from the client to the server with a payload greater then 64KB. + private static final int HEADER_LENGTH = 14; + private static final int FIRST_HEADER_LENGTH = 2; + private static final int MASK_LENGTH = 4; + + private final Session session; + private final byte[] header; + private final int maxFramePayloadLength = Integer.getInteger("one.nio.ws.MAX_FRAME_PAYLOAD_LENGTH", 128 * 1024); + + private Frame frame; + private int ptr; + + public FrameReader(Session session) { + this.session = session; + this.header = new byte[HEADER_LENGTH]; + } + + public Frame read() throws IOException { + Frame frame = this.frame; + int ptr = this.ptr; + + if (frame == null) { + ptr += session.read(header, ptr, FIRST_HEADER_LENGTH - ptr); + + if (ptr < FIRST_HEADER_LENGTH) { + this.ptr = ptr; + return null; + } + + frame = createFrame(header); + ptr = 0; + this.frame = frame; + } + + if (frame.getPayload() == null) { + int payloadLength = frame.getPayloadLength(); + int len = payloadLength == 126 ? 2 : payloadLength == 127 ? 8 : 0; + if (len > 0) { + ptr += session.read(header, ptr, len - ptr); + if (ptr < len) { + this.ptr = ptr; + return null; + } + payloadLength = byteArrayToInt(header, len); + } + if (payloadLength < 0) { + throw new ProtocolException("negative payload length"); + } + if (payloadLength > maxFramePayloadLength) { + throw new TooBigException("payload can not be more than " + maxFramePayloadLength); + } + frame.setPayload(new byte[payloadLength]); + ptr = 0; + } + + if (frame.getMask() == null) { + ptr += session.read(header, ptr, MASK_LENGTH - ptr); + + if (ptr < MASK_LENGTH) { + this.ptr = ptr; + return null; + } + + frame.setMask(Arrays.copyOf(header, MASK_LENGTH)); + ptr = 0; + } + + if (ptr < frame.getPayloadLength()) { + ptr += session.read(frame.getPayload(), ptr, frame.getPayloadLength() - ptr); + + if (ptr < frame.getPayloadLength()) { + this.ptr = ptr; + return null; + } + } + + this.frame = null; + this.ptr = 0; + + return frame; + } + + private Frame createFrame(byte[] header) throws WebSocketException { + byte b0 = header[0]; + byte b1 = header[1]; + + boolean fin = (b0 & 0x80) > 0; + int rsv = (b0 & 0x70) >>> 4; + Opcode opcode = Opcode.valueOf(b0 & 0x0F); + int payloadLength = b1 & 0x7F; + + if ((b1 & 0x80) == 0) { + throw new ProtocolException("not masked"); + } + + if (opcode == null) { + throw new CannotAcceptException("invalid opcode (" + (b0 & 0x0F) + ')'); + } else if (opcode.isControl()) { + if (payloadLength > 125) { + throw new ProtocolException("control payload too big"); + } + + if (!fin) { + throw new ProtocolException("control payload can not be fragmented"); + } + } + + return new Frame(fin, opcode, rsv, payloadLength); + } + + private int byteArrayToInt(byte[] b, int len) { + int result = 0; + int shift = 0; + for (int i = len - 1; i >= 0; i--) { + result |= ((b[i] & 0xFF) << shift); + shift += 8; + } + return result; + } +} diff --git a/src/one/nio/ws/frame/FrameWriter.java b/src/one/nio/ws/frame/FrameWriter.java new file mode 100644 index 0000000..7f39aa6 --- /dev/null +++ b/src/one/nio/ws/frame/FrameWriter.java @@ -0,0 +1,62 @@ +/* + * Copyright 2015 Odnoklassniki Ltd, Mail.Ru Group + * + * Licensed 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 one.nio.ws.frame; + +import java.io.IOException; + +import one.nio.net.Session; +import one.nio.net.Socket; + +/** + * @author Vadim Yelisseyev + */ +public class FrameWriter { + private final Session session; + + public FrameWriter(Session session) { + this.session = session; + } + + public void write(Frame frame) throws IOException { + final byte[] payload = frame.getPayload(); + final byte[] header = serializeHeader(frame.getRsv(), frame.getOpcode(), payload); + session.write(header, 0, header.length, Socket.MSG_MORE); + session.write(payload, 0, payload.length); + } + + private byte[] serializeHeader(int rsv, Opcode opcode, byte[] payload) { + int len = payload.length < 126 ? 2 : payload.length < 65536 ? 4 : 10; + byte[] header = new byte[len]; + header[0] = (byte) (0x80 | (rsv << 4) | opcode.value); + // Next write the mask && length + if (payload.length < 126) { + header[1] = (byte) (payload.length); + } else if (payload.length < 65536) { + header[1] = (byte) 126; + header[2] = (byte) (payload.length >>> 8); + header[3] = (byte) (payload.length & 0xFF); + } else { + // Will never be more than 2^31-1 + header[1] = (byte) 127; + header[6] = (byte) (payload.length >>> 24); + header[7] = (byte) (payload.length >>> 16); + header[8] = (byte) (payload.length >>> 8); + header[9] = (byte) payload.length; + } + return header; + } +} diff --git a/src/one/nio/ws/frame/Opcode.java b/src/one/nio/ws/frame/Opcode.java new file mode 100644 index 0000000..77b750b --- /dev/null +++ b/src/one/nio/ws/frame/Opcode.java @@ -0,0 +1,58 @@ +/* + * Copyright 2015 Odnoklassniki Ltd, Mail.Ru Group + * + * Licensed 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 one.nio.ws.frame; + +/** + * @author Vadim Yelisseyev + */ +public enum Opcode { + CONTINUATION(0x00), + TEXT(0x01), + BINARY(0x02), + CLOSE(0x08), + PING(0x09), + PONG(0x0A); + + private static final Opcode[] VALUES; + static { + VALUES = new Opcode[11]; + for (Opcode opcode : Opcode.values()) { + if (VALUES[opcode.value] != null) { + throw new IllegalArgumentException("Opcode " + opcode.value + " already used."); + } + VALUES[opcode.value] = opcode; + } + } + + public final byte value; + + Opcode(int value) { + this.value = (byte) value; + } + + public boolean isControl() { + return (value & 0x08) > 0; + } + + public boolean isContinuation() { + return this == CONTINUATION; + } + + public static Opcode valueOf(int value) { + return VALUES[value]; + } +} diff --git a/src/one/nio/ws/message/BinaryMessage.java b/src/one/nio/ws/message/BinaryMessage.java new file mode 100644 index 0000000..2bfdd98 --- /dev/null +++ b/src/one/nio/ws/message/BinaryMessage.java @@ -0,0 +1,45 @@ +/* + * Copyright 2015 Odnoklassniki Ltd, Mail.Ru Group + * + * Licensed 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 one.nio.ws.message; + +import one.nio.util.Hex; +import one.nio.util.SimpleName; +import one.nio.ws.frame.Opcode; + +/** + * @author Vadim Yelisseyev + */ +public class BinaryMessage extends Message { + + public BinaryMessage(byte[] payload) { + this(Opcode.BINARY, payload); + } + + protected BinaryMessage(Opcode opcode, byte[] payload) { + super(opcode, payload); + } + + @Override + public byte[] payload() { + return payload; + } + + @Override + public String toString() { + return SimpleName.of(getClass()) + "<" + Hex.toHex(payload) + ">"; + } +} diff --git a/src/one/nio/ws/message/CloseMessage.java b/src/one/nio/ws/message/CloseMessage.java new file mode 100644 index 0000000..b490762 --- /dev/null +++ b/src/one/nio/ws/message/CloseMessage.java @@ -0,0 +1,65 @@ +/* + * Copyright 2015 Odnoklassniki Ltd, Mail.Ru Group + * + * Licensed 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 one.nio.ws.message; + +import one.nio.ws.frame.Opcode; + +import java.nio.ByteBuffer; +import java.nio.ByteOrder; + +/** + * @author Vadim Yelisseyev + */ +public class CloseMessage extends Message { + public static final short NORMAL = 1000; + public static final short GOING_AWAY = 1001; + public static final short PROTOCOL_ERROR = 1002; + public static final short CANNOT_ACCEPT = 1003; + public static final short RESERVED = 1004; + public static final short NO_STATUS_CODE = 1005; + public static final short CLOSED_ABNORMALLY = 1006; + public static final short NOT_CONSISTENT = 1007; + public static final short VIOLATED_POLICY = 1008; + public static final short TOO_BIG = 1009; + public static final short NO_EXTENSION = 1010; + public static final short UNEXPECTED_CONDITION = 1011; + public static final short SERVICE_RESTART = 1012; + public static final short TRY_AGAIN_LATER = 1013; + public static final short TLS_HANDSHAKE_FAILURE = 1015; + + public CloseMessage(byte[] payload) { + this(payload.length == 0 ? null : ByteBuffer.wrap(payload).order(ByteOrder.LITTLE_ENDIAN).getShort()); + } + + public CloseMessage(Short code) { + super(Opcode.CLOSE, code); + } + + @Override + public byte[] payload() { + return new byte[] { + (byte) (payload & 0xff), + (byte) ((payload >> 8) & 0xff) + }; + } + + @Override + public String toString() { + return "CloseMessage<" + payload + ">"; + } +} + diff --git a/src/one/nio/ws/message/Message.java b/src/one/nio/ws/message/Message.java new file mode 100644 index 0000000..f32f952 --- /dev/null +++ b/src/one/nio/ws/message/Message.java @@ -0,0 +1,38 @@ +/* + * Copyright 2015 Odnoklassniki Ltd, Mail.Ru Group + * + * Licensed 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 one.nio.ws.message; + +import one.nio.ws.frame.Opcode; + +/** + * @author Vadim Yelisseyev + */ +public abstract class Message { + protected final Opcode opcode; + protected final T payload; + + protected Message(Opcode opcode, T payload) { + this.opcode = opcode; + this.payload = payload; + } + + public Opcode opcode() { + return opcode; + } + + public abstract byte[] payload(); +} diff --git a/src/one/nio/ws/message/MessageReader.java b/src/one/nio/ws/message/MessageReader.java new file mode 100644 index 0000000..97bf89d --- /dev/null +++ b/src/one/nio/ws/message/MessageReader.java @@ -0,0 +1,143 @@ +/* + * Copyright 2015 Odnoklassniki Ltd, Mail.Ru Group + * + * Licensed 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 one.nio.ws.message; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; + +import one.nio.net.Session; +import one.nio.ws.exception.TooBigException; +import one.nio.ws.exception.WebSocketException; +import one.nio.ws.extension.Extension; +import one.nio.ws.frame.Frame; +import one.nio.ws.frame.FrameReader; +import one.nio.ws.frame.Opcode; + +/** + * @author Vadim Yelisseyev + */ +public class MessageReader { + private final FrameReader reader; + private final List extensions; + private final int maxMessagePayloadLength = Integer.getInteger("one.nio.ws.MAX_MESSAGE_PAYLOAD_LENGTH", 16 * 1024 * 1024); + + private PayloadBuffer buffer; + + public MessageReader(Session session, List extensions) { + this.reader = new FrameReader(session); + this.extensions = extensions; + } + + public Message read() throws IOException { + final Frame frame = reader.read(); + if (frame == null) { + // not all frame data was read from socket + return null; + } + if (frame.isControl()) { + // control messages can not be fragmented + // and it can be between 2 fragments of another message + // so handle it separately + return createMessage(frame.getOpcode(), getPayload(frame)); + } + if (!frame.isFin()) { + // not finished fragmented frame + // append it to buffer and wait for next frames + appendFrame(frame); + return null; + } + if (buffer != null) { + // buffer is not null, and this is the frame with fin=true + // so collect all data from buffer to the resulting message + appendFrame(frame); + Message message = createMessage(buffer.getOpcode(), buffer.getPayload()); + buffer = null; + return message; + } + // just a simple message consisting of one fragment + return createMessage(frame.getOpcode(), getPayload(frame)); + } + + private void appendFrame(Frame frame) throws IOException { + if (buffer == null) { + buffer = new PayloadBuffer(frame.getOpcode(), maxMessagePayloadLength); + } + buffer.append(getPayload(frame)); + } + + private Message createMessage(Opcode opcode, byte[] payload) { + switch (opcode) { + case CLOSE: + return new CloseMessage(payload); + case PING: + return new PingMessage(payload); + case PONG: + return new PongMessage(payload); + case BINARY: + return new BinaryMessage(payload); + case TEXT: + return new TextMessage(new String(payload, StandardCharsets.UTF_8)); + } + throw new IllegalArgumentException("Unsupported opcode: " + opcode); + } + + private byte[] getPayload(Frame frame) throws IOException { + frame.unmask(); + for (Extension extension : extensions) { + extension.transformInput(frame); + } + return frame.getPayload(); + } + + public static class PayloadBuffer { + private final Opcode opcode; + private final List chunks; + private final int maxMessagePayloadLength; + private int payloadLength; + + public PayloadBuffer(Opcode opcode, int maxMessagePayloadLength) { + this.opcode = opcode; + this.chunks = new ArrayList<>(); + this.maxMessagePayloadLength = maxMessagePayloadLength; + } + + public Opcode getOpcode() { + return opcode; + } + + public byte[] getPayload() { + final byte[] result = new byte[payloadLength]; + int pos = 0; + for (byte[] chunk : chunks) { + int length = chunk.length; + System.arraycopy(chunk,0, result, pos, length); + pos += length; + } + return result; + } + + public void append(byte[] payload) throws WebSocketException { + payloadLength += payload.length; + if (payloadLength > this.maxMessagePayloadLength) { + throw new TooBigException("payload can not be more than " + maxMessagePayloadLength); + } + chunks.add(payload); + } + } +} diff --git a/src/one/nio/ws/message/MessageWriter.java b/src/one/nio/ws/message/MessageWriter.java new file mode 100644 index 0000000..69e3ce6 --- /dev/null +++ b/src/one/nio/ws/message/MessageWriter.java @@ -0,0 +1,46 @@ +/* + * Copyright 2015 Odnoklassniki Ltd, Mail.Ru Group + * + * Licensed 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 one.nio.ws.message; + +import java.io.IOException; +import java.util.List; + +import one.nio.net.Session; +import one.nio.ws.extension.Extension; +import one.nio.ws.frame.Frame; +import one.nio.ws.frame.FrameWriter; + +/** + * @author Vadim Yelisseyev + */ +public class MessageWriter { + private final FrameWriter writer; + private final List extensions; + + public MessageWriter(Session session, List extensions) { + this.writer = new FrameWriter(session); + this.extensions = extensions; + } + + public void write(Message message) throws IOException { + Frame frame = new Frame(message.opcode(), message.payload()); + for (Extension extension : extensions) { + extension.transformOutput(frame); + } + writer.write(frame); + } +} diff --git a/src/one/nio/ws/message/PingMessage.java b/src/one/nio/ws/message/PingMessage.java new file mode 100644 index 0000000..350d46d --- /dev/null +++ b/src/one/nio/ws/message/PingMessage.java @@ -0,0 +1,31 @@ +/* + * Copyright 2015 Odnoklassniki Ltd, Mail.Ru Group + * + * Licensed 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 one.nio.ws.message; + +import one.nio.http.Response; +import one.nio.ws.frame.Opcode; + +/** + * @author Vadim Yelisseyev + */ +public class PingMessage extends BinaryMessage { + public static final PingMessage EMPTY = new PingMessage(Response.EMPTY); + + public PingMessage(byte[] payload) { + super(Opcode.PING, payload); + } +} diff --git a/src/one/nio/ws/message/PongMessage.java b/src/one/nio/ws/message/PongMessage.java new file mode 100644 index 0000000..0c7a535 --- /dev/null +++ b/src/one/nio/ws/message/PongMessage.java @@ -0,0 +1,32 @@ +/* + * Copyright 2015 Odnoklassniki Ltd, Mail.Ru Group + * + * Licensed 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 one.nio.ws.message; + +import one.nio.http.Response; +import one.nio.ws.frame.Opcode; + +/** + * @author Vadim Yelisseyev + */ +public class PongMessage extends BinaryMessage { + public static final PongMessage EMPTY = new PongMessage(Response.EMPTY); + + public PongMessage(byte[] payload) { + super(Opcode.PONG, payload); + } +} + diff --git a/src/one/nio/ws/message/TextMessage.java b/src/one/nio/ws/message/TextMessage.java new file mode 100644 index 0000000..e208d66 --- /dev/null +++ b/src/one/nio/ws/message/TextMessage.java @@ -0,0 +1,41 @@ +/* + * Copyright 2015 Odnoklassniki Ltd, Mail.Ru Group + * + * Licensed 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 one.nio.ws.message; + +import java.nio.charset.StandardCharsets; + +import one.nio.ws.frame.Opcode; + +/** + * @author Vadim Yelisseyev + */ +public class TextMessage extends Message { + + public TextMessage(String payload) { + super(Opcode.TEXT, payload); + } + + @Override + public byte[] payload() { + return payload.getBytes(StandardCharsets.UTF_8); + } + + @Override + public String toString() { + return "TextMessage<" + payload + ">"; + } +} diff --git a/test/one/nio/ws/EchoServerTest.java b/test/one/nio/ws/EchoServerTest.java new file mode 100644 index 0000000..8eb1f7f --- /dev/null +++ b/test/one/nio/ws/EchoServerTest.java @@ -0,0 +1,51 @@ +package one.nio.ws; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Collections; + +import one.nio.rpc.echo.EchoServer; +import one.nio.server.AcceptorConfig; +import one.nio.ws.message.TextMessage; + +public class EchoServerTest { + + public static void main(String[] args) throws IOException { + EchoServer server = new EchoServer(config()); + server.registerShutdownHook(); + server.start(); + } + + private static WebSocketServerConfig config() { + WebSocketServerConfig config = new WebSocketServerConfig(); + config.supportedProtocols = Collections.singleton("echo1"); + config.websocketBaseUri = "/echo"; + config.keepAlive = 30000; + config.maxWorkers = 1000; + config.queueTime = 50; + config.acceptors = acceptors(); + return config; + } + + private static AcceptorConfig[] acceptors() { + AcceptorConfig config = new AcceptorConfig(); + config.port = 8002; + config.backlog = 10000; + config.deferAccept = true; + return new AcceptorConfig[] { + config + }; + } + + public static class EchoServer extends WebSocketServer { + + public EchoServer(WebSocketServerConfig config) throws IOException { + super(config); + } + + @Override + public void handleMessage(WebSocketSession session, TextMessage message) throws IOException { + session.sendMessage(new TextMessage(new String(message.payload(), StandardCharsets.UTF_8))); + } + } +} diff --git a/test/one/nio/ws/ExtensionRequestTest.java b/test/one/nio/ws/ExtensionRequestTest.java new file mode 100644 index 0000000..3a694e8 --- /dev/null +++ b/test/one/nio/ws/ExtensionRequestTest.java @@ -0,0 +1,44 @@ +package one.nio.ws; + +import one.nio.ws.extension.ExtensionRequest; +import one.nio.ws.extension.ExtensionRequestParser; +import org.junit.Test; + +import java.util.*; + +import static org.junit.Assert.assertEquals; + +public class ExtensionRequestTest { + + @Test + public void testSimpleValue() { + String header = "permessage-deflate; client_max_window_bits"; + List parsed = ExtensionRequestParser.parse(header); + assertEquals(1, parsed.size()); + assertEquals("permessage-deflate", parsed.get(0).getName()); + assertEquals("permessage-deflate", parsed.get(0).getName()); + } + + @Test + public void testMultiValue() { + String header = "mux;max-channels=a;flow-control,deflate-stream"; + List parsed = ExtensionRequestParser.parse(header); + assertEquals(2, parsed.size()); + assertEquals("mux", parsed.get(0).getName()); + assertEquals(2, parsed.get(0).getParameters().size()); + assertEquals("deflate-stream", parsed.get(1).getName()); + assertEquals(0, parsed.get(1).getParameters().size()); + } + + @Test + public void testMultiValueWithQuotasAndSpaces() { + String header = "mux; max-channels=\"a\"; flow-control=\"\""; + List parsed = ExtensionRequestParser.parse(header); + assertEquals(1, parsed.size()); + assertEquals("mux", parsed.get(0).getName()); + assertEquals(2, parsed.get(0).getParameters().size()); + assertEquals("a", parsed.get(0).getParameters().get("max-channels")); + assertEquals("", parsed.get(0).getParameters().get("flow-control")); + } + +} diff --git a/test/one/nio/ws/echo.html b/test/one/nio/ws/echo.html new file mode 100644 index 0000000..b107dc1 --- /dev/null +++ b/test/one/nio/ws/echo.html @@ -0,0 +1,87 @@ + + + + + Echo server test + + + +
+
+
+
+ +
+
+ + + +
+
+
+
+
+ + +