blob: f21ff2c37df7fc2404653b655ae2cec100abe5c5 [file] [log] [blame]
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you 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 org.apache.hadoop.yarn.server.webproxy;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.ObjectInputStream;
import java.io.OutputStream;
import java.io.PrintWriter;
import java.net.InetAddress;
import java.net.SocketException;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URLEncoder;
import java.util.Arrays;
import java.util.EnumSet;
import java.util.Enumeration;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import javax.servlet.ServletException;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.ws.rs.core.UriBuilder;
import javax.ws.rs.core.UriBuilderException;
import org.apache.hadoop.io.IOUtils;
import org.apache.hadoop.net.NetUtils;
import org.apache.hadoop.yarn.api.records.ApplicationId;
import org.apache.hadoop.yarn.api.records.ApplicationReport;
import org.apache.hadoop.yarn.conf.YarnConfiguration;
import org.apache.hadoop.yarn.exceptions.ApplicationNotFoundException;
import org.apache.hadoop.yarn.exceptions.YarnException;
import org.apache.hadoop.yarn.server.webproxy.AppReportFetcher.AppReportSource;
import org.apache.hadoop.yarn.server.webproxy.AppReportFetcher.FetchedAppReport;
import org.apache.hadoop.yarn.util.Apps;
import org.apache.hadoop.yarn.util.StringHelper;
import org.apache.hadoop.yarn.util.TrackingUriPlugin;
import org.apache.hadoop.yarn.webapp.MimeType;
import org.apache.hadoop.yarn.webapp.hamlet2.Hamlet;
import org.apache.hadoop.yarn.webapp.util.WebAppUtils;
import org.apache.http.Header;
import org.apache.http.HttpResponse;
import org.apache.http.NameValuePair;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.client.methods.HttpPut;
import org.apache.http.client.methods.HttpRequestBase;
import org.apache.http.client.params.ClientPNames;
import org.apache.http.client.params.CookiePolicy;
import org.apache.http.client.utils.URLEncodedUtils;
import org.apache.http.conn.params.ConnRoutePNames;
import org.apache.http.entity.StringEntity;
import org.apache.http.impl.client.DefaultHttpClient;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class WebAppProxyServlet extends HttpServlet {
private static final long serialVersionUID = 1L;
private static final Logger LOG = LoggerFactory.getLogger(
WebAppProxyServlet.class);
private static final String REDIRECT = "/redirect";
private static final Set<String> PASS_THROUGH_HEADERS =
new HashSet<>(Arrays.asList(
"User-Agent",
"Accept",
"Accept-Encoding",
"Accept-Language",
"Accept-Charset",
"Content-Type",
"Origin",
"Access-Control-Request-Method",
"Access-Control-Request-Headers"));
public static final String PROXY_USER_COOKIE_NAME = "proxy-user";
private transient List<TrackingUriPlugin> trackingUriPlugins;
private final String rmAppPageUrlBase;
private final String ahsAppPageUrlBase;
private final String failurePageUrlBase;
private transient YarnConfiguration conf;
/**
* HTTP methods.
*/
private enum HTTP { GET, POST, HEAD, PUT, DELETE };
/**
* Empty Hamlet class.
*/
private static class __ implements Hamlet.__ {
//Empty
}
private static class Page extends Hamlet {
Page(PrintWriter out) {
super(out, 0, false);
}
public HTML<WebAppProxyServlet.__> html() {
return new HTML<>("html", null, EnumSet.of(EOpt.ENDTAG));
}
}
/**
* Default constructor
*/
public WebAppProxyServlet() {
super();
conf = new YarnConfiguration();
this.trackingUriPlugins =
conf.getInstances(YarnConfiguration.YARN_TRACKING_URL_GENERATOR,
TrackingUriPlugin.class);
this.rmAppPageUrlBase =
StringHelper.pjoin(WebAppUtils.getResolvedRMWebAppURLWithScheme(conf),
"cluster", "app");
this.failurePageUrlBase =
StringHelper.pjoin(WebAppUtils.getResolvedRMWebAppURLWithScheme(conf),
"cluster", "failure");
this.ahsAppPageUrlBase =
StringHelper.pjoin(WebAppUtils.getHttpSchemePrefix(conf)
+ WebAppUtils.getAHSWebAppURLWithoutScheme(conf),
"applicationhistory", "app");
}
/**
* Output 404 with appropriate message.
* @param resp the http response.
* @param message the message to include on the page.
* @throws IOException on any error.
*/
private static void notFound(HttpServletResponse resp, String message)
throws IOException {
ProxyUtils.notFound(resp, message);
}
/**
* Warn the user that the link may not be safe!
* @param resp the http response
* @param link the link to point to
* @param user the user that owns the link.
* @throws IOException on any error.
*/
private static void warnUserPage(HttpServletResponse resp, String link,
String user, ApplicationId id) throws IOException {
//Set the cookie when we warn which overrides the query parameter
//This is so that if a user passes in the approved query parameter without
//having first visited this page then this page will still be displayed
resp.addCookie(makeCheckCookie(id, false));
resp.setContentType(MimeType.HTML);
Page p = new Page(resp.getWriter());
p.html().
h1("WARNING: The following page may not be safe!").
h3().
__("click ").a(link, "here").
__(" to continue to an Application Master web interface owned by ", user).
__().
__();
}
/**
* Download link and have it be the response.
* @param req the http request
* @param resp the http response
* @param link the link to download
* @param c the cookie to set if any
* @param proxyHost the proxy host
* @param method the http method
* @throws IOException on any error.
*/
private static void proxyLink(final HttpServletRequest req,
final HttpServletResponse resp, final URI link, final Cookie c,
final String proxyHost, final HTTP method) throws IOException {
DefaultHttpClient client = new DefaultHttpClient();
client
.getParams()
.setParameter(ClientPNames.COOKIE_POLICY,
CookiePolicy.BROWSER_COMPATIBILITY)
.setBooleanParameter(ClientPNames.ALLOW_CIRCULAR_REDIRECTS, true);
// Make sure we send the request from the proxy address in the config
// since that is what the AM filter checks against. IP aliasing or
// similar could cause issues otherwise.
InetAddress localAddress = InetAddress.getByName(proxyHost);
if (LOG.isDebugEnabled()) {
LOG.debug("local InetAddress for proxy host: {}", localAddress);
}
client.getParams()
.setParameter(ConnRoutePNames.LOCAL_ADDRESS, localAddress);
HttpRequestBase base = null;
if (method.equals(HTTP.GET)) {
base = new HttpGet(link);
} else if (method.equals(HTTP.PUT)) {
base = new HttpPut(link);
StringBuilder sb = new StringBuilder();
BufferedReader reader =
new BufferedReader(
new InputStreamReader(req.getInputStream(), "UTF-8"));
String line;
while ((line = reader.readLine()) != null) {
sb.append(line);
}
((HttpPut) base).setEntity(new StringEntity(sb.toString()));
} else {
resp.setStatus(HttpServletResponse.SC_METHOD_NOT_ALLOWED);
return;
}
@SuppressWarnings("unchecked")
Enumeration<String> names = req.getHeaderNames();
while (names.hasMoreElements()) {
String name = names.nextElement();
if (PASS_THROUGH_HEADERS.contains(name)) {
String value = req.getHeader(name);
if (LOG.isDebugEnabled()) {
LOG.debug("REQ HEADER: {} : {}", name, value);
}
base.setHeader(name, value);
}
}
String user = req.getRemoteUser();
if (user != null && !user.isEmpty()) {
base.setHeader("Cookie",
PROXY_USER_COOKIE_NAME + "=" + URLEncoder.encode(user, "ASCII"));
}
OutputStream out = resp.getOutputStream();
try {
HttpResponse httpResp = client.execute(base);
resp.setStatus(httpResp.getStatusLine().getStatusCode());
for (Header header : httpResp.getAllHeaders()) {
resp.setHeader(header.getName(), header.getValue());
}
if (c != null) {
resp.addCookie(c);
}
InputStream in = httpResp.getEntity().getContent();
if (in != null) {
IOUtils.copyBytes(in, out, 4096, true);
}
} finally {
base.releaseConnection();
}
}
private static String getCheckCookieName(ApplicationId id){
return "checked_"+id;
}
private static Cookie makeCheckCookie(ApplicationId id, boolean isSet) {
Cookie c = new Cookie(getCheckCookieName(id),String.valueOf(isSet));
c.setPath(ProxyUriUtils.getPath(id));
c.setMaxAge(60 * 60 * 2); //2 hours in seconds
return c;
}
private boolean isSecurityEnabled() {
Boolean b = (Boolean) getServletContext()
.getAttribute(WebAppProxy.IS_SECURITY_ENABLED_ATTRIBUTE);
return b != null ? b : false;
}
private FetchedAppReport getApplicationReport(ApplicationId id)
throws IOException, YarnException {
return ((AppReportFetcher) getServletContext()
.getAttribute(WebAppProxy.FETCHER_ATTRIBUTE)).getApplicationReport(id);
}
private String getProxyHost() throws IOException {
return ((String) getServletContext()
.getAttribute(WebAppProxy.PROXY_HOST_ATTRIBUTE));
}
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp)
throws ServletException, IOException {
methodAction(req, resp, HTTP.GET);
}
@Override
protected final void doPut(final HttpServletRequest req,
final HttpServletResponse resp) throws ServletException, IOException {
methodAction(req, resp, HTTP.PUT);
}
/**
* The action against the HTTP method.
* @param req the HttpServletRequest
* @param resp the HttpServletResponse
* @param method the HTTP method
* @throws ServletException
* @throws IOException
*/
private void methodAction(final HttpServletRequest req,
final HttpServletResponse resp,
final HTTP method) throws ServletException, IOException {
try {
String userApprovedParamS =
req.getParameter(ProxyUriUtils.PROXY_APPROVAL_PARAM);
boolean userWasWarned = false;
boolean userApproved = Boolean.parseBoolean(userApprovedParamS);
boolean securityEnabled = isSecurityEnabled();
boolean isRedirect = false;
String pathInfo = req.getPathInfo();
final String remoteUser = req.getRemoteUser();
String[] parts = null;
if (pathInfo != null) {
// If there's a redirect, strip the redirect so that the path can be
// parsed
if (pathInfo.startsWith(REDIRECT)) {
pathInfo = pathInfo.substring(REDIRECT.length());
isRedirect = true;
}
parts = pathInfo.split("/", 3);
}
if ((parts == null) || (parts.length < 2)) {
LOG.warn("{} gave an invalid proxy path {}", remoteUser, pathInfo);
notFound(resp, "Your path appears to be formatted incorrectly.");
return;
}
//parts[0] is empty because path info always starts with a /
String appId = parts[1];
String rest = parts.length > 2 ? parts[2] : "";
ApplicationId id = Apps.toAppID(appId);
if (id == null) {
LOG.warn("{} attempting to access {} that is invalid",
remoteUser, appId);
notFound(resp, appId + " appears to be formatted incorrectly.");
return;
}
// If this call is from an AM redirect, we need to be careful about how
// we handle it. If this method returns true, it means the method
// already redirected the response, so we can just return.
if (isRedirect && handleRedirect(appId, req, resp)) {
return;
}
if (securityEnabled) {
String cookieName = getCheckCookieName(id);
Cookie[] cookies = req.getCookies();
if (cookies != null) {
for (Cookie c : cookies) {
if (cookieName.equals(c.getName())) {
userWasWarned = true;
userApproved = userApproved || Boolean.parseBoolean(c.getValue());
break;
}
}
}
}
boolean checkUser = securityEnabled && (!userWasWarned || !userApproved);
FetchedAppReport fetchedAppReport;
try {
fetchedAppReport = getFetchedAppReport(id);
} catch (ApplicationNotFoundException e) {
fetchedAppReport = null;
}
ApplicationReport applicationReport = null;
if (fetchedAppReport != null) {
applicationReport = fetchedAppReport.getApplicationReport();
}
if (applicationReport == null) {
LOG.warn("{} attempting to access {} that was not found",
remoteUser, id);
URI toFetch =
ProxyUriUtils
.getUriFromTrackingPlugins(id, this.trackingUriPlugins);
if (toFetch != null) {
ProxyUtils.sendRedirect(req, resp, toFetch.toString());
return;
}
notFound(resp, "Application " + appId + " could not be found " +
"in RM or history server");
return;
}
URI trackingUri = getTrackingUri(req, resp, id,
applicationReport.getOriginalTrackingUrl(),
fetchedAppReport.getAppReportSource());
// If the tracking URI is null, there was a redirect, so just return.
if (trackingUri == null) {
return;
}
String runningUser = applicationReport.getUser();
if (checkUser && !runningUser.equals(remoteUser)) {
LOG.info("Asking {} if they want to connect to the "
+ "app master GUI of {} owned by {}",
remoteUser, appId, runningUser);
warnUserPage(resp, ProxyUriUtils.getPathAndQuery(id, rest,
req.getQueryString(), true), runningUser, id);
return;
}
// Append the user-provided path and query parameter to the original
// tracking url.
URI toFetch = buildTrackingUrl(trackingUri, req, rest);
LOG.info("{} is accessing unchecked {}"
+ " which is the app master GUI of {} owned by {}",
remoteUser, toFetch, appId, runningUser);
switch (applicationReport.getYarnApplicationState()) {
case KILLED:
case FINISHED:
case FAILED:
ProxyUtils.sendRedirect(req, resp, toFetch.toString());
return;
default:
// fall out of the switch
}
Cookie c = null;
if (userWasWarned && userApproved) {
c = makeCheckCookie(id, true);
}
proxyLink(req, resp, toFetch, c, getProxyHost(), method);
} catch(URISyntaxException | YarnException e) {
throw new IOException(e);
}
}
/**
* Return a URL based on the {@code trackingUri} that includes the
* user-provided path and query parameters.
*
* @param trackingUri the base tracking URI
* @param req the service request
* @param rest the user-provided path
* @return the new tracking URI
* @throws UriBuilderException if there's an error building the URL
*/
private URI buildTrackingUrl(URI trackingUri, final HttpServletRequest req,
String rest) throws UriBuilderException {
UriBuilder builder = UriBuilder.fromUri(trackingUri);
String queryString = req.getQueryString();
if (queryString != null) {
List<NameValuePair> queryPairs = URLEncodedUtils.parse(queryString, null);
for (NameValuePair pair : queryPairs) {
builder.queryParam(pair.getName(), pair.getValue());
}
}
return builder.path(rest).build();
}
/**
* Locate the tracking URI for the application based on the reported tracking
* URI. If the reported URI is invalid, redirect to the history server or RM
* app page. If the URI is valid, covert it into a usable URI object with a
* schema. If the returned URI is null, that means there was a redirect.
*
* @param req the servlet request for redirects
* @param resp the servlet response for redirects
* @param id the application ID
* @param originalUri the reported tracking URI
* @param appReportSource the source of the application report
* @return a valid tracking URI or null if redirected instead
* @throws IOException thrown if the redirect fails
* @throws URISyntaxException if the tracking URI is invalid
*/
private URI getTrackingUri(HttpServletRequest req, HttpServletResponse resp,
ApplicationId id, String originalUri, AppReportSource appReportSource)
throws IOException, URISyntaxException {
URI trackingUri = null;
if ((originalUri == null) ||
originalUri.equals("N/A") ||
originalUri.equals("")) {
if (appReportSource == AppReportSource.RM) {
// fallback to ResourceManager's app page if no tracking URI provided
// and Application Report was fetched from RM
LOG.debug("Original tracking url is '{}'. Redirecting to RM app page",
originalUri == null ? "NULL" : originalUri);
ProxyUtils.sendRedirect(req, resp,
StringHelper.pjoin(rmAppPageUrlBase, id.toString()));
} else if (appReportSource == AppReportSource.AHS) {
// fallback to Application History Server app page if the application
// report was fetched from AHS
LOG.debug("Original tracking url is '{}'. Redirecting to AHS app page",
originalUri == null ? "NULL" : originalUri);
ProxyUtils.sendRedirect(req, resp,
StringHelper.pjoin(ahsAppPageUrlBase, id.toString()));
}
} else if (ProxyUriUtils.getSchemeFromUrl(originalUri).isEmpty()) {
trackingUri =
ProxyUriUtils.getUriFromAMUrl(WebAppUtils.getHttpSchemePrefix(conf),
originalUri);
} else {
trackingUri = new URI(originalUri);
}
return trackingUri;
}
/**
* Fetch the application report from the RM.
*
* @param id the app ID
* @return the application report
* @throws IOException if the request to the RM fails
* @throws YarnException if the request to the RM fails
*/
private FetchedAppReport getFetchedAppReport(ApplicationId id)
throws IOException, YarnException {
FetchedAppReport fetchedAppReport = getApplicationReport(id);
if (fetchedAppReport != null) {
if ((fetchedAppReport.getAppReportSource() != AppReportSource.RM) &&
(fetchedAppReport.getAppReportSource() != AppReportSource.AHS)) {
throw new UnsupportedOperationException("Application report not "
+ "fetched from RM or history server.");
}
}
return fetchedAppReport;
}
/**
* Check whether the request is a redirect from the AM and handle it
* appropriately. This check exists to prevent the AM from forwarding back to
* the web proxy, which would contact the AM again, which would forward
* again... If this method returns true, there was a redirect, and
* it was handled by redirecting the current request to an error page.
*
* @param path the part of the request path after the app id
* @param id the app id
* @param req the request object
* @param resp the response object
* @return whether there was a redirect
* @throws IOException if a redirect fails
*/
private boolean handleRedirect(String id, HttpServletRequest req,
HttpServletResponse resp) throws IOException {
// If this isn't a redirect, we don't care.
boolean badRedirect = false;
// If this is a redirect, check if we're calling ourselves.
try {
badRedirect = NetUtils.getLocalInetAddress(req.getRemoteHost()) != null;
} catch (SocketException ex) {
// This exception means we can't determine the calling host. Odds are
// that means it's not us. Let it go and hope it works out better next
// time.
}
// If the proxy tries to call itself, it gets into an endless
// loop and consumes all available handler threads until the
// application completes. Redirect to the app page with a flag
// that tells it to print an appropriate error message.
if (badRedirect) {
LOG.error("The AM's web app redirected the RM web proxy's request back "
+ "to the web proxy. The typical cause is that the AM is resolving "
+ "the RM's address as something other than what it expects. Check "
+ "your network configuration and the value of the "
+ "yarn.web-proxy.address property. Once the host resolution issue "
+ "has been resolved, you will likely need to delete the "
+ "misbehaving application, " + id);
String redirect = StringHelper.pjoin(failurePageUrlBase, id);
LOG.error("REDIRECT: sending redirect to " + redirect);
ProxyUtils.sendRedirect(req, resp, redirect);
}
return badRedirect;
}
/**
* This method is used by Java object deserialization, to fill in the
* transient {@link #trackingUriPlugins} field.
* See {@link ObjectInputStream#defaultReadObject()}
* <p>
* <I>Do not remove</I>
* <p>
* YARN isn't currently serializing this class, but findbugs
* complains in its absence.
*
*
* @param input source
* @throws IOException IO failure
* @throws ClassNotFoundException classloader fun
*/
private void readObject(ObjectInputStream input)
throws IOException, ClassNotFoundException {
input.defaultReadObject();
conf = new YarnConfiguration();
this.trackingUriPlugins =
conf.getInstances(YarnConfiguration.YARN_TRACKING_URL_GENERATOR,
TrackingUriPlugin.class);
}
}