package org.yamcs.web; import static io.netty.handler.codec.http.HttpResponseStatus.FORBIDDEN; import static io.netty.handler.codec.http.HttpResponseStatus.NOT_FOUND; import static io.netty.handler.codec.http.HttpVersion.HTTP_1_1; import java.io.IOException; import java.nio.channels.ClosedChannelException; import java.nio.charset.StandardCharsets; import java.util.concurrent.CompletionException; import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.yamcs.YamcsServer; import org.yamcs.api.MediaType; import org.yamcs.security.AuthenticationToken; import org.yamcs.security.AuthorizationPendingException; import org.yamcs.security.Privilege; import org.yamcs.web.rest.Router; import org.yamcs.web.websocket.WebSocketFrameHandler; import com.fasterxml.jackson.core.JsonEncoding; import com.fasterxml.jackson.core.JsonFactory; import com.fasterxml.jackson.core.JsonGenerator; import com.google.common.util.concurrent.UncheckedExecutionException; import com.google.protobuf.MessageLite; import io.netty.buffer.ByteBuf; import io.netty.buffer.ByteBufOutputStream; import io.netty.buffer.Unpooled; import io.netty.channel.Channel; import io.netty.channel.ChannelFuture; import io.netty.channel.ChannelFutureListener; import io.netty.channel.ChannelHandlerContext; import io.netty.channel.ChannelInboundHandlerAdapter; import io.netty.channel.ChannelPipeline; import io.netty.handler.codec.http.DefaultFullHttpResponse; import io.netty.handler.codec.http.DefaultHttpContent; import io.netty.handler.codec.http.DefaultHttpResponse; import io.netty.handler.codec.http.FullHttpResponse; import io.netty.handler.codec.http.HttpContent; import io.netty.handler.codec.http.HttpHeaderNames; import io.netty.handler.codec.http.HttpHeaderValues; import io.netty.handler.codec.http.HttpMethod; import io.netty.handler.codec.http.HttpObjectAggregator; import io.netty.handler.codec.http.HttpRequest; import io.netty.handler.codec.http.HttpResponse; import io.netty.handler.codec.http.HttpResponseStatus; import io.netty.handler.codec.http.HttpUtil; import io.netty.handler.codec.http.HttpVersion; import io.netty.handler.codec.http.LastHttpContent; import io.netty.handler.codec.http.QueryStringDecoder; import io.netty.handler.codec.http.websocketx.WebSocketServerProtocolHandler; import io.netty.util.AttributeKey; import io.netty.util.CharsetUtil; import io.netty.util.ReferenceCountUtil; import io.protostuff.JsonIOUtil; import io.protostuff.Schema; /** * Handles handshakes and messages. * * We have following different request types - static requests - sent to the * fileRequestHandler - do no go higher in the netty pipeline - websocket * requests - the pipeline is modified to add the websocket handshaker. - load * data requests - the pipeline is modified by the respective route handler - * standard API calls (the vast majority) - the HttpObjectAgreggator is added * upstream to collect (and limit) all data from the http request in one object. * * Because we support multiple http requests on one connection (keep-alive), we * have to clean the pipeline when the request type changes */ public class HttpRequestHandler extends ChannelInboundHandlerAdapter { private static final String STATIC_PATH = "_static"; private static final String API_PATH = "api"; public static final AttributeKey<ChunkedTransferStats> CTX_CHUNK_STATS = AttributeKey.valueOf("chunkedTransferStats"); public static final AttributeKey<AuthenticationToken> CTX_AUTH_TOKEN = AttributeKey.valueOf("authToken"); private static final Logger log = LoggerFactory.getLogger(HttpRequestHandler.class); public static final Object CONTENT_FINISHED_EVENT = new Object(); private static StaticFileHandler fileRequestHandler = new StaticFileHandler(); private Router apiRouter; private boolean contentExpected = false; private static JsonFactory jsonFactory = new JsonFactory(); private static final FullHttpResponse BAD_REQUEST = new DefaultFullHttpResponse( HttpVersion.HTTP_1_1, HttpResponseStatus.BAD_REQUEST, Unpooled.EMPTY_BUFFER); static { HttpUtil.setContentLength(BAD_REQUEST, 0); } public static final byte[] NEWLINE_BYTES = "\r\n".getBytes(); public HttpRequestHandler(Router apiRouter) { this.apiRouter = apiRouter; } @Override public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception { if (msg instanceof HttpRequest) { contentExpected = false; processRequest(ctx, (HttpRequest) msg); ReferenceCountUtil.release(msg); } else if (msg instanceof HttpContent) { if (contentExpected) { ctx.fireChannelRead(msg); if (msg instanceof LastHttpContent) { ctx.fireUserEventTriggered(CONTENT_FINISHED_EVENT); } } else if (!(msg instanceof LastHttpContent)) { log.warn("Unexpected http content received: {}", msg); ReferenceCountUtil.release(msg); ctx.close(); } } else { log.error("Unexpected message received: {}", msg); ReferenceCountUtil.release(msg); } } private void processRequest(ChannelHandlerContext ctx, HttpRequest req) { // We have this also on info level coupled with the HTTP response status // code, // but this is on debug for an earlier reporting while debugging issues log.debug("{} {}", req.method(), req.uri()); Privilege priv = Privilege.getInstance(); if (priv.isEnabled()) { ctx.channel().attr(CTX_AUTH_TOKEN).set(null); priv.authenticateHttp(ctx, req).whenComplete((authToken, t) -> { if (t != null) { t = unwindThrowable(t); if (t instanceof AuthorizationPendingException) { return; } else { sendPlainTextError(ctx, req, HttpResponseStatus.UNAUTHORIZED); } } else { ctx.channel().attr(CTX_AUTH_TOKEN).set(authToken); handleRequest(authToken, ctx, req); } }); } else { handleRequest(null, ctx, req); } } private Throwable unwindThrowable(Throwable t) { while (((t instanceof ExecutionException) || (t instanceof CompletionException) || (t instanceof UncheckedExecutionException)) && t.getCause() != null) { t = t.getCause(); } return t; } private void handleRequest(AuthenticationToken authToken, ChannelHandlerContext ctx, HttpRequest req) { try { cleanPipeline(ctx.pipeline()); // Decode URI, to correctly ignore query strings in path handling QueryStringDecoder qsDecoder = new QueryStringDecoder(req.uri()); String[] path = qsDecoder.path().split("/", 3); // path starts with / so path[0] is always empty switch (path[1]) { case STATIC_PATH: if (path.length == 2) { // do not accept "/_static/" (i.e. directory listing) requests sendPlainTextError(ctx, req, FORBIDDEN); return; } fileRequestHandler.handleStaticFileRequest(ctx, req, path[2]); return; case API_PATH: contentExpected = apiRouter.scheduleExecution(ctx, req, qsDecoder); return; case "": // overview of all instances fileRequestHandler.handleStaticFileRequest(ctx, req, "_site/index.html"); return; default: String yamcsInstance = path[1]; if (!YamcsServer.hasInstance(yamcsInstance)) { sendPlainTextError(ctx, req, NOT_FOUND); return; } if (path.length > 2) { String[] rpath = path[2].split("/", 2); String handler = rpath[0]; if (WebSocketFrameHandler.WEBSOCKET_PATH.equals(handler)) { prepareChannelForWebSocketUpgrade(ctx, req, yamcsInstance, authToken); return; } else { // Everything else is handled by angular's router // (enables deep linking in html5 mode) fileRequestHandler.handleStaticFileRequest(ctx, req, "_site/instance.html"); } } else { fileRequestHandler.handleStaticFileRequest(ctx, req, "_site/instance.html"); } } } catch (IOException e) { log.warn("Exception while handling http request", e); sendPlainTextError(ctx, req, HttpResponseStatus.INTERNAL_SERVER_ERROR); } } /** * Adapts Netty's pipeline for allowing WebSocket upgrade * * @param ctx * context for this channel handler */ private void prepareChannelForWebSocketUpgrade(ChannelHandlerContext ctx, HttpRequest req, String yamcsInstance, AuthenticationToken authToken) { contentExpected = true; ctx.pipeline().addLast(new HttpObjectAggregator(65536)); // Add websocket-specific handlers to channel pipeline String webSocketPath = req.uri(); ctx.pipeline().addLast(new WebSocketServerProtocolHandler(webSocketPath)); HttpRequestInfo originalRequestInfo = new HttpRequestInfo(req); originalRequestInfo.setYamcsInstance(yamcsInstance); originalRequestInfo.setAuthenticationToken(authToken); ctx.pipeline().addLast(new WebSocketFrameHandler(originalRequestInfo)); // Effectively trigger websocket-handler (will attempt handshake) ctx.fireChannelRead(req); } @Override public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception { log.error("Will close channel due to exception", cause); ctx.close(); } public ChannelFuture sendRedirect(ChannelHandlerContext ctx, HttpRequest req, String newUri) { FullHttpResponse response = new DefaultFullHttpResponse(HTTP_1_1, HttpResponseStatus.FOUND); response.headers().set(HttpHeaderNames.LOCATION, newUri); log.info("{} {} {}", req.method(), req.uri(), HttpResponseStatus.FOUND.code()); return ctx.writeAndFlush(response).addListener(ChannelFutureListener.CLOSE); } public static <T extends MessageLite> ChannelFuture sendMessageResponse(ChannelHandlerContext ctx, HttpRequest req, HttpResponseStatus status, T responseMsg, Schema<T> responseSchema) { return sendMessageResponse(ctx, req, status, responseMsg, responseSchema, true); } public static <T extends MessageLite> ChannelFuture sendMessageResponse(ChannelHandlerContext ctx, HttpRequest req, HttpResponseStatus status, T responseMsg, Schema<T> responseSchema, boolean autoCloseOnError) { ByteBuf body = ctx.alloc().buffer(); MediaType contentType = MediaType.getAcceptType(req); try (ByteBufOutputStream channelOut = new ByteBufOutputStream(body)){ if (contentType == MediaType.PROTOBUF) { responseMsg.writeTo(channelOut); } else if (contentType == MediaType.PLAIN_TEXT) { channelOut.write(responseMsg.toString().getBytes(StandardCharsets.UTF_8)); } else { //JSON by default contentType = MediaType.JSON; JsonGenerator generator = jsonFactory.createGenerator(channelOut, JsonEncoding.UTF8); JsonIOUtil.writeTo(generator, responseMsg, responseSchema, false); generator.close(); body.writeBytes(NEWLINE_BYTES); // For curl comfort } } catch (IOException e) { return sendPlainTextError(ctx, req, HttpResponseStatus.INTERNAL_SERVER_ERROR, e.toString()); } byte[] dst = new byte[body.readableBytes()]; body.markReaderIndex(); body.readBytes(dst); body.resetReaderIndex(); HttpResponse response = new DefaultFullHttpResponse(HTTP_1_1, status, body); HttpUtils.setContentTypeHeader(response, contentType); int txSize = body.readableBytes(); HttpUtil.setContentLength(response, txSize); return sendResponse(ctx, req, response, autoCloseOnError); } public static ChannelFuture sendPlainTextError(ChannelHandlerContext ctx, HttpRequest req, HttpResponseStatus status) { return sendPlainTextError(ctx, req, status, status.toString()); } public static ChannelFuture sendPlainTextError(ChannelHandlerContext ctx, HttpRequest req, HttpResponseStatus status, String msg) { FullHttpResponse response = new DefaultFullHttpResponse(HTTP_1_1, status, Unpooled.copiedBuffer(msg + "\r\n", CharsetUtil.UTF_8)); response.headers().set(HttpHeaderNames.CONTENT_TYPE, "text/plain; charset=UTF-8"); return sendResponse(ctx, req, response, true); } public static ChannelFuture sendResponse(ChannelHandlerContext ctx, HttpRequest req, HttpResponse response, boolean autoCloseOnError) { if(response.status()==HttpResponseStatus.OK) { log.info("{} {} {}", req.method(), req.uri(), response.status().code()); ChannelFuture writeFuture = ctx.writeAndFlush(response); if (!HttpUtil.isKeepAlive(req)) { writeFuture.addListener(ChannelFutureListener.CLOSE); } return writeFuture; } else { if (req != null) { log.warn("{} {} {}", req.method(), req.uri(), response.status().code()); } else { log.warn("Malformed or illegal request. Sending back {}", response.status().code()); } ChannelFuture writeFuture = ctx.writeAndFlush(response); if(autoCloseOnError) { writeFuture = writeFuture.addListener(ChannelFutureListener.CLOSE); } return writeFuture; } } private void cleanPipeline(ChannelPipeline pipeline) { while (pipeline.last() != this) { pipeline.removeLast(); } } /** * Sends base HTTP response indicating the use of chunked transfer encoding */ public static ChannelFuture startChunkedTransfer(ChannelHandlerContext ctx, HttpRequest req, MediaType contentType, String filename) { log.info("{} {} 200 Starting chunked transfer", req.method(), req.uri()); ctx.channel().attr(CTX_CHUNK_STATS).set(new ChunkedTransferStats(req.method(), req.uri())); HttpResponse response = new DefaultHttpResponse(HTTP_1_1, HttpResponseStatus.OK); response.headers().set(HttpHeaderNames.TRANSFER_ENCODING, HttpHeaderValues.CHUNKED); response.headers().set(HttpHeaderNames.CONTENT_TYPE, contentType); // Set Content-Disposition header so that supporting clients will treat // response as a downloadable file if (filename != null) { response.headers().set("Content-Disposition", "attachment; filename=\"" + filename + "\""); } return ctx.writeAndFlush(response).addListener(ChannelFutureListener.CLOSE_ON_FAILURE); } public static ChannelFuture writeChunk(ChannelHandlerContext ctx, ByteBuf buf) throws IOException { Channel ch = ctx.channel(); if (!ch.isOpen()) { throw new ClosedChannelException(); } ChannelFuture writeFuture = ctx.writeAndFlush(new DefaultHttpContent(buf)); try { if (!ch.isWritable()) { log.warn("Channel open, but not writable. Waiting it out for max 10 seconds"); boolean writeCompleted = writeFuture.await(10, TimeUnit.SECONDS); if (!writeCompleted) { throw new IOException("Channel did not become writable in 10 seconds"); } } } catch (InterruptedException e) { log.warn("Interrupted while waiting for channel to become writable", e); throw new IOException(e); } return writeFuture; } public static class ChunkedTransferStats { public int totalBytes = 0; public int chunkCount = 0; HttpMethod originalMethod; String originalUri; public ChunkedTransferStats(HttpMethod method, String uri) { originalMethod = method; originalUri = uri; } } }