package org.yamcs.web.websocket; import java.io.IOException; import java.io.InputStream; import java.util.HashMap; import java.util.Map; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.yamcs.ConfigurationException; import org.yamcs.YamcsServer; import org.yamcs.api.ws.WSConstants; import org.yamcs.protobuf.Web.WebSocketServerMessage.WebSocketReplyData; import org.yamcs.protobuf.Yamcs.ProtoDataType; import org.yamcs.security.AuthenticationToken; import org.yamcs.web.HttpRequestHandler; import org.yamcs.web.HttpRequestInfo; import org.yamcs.web.HttpServer; import io.netty.buffer.ByteBuf; import io.netty.buffer.ByteBufInputStream; import io.netty.channel.Channel; import io.netty.channel.ChannelHandlerContext; import io.netty.channel.SimpleChannelInboundHandler; import io.netty.handler.codec.http.HttpHeaderNames; import io.netty.handler.codec.http.HttpResponseStatus; import io.netty.handler.codec.http.websocketx.BinaryWebSocketFrame; import io.netty.handler.codec.http.websocketx.TextWebSocketFrame; import io.netty.handler.codec.http.websocketx.WebSocketFrame; import io.netty.handler.codec.http.websocketx.WebSocketServerProtocolHandler.ServerHandshakeStateEvent; import io.netty.util.AttributeKey; import io.protostuff.Schema; /** * Class for text/binary websocket handling */ public class WebSocketFrameHandler extends SimpleChannelInboundHandler<WebSocketFrame> { public static final String WEBSOCKET_PATH = "_websocket"; public static final AttributeKey<HttpRequestInfo> CTX_HTTP_REQUEST_INFO = AttributeKey.valueOf("httpRequestInfo"); private static final Logger log = LoggerFactory.getLogger(WebSocketFrameHandler.class); private ChannelHandlerContext ctx; //these two are valid after the socket has been upgraded and they are practical final private Channel channel; private WebSocketProcessorClient processorClient; private WebSocketDecoder decoder; private WebSocketEncoder encoder; private int dataSeqCount = -1; private int droppedWrites = 0; // Consecutive dropped writes used to free up resources // Basic info about the original http request that lead to the websocket upgrade private HttpRequestInfo originalRequestInfo; // Provides access to the various resources served through this websocket private Map<String, AbstractWebSocketResource> resourcesByName = new HashMap<>(); public WebSocketFrameHandler(HttpRequestInfo originalRequestInfo) { this.originalRequestInfo = originalRequestInfo; } @Override public void handlerAdded(ChannelHandlerContext ctx) throws Exception { this.ctx = ctx; channel = ctx.channel(); // Try to use an application name as provided by the client. For browsers (since overriding // websocket headers is not supported) this will be the browser's standard user-agent string String applicationName; if (originalRequestInfo.getHeaders().contains(HttpHeaderNames.USER_AGENT)) { applicationName = originalRequestInfo.getHeaders().get(HttpHeaderNames.USER_AGENT); } else { applicationName = "Unknown (" + channel.remoteAddress() +")"; } String yamcsInstance = originalRequestInfo.getYamcsInstance(); AuthenticationToken authToken = originalRequestInfo.getAuthenticationToken(); processorClient = new WebSocketProcessorClient(yamcsInstance, this, applicationName, authToken); HttpServer httpServer = YamcsServer.getGlobalService(HttpServer.class); if (httpServer != null) { // Can happen in junit when not using yamcs.yaml for (WebSocketResourceProvider provider : httpServer.getWebSocketResourceProviders()) { AbstractWebSocketResource resource = provider.createForClient(processorClient); processorClient.registerResource(provider.getRoute(), resource); } } } @Override public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception { if (evt == ServerHandshakeStateEvent.HANDSHAKE_COMPLETE) { log.info("{} {} {}", originalRequestInfo.getMethod(), originalRequestInfo.getUri(), HttpResponseStatus.SWITCHING_PROTOCOLS.code()); // After upgrade, no further HTTP messages will be received ctx.pipeline().remove(HttpRequestHandler.class); } else { super.userEventTriggered(ctx, evt); } } @Override protected void channelRead0(ChannelHandlerContext ctx, WebSocketFrame frame) throws Exception { try { try { log.debug("Received frame {}", frame); if (frame instanceof TextWebSocketFrame) { // We could do something more clever here, but only need to support json and gpb for now if (decoder == null) decoder = new JsonDecoder(); if (encoder == null) encoder = new JsonEncoder(); } else if (frame instanceof BinaryWebSocketFrame) { if (decoder == null) decoder = new ProtobufDecoder(); if (encoder == null) encoder = new ProtobufEncoder(ctx); } else { // Pong, ping, continuation and close should already be handled by netty's handler return; } ByteBuf binary = frame.content(); if (binary != null) { InputStream in = new ByteBufInputStream(binary); WebSocketDecodeContext msg = decoder.decodeMessage(in); AbstractWebSocketResource resource = resourcesByName.get(msg.getResource()); if (resource != null) { WebSocketReplyData reply = resource.processRequest(msg, decoder); if(reply != null) { sendReply(reply); } } else { throw new WebSocketException(msg.getRequestId(), "Invalid message (unsupported resource: '"+msg.getResource()+"')"); } } } catch (WebSocketException e) { log.debug("Returning nominal exception back to the client: {}", e.getMessage()); sendException(e); } } catch (Exception e) { log.error("Internal Server Error while handling incoming web socket frame", e); try { // Gut-shot, at least try to inform the client // TODO should do our best to return a better requestId here sendException(new WebSocketException(WSConstants.NO_REQUEST_ID, "Internal Server Error")); } catch(Exception e2) { // Oh well, we tried. log.warn("Could not inform client of earlier Internal Server Error due to additional exception " + e2, e2); } } } @Override public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception { log.error("Will close channel due to internal error", cause); ctx.close(); } @Override public void channelInactive(ChannelHandlerContext ctx) throws Exception { if (processorClient != null) { log.info("Channel {} closed", ctx.channel().remoteAddress()); processorClient.quit(); } } void addResource(String name, AbstractWebSocketResource resource) { if (resourcesByName.containsKey(name)) { throw new ConfigurationException("A resource named '" + name + "' is already being served"); } resourcesByName.put(name, resource); } public void sendReply(WebSocketReplyData reply) throws IOException { if(!channel.isOpen()) { throw new IOException("Channel not open"); } if(!channel.isWritable()) { log.warn("Dropping reply message because channel is not writable"); return; } WebSocketFrame frame = encoder.encodeReply(reply); channel.writeAndFlush(frame); } private void sendException(WebSocketException e) throws IOException { WebSocketFrame frame = encoder.encodeException(e); channel.writeAndFlush(frame); } /** * Sends actual data over the web socket. If the channel is not or no longer * writable, the message is dropped. TODO A better approach could be to await the * write for a little while, like we do for chunked writes, but doing so * in-thread would currently cause other yamcs-level subscribers to block as * well for certain types of requests. Needs work. */ public <S> void sendData(ProtoDataType dataType, S data, Schema<S> schema) throws IOException { dataSeqCount++; if(!channel.isOpen()) { throw new IOException("Channel not open"); } if(!channel.isWritable()) { log.warn("Dropping {} message for client [id={}, username={}] because channel is not or no longer writable", dataType, processorClient.getClientId(), processorClient.getUsername()); droppedWrites++; if (droppedWrites == 5) { log.warn("Too many failed writes for client [id={}, username={}]. Forcing disconnect", processorClient.getClientId(), processorClient.getUsername()); ctx.close(); } return; } droppedWrites = 0; WebSocketFrame frame = encoder.encodeData(dataSeqCount, dataType, data, schema); channel.writeAndFlush(frame); } }