blob: 86f208b54519213e881d14d6014dff297d62a40b [file] [log] [blame]
//
// ========================================================================
// Copyright (c) 1995-2014 Mort Bay Consulting Pty. Ltd.
// ------------------------------------------------------------------------
// All rights reserved. This program and the accompanying materials
// are made available under the terms of the Eclipse Public License v1.0
// and Apache License v2.0 which accompanies this distribution.
//
// The Eclipse Public License is available at
// http://www.eclipse.org/legal/epl-v10.html
//
// The Apache License v2.0 is available at
// http://www.opensource.org/licenses/apache2.0.php
//
// You may elect to redistribute this code under either of these licenses.
// ========================================================================
//
package org.eclipse.jetty.servlets;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.net.MalformedURLException;
import java.net.Socket;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.Collections;
import java.util.Enumeration;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.StringTokenizer;
import javax.servlet.Servlet;
import javax.servlet.ServletConfig;
import javax.servlet.ServletContext;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.UnavailableException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.eclipse.jetty.client.HttpClient;
import org.eclipse.jetty.client.HttpExchange;
import org.eclipse.jetty.continuation.Continuation;
import org.eclipse.jetty.continuation.ContinuationSupport;
import org.eclipse.jetty.http.HttpHeaderValues;
import org.eclipse.jetty.http.HttpHeaders;
import org.eclipse.jetty.http.HttpSchemes;
import org.eclipse.jetty.http.HttpURI;
import org.eclipse.jetty.http.PathMap;
import org.eclipse.jetty.io.Buffer;
import org.eclipse.jetty.io.EofException;
import org.eclipse.jetty.util.HostMap;
import org.eclipse.jetty.util.IO;
import org.eclipse.jetty.util.log.Log;
import org.eclipse.jetty.util.log.Logger;
import org.eclipse.jetty.util.thread.QueuedThreadPool;
/**
* Asynchronous Proxy Servlet.
*
* Forward requests to another server either as a standard web proxy (as defined by RFC2616) or as a transparent proxy.
* <p>
* This servlet needs the jetty-util and jetty-client classes to be available to the web application.
* <p>
* To facilitate JMX monitoring, the "HttpClient" and "ThreadPool" are set as context attributes prefixed with the servlet name.
* <p>
* The following init parameters may be used to configure the servlet:
* <ul>
* <li>name - Name of Proxy servlet (default: "ProxyServlet"
* <li>maxThreads - maximum threads
* <li>maxConnections - maximum connections per destination
* <li>timeout - the period in ms the client will wait for a response from the proxied server
* <li>idleTimeout - the period in ms a connection to proxied server can be idle for before it is closed
* <li>requestHeaderSize - the size of the request header buffer (d. 6,144)
* <li>requestBufferSize - the size of the request buffer (d. 12,288)
* <li>responseHeaderSize - the size of the response header buffer (d. 6,144)
* <li>responseBufferSize - the size of the response buffer (d. 32,768)
* <li>HostHeader - Force the host header to a particular value
* <li>whiteList - comma-separated list of allowed proxy destinations
* <li>blackList - comma-separated list of forbidden proxy destinations
* </ul>
*
* @see org.eclipse.jetty.server.handler.ConnectHandler
*/
public class ProxyServlet implements Servlet
{
protected Logger _log;
protected HttpClient _client;
protected String _hostHeader;
protected HashSet<String> _DontProxyHeaders = new HashSet<String>();
{
_DontProxyHeaders.add("proxy-connection");
_DontProxyHeaders.add("connection");
_DontProxyHeaders.add("keep-alive");
_DontProxyHeaders.add("transfer-encoding");
_DontProxyHeaders.add("te");
_DontProxyHeaders.add("trailer");
_DontProxyHeaders.add("proxy-authorization");
_DontProxyHeaders.add("proxy-authenticate");
_DontProxyHeaders.add("upgrade");
}
protected ServletConfig _config;
protected ServletContext _context;
protected HostMap<PathMap> _white = new HostMap<PathMap>();
protected HostMap<PathMap> _black = new HostMap<PathMap>();
/* ------------------------------------------------------------ */
/*
* (non-Javadoc)
*
* @see javax.servlet.Servlet#init(javax.servlet.ServletConfig)
*/
public void init(ServletConfig config) throws ServletException
{
_config = config;
_context = config.getServletContext();
_hostHeader = config.getInitParameter("HostHeader");
try
{
_log = createLogger(config);
_client = createHttpClient(config);
if (_context != null)
{
_context.setAttribute(config.getServletName() + ".ThreadPool",_client.getThreadPool());
_context.setAttribute(config.getServletName() + ".HttpClient",_client);
}
String white = config.getInitParameter("whiteList");
if (white != null)
{
parseList(white,_white);
}
String black = config.getInitParameter("blackList");
if (black != null)
{
parseList(black,_black);
}
}
catch (Exception e)
{
throw new ServletException(e);
}
}
public void destroy()
{
try
{
_client.stop();
}
catch (Exception x)
{
_log.debug(x);
}
}
/**
* Create and return a logger based on the ServletConfig for use in the
* proxy servlet
*
* @param config
* @return Logger
*/
protected Logger createLogger(ServletConfig config)
{
return Log.getLogger("org.eclipse.jetty.servlets." + config.getServletName());
}
/**
* Create and return an HttpClientInstance
*
* @return HttpClient
*/
protected HttpClient createHttpClientInstance()
{
return new HttpClient();
}
/**
* Create and return an HttpClient based on ServletConfig
*
* By default this implementation will create an instance of the
* HttpClient for use by this proxy servlet.
*
* @param config
* @return HttpClient
* @throws Exception
*/
protected HttpClient createHttpClient(ServletConfig config) throws Exception
{
HttpClient client = createHttpClientInstance();
client.setConnectorType(HttpClient.CONNECTOR_SELECT_CHANNEL);
String t = config.getInitParameter("maxThreads");
if (t != null)
{
client.setThreadPool(new QueuedThreadPool(Integer.parseInt(t)));
}
else
{
client.setThreadPool(new QueuedThreadPool());
}
((QueuedThreadPool)client.getThreadPool()).setName(config.getServletName());
t = config.getInitParameter("maxConnections");
if (t != null)
{
client.setMaxConnectionsPerAddress(Integer.parseInt(t));
}
t = config.getInitParameter("timeout");
if ( t != null )
{
client.setTimeout(Long.parseLong(t));
}
t = config.getInitParameter("idleTimeout");
if ( t != null )
{
client.setIdleTimeout(Long.parseLong(t));
}
t = config.getInitParameter("requestHeaderSize");
if ( t != null )
{
client.setRequestHeaderSize(Integer.parseInt(t));
}
t = config.getInitParameter("requestBufferSize");
if ( t != null )
{
client.setRequestBufferSize(Integer.parseInt(t));
}
t = config.getInitParameter("responseHeaderSize");
if ( t != null )
{
client.setResponseHeaderSize(Integer.parseInt(t));
}
t = config.getInitParameter("responseBufferSize");
if ( t != null )
{
client.setResponseBufferSize(Integer.parseInt(t));
}
client.start();
return client;
}
/* ------------------------------------------------------------ */
/**
* Helper function to process a parameter value containing a list of new entries and initialize the specified host map.
*
* @param list
* comma-separated list of new entries
* @param hostMap
* target host map
*/
private void parseList(String list, HostMap<PathMap> hostMap)
{
if (list != null && list.length() > 0)
{
int idx;
String entry;
StringTokenizer entries = new StringTokenizer(list,",");
while (entries.hasMoreTokens())
{
entry = entries.nextToken();
idx = entry.indexOf('/');
String host = idx > 0?entry.substring(0,idx):entry;
String path = idx > 0?entry.substring(idx):"/*";
host = host.trim();
PathMap pathMap = hostMap.get(host);
if (pathMap == null)
{
pathMap = new PathMap(true);
hostMap.put(host,pathMap);
}
if (path != null)
{
pathMap.put(path,path);
}
}
}
}
/* ------------------------------------------------------------ */
/**
* Check the request hostname and path against white- and blacklist.
*
* @param host
* hostname to check
* @param path
* path to check
* @return true if request is allowed to be proxied
*/
public boolean validateDestination(String host, String path)
{
if (_white.size() > 0)
{
boolean match = false;
Object whiteObj = _white.getLazyMatches(host);
if (whiteObj != null)
{
List whiteList = (whiteObj instanceof List)?(List)whiteObj:Collections.singletonList(whiteObj);
for (Object entry : whiteList)
{
PathMap pathMap = ((Map.Entry<String, PathMap>)entry).getValue();
if (match = (pathMap != null && (pathMap.size() == 0 || pathMap.match(path) != null)))
break;
}
}
if (!match)
return false;
}
if (_black.size() > 0)
{
Object blackObj = _black.getLazyMatches(host);
if (blackObj != null)
{
List blackList = (blackObj instanceof List)?(List)blackObj:Collections.singletonList(blackObj);
for (Object entry : blackList)
{
PathMap pathMap = ((Map.Entry<String, PathMap>)entry).getValue();
if (pathMap != null && (pathMap.size() == 0 || pathMap.match(path) != null))
return false;
}
}
}
return true;
}
/* ------------------------------------------------------------ */
/*
* (non-Javadoc)
*
* @see javax.servlet.Servlet#getServletConfig()
*/
public ServletConfig getServletConfig()
{
return _config;
}
/* ------------------------------------------------------------ */
/**
* Get the hostHeader.
*
* @return the hostHeader
*/
public String getHostHeader()
{
return _hostHeader;
}
/* ------------------------------------------------------------ */
/**
* Set the hostHeader.
*
* @param hostHeader
* the hostHeader to set
*/
public void setHostHeader(String hostHeader)
{
_hostHeader = hostHeader;
}
/* ------------------------------------------------------------ */
/*
* (non-Javadoc)
*
* @see javax.servlet.Servlet#service(javax.servlet.ServletRequest, javax.servlet.ServletResponse)
*/
public void service(ServletRequest req, ServletResponse res) throws ServletException, IOException
{
final int debug = _log.isDebugEnabled()?req.hashCode():0;
final HttpServletRequest request = (HttpServletRequest)req;
final HttpServletResponse response = (HttpServletResponse)res;
if ("CONNECT".equalsIgnoreCase(request.getMethod()))
{
handleConnect(request,response);
}
else
{
final InputStream in = request.getInputStream();
final OutputStream out = response.getOutputStream();
final Continuation continuation = ContinuationSupport.getContinuation(request);
if (!continuation.isInitial())
response.sendError(HttpServletResponse.SC_GATEWAY_TIMEOUT); // Need better test that isInitial
else
{
String uri = request.getRequestURI();
if (request.getQueryString() != null)
uri += "?" + request.getQueryString();
HttpURI url = proxyHttpURI(request,uri);
if (debug != 0)
_log.debug(debug + " proxy " + uri + "-->" + url);
if (url == null)
{
response.sendError(HttpServletResponse.SC_FORBIDDEN);
return;
}
HttpExchange exchange = new HttpExchange()
{
@Override
protected void onRequestCommitted() throws IOException
{
}
@Override
protected void onRequestComplete() throws IOException
{
}
@Override
protected void onResponseComplete() throws IOException
{
if (debug != 0)
_log.debug(debug + " complete");
continuation.complete();
}
@Override
protected void onResponseContent(Buffer content) throws IOException
{
if (debug != 0)
_log.debug(debug + " content" + content.length());
content.writeTo(out);
}
@Override
protected void onResponseHeaderComplete() throws IOException
{
}
@Override
protected void onResponseStatus(Buffer version, int status, Buffer reason) throws IOException
{
if (debug != 0)
_log.debug(debug + " " + version + " " + status + " " + reason);
if (reason != null && reason.length() > 0)
response.setStatus(status,reason.toString());
else
response.setStatus(status);
}
@Override
protected void onResponseHeader(Buffer name, Buffer value) throws IOException
{
String nameString = name.toString();
String s = nameString.toLowerCase(Locale.ENGLISH);
if (!_DontProxyHeaders.contains(s) || (HttpHeaders.CONNECTION_BUFFER.equals(name) && HttpHeaderValues.CLOSE_BUFFER.equals(value)))
{
if (debug != 0)
_log.debug(debug + " " + name + ": " + value);
String filteredHeaderValue = filterResponseHeaderValue(nameString,value.toString(),request);
if (filteredHeaderValue != null && filteredHeaderValue.trim().length() > 0)
{
if (debug != 0)
_log.debug(debug + " " + name + ": (filtered): " + filteredHeaderValue);
response.addHeader(nameString,filteredHeaderValue);
}
}
else if (debug != 0)
_log.debug(debug + " " + name + "! " + value);
}
@Override
protected void onConnectionFailed(Throwable ex)
{
handleOnConnectionFailed(ex,request,response);
// it is possible this might trigger before the
// continuation.suspend()
if (!continuation.isInitial())
{
continuation.complete();
}
}
@Override
protected void onException(Throwable ex)
{
if (ex instanceof EofException)
{
_log.ignore(ex);
//return;
}
handleOnException(ex,request,response);
// it is possible this might trigger before the
// continuation.suspend()
if (!continuation.isInitial())
{
continuation.complete();
}
}
@Override
protected void onExpire()
{
handleOnExpire(request,response);
continuation.complete();
}
};
exchange.setScheme(HttpSchemes.HTTPS.equals(request.getScheme())?HttpSchemes.HTTPS_BUFFER:HttpSchemes.HTTP_BUFFER);
exchange.setMethod(request.getMethod());
exchange.setURL(url.toString());
exchange.setVersion(request.getProtocol());
if (debug != 0)
_log.debug(debug + " " + request.getMethod() + " " + url + " " + request.getProtocol());
// check connection header
String connectionHdr = request.getHeader("Connection");
if (connectionHdr != null)
{
connectionHdr = connectionHdr.toLowerCase(Locale.ENGLISH);
if (connectionHdr.indexOf("keep-alive") < 0 && connectionHdr.indexOf("close") < 0)
connectionHdr = null;
}
// force host
if (_hostHeader != null)
exchange.setRequestHeader("Host",_hostHeader);
// copy headers
boolean xForwardedFor = false;
boolean hasContent = false;
long contentLength = -1;
Enumeration<?> enm = request.getHeaderNames();
while (enm.hasMoreElements())
{
// TODO could be better than this!
String hdr = (String)enm.nextElement();
String lhdr = hdr.toLowerCase(Locale.ENGLISH);
if ("transfer-encoding".equals(lhdr))
{
if (request.getHeader("transfer-encoding").indexOf("chunk")>=0)
hasContent = true;
}
if (_DontProxyHeaders.contains(lhdr))
continue;
if (connectionHdr != null && connectionHdr.indexOf(lhdr) >= 0)
continue;
if (_hostHeader != null && "host".equals(lhdr))
continue;
if ("content-type".equals(lhdr))
hasContent = true;
else if ("content-length".equals(lhdr))
{
contentLength = request.getContentLength();
exchange.setRequestHeader(HttpHeaders.CONTENT_LENGTH,Long.toString(contentLength));
if (contentLength > 0)
hasContent = true;
}
else if ("x-forwarded-for".equals(lhdr))
xForwardedFor = true;
Enumeration<?> vals = request.getHeaders(hdr);
while (vals.hasMoreElements())
{
String val = (String)vals.nextElement();
if (val != null)
{
if (debug != 0)
_log.debug(debug + " " + hdr + ": " + val);
exchange.setRequestHeader(hdr,val);
}
}
}
// Proxy headers
exchange.setRequestHeader("Via","1.1 (jetty)");
if (!xForwardedFor)
{
exchange.addRequestHeader("X-Forwarded-For",request.getRemoteAddr());
exchange.addRequestHeader("X-Forwarded-Proto",request.getScheme());
exchange.addRequestHeader("X-Forwarded-Host",request.getHeader("Host"));
exchange.addRequestHeader("X-Forwarded-Server",request.getLocalName());
}
if (hasContent)
{
exchange.setRequestContentSource(in);
}
customizeExchange(exchange, request);
/*
* we need to set the timeout on the continuation to take into
* account the timeout of the HttpClient and the HttpExchange
*/
long ctimeout = (_client.getTimeout() > exchange.getTimeout()) ? _client.getTimeout() : exchange.getTimeout();
// continuation fudge factor of 1000, underlying components
// should fail/expire first from exchange
if ( ctimeout == 0 )
{
continuation.setTimeout(0); // ideally never times out
}
else
{
continuation.setTimeout(ctimeout + 1000);
}
customizeContinuation(continuation);
continuation.suspend(response);
_client.send(exchange);
}
}
}
/* ------------------------------------------------------------ */
public void handleConnect(HttpServletRequest request, HttpServletResponse response) throws IOException
{
String uri = request.getRequestURI();
String port = "";
String host = "";
int c = uri.indexOf(':');
if (c >= 0)
{
port = uri.substring(c + 1);
host = uri.substring(0,c);
if (host.indexOf('/') > 0)
host = host.substring(host.indexOf('/') + 1);
}
// TODO - make this async!
InetSocketAddress inetAddress = new InetSocketAddress(host,Integer.parseInt(port));
// if (isForbidden(HttpMessage.__SSL_SCHEME,addrPort.getHost(),addrPort.getPort(),false))
// {
// sendForbid(request,response,uri);
// }
// else
{
InputStream in = request.getInputStream();
OutputStream out = response.getOutputStream();
Socket socket = new Socket(inetAddress.getAddress(),inetAddress.getPort());
response.setStatus(200);
response.setHeader("Connection","close");
response.flushBuffer();
// TODO prevent real close!
IO.copyThread(socket.getInputStream(),out);
IO.copy(in,socket.getOutputStream());
}
}
/* ------------------------------------------------------------ */
protected HttpURI proxyHttpURI(HttpServletRequest request, String uri) throws MalformedURLException
{
return proxyHttpURI(request.getScheme(), request.getServerName(), request.getServerPort(), uri);
}
protected HttpURI proxyHttpURI(String scheme, String serverName, int serverPort, String uri) throws MalformedURLException
{
if (!validateDestination(serverName,uri))
return null;
return new HttpURI(scheme + "://" + serverName + ":" + serverPort + uri);
}
/*
* (non-Javadoc)
*
* @see javax.servlet.Servlet#getServletInfo()
*/
public String getServletInfo()
{
return "Proxy Servlet";
}
/**
* Extension point for subclasses to customize an exchange. Useful for setting timeouts etc. The default implementation does nothing.
*
* @param exchange
* @param request
*/
protected void customizeExchange(HttpExchange exchange, HttpServletRequest request)
{
}
/**
* Extension point for subclasses to customize the Continuation after it's initial creation in the service method. Useful for setting timeouts etc. The
* default implementation does nothing.
*
* @param continuation
*/
protected void customizeContinuation(Continuation continuation)
{
}
/**
* Extension point for custom handling of an HttpExchange's onConnectionFailed method. The default implementation delegates to
* {@link #handleOnException(Throwable, javax.servlet.http.HttpServletRequest, javax.servlet.http.HttpServletResponse)}
*
* @param ex
* @param request
* @param response
*/
protected void handleOnConnectionFailed(Throwable ex, HttpServletRequest request, HttpServletResponse response)
{
handleOnException(ex,request,response);
}
/**
* Extension point for custom handling of an HttpExchange's onException method. The default implementation sets the response status to
* HttpServletResponse.SC_INTERNAL_SERVER_ERROR (503)
*
* @param ex
* @param request
* @param response
*/
protected void handleOnException(Throwable ex, HttpServletRequest request, HttpServletResponse response)
{
if (ex instanceof IOException)
{
_log.warn(ex.toString());
_log.debug(ex);
}
else
_log.warn(ex);
if (!response.isCommitted())
{
response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
}
}
/**
* Extension point for custom handling of an HttpExchange's onExpire method. The default implementation sets the response status to
* HttpServletResponse.SC_GATEWAY_TIMEOUT (504)
*
* @param request
* @param response
*/
protected void handleOnExpire(HttpServletRequest request, HttpServletResponse response)
{
if (!response.isCommitted())
{
response.setStatus(HttpServletResponse.SC_GATEWAY_TIMEOUT);
}
}
/**
* Extension point for remote server response header filtering. The default implementation returns the header value as is. If null is returned, this header
* won't be forwarded back to the client.
*
* @param headerName
* @param headerValue
* @param request
* @return filteredHeaderValue
*/
protected String filterResponseHeaderValue(String headerName, String headerValue, HttpServletRequest request)
{
return headerValue;
}
/**
* Transparent Proxy.
*
* This convenience extension to ProxyServlet configures the servlet as a transparent proxy. The servlet is configured with init parameters:
* <ul>
* <li>ProxyTo - a URI like http://host:80/context to which the request is proxied.
* <li>Prefix - a URI prefix that is striped from the start of the forwarded URI.
* </ul>
* For example, if a request was received at /foo/bar and the ProxyTo was http://host:80/context and the Prefix was /foo, then the request would be proxied
* to http://host:80/context/bar
*
*/
public static class Transparent extends ProxyServlet
{
String _prefix;
String _proxyTo;
public Transparent()
{
}
public Transparent(String prefix, String host, int port)
{
this(prefix,"http",host,port,null);
}
public Transparent(String prefix, String schema, String host, int port, String path)
{
try
{
if (prefix != null)
{
_prefix = new URI(prefix).normalize().toString();
}
_proxyTo = new URI(schema,null,host,port,path,null,null).normalize().toString();
}
catch (URISyntaxException ex)
{
_log.debug("Invalid URI syntax",ex);
}
}
@Override
public void init(ServletConfig config) throws ServletException
{
super.init(config);
String prefix = config.getInitParameter("Prefix");
_prefix = prefix == null?_prefix:prefix;
// Adjust prefix value to account for context path
String contextPath = _context.getContextPath();
_prefix = _prefix == null?contextPath:(contextPath + _prefix);
String proxyTo = config.getInitParameter("ProxyTo");
_proxyTo = proxyTo == null?_proxyTo:proxyTo;
if (_proxyTo == null)
throw new UnavailableException("ProxyTo parameter is requred.");
if (!_prefix.startsWith("/"))
throw new UnavailableException("Prefix parameter must start with a '/'.");
_log.info(config.getServletName() + " @ " + _prefix + " to " + _proxyTo);
}
@Override
protected HttpURI proxyHttpURI(final String scheme, final String serverName, int serverPort, final String uri) throws MalformedURLException
{
try
{
if (!uri.startsWith(_prefix))
return null;
URI dstUri = new URI(_proxyTo + uri.substring(_prefix.length())).normalize();
if (!validateDestination(dstUri.getHost(),dstUri.getPath()))
return null;
return new HttpURI(dstUri.toString());
}
catch (URISyntaxException ex)
{
throw new MalformedURLException(ex.getMessage());
}
}
}
}