/* vim: set ts=2 et sw=2 cindent fo=qroca: */ package com.globant.katari.core.web; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.io.PrintWriter; import java.io.UnsupportedEncodingException; import java.net.HttpURLConnection; import java.net.MalformedURLException; import java.net.URI; import java.net.URISyntaxException; import java.net.URLEncoder; import java.util.Enumeration; import java.util.HashMap; import java.util.List; import java.util.Map; import javax.servlet.Filter; import javax.servlet.FilterChain; import javax.servlet.FilterConfig; import javax.servlet.ServletException; import javax.servlet.ServletRequest; import javax.servlet.ServletResponse; import javax.servlet.http.Cookie; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import org.apache.commons.lang.Validate; import org.apache.commons.lang.StringEscapeUtils; /** Filter to proxy connections to specific nodes in a cluster. * * This filter is used let users select which node to hit in a cluster. It * shows a list of available nodes, and proxies the request to the user * selected one. * * It is initialized by a map of node ids (a simple string that is shown to the * user), to an schema://host:port prefix corresponding to that node. The hosts * defined in this map must be accessible by all the other nodes in the * cluster. * * To use this filter to proxy katari monitoring to a specific node, use: * * - Add a parameter katari-node= (with empty value) to the menu item * (link='katari-monitoring?katari-node=') * * - Add the filter to match the path of the monitoring endpoints: * (.*\/module/monitoring/katari-monitoring.*) * * - Add the nodes to the filter: Node1 - http://localhost:8098 / Node2 - * http://localhost:8099. * * With this, when hitting the monitoring menu item, instead of showing the * monitoring page, it shows a list of cluster node. Each node is a link to the * monitoring module in the corresponding cluster node. */ public class ClusterNodeProxyFilter implements Filter { /** A map of node names to url prefix (scheme, host and port). * * The keys in this map are url compatible (only digits, letters, * - and _). It never contains the 'local' key. It cannot be null. * * The url prefix cannot end in '/'. */ private Map<String, String> nodeToUrl = new HashMap<String, String>(); /** Constructor, creates a ClusterNodeProxyFilter instance. * * The keys in the map must only contain digits, letters, _ and -. The key * named 'local' is reserved and cannot be used. * * If the map is empty, this filter does nothing and just forwards the * request to the local node. * * @param theNodeToUrl a map of node names to node urls. It cannot be null. */ public ClusterNodeProxyFilter(final Map<String, String> theNodeToUrl) { Validate.notNull(theNodeToUrl, "The map of nodes cannot be null."); for (String key : theNodeToUrl.keySet()) { Validate.isTrue(!key.equals("local"), "The key 'local' is forbidden."); Validate.isTrue(key.matches("^[a-zA-Z0-9-_-]*$"), "The key can only contain letters, digits, - and _."); } nodeToUrl = theNodeToUrl; } /** {@inheritDoc} */ public void init(FilterConfig filterConfig) throws ServletException { } /** {@inheritDoc} */ public void doFilter(final ServletRequest servletRequest, final ServletResponse servletResponse, final FilterChain chain) throws IOException, ServletException { HttpServletRequest request = (HttpServletRequest) servletRequest; HttpServletResponse response = (HttpServletResponse) servletResponse; String node = request.getParameter("katari-node"); if (node == null) { // Find the node in a cookie. node = ""; if (request.getCookies() != null) { for (Cookie cookie : request.getCookies()) { if (cookie.getName().equals("katari-node")) { node = cookie.getValue(); } } } } if (node.equals("") && !nodeToUrl.isEmpty()) { // There are nodes configured but none was selected. listNodes(request, response); } else if (node.equals("local") || nodeToUrl.isEmpty()) { // There are no nodes configured, or we want the local node. Show the // current node, chain.doFilter(request, response); } else { proxyToNode(request, response, node); } } /** Performs a request to another node and sends the result to the browser. * * @param request the original http request. It cannot be null. * * @param response the original http response. It cannot be null. * * @param node the node to forward the request to. It cannot be null. */ @SuppressWarnings("unchecked") private void proxyToNode(final HttpServletRequest request, final HttpServletResponse response, final String node) throws IOException, MalformedURLException { // Proxy another node. Cookie cookie = new Cookie("katari-node", node); response.addCookie(cookie); // Resolve url from node name. URI destination = calculateDestination(request, node, true); request.setAttribute("katari-skip-decoration", "true"); HttpURLConnection connection = null; boolean sendPayload = "POST".equals(request.getMethod()) || "PUT".equals(request.getMethod()) || "OPTIONS".equals(request.getMethod()); try { connection = (HttpURLConnection) destination.toURL().openConnection(); connection.setInstanceFollowRedirects(false); if (sendPayload) { connection.setDoOutput(true); } connection.setRequestMethod(request.getMethod()); // Send headers received from the browser to the target host. Enumeration<String> headerNames = request.getHeaderNames(); while (headerNames.hasMoreElements()) { String headerName = headerNames.nextElement(); Enumeration<String> headerValues = request.getHeaders(headerName); while (headerValues.hasMoreElements()) { String headerValue = headerValues.nextElement(); connection.addRequestProperty(headerName, headerValue); } } InputStream in = null; OutputStream out = null; byte[] buffer = new byte[8192]; int count; if (sendPayload) { try { // Send the content received from the browser to the target host. in = request.getInputStream(); out = connection.getOutputStream(); count = in.read(buffer); while (count != -1) { out.write(buffer, 0, count); count = in.read(buffer); } } finally { if (out != null) { in = null; out.close(); out = null; } } } int responseCode = connection.getResponseCode(); response.setStatus(responseCode); // Send the headers received from the target host to the browser. Map<String, List<String>> headers = connection.getHeaderFields(); for (String headerName : headers.keySet()) { for (String headerValue : headers.get(headerName)) { if (headerName != null) { response.addHeader(headerName, headerValue); } } } try { // Send the content received from the target host to the browser. if (responseCode < 400) { // No error. in = connection.getInputStream(); } else { in = connection.getErrorStream(); } out = response.getOutputStream(); buffer = new byte[8192]; count = in.read(buffer); while (count != -1) { out.write(buffer, 0, count); count = in.read(buffer); } } finally { if (in != null) { out = null; in.close(); in = null; } } } finally { if (connection != null) { connection.disconnect(); } } } /** Calculates the destination url to hit the node. * * This operation considers two scenarios: the first one is to generate a url * to hit the cluster, specifying which node to proxy to. This uses the * request object and adds a query string parameter katari-node with the * provided node name. * * The other scenario is used to hit an internal node from another node. The * generated url is built from the host associated with the node, the request * and with a query string parameter katari-node with 'local' as its value. * * @param request the http request. It cannot be null. * * @param node the name of the node to proxy to. It cannot be null. * * @param toLocal true if the destination must point to an internal node, * true if the destination must point to the cluster. */ @SuppressWarnings("unchecked") private URI calculateDestination(final HttpServletRequest request, final String node, final boolean toLocal) { URI destination; try { // If the node is local, we use the current request host. String host; String targetNode; if (toLocal) { host = nodeToUrl.get(node); targetNode = "local"; } else { host = request.getScheme() + "://" + request.getServerName() + ":" + request.getServerPort(); targetNode = node; } destination = new URI(host + request.getRequestURI()); destination = new URI(destination + "?katari-node=" + targetNode); Enumeration<String> parameterNames = request.getParameterNames(); while (parameterNames.hasMoreElements()) { String parameterName = parameterNames.nextElement(); if (!parameterName.equals("katari-node")) { String[] parameterValues; parameterValues = request.getParameterValues(parameterName); for (String parameterValue : parameterValues) { destination = new URI(destination + "&" + URLEncoder.encode(parameterName, "UTF-8") + "=" + URLEncoder.encode(parameterValue, "UTF-8")); } } } } catch (URISyntaxException e) { throw new RuntimeException("Error building uri", e); } catch (UnsupportedEncodingException e) { throw new RuntimeException("Error building uri", e); } return destination; } /** Generates an html page that lists the available nodes. * * Each node in the list is a link to the original url, but with a parameter * katari-node set to the name of the node. * * @param request the http request. It cannot be null. * * @param response the http response where this operation writes the html * page. It cannot be null. */ private void listNodes(final HttpServletRequest request, final HttpServletResponse response) throws IOException { // Show the list of nodes to select. Cookie cookie = new Cookie("katari-node", ""); cookie.setMaxAge(0); response.addCookie(cookie); response.setContentType("text/html"); PrintWriter writer = response.getWriter(); writer.append("<h3>List of Cluster Nodes</h3>"); for (String nodeName : nodeToUrl.keySet()) { writer.append("<br><a href = '"); URI nodeUrl = calculateDestination(request, nodeName, false); writer.append(StringEscapeUtils.escapeHtml(nodeUrl.toString())); writer.append("'>" + nodeName + "</a>"); } } /** {@inheritDoc} */ public void destroy() { } }