// This file is part of OpenTSDB. // Copyright (C) 2010-2012 The OpenTSDB Authors. // // This program is free software: you can redistribute it and/or modify it // under the terms of the GNU Lesser General Public License as published by // the Free Software Foundation, either version 2.1 of the License, or (at your // option) any later version. This program is distributed in the hope that it // will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty // of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser // General Public License for more details. You should have received a copy // of the GNU Lesser General Public License along with this program. If not, // see <http://www.gnu.org/licenses/>. package net.opentsdb.tsd; import java.util.Arrays; import java.util.HashSet; import java.util.concurrent.atomic.AtomicLong; import com.google.common.base.Strings; import com.google.common.net.HttpHeaders; import com.stumbleupon.async.Deferred; import org.jboss.netty.channel.Channel; import org.jboss.netty.channel.ChannelFuture; import org.jboss.netty.channel.ChannelFutureListener; import org.jboss.netty.channel.ChannelHandlerContext; import org.jboss.netty.channel.MessageEvent; import org.jboss.netty.handler.codec.http.DefaultHttpResponse; import org.jboss.netty.handler.codec.http.HttpMethod; import org.jboss.netty.handler.codec.http.HttpRequest; import org.jboss.netty.handler.codec.http.HttpResponseStatus; import org.jboss.netty.handler.codec.http.HttpVersion; import org.jboss.netty.handler.timeout.IdleState; import org.jboss.netty.handler.timeout.IdleStateAwareChannelUpstreamHandler; import org.jboss.netty.handler.timeout.IdleStateEvent; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import net.opentsdb.core.TSDB; import net.opentsdb.stats.StatsCollector; /** * Stateless handler for all RPCs: telnet-style, built-in or plugin * HTTP. */ final class RpcHandler extends IdleStateAwareChannelUpstreamHandler { private static final Logger LOG = LoggerFactory.getLogger(RpcHandler.class); private static final AtomicLong telnet_rpcs_received = new AtomicLong(); private static final AtomicLong http_rpcs_received = new AtomicLong(); private static final AtomicLong http_plugin_rpcs_received = new AtomicLong(); private static final AtomicLong exceptions_caught = new AtomicLong(); /** RPC executed when there's an unknown telnet-style command. */ private final TelnetRpc unknown_cmd = new Unknown(); /** List of domains to allow access to HTTP. By default this will be empty and * all CORS headers will be ignored. */ private final HashSet<String> cors_domains; /** List of headers allowed for access to HTTP. By default this will contain a * set of known-to-work headers */ private final String cors_headers; /** RPC plugins. Contains the handlers we dispatch requests to. */ private final RpcManager rpc_manager; /** The TSDB to use. */ private final TSDB tsdb; /** * Constructor that loads the CORS domain list and prepares for * handling requests. This constructor creates its own {@link RpcManager}. * @param tsdb The TSDB to use. * @param manager instance of a ready-to-use {@link RpcManager}. * @throws IllegalArgumentException if there was an error with the CORS domain * list */ public RpcHandler(final TSDB tsdb) { this(tsdb, RpcManager.instance(tsdb)); } /** * Constructor that loads the CORS domain list and prepares for handling * requests. * @param tsdb The TSDB to use. * @param manager instance of a ready-to-use {@link RpcManager}. * @throws IllegalArgumentException if there was an error with the CORS domain * list */ public RpcHandler(final TSDB tsdb, final RpcManager manager) { this.tsdb = tsdb; this.rpc_manager = manager; final String cors = tsdb.getConfig().getString("tsd.http.request.cors_domains"); final String mode = tsdb.getConfig().getString("tsd.mode"); LOG.info("TSD is in " + mode + " mode"); if (cors == null || cors.isEmpty()) { cors_domains = null; LOG.info("CORS domain list was empty, CORS will not be enabled"); } else { final String[] domains = cors.split(","); cors_domains = new HashSet<String>(domains.length); for (final String domain : domains) { if (domain.equals("*") && domains.length > 1) { throw new IllegalArgumentException( "tsd.http.request.cors_domains must be a public resource (*) or " + "a list of specific domains, you cannot mix both."); } cors_domains.add(domain.trim().toUpperCase()); LOG.info("Loaded CORS domain (" + domain + ")"); } } cors_headers = tsdb.getConfig().getString("tsd.http.request.cors_headers") .trim(); if ((cors_headers == null) || !cors_headers.matches("^([a-zA-Z0-9_.-]+,\\s*)*[a-zA-Z0-9_.-]+$")) { throw new IllegalArgumentException( "tsd.http.request.cors_headers must be a list of validly-formed " + "HTTP header names. No wildcards are allowed."); } else { LOG.info("Loaded CORS headers (" + cors_headers + ")"); } } @Override public void messageReceived(final ChannelHandlerContext ctx, final MessageEvent msgevent) { try { final Object message = msgevent.getMessage(); if (message instanceof String[]) { handleTelnetRpc(msgevent.getChannel(), (String[]) message); } else if (message instanceof HttpRequest) { handleHttpQuery(tsdb, msgevent.getChannel(), (HttpRequest) message); } else { logError(msgevent.getChannel(), "Unexpected message type " + message.getClass() + ": " + message); exceptions_caught.incrementAndGet(); } } catch (Exception e) { Object pretty_message = msgevent.getMessage(); if (pretty_message instanceof String[]) { pretty_message = Arrays.toString((String[]) pretty_message); } logError(msgevent.getChannel(), "Unexpected exception caught" + " while serving " + pretty_message, e); exceptions_caught.incrementAndGet(); } } /** * Finds the right handler for a telnet-style RPC and executes it. * @param chan The channel on which the RPC was received. * @param command The split telnet-style command. */ private void handleTelnetRpc(final Channel chan, final String[] command) { TelnetRpc rpc = rpc_manager.lookupTelnetRpc(command[0]); if (rpc == null) { rpc = unknown_cmd; } telnet_rpcs_received.incrementAndGet(); rpc.execute(tsdb, chan, command); } /** * Using the request URI, creates a query instance capable of handling * the given request. * @param tsdb the TSDB instance we are running within * @param request the incoming HTTP request * @param chan the {@link Channel} the request came in on. * @return a subclass of {@link AbstractHttpQuery} * @throws BadRequestException if the request is invalid in a way that * can be detected early, here. */ private AbstractHttpQuery createQueryInstance(final TSDB tsdb, final HttpRequest request, final Channel chan) throws BadRequestException { final String uri = request.getUri(); if (Strings.isNullOrEmpty(uri)) { throw new BadRequestException("Request URI is empty"); } else if (uri.charAt(0) != '/') { throw new BadRequestException("Request URI doesn't start with a slash"); } else if (rpc_manager.isHttpRpcPluginPath(uri)) { http_plugin_rpcs_received.incrementAndGet(); return new HttpRpcPluginQuery(tsdb, request, chan); } else { http_rpcs_received.incrementAndGet(); HttpQuery builtinQuery = new HttpQuery(tsdb, request, chan); return builtinQuery; } } /** * Helper method to apply CORS configuration to a request, either a built-in * RPC or a user plugin. * @return <code>true</code> if a status reply was sent (in the the case of * certain HTTP methods); <code>false</code> otherwise. */ private boolean applyCorsConfig(final HttpRequest req, final AbstractHttpQuery query) throws BadRequestException { final String domain = req.headers().get("Origin"); // catch CORS requests and add the header or refuse them if the domain // list has been configured if (query.method() == HttpMethod.OPTIONS || (cors_domains != null && domain != null && !domain.isEmpty())) { if (cors_domains == null || domain == null || domain.isEmpty()) { throw new BadRequestException(HttpResponseStatus.METHOD_NOT_ALLOWED, "Method not allowed", "The HTTP method [" + query.method().getName() + "] is not permitted"); } if (cors_domains.contains("*") || cors_domains.contains(domain.toUpperCase())) { // when a domain has matched successfully, we need to add the header query.response().headers().add(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN, domain); query.response().headers().add(HttpHeaders.ACCESS_CONTROL_ALLOW_METHODS, "GET, POST, PUT, DELETE"); query.response().headers().add(HttpHeaders.ACCESS_CONTROL_ALLOW_HEADERS, cors_headers); // if the method requested was for OPTIONS then we'll return an OK // here and no further processing is needed. if (query.method() == HttpMethod.OPTIONS) { query.sendStatusOnly(HttpResponseStatus.OK); return true; } } else { // You'd think that they would want the server to return a 403 if // the Origin wasn't in the CORS domain list, but they want a 200 // without the allow origin header. We'll return an error in the // body though. throw new BadRequestException(HttpResponseStatus.OK, "CORS domain not allowed", "The domain [" + domain + "] is not permitted access"); } } return false; } /** * Finds the right handler for an HTTP query (either built-in or user plugin) * and executes it. Also handles simple and pre-flight CORS requests if * configured, rejecting requests that do not match a domain in the list. * @param chan The channel on which the query was received. * @param req The parsed HTTP request. */ private void handleHttpQuery(final TSDB tsdb, final Channel chan, final HttpRequest req) { AbstractHttpQuery abstractQuery = null; try { abstractQuery = createQueryInstance(tsdb, req, chan); if (!tsdb.getConfig().enable_chunked_requests() && req.isChunked()) { logError(abstractQuery, "Received an unsupported chunked request: " + abstractQuery.request()); abstractQuery.badRequest(new BadRequestException("Chunked request not supported.")); return; } // NOTE: Some methods in HttpQuery have side-effects (getQueryBaseRoute and // setSerializer for instance) so invocation order is important here. final String route = abstractQuery.getQueryBaseRoute(); if (abstractQuery.getClass().isAssignableFrom(HttpRpcPluginQuery.class)) { if (applyCorsConfig(req, abstractQuery)) { return; } final HttpRpcPluginQuery pluginQuery = (HttpRpcPluginQuery) abstractQuery; final HttpRpcPlugin rpc = rpc_manager.lookupHttpRpcPlugin(route); if (rpc != null) { rpc.execute(tsdb, pluginQuery); } else { pluginQuery.notFound(); } } else if (abstractQuery.getClass().isAssignableFrom(HttpQuery.class)) { final HttpQuery builtinQuery = (HttpQuery) abstractQuery; builtinQuery.setSerializer(); if (applyCorsConfig(req, abstractQuery)) { return; } final HttpRpc rpc = rpc_manager.lookupHttpRpc(route); if (rpc != null) { rpc.execute(tsdb, builtinQuery); } else { builtinQuery.notFound(); } } else { throw new IllegalStateException("Unknown instance of AbstractHttpQuery: " + abstractQuery.getClass().getName()); } } catch (BadRequestException ex) { if (abstractQuery == null) { LOG.warn("{} Unable to create query for {}. Reason: {}", chan, req, ex); sendStatusAndClose(chan, HttpResponseStatus.BAD_REQUEST); } else { abstractQuery.badRequest(ex); } } catch (Exception ex) { exceptions_caught.incrementAndGet(); if (abstractQuery == null) { LOG.warn("{} Unexpected error handling HTTP request {}. Reason: {} ", chan, req, ex); sendStatusAndClose(chan, HttpResponseStatus.INTERNAL_SERVER_ERROR); } else { abstractQuery.internalError(ex); } } } /** * Helper method for sending a status-only HTTP response. This is used in cases where * {@link #createQueryInstance(TSDB, HttpRequest, Channel)} failed to determine a query * and we still want to return an error status to the client. */ private void sendStatusAndClose(final Channel chan, final HttpResponseStatus status) { if (chan.isConnected()) { final DefaultHttpResponse response = new DefaultHttpResponse(HttpVersion.HTTP_1_1, status); final ChannelFuture future = chan.write(response); future.addListener(ChannelFutureListener.CLOSE); } } /** * Collects the stats and metrics tracked by this instance. * @param collector The collector to use. */ public static void collectStats(final StatsCollector collector) { collector.record("rpc.received", telnet_rpcs_received, "type=telnet"); collector.record("rpc.received", http_rpcs_received, "type=http"); collector.record("rpc.received", http_plugin_rpcs_received, "type=http_plugin"); collector.record("rpc.exceptions", exceptions_caught); HttpQuery.collectStats(collector); GraphHandler.collectStats(collector); PutDataPointRpc.collectStats(collector); QueryRpc.collectStats(collector); } /** * Returns the directory path stored in the given system property. * @param prop The name of the system property. * @return The directory path. * @throws IllegalStateException if the system property is not set * or has an invalid value. */ static String getDirectoryFromSystemProp(final String prop) { final String dir = System.getProperty(prop); String err = null; if (dir == null) { err = "' is not set."; } else if (dir.isEmpty()) { err = "' is empty."; } else if (dir.charAt(dir.length() - 1) != '/') { // Screw Windows. err = "' is not terminated with `/'."; } if (err != null) { throw new IllegalStateException("System property `" + prop + err); } return dir; } // ---------------------------- // // Individual command handlers. // // ---------------------------- // /** For unknown commands. */ private static final class Unknown implements TelnetRpc { public Deferred<Object> execute(final TSDB tsdb, final Channel chan, final String[] cmd) { logWarn(chan, "unknown command : " + Arrays.toString(cmd)); chan.write("unknown command: " + cmd[0] + ". Try `help'.\n"); return Deferred.fromResult(null); } } @Override public void channelIdle(ChannelHandlerContext ctx, IdleStateEvent e) { if (e.getState() == IdleState.ALL_IDLE) { final String channel_info = e.getChannel().toString(); LOG.debug("Closing idle socket: " + channel_info); e.getChannel().close(); LOG.info("Closed idle socket: " + channel_info); } } // ---------------- // // Logging helpers. // // ---------------- // private static void logWarn(final AbstractHttpQuery query, final String msg) { LOG.warn(query.channel().toString() + ' ' + msg); } private void logError(final AbstractHttpQuery query, final String msg) { LOG.error(query.channel().toString() + ' ' + msg); } private static void logWarn(final Channel chan, final String msg) { LOG.warn(chan.toString() + ' ' + msg); } private void logError(final Channel chan, final String msg) { LOG.error(chan.toString() + ' ' + msg); } private void logError(final Channel chan, final String msg, final Exception e) { LOG.error(chan.toString() + ' ' + msg, e); } }