/* * Copyright 2017 the original author or authors. * * 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 org.glowroot.ui; import java.io.IOException; import java.io.PrintWriter; import java.io.StringWriter; import java.lang.annotation.Annotation; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.net.URL; import java.sql.SQLException; import java.util.Date; import java.util.List; import java.util.Map; import java.util.Map.Entry; import java.util.regex.Matcher; import java.util.regex.Pattern; import javax.annotation.Nullable; import com.fasterxml.jackson.core.JsonGenerator; import com.fasterxml.jackson.databind.ObjectMapper; import com.google.common.base.Charsets; import com.google.common.base.Joiner; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; import com.google.common.collect.Lists; import com.google.common.io.CharStreams; import com.google.common.io.Resources; import com.google.common.net.MediaType; import io.netty.buffer.Unpooled; import io.netty.handler.codec.http.DefaultHttpHeaders; import io.netty.handler.codec.http.HttpHeaderNames; import io.netty.handler.codec.http.HttpHeaders; import io.netty.handler.codec.http.HttpResponseStatus; import org.immutables.value.Value; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.glowroot.agent.api.Glowroot; import org.glowroot.common.util.Clock; import org.glowroot.common.util.ObjectMappers; import org.glowroot.ui.HttpSessionManager.Authentication; import static com.google.common.base.Preconditions.checkNotNull; import static com.google.common.base.Preconditions.checkState; import static io.netty.handler.codec.http.HttpResponseStatus.BAD_REQUEST; import static io.netty.handler.codec.http.HttpResponseStatus.FORBIDDEN; import static io.netty.handler.codec.http.HttpResponseStatus.INTERNAL_SERVER_ERROR; import static io.netty.handler.codec.http.HttpResponseStatus.NOT_FOUND; import static io.netty.handler.codec.http.HttpResponseStatus.NOT_MODIFIED; import static io.netty.handler.codec.http.HttpResponseStatus.OK; import static io.netty.handler.codec.http.HttpResponseStatus.REQUEST_TIMEOUT; import static io.netty.handler.codec.http.HttpResponseStatus.UNAUTHORIZED; import static java.util.concurrent.TimeUnit.DAYS; import static java.util.concurrent.TimeUnit.MINUTES; public class CommonHandler { private static final Logger logger = LoggerFactory.getLogger(CommonHandler.class); private static final Logger auditLogger = LoggerFactory.getLogger("audit"); private static final ObjectMapper mapper = ObjectMappers.create(); private static final long TEN_YEARS = DAYS.toMillis(365 * 10); private static final long ONE_DAY = DAYS.toMillis(1); private static final long FIVE_MINUTES = MINUTES.toMillis(5); private static final String RESOURCE_BASE = "org/glowroot/ui/app-dist"; // only null when running tests with glowroot.ui.skip=true (e.g. travis "deploy" build) private static final @Nullable String RESOURCE_BASE_URL_PREFIX; private static final ImmutableMap<String, MediaType> mediaTypes = ImmutableMap.<String, MediaType>builder() .put("html", MediaType.HTML_UTF_8) .put("js", MediaType.JAVASCRIPT_UTF_8) .put("css", MediaType.CSS_UTF_8) .put("ico", MediaType.ICO) .put("woff", MediaType.WOFF) .put("woff2", MediaType.create("application", "font-woff2")) .put("swf", MediaType.create("application", "vnd.adobe.flash-movie")) .put("map", MediaType.JSON_UTF_8) .build(); // this constant is from org.h2.api.ErrorCode.STATEMENT_WAS_CANCELED // (but h2 jar is not a dependency of glowroot-ui) private static final int H2_STATEMENT_WAS_CANCELED = 57014; static { URL resourceBaseUrl = getUrlForPath(RESOURCE_BASE); if (resourceBaseUrl == null) { RESOURCE_BASE_URL_PREFIX = null; } else { RESOURCE_BASE_URL_PREFIX = resourceBaseUrl.toExternalForm(); } } private final LayoutService layoutService; private final ImmutableMap<Pattern, HttpService> httpServices; private final ImmutableList<JsonServiceMapping> jsonServiceMappings; private final HttpSessionManager httpSessionManager; private final Clock clock; public CommonHandler(LayoutService layoutService, Map<Pattern, HttpService> httpServices, HttpSessionManager httpSessionManager, List<Object> jsonServices, Clock clock) { this.layoutService = layoutService; this.httpServices = ImmutableMap.copyOf(httpServices); this.httpSessionManager = httpSessionManager; this.clock = clock; List<JsonServiceMapping> jsonServiceMappings = Lists.newArrayList(); for (Object jsonService : jsonServices) { for (Method method : jsonService.getClass().getDeclaredMethods()) { GET annotationGET = method.getAnnotation(GET.class); if (annotationGET != null) { jsonServiceMappings.add(build(HttpMethod.GET, annotationGET.path(), annotationGET.permission(), jsonService, method)); } POST annotationPOST = method.getAnnotation(POST.class); if (annotationPOST != null) { jsonServiceMappings.add(build(HttpMethod.POST, annotationPOST.path(), annotationPOST.permission(), jsonService, method)); } } } this.jsonServiceMappings = ImmutableList.copyOf(jsonServiceMappings); } public CommonResponse handle(CommonRequest request) throws Exception { logger.debug("handleRequest(): path={}", request.getPath()); CommonResponse response = handleIfLoginOrLogoutRequest(request); if (response != null) { return response; } boolean autoRefresh = isAutoRefresh(request.getParameters("auto-refresh")); boolean touchSession = !autoRefresh && !request.getPath().equals("/backend/layout"); Authentication authentication = httpSessionManager.getAuthentication(request, touchSession); Glowroot.setTransactionUser(authentication.caseAmbiguousUsername()); response = handleRequest(request, authentication); if (request.getPath().startsWith("/backend/") && !request.getPath().equals("/backend/layout")) { response.setHeader("Glowroot-Layout-Version", layoutService.getLayoutVersion(authentication)); } return response; } private @Nullable CommonResponse handleIfLoginOrLogoutRequest(CommonRequest request) throws Exception { String path = request.getPath(); if (path.equals("/backend/login")) { String content = request.getContent(); Credentials credentials = mapper.readValue(content, ImmutableCredentials.class); Glowroot.setTransactionUser(credentials.username()); return httpSessionManager.login(credentials.username(), credentials.password()); } if (path.equals("/backend/sign-out")) { httpSessionManager.signOut(request); Authentication authentication = httpSessionManager.getAnonymousAuthentication(); Glowroot.setTransactionUser(authentication.caseAmbiguousUsername()); String anonymousLayout = layoutService.getLayoutJson(authentication); CommonResponse response = new CommonResponse(OK, MediaType.JSON_UTF_8, anonymousLayout); httpSessionManager.deleteSessionCookie(response); return response; } if (path.equals("/backend/check-layout")) { Authentication authentication = httpSessionManager.getAuthentication(request, false); CommonResponse response = new CommonResponse(OK); response.setHeader("Glowroot-Layout-Version", layoutService.getLayoutVersion(authentication)); return response; } if (path.equals("/backend/layout")) { Authentication authentication = httpSessionManager.getAuthentication(request, false); return new CommonResponse(OK, MediaType.JSON_UTF_8, layoutService.getLayoutJson(authentication)); } return null; } private CommonResponse handleRequest(CommonRequest request, Authentication authentication) throws Exception { String path = request.getPath(); HttpService httpService = getHttpService(path); if (httpService != null) { return handleHttpService(request, httpService, authentication); } JsonServiceMapping jsonServiceMapping = getJsonServiceMapping(request, path); if (jsonServiceMapping != null) { return handleJsonServiceMappings(request, jsonServiceMapping, authentication); } return handleStaticResource(path, request); } private @Nullable HttpService getHttpService(String path) throws Exception { for (Entry<Pattern, HttpService> entry : httpServices.entrySet()) { Matcher matcher = entry.getKey().matcher(path); if (matcher.matches()) { return entry.getValue(); } } return null; } private CommonResponse handleHttpService(CommonRequest request, HttpService httpService, Authentication authentication) throws Exception { String permission = httpService.getPermission(); if (permission.equals("")) { // service does not require any permission return httpService.handleRequest(request, authentication); } List<String> agentRollupIds = request.getParameters("agent-rollup-id"); String agentRollupId = agentRollupIds.isEmpty() ? "" : agentRollupIds.get(0); if (!authentication.isPermitted(agentRollupId, permission)) { return handleNotAuthorized(request, authentication); } return httpService.handleRequest(request, authentication); } private @Nullable JsonServiceMapping getJsonServiceMapping(CommonRequest request, String path) { for (JsonServiceMapping jsonServiceMapping : jsonServiceMappings) { if (!jsonServiceMapping.httpMethod().name().equals(request.getMethod())) { continue; } if (jsonServiceMapping.path().equals(path)) { return jsonServiceMapping; } } return null; } private CommonResponse handleJsonServiceMappings(CommonRequest request, JsonServiceMapping jsonServiceMapping, Authentication authentication) throws Exception { List<Class<?>> parameterTypes = Lists.newArrayList(); List<Object> parameters = Lists.newArrayList(); Map<String, List<String>> queryParameters = request.getParameters(); boolean permitted; if (jsonServiceMapping.bindAgentId()) { List<String> values = queryParameters.get("agent-id"); if (values == null) { throw new JsonServiceException(BAD_REQUEST, "missing agent-id query parameter"); } String agentId = values.get(0); parameterTypes.add(String.class); parameters.add(agentId); queryParameters.remove("agent-id"); permitted = authentication.isAgentPermitted(agentId, jsonServiceMapping.permission()); } else if (jsonServiceMapping.bindAgentRollup()) { List<String> agentRollupIds = queryParameters.get("agent-rollup-id"); if (agentRollupIds == null) { throw new JsonServiceException(BAD_REQUEST, "missing agent-rollup-id query parameter"); } String agentRollupId = agentRollupIds.get(0); parameterTypes.add(String.class); parameters.add(agentRollupId); queryParameters.remove("agent-rollup-id"); permitted = authentication.isAgentPermitted(agentRollupId, jsonServiceMapping.permission()); } else { permitted = jsonServiceMapping.permission().isEmpty() || authentication.isAdminPermitted(jsonServiceMapping.permission()); } if (!permitted) { return handleNotAuthorized(request, authentication); } Object responseObject; try { responseObject = callMethod(jsonServiceMapping, parameterTypes, parameters, queryParameters, authentication, request); } catch (Exception e) { return newHttpResponseFromException(request, authentication, e); } return buildJsonResponse(responseObject); } CommonResponse newHttpResponseFromException(CommonRequest request, Authentication authentication, Exception exception) throws Exception { Exception e = exception; if (e instanceof InvocationTargetException) { Throwable cause = e.getCause(); if (cause instanceof Exception) { e = (Exception) cause; } } if (e instanceof JsonServiceException) { // this is an "expected" exception, no need to log JsonServiceException jsonServiceException = (JsonServiceException) e; if (jsonServiceException.getStatus() == FORBIDDEN) { return handleNotAuthorized(request, authentication); } else { return newHttpResponseWithMessage(jsonServiceException.getStatus(), jsonServiceException.getMessage()); } } logger.error(e.getMessage(), e); if (e instanceof SQLException && ((SQLException) e).getErrorCode() == H2_STATEMENT_WAS_CANCELED) { return newHttpResponseWithMessage(REQUEST_TIMEOUT, "Query timed out (timeout is configurable under Configuration > Advanced)"); } return newHttpResponseWithStackTrace(e, INTERNAL_SERVER_ERROR, null); } private CommonResponse buildJsonResponse(@Nullable Object responseObject) { if (responseObject == null) { return new CommonResponse(OK, MediaType.JSON_UTF_8, ""); } else if (responseObject instanceof CommonResponse) { return (CommonResponse) responseObject; } else if (responseObject instanceof String) { return new CommonResponse(OK, MediaType.JSON_UTF_8, (String) responseObject); } else { logger.warn("unexpected type of json service response: {}", responseObject.getClass().getName()); return new CommonResponse(INTERNAL_SERVER_ERROR); } } private CommonResponse handleNotAuthorized(CommonRequest request, Authentication authentication) throws Exception { if (authentication.anonymous()) { if (httpSessionManager.getSessionId(request) != null) { return new CommonResponse(UNAUTHORIZED, MediaType.JSON_UTF_8, "{\"timedOut\":true}"); } else { return new CommonResponse(UNAUTHORIZED); } } else { return new CommonResponse(FORBIDDEN); } } private CommonResponse handleStaticResource(String path, CommonRequest request) throws IOException { URL url = getSecureUrlForPath(RESOURCE_BASE + path); if (url == null) { // log at debug only since this is typically just exploit bot spam logger.debug("unexpected path: {}", path); return new CommonResponse(NOT_FOUND); } Date expires = getExpiresForPath(path); if (request.getHeader(HttpHeaderNames.IF_MODIFIED_SINCE) != null && expires == null) { // all static resources without explicit expires are versioned and can be safely // cached forever return new CommonResponse(NOT_MODIFIED); } int extensionStartIndex = path.lastIndexOf('.'); checkState(extensionStartIndex != -1, "found path under %s with no extension: %s", RESOURCE_BASE, path); String extension = path.substring(extensionStartIndex + 1); MediaType mediaType = mediaTypes.get(extension); checkNotNull(mediaType, "found extension under %s with no media type: %s", RESOURCE_BASE, extension); CommonResponse response = new CommonResponse(OK, mediaType, url); if (expires != null) { response.setHeader(HttpHeaderNames.EXPIRES, expires); } else { response.setHeader(HttpHeaderNames.LAST_MODIFIED, new Date(0)); response.setHeader(HttpHeaderNames.EXPIRES, new Date(clock.currentTimeMillis() + TEN_YEARS)); } return response; } private @Nullable Date getExpiresForPath(String path) { if (path.startsWith("org/glowroot/ui/app-dist/favicon.")) { return new Date(clock.currentTimeMillis() + ONE_DAY); } else if (path.endsWith(".js.map") || path.startsWith("/sources/")) { // javascript source maps and source files are not versioned return new Date(clock.currentTimeMillis() + FIVE_MINUTES); } else { return null; } } private static JsonServiceMapping build(HttpMethod httpMethod, String path, String permission, Object jsonService, Method method) { boolean bindAgentId = false; boolean bindAgentRollup = false; Class<?> bindRequest = null; boolean bindAutoRefresh = false; boolean bindAuthentication = false; for (int i = 0; i < method.getParameterAnnotations().length; i++) { Annotation[] parameterAnnotations = method.getParameterAnnotations()[i]; for (Annotation annotation : parameterAnnotations) { if (annotation.annotationType() == BindAgentId.class) { bindAgentId = true; } else if (annotation.annotationType() == BindAgentRollupId.class) { bindAgentRollup = true; } else if (annotation.annotationType() == BindRequest.class) { bindRequest = method.getParameterTypes()[i]; } else if (annotation.annotationType() == BindAutoRefresh.class) { bindAutoRefresh = true; } else if (annotation.annotationType() == BindAuthentication.class) { bindAuthentication = true; } } } return ImmutableJsonServiceMapping.builder() .httpMethod(httpMethod) .path(path) .permission(permission) .service(jsonService) .method(method) .bindAgentId(bindAgentId) .bindAgentRollup(bindAgentRollup) .bindRequest(bindRequest) .bindAutoRefresh(bindAutoRefresh) .bindAuthentication(bindAuthentication) .build(); } private static @Nullable URL getSecureUrlForPath(String path) { URL url = getUrlForPath(path); if (url != null && RESOURCE_BASE_URL_PREFIX != null && url.toExternalForm().startsWith(RESOURCE_BASE_URL_PREFIX)) { return url; } return null; } private static @Nullable URL getUrlForPath(String path) { ClassLoader classLoader = HttpServerHandler.class.getClassLoader(); if (classLoader == null) { return ClassLoader.getSystemResource(path); } else { return classLoader.getResource(path); } } private static CommonResponse newHttpResponseWithMessage(HttpResponseStatus status, @Nullable String message) { // this is an "expected" exception, no need to send back stack trace StringBuilder sb = new StringBuilder(); try { JsonGenerator jg = mapper.getFactory().createGenerator(CharStreams.asWriter(sb)); jg.writeStartObject(); jg.writeStringField("message", message); jg.writeEndObject(); jg.close(); } catch (IOException f) { logger.error(f.getMessage(), f); return new CommonResponse(INTERNAL_SERVER_ERROR); } return new CommonResponse(status, MediaType.JSON_UTF_8, sb.toString()); } static CommonResponse newHttpResponseWithStackTrace(Exception e, HttpResponseStatus status, @Nullable String simplifiedMessage) { try { return new CommonResponse(status, MediaType.JSON_UTF_8, getHttpResponseWithStackTrace(e, simplifiedMessage)); } catch (IOException f) { logger.error(f.getMessage(), f); return new CommonResponse(INTERNAL_SERVER_ERROR); } } static String getHttpResponseWithStackTrace(Exception e, @Nullable String simplifiedMessage) throws IOException { StringWriter sw = new StringWriter(); e.printStackTrace(new PrintWriter(sw)); StringBuilder sb = new StringBuilder(); JsonGenerator jg = mapper.getFactory().createGenerator(CharStreams.asWriter(sb)); jg.writeStartObject(); String message; if (simplifiedMessage == null) { Throwable cause = e; Throwable childCause = cause.getCause(); while (childCause != null) { cause = childCause; childCause = cause.getCause(); } message = cause.getMessage(); } else { message = simplifiedMessage; } jg.writeStringField("message", message); jg.writeStringField("stackTrace", sw.toString()); jg.writeEndObject(); jg.close(); return sb.toString(); } private static @Nullable Object callMethod(JsonServiceMapping jsonServiceMapping, List<Class<?>> parameterTypes, List<Object> parameters, Map<String, List<String>> queryParameters, Authentication authentication, CommonRequest request) throws Exception { List<String> autoRefreshParams = queryParameters.remove("auto-refresh"); boolean autoRefresh = isAutoRefresh(autoRefreshParams); Class<?> bindRequest = jsonServiceMapping.bindRequest(); if (bindRequest != null) { parameterTypes.add(bindRequest); if (jsonServiceMapping.httpMethod() == HttpMethod.GET) { parameters.add(QueryStrings.decode(queryParameters, bindRequest)); } else { String content = request.getContent(); auditLogger.info("{} - POST {} - {}", authentication.caseAmbiguousUsername(), request.getUri(), content); if (bindRequest == String.class) { parameters.add(content); } else { // TODO report checker framework issue that occurs without this suppression @SuppressWarnings("argument.type.incompatible") Object param = checkNotNull( mapper.readValue(content, QueryStrings.getImmutableClass(bindRequest))); parameters.add(param); } } } if (jsonServiceMapping.bindAutoRefresh()) { parameterTypes.add(boolean.class); parameters.add(autoRefresh); } if (jsonServiceMapping.bindAuthentication()) { parameterTypes.add(Authentication.class); parameters.add(authentication); } Object service = jsonServiceMapping.service(); if (logger.isDebugEnabled()) { String params = Joiner.on(", ").join(parameters); logger.debug("{}.{}(): {}", service.getClass().getSimpleName(), jsonServiceMapping.method().getName(), params); } return jsonServiceMapping.method().invoke(service, parameters.toArray(new Object[parameters.size()])); } private static boolean isAutoRefresh(@Nullable List<String> autoRefreshParams) { return autoRefreshParams != null && autoRefreshParams.size() == 1 && Boolean.valueOf(autoRefreshParams.get(0)); } @Value.Immutable interface Credentials { String username(); String password(); } @Value.Immutable interface JsonServiceMapping { HttpMethod httpMethod(); String path(); String permission(); Object service(); Method method(); boolean bindAgentId(); boolean bindAgentRollup(); @Nullable Class<?> bindRequest(); boolean bindAutoRefresh(); boolean bindAuthentication(); } enum HttpMethod { GET, POST } public interface CommonRequest { String getMethod(); // includes context path String getUri(); String getContextPath(); // does not include context path String getPath(); @Nullable String getHeader(CharSequence name); Map<String, List<String>> getParameters(); List<String> getParameters(String name); String getContent() throws IOException; } public static class CommonResponse { private final HttpResponseStatus status; private final HttpHeaders headers = new DefaultHttpHeaders(); private final Object content; private @Nullable String zipFileName; private boolean closeConnectionAfterPortChange; CommonResponse(HttpResponseStatus status, MediaType mediaType, String content) { this(status, mediaType, Unpooled.copiedBuffer(content, Charsets.UTF_8), true); } CommonResponse(HttpResponseStatus status, MediaType mediaType, ChunkSource content) { this(status, mediaType, content, true); } CommonResponse(HttpResponseStatus status) { this(status, null, Unpooled.buffer(0), true); } private CommonResponse(HttpResponseStatus status, MediaType mediaType, URL url) throws IOException { this(status, mediaType, Unpooled.copiedBuffer(Resources.toByteArray(url)), false); } private CommonResponse(HttpResponseStatus status, @Nullable MediaType mediaType, Object content, boolean preventCaching) { this.status = status; this.content = content; if (mediaType != null) { headers.set(HttpHeaderNames.CONTENT_TYPE, mediaType); } if (preventCaching) { // prevent caching of dynamic json data, using 'definitive' minimum set of headers // from http://stackoverflow.com/questions/49547/ // making-sure-a-web-page-is-not-cached-across-all-browsers/2068407#2068407 headers.set(HttpHeaderNames.CACHE_CONTROL, "no-cache, no-store, must-revalidate"); headers.set(HttpHeaderNames.PRAGMA, "no-cache"); headers.set(HttpHeaderNames.EXPIRES, new Date(0)); } } void setHeader(CharSequence name, Object value) { headers.set(name, value); } void setZipFileName(String zipFileName) { this.zipFileName = zipFileName; } void setCloseConnectionAfterPortChange() { closeConnectionAfterPortChange = true; } public HttpResponseStatus getStatus() { return status; } public HttpHeaders getHeaders() { return headers; } // returns ByteBuf or ChunkSource public Object getContent() { return content; } public @Nullable String getZipFileName() { return zipFileName; } boolean isCloseConnectionAfterPortChange() { return closeConnectionAfterPortChange; } } }