blob: 360ba35e8c943d5a482f0d3e8f5831e654b5c4c6 [file] [log] [blame]
// Copyright 2014 Google Inc. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package com.google.enterprise.adaptor;
import com.google.common.collect.ImmutableSortedSet;
import com.google.common.collect.Sets;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpHandler;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.Authenticator;
import java.net.HttpURLConnection;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.logging.Level;
import java.util.logging.Logger;
/**
* An HTTP reverse proxy. This class uses HttpURLConnection internally, so the
* default {@link Authenticator} must be {@code null} for proper operation. The
* implementation is known to drop the response body for 401 responses to POST
* requests.
*/
class ReverseProxyHandler implements HttpHandler {
// The Hop-by-hop headers that are connection-local, as defined by RFC 2616
// Section 13.5.1. As mentioned in the section, additional headers can be
// specified in the Connection header.
private static final Set<String> PREDEFINED_HOP_BY_HOP_HEADERS
= ImmutableSortedSet.orderedBy(String.CASE_INSENSITIVE_ORDER).add(
"Connection",
"Host", // Because this is a reverse proxy.
"Keep-Alive",
"Proxy-Authenticate",
"Proxy-Authorization",
"TE",
"Trailers",
"Transfer-Encoding",
"Upgrade").build();
private static final Logger log
= Logger.getLogger(HttpExchanges.class.getName());
private final URI destinationBase;
public ReverseProxyHandler(URI destinationBase) {
if (destinationBase == null) {
throw new NullPointerException();
}
this.destinationBase = destinationBase;
if (destinationBase.getScheme() == null || destinationBase.getHost() == null
|| destinationBase.getPath() == null) {
throw new IllegalArgumentException(
"destinationBase must contain a scheme, host, and path");
}
}
@Override
public void handle(HttpExchange ex) throws IOException {
URI dest = computeProxyDestination(ex);
// Set up request
HttpURLConnection conn = (HttpURLConnection) dest.toURL().openConnection();
conn.setRequestMethod(ex.getRequestMethod());
conn.setAllowUserInteraction(false);
conn.setInstanceFollowRedirects(false);
// This adds Cache-Control: no-cache and Pragma: no-cache if those headers
// keys aren't already there. It is unfortunate that it modifies the
// request, however, the presense of these headers shouldn't have an impact
// because they are targeted to proxy servers, not the application server.
// See RFC 2616 Section 14.9.1 and 14.32.
conn.setUseCaches(false);
// HttpUrlConnection will add Accept and User-Agent headers if they aren't
// already there. Luckily they are almost always there.
copyRequestHeaders(ex, conn);
conn.addRequestProperty("X-Forwarded-For", getClientIp(ex));
// As defined in RFC 2616 Section 4.3
boolean hasRequestBody
= ex.getRequestHeaders().containsKey("Content-Length")
|| ex.getRequestHeaders().containsKey("Transfer-Encoding");
conn.setDoOutput(hasRequestBody);
// Input is required for getResponseCode()
conn.setDoInput(true);
if (hasRequestBody) {
String strContentLength
= ex.getRequestHeaders().getFirst("Content-Length");
long contentLength = -1;
if (strContentLength != null) {
try {
contentLength = Long.parseLong(strContentLength);
} catch (NumberFormatException e) {
// Keep contentLength = -1
}
}
// Enable streaming mode on the connection to prevent entire request body
// from being buffered. Streaming mode also disables automatic
// authentication.
if (contentLength < 0) {
conn.setChunkedStreamingMode(-1);
} else {
// TODO(ejona): remove cast once we depend on Java 7.
conn.setFixedLengthStreamingMode((int) contentLength);
}
} else {
// It is essential that no authentication happens. Unfortunately for
// requests with no body (like GET), it seems impossible to detect if
// authentication could be or was performed and impossible to prevent it
// for a particular request.
//
// Thus, we disable authentication globally, which could easily break
// other code's assumptions. However, the hope is that the other code will
// begin breaking and we have a chance at discovering the incompatibility
// between the two pieces of code, instead of silently offering content to
// requesters that they are not authenticated for.
Authenticator.setDefault(null);
}
// Actually issue the request, and copy the data back
conn.connect();
if (hasRequestBody) {
try {
IOHelper.copyStream(ex.getRequestBody(), conn.getOutputStream());
ex.getRequestBody().close();
conn.getOutputStream().close();
} catch (IOException e) {
// TODO(ejona): determine if there is a more graceful way to clean up
conn.disconnect();
throw e;
}
}
InputStream inputStream = conn.getResponseCode() >= 400
? conn.getErrorStream() : conn.getInputStream();
// InputStream is null when responseCode == 401 and doing streaming, because
// HttpURLConnection does not consider its state "connected". Unfortunately,
// that means that we will lose the content for 401 responses for POST.
if (conn.getResponseCode() == 401 && inputStream == null) {
inputStream = new ByteArrayInputStream(new byte[0]);
}
// As defined in RFC 2616 Section 4.3
boolean hasResponseBody = !("HEAD".equalsIgnoreCase(ex.getRequestMethod())
|| conn.getResponseCode() / 100 == 1 // 1xx Informational
|| conn.getResponseCode() == 204 // No Content
|| conn.getResponseCode() == 304); // Not Modified
try {
copyResponseHeaders(conn, ex);
if (!hasResponseBody) {
ex.sendResponseHeaders(conn.getResponseCode(), -1);
} else {
int contentLength = conn.getContentLength();
if (contentLength <= 0) {
// If the content length was unknown (-1) or if it was zero, then we
// are forced to use chunked transfer encoding.
contentLength = 0;
}
ex.sendResponseHeaders(conn.getResponseCode(), contentLength);
IOHelper.copyStream(inputStream, ex.getResponseBody());
}
// Don't close in a finally because that would be a successful response.
// If there is an error we want the server to kill the connection, which
// informs the client that something went wrong.
ex.close();
} finally {
inputStream.close();
}
}
/**
* Compute the URI the proxy should send a request to, to proxy the provided
* request.
*/
private URI computeProxyDestination(HttpExchange ex) {
URI req = HttpExchanges.getRequestUri(ex);
final String basePath = ex.getHttpContext().getPath();
if (!req.getPath().startsWith(basePath)) {
throw new AssertionError();
}
String lastPartOfPath = req.getPath().substring(basePath.length());
try {
return new URI(destinationBase.getScheme(),
destinationBase.getAuthority(),
destinationBase.getPath() + lastPartOfPath, req.getQuery(),
req.getFragment());
} catch (URISyntaxException e) {
throw new AssertionError(e);
}
}
/**
* Compute the equivalent URI to this proxy provided a possible URI pointing
* to the destination server.
*
* <p>Redirects and similar require absolute URIs, so the destination server
* is forced to expose its hostname and IP. We could pass the original
* Host in the request (a la Apache's ProxyPreserveHost), but that is not
* sufficent because it can't specify the path. So instead, we rewrite
* such response headers when they match the destinationUri so they point to
* the proxy instead of the destination (a la Apache's ProxyPassReverse).
*/
private String computeReverseProxyDestination(HttpExchange ex,
String location) {
URI uri = null;
try {
uri = new URI(location);
} catch (URISyntaxException e) {
log.log(Level.INFO, "Could not parse value from header: " + location, e);
return location;
}
URI base = destinationBase;
// Check if the value starts with destinationBase. Use URI so that we
// can correctly take case into consideration as appropriate.
if (!(base.getScheme().equalsIgnoreCase(uri.getScheme())
&& base.getHost().equalsIgnoreCase(uri.getHost())
&& base.getPort() == uri.getPort()
&& uri.getPath() != null
// This is correctly case-sensitive.
&& uri.getPath().startsWith(base.getPath()))) {
return location;
}
String lastPartOfPath = uri.getPath().substring(base.getPath().length());
URI requestUri = HttpExchanges.getRequestUri(ex);
try {
return new URI(requestUri.getScheme(), requestUri.getAuthority(),
ex.getHttpContext().getPath() + lastPartOfPath, uri.getQuery(),
uri.getFragment()).toASCIIString();
} catch (URISyntaxException e) {
throw new AssertionError(e);
}
}
private void copyRequestHeaders(HttpExchange from, HttpURLConnection to) {
Set<String> requestHopByHopHeaders
= getHopByHopHeaders(from.getRequestHeaders().get("Connection"));
for (Map.Entry<String, List<String>> me
: from.getRequestHeaders().entrySet()) {
if (requestHopByHopHeaders.contains(me.getKey())) {
continue;
}
for (String value : me.getValue()) {
to.addRequestProperty(me.getKey(), value);
}
}
}
private void copyResponseHeaders(HttpURLConnection from, HttpExchange to) {
Set<String> responseHopByHopHeaders
= getHopByHopHeaders(from.getHeaderFields().get("Connection"));
// HttpURLConnection.getHeaderFields() reverses the order of repeated
// headers' values, so we avoid it. Specifically,
// sun.net.www.MessageHeader.filterAndAddHeaders() loops through the
// headers in reverse order while building the map.
for (int i = 0;; i++) {
String key = from.getHeaderFieldKey(i);
String value = from.getHeaderField(i);
if (value == null) {
break; // Reached end
}
if (key == null) {
continue; // Value is the HTTP status line, which we want to skip
}
if (responseHopByHopHeaders.contains(key)) {
continue;
}
if ("Location".equalsIgnoreCase(key)
|| "Content-Location".equalsIgnoreCase(key)
|| "URI".equalsIgnoreCase(key)) {
value = computeReverseProxyDestination(to, value);
}
to.getResponseHeaders().add(key, value);
}
}
private String getClientIp(HttpExchange ex) {
InetSocketAddress clientAddr = ex.getRemoteAddress();
if (clientAddr.isUnresolved()) {
log.log(Level.WARNING, "Could not determine client's IP");
return "unknown";
}
return clientAddr.getAddress().getHostAddress();
}
private Set<String> getHopByHopHeaders(List<String> connectionValues) {
List<String> rawConnectionHeaders
= HttpExchanges.splitHeaderValues(connectionValues);
if (rawConnectionHeaders == null) {
rawConnectionHeaders = Collections.emptyList();
}
Set<String> connectionHeaders
= ImmutableSortedSet.orderedBy(String.CASE_INSENSITIVE_ORDER)
.addAll(rawConnectionHeaders).build();
return Sets.union(PREDEFINED_HOP_BY_HOP_HEADERS, connectionHeaders);
}
}