aboutsummaryrefslogtreecommitdiffstats
path: root/container-core/src/main/java/com/yahoo/jdisc/http/server
diff options
context:
space:
mode:
Diffstat (limited to 'container-core/src/main/java/com/yahoo/jdisc/http/server')
-rw-r--r--container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/AccessLogRequestLog.java167
-rw-r--r--container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/AccessLoggingRequestHandler.java59
-rw-r--r--container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/AsyncCompleteListener.java22
-rw-r--r--container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/CompletionHandlerUtils.java14
-rw-r--r--container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/CompletionHandlers.java57
-rw-r--r--container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/ConnectionThrottler.java274
-rw-r--r--container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/ConnectorFactory.java140
-rw-r--r--container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/ErrorResponseContentCreator.java41
-rw-r--r--container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/ExceptionWrapper.java59
-rw-r--r--container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/FilterBindings.java102
-rw-r--r--container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/FilterInvoker.java28
-rw-r--r--container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/FilterInvokingPrintWriter.java266
-rw-r--r--container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/FilterInvokingServletOutputStream.java165
-rw-r--r--container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/FilterResolver.java88
-rw-r--r--container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/FilteringRequestHandler.java134
-rw-r--r--container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/FormPostRequestHandler.java188
-rw-r--r--container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/HealthCheckProxyHandler.java274
-rw-r--r--container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/HttpRequestDispatch.java243
-rw-r--r--container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/HttpRequestFactory.java87
-rw-r--r--container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/HttpResponseStatisticsCollector.java300
-rw-r--r--container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/HttpServletRequestUtils.java38
-rw-r--r--container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/JDiscContext.java33
-rw-r--r--container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/JDiscFilterInvokerFilter.java294
-rw-r--r--container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/JDiscHttpServlet.java148
-rw-r--r--container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/JDiscServerConnector.java104
-rw-r--r--container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/JettyConnectionLogger.java373
-rw-r--r--container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/JettyHttpServer.java298
-rw-r--r--container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/MetricDefinitions.java79
-rw-r--r--container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/OneTimeRunnable.java23
-rw-r--r--container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/ReferenceCountingRequestHandler.java257
-rw-r--r--container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/RequestException.java39
-rw-r--r--container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/RequestMetricReporter.java85
-rw-r--r--container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/SecuredRedirectHandler.java58
-rw-r--r--container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/ServerMetricReporter.java115
-rw-r--r--container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/ServletOutputStreamWriter.java299
-rw-r--r--container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/ServletRequestReader.java270
-rw-r--r--container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/ServletResponseController.java251
-rw-r--r--container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/SslHandshakeFailedListener.java52
-rw-r--r--container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/SslHandshakeFailure.java61
-rw-r--r--container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/TlsClientAuthenticationEnforcer.java83
-rw-r--r--container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/UnsupportedFilterInvoker.java32
-rw-r--r--container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/VoidConnectionLog.java16
-rw-r--r--container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/VoidRequestLog.java14
-rw-r--r--container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/package-info.java3
44 files changed, 5733 insertions, 0 deletions
diff --git a/container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/AccessLogRequestLog.java b/container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/AccessLogRequestLog.java
new file mode 100644
index 00000000000..4de5e5e5387
--- /dev/null
+++ b/container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/AccessLogRequestLog.java
@@ -0,0 +1,167 @@
+// Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.jdisc.http.server.jetty;
+
+import com.google.common.base.Objects;
+import com.yahoo.container.logging.AccessLog;
+import com.yahoo.container.logging.AccessLogEntry;
+import com.yahoo.container.logging.RequestLog;
+import com.yahoo.container.logging.RequestLogEntry;
+import com.yahoo.jdisc.http.ServerConfig;
+import com.yahoo.jdisc.http.servlet.ServletRequest;
+import org.eclipse.jetty.server.Request;
+import org.eclipse.jetty.server.Response;
+import org.eclipse.jetty.util.component.AbstractLifeCycle;
+
+import javax.servlet.http.HttpServletRequest;
+import java.security.Principal;
+import java.security.cert.X509Certificate;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.List;
+import java.util.OptionalInt;
+import java.util.UUID;
+import java.util.function.BiConsumer;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+import static com.yahoo.jdisc.http.server.jetty.HttpServletRequestUtils.getConnectorLocalPort;
+
+/**
+ * This class is a bridge between Jetty's {@link org.eclipse.jetty.server.handler.RequestLogHandler}
+ * and our own configurable access logging in different formats provided by {@link AccessLog}.
+ *
+ * @author Oyvind Bakksjo
+ * @author bjorncs
+ */
+class AccessLogRequestLog extends AbstractLifeCycle implements org.eclipse.jetty.server.RequestLog {
+
+ private static final Logger logger = Logger.getLogger(AccessLogRequestLog.class.getName());
+
+ // HTTP headers that are logged as extra key-value-pairs in access log entries
+ private static final List<String> LOGGED_REQUEST_HEADERS = List.of("Vespa-Client-Version");
+
+ private final RequestLog requestLog;
+ private final List<String> remoteAddressHeaders;
+ private final List<String> remotePortHeaders;
+
+ AccessLogRequestLog(RequestLog requestLog, ServerConfig.AccessLog config) {
+ this.requestLog = requestLog;
+ this.remoteAddressHeaders = config.remoteAddressHeaders();
+ this.remotePortHeaders = config.remotePortHeaders();
+ }
+
+ @Override
+ public void log(Request request, Response response) {
+ try {
+ RequestLogEntry.Builder builder = new RequestLogEntry.Builder();
+
+ String peerAddress = request.getRemoteAddr();
+ int peerPort = request.getRemotePort();
+ long startTime = request.getTimeStamp();
+ long endTime = System.currentTimeMillis();
+ builder.peerAddress(peerAddress)
+ .peerPort(peerPort)
+ .localPort(getLocalPort(request))
+ .timestamp(Instant.ofEpochMilli(startTime))
+ .duration(Duration.ofMillis(Math.max(0, endTime - startTime)))
+ .contentSize(response.getHttpChannel().getBytesWritten())
+ .statusCode(response.getCommittedMetaData().getStatus());
+
+ addNonNullValue(builder, request.getMethod(), RequestLogEntry.Builder::httpMethod);
+ addNonNullValue(builder, request.getRequestURI(), RequestLogEntry.Builder::rawPath);
+ addNonNullValue(builder, request.getProtocol(), RequestLogEntry.Builder::httpVersion);
+ addNonNullValue(builder, request.getScheme(), RequestLogEntry.Builder::scheme);
+ addNonNullValue(builder, request.getHeader("User-Agent"), RequestLogEntry.Builder::userAgent);
+ addNonNullValue(builder, request.getHeader("Host"), RequestLogEntry.Builder::hostString);
+ addNonNullValue(builder, request.getHeader("Referer"), RequestLogEntry.Builder::referer);
+ addNonNullValue(builder, request.getQueryString(), RequestLogEntry.Builder::rawQuery);
+
+ Principal principal = (Principal) request.getAttribute(ServletRequest.JDISC_REQUEST_PRINCIPAL);
+ addNonNullValue(builder, principal, RequestLogEntry.Builder::userPrincipal);
+
+ String requestFilterId = (String) request.getAttribute(ServletRequest.JDISC_REQUEST_CHAIN);
+ addNonNullValue(builder, requestFilterId, (b, chain) -> b.addExtraAttribute("request-chain", chain));
+
+ String responseFilterId = (String) request.getAttribute(ServletRequest.JDISC_RESPONSE_CHAIN);
+ addNonNullValue(builder, responseFilterId, (b, chain) -> b.addExtraAttribute("response-chain", chain));
+
+ UUID connectionId = (UUID) request.getAttribute(JettyConnectionLogger.CONNECTION_ID_REQUEST_ATTRIBUTE);
+ addNonNullValue(builder, connectionId, (b, uuid) -> b.connectionId(uuid.toString()));
+
+ String remoteAddress = getRemoteAddress(request);
+ if (!Objects.equal(remoteAddress, peerAddress)) {
+ builder.remoteAddress(remoteAddress);
+ }
+ int remotePort = getRemotePort(request);
+ if (remotePort != peerPort) {
+ builder.remotePort(remotePort);
+ }
+ LOGGED_REQUEST_HEADERS.forEach(header -> {
+ String value = request.getHeader(header);
+ if (value != null) {
+ builder.addExtraAttribute(header, value);
+ }
+ });
+ X509Certificate[] clientCert = (X509Certificate[]) request.getAttribute(ServletRequest.SERVLET_REQUEST_X509CERT);
+ if (clientCert != null && clientCert.length > 0) {
+ builder.sslPrincipal(clientCert[0].getSubjectX500Principal());
+ }
+
+ AccessLogEntry accessLogEntry = (AccessLogEntry) request.getAttribute(JDiscHttpServlet.ATTRIBUTE_NAME_ACCESS_LOG_ENTRY);
+ if (accessLogEntry != null) {
+ var extraAttributes = accessLogEntry.getKeyValues();
+ if (extraAttributes != null) {
+ extraAttributes.forEach(builder::addExtraAttributes);
+ }
+ addNonNullValue(builder, accessLogEntry.getHitCounts(), RequestLogEntry.Builder::hitCounts);
+ addNonNullValue(builder, accessLogEntry.getTrace(), RequestLogEntry.Builder::traceNode);
+ }
+
+ requestLog.log(builder.build());
+ } catch (Exception e) {
+ // Catching any exceptions here as it is unclear how Jetty handles exceptions from a RequestLog.
+ logger.log(Level.SEVERE, "Failed to log access log entry: " + e.getMessage(), e);
+ }
+ }
+
+ private String getRemoteAddress(HttpServletRequest request) {
+ for (String header : remoteAddressHeaders) {
+ String value = request.getHeader(header);
+ if (value != null) return value;
+ }
+ return request.getRemoteAddr();
+ }
+
+ private int getRemotePort(HttpServletRequest request) {
+ for (String header : remotePortHeaders) {
+ String value = request.getHeader(header);
+ if (value != null) {
+ OptionalInt maybePort = parsePort(value);
+ if (maybePort.isPresent()) return maybePort.getAsInt();
+ }
+ }
+ return request.getRemotePort();
+ }
+
+ private static int getLocalPort(Request request) {
+ int connectorLocalPort = getConnectorLocalPort(request);
+ if (connectorLocalPort <= 0) return request.getLocalPort(); // If connector is already closed
+ return connectorLocalPort;
+ }
+
+ private static OptionalInt parsePort(String port) {
+ try {
+ return OptionalInt.of(Integer.parseInt(port));
+ } catch (IllegalArgumentException e) {
+ return OptionalInt.empty();
+ }
+ }
+
+ private static <T> void addNonNullValue(
+ RequestLogEntry.Builder builder, T value, BiConsumer<RequestLogEntry.Builder, T> setter) {
+ if (value != null) {
+ setter.accept(builder, value);
+ }
+ }
+
+}
diff --git a/container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/AccessLoggingRequestHandler.java b/container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/AccessLoggingRequestHandler.java
new file mode 100644
index 00000000000..842ab75a312
--- /dev/null
+++ b/container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/AccessLoggingRequestHandler.java
@@ -0,0 +1,59 @@
+// Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.jdisc.http.server.jetty;
+
+import com.google.common.base.Preconditions;
+import com.yahoo.container.logging.AccessLogEntry;
+import com.yahoo.jdisc.Request;
+import com.yahoo.jdisc.handler.AbstractRequestHandler;
+import com.yahoo.jdisc.handler.ContentChannel;
+import com.yahoo.jdisc.handler.RequestHandler;
+import com.yahoo.jdisc.handler.ResponseHandler;
+import com.yahoo.jdisc.http.HttpRequest;
+
+import java.util.Map;
+import java.util.Optional;
+
+/**
+ * A wrapper RequestHandler that enables access logging. By wrapping the request handler, we are able to wrap the
+ * response handler as well. Hence, we can populate the access log entry with information from both the request
+ * and the response. This wrapper also adds the access log entry to the request context, so that request handlers
+ * may add information to it.
+ *
+ * Does not otherwise interfere with the request processing of the delegate request handler.
+ *
+ * @author bakksjo
+ */
+public class AccessLoggingRequestHandler extends AbstractRequestHandler {
+ public static final String CONTEXT_KEY_ACCESS_LOG_ENTRY
+ = AccessLoggingRequestHandler.class.getName() + "_access-log-entry";
+
+ public static Optional<AccessLogEntry> getAccessLogEntry(final HttpRequest jdiscRequest) {
+ final Map<String, Object> requestContextMap = jdiscRequest.context();
+ return getAccessLogEntry(requestContextMap);
+ }
+
+ public static Optional<AccessLogEntry> getAccessLogEntry(final Map<String, Object> requestContextMap) {
+ return Optional.ofNullable(
+ (AccessLogEntry) requestContextMap.get(CONTEXT_KEY_ACCESS_LOG_ENTRY));
+ }
+
+ private final RequestHandler delegate;
+ private final AccessLogEntry accessLogEntry;
+
+ public AccessLoggingRequestHandler(
+ final RequestHandler delegateRequestHandler,
+ final AccessLogEntry accessLogEntry) {
+ this.delegate = delegateRequestHandler;
+ this.accessLogEntry = accessLogEntry;
+ }
+
+ @Override
+ public ContentChannel handleRequest(final Request request, final ResponseHandler handler) {
+ Preconditions.checkArgument(request instanceof HttpRequest, "Expected HttpRequest, got " + request);
+ final HttpRequest httpRequest = (HttpRequest) request;
+ httpRequest.context().put(CONTEXT_KEY_ACCESS_LOG_ENTRY, accessLogEntry);
+ return delegate.handleRequest(request, handler);
+ }
+
+
+}
diff --git a/container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/AsyncCompleteListener.java b/container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/AsyncCompleteListener.java
new file mode 100644
index 00000000000..7dba217e01c
--- /dev/null
+++ b/container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/AsyncCompleteListener.java
@@ -0,0 +1,22 @@
+// Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.jdisc.http.server.jetty;
+
+import javax.servlet.AsyncEvent;
+import javax.servlet.AsyncListener;
+import java.io.IOException;
+
+/**
+ * Interface for async listeners only interested in onComplete.
+ * @author Tony Vaagenes
+ */
+@FunctionalInterface
+interface AsyncCompleteListener extends AsyncListener {
+ @Override
+ default void onTimeout(AsyncEvent event) throws IOException {}
+
+ @Override
+ default void onError(AsyncEvent event) throws IOException {}
+
+ @Override
+ default void onStartAsync(AsyncEvent event) throws IOException {}
+}
diff --git a/container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/CompletionHandlerUtils.java b/container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/CompletionHandlerUtils.java
new file mode 100644
index 00000000000..f436d5490d7
--- /dev/null
+++ b/container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/CompletionHandlerUtils.java
@@ -0,0 +1,14 @@
+// Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.jdisc.http.server.jetty;
+
+import com.yahoo.jdisc.handler.CompletionHandler;
+
+/**
+ * @author bjorncs
+ */
+public interface CompletionHandlerUtils {
+ CompletionHandler NOOP_COMPLETION_HANDLER = new CompletionHandler() {
+ @Override public void completed() {}
+ @Override public void failed(final Throwable t) {}
+ };
+}
diff --git a/container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/CompletionHandlers.java b/container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/CompletionHandlers.java
new file mode 100644
index 00000000000..975d88f5c34
--- /dev/null
+++ b/container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/CompletionHandlers.java
@@ -0,0 +1,57 @@
+// Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.jdisc.http.server.jetty;
+
+import com.yahoo.jdisc.handler.CompletionHandler;
+
+import java.util.Arrays;
+
+/**
+ * @author Simon Thoresen Hult
+ */
+public class CompletionHandlers {
+
+ public static void tryComplete(CompletionHandler handler) {
+ if (handler == null) {
+ return;
+ }
+ try {
+ handler.completed();
+ } catch (Exception e) {
+ // ignore
+ }
+ }
+
+ public static void tryFail(CompletionHandler handler, Throwable t) {
+ if (handler == null) {
+ return;
+ }
+ try {
+ handler.failed(t);
+ } catch (Exception e) {
+ // ignore
+ }
+ }
+
+ public static CompletionHandler wrap(CompletionHandler... handlers) {
+ return wrap(Arrays.asList(handlers));
+ }
+
+ public static CompletionHandler wrap(final Iterable<CompletionHandler> handlers) {
+ return new CompletionHandler() {
+
+ @Override
+ public void completed() {
+ for (CompletionHandler handler : handlers) {
+ tryComplete(handler);
+ }
+ }
+
+ @Override
+ public void failed(Throwable t) {
+ for (CompletionHandler handler : handlers) {
+ tryFail(handler, t);
+ }
+ }
+ };
+ }
+}
diff --git a/container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/ConnectionThrottler.java b/container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/ConnectionThrottler.java
new file mode 100644
index 00000000000..b9001d187a9
--- /dev/null
+++ b/container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/ConnectionThrottler.java
@@ -0,0 +1,274 @@
+// Copyright 2019 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.jdisc.http.server.jetty;
+
+import com.yahoo.jdisc.http.ConnectorConfig;
+import org.eclipse.jetty.io.Connection;
+import org.eclipse.jetty.io.SelectorManager;
+import org.eclipse.jetty.server.AbstractConnector;
+import org.eclipse.jetty.server.ConnectionLimit;
+import org.eclipse.jetty.server.LowResourceMonitor;
+import org.eclipse.jetty.util.annotation.ManagedObject;
+import org.eclipse.jetty.util.component.AbstractLifeCycle;
+import org.eclipse.jetty.util.component.ContainerLifeCycle;
+import org.eclipse.jetty.util.component.LifeCycle;
+import org.eclipse.jetty.util.statistic.RateStatistic;
+import org.eclipse.jetty.util.thread.Scheduler;
+
+import java.nio.channels.SelectableChannel;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Optional;
+import java.util.Set;
+import java.util.concurrent.TimeUnit;
+import java.util.logging.Logger;
+
+import static java.util.stream.Collectors.toList;
+
+/**
+ * Monitor various resource constraints and throttles new connections once a threshold is exceeded.
+ * Implementation inspired by Jetty's {@link LowResourceMonitor}, {@link AcceptRateLimit} and {@link ConnectionLimit}.
+ *
+ * @author bjorncs
+ */
+@ManagedObject("Monitor various resource constraints and throttles new connections once a threshold is exceeded")
+class ConnectionThrottler extends ContainerLifeCycle implements SelectorManager.AcceptListener {
+
+ private static final Logger log = Logger.getLogger(ConnectionThrottler.class.getName());
+
+ private final Object monitor = new Object();
+ private final Collection<ResourceLimit> resourceLimits = new ArrayList<>();
+ private final AbstractConnector connector;
+ private final Duration idleTimeout;
+ private final Scheduler scheduler;
+
+ private boolean isRegistered = false;
+ private boolean isThrottling = false;
+
+ ConnectionThrottler(AbstractConnector connector, ConnectorConfig.Throttling config) {
+ this(Runtime.getRuntime(), new RateStatistic(1, TimeUnit.SECONDS), connector.getScheduler(), connector, config);
+ }
+
+ // Intended for unit testing
+ ConnectionThrottler(Runtime runtime,
+ RateStatistic rateStatistic,
+ Scheduler scheduler,
+ AbstractConnector connector,
+ ConnectorConfig.Throttling config) {
+ this.connector = connector;
+ if (config.maxHeapUtilization() != -1) {
+ this.resourceLimits.add(new HeapResourceLimit(runtime, config.maxHeapUtilization()));
+ }
+ if (config.maxConnections() != -1) {
+ this.resourceLimits.add(new ConnectionLimitThreshold(config.maxConnections()));
+ }
+ if (config.maxAcceptRate() != -1) {
+ this.resourceLimits.add(new AcceptRateLimit(rateStatistic, config.maxAcceptRate()));
+ }
+ this.idleTimeout = config.idleTimeout() != -1 ? Duration.ofMillis((long) (config.idleTimeout()*1000)) : null;
+ this.scheduler = scheduler;
+ }
+
+ void registerWithConnector() {
+ synchronized (monitor) {
+ if (isRegistered) return;
+ isRegistered = true;
+ resourceLimits.forEach(connector::addBean);
+ connector.addBean(this);
+ }
+ }
+
+ @Override
+ public void onAccepting(SelectableChannel channel) {
+ throttleIfAnyThresholdIsExceeded();
+ }
+
+ private void throttleIfAnyThresholdIsExceeded() {
+ synchronized (monitor) {
+ if (isThrottling) return;
+ List<String> reasons = getThrottlingReasons();
+ if (reasons.isEmpty()) return;
+ log.warning(String.format("Throttling new connection. Reasons: %s", reasons));
+ isThrottling = true;
+ if (connector.isAccepting()) {
+ connector.setAccepting(false);
+ }
+ if (idleTimeout != null) {
+ log.warning(String.format("Applying idle timeout to existing connections: timeout=%sms", idleTimeout));
+ connector.getConnectedEndPoints()
+ .forEach(endPoint -> endPoint.setIdleTimeout(idleTimeout.toMillis()));
+ }
+ scheduler.schedule(this::unthrottleIfBelowThresholds, 1, TimeUnit.SECONDS);
+ }
+ }
+
+ private void unthrottleIfBelowThresholds() {
+ synchronized (monitor) {
+ if (!isThrottling) return;
+ List<String> reasons = getThrottlingReasons();
+ if (!reasons.isEmpty()) {
+ log.warning(String.format("Throttling continued. Reasons: %s", reasons));
+ scheduler.schedule(this::unthrottleIfBelowThresholds, 1, TimeUnit.SECONDS);
+ return;
+ }
+ if (idleTimeout != null) {
+ long originalTimeout = connector.getIdleTimeout();
+ log.info(String.format("Reverting idle timeout for existing connections: timeout=%sms", originalTimeout));
+ connector.getConnectedEndPoints()
+ .forEach(endPoint -> endPoint.setIdleTimeout(originalTimeout));
+ }
+ log.info("Throttling disabled - resource thresholds no longer exceeded");
+ if (!connector.isAccepting()) {
+ connector.setAccepting(true);
+ }
+ isThrottling = false;
+ }
+ }
+
+ private List<String> getThrottlingReasons() {
+ synchronized (monitor) {
+ return resourceLimits.stream()
+ .map(ResourceLimit::isThresholdExceeded)
+ .filter(Optional::isPresent)
+ .map(Optional::get)
+ .collect(toList());
+ }
+ }
+
+ private interface ResourceLimit extends LifeCycle, SelectorManager.AcceptListener, Connection.Listener {
+ /**
+ * @return A string containing the reason if threshold exceeded, empty otherwise.
+ */
+ Optional<String> isThresholdExceeded();
+
+ @Override default void onOpened(Connection connection) {}
+
+ @Override default void onClosed(Connection connection) {}
+ }
+
+ /**
+ * Note: implementation inspired by Jetty's {@link LowResourceMonitor}
+ */
+ private static class HeapResourceLimit extends AbstractLifeCycle implements ResourceLimit {
+ private final Runtime runtime;
+ private final double maxHeapUtilization;
+
+ HeapResourceLimit(Runtime runtime, double maxHeapUtilization) {
+ this.runtime = runtime;
+ this.maxHeapUtilization = maxHeapUtilization;
+ }
+
+ @Override
+ public Optional<String> isThresholdExceeded() {
+ double heapUtilization = (runtime.maxMemory() - runtime.freeMemory()) / (double) runtime.maxMemory();
+ if (heapUtilization > maxHeapUtilization) {
+ return Optional.of(String.format("Max heap utilization exceeded: %f%%>%f%%", heapUtilization*100, maxHeapUtilization*100));
+ }
+ return Optional.empty();
+ }
+ }
+
+ /**
+ * Note: implementation inspired by Jetty's {@link org.eclipse.jetty.server.AcceptRateLimit}
+ */
+ private static class AcceptRateLimit extends AbstractLifeCycle implements ResourceLimit {
+ private final Object monitor = new Object();
+ private final RateStatistic rateStatistic;
+ private final int maxAcceptRate;
+
+ AcceptRateLimit(RateStatistic rateStatistic, int maxAcceptRate) {
+ this.rateStatistic = rateStatistic;
+ this.maxAcceptRate = maxAcceptRate;
+ }
+
+ @Override
+ public Optional<String> isThresholdExceeded() {
+ synchronized (monitor) {
+ int acceptRate = rateStatistic.getRate();
+ if (acceptRate > maxAcceptRate) {
+ return Optional.of(String.format("Max accept rate exceeded: %d>%d", acceptRate, maxAcceptRate));
+ }
+ return Optional.empty();
+ }
+ }
+
+ @Override
+ public void onAccepting(SelectableChannel channel) {
+ synchronized (monitor) {
+ rateStatistic.record();
+ }
+ }
+
+ @Override
+ protected void doStop() {
+ synchronized (monitor) {
+ rateStatistic.reset();
+ }
+ }
+ }
+
+ /**
+ * Note: implementation inspired by Jetty's {@link ConnectionLimit}.
+ */
+ private static class ConnectionLimitThreshold extends AbstractLifeCycle implements ResourceLimit {
+ private final Object monitor = new Object();
+ private final int maxConnections;
+ private final Set<SelectableChannel> connectionsAccepting = new HashSet<>();
+ private int connectionOpened;
+
+ ConnectionLimitThreshold(int maxConnections) {
+ this.maxConnections = maxConnections;
+ }
+
+ @Override
+ public Optional<String> isThresholdExceeded() {
+ synchronized (monitor) {
+ int totalConnections = connectionOpened + connectionsAccepting.size();
+ if (totalConnections > maxConnections) {
+ return Optional.of(String.format("Max connection exceeded: %d>%d", totalConnections, maxConnections));
+ }
+ return Optional.empty();
+ }
+ }
+
+ @Override
+ public void onOpened(Connection connection) {
+ synchronized (monitor) {
+ connectionsAccepting.remove(connection.getEndPoint().getTransport());
+ ++connectionOpened;
+ }
+ }
+
+ @Override
+ public void onClosed(Connection connection) {
+ synchronized (monitor) {
+ --connectionOpened;
+ }
+ }
+
+ @Override
+ public void onAccepting(SelectableChannel channel) {
+ synchronized (monitor) {
+ connectionsAccepting.add(channel);
+ }
+
+ }
+
+ @Override
+ public void onAcceptFailed(SelectableChannel channel, Throwable cause) {
+ synchronized (monitor) {
+ connectionsAccepting.remove(channel);
+ }
+ }
+
+ @Override
+ protected void doStop() {
+ synchronized (monitor) {
+ connectionsAccepting.clear();
+ connectionOpened = 0;
+ }
+ }
+ }
+}
diff --git a/container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/ConnectorFactory.java b/container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/ConnectorFactory.java
new file mode 100644
index 00000000000..d7ad12a5c64
--- /dev/null
+++ b/container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/ConnectorFactory.java
@@ -0,0 +1,140 @@
+// Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.jdisc.http.server.jetty;
+
+import com.google.inject.Inject;
+import com.yahoo.jdisc.Metric;
+import com.yahoo.jdisc.http.ConnectorConfig;
+import com.yahoo.jdisc.http.ssl.SslContextFactoryProvider;
+import com.yahoo.security.tls.MixedMode;
+import com.yahoo.security.tls.TransportSecurityUtils;
+import org.eclipse.jetty.server.ConnectionFactory;
+import org.eclipse.jetty.server.DetectorConnectionFactory;
+import org.eclipse.jetty.server.HttpConfiguration;
+import org.eclipse.jetty.server.HttpConnectionFactory;
+import org.eclipse.jetty.server.ProxyConnectionFactory;
+import org.eclipse.jetty.server.SecureRequestCustomizer;
+import org.eclipse.jetty.server.Server;
+import org.eclipse.jetty.server.ServerConnector;
+import org.eclipse.jetty.server.SslConnectionFactory;
+import org.eclipse.jetty.util.ssl.SslContextFactory;
+
+import java.util.List;
+
+/**
+ * @author Einar M R Rosenvinge
+ * @author bjorncs
+ */
+public class ConnectorFactory {
+
+ private final ConnectorConfig connectorConfig;
+ private final SslContextFactoryProvider sslContextFactoryProvider;
+
+ @Inject
+ public ConnectorFactory(ConnectorConfig connectorConfig,
+ SslContextFactoryProvider sslContextFactoryProvider) {
+ runtimeConnectorConfigValidation(connectorConfig);
+ this.connectorConfig = connectorConfig;
+ this.sslContextFactoryProvider = sslContextFactoryProvider;
+ }
+
+ // Perform extra connector config validation that can only be performed at runtime,
+ // e.g. due to TLS configuration through environment variables.
+ private static void runtimeConnectorConfigValidation(ConnectorConfig config) {
+ validateProxyProtocolConfiguration(config);
+ validateSecureRedirectConfig(config);
+ }
+
+ private static void validateProxyProtocolConfiguration(ConnectorConfig config) {
+ ConnectorConfig.ProxyProtocol proxyProtocolConfig = config.proxyProtocol();
+ if (proxyProtocolConfig.enabled()) {
+ boolean tlsMixedModeEnabled = TransportSecurityUtils.getInsecureMixedMode() != MixedMode.DISABLED;
+ if (!isSslEffectivelyEnabled(config) || tlsMixedModeEnabled) {
+ throw new IllegalArgumentException("Proxy protocol can only be enabled if connector is effectively HTTPS only");
+ }
+ }
+ }
+
+ private static void validateSecureRedirectConfig(ConnectorConfig config) {
+ if (config.secureRedirect().enabled() && isSslEffectivelyEnabled(config)) {
+ throw new IllegalArgumentException("Secure redirect can only be enabled on connectors without HTTPS");
+ }
+ }
+
+ public ConnectorConfig getConnectorConfig() {
+ return connectorConfig;
+ }
+
+ public ServerConnector createConnector(final Metric metric, final Server server, JettyConnectionLogger connectionLogger) {
+ ServerConnector connector = new JDiscServerConnector(
+ connectorConfig, metric, server, connectionLogger, createConnectionFactories(metric).toArray(ConnectionFactory[]::new));
+ connector.setPort(connectorConfig.listenPort());
+ connector.setName(connectorConfig.name());
+ connector.setAcceptQueueSize(connectorConfig.acceptQueueSize());
+ connector.setReuseAddress(connectorConfig.reuseAddress());
+ connector.setIdleTimeout((long)(connectorConfig.idleTimeout() * 1000.0));
+ return connector;
+ }
+
+ private List<ConnectionFactory> createConnectionFactories(Metric metric) {
+ HttpConnectionFactory httpFactory = newHttpConnectionFactory();
+ if (!isSslEffectivelyEnabled(connectorConfig)) {
+ return List.of(httpFactory);
+ } else if (connectorConfig.ssl().enabled()) {
+ return connectionFactoriesForHttps(metric, httpFactory);
+ } else if (TransportSecurityUtils.isTransportSecurityEnabled()) {
+ switch (TransportSecurityUtils.getInsecureMixedMode()) {
+ case TLS_CLIENT_MIXED_SERVER:
+ case PLAINTEXT_CLIENT_MIXED_SERVER:
+ return List.of(new DetectorConnectionFactory(newSslConnectionFactory(metric, httpFactory)), httpFactory);
+ case DISABLED:
+ return connectionFactoriesForHttps(metric, httpFactory);
+ default:
+ throw new IllegalStateException();
+ }
+ } else {
+ return List.of(httpFactory);
+ }
+ }
+
+ private List<ConnectionFactory> connectionFactoriesForHttps(Metric metric, HttpConnectionFactory httpFactory) {
+ ConnectorConfig.ProxyProtocol proxyProtocolConfig = connectorConfig.proxyProtocol();
+ SslConnectionFactory sslFactory = newSslConnectionFactory(metric, httpFactory);
+ if (proxyProtocolConfig.enabled()) {
+ if (proxyProtocolConfig.mixedMode()) {
+ return List.of(new DetectorConnectionFactory(sslFactory, new ProxyConnectionFactory(sslFactory.getProtocol())), sslFactory, httpFactory);
+ } else {
+ return List.of(new ProxyConnectionFactory(sslFactory.getProtocol()), sslFactory, httpFactory);
+ }
+ } else {
+ return List.of(sslFactory, httpFactory);
+ }
+ }
+
+ private HttpConnectionFactory newHttpConnectionFactory() {
+ HttpConfiguration httpConfig = new HttpConfiguration();
+ httpConfig.setSendDateHeader(true);
+ httpConfig.setSendServerVersion(false);
+ httpConfig.setSendXPoweredBy(false);
+ httpConfig.setHeaderCacheSize(connectorConfig.headerCacheSize());
+ httpConfig.setOutputBufferSize(connectorConfig.outputBufferSize());
+ httpConfig.setRequestHeaderSize(connectorConfig.requestHeaderSize());
+ httpConfig.setResponseHeaderSize(connectorConfig.responseHeaderSize());
+ if (isSslEffectivelyEnabled(connectorConfig)) {
+ httpConfig.addCustomizer(new SecureRequestCustomizer());
+ }
+ return new HttpConnectionFactory(httpConfig);
+ }
+
+ private SslConnectionFactory newSslConnectionFactory(Metric metric, HttpConnectionFactory httpFactory) {
+ SslContextFactory ctxFactory = sslContextFactoryProvider.getInstance(connectorConfig.name(), connectorConfig.listenPort());
+ SslConnectionFactory connectionFactory = new SslConnectionFactory(ctxFactory, httpFactory.getProtocol());
+ connectionFactory.addBean(new SslHandshakeFailedListener(metric, connectorConfig.name(), connectorConfig.listenPort()));
+ return connectionFactory;
+ }
+
+ private static boolean isSslEffectivelyEnabled(ConnectorConfig config) {
+ return config.ssl().enabled()
+ || (config.implicitTlsEnabled() && TransportSecurityUtils.isTransportSecurityEnabled());
+ }
+
+}
diff --git a/container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/ErrorResponseContentCreator.java b/container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/ErrorResponseContentCreator.java
new file mode 100644
index 00000000000..cd21dccde0e
--- /dev/null
+++ b/container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/ErrorResponseContentCreator.java
@@ -0,0 +1,41 @@
+// Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.jdisc.http.server.jetty;
+
+import org.eclipse.jetty.util.ByteArrayISO8859Writer;
+import org.eclipse.jetty.util.StringUtil;
+
+import java.io.IOException;
+import java.util.Optional;
+
+/**
+ * Creates HTML body having the status code, error message and request uri.
+ * The body is constructed from a template that is inspired by the default Jetty template (see {@link org.eclipse.jetty.server.Response#sendError(int, String)}).
+ * The content is written using the ISO-8859-1 charset.
+ *
+ * @author bjorncs
+ */
+public class ErrorResponseContentCreator {
+
+ private final ByteArrayISO8859Writer writer = new ByteArrayISO8859Writer(2048);
+
+ public byte[] createErrorContent(String requestUri, int statusCode, Optional<String> message) {
+ String sanitizedString = message.map(StringUtil::sanitizeXmlString).orElse("");
+ String statusCodeString = Integer.toString(statusCode);
+ writer.resetWriter();
+ try {
+ writer.write("<html>\n<head>\n<meta http-equiv=\"Content-Type\" content=\"text/html;charset=ISO-8859-1\"/>\n<title>Error ");
+ writer.write(statusCodeString);
+ writer.write("</title>\n</head>\n<body>\n<h2>HTTP ERROR: ");
+ writer.write(statusCodeString);
+ writer.write("</h2>\n<p>Problem accessing ");
+ writer.write(StringUtil.sanitizeXmlString(requestUri));
+ writer.write(". Reason:\n<pre> ");
+ writer.write(sanitizedString);
+ writer.write("</pre></p>\n<hr/>\n</body>\n</html>\n");
+ } catch (IOException e) {
+ // IOException should not be thrown unless writer is constructed using byte[] parameter
+ throw new RuntimeException(e);
+ }
+ return writer.getByteArray();
+ }
+}
diff --git a/container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/ExceptionWrapper.java b/container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/ExceptionWrapper.java
new file mode 100644
index 00000000000..ebc10482600
--- /dev/null
+++ b/container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/ExceptionWrapper.java
@@ -0,0 +1,59 @@
+// Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.jdisc.http.server.jetty;
+
+/**
+ * A wrapper to make exceptions leaking into Jetty easier to track. Jetty
+ * swallows all information about where an exception was thrown, so this wrapper
+ * ensures some extra information is automatically added to the contents of
+ * getMessage().
+ *
+ * @author <a href="mailto:steinar@yahoo-inc.com">Steinar Knutsen</a>
+ */
+public class ExceptionWrapper extends RuntimeException {
+ private final String message;
+
+ /**
+ * Update if serializable contents are added.
+ */
+ private static final long serialVersionUID = 1L;
+
+ public ExceptionWrapper(Throwable t) {
+ super(t);
+ this.message = formatMessage(t);
+ }
+
+ // If calling methods from the constructor, it makes life easier if the
+ // methods are static...
+ private static String formatMessage(final Throwable t) {
+ StringBuilder b = new StringBuilder();
+ Throwable cause = t;
+ while (cause != null) {
+ StackTraceElement[] trace = cause.getStackTrace();
+ String currentMsg = cause.getMessage();
+
+ if (b.length() > 0) {
+ b.append(": ");
+ }
+ b.append(t.getClass().getSimpleName()).append('(');
+ if (currentMsg != null) {
+ b.append('"').append(currentMsg).append('"');
+ }
+ b.append(')');
+ if (trace.length > 0) {
+ b.append(" at ").append(trace[0].getClassName()).append('(');
+ if (trace[0].getFileName() != null) {
+ b.append(trace[0].getFileName()).append(':')
+ .append(trace[0].getLineNumber());
+ }
+ b.append(')');
+ }
+ cause = cause.getCause();
+ }
+ return b.toString();
+ }
+
+ @Override
+ public String getMessage() {
+ return message;
+ }
+}
diff --git a/container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/FilterBindings.java b/container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/FilterBindings.java
new file mode 100644
index 00000000000..310f3c9a646
--- /dev/null
+++ b/container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/FilterBindings.java
@@ -0,0 +1,102 @@
+// Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.jdisc.http.server.jetty;
+
+import com.yahoo.jdisc.application.BindingRepository;
+import com.yahoo.jdisc.application.BindingSet;
+import com.yahoo.jdisc.http.filter.RequestFilter;
+import com.yahoo.jdisc.http.filter.ResponseFilter;
+
+import java.net.URI;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Map;
+import java.util.Optional;
+import java.util.TreeMap;
+
+/**
+ * Resolves request/response filter (chain) from a {@link URI} instance.
+ *
+ * @author Oyvind Bakksjo
+ * @author bjorncs
+ */
+public class FilterBindings {
+
+ private final Map<String, RequestFilter> requestFilters;
+ private final Map<String, ResponseFilter> responseFilters;
+ private final Map<Integer, String> defaultRequestFilters;
+ private final Map<Integer, String> defaultResponseFilters;
+ private final BindingSet<String> requestFilterBindings;
+ private final BindingSet<String> responseFilterBindings;
+
+ private FilterBindings(
+ Map<String, RequestFilter> requestFilters,
+ Map<String, ResponseFilter> responseFilters,
+ Map<Integer, String> defaultRequestFilters,
+ Map<Integer, String> defaultResponseFilters,
+ BindingSet<String> requestFilterBindings,
+ BindingSet<String> responseFilterBindings) {
+ this.requestFilters = requestFilters;
+ this.responseFilters = responseFilters;
+ this.defaultRequestFilters = defaultRequestFilters;
+ this.defaultResponseFilters = defaultResponseFilters;
+ this.requestFilterBindings = requestFilterBindings;
+ this.responseFilterBindings = responseFilterBindings;
+ }
+
+ public Optional<String> resolveRequestFilter(URI uri, int localPort) {
+ String filterId = requestFilterBindings.resolve(uri);
+ if (filterId != null) return Optional.of(filterId);
+ return Optional.ofNullable(defaultRequestFilters.get(localPort));
+ }
+
+ public Optional<String> resolveResponseFilter(URI uri, int localPort) {
+ String filterId = responseFilterBindings.resolve(uri);
+ if (filterId != null) return Optional.of(filterId);
+ return Optional.ofNullable(defaultResponseFilters.get(localPort));
+ }
+
+ public RequestFilter getRequestFilter(String filterId) { return requestFilters.get(filterId); }
+
+ public ResponseFilter getResponseFilter(String filterId) { return responseFilters.get(filterId); }
+
+ public Collection<String> requestFilterIds() { return requestFilters.keySet(); }
+
+ public Collection<String> responseFilterIds() { return responseFilters.keySet(); }
+
+ public Collection<RequestFilter> requestFilters() { return requestFilters.values(); }
+
+ public Collection<ResponseFilter> responseFilters() { return responseFilters.values(); }
+
+ public static class Builder {
+ private final Map<String, RequestFilter> requestFilters = new TreeMap<>();
+ private final Map<String, ResponseFilter> responseFilters = new TreeMap<>();
+ private final Map<Integer, String> defaultRequestFilters = new TreeMap<>();
+ private final Map<Integer, String> defaultResponseFilters = new TreeMap<>();
+ private final BindingRepository<String> requestFilterBindings = new BindingRepository<>();
+ private final BindingRepository<String> responseFilterBindings = new BindingRepository<>();
+
+ public Builder() {}
+
+ public Builder addRequestFilter(String id, RequestFilter filter) { requestFilters.put(id, filter); return this; }
+
+ public Builder addResponseFilter(String id, ResponseFilter filter) { responseFilters.put(id, filter); return this; }
+
+ public Builder addRequestFilterBinding(String id, String binding) { requestFilterBindings.bind(binding, id); return this; }
+
+ public Builder addResponseFilterBinding(String id, String binding) { responseFilterBindings.bind(binding, id); return this; }
+
+ public Builder setRequestFilterDefaultForPort(String id, int port) { defaultRequestFilters.put(port, id); return this; }
+
+ public Builder setResponseFilterDefaultForPort(String id, int port) { defaultResponseFilters.put(port, id); return this; }
+
+ public FilterBindings build() {
+ return new FilterBindings(
+ Collections.unmodifiableMap(requestFilters),
+ Collections.unmodifiableMap(responseFilters),
+ Collections.unmodifiableMap(defaultRequestFilters),
+ Collections.unmodifiableMap(defaultResponseFilters),
+ requestFilterBindings.activate(),
+ responseFilterBindings.activate());
+ }
+ }
+}
diff --git a/container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/FilterInvoker.java b/container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/FilterInvoker.java
new file mode 100644
index 00000000000..0827ccdc39e
--- /dev/null
+++ b/container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/FilterInvoker.java
@@ -0,0 +1,28 @@
+// Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.jdisc.http.server.jetty;
+
+import com.google.inject.ImplementedBy;
+import com.yahoo.jdisc.handler.ResponseHandler;
+import com.yahoo.jdisc.http.filter.RequestFilter;
+import com.yahoo.jdisc.http.filter.ResponseFilter;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.net.URI;
+
+/**
+ * Separate interface since DiscFilterRequest/Response and Security filter chains are not accessible in this bundle
+ */
+@ImplementedBy(UnsupportedFilterInvoker.class)
+public interface FilterInvoker {
+ HttpServletRequest invokeRequestFilterChain(RequestFilter requestFilterChain,
+ URI uri,
+ HttpServletRequest httpRequest,
+ ResponseHandler responseHandler);
+
+ void invokeResponseFilterChain(
+ ResponseFilter responseFilterChain,
+ URI uri,
+ HttpServletRequest request,
+ HttpServletResponse response);
+}
diff --git a/container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/FilterInvokingPrintWriter.java b/container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/FilterInvokingPrintWriter.java
new file mode 100644
index 00000000000..3ebc7bbc551
--- /dev/null
+++ b/container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/FilterInvokingPrintWriter.java
@@ -0,0 +1,266 @@
+// Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.jdisc.http.server.jetty;
+
+import java.io.IOException;
+import java.io.PrintWriter;
+import java.io.Writer;
+import java.util.Locale;
+
+/**
+ * Invokes the response filter the first time anything is output to the underlying PrintWriter.
+ * The filter must be invoked before the first output call since this might cause the response
+ * to be committed, i.e. locked and potentially put on the wire.
+ * Any changes to the response after it has been committed might be ignored or cause exceptions.
+ * @author Tony Vaagenes
+ */
+final class FilterInvokingPrintWriter extends PrintWriter {
+ private final PrintWriter delegate;
+ private final OneTimeRunnable filterInvoker;
+
+ public FilterInvokingPrintWriter(PrintWriter delegate, OneTimeRunnable filterInvoker) {
+ /* The PrintWriter class both
+ * 1) exposes new methods, the PrintWriter "interface"
+ * 2) implements PrintWriter and Writer methods that does some extra things before calling down to the writer methods.
+ * If super was invoked with the delegate PrintWriter, the superclass would behave as a PrintWriter(PrintWriter),
+ * i.e. the extra things in 2. would be done twice.
+ * To avoid this, all the methods of PrintWriter are overridden with versions that forward directly to the underlying delegate
+ * instead of going through super.
+ * The super class is initialized with a non-functioning writer to catch mistakenly non-overridden methods.
+ */
+ super(new Writer() {
+ @Override
+ public void write(char[] cbuf, int off, int len) throws IOException {
+ throwAssertionError();
+ }
+
+ private void throwAssertionError() {
+ throw new AssertionError(FilterInvokingPrintWriter.class.getName() + " failed to delegate to the underlying writer");
+ }
+
+ @Override
+ public void flush() throws IOException {
+ throwAssertionError();
+ }
+
+ @Override
+ public void close() throws IOException {
+ throwAssertionError();
+ }
+ });
+
+ this.delegate = delegate;
+ this.filterInvoker = filterInvoker;
+ }
+
+ @Override
+ public String toString() {
+ return getClass().getName() + " (" + super.toString() + ")";
+ }
+
+ private void runFilterIfFirstInvocation() {
+ filterInvoker.runIfFirstInvocation();
+ }
+
+ @Override
+ public void flush() {
+ runFilterIfFirstInvocation();
+ delegate.flush();
+ }
+
+ @Override
+ public void close() {
+ runFilterIfFirstInvocation();
+ delegate.close();
+ }
+
+ @Override
+ public boolean checkError() {
+ return delegate.checkError();
+ }
+
+ @Override
+ public void write(int c) {
+ runFilterIfFirstInvocation();
+ delegate.write(c);
+ }
+
+ @Override
+ public void write(char[] buf, int off, int len) {
+ runFilterIfFirstInvocation();
+ delegate.write(buf, off, len);
+ }
+
+ @Override
+ public void write(char[] buf) {
+ runFilterIfFirstInvocation();
+ delegate.write(buf);
+ }
+
+ @Override
+ public void write(String s, int off, int len) {
+ runFilterIfFirstInvocation();
+ delegate.write(s, off, len);
+ }
+
+ @Override
+ public void write(String s) {
+ runFilterIfFirstInvocation();
+ delegate.write(s);
+ }
+
+ @Override
+ public void print(boolean b) {
+ runFilterIfFirstInvocation();
+ delegate.print(b);
+ }
+
+ @Override
+ public void print(char c) {
+ runFilterIfFirstInvocation();
+ delegate.print(c);
+ }
+
+ @Override
+ public void print(int i) {
+ runFilterIfFirstInvocation();
+ delegate.print(i);
+ }
+
+ @Override
+ public void print(long l) {
+ runFilterIfFirstInvocation();
+ delegate.print(l);
+ }
+
+ @Override
+ public void print(float f) {
+ runFilterIfFirstInvocation();
+ delegate.print(f);
+ }
+
+ @Override
+ public void print(double d) {
+ runFilterIfFirstInvocation();
+ delegate.print(d);
+ }
+
+ @Override
+ public void print(char[] s) {
+ runFilterIfFirstInvocation();
+ delegate.print(s);
+ }
+
+ @Override
+ public void print(String s) {
+ runFilterIfFirstInvocation();
+ delegate.print(s);
+ }
+
+ @Override
+ public void print(Object obj) {
+ runFilterIfFirstInvocation();
+ delegate.print(obj);
+ }
+
+ @Override
+ public void println() {
+ runFilterIfFirstInvocation();
+ delegate.println();
+ }
+
+ @Override
+ public void println(boolean x) {
+ runFilterIfFirstInvocation();
+ delegate.println(x);
+ }
+
+ @Override
+ public void println(char x) {
+ runFilterIfFirstInvocation();
+ delegate.println(x);
+ }
+
+ @Override
+ public void println(int x) {
+ runFilterIfFirstInvocation();
+ delegate.println(x);
+ }
+
+ @Override
+ public void println(long x) {
+ runFilterIfFirstInvocation();
+ delegate.println(x);
+ }
+
+ @Override
+ public void println(float x) {
+ runFilterIfFirstInvocation();
+ delegate.println(x);
+ }
+
+ @Override
+ public void println(double x) {
+ runFilterIfFirstInvocation();
+ delegate.println(x);
+ }
+
+ @Override
+ public void println(char[] x) {
+ runFilterIfFirstInvocation();
+ delegate.println(x);
+ }
+
+ @Override
+ public void println(String x) {
+ runFilterIfFirstInvocation();
+ delegate.println(x);
+ }
+
+ @Override
+ public void println(Object x) {
+ runFilterIfFirstInvocation();
+ delegate.println(x);
+ }
+
+ @Override
+ public PrintWriter printf(String format, Object... args) {
+ runFilterIfFirstInvocation();
+ return delegate.printf(format, args);
+ }
+
+ @Override
+ public PrintWriter printf(Locale l, String format, Object... args) {
+ runFilterIfFirstInvocation();
+ return delegate.printf(l, format, args);
+ }
+
+ @Override
+ public PrintWriter format(String format, Object... args) {
+ runFilterIfFirstInvocation();
+ return delegate.format(format, args);
+ }
+
+ @Override
+ public PrintWriter format(Locale l, String format, Object... args) {
+ runFilterIfFirstInvocation();
+ return delegate.format(l, format, args);
+ }
+
+ @Override
+ public PrintWriter append(CharSequence csq) {
+ runFilterIfFirstInvocation();
+ return delegate.append(csq);
+ }
+
+ @Override
+ public PrintWriter append(CharSequence csq, int start, int end) {
+ runFilterIfFirstInvocation();
+ return delegate.append(csq, start, end);
+ }
+
+ @Override
+ public PrintWriter append(char c) {
+ runFilterIfFirstInvocation();
+ return delegate.append(c);
+ }
+}
diff --git a/container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/FilterInvokingServletOutputStream.java b/container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/FilterInvokingServletOutputStream.java
new file mode 100644
index 00000000000..a605ccebfa7
--- /dev/null
+++ b/container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/FilterInvokingServletOutputStream.java
@@ -0,0 +1,165 @@
+// Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.jdisc.http.server.jetty;
+
+import javax.servlet.ServletOutputStream;
+import javax.servlet.WriteListener;
+import java.io.IOException;
+
+/**
+ * Invokes the response filter the first time anything is output to the underlying ServletOutputStream.
+ * The filter must be invoked before the first output call since this might cause the response
+ * to be committed, i.e. locked and potentially put on the wire.
+ * Any changes to the response after it has been committed might be ignored or cause exceptions.
+ *
+ * @author Tony Vaagenes
+ */
+class FilterInvokingServletOutputStream extends ServletOutputStream {
+ private final ServletOutputStream delegate;
+ private final OneTimeRunnable filterInvoker;
+
+ public FilterInvokingServletOutputStream(ServletOutputStream delegate, OneTimeRunnable filterInvoker) {
+ this.delegate = delegate;
+ this.filterInvoker = filterInvoker;
+ }
+
+ @Override
+ public boolean isReady() {
+ return delegate.isReady();
+ }
+
+ @Override
+ public void setWriteListener(WriteListener writeListener) {
+ delegate.setWriteListener(writeListener);
+ }
+
+
+ private void runFilterIfFirstInvocation() {
+ filterInvoker.runIfFirstInvocation();
+ }
+
+ @Override
+ public void write(int b) throws IOException {
+ runFilterIfFirstInvocation();
+ delegate.write(b);
+ }
+
+
+ @Override
+ public void write(byte[] b) throws IOException {
+ runFilterIfFirstInvocation();
+ delegate.write(b);
+ }
+
+ @Override
+ public void print(String s) throws IOException {
+ runFilterIfFirstInvocation();
+ delegate.print(s);
+ }
+
+ @Override
+ public void write(byte[] b, int off, int len) throws IOException {
+ runFilterIfFirstInvocation();
+ delegate.write(b, off, len);
+ }
+
+ @Override
+ public void print(boolean b) throws IOException {
+ runFilterIfFirstInvocation();
+ delegate.print(b);
+ }
+
+ @Override
+ public void flush() throws IOException {
+ runFilterIfFirstInvocation();
+ delegate.flush();
+ }
+
+ @Override
+ public void print(char c) throws IOException {
+ runFilterIfFirstInvocation();
+ delegate.print(c);
+ }
+
+ @Override
+ public void close() throws IOException {
+ runFilterIfFirstInvocation();
+ delegate.close();
+ }
+
+ @Override
+ public void print(int i) throws IOException {
+ runFilterIfFirstInvocation();
+ delegate.print(i);
+ }
+
+ @Override
+ public void print(long l) throws IOException {
+ runFilterIfFirstInvocation();
+ delegate.print(l);
+ }
+
+ @Override
+ public void print(float f) throws IOException {
+ runFilterIfFirstInvocation();
+ delegate.print(f);
+ }
+
+ @Override
+ public void print(double d) throws IOException {
+ runFilterIfFirstInvocation();
+ delegate.print(d);
+ }
+
+ @Override
+ public void println() throws IOException {
+ runFilterIfFirstInvocation();
+ delegate.println();
+ }
+
+ @Override
+ public void println(String s) throws IOException {
+ runFilterIfFirstInvocation();
+ delegate.println(s);
+ }
+
+ @Override
+ public void println(boolean b) throws IOException {
+ runFilterIfFirstInvocation();
+ delegate.println(b);
+ }
+
+ @Override
+ public void println(char c) throws IOException {
+ runFilterIfFirstInvocation();
+ delegate.println(c);
+ }
+
+ @Override
+ public void println(int i) throws IOException {
+ runFilterIfFirstInvocation();
+ delegate.println(i);
+ }
+
+ @Override
+ public void println(long l) throws IOException {
+ runFilterIfFirstInvocation();
+ delegate.println(l);
+ }
+
+ @Override
+ public void println(float f) throws IOException {
+ runFilterIfFirstInvocation();
+ delegate.println(f);
+ }
+
+ @Override
+ public void println(double d) throws IOException {
+ runFilterIfFirstInvocation();
+ delegate.println(d);
+ }
+
+ @Override
+ public String toString() {
+ return getClass().getCanonicalName() + " (" + delegate.toString() + ")";
+ }
+}
diff --git a/container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/FilterResolver.java b/container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/FilterResolver.java
new file mode 100644
index 00000000000..1e2686aa184
--- /dev/null
+++ b/container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/FilterResolver.java
@@ -0,0 +1,88 @@
+// Copyright Verizon Media. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.jdisc.http.server.jetty;
+
+import com.yahoo.jdisc.Metric;
+import com.yahoo.jdisc.NoopSharedResource;
+import com.yahoo.jdisc.Response;
+import com.yahoo.jdisc.handler.FastContentWriter;
+import com.yahoo.jdisc.handler.ResponseDispatch;
+import com.yahoo.jdisc.handler.ResponseHandler;
+import com.yahoo.jdisc.http.HttpRequest;
+import com.yahoo.jdisc.http.filter.RequestFilter;
+import com.yahoo.jdisc.http.filter.ResponseFilter;
+import com.yahoo.jdisc.http.servlet.ServletRequest;
+
+import javax.servlet.http.HttpServletRequest;
+import java.net.URI;
+import java.util.Map;
+import java.util.Optional;
+
+import static com.yahoo.jdisc.http.server.jetty.JDiscHttpServlet.getConnector;
+
+/**
+ * Resolve request/response filter (chain) based on {@link FilterBindings}.
+ *
+ * @author bjorncs
+ */
+class FilterResolver {
+
+ private final FilterBindings bindings;
+ private final Metric metric;
+ private final boolean strictFiltering;
+
+ FilterResolver(FilterBindings bindings, Metric metric, boolean strictFiltering) {
+ this.bindings = bindings;
+ this.metric = metric;
+ this.strictFiltering = strictFiltering;
+ }
+
+ Optional<RequestFilter> resolveRequestFilter(HttpServletRequest servletRequest, URI jdiscUri) {
+ Optional<String> maybeFilterId = bindings.resolveRequestFilter(jdiscUri, getConnector(servletRequest).listenPort());
+ if (maybeFilterId.isPresent()) {
+ metric.add(MetricDefinitions.FILTERING_REQUEST_HANDLED, 1L, createMetricContext(servletRequest, maybeFilterId.get()));
+ servletRequest.setAttribute(ServletRequest.JDISC_REQUEST_CHAIN, maybeFilterId.get());
+ } else if (!strictFiltering) {
+ metric.add(MetricDefinitions.FILTERING_REQUEST_UNHANDLED, 1L, createMetricContext(servletRequest, null));
+ } else {
+ String syntheticFilterId = RejectingRequestFilter.SYNTHETIC_FILTER_CHAIN_ID;
+ metric.add(MetricDefinitions.FILTERING_REQUEST_HANDLED, 1L, createMetricContext(servletRequest, syntheticFilterId));
+ servletRequest.setAttribute(ServletRequest.JDISC_REQUEST_CHAIN, syntheticFilterId);
+ return Optional.of(RejectingRequestFilter.INSTANCE);
+ }
+ return maybeFilterId.map(bindings::getRequestFilter);
+ }
+
+ Optional<ResponseFilter> resolveResponseFilter(HttpServletRequest servletRequest, URI jdiscUri) {
+ Optional<String> maybeFilterId = bindings.resolveResponseFilter(jdiscUri, getConnector(servletRequest).listenPort());
+ if (maybeFilterId.isPresent()) {
+ metric.add(MetricDefinitions.FILTERING_RESPONSE_HANDLED, 1L, createMetricContext(servletRequest, maybeFilterId.get()));
+ servletRequest.setAttribute(ServletRequest.JDISC_RESPONSE_CHAIN, maybeFilterId.get());
+ } else {
+ metric.add(MetricDefinitions.FILTERING_RESPONSE_UNHANDLED, 1L, createMetricContext(servletRequest, null));
+ }
+ return maybeFilterId.map(bindings::getResponseFilter);
+ }
+
+ private Metric.Context createMetricContext(HttpServletRequest request, String filterId) {
+ Map<String, String> extraDimensions = filterId != null
+ ? Map.of(MetricDefinitions.FILTER_CHAIN_ID_DIMENSION, filterId)
+ : Map.of();
+ return JDiscHttpServlet.getConnector(request).createRequestMetricContext(request, extraDimensions);
+ }
+
+ private static class RejectingRequestFilter extends NoopSharedResource implements RequestFilter {
+
+ private static final RejectingRequestFilter INSTANCE = new RejectingRequestFilter();
+ private static final String SYNTHETIC_FILTER_CHAIN_ID = "strict-reject";
+
+ @Override
+ public void filter(HttpRequest request, ResponseHandler handler) {
+ Response response = new Response(Response.Status.FORBIDDEN);
+ response.headers().add("Content-Type", "text/plain");
+ try (FastContentWriter writer = ResponseDispatch.newInstance(response).connectFastWriter(handler)) {
+ writer.write("Request did not match any request filter chain");
+ }
+ }
+ }
+
+}
diff --git a/container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/FilteringRequestHandler.java b/container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/FilteringRequestHandler.java
new file mode 100644
index 00000000000..de768f979a1
--- /dev/null
+++ b/container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/FilteringRequestHandler.java
@@ -0,0 +1,134 @@
+// Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.jdisc.http.server.jetty;
+
+import com.google.common.base.Preconditions;
+import com.yahoo.jdisc.Request;
+import com.yahoo.jdisc.Response;
+import com.yahoo.jdisc.handler.AbstractRequestHandler;
+import com.yahoo.jdisc.handler.BindingNotFoundException;
+import com.yahoo.jdisc.handler.CompletionHandler;
+import com.yahoo.jdisc.handler.ContentChannel;
+import com.yahoo.jdisc.handler.RequestDeniedException;
+import com.yahoo.jdisc.handler.RequestHandler;
+import com.yahoo.jdisc.handler.ResponseHandler;
+import com.yahoo.jdisc.http.HttpRequest;
+import com.yahoo.jdisc.http.filter.RequestFilter;
+import com.yahoo.jdisc.http.filter.ResponseFilter;
+
+import javax.servlet.http.HttpServletRequest;
+import java.nio.ByteBuffer;
+import java.util.Objects;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+/**
+ * Request handler that invokes request and response filters in addition to the bound request handler.
+ *
+ * @author Øyvind Bakksjø
+ */
+class FilteringRequestHandler extends AbstractRequestHandler {
+
+ private static final ContentChannel COMPLETING_CONTENT_CHANNEL = new ContentChannel() {
+
+ @Override
+ public void write(ByteBuffer buf, CompletionHandler handler) {
+ CompletionHandlers.tryComplete(handler);
+ }
+
+ @Override
+ public void close(CompletionHandler handler) {
+ CompletionHandlers.tryComplete(handler);
+ }
+
+ };
+
+ private final FilterResolver filterResolver;
+ private final HttpServletRequest servletRequest;
+
+ public FilteringRequestHandler(FilterResolver filterResolver, HttpServletRequest servletRequest) {
+ this.filterResolver = filterResolver;
+ this.servletRequest = servletRequest;
+ }
+
+ @Override
+ public ContentChannel handleRequest(Request request, ResponseHandler originalResponseHandler) {
+ Preconditions.checkArgument(request instanceof HttpRequest, "Expected HttpRequest, got " + request);
+ Objects.requireNonNull(originalResponseHandler, "responseHandler");
+
+ RequestFilter requestFilter = filterResolver.resolveRequestFilter(servletRequest, request.getUri())
+ .orElse(null);
+ ResponseFilter responseFilter = filterResolver.resolveResponseFilter(servletRequest, request.getUri())
+ .orElse(null);
+
+ // Not using request.connect() here - it adds logic for error handling that we'd rather leave to the framework.
+ RequestHandler resolvedRequestHandler = request.container().resolveHandler(request);
+
+ if (resolvedRequestHandler == null) {
+ throw new BindingNotFoundException(request.getUri());
+ }
+
+ RequestHandler requestHandler = new ReferenceCountingRequestHandler(resolvedRequestHandler);
+
+ ResponseHandler responseHandler;
+ if (responseFilter != null) {
+ responseHandler = new FilteringResponseHandler(originalResponseHandler, responseFilter, request);
+ } else {
+ responseHandler = originalResponseHandler;
+ }
+
+ if (requestFilter != null) {
+ InterceptingResponseHandler interceptingResponseHandler = new InterceptingResponseHandler(responseHandler);
+ requestFilter.filter(HttpRequest.class.cast(request), interceptingResponseHandler);
+ if (interceptingResponseHandler.hasProducedResponse()) {
+ return COMPLETING_CONTENT_CHANNEL;
+ }
+ }
+
+ ContentChannel contentChannel = requestHandler.handleRequest(request, responseHandler);
+ if (contentChannel == null) {
+ throw new RequestDeniedException(request);
+ }
+ return contentChannel;
+ }
+
+ private static class FilteringResponseHandler implements ResponseHandler {
+
+ private final ResponseHandler delegate;
+ private final ResponseFilter responseFilter;
+ private final Request request;
+
+ public FilteringResponseHandler(ResponseHandler delegate, ResponseFilter responseFilter, Request request) {
+ this.delegate = Objects.requireNonNull(delegate);
+ this.responseFilter = Objects.requireNonNull(responseFilter);
+ this.request = request;
+ }
+
+ @Override
+ public ContentChannel handleResponse(Response response) {
+ responseFilter.filter(response, request);
+ return delegate.handleResponse(response);
+ }
+
+ }
+
+ private static class InterceptingResponseHandler implements ResponseHandler {
+
+ private final ResponseHandler delegate;
+ private AtomicBoolean hasResponded = new AtomicBoolean(false);
+
+ public InterceptingResponseHandler(ResponseHandler delegate) {
+ this.delegate = Objects.requireNonNull(delegate);
+ }
+
+ @Override
+ public ContentChannel handleResponse(Response response) {
+ ContentChannel content = delegate.handleResponse(response);
+ hasResponded.set(true);
+ return content;
+ }
+
+ public boolean hasProducedResponse() {
+ return hasResponded.get();
+ }
+ }
+
+}
diff --git a/container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/FormPostRequestHandler.java b/container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/FormPostRequestHandler.java
new file mode 100644
index 00000000000..38f84438526
--- /dev/null
+++ b/container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/FormPostRequestHandler.java
@@ -0,0 +1,188 @@
+// Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.jdisc.http.server.jetty;
+
+import com.google.common.base.Preconditions;
+import com.yahoo.jdisc.Request;
+import com.yahoo.jdisc.ResourceReference;
+import com.yahoo.jdisc.handler.AbstractRequestHandler;
+import com.yahoo.jdisc.handler.CompletionHandler;
+import com.yahoo.jdisc.handler.ContentChannel;
+import com.yahoo.jdisc.handler.RequestHandler;
+import com.yahoo.jdisc.handler.ResponseHandler;
+import com.yahoo.jdisc.http.HttpRequest;
+
+import java.io.ByteArrayOutputStream;
+import java.io.UnsupportedEncodingException;
+import java.net.URLDecoder;
+import java.nio.ByteBuffer;
+import java.nio.charset.Charset;
+import java.nio.charset.IllegalCharsetNameException;
+import java.nio.charset.StandardCharsets;
+import java.nio.charset.UnsupportedCharsetException;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+
+import static com.yahoo.jdisc.Response.Status.UNSUPPORTED_MEDIA_TYPE;
+import static com.yahoo.jdisc.http.server.jetty.CompletionHandlerUtils.NOOP_COMPLETION_HANDLER;
+
+/**
+ * Request handler that wraps POST requests of application/x-www-form-urlencoded data.
+ *
+ * The wrapper defers invocation of the "real" request handler until it has read the request content (body),
+ * parsed the form parameters and merged them into the request's parameters.
+ *
+ * @author bakksjo
+ * $Id$
+ */
+class FormPostRequestHandler extends AbstractRequestHandler implements ContentChannel {
+
+ private final ByteArrayOutputStream accumulatedRequestContent = new ByteArrayOutputStream();
+ private final RequestHandler delegateHandler;
+ private final String contentCharsetName;
+ private final boolean removeBody;
+
+ private Charset contentCharset;
+ private HttpRequest request;
+ private ResourceReference requestReference;
+ private ResponseHandler responseHandler;
+
+ /**
+ * @param delegateHandler the "real" request handler that this handler wraps
+ * @param contentCharsetName name of the charset to use when interpreting the content data
+ */
+ public FormPostRequestHandler(
+ final RequestHandler delegateHandler,
+ final String contentCharsetName,
+ final boolean removeBody) {
+ this.delegateHandler = Objects.requireNonNull(delegateHandler);
+ this.contentCharsetName = Objects.requireNonNull(contentCharsetName);
+ this.removeBody = removeBody;
+ }
+
+ @Override
+ public ContentChannel handleRequest(final Request request, final ResponseHandler responseHandler) {
+ Preconditions.checkArgument(request instanceof HttpRequest, "Expected HttpRequest, got " + request);
+ Objects.requireNonNull(responseHandler, "responseHandler");
+
+ this.contentCharset = getCharsetByName(contentCharsetName);
+ this.responseHandler = responseHandler;
+ this.request = (HttpRequest) request;
+ this.requestReference = request.refer();
+
+ return this;
+ }
+
+ @Override
+ public void write(final ByteBuffer buf, final CompletionHandler completionHandler) {
+ assert buf.hasArray();
+ accumulatedRequestContent.write(buf.array(), buf.arrayOffset() + buf.position(), buf.remaining());
+ completionHandler.completed();
+ }
+
+ @SuppressWarnings("try")
+ @Override
+ public void close(final CompletionHandler completionHandler) {
+ try (final ResourceReference ref = requestReference) {
+ final byte[] requestContentBytes = accumulatedRequestContent.toByteArray();
+ final String content = new String(requestContentBytes, contentCharset);
+ completionHandler.completed();
+ final Map<String, List<String>> parameterMap = parseFormParameters(content);
+ mergeParameters(parameterMap, request.parameters());
+ final ContentChannel contentChannel = delegateHandler.handleRequest(request, responseHandler);
+ if (contentChannel != null) {
+ if (!removeBody) {
+ final ByteBuffer byteBuffer = ByteBuffer.wrap(requestContentBytes);
+ contentChannel.write(byteBuffer, NOOP_COMPLETION_HANDLER);
+ }
+ contentChannel.close(NOOP_COMPLETION_HANDLER);
+ }
+ }
+ }
+
+ /**
+ * Looks up a Charset given a charset name.
+ *
+ * @param charsetName the name of the charset to look up
+ * @return a valid Charset for the charset name (never returns null)
+ * @throws RequestException if the charset name is invalid or unsupported
+ */
+ private static Charset getCharsetByName(final String charsetName) throws RequestException {
+ try {
+ final Charset charset = Charset.forName(charsetName);
+ if (charset == null) {
+ throw new RequestException(UNSUPPORTED_MEDIA_TYPE, "Unsupported charset " + charsetName);
+ }
+ return charset;
+ } catch (final IllegalCharsetNameException |UnsupportedCharsetException e) {
+ throw new RequestException(UNSUPPORTED_MEDIA_TYPE, "Unsupported charset " + charsetName, e);
+ }
+ }
+
+ /**
+ * Parses application/x-www-form-urlencoded data into a map of parameters.
+ *
+ * @param formContent raw form content data (body)
+ * @return map of decoded parameters
+ */
+ private static Map<String, List<String>> parseFormParameters(final String formContent) {
+ if (formContent.isEmpty()) {
+ return Collections.emptyMap();
+ }
+
+ final Map<String, List<String>> parameterMap = new HashMap<>();
+ final String[] params = formContent.split("&");
+ for (final String param : params) {
+ final String[] parts = param.split("=");
+ final String paramName = urlDecode(parts[0]);
+ final String paramValue = parts.length > 1 ? urlDecode(parts[1]) : "";
+ List<String> currentValues = parameterMap.get(paramName);
+ if (currentValues == null) {
+ currentValues = new LinkedList<>();
+ parameterMap.put(paramName, currentValues);
+ }
+ currentValues.add(paramValue);
+ }
+ return parameterMap;
+ }
+
+ /**
+ * Percent-decoding method that doesn't throw.
+ *
+ * @param encoded percent-encoded data
+ * @return decoded data
+ */
+ private static String urlDecode(final String encoded) {
+ try {
+ // Regardless of the charset used to transfer the request body,
+ // all percent-escaping of non-ascii characters should use UTF-8 code points.
+ return URLDecoder.decode(encoded, StandardCharsets.UTF_8.name());
+ } catch (final UnsupportedEncodingException e) {
+ // Unfortunately, there is no URLDecoder.decode() method that takes a Charset, so we have to deal
+ // with this exception.
+ throw new IllegalStateException("Whoa, JVM doesn't support UTF-8 today.", e);
+ }
+ }
+
+ /**
+ * Merges source parameters into a destination map.
+ *
+ * @param source containing the parameters to copy into the destination
+ * @param destination receiver of parameters, possibly already containing data
+ */
+ private static void mergeParameters(
+ final Map<String,List<String>> source,
+ final Map<String,List<String>> destination) {
+ for (Map.Entry<String, List<String>> entry : source.entrySet()) {
+ final List<String> destinationValues = destination.get(entry.getKey());
+ if (destinationValues != null) {
+ destinationValues.addAll(entry.getValue());
+ } else {
+ destination.put(entry.getKey(), entry.getValue());
+ }
+ }
+ }
+}
diff --git a/container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/HealthCheckProxyHandler.java b/container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/HealthCheckProxyHandler.java
new file mode 100644
index 00000000000..0f7ce77e4cd
--- /dev/null
+++ b/container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/HealthCheckProxyHandler.java
@@ -0,0 +1,274 @@
+// Copyright 2019 Oath Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.jdisc.http.server.jetty;
+
+import com.yahoo.concurrent.DaemonThreadFactory;
+import com.yahoo.jdisc.http.ConnectorConfig;
+import com.yahoo.security.SslContextBuilder;
+import com.yahoo.security.tls.TransportSecurityOptions;
+import com.yahoo.security.tls.TransportSecurityUtils;
+import com.yahoo.security.tls.TrustAllX509TrustManager;
+import org.apache.http.Header;
+import org.apache.http.HttpEntity;
+import org.apache.http.client.config.RequestConfig;
+import org.apache.http.client.methods.CloseableHttpResponse;
+import org.apache.http.client.methods.HttpGet;
+import org.apache.http.conn.ssl.NoopHostnameVerifier;
+import org.apache.http.impl.client.CloseableHttpClient;
+import org.apache.http.impl.client.HttpClientBuilder;
+import org.apache.http.util.EntityUtils;
+import org.eclipse.jetty.server.DetectorConnectionFactory;
+import org.eclipse.jetty.server.Request;
+import org.eclipse.jetty.server.SslConnectionFactory;
+import org.eclipse.jetty.server.handler.HandlerWrapper;
+import org.eclipse.jetty.util.ssl.SslContextFactory;
+
+import javax.net.ssl.SSLContext;
+import javax.servlet.AsyncContext;
+import javax.servlet.ServletException;
+import javax.servlet.ServletOutputStream;
+import javax.servlet.WriteListener;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.io.IOException;
+import java.time.Duration;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.concurrent.Executor;
+import java.util.concurrent.Executors;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+import static com.yahoo.jdisc.http.server.jetty.HttpServletRequestUtils.getConnectorLocalPort;
+
+/**
+ * A handler that proxies status.html health checks
+ *
+ * @author bjorncs
+ */
+class HealthCheckProxyHandler extends HandlerWrapper {
+
+ private static final Logger log = Logger.getLogger(HealthCheckProxyHandler.class.getName());
+
+ private static final String HEALTH_CHECK_PATH = "/status.html";
+
+ private final Executor executor = Executors.newSingleThreadExecutor(new DaemonThreadFactory("health-check-proxy-client-"));
+ private final Map<Integer, ProxyTarget> portToProxyTargetMapping;
+
+ HealthCheckProxyHandler(List<JDiscServerConnector> connectors) {
+ this.portToProxyTargetMapping = createPortToProxyTargetMapping(connectors);
+ }
+
+ private static Map<Integer, ProxyTarget> createPortToProxyTargetMapping(List<JDiscServerConnector> connectors) {
+ var mapping = new HashMap<Integer, ProxyTarget>();
+ for (JDiscServerConnector connector : connectors) {
+ ConnectorConfig.HealthCheckProxy proxyConfig = connector.connectorConfig().healthCheckProxy();
+ if (proxyConfig.enable()) {
+ Duration targetTimeout = Duration.ofMillis((int) (proxyConfig.clientTimeout() * 1000));
+ mapping.put(connector.listenPort(), createProxyTarget(proxyConfig.port(), targetTimeout, connectors));
+ log.info(String.format("Port %1$d is configured as a health check proxy for port %2$d. " +
+ "HTTP requests to '%3$s' on %1$d are proxied as HTTPS to %2$d.",
+ connector.listenPort(), proxyConfig.port(), HEALTH_CHECK_PATH));
+ }
+ }
+ return mapping;
+ }
+
+ private static ProxyTarget createProxyTarget(int targetPort, Duration targetTimeout, List<JDiscServerConnector> connectors) {
+ JDiscServerConnector targetConnector = connectors.stream()
+ .filter(connector -> connector.listenPort() == targetPort)
+ .findAny()
+ .orElseThrow(() -> new IllegalArgumentException("Could not find any connector with listen port " + targetPort));
+ SslContextFactory.Server sslContextFactory =
+ Optional.ofNullable(targetConnector.getConnectionFactory(SslConnectionFactory.class))
+ .or(() -> Optional.ofNullable(targetConnector.getConnectionFactory(DetectorConnectionFactory.class))
+ .map(detectorConnFactory -> detectorConnFactory.getBean(SslConnectionFactory.class)))
+ .map(connFactory -> (SslContextFactory.Server) connFactory.getSslContextFactory())
+ .orElseThrow(() -> new IllegalArgumentException("Health check proxy can only target https port"));
+ return new ProxyTarget(targetPort, targetTimeout, sslContextFactory);
+ }
+
+ @Override
+ public void handle(String target, Request request, HttpServletRequest servletRequest, HttpServletResponse servletResponse) throws IOException, ServletException {
+ int localPort = getConnectorLocalPort(servletRequest);
+ ProxyTarget proxyTarget = portToProxyTargetMapping.get(localPort);
+ if (proxyTarget != null) {
+ AsyncContext asyncContext = servletRequest.startAsync();
+ ServletOutputStream out = servletResponse.getOutputStream();
+ if (servletRequest.getRequestURI().equals(HEALTH_CHECK_PATH)) {
+ executor.execute(new ProxyRequestTask(asyncContext, proxyTarget, servletResponse, out));
+ } else {
+ servletResponse.setStatus(HttpServletResponse.SC_NOT_FOUND);
+ asyncContext.complete();
+ }
+ request.setHandled(true);
+ } else {
+ _handler.handle(target, request, servletRequest, servletResponse);
+ }
+ }
+
+ @Override
+ protected void doStop() throws Exception {
+ for (ProxyTarget target : portToProxyTargetMapping.values()) {
+ target.close();
+ }
+ super.doStop();
+ }
+
+ private static class ProxyRequestTask implements Runnable {
+
+ final AsyncContext asyncContext;
+ final ProxyTarget target;
+ final HttpServletResponse servletResponse;
+ final ServletOutputStream output;
+
+ ProxyRequestTask(AsyncContext asyncContext, ProxyTarget target, HttpServletResponse servletResponse, ServletOutputStream output) {
+ this.asyncContext = asyncContext;
+ this.target = target;
+ this.servletResponse = servletResponse;
+ this.output = output;
+ }
+
+ @Override
+ public void run() {
+ StatusResponse statusResponse = target.requestStatusHtml();
+ servletResponse.setStatus(statusResponse.statusCode);
+ if (statusResponse.contentType != null) {
+ servletResponse.setHeader("Content-Type", statusResponse.contentType);
+ }
+ servletResponse.setHeader("Vespa-Health-Check-Proxy-Target", Integer.toString(target.port));
+ output.setWriteListener(new WriteListener() {
+ @Override
+ public void onWritePossible() throws IOException {
+ if (output.isReady()) {
+ if (statusResponse.content != null) {
+ output.write(statusResponse.content);
+ }
+ asyncContext.complete();
+ }
+ }
+
+ @Override
+ public void onError(Throwable t) {
+ log.log(Level.FINE, t, () -> "Failed to write status response: " + t.getMessage());
+ asyncContext.complete();
+ }
+ });
+ }
+ }
+
+ private static class ProxyTarget implements AutoCloseable {
+ final int port;
+ final Duration timeout;
+ final SslContextFactory.Server sslContextFactory;
+ volatile CloseableHttpClient client;
+ volatile StatusResponse lastResponse;
+
+ ProxyTarget(int port, Duration timeout, SslContextFactory.Server sslContextFactory) {
+ this.port = port;
+ this.timeout = timeout;
+ this.sslContextFactory = sslContextFactory;
+ }
+
+ StatusResponse requestStatusHtml() {
+ StatusResponse response = lastResponse;
+ if (response != null && !response.isExpired()) {
+ return response;
+ }
+ return this.lastResponse = getStatusResponse();
+ }
+
+ private StatusResponse getStatusResponse() {
+ try (CloseableHttpResponse clientResponse = client().execute(new HttpGet("https://localhost:" + port + HEALTH_CHECK_PATH))) {
+ int statusCode = clientResponse.getStatusLine().getStatusCode();
+ HttpEntity entity = clientResponse.getEntity();
+ if (entity != null) {
+ Header contentTypeHeader = entity.getContentType();
+ String contentType = contentTypeHeader != null ? contentTypeHeader.getValue() : null;
+ byte[] content = EntityUtils.toByteArray(entity);
+ return new StatusResponse(statusCode, contentType, content);
+ } else {
+ return new StatusResponse(statusCode, null, null);
+ }
+ } catch (Exception e) {
+ log.log(Level.FINE, e, () -> "Proxy request failed" + e.getMessage());
+ return new StatusResponse(500, "text/plain", e.getMessage().getBytes());
+ }
+ }
+
+ // Client construction must be delayed to ensure that the SslContextFactory is started before calling getSslContext().
+ private CloseableHttpClient client() {
+ if (client == null) {
+ synchronized (this) {
+ if (client == null) {
+ int timeoutMillis = (int) timeout.toMillis();
+ client = HttpClientBuilder.create()
+ .disableAutomaticRetries()
+ .setMaxConnPerRoute(4)
+ .setSSLContext(getSslContext(sslContextFactory))
+ .setSSLHostnameVerifier(NoopHostnameVerifier.INSTANCE) // Certificate may not match "localhost"
+ .setUserTokenHandler(context -> null) // https://stackoverflow.com/a/42112034/1615280
+ .setUserAgent("health-check-proxy-client")
+ .setDefaultRequestConfig(
+ RequestConfig.custom()
+ .setConnectTimeout(timeoutMillis)
+ .setConnectionRequestTimeout(timeoutMillis)
+ .setSocketTimeout(timeoutMillis)
+ .build())
+ .build();
+ }
+ }
+ }
+ return client;
+ }
+
+ private SSLContext getSslContext(SslContextFactory.Server sslContextFactory) {
+ // A client certificate is only required if the server connector's ssl context factory is configured with "need-auth".
+ if (sslContextFactory.getNeedClientAuth()) {
+ log.info(String.format("Port %d requires client certificate - client will provide its node certificate", port));
+ // We should ideally specify the client certificate through connector config, but the model has currently no knowledge of node certificate location on disk.
+ // Instead we assume that the server connector will accept its own node certificate. This will work for the current hosted use-case.
+ // The Vespa TLS config will provide us the location of certificate and key.
+ TransportSecurityOptions options = TransportSecurityUtils.getOptions()
+ .orElseThrow(() ->
+ new IllegalStateException("Vespa TLS configuration is required when using health check proxy to a port with client auth 'need'"));
+ return new SslContextBuilder()
+ .withKeyStore(options.getPrivateKeyFile().get(), options.getCertificatesFile().get())
+ .withTrustManager(new TrustAllX509TrustManager())
+ .build();
+ } else {
+ log.info(String.format(
+ "Port %d does not require a client certificate - client will not provide a certificate", port));
+ return new SslContextBuilder()
+ .withTrustManager(new TrustAllX509TrustManager())
+ .build();
+ }
+ }
+
+ @Override
+ public void close() throws IOException {
+ synchronized (this) {
+ if (client != null) {
+ client.close();
+ client = null;
+ }
+ }
+ }
+ }
+
+ private static class StatusResponse {
+ final long createdAt = System.nanoTime();
+ final int statusCode;
+ final String contentType;
+ final byte[] content;
+
+ StatusResponse(int statusCode, String contentType, byte[] content) {
+ this.statusCode = statusCode;
+ this.contentType = contentType;
+ this.content = content;
+ }
+
+ boolean isExpired() { return System.nanoTime() - createdAt > Duration.ofSeconds(1).toNanos(); }
+ }
+}
diff --git a/container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/HttpRequestDispatch.java b/container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/HttpRequestDispatch.java
new file mode 100644
index 00000000000..05715b13d10
--- /dev/null
+++ b/container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/HttpRequestDispatch.java
@@ -0,0 +1,243 @@
+// Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.jdisc.http.server.jetty;
+
+import com.yahoo.container.logging.AccessLogEntry;
+import com.yahoo.jdisc.Metric.Context;
+import com.yahoo.jdisc.References;
+import com.yahoo.jdisc.ResourceReference;
+import com.yahoo.jdisc.Response;
+import com.yahoo.jdisc.handler.BindingNotFoundException;
+import com.yahoo.jdisc.handler.ContentChannel;
+import com.yahoo.jdisc.handler.OverloadException;
+import com.yahoo.jdisc.handler.RequestHandler;
+import com.yahoo.jdisc.http.ConnectorConfig;
+import com.yahoo.jdisc.http.HttpHeaders;
+import com.yahoo.jdisc.http.HttpRequest;
+import org.eclipse.jetty.io.EofException;
+import org.eclipse.jetty.server.HttpConnection;
+import org.eclipse.jetty.server.Request;
+
+import javax.servlet.AsyncContext;
+import javax.servlet.ServletInputStream;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.time.Instant;
+import java.util.Arrays;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CompletionException;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.function.BiConsumer;
+import java.util.function.Consumer;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+import static com.yahoo.jdisc.http.HttpHeaders.Values.APPLICATION_X_WWW_FORM_URLENCODED;
+import static com.yahoo.jdisc.http.server.jetty.HttpServletRequestUtils.getConnection;
+import static com.yahoo.jdisc.http.server.jetty.JDiscHttpServlet.getConnector;
+import static com.yahoo.yolean.Exceptions.throwUnchecked;
+
+/**
+ * @author Simon Thoresen Hult
+ * @author bjorncs
+ */
+class HttpRequestDispatch {
+
+ private static final Logger log = Logger.getLogger(HttpRequestDispatch.class.getName());
+
+ private final static String CHARSET_ANNOTATION = ";charset=";
+
+ private final JDiscContext jDiscContext;
+ private final AsyncContext async;
+ private final Request jettyRequest;
+
+ private final ServletResponseController servletResponseController;
+ private final RequestHandler requestHandler;
+ private final RequestMetricReporter metricReporter;
+
+ public HttpRequestDispatch(JDiscContext jDiscContext,
+ AccessLogEntry accessLogEntry,
+ Context metricContext,
+ HttpServletRequest servletRequest,
+ HttpServletResponse servletResponse) throws IOException {
+ this.jDiscContext = jDiscContext;
+
+ requestHandler = newRequestHandler(jDiscContext, accessLogEntry, servletRequest);
+
+ this.jettyRequest = (Request) servletRequest;
+ this.metricReporter = new RequestMetricReporter(jDiscContext.metric, metricContext, jettyRequest.getTimeStamp());
+ this.servletResponseController = new ServletResponseController(servletRequest,
+ servletResponse,
+ jDiscContext.janitor,
+ metricReporter,
+ jDiscContext.developerMode());
+ markConnectionAsNonPersistentIfThresholdReached(servletRequest);
+ this.async = servletRequest.startAsync();
+ async.setTimeout(0);
+ metricReporter.uriLength(jettyRequest.getOriginalURI().length());
+ }
+
+ public void dispatch() throws IOException {
+ ServletRequestReader servletRequestReader;
+ try {
+ servletRequestReader = handleRequest();
+ } catch (Throwable throwable) {
+ servletResponseController.trySendError(throwable);
+ servletResponseController.finishedFuture().whenComplete((result, exception) ->
+ completeRequestCallback.accept(null, throwable));
+ return;
+ }
+
+ try {
+ onError(servletRequestReader.finishedFuture, servletResponseController::trySendError);
+ onError(servletResponseController.finishedFuture(), servletRequestReader::onError);
+ CompletableFuture.allOf(servletRequestReader.finishedFuture, servletResponseController.finishedFuture())
+ .whenComplete(completeRequestCallback);
+ } catch (Throwable throwable) {
+ log.log(Level.WARNING, "Failed registering finished listeners.", throwable);
+ }
+ }
+
+ private final BiConsumer<Void, Throwable> completeRequestCallback;
+ {
+ AtomicBoolean completeRequestCalled = new AtomicBoolean(false);
+ HttpRequestDispatch parent = this; //used to avoid binding uninitialized variables
+
+ completeRequestCallback = (result, error) -> {
+ boolean alreadyCalled = completeRequestCalled.getAndSet(true);
+ if (alreadyCalled) {
+ AssertionError e = new AssertionError("completeRequest called more than once");
+ log.log(Level.WARNING, "Assertion failed.", e);
+ throw e;
+ }
+
+ boolean reportedError = false;
+
+ if (error != null) {
+ if (isErrorOfType(error, EofException.class, IOException.class)) {
+ log.log(Level.FINE,
+ error,
+ () -> "Network connection was unexpectedly terminated: " + parent.jettyRequest.getRequestURI());
+ parent.metricReporter.prematurelyClosed();
+ } else if (!isErrorOfType(error, OverloadException.class, BindingNotFoundException.class, RequestException.class)) {
+ log.log(Level.WARNING, "Request failed: " + parent.jettyRequest.getRequestURI(), error);
+ }
+ reportedError = true;
+ parent.metricReporter.failedResponse();
+ } else {
+ parent.metricReporter.successfulResponse();
+ }
+
+ try {
+ parent.async.complete();
+ log.finest(() -> "Request completed successfully: " + parent.jettyRequest.getRequestURI());
+ } catch (Throwable throwable) {
+ Level level = reportedError ? Level.FINE: Level.WARNING;
+ log.log(level, "Async.complete failed", throwable);
+ }
+ };
+ }
+
+ private static void markConnectionAsNonPersistentIfThresholdReached(HttpServletRequest request) {
+ ConnectorConfig connectorConfig = getConnector(request).connectorConfig();
+ int maxRequestsPerConnection = connectorConfig.maxRequestsPerConnection();
+ if (maxRequestsPerConnection > 0) {
+ HttpConnection connection = getConnection(request);
+ if (connection.getMessagesIn() >= maxRequestsPerConnection) {
+ connection.getGenerator().setPersistent(false);
+ }
+ }
+ double maxConnectionLifeInSeconds = connectorConfig.maxConnectionLife();
+ if (maxConnectionLifeInSeconds > 0) {
+ HttpConnection connection = getConnection(request);
+ Instant expireAt = Instant.ofEpochMilli((long)(connection.getCreatedTimeStamp() + maxConnectionLifeInSeconds * 1000));
+ if (Instant.now().isAfter(expireAt)) {
+ connection.getGenerator().setPersistent(false);
+ }
+ }
+ }
+
+ @SafeVarargs
+ @SuppressWarnings("varargs")
+ private static boolean isErrorOfType(Throwable throwable, Class<? extends Throwable>... handledTypes) {
+ return Arrays.stream(handledTypes)
+ .anyMatch(
+ exceptionType -> exceptionType.isInstance(throwable)
+ || throwable instanceof CompletionException && exceptionType.isInstance(throwable.getCause()));
+ }
+
+ @SuppressWarnings("try")
+ private ServletRequestReader handleRequest() throws IOException {
+ HttpRequest jdiscRequest = HttpRequestFactory.newJDiscRequest(jDiscContext.container, jettyRequest);
+ ContentChannel requestContentChannel;
+
+ try (ResourceReference ref = References.fromResource(jdiscRequest)) {
+ HttpRequestFactory.copyHeaders(jettyRequest, jdiscRequest);
+ requestContentChannel = requestHandler.handleRequest(jdiscRequest, servletResponseController.responseHandler);
+ }
+
+ ServletInputStream servletInputStream = jettyRequest.getInputStream();
+
+ ServletRequestReader servletRequestReader = new ServletRequestReader(servletInputStream,
+ requestContentChannel,
+ jDiscContext.janitor,
+ metricReporter);
+
+ servletInputStream.setReadListener(servletRequestReader);
+ return servletRequestReader;
+ }
+
+ private static void onError(CompletableFuture<?> future, Consumer<Throwable> errorHandler) {
+ future.whenComplete((result, exception) -> {
+ if (exception != null) {
+ errorHandler.accept(exception);
+ }
+ });
+ }
+
+ ContentChannel handleRequestFilterResponse(Response response) {
+ try {
+ jettyRequest.getInputStream().close();
+ ContentChannel responseContentChannel = servletResponseController.responseHandler.handleResponse(response);
+ servletResponseController.finishedFuture().whenComplete(completeRequestCallback);
+ return responseContentChannel;
+ } catch (IOException e) {
+ throw throwUnchecked(e);
+ }
+ }
+
+
+ private static RequestHandler newRequestHandler(JDiscContext context,
+ AccessLogEntry accessLogEntry,
+ HttpServletRequest servletRequest) {
+ RequestHandler requestHandler = wrapHandlerIfFormPost(
+ new FilteringRequestHandler(context.filterResolver, servletRequest),
+ servletRequest, context.serverConfig.removeRawPostBodyForWwwUrlEncodedPost());
+
+ return new AccessLoggingRequestHandler(requestHandler, accessLogEntry);
+ }
+
+ private static RequestHandler wrapHandlerIfFormPost(RequestHandler requestHandler,
+ HttpServletRequest servletRequest,
+ boolean removeBodyForFormPost) {
+ if (!servletRequest.getMethod().equals("POST")) {
+ return requestHandler;
+ }
+ String contentType = servletRequest.getHeader(HttpHeaders.Names.CONTENT_TYPE);
+ if (contentType == null) {
+ return requestHandler;
+ }
+ if (!contentType.startsWith(APPLICATION_X_WWW_FORM_URLENCODED)) {
+ return requestHandler;
+ }
+ return new FormPostRequestHandler(requestHandler, getCharsetName(contentType), removeBodyForFormPost);
+ }
+
+ private static String getCharsetName(String contentType) {
+ if (!contentType.startsWith(CHARSET_ANNOTATION, APPLICATION_X_WWW_FORM_URLENCODED.length())) {
+ return StandardCharsets.UTF_8.name();
+ }
+ return contentType.substring(APPLICATION_X_WWW_FORM_URLENCODED.length() + CHARSET_ANNOTATION.length());
+ }
+}
diff --git a/container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/HttpRequestFactory.java b/container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/HttpRequestFactory.java
new file mode 100644
index 00000000000..e8d37cfadb5
--- /dev/null
+++ b/container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/HttpRequestFactory.java
@@ -0,0 +1,87 @@
+// Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.jdisc.http.server.jetty;
+
+import com.yahoo.jdisc.http.HttpRequest;
+import com.yahoo.jdisc.http.servlet.ServletRequest;
+import com.yahoo.jdisc.service.CurrentContainer;
+import org.eclipse.jetty.util.Utf8Appendable;
+
+import javax.servlet.http.HttpServletRequest;
+import java.net.InetSocketAddress;
+import java.net.URI;
+import java.security.cert.X509Certificate;
+import java.util.Enumeration;
+
+import static com.yahoo.jdisc.Response.Status.BAD_REQUEST;
+import static com.yahoo.jdisc.http.server.jetty.HttpServletRequestUtils.getConnection;
+import static com.yahoo.jdisc.http.server.jetty.HttpServletRequestUtils.getConnectorLocalPort;
+
+/**
+ * @author Simon Thoresen Hult
+ * @author bjorncs
+ */
+class HttpRequestFactory {
+
+ public static HttpRequest newJDiscRequest(CurrentContainer container, HttpServletRequest servletRequest) {
+ try {
+ HttpRequest httpRequest = HttpRequest.newServerRequest(
+ container,
+ getUri(servletRequest),
+ HttpRequest.Method.valueOf(servletRequest.getMethod()),
+ HttpRequest.Version.fromString(servletRequest.getProtocol()),
+ new InetSocketAddress(servletRequest.getRemoteAddr(), servletRequest.getRemotePort()),
+ getConnection(servletRequest).getCreatedTimeStamp());
+ httpRequest.context().put(ServletRequest.JDISC_REQUEST_X509CERT, getCertChain(servletRequest));
+ return httpRequest;
+ } catch (Utf8Appendable.NotUtf8Exception e) {
+ throw createBadQueryException(e);
+ }
+ }
+
+ // Implementation based on org.eclipse.jetty.server.Request.getRequestURL(), but with the connector's local port instead
+ public static URI getUri(HttpServletRequest servletRequest) {
+ try {
+ String scheme = servletRequest.getScheme();
+ String host = servletRequest.getServerName();
+ int port = getConnectorLocalPort(servletRequest);
+ String path = servletRequest.getRequestURI();
+ String query = servletRequest.getQueryString();
+
+ URI uri = URI.create(scheme + "://" +
+ host + ":" + port +
+ (path != null ? path : "") +
+ (query != null ? "?" + query : ""));
+
+ validateSchemeHostPort(scheme, host, port, uri);
+ return uri;
+ }
+ catch (IllegalArgumentException e) {
+ throw createBadQueryException(e);
+ }
+ }
+
+ private static void validateSchemeHostPort(String scheme, String host, int port, URI uri) {
+ if ( ! scheme.equals(uri.getScheme()))
+ throw new IllegalArgumentException("Bad scheme: " + scheme);
+
+ if ( ! host.equals(uri.getHost()) || port != uri.getPort())
+ throw new IllegalArgumentException("Bad authority: " + uri.getRawAuthority() + " != " + host + ":" + port);
+ }
+
+ private static RequestException createBadQueryException(IllegalArgumentException e) {
+ return new RequestException(BAD_REQUEST, "URL violates RFC 2396: " + e.getMessage(), e);
+ }
+
+ public static void copyHeaders(HttpServletRequest from, HttpRequest to) {
+ for (Enumeration<String> it = from.getHeaderNames(); it.hasMoreElements(); ) {
+ String key = it.nextElement();
+ for (Enumeration<String> value = from.getHeaders(key); value.hasMoreElements(); ) {
+ to.headers().add(key, value.nextElement());
+ }
+ }
+ }
+
+ private static X509Certificate[] getCertChain(HttpServletRequest servletRequest) {
+ return (X509Certificate[]) servletRequest.getAttribute("javax.servlet.request.X509Certificate");
+ }
+}
diff --git a/container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/HttpResponseStatisticsCollector.java b/container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/HttpResponseStatisticsCollector.java
new file mode 100644
index 00000000000..82c445c7ca9
--- /dev/null
+++ b/container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/HttpResponseStatisticsCollector.java
@@ -0,0 +1,300 @@
+// Copyright 2018 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.jdisc.http.server.jetty;
+
+import com.yahoo.jdisc.http.HttpRequest;
+import org.eclipse.jetty.http.HttpStatus;
+import org.eclipse.jetty.server.AsyncContextEvent;
+import org.eclipse.jetty.server.Handler;
+import org.eclipse.jetty.server.HttpChannelState;
+import org.eclipse.jetty.server.Request;
+import org.eclipse.jetty.server.handler.HandlerWrapper;
+import org.eclipse.jetty.util.FutureCallback;
+import org.eclipse.jetty.util.component.Graceful;
+
+import javax.servlet.AsyncEvent;
+import javax.servlet.AsyncListener;
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeoutException;
+import java.util.concurrent.atomic.AtomicLong;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.concurrent.atomic.LongAdder;
+
+/**
+ * HttpResponseStatisticsCollector collects statistics about HTTP response types aggregated by category
+ * (1xx, 2xx, etc). It is similar to {@link org.eclipse.jetty.server.handler.StatisticsHandler}
+ * with the distinction that this class collects response type statistics grouped
+ * by HTTP method and only collects the numbers that are reported as metrics from Vespa.
+ *
+ * @author ollivir
+ */
+public class HttpResponseStatisticsCollector extends HandlerWrapper implements Graceful {
+
+ static final String requestTypeAttribute = "requestType";
+
+ private final AtomicReference<FutureCallback> shutdown = new AtomicReference<>();
+ private final List<String> monitoringHandlerPaths;
+ private final List<String> searchHandlerPaths;
+
+ public enum HttpMethod {
+ GET, PATCH, POST, PUT, DELETE, OPTIONS, HEAD, OTHER
+ }
+
+ public enum HttpScheme {
+ HTTP, HTTPS, OTHER
+ }
+
+ private static final String[] HTTP_RESPONSE_GROUPS = {
+ MetricDefinitions.RESPONSES_1XX,
+ MetricDefinitions.RESPONSES_2XX,
+ MetricDefinitions.RESPONSES_3XX,
+ MetricDefinitions.RESPONSES_4XX,
+ MetricDefinitions.RESPONSES_5XX,
+ MetricDefinitions.RESPONSES_401,
+ MetricDefinitions.RESPONSES_403
+ };
+
+ private final AtomicLong inFlight = new AtomicLong();
+ private final LongAdder[][][][] statistics;
+
+ public HttpResponseStatisticsCollector(List<String> monitoringHandlerPaths, List<String> searchHandlerPaths) {
+ this.monitoringHandlerPaths = monitoringHandlerPaths;
+ this.searchHandlerPaths = searchHandlerPaths;
+ statistics = new LongAdder[HttpScheme.values().length][HttpMethod.values().length][][];
+ for (int scheme = 0; scheme < HttpScheme.values().length; ++scheme) {
+ for (int method = 0; method < HttpMethod.values().length; method++) {
+ statistics[scheme][method] = new LongAdder[HTTP_RESPONSE_GROUPS.length][];
+ for (int group = 0; group < HTTP_RESPONSE_GROUPS.length; group++) {
+ statistics[scheme][method][group] = new LongAdder[HttpRequest.RequestType.values().length];
+ for (int requestType = 0; requestType < HttpRequest.RequestType.values().length; requestType++) {
+ statistics[scheme][method][group][requestType] = new LongAdder();
+ }
+ }
+ }
+ }
+ }
+
+ private final AsyncListener completionWatcher = new AsyncListener() {
+
+ @Override
+ public void onTimeout(AsyncEvent event) { }
+
+ @Override
+ public void onStartAsync(AsyncEvent event) {
+ event.getAsyncContext().addListener(this);
+ }
+
+ @Override
+ public void onError(AsyncEvent event) { }
+
+ @Override
+ public void onComplete(AsyncEvent event) throws IOException {
+ HttpChannelState state = ((AsyncContextEvent) event).getHttpChannelState();
+ Request request = state.getBaseRequest();
+
+ observeEndOfRequest(request, null);
+ }
+ };
+
+ @Override
+ public void handle(String path, Request baseRequest, HttpServletRequest request, HttpServletResponse response)
+ throws IOException, ServletException {
+ inFlight.incrementAndGet();
+
+ try {
+ Handler handler = getHandler();
+ if (handler != null && shutdown.get() == null && isStarted()) {
+ handler.handle(path, baseRequest, request, response);
+ } else if ( ! baseRequest.isHandled()) {
+ baseRequest.setHandled(true);
+ response.sendError(HttpStatus.SERVICE_UNAVAILABLE_503);
+ }
+ } finally {
+ HttpChannelState state = baseRequest.getHttpChannelState();
+ if (state.isSuspended()) {
+ if (state.isInitial()) {
+ state.addListener(completionWatcher);
+ }
+ } else if (state.isInitial()) {
+ observeEndOfRequest(baseRequest, response);
+ }
+ }
+ }
+
+ private void observeEndOfRequest(Request request, HttpServletResponse flushableResponse) throws IOException {
+ int group = groupIndex(request);
+ if (group >= 0) {
+ HttpScheme scheme = getScheme(request);
+ HttpMethod method = getMethod(request);
+ HttpRequest.RequestType requestType = getRequestType(request);
+
+ statistics[scheme.ordinal()][method.ordinal()][group][requestType.ordinal()].increment();
+ if (group == 5 || group == 6) { // if 401/403, also increment 4xx
+ statistics[scheme.ordinal()][method.ordinal()][3][requestType.ordinal()].increment();
+ }
+ }
+
+ long live = inFlight.decrementAndGet();
+ FutureCallback shutdownCb = shutdown.get();
+ if (shutdownCb != null) {
+ if (flushableResponse != null) {
+ flushableResponse.flushBuffer();
+ }
+ if (live == 0) {
+ shutdownCb.succeeded();
+ }
+ }
+ }
+
+ private int groupIndex(Request request) {
+ int index = request.getResponse().getStatus();
+ if (index == 401) {
+ return 5;
+ }
+ if (index == 403) {
+ return 6;
+ }
+
+ index = index / 100 - 1; // 1xx = 0, 2xx = 1 etc.
+ if (index < 0 || index >= statistics[0].length) {
+ return -1;
+ } else {
+ return index;
+ }
+ }
+
+ private HttpScheme getScheme(Request request) {
+ switch (request.getScheme()) {
+ case "http":
+ return HttpScheme.HTTP;
+ case "https":
+ return HttpScheme.HTTPS;
+ default:
+ return HttpScheme.OTHER;
+ }
+ }
+
+ private HttpMethod getMethod(Request request) {
+ switch (request.getMethod()) {
+ case "GET":
+ return HttpMethod.GET;
+ case "PATCH":
+ return HttpMethod.PATCH;
+ case "POST":
+ return HttpMethod.POST;
+ case "PUT":
+ return HttpMethod.PUT;
+ case "DELETE":
+ return HttpMethod.DELETE;
+ case "OPTIONS":
+ return HttpMethod.OPTIONS;
+ case "HEAD":
+ return HttpMethod.HEAD;
+ default:
+ return HttpMethod.OTHER;
+ }
+ }
+
+ private HttpRequest.RequestType getRequestType(Request request) {
+ HttpRequest.RequestType requestType = (HttpRequest.RequestType)request.getAttribute(requestTypeAttribute);
+ if (requestType != null) return requestType;
+
+ // Deduce from path and method:
+ String path = request.getRequestURI();
+ for (String monitoringHandlerPath : monitoringHandlerPaths) {
+ if (path.startsWith(monitoringHandlerPath)) return HttpRequest.RequestType.MONITORING;
+ }
+ for (String searchHandlerPath : searchHandlerPaths) {
+ if (path.startsWith(searchHandlerPath)) return HttpRequest.RequestType.READ;
+ }
+ if ("GET".equals(request.getMethod())) {
+ return HttpRequest.RequestType.READ;
+ } else {
+ return HttpRequest.RequestType.WRITE;
+ }
+ }
+
+ public List<StatisticsEntry> takeStatistics() {
+ var ret = new ArrayList<StatisticsEntry>();
+ for (HttpScheme scheme : HttpScheme.values()) {
+ int schemeIndex = scheme.ordinal();
+ for (HttpMethod method : HttpMethod.values()) {
+ int methodIndex = method.ordinal();
+ for (int group = 0; group < HTTP_RESPONSE_GROUPS.length; group++) {
+ for (HttpRequest.RequestType type : HttpRequest.RequestType.values()) {
+ long value = statistics[schemeIndex][methodIndex][group][type.ordinal()].sumThenReset();
+ if (value > 0) {
+ ret.add(new StatisticsEntry(scheme.name().toLowerCase(), method.name(), HTTP_RESPONSE_GROUPS[group], type.name().toLowerCase(), value));
+ }
+ }
+ }
+ }
+ }
+ return ret;
+ }
+
+ @Override
+ protected void doStart() throws Exception {
+ shutdown.set(null);
+ super.doStart();
+ }
+
+ @Override
+ protected void doStop() throws Exception {
+ super.doStop();
+ FutureCallback shutdownCb = shutdown.get();
+ if ( ! shutdownCb.isDone()) {
+ shutdownCb.failed(new TimeoutException());
+ }
+ }
+
+ @Override
+ public Future<Void> shutdown() {
+ FutureCallback shutdownCb = new FutureCallback(false);
+ shutdown.compareAndSet(null, shutdownCb);
+ shutdownCb = shutdown.get();
+ if (inFlight.get() == 0) {
+ shutdownCb.succeeded();
+ }
+ return shutdownCb;
+ }
+
+ @Override
+ public boolean isShutdown() {
+ FutureCallback futureCallback = shutdown.get();
+ return futureCallback != null && futureCallback.isDone();
+ }
+
+ public static class StatisticsEntry {
+
+ public final String scheme;
+ public final String method;
+ public final String name;
+ public final String requestType;
+ public final long value;
+
+ public StatisticsEntry(String scheme, String method, String name, String requestType, long value) {
+ this.scheme = scheme;
+ this.method = method;
+ this.name = name;
+ this.requestType = requestType;
+ this.value = value;
+ }
+
+ @Override
+ public String toString() {
+ return "scheme: " + scheme +
+ ", method: " + method +
+ ", name: " + name +
+ ", requestType: " + requestType +
+ ", value: " + value;
+ }
+
+ }
+
+}
diff --git a/container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/HttpServletRequestUtils.java b/container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/HttpServletRequestUtils.java
new file mode 100644
index 00000000000..e7b9f459d2e
--- /dev/null
+++ b/container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/HttpServletRequestUtils.java
@@ -0,0 +1,38 @@
+// Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.jdisc.http.server.jetty;
+
+import org.eclipse.jetty.server.HttpConnection;
+
+import javax.servlet.http.HttpServletRequest;
+
+/**
+ * @author bjorncs
+ */
+public class HttpServletRequestUtils {
+ private HttpServletRequestUtils() {}
+
+ public static HttpConnection getConnection(HttpServletRequest request) {
+ return (HttpConnection)request.getAttribute("org.eclipse.jetty.server.HttpConnection");
+ }
+
+ /**
+ * Note: {@link HttpServletRequest#getLocalPort()} may return the local port of the load balancer / reverse proxy if proxy-protocol is enabled.
+ * @return the actual local port of the underlying Jetty connector
+ */
+ public static int getConnectorLocalPort(HttpServletRequest request) {
+ JDiscServerConnector connector = (JDiscServerConnector) getConnection(request).getConnector();
+ int actualLocalPort = connector.getLocalPort();
+ int localPortIfConnectorUnopened = -1;
+ int localPortIfConnectorClosed = -2;
+ if (actualLocalPort == localPortIfConnectorUnopened || actualLocalPort == localPortIfConnectorClosed) {
+ int configuredLocalPort = connector.listenPort();
+ int localPortEphemeralPort = 0;
+ if (configuredLocalPort == localPortEphemeralPort) {
+ throw new IllegalStateException("Unable to determine connector's listen port");
+ }
+ return configuredLocalPort;
+ }
+ return actualLocalPort;
+ }
+
+}
diff --git a/container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/JDiscContext.java b/container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/JDiscContext.java
new file mode 100644
index 00000000000..b37a7352dc6
--- /dev/null
+++ b/container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/JDiscContext.java
@@ -0,0 +1,33 @@
+// Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.jdisc.http.server.jetty;
+
+import com.yahoo.jdisc.Metric;
+import com.yahoo.jdisc.http.ServerConfig;
+import com.yahoo.jdisc.service.CurrentContainer;
+
+import java.util.concurrent.Executor;
+
+public class JDiscContext {
+ final FilterResolver filterResolver;
+ final CurrentContainer container;
+ final Executor janitor;
+ final Metric metric;
+ final ServerConfig serverConfig;
+
+ public JDiscContext(FilterBindings filterBindings,
+ CurrentContainer container,
+ Executor janitor,
+ Metric metric,
+ ServerConfig serverConfig) {
+
+ this.filterResolver = new FilterResolver(filterBindings, metric, serverConfig.strictFiltering());
+ this.container = container;
+ this.janitor = janitor;
+ this.metric = metric;
+ this.serverConfig = serverConfig;
+ }
+
+ public boolean developerMode() {
+ return serverConfig.developerMode();
+ }
+}
diff --git a/container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/JDiscFilterInvokerFilter.java b/container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/JDiscFilterInvokerFilter.java
new file mode 100644
index 00000000000..a89c115a1c2
--- /dev/null
+++ b/container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/JDiscFilterInvokerFilter.java
@@ -0,0 +1,294 @@
+// Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.jdisc.http.server.jetty;
+
+import com.yahoo.container.logging.AccessLogEntry;
+import com.yahoo.jdisc.handler.ResponseHandler;
+import com.yahoo.jdisc.http.filter.RequestFilter;
+
+import javax.servlet.AsyncContext;
+import javax.servlet.AsyncListener;
+import javax.servlet.Filter;
+import javax.servlet.FilterChain;
+import javax.servlet.FilterConfig;
+import javax.servlet.ServletContext;
+import javax.servlet.ServletException;
+import javax.servlet.ServletOutputStream;
+import javax.servlet.ServletRequest;
+import javax.servlet.ServletResponse;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletRequestWrapper;
+import javax.servlet.http.HttpServletResponse;
+import javax.servlet.http.HttpServletResponseWrapper;
+import java.io.IOException;
+import java.io.PrintWriter;
+import java.net.URI;
+import java.util.Map;
+import java.util.Optional;
+import java.util.concurrent.atomic.AtomicReference;
+
+import static com.yahoo.jdisc.http.server.jetty.JDiscHttpServlet.getConnector;
+import static com.yahoo.yolean.Exceptions.throwUnchecked;
+
+/**
+ * Runs JDisc security filters for Servlets
+ * This component is split in two:
+ * 1) JDiscFilterInvokerFilter, which uses package private methods to support JDisc APIs
+ * 2) SecurityFilterInvoker, which is intended for use in a servlet context.
+ *
+ * @author Tony Vaagenes
+ */
+class JDiscFilterInvokerFilter implements Filter {
+ private final JDiscContext jDiscContext;
+ private final FilterInvoker filterInvoker;
+
+ public JDiscFilterInvokerFilter(JDiscContext jDiscContext,
+ FilterInvoker filterInvoker) {
+ this.jDiscContext = jDiscContext;
+ this.filterInvoker = filterInvoker;
+ }
+
+
+ @Override
+ public void init(FilterConfig filterConfig) throws ServletException {}
+
+ @Override
+ public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
+ HttpServletRequest httpRequest = (HttpServletRequest)request;
+ HttpServletResponse httpResponse = (HttpServletResponse)response;
+
+ URI uri;
+ try {
+ uri = HttpRequestFactory.getUri(httpRequest);
+ } catch (RequestException e) {
+ httpResponse.sendError(e.getResponseStatus(), e.getMessage());
+ return;
+ }
+
+ AtomicReference<Boolean> responseReturned = new AtomicReference<>(null);
+
+ HttpServletRequest newRequest = runRequestFilterWithMatchingBinding(responseReturned, uri, httpRequest, httpResponse);
+ assert newRequest != null;
+ responseReturned.compareAndSet(null, false);
+
+ if (!responseReturned.get()) {
+ runChainAndResponseFilters(uri, newRequest, httpResponse, chain);
+ }
+ }
+
+ private void runChainAndResponseFilters(URI uri, HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
+ Optional<OneTimeRunnable> responseFilterInvoker =
+ jDiscContext.filterResolver.resolveResponseFilter(request, uri)
+ .map(responseFilter ->
+ new OneTimeRunnable(() ->
+ filterInvoker.invokeResponseFilterChain(responseFilter, uri, request, response)));
+
+
+ HttpServletResponse responseForServlet = responseFilterInvoker
+ .<HttpServletResponse>map(invoker ->
+ new FilterInvokingResponseWrapper(response, invoker))
+ .orElse(response);
+
+ HttpServletRequest requestForServlet = responseFilterInvoker
+ .<HttpServletRequest>map(invoker ->
+ new FilterInvokingRequestWrapper(request, invoker, responseForServlet))
+ .orElse(request);
+
+ chain.doFilter(requestForServlet, responseForServlet);
+
+ responseFilterInvoker.ifPresent(invoker -> {
+ boolean requestHandledSynchronously = !request.isAsyncStarted();
+
+ if (requestHandledSynchronously) {
+ invoker.runIfFirstInvocation();
+ }
+ // For async requests, response filters will be invoked on AsyncContext.complete().
+ });
+ }
+
+ private HttpServletRequest runRequestFilterWithMatchingBinding(AtomicReference<Boolean> responseReturned, URI uri, HttpServletRequest request, HttpServletResponse response) throws IOException {
+ try {
+ RequestFilter requestFilter = jDiscContext.filterResolver.resolveRequestFilter(request, uri).orElse(null);
+ if (requestFilter == null)
+ return request;
+
+ ResponseHandler responseHandler = createResponseHandler(responseReturned, request, response);
+ return filterInvoker.invokeRequestFilterChain(requestFilter, uri, request, responseHandler);
+ } catch (Exception e) {
+ throw new RuntimeException("Failed running request filter chain for uri " + uri, e);
+ }
+ }
+
+ private ResponseHandler createResponseHandler(AtomicReference<Boolean> responseReturned, HttpServletRequest httpRequest, HttpServletResponse httpResponse) {
+ return jdiscResponse -> {
+ boolean oldValueWasNull = responseReturned.compareAndSet(null, true);
+ if (!oldValueWasNull)
+ throw new RuntimeException("Can't return response from filter asynchronously");
+
+ HttpRequestDispatch requestDispatch = createRequestDispatch(httpRequest, httpResponse);
+ return requestDispatch.handleRequestFilterResponse(jdiscResponse);
+ };
+ }
+
+ private HttpRequestDispatch createRequestDispatch(HttpServletRequest request, HttpServletResponse response) {
+ try {
+ final AccessLogEntry accessLogEntry = null; // Not used in this context.
+ return new HttpRequestDispatch(jDiscContext,
+ accessLogEntry,
+ getConnector(request).createRequestMetricContext(request, Map.of()),
+ request, response);
+ } catch (IOException e) {
+ throw throwUnchecked(e);
+ }
+ }
+
+ @Override
+ public void destroy() {}
+
+ // ServletRequest wrapper that is necessary because we need to wrap AsyncContext.
+ private static class FilterInvokingRequestWrapper extends HttpServletRequestWrapper {
+ private final OneTimeRunnable filterInvoker;
+ private final HttpServletResponse servletResponse;
+
+ public FilterInvokingRequestWrapper(
+ HttpServletRequest request,
+ OneTimeRunnable filterInvoker,
+ HttpServletResponse servletResponse) {
+ super(request);
+ this.filterInvoker = filterInvoker;
+ this.servletResponse = servletResponse;
+ }
+
+ @Override
+ public AsyncContext startAsync() {
+ final AsyncContext asyncContext = super.startAsync();
+ return new FilterInvokingAsyncContext(asyncContext, filterInvoker, this, servletResponse);
+ }
+
+ @Override
+ public AsyncContext startAsync(
+ final ServletRequest wrappedRequest,
+ final ServletResponse wrappedResponse) {
+ // According to the documentation, the passed request/response parameters here must either
+ // _be_ or _wrap_ the original request/response objects passed to the servlet - which are
+ // our wrappers, so no need to wrap again - we can use the user-supplied objects.
+ final AsyncContext asyncContext = super.startAsync(wrappedRequest, wrappedResponse);
+ return new FilterInvokingAsyncContext(asyncContext, filterInvoker, this, wrappedResponse);
+ }
+
+ @Override
+ public AsyncContext getAsyncContext() {
+ final AsyncContext asyncContext = super.getAsyncContext();
+ return new FilterInvokingAsyncContext(asyncContext, filterInvoker, this, servletResponse);
+ }
+ }
+
+ // AsyncContext wrapper that is necessary for two reasons:
+ // 1) Run response filters when AsyncContext.complete() is called.
+ // 2) Eliminate paths where application code can get its hands on un-wrapped response object, circumventing
+ // running of response filters.
+ private static class FilterInvokingAsyncContext implements AsyncContext {
+ private final AsyncContext delegate;
+ private final OneTimeRunnable filterInvoker;
+ private final ServletRequest servletRequest;
+ private final ServletResponse servletResponse;
+
+ public FilterInvokingAsyncContext(
+ AsyncContext delegate,
+ OneTimeRunnable filterInvoker,
+ ServletRequest servletRequest,
+ ServletResponse servletResponse) {
+ this.delegate = delegate;
+ this.filterInvoker = filterInvoker;
+ this.servletRequest = servletRequest;
+ this.servletResponse = servletResponse;
+ }
+
+ @Override
+ public ServletRequest getRequest() {
+ return servletRequest;
+ }
+
+ @Override
+ public ServletResponse getResponse() {
+ return servletResponse;
+ }
+
+ @Override
+ public boolean hasOriginalRequestAndResponse() {
+ return delegate.hasOriginalRequestAndResponse();
+ }
+
+ @Override
+ public void dispatch() {
+ delegate.dispatch();
+ }
+
+ @Override
+ public void dispatch(String s) {
+ delegate.dispatch(s);
+ }
+
+ @Override
+ public void dispatch(ServletContext servletContext, String s) {
+ delegate.dispatch(servletContext, s);
+ }
+
+ @Override
+ public void complete() {
+ // Completing may commit the response, so this is the last chance to run response filters.
+ filterInvoker.runIfFirstInvocation();
+ delegate.complete();
+ }
+
+ @Override
+ public void start(Runnable runnable) {
+ delegate.start(runnable);
+ }
+
+ @Override
+ public void addListener(AsyncListener asyncListener) {
+ delegate.addListener(asyncListener);
+ }
+
+ @Override
+ public void addListener(AsyncListener asyncListener, ServletRequest servletRequest, ServletResponse servletResponse) {
+ delegate.addListener(asyncListener, servletRequest, servletResponse);
+ }
+
+ @Override
+ public <T extends AsyncListener> T createListener(Class<T> aClass) throws ServletException {
+ return delegate.createListener(aClass);
+ }
+
+ @Override
+ public void setTimeout(long l) {
+ delegate.setTimeout(l);
+ }
+
+ @Override
+ public long getTimeout() {
+ return delegate.getTimeout();
+ }
+ }
+
+ private static class FilterInvokingResponseWrapper extends HttpServletResponseWrapper {
+ private final OneTimeRunnable filterInvoker;
+
+ public FilterInvokingResponseWrapper(HttpServletResponse response, OneTimeRunnable filterInvoker) {
+ super(response);
+ this.filterInvoker = filterInvoker;
+ }
+
+ @Override
+ public ServletOutputStream getOutputStream() throws IOException {
+ ServletOutputStream delegate = super.getOutputStream();
+ return new FilterInvokingServletOutputStream(delegate, filterInvoker);
+ }
+
+ @Override
+ public PrintWriter getWriter() throws IOException {
+ PrintWriter delegate = super.getWriter();
+ return new FilterInvokingPrintWriter(delegate, filterInvoker);
+ }
+ }
+}
diff --git a/container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/JDiscHttpServlet.java b/container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/JDiscHttpServlet.java
new file mode 100644
index 00000000000..41a1ffc2709
--- /dev/null
+++ b/container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/JDiscHttpServlet.java
@@ -0,0 +1,148 @@
+// Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.jdisc.http.server.jetty;
+
+import com.yahoo.container.logging.AccessLogEntry;
+import com.yahoo.jdisc.Metric;
+import com.yahoo.jdisc.handler.OverloadException;
+import com.yahoo.jdisc.http.HttpRequest.Method;
+
+import javax.servlet.ServletException;
+import javax.servlet.annotation.WebServlet;
+import javax.servlet.http.HttpServlet;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.io.IOException;
+import java.util.Enumeration;
+import java.util.Map;
+import java.util.Set;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+import static com.yahoo.jdisc.http.server.jetty.HttpServletRequestUtils.getConnection;
+
+/**
+ * @author Simon Thoresen Hult
+ * @author bjorncs
+ */
+@WebServlet(asyncSupported = true, description = "Bridge between Servlet and JDisc APIs")
+class JDiscHttpServlet extends HttpServlet {
+
+ public static final String ATTRIBUTE_NAME_ACCESS_LOG_ENTRY = JDiscHttpServlet.class.getName() + "_access-log-entry";
+
+ private final static Logger log = Logger.getLogger(JDiscHttpServlet.class.getName());
+ private final JDiscContext context;
+
+ private static final Set<String> servletSupportedMethods =
+ Stream.of(Method.OPTIONS, Method.GET, Method.HEAD, Method.POST, Method.PUT, Method.DELETE, Method.TRACE)
+ .map(Method::name)
+ .collect(Collectors.toSet());
+
+ public JDiscHttpServlet(JDiscContext context) {
+ this.context = context;
+ }
+
+ @Override
+ protected void doGet(HttpServletRequest request, HttpServletResponse response) throws IOException {
+ dispatchHttpRequest(request, response);
+ }
+
+ @Override
+ protected void doPost(HttpServletRequest request, HttpServletResponse response) throws IOException {
+ dispatchHttpRequest(request, response);
+ }
+
+ @Override
+ protected void doHead(HttpServletRequest request, HttpServletResponse response) throws IOException {
+ dispatchHttpRequest(request, response);
+ }
+
+ @Override
+ protected void doPut(HttpServletRequest request, HttpServletResponse response) throws IOException {
+ dispatchHttpRequest(request, response);
+ }
+
+ @Override
+ protected void doDelete(HttpServletRequest request, HttpServletResponse response) throws IOException {
+ dispatchHttpRequest(request, response);
+ }
+
+ @Override
+ protected void doOptions(HttpServletRequest request, HttpServletResponse response) throws IOException {
+ dispatchHttpRequest(request, response);
+ }
+
+ @Override
+ protected void doTrace(HttpServletRequest request, HttpServletResponse response) throws IOException {
+ dispatchHttpRequest(request, response);
+ }
+
+ /**
+ * Override to set connector attribute before the request becomes an upgrade request in the web socket case.
+ * (After the upgrade, the HttpConnection is no longer available.)
+ */
+ @Override
+ protected void service(HttpServletRequest request, HttpServletResponse response)
+ throws ServletException, IOException {
+ request.setAttribute(JDiscServerConnector.REQUEST_ATTRIBUTE, getConnector(request));
+
+ Metric.Context metricContext = getMetricContext(request);
+ context.metric.add(MetricDefinitions.NUM_REQUESTS, 1, metricContext);
+ context.metric.add(MetricDefinitions.JDISC_HTTP_REQUESTS, 1, metricContext);
+
+ String method = request.getMethod().toUpperCase();
+ if (servletSupportedMethods.contains(method)) {
+ super.service(request, response);
+ } else if (method.equals(Method.PATCH.name())) {
+ // PATCH method is not handled by the Servlet spec
+ dispatchHttpRequest(request, response);
+ } else {
+ // Divergence from HTTP / Servlet spec: JDisc returns 405 for both unknown and known (but unsupported) methods.
+ response.sendError(HttpServletResponse.SC_METHOD_NOT_ALLOWED);
+ }
+ }
+
+ static JDiscServerConnector getConnector(HttpServletRequest request) {
+ return (JDiscServerConnector)getConnection(request).getConnector();
+ }
+
+ private void dispatchHttpRequest(HttpServletRequest request, HttpServletResponse response) throws IOException {
+ AccessLogEntry accessLogEntry = new AccessLogEntry();
+ request.setAttribute(ATTRIBUTE_NAME_ACCESS_LOG_ENTRY, accessLogEntry);
+ try {
+ switch (request.getDispatcherType()) {
+ case REQUEST:
+ new HttpRequestDispatch(context, accessLogEntry, getMetricContext(request), request, response).dispatch();
+ break;
+ default:
+ if (log.isLoggable(Level.INFO)) {
+ log.info("Unexpected " + request.getDispatcherType() + "; " + formatAttributes(request));
+ }
+ break;
+ }
+ } catch (OverloadException e) {
+ // nop
+ } catch (RuntimeException e) {
+ throw new ExceptionWrapper(e);
+ }
+ }
+
+ private static Metric.Context getMetricContext(HttpServletRequest request) {
+ return JDiscServerConnector.fromRequest(request).createRequestMetricContext(request, Map.of());
+ }
+
+ private static String formatAttributes(final HttpServletRequest request) {
+ StringBuilder out = new StringBuilder();
+ out.append("attributes = {");
+ for (Enumeration<String> names = request.getAttributeNames(); names.hasMoreElements(); ) {
+ String name = names.nextElement();
+ out.append(" '").append(name).append("' = '").append(request.getAttribute(name)).append("'");
+ if (names.hasMoreElements()) {
+ out.append(",");
+ }
+ }
+ out.append(" }");
+ return out.toString();
+ }
+}
diff --git a/container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/JDiscServerConnector.java b/container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/JDiscServerConnector.java
new file mode 100644
index 00000000000..99d0c5c8d8c
--- /dev/null
+++ b/container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/JDiscServerConnector.java
@@ -0,0 +1,104 @@
+// Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.jdisc.http.server.jetty;
+
+import com.yahoo.jdisc.Metric;
+import com.yahoo.jdisc.http.ConnectorConfig;
+import org.eclipse.jetty.io.ConnectionStatistics;
+import org.eclipse.jetty.server.ConnectionFactory;
+import org.eclipse.jetty.server.Server;
+import org.eclipse.jetty.server.ServerConnector;
+
+import javax.servlet.ServletRequest;
+import javax.servlet.http.HttpServletRequest;
+import java.net.Socket;
+import java.net.SocketException;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Optional;
+
+/**
+ * @author bjorncs
+ */
+class JDiscServerConnector extends ServerConnector {
+
+ public static final String REQUEST_ATTRIBUTE = JDiscServerConnector.class.getName();
+ private final Metric.Context metricCtx;
+ private final ConnectionStatistics statistics;
+ private final ConnectorConfig config;
+ private final boolean tcpKeepAlive;
+ private final boolean tcpNoDelay;
+ private final Metric metric;
+ private final String connectorName;
+ private final int listenPort;
+
+ JDiscServerConnector(ConnectorConfig config, Metric metric, Server server, JettyConnectionLogger connectionLogger, ConnectionFactory... factories) {
+ super(server, factories);
+ this.config = config;
+ this.tcpKeepAlive = config.tcpKeepAliveEnabled();
+ this.tcpNoDelay = config.tcpNoDelay();
+ this.metric = metric;
+ this.connectorName = config.name();
+ this.listenPort = config.listenPort();
+ this.metricCtx = metric.createContext(createConnectorDimensions(listenPort, connectorName));
+
+ this.statistics = new ConnectionStatistics();
+ addBean(statistics);
+ ConnectorConfig.Throttling throttlingConfig = config.throttling();
+ if (throttlingConfig.enabled()) {
+ new ConnectionThrottler(this, throttlingConfig).registerWithConnector();
+ }
+ addBean(connectionLogger);
+ }
+
+ @Override
+ protected void configure(final Socket socket) {
+ super.configure(socket);
+ try {
+ socket.setKeepAlive(tcpKeepAlive);
+ socket.setTcpNoDelay(tcpNoDelay);
+ } catch (SocketException ignored) {
+ }
+ }
+
+ public ConnectionStatistics getStatistics() {
+ return statistics;
+ }
+
+ public Metric.Context getConnectorMetricContext() {
+ return metricCtx;
+ }
+
+ public Metric.Context createRequestMetricContext(HttpServletRequest request, Map<String, String> extraDimensions) {
+ String method = request.getMethod();
+ String scheme = request.getScheme();
+ boolean clientAuthenticated = request.getAttribute(com.yahoo.jdisc.http.servlet.ServletRequest.SERVLET_REQUEST_X509CERT) != null;
+ Map<String, Object> dimensions = createConnectorDimensions(listenPort, connectorName);
+ dimensions.put(MetricDefinitions.METHOD_DIMENSION, method);
+ dimensions.put(MetricDefinitions.SCHEME_DIMENSION, scheme);
+ dimensions.put(MetricDefinitions.CLIENT_AUTHENTICATED_DIMENSION, Boolean.toString(clientAuthenticated));
+ String serverName = Optional.ofNullable(request.getServerName()).orElse("unknown");
+ dimensions.put(MetricDefinitions.REQUEST_SERVER_NAME_DIMENSION, serverName);
+ dimensions.putAll(extraDimensions);
+ return metric.createContext(dimensions);
+ }
+
+ public static JDiscServerConnector fromRequest(ServletRequest request) {
+ return (JDiscServerConnector) request.getAttribute(REQUEST_ATTRIBUTE);
+ }
+
+ ConnectorConfig connectorConfig() {
+ return config;
+ }
+
+ int listenPort() {
+ return listenPort;
+ }
+
+ private static Map<String, Object> createConnectorDimensions(int listenPort, String connectorName) {
+ Map<String, Object> props = new HashMap<>();
+ props.put(MetricDefinitions.NAME_DIMENSION, connectorName);
+ props.put(MetricDefinitions.PORT_DIMENSION, listenPort);
+ return props;
+ }
+
+}
diff --git a/container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/JettyConnectionLogger.java b/container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/JettyConnectionLogger.java
new file mode 100644
index 00000000000..cd1ca490f61
--- /dev/null
+++ b/container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/JettyConnectionLogger.java
@@ -0,0 +1,373 @@
+// Copyright Verizon Media. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.jdisc.http.server.jetty;
+
+import com.yahoo.container.logging.ConnectionLog;
+import com.yahoo.container.logging.ConnectionLogEntry;
+import com.yahoo.container.logging.ConnectionLogEntry.SslHandshakeFailure.ExceptionEntry;
+import com.yahoo.io.HexDump;
+import com.yahoo.jdisc.http.ServerConfig;
+import org.eclipse.jetty.io.Connection;
+import org.eclipse.jetty.io.EndPoint;
+import org.eclipse.jetty.io.SocketChannelEndPoint;
+import org.eclipse.jetty.io.ssl.SslConnection;
+import org.eclipse.jetty.io.ssl.SslHandshakeListener;
+import org.eclipse.jetty.server.HttpChannel;
+import org.eclipse.jetty.server.HttpConnection;
+import org.eclipse.jetty.server.ProxyConnectionFactory;
+import org.eclipse.jetty.server.Request;
+import org.eclipse.jetty.util.component.AbstractLifeCycle;
+
+import javax.net.ssl.ExtendedSSLSession;
+import javax.net.ssl.SNIHostName;
+import javax.net.ssl.SNIServerName;
+import javax.net.ssl.SSLEngine;
+import javax.net.ssl.SSLHandshakeException;
+import javax.net.ssl.SSLPeerUnverifiedException;
+import javax.net.ssl.SSLSession;
+import javax.net.ssl.StandardConstants;
+import java.net.InetSocketAddress;
+import java.security.cert.X509Certificate;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.List;
+import java.util.Objects;
+import java.util.UUID;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+/**
+ * Jetty integration for jdisc connection log ({@link ConnectionLog}).
+ *
+ * @author bjorncs
+ */
+class JettyConnectionLogger extends AbstractLifeCycle implements Connection.Listener, HttpChannel.Listener, SslHandshakeListener {
+
+ static final String CONNECTION_ID_REQUEST_ATTRIBUTE = "jdisc.request.connection.id";
+
+ private static final Logger log = Logger.getLogger(JettyConnectionLogger.class.getName());
+
+ private final ConcurrentMap<IdentityKey<SocketChannelEndPoint>, ConnectionInfo> connectionInfo = new ConcurrentHashMap<>();
+ private final ConcurrentMap<IdentityKey<SSLEngine>, ConnectionInfo> sslToConnectionInfo = new ConcurrentHashMap<>();
+
+ private final boolean enabled;
+ private final ConnectionLog connectionLog;
+
+ JettyConnectionLogger(ServerConfig.ConnectionLog config, ConnectionLog connectionLog) {
+ this.enabled = config.enabled();
+ this.connectionLog = connectionLog;
+ log.log(Level.FINE, () -> "Jetty connection logger is " + (config.enabled() ? "enabled" : "disabled"));
+ }
+
+ //
+ // AbstractLifeCycle methods start
+ //
+ @Override
+ protected void doStop() {
+ handleListenerInvocation("AbstractLifeCycle", "doStop", "", List.of(), () -> {
+ log.log(Level.FINE, () -> "Jetty connection logger is stopped");
+ });
+ }
+
+ @Override
+ protected void doStart() {
+ handleListenerInvocation("AbstractLifeCycle", "doStart", "", List.of(), () -> {
+ log.log(Level.FINE, () -> "Jetty connection logger is started");
+ });
+ }
+ //
+ // AbstractLifeCycle methods stop
+ //
+
+ //
+ // Connection.Listener methods start
+ //
+ @Override
+ public void onOpened(Connection connection) {
+ handleListenerInvocation("Connection.Listener", "onOpened", "%h", List.of(connection), () -> {
+ SocketChannelEndPoint endpoint = findUnderlyingSocketEndpoint(connection.getEndPoint());
+ var endpointKey = IdentityKey.of(endpoint);
+ ConnectionInfo info = connectionInfo.get(endpointKey);
+ if (info == null) {
+ info = ConnectionInfo.from(endpoint);
+ connectionInfo.put(IdentityKey.of(endpoint), info);
+ }
+ if (connection instanceof SslConnection) {
+ SSLEngine sslEngine = ((SslConnection) connection).getSSLEngine();
+ sslToConnectionInfo.put(IdentityKey.of(sslEngine), info);
+ }
+ if (connection.getEndPoint() instanceof ProxyConnectionFactory.ProxyEndPoint) {
+ InetSocketAddress remoteAddress = connection.getEndPoint().getRemoteAddress();
+ info.setRemoteAddress(remoteAddress);
+ }
+ });
+ }
+
+ @Override
+ public void onClosed(Connection connection) {
+ handleListenerInvocation("Connection.Listener", "onClosed", "%h", List.of(connection), () -> {
+ SocketChannelEndPoint endpoint = findUnderlyingSocketEndpoint(connection.getEndPoint());
+ var endpointKey = IdentityKey.of(endpoint);
+ ConnectionInfo info = connectionInfo.get(endpointKey);
+ if (info == null) return; // Closed connection already handled
+ if (connection instanceof HttpConnection) {
+ info.setHttpBytes(connection.getBytesIn(), connection.getBytesOut());
+ }
+ if (!endpoint.isOpen()) {
+ info.setClosedAt(System.currentTimeMillis());
+ connectionLog.log(info.toLogEntry());
+ connectionInfo.remove(endpointKey);
+ }
+ });
+ }
+ //
+ // Connection.Listener methods end
+ //
+
+ //
+ // HttpChannel.Listener methods start
+ //
+ @Override
+ public void onRequestBegin(Request request) {
+ handleListenerInvocation("HttpChannel.Listener", "onRequestBegin", "%h", List.of(request), () -> {
+ SocketChannelEndPoint endpoint = findUnderlyingSocketEndpoint(request.getHttpChannel().getEndPoint());
+ ConnectionInfo info = Objects.requireNonNull(connectionInfo.get(IdentityKey.of(endpoint)));
+ info.incrementRequests();
+ request.setAttribute(CONNECTION_ID_REQUEST_ATTRIBUTE, info.uuid());
+ });
+ }
+
+ @Override
+ public void onResponseBegin(Request request) {
+ handleListenerInvocation("HttpChannel.Listener", "onResponseBegin", "%h", List.of(request), () -> {
+ SocketChannelEndPoint endpoint = findUnderlyingSocketEndpoint(request.getHttpChannel().getEndPoint());
+ ConnectionInfo info = Objects.requireNonNull(connectionInfo.get(IdentityKey.of(endpoint)));
+ info.incrementResponses();
+ });
+ }
+ //
+ // HttpChannel.Listener methods end
+ //
+
+ //
+ // SslHandshakeListener methods start
+ //
+ @Override
+ public void handshakeSucceeded(Event event) {
+ SSLEngine sslEngine = event.getSSLEngine();
+ handleListenerInvocation("SslHandshakeListener", "handshakeSucceeded", "sslEngine=%h", List.of(sslEngine), () -> {
+ ConnectionInfo info = sslToConnectionInfo.remove(IdentityKey.of(sslEngine));
+ info.setSslSessionDetails(sslEngine.getSession());
+ });
+ }
+
+ @Override
+ public void handshakeFailed(Event event, Throwable failure) {
+ SSLEngine sslEngine = event.getSSLEngine();
+ handleListenerInvocation("SslHandshakeListener", "handshakeFailed", "sslEngine=%h,failure=%s", List.of(sslEngine, failure), () -> {
+ log.log(Level.FINE, failure, failure::toString);
+ ConnectionInfo info = sslToConnectionInfo.remove(IdentityKey.of(sslEngine));
+ info.setSslHandshakeFailure((SSLHandshakeException)failure);
+ });
+ }
+ //
+ // SslHandshakeListener methods end
+ //
+
+ private void handleListenerInvocation(
+ String listenerType, String methodName, String methodArgumentsFormat, List<Object> methodArguments, ListenerHandler handler) {
+ if (!enabled) return;
+ try {
+ log.log(Level.FINE, () -> String.format(listenerType + "." + methodName + "(" + methodArgumentsFormat + ")", methodArguments.toArray()));
+ handler.run();
+ } catch (Exception e) {
+ log.log(Level.WARNING, String.format("Exception in %s.%s listener: %s", listenerType, methodName, e.getMessage()), e);
+ }
+ }
+
+ /**
+ * Protocol layers are connected through each {@link Connection}'s {@link EndPoint} reference.
+ * This methods iterates through the endpoints recursively to find the underlying socket endpoint.
+ */
+ private static SocketChannelEndPoint findUnderlyingSocketEndpoint(EndPoint endpoint) {
+ if (endpoint instanceof SocketChannelEndPoint) {
+ return (SocketChannelEndPoint) endpoint;
+ } else if (endpoint instanceof SslConnection.DecryptedEndPoint) {
+ var decryptedEndpoint = (SslConnection.DecryptedEndPoint) endpoint;
+ return findUnderlyingSocketEndpoint(decryptedEndpoint.getSslConnection().getEndPoint());
+ } else if (endpoint instanceof ProxyConnectionFactory.ProxyEndPoint) {
+ var proxyEndpoint = (ProxyConnectionFactory.ProxyEndPoint) endpoint;
+ return findUnderlyingSocketEndpoint(proxyEndpoint.unwrap());
+ } else {
+ throw new IllegalArgumentException("Unknown connection endpoint type: " + endpoint.getClass().getName());
+ }
+ }
+
+ @FunctionalInterface private interface ListenerHandler { void run() throws Exception; }
+
+ private static class ConnectionInfo {
+ private final UUID uuid;
+ private final long createdAt;
+ private final InetSocketAddress localAddress;
+ private final InetSocketAddress peerAddress;
+
+ private long closedAt = 0;
+ private long httpBytesReceived = 0;
+ private long httpBytesSent = 0;
+ private long requests = 0;
+ private long responses = 0;
+ private InetSocketAddress remoteAddress;
+ private byte[] sslSessionId;
+ private String sslProtocol;
+ private String sslCipherSuite;
+ private String sslPeerSubject;
+ private Date sslPeerNotBefore;
+ private Date sslPeerNotAfter;
+ private List<SNIServerName> sslSniServerNames;
+ private SSLHandshakeException sslHandshakeException;
+
+ private ConnectionInfo(UUID uuid, long createdAt, InetSocketAddress localAddress, InetSocketAddress peerAddress) {
+ this.uuid = uuid;
+ this.createdAt = createdAt;
+ this.localAddress = localAddress;
+ this.peerAddress = peerAddress;
+ }
+
+ static ConnectionInfo from(SocketChannelEndPoint endpoint) {
+ return new ConnectionInfo(
+ UUID.randomUUID(),
+ endpoint.getCreatedTimeStamp(),
+ endpoint.getLocalAddress(),
+ endpoint.getRemoteAddress());
+ }
+
+ synchronized UUID uuid() { return uuid; }
+
+ synchronized ConnectionInfo setClosedAt(long closedAt) {
+ this.closedAt = closedAt;
+ return this;
+ }
+
+ synchronized ConnectionInfo setHttpBytes(long received, long sent) {
+ this.httpBytesReceived = received;
+ this.httpBytesSent = sent;
+ return this;
+ }
+
+ synchronized ConnectionInfo incrementRequests() { ++this.requests; return this; }
+
+ synchronized ConnectionInfo incrementResponses() { ++this.responses; return this; }
+
+ synchronized ConnectionInfo setRemoteAddress(InetSocketAddress remoteAddress) {
+ this.remoteAddress = remoteAddress;
+ return this;
+ }
+
+ synchronized ConnectionInfo setSslSessionDetails(SSLSession session) {
+ this.sslCipherSuite = session.getCipherSuite();
+ this.sslProtocol = session.getProtocol();
+ this.sslSessionId = session.getId();
+ if (session instanceof ExtendedSSLSession) {
+ ExtendedSSLSession extendedSession = (ExtendedSSLSession) session;
+ this.sslSniServerNames = extendedSession.getRequestedServerNames();
+ }
+ try {
+ this.sslPeerSubject = session.getPeerPrincipal().getName();
+ X509Certificate peerCertificate = (X509Certificate) session.getPeerCertificates()[0];
+ this.sslPeerNotBefore = peerCertificate.getNotBefore();
+ this.sslPeerNotAfter = peerCertificate.getNotAfter();
+ } catch (SSLPeerUnverifiedException e) {
+ // Throw if peer is not authenticated (e.g when client auth is disabled)
+ // JSSE provides no means of checking for client authentication without catching this exception
+ }
+ return this;
+ }
+
+ synchronized ConnectionInfo setSslHandshakeFailure(SSLHandshakeException exception) {
+ this.sslHandshakeException = exception;
+ return this;
+ }
+
+ synchronized ConnectionLogEntry toLogEntry() {
+ ConnectionLogEntry.Builder builder = ConnectionLogEntry.builder(uuid, Instant.ofEpochMilli(createdAt));
+ if (closedAt > 0) {
+ builder.withDuration((closedAt - createdAt) / 1000D);
+ }
+ if (httpBytesReceived > 0) {
+ builder.withHttpBytesReceived(httpBytesReceived);
+ }
+ if (httpBytesSent > 0) {
+ builder.withHttpBytesSent(httpBytesSent);
+ }
+ if (requests > 0) {
+ builder.withRequests(requests);
+ }
+ if (responses > 0) {
+ builder.withResponses(responses);
+ }
+ if (peerAddress != null) {
+ builder.withPeerAddress(peerAddress.getHostString())
+ .withPeerPort(peerAddress.getPort());
+ }
+ if (localAddress != null) {
+ builder.withLocalAddress(localAddress.getHostString())
+ .withLocalPort(localAddress.getPort());
+ }
+ if (remoteAddress != null) {
+ builder.withRemoteAddress(remoteAddress.getHostString())
+ .withRemotePort(remoteAddress.getPort());
+ }
+ if (sslProtocol != null && sslCipherSuite != null && sslSessionId != null) {
+ builder.withSslProtocol(sslProtocol)
+ .withSslCipherSuite(sslCipherSuite)
+ .withSslSessionId(HexDump.toHexString(sslSessionId));
+ }
+ if (sslSniServerNames != null) {
+ sslSniServerNames.stream()
+ .filter(name -> name instanceof SNIHostName && name.getType() == StandardConstants.SNI_HOST_NAME)
+ .map(name -> ((SNIHostName) name).getAsciiName())
+ .findAny()
+ .ifPresent(builder::withSslSniServerName);
+ }
+ if (sslPeerSubject != null && sslPeerNotAfter != null && sslPeerNotBefore != null) {
+ builder.withSslPeerSubject(sslPeerSubject)
+ .withSslPeerNotAfter(sslPeerNotAfter.toInstant())
+ .withSslPeerNotBefore(sslPeerNotBefore.toInstant());
+ }
+ if (sslHandshakeException != null) {
+ List<ExceptionEntry> exceptionChain = new ArrayList<>();
+ Throwable cause = sslHandshakeException;
+ while (cause != null) {
+ exceptionChain.add(new ExceptionEntry(cause.getClass().getName(), cause.getMessage()));
+ cause = cause.getCause();
+ }
+ String type = SslHandshakeFailure.fromSslHandshakeException(sslHandshakeException)
+ .map(SslHandshakeFailure::failureType)
+ .orElse("UNKNOWN");
+ builder.withSslHandshakeFailure(new ConnectionLogEntry.SslHandshakeFailure(type, exceptionChain));
+ }
+ return builder.build();
+ }
+
+ }
+
+ private static class IdentityKey<T> {
+ final T instance;
+
+ IdentityKey(T instance) { this.instance = instance; }
+
+ static <T> IdentityKey<T> of(T instance) { return new IdentityKey<>(instance); }
+
+ @Override public int hashCode() { return System.identityHashCode(instance); }
+
+ @Override
+ public boolean equals(Object obj) {
+ if (this == obj) return true;
+ if (!(obj instanceof IdentityKey<?>)) return false;
+ IdentityKey<?> other = (IdentityKey<?>) obj;
+ return this.instance == other.instance;
+ }
+ }
+}
diff --git a/container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/JettyHttpServer.java b/container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/JettyHttpServer.java
new file mode 100644
index 00000000000..510c561c10f
--- /dev/null
+++ b/container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/JettyHttpServer.java
@@ -0,0 +1,298 @@
+// Copyright 2018 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.jdisc.http.server.jetty;
+
+import com.google.inject.Inject;
+import com.yahoo.component.ComponentId;
+import com.yahoo.component.provider.ComponentRegistry;
+import com.yahoo.concurrent.DaemonThreadFactory;
+import com.yahoo.container.logging.AccessLog;
+import com.yahoo.container.logging.ConnectionLog;
+import com.yahoo.container.logging.RequestLog;
+import com.yahoo.jdisc.Metric;
+import com.yahoo.jdisc.http.ConnectorConfig;
+import com.yahoo.jdisc.http.ServerConfig;
+import com.yahoo.jdisc.http.ServletPathsConfig;
+import com.yahoo.jdisc.service.AbstractServerProvider;
+import com.yahoo.jdisc.service.CurrentContainer;
+import org.eclipse.jetty.http.HttpField;
+import org.eclipse.jetty.jmx.ConnectorServer;
+import org.eclipse.jetty.jmx.MBeanContainer;
+import org.eclipse.jetty.server.Connector;
+import org.eclipse.jetty.server.Handler;
+import org.eclipse.jetty.server.Server;
+import org.eclipse.jetty.server.ServerConnector;
+import org.eclipse.jetty.server.SslConnectionFactory;
+import org.eclipse.jetty.server.handler.HandlerCollection;
+import org.eclipse.jetty.server.handler.StatisticsHandler;
+import org.eclipse.jetty.server.handler.gzip.GzipHandler;
+import org.eclipse.jetty.server.handler.gzip.GzipHttpOutputInterceptor;
+import org.eclipse.jetty.servlet.FilterHolder;
+import org.eclipse.jetty.servlet.ServletContextHandler;
+import org.eclipse.jetty.servlet.ServletHolder;
+import org.eclipse.jetty.util.log.JavaUtilLog;
+import org.eclipse.jetty.util.log.Log;
+import org.eclipse.jetty.util.thread.QueuedThreadPool;
+
+import javax.management.remote.JMXServiceURL;
+import javax.servlet.DispatcherType;
+import java.io.IOException;
+import java.lang.management.ManagementFactory;
+import java.net.BindException;
+import java.net.MalformedURLException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.EnumSet;
+import java.util.List;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+import java.util.stream.Collectors;
+
+import static java.util.stream.Collectors.toList;
+
+/**
+ * @author Simon Thoresen Hult
+ * @author bjorncs
+ */
+public class JettyHttpServer extends AbstractServerProvider {
+
+ private final static Logger log = Logger.getLogger(JettyHttpServer.class.getName());
+
+ private final ExecutorService janitor;
+
+ private final Server server;
+ private final List<Integer> listenedPorts = new ArrayList<>();
+ private final ServerMetricReporter metricsReporter;
+
+ @Inject
+ public JettyHttpServer(CurrentContainer container,
+ Metric metric,
+ ServerConfig serverConfig,
+ ServletPathsConfig servletPathsConfig,
+ FilterBindings filterBindings,
+ ComponentRegistry<ConnectorFactory> connectorFactories,
+ ComponentRegistry<ServletHolder> servletHolders,
+ FilterInvoker filterInvoker,
+ RequestLog requestLog,
+ ConnectionLog connectionLog) {
+ super(container);
+ if (connectorFactories.allComponents().isEmpty())
+ throw new IllegalArgumentException("No connectors configured.");
+
+ initializeJettyLogging();
+
+ server = new Server();
+ server.setStopTimeout((long)(serverConfig.stopTimeout() * 1000.0));
+ server.setRequestLog(new AccessLogRequestLog(requestLog, serverConfig.accessLog()));
+ setupJmx(server, serverConfig);
+ configureJettyThreadpool(server, serverConfig);
+ JettyConnectionLogger connectionLogger = new JettyConnectionLogger(serverConfig.connectionLog(), connectionLog);
+
+ for (ConnectorFactory connectorFactory : connectorFactories.allComponents()) {
+ ConnectorConfig connectorConfig = connectorFactory.getConnectorConfig();
+ server.addConnector(connectorFactory.createConnector(metric, server, connectionLogger));
+ listenedPorts.add(connectorConfig.listenPort());
+ }
+
+ janitor = newJanitor();
+
+ JDiscContext jDiscContext = new JDiscContext(filterBindings,
+ container,
+ janitor,
+ metric,
+ serverConfig);
+
+ ServletHolder jdiscServlet = new ServletHolder(new JDiscHttpServlet(jDiscContext));
+ FilterHolder jDiscFilterInvokerFilter = new FilterHolder(new JDiscFilterInvokerFilter(jDiscContext, filterInvoker));
+
+ List<JDiscServerConnector> connectors = Arrays.stream(server.getConnectors())
+ .map(JDiscServerConnector.class::cast)
+ .collect(toList());
+
+ server.setHandler(getHandlerCollection(serverConfig,
+ servletPathsConfig,
+ connectors,
+ jdiscServlet,
+ servletHolders,
+ jDiscFilterInvokerFilter));
+ this.metricsReporter = new ServerMetricReporter(metric, server);
+ }
+
+ private static void initializeJettyLogging() {
+ // Note: Jetty is logging stderr if no logger is explicitly configured
+ try {
+ Log.setLog(new JavaUtilLog());
+ } catch (Exception e) {
+ throw new RuntimeException("Unable to initialize logging framework for Jetty");
+ }
+ }
+
+ private static void setupJmx(Server server, ServerConfig serverConfig) {
+ if (serverConfig.jmx().enabled()) {
+ System.setProperty("java.rmi.server.hostname", "localhost");
+ server.addBean(new MBeanContainer(ManagementFactory.getPlatformMBeanServer()));
+ server.addBean(new ConnectorServer(createJmxLoopbackOnlyServiceUrl(serverConfig.jmx().listenPort()),
+ "org.eclipse.jetty.jmx:name=rmiconnectorserver"));
+ }
+ }
+
+ private static void configureJettyThreadpool(Server server, ServerConfig config) {
+ QueuedThreadPool pool = (QueuedThreadPool) server.getThreadPool();
+ pool.setMaxThreads(config.maxWorkerThreads());
+ pool.setMinThreads(config.minWorkerThreads());
+ }
+
+ private static JMXServiceURL createJmxLoopbackOnlyServiceUrl(int port) {
+ try {
+ return new JMXServiceURL("rmi", "localhost", port, "/jndi/rmi://localhost:" + port + "/jmxrmi");
+ } catch (MalformedURLException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ private HandlerCollection getHandlerCollection(ServerConfig serverConfig,
+ ServletPathsConfig servletPathsConfig,
+ List<JDiscServerConnector> connectors,
+ ServletHolder jdiscServlet,
+ ComponentRegistry<ServletHolder> servletHolders,
+ FilterHolder jDiscFilterInvokerFilter) {
+ ServletContextHandler servletContextHandler = createServletContextHandler();
+
+ servletHolders.allComponentsById().forEach((id, servlet) -> {
+ String path = getServletPath(servletPathsConfig, id);
+ servletContextHandler.addServlet(servlet, path);
+ servletContextHandler.addFilter(jDiscFilterInvokerFilter, path, EnumSet.allOf(DispatcherType.class));
+ });
+
+ servletContextHandler.addServlet(jdiscServlet, "/*");
+
+ List<ConnectorConfig> connectorConfigs = connectors.stream().map(JDiscServerConnector::connectorConfig).collect(toList());
+ var secureRedirectHandler = new SecuredRedirectHandler(connectorConfigs);
+ secureRedirectHandler.setHandler(servletContextHandler);
+
+ var proxyHandler = new HealthCheckProxyHandler(connectors);
+ proxyHandler.setHandler(secureRedirectHandler);
+
+ var authEnforcer = new TlsClientAuthenticationEnforcer(connectorConfigs);
+ authEnforcer.setHandler(proxyHandler);
+
+ GzipHandler gzipHandler = newGzipHandler(serverConfig);
+ gzipHandler.setHandler(authEnforcer);
+
+ HttpResponseStatisticsCollector statisticsCollector =
+ new HttpResponseStatisticsCollector(serverConfig.metric().monitoringHandlerPaths(),
+ serverConfig.metric().searchHandlerPaths());
+ statisticsCollector.setHandler(gzipHandler);
+
+ StatisticsHandler statisticsHandler = newStatisticsHandler();
+ statisticsHandler.setHandler(statisticsCollector);
+
+ HandlerCollection handlerCollection = new HandlerCollection();
+ handlerCollection.setHandlers(new Handler[] { statisticsHandler });
+ return handlerCollection;
+ }
+
+ private static String getServletPath(ServletPathsConfig servletPathsConfig, ComponentId id) {
+ return "/" + servletPathsConfig.servlets(id.stringValue()).path();
+ }
+
+ private ServletContextHandler createServletContextHandler() {
+ ServletContextHandler servletContextHandler = new ServletContextHandler(ServletContextHandler.NO_SECURITY | ServletContextHandler.NO_SESSIONS);
+ servletContextHandler.setContextPath("/");
+ servletContextHandler.setDisplayName(getDisplayName(listenedPorts));
+ return servletContextHandler;
+ }
+
+ private static String getDisplayName(List<Integer> ports) {
+ return ports.stream().map(Object::toString).collect(Collectors.joining(":"));
+ }
+
+ // Separate threadpool for tasks that cannot be executed on the jdisc default threadpool due to risk of deadlock
+ private static ExecutorService newJanitor() {
+ int threadPoolSize = Math.max(1, Runtime.getRuntime().availableProcessors()/8);
+ log.info("Creating janitor executor with " + threadPoolSize + " threads");
+ return Executors.newFixedThreadPool(
+ threadPoolSize,
+ new DaemonThreadFactory(JettyHttpServer.class.getName() + "-Janitor-"));
+ }
+
+ @Override
+ public void start() {
+ try {
+ server.start();
+ metricsReporter.start();
+ logEffectiveSslConfiguration();
+ } catch (final Exception e) {
+ if (e instanceof IOException && e.getCause() instanceof BindException) {
+ throw new RuntimeException("Failed to start server due to BindException. ListenPorts = " + listenedPorts.toString(), e.getCause());
+ }
+ throw new RuntimeException("Failed to start server.", e);
+ }
+ }
+
+ private void logEffectiveSslConfiguration() {
+ if (!server.isStarted()) throw new IllegalStateException();
+ for (Connector connector : server.getConnectors()) {
+ ServerConnector serverConnector = (ServerConnector) connector;
+ int localPort = serverConnector.getLocalPort();
+ var sslConnectionFactory = serverConnector.getConnectionFactory(SslConnectionFactory.class);
+ if (sslConnectionFactory != null) {
+ var sslContextFactory = sslConnectionFactory.getSslContextFactory();
+ log.info(String.format("Enabled SSL cipher suites for port '%d': %s",
+ localPort, Arrays.toString(sslContextFactory.getSelectedCipherSuites())));
+ log.info(String.format("Enabled SSL protocols for port '%d': %s",
+ localPort, Arrays.toString(sslContextFactory.getSelectedProtocols())));
+ }
+ }
+ }
+
+ @Override
+ public void close() {
+ try {
+ log.log(Level.INFO, String.format("Shutting down server (graceful=%b, timeout=%.1fs)", isGracefulShutdownEnabled(), server.getStopTimeout()/1000d));
+ server.stop();
+ log.log(Level.INFO, "Server shutdown completed");
+ } catch (final Exception e) {
+ log.log(Level.SEVERE, "Server shutdown threw an unexpected exception.", e);
+ }
+
+ metricsReporter.shutdown();
+ janitor.shutdown();
+ }
+
+ private boolean isGracefulShutdownEnabled() {
+ return server.getChildHandlersByClass(StatisticsHandler.class).length > 0 && server.getStopTimeout() > 0;
+ }
+
+ public int getListenPort() {
+ return ((ServerConnector)server.getConnectors()[0]).getLocalPort();
+ }
+
+ Server server() { return server; }
+
+ private StatisticsHandler newStatisticsHandler() {
+ StatisticsHandler statisticsHandler = new StatisticsHandler();
+ statisticsHandler.statsReset();
+ return statisticsHandler;
+ }
+
+ private GzipHandler newGzipHandler(ServerConfig serverConfig) {
+ GzipHandler gzipHandler = new GzipHandlerWithVaryHeaderFixed();
+ gzipHandler.setCompressionLevel(serverConfig.responseCompressionLevel());
+ gzipHandler.setInflateBufferSize(8 * 1024);
+ gzipHandler.setIncludedMethods("GET", "POST", "PUT", "PATCH");
+ return gzipHandler;
+ }
+
+ /** A subclass which overrides Jetty's default behavior of including user-agent in the vary field */
+ private static class GzipHandlerWithVaryHeaderFixed extends GzipHandler {
+
+ @Override
+ public HttpField getVaryField() {
+ return GzipHttpOutputInterceptor.VARY_ACCEPT_ENCODING;
+ }
+
+ }
+
+}
diff --git a/container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/MetricDefinitions.java b/container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/MetricDefinitions.java
new file mode 100644
index 00000000000..5e953179b53
--- /dev/null
+++ b/container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/MetricDefinitions.java
@@ -0,0 +1,79 @@
+// Copyright Verizon Media. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.jdisc.http.server.jetty;
+
+/**
+ * Name and dimensions for jdisc/container metrics
+ *
+ * @author bjorncs
+ */
+class MetricDefinitions {
+ static final String NAME_DIMENSION = "serverName";
+ static final String PORT_DIMENSION = "serverPort";
+ static final String METHOD_DIMENSION = "httpMethod";
+ static final String SCHEME_DIMENSION = "scheme";
+ static final String REQUEST_TYPE_DIMENSION = "requestType";
+ static final String CLIENT_IP_DIMENSION = "clientIp";
+ static final String CLIENT_AUTHENTICATED_DIMENSION = "clientAuthenticated";
+ static final String REQUEST_SERVER_NAME_DIMENSION = "requestServerName";
+ static final String FILTER_CHAIN_ID_DIMENSION = "chainId";
+
+ static final String NUM_OPEN_CONNECTIONS = "serverNumOpenConnections";
+ static final String NUM_CONNECTIONS_OPEN_MAX = "serverConnectionsOpenMax";
+ static final String CONNECTION_DURATION_MAX = "serverConnectionDurationMax";
+ static final String CONNECTION_DURATION_MEAN = "serverConnectionDurationMean";
+ static final String CONNECTION_DURATION_STD_DEV = "serverConnectionDurationStdDev";
+ static final String NUM_PREMATURELY_CLOSED_CONNECTIONS = "jdisc.http.request.prematurely_closed";
+
+ static final String NUM_BYTES_RECEIVED = "serverBytesReceived";
+ static final String NUM_BYTES_SENT = "serverBytesSent";
+
+ static final String NUM_CONNECTIONS = "serverNumConnections";
+
+ /* For historical reasons, these are all aliases for the same metric. 'jdisc.http' should ideally be the only one. */
+ static final String JDISC_HTTP_REQUESTS = "jdisc.http.requests";
+ static final String NUM_REQUESTS = "serverNumRequests";
+
+ static final String NUM_SUCCESSFUL_RESPONSES = "serverNumSuccessfulResponses";
+ static final String NUM_FAILED_RESPONSES = "serverNumFailedResponses";
+ static final String NUM_SUCCESSFUL_WRITES = "serverNumSuccessfulResponseWrites";
+ static final String NUM_FAILED_WRITES = "serverNumFailedResponseWrites";
+
+ static final String TOTAL_SUCCESSFUL_LATENCY = "serverTotalSuccessfulResponseLatency";
+ static final String TOTAL_FAILED_LATENCY = "serverTotalFailedResponseLatency";
+ static final String TIME_TO_FIRST_BYTE = "serverTimeToFirstByte";
+
+ static final String RESPONSES_1XX = "http.status.1xx";
+ static final String RESPONSES_2XX = "http.status.2xx";
+ static final String RESPONSES_3XX = "http.status.3xx";
+ static final String RESPONSES_4XX = "http.status.4xx";
+ static final String RESPONSES_5XX = "http.status.5xx";
+ static final String RESPONSES_401 = "http.status.401";
+ static final String RESPONSES_403 = "http.status.403";
+
+ static final String STARTED_MILLIS = "serverStartedMillis";
+
+ static final String URI_LENGTH = "jdisc.http.request.uri_length";
+ static final String CONTENT_SIZE = "jdisc.http.request.content_size";
+
+ static final String SSL_HANDSHAKE_FAILURE_MISSING_CLIENT_CERT = "jdisc.http.ssl.handshake.failure.missing_client_cert";
+ static final String SSL_HANDSHAKE_FAILURE_EXPIRED_CLIENT_CERT = "jdisc.http.ssl.handshake.failure.expired_client_cert";
+ static final String SSL_HANDSHAKE_FAILURE_INVALID_CLIENT_CERT = "jdisc.http.ssl.handshake.failure.invalid_client_cert";
+ static final String SSL_HANDSHAKE_FAILURE_INCOMPATIBLE_PROTOCOLS = "jdisc.http.ssl.handshake.failure.incompatible_protocols";
+ static final String SSL_HANDSHAKE_FAILURE_INCOMPATIBLE_CIPHERS = "jdisc.http.ssl.handshake.failure.incompatible_ciphers";
+ static final String SSL_HANDSHAKE_FAILURE_UNKNOWN = "jdisc.http.ssl.handshake.failure.unknown";
+
+ static final String JETTY_THREADPOOL_MAX_THREADS = "jdisc.http.jetty.threadpool.thread.max";
+ static final String JETTY_THREADPOOL_MIN_THREADS = "jdisc.http.jetty.threadpool.thread.min";
+ static final String JETTY_THREADPOOL_RESERVED_THREADS = "jdisc.http.jetty.threadpool.thread.reserved";
+ static final String JETTY_THREADPOOL_BUSY_THREADS = "jdisc.http.jetty.threadpool.thread.busy";
+ static final String JETTY_THREADPOOL_IDLE_THREADS = "jdisc.http.jetty.threadpool.thread.idle";
+ static final String JETTY_THREADPOOL_TOTAL_THREADS = "jdisc.http.jetty.threadpool.thread.total";
+ static final String JETTY_THREADPOOL_QUEUE_SIZE = "jdisc.http.jetty.threadpool.queue.size";
+
+ static final String FILTERING_REQUEST_HANDLED = "jdisc.http.filtering.request.handled";
+ static final String FILTERING_REQUEST_UNHANDLED = "jdisc.http.filtering.request.unhandled";
+ static final String FILTERING_RESPONSE_HANDLED = "jdisc.http.filtering.response.handled";
+ static final String FILTERING_RESPONSE_UNHANDLED = "jdisc.http.filtering.response.unhandled";
+
+ private MetricDefinitions() {}
+}
diff --git a/container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/OneTimeRunnable.java b/container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/OneTimeRunnable.java
new file mode 100644
index 00000000000..eb83d3d7d03
--- /dev/null
+++ b/container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/OneTimeRunnable.java
@@ -0,0 +1,23 @@
+// Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.jdisc.http.server.jetty;
+
+import java.util.concurrent.atomic.AtomicBoolean;
+
+/**
+ * @author Tony Vaagenes
+ */
+public class OneTimeRunnable {
+ private final Runnable runnable;
+ private final AtomicBoolean hasRun = new AtomicBoolean(false);
+
+ public OneTimeRunnable(Runnable runnable) {
+ this.runnable = runnable;
+ }
+
+ public void runIfFirstInvocation() {
+ boolean previous = hasRun.getAndSet(true);
+ if (!previous) {
+ runnable.run();
+ }
+ }
+}
diff --git a/container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/ReferenceCountingRequestHandler.java b/container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/ReferenceCountingRequestHandler.java
new file mode 100644
index 00000000000..f2bf5b56d5c
--- /dev/null
+++ b/container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/ReferenceCountingRequestHandler.java
@@ -0,0 +1,257 @@
+// Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.jdisc.http.server.jetty;
+
+import com.yahoo.jdisc.Request;
+import com.yahoo.jdisc.ResourceReference;
+import com.yahoo.jdisc.Response;
+import com.yahoo.jdisc.SharedResource;
+import com.yahoo.jdisc.handler.CompletionHandler;
+import com.yahoo.jdisc.handler.ContentChannel;
+import com.yahoo.jdisc.handler.NullContent;
+import com.yahoo.jdisc.handler.RequestHandler;
+import com.yahoo.jdisc.handler.ResponseHandler;
+
+import java.nio.ByteBuffer;
+import java.util.Objects;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+/**
+ * This class wraps a request handler and does reference counting on the request for every object that depends on the
+ * request, such as the response handler, content channels and completion handlers. This ensures that requests (and
+ * hence the current container) will be referenced until the end of the request handling - even with async handling in
+ * non-framework threads - without requiring the application to handle this tedious work.
+ *
+ * @author bakksjo
+ */
+@SuppressWarnings("try")
+class ReferenceCountingRequestHandler implements RequestHandler {
+
+ private static final Logger log = Logger.getLogger(ReferenceCountingRequestHandler.class.getName());
+
+ final RequestHandler delegate;
+
+ ReferenceCountingRequestHandler(RequestHandler delegate) {
+ Objects.requireNonNull(delegate, "delegate");
+ this.delegate = delegate;
+ }
+
+ @Override
+ public ContentChannel handleRequest(Request request, ResponseHandler responseHandler) {
+ try (final ResourceReference requestReference = request.refer()) {
+ ContentChannel contentChannel;
+ final ReferenceCountingResponseHandler referenceCountingResponseHandler
+ = new ReferenceCountingResponseHandler(request, new NullContentResponseHandler(responseHandler));
+ try {
+ contentChannel = delegate.handleRequest(request, referenceCountingResponseHandler);
+ Objects.requireNonNull(contentChannel, "contentChannel");
+ } catch (Throwable t) {
+ try {
+ // The response handler might never be invoked, due to the exception thrown from handleRequest().
+ referenceCountingResponseHandler.unrefer();
+ } catch (Throwable thrownFromUnrefer) {
+ log.log(Level.WARNING, "Unexpected problem", thrownFromUnrefer);
+ }
+ throw t;
+ }
+ return new ReferenceCountingContentChannel(request, contentChannel);
+ }
+ }
+
+ @Override
+ public void handleTimeout(Request request, ResponseHandler responseHandler) {
+ delegate.handleTimeout(request, new NullContentResponseHandler(responseHandler));
+ }
+
+ @Override
+ public ResourceReference refer() {
+ return delegate.refer();
+ }
+
+ @Override
+ public void release() {
+ delegate.release();
+ }
+
+ @Override
+ public String toString() {
+ return delegate.toString();
+ }
+
+ private static class ReferenceCountingResponseHandler implements ResponseHandler {
+
+ final SharedResource request;
+ final ResourceReference requestReference;
+ final ResponseHandler delegate;
+ final AtomicBoolean closed = new AtomicBoolean(false);
+
+ ReferenceCountingResponseHandler(SharedResource request, ResponseHandler delegate) {
+ Objects.requireNonNull(request, "request");
+ Objects.requireNonNull(delegate, "delegate");
+ this.request = request;
+ this.delegate = delegate;
+ this.requestReference = request.refer();
+ }
+
+ @Override
+ public ContentChannel handleResponse(Response response) {
+ if (closed.getAndSet(true)) {
+ throw new IllegalStateException(delegate + " is already called.");
+ }
+ try (final ResourceReference ref = requestReference) {
+ ContentChannel contentChannel = delegate.handleResponse(response);
+ Objects.requireNonNull(contentChannel, "contentChannel");
+ return new ReferenceCountingContentChannel(request, contentChannel);
+ }
+ }
+
+ @Override
+ public String toString() {
+ return delegate.toString();
+ }
+
+ /**
+ * Close the reference that is normally closed by {@link #handleResponse(Response)}.
+ *
+ * This is to be used in error situations, where handleResponse() may not be invoked.
+ */
+ public void unrefer() {
+ if (closed.getAndSet(true)) {
+ // This simply means that handleResponse() has been run, in which case we are
+ // guaranteed that the reference is closed.
+ return;
+ }
+ requestReference.close();
+ }
+ }
+
+ private static class ReferenceCountingContentChannel implements ContentChannel {
+
+ final SharedResource request;
+ final ResourceReference requestReference;
+ final ContentChannel delegate;
+
+ ReferenceCountingContentChannel(SharedResource request, ContentChannel delegate) {
+ Objects.requireNonNull(request, "request");
+ Objects.requireNonNull(delegate, "delegate");
+ this.request = request;
+ this.delegate = delegate;
+ this.requestReference = request.refer();
+ }
+
+ @Override
+ public void write(ByteBuffer buf, CompletionHandler completionHandler) {
+ final CompletionHandler referenceCountingCompletionHandler
+ = new ReferenceCountingCompletionHandler(request, completionHandler);
+ try {
+ delegate.write(buf, referenceCountingCompletionHandler);
+ } catch (Throwable t) {
+ try {
+ referenceCountingCompletionHandler.failed(t);
+ } catch (AlreadyCompletedException ignored) {
+ } catch (Throwable failFailure) {
+ log.log(Level.WARNING, "Failure during call to CompletionHandler.failed()", failFailure);
+ }
+ throw t;
+ }
+ }
+
+ @Override
+ public void close(CompletionHandler completionHandler) {
+ final CompletionHandler referenceCountingCompletionHandler
+ = new ReferenceCountingCompletionHandler(request, completionHandler);
+ try (final ResourceReference ref = requestReference) {
+ delegate.close(referenceCountingCompletionHandler);
+ } catch (Throwable t) {
+ try {
+ referenceCountingCompletionHandler.failed(t);
+ } catch (AlreadyCompletedException ignored) {
+ } catch (Throwable failFailure) {
+ log.log(Level.WARNING, "Failure during call to CompletionHandler.failed()", failFailure);
+ }
+ throw t;
+ }
+ }
+
+ @Override
+ public String toString() {
+ return delegate.toString();
+ }
+ }
+
+ private static class AlreadyCompletedException extends IllegalStateException {
+ public AlreadyCompletedException(final CompletionHandler completionHandler) {
+ super(completionHandler + " is already called.");
+ }
+ }
+
+ private static class ReferenceCountingCompletionHandler implements CompletionHandler {
+
+ final ResourceReference requestReference;
+ final CompletionHandler delegate;
+ final AtomicBoolean closed = new AtomicBoolean(false);
+
+ public ReferenceCountingCompletionHandler(SharedResource request, CompletionHandler delegate) {
+ this.delegate = delegate;
+ this.requestReference = request.refer();
+ }
+
+ @Override
+ public void completed() {
+ if (closed.getAndSet(true)) {
+ throw new AlreadyCompletedException(delegate);
+ }
+ try {
+ if (delegate != null) {
+ delegate.completed();
+ }
+ } finally {
+ requestReference.close();
+ }
+ }
+
+ @Override
+ public void failed(Throwable t) {
+ if (closed.getAndSet(true)) {
+ throw new AlreadyCompletedException(delegate);
+ }
+ try (final ResourceReference ref = requestReference) {
+ if (delegate != null) {
+ delegate.failed(t);
+ } else {
+ log.log(Level.WARNING, "Uncaught completion failure.", t);
+ }
+ }
+ }
+
+ @Override
+ public String toString() {
+ return String.valueOf(delegate);
+ }
+ }
+
+ private static class NullContentResponseHandler implements ResponseHandler {
+
+ final ResponseHandler delegate;
+
+ NullContentResponseHandler(ResponseHandler delegate) {
+ Objects.requireNonNull(delegate, "delegate");
+ this.delegate = delegate;
+ }
+
+ @Override
+ public ContentChannel handleResponse(Response response) {
+ ContentChannel contentChannel = delegate.handleResponse(response);
+ if (contentChannel == null) {
+ contentChannel = NullContent.INSTANCE;
+ }
+ return contentChannel;
+ }
+
+ @Override
+ public String toString() {
+ return delegate.toString();
+ }
+ }
+}
diff --git a/container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/RequestException.java b/container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/RequestException.java
new file mode 100644
index 00000000000..eea69cd7f74
--- /dev/null
+++ b/container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/RequestException.java
@@ -0,0 +1,39 @@
+// Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.jdisc.http.server.jetty;
+
+/**
+ * This exception may be thrown from a request handler to fail a request with a given response code and message.
+ * It is given some special treatment in {@link ServletResponseController}.
+ *
+ * @author bakksjo
+ */
+class RequestException extends RuntimeException {
+
+ private final int responseStatus;
+
+ /**
+ * @param responseStatus the response code to use for the http response
+ * @param message exception message
+ * @param cause chained throwable
+ */
+ public RequestException(final int responseStatus, final String message, final Throwable cause) {
+ super(message, cause);
+ this.responseStatus = responseStatus;
+ }
+
+ /**
+ * @param responseStatus the response code to use for the http response
+ * @param message exception message
+ */
+ public RequestException(final int responseStatus, final String message) {
+ super(message);
+ this.responseStatus = responseStatus;
+ }
+
+ /**
+ * Returns the response code to use for the http response.
+ */
+ public int getResponseStatus() {
+ return responseStatus;
+ }
+}
diff --git a/container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/RequestMetricReporter.java b/container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/RequestMetricReporter.java
new file mode 100644
index 00000000000..7596be0415a
--- /dev/null
+++ b/container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/RequestMetricReporter.java
@@ -0,0 +1,85 @@
+// Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.jdisc.http.server.jetty;
+
+import com.yahoo.jdisc.Metric;
+import com.yahoo.jdisc.Metric.Context;
+
+import java.util.concurrent.atomic.AtomicBoolean;
+
+
+/**
+ * Responsible for metric reporting for JDisc http request handler support.
+ * @author Tony Vaagenes
+ */
+class RequestMetricReporter {
+ private final Metric metric;
+ private final Context context;
+
+ private final long requestStartTime;
+
+ //TODO: rename
+ private final AtomicBoolean firstSetOfTimeToFirstByte = new AtomicBoolean(true);
+
+
+ RequestMetricReporter(Metric metric, Context context, long requestStartTime) {
+ this.metric = metric;
+ this.context = context;
+ this.requestStartTime = requestStartTime;
+ }
+
+ void successfulWrite(int numBytes) {
+ setTimeToFirstByteFirstTime();
+
+ metric.add(MetricDefinitions.NUM_SUCCESSFUL_WRITES, 1, context);
+ metric.set(MetricDefinitions.NUM_BYTES_SENT, numBytes, context);
+ }
+
+ private void setTimeToFirstByteFirstTime() {
+ boolean isFirstWrite = firstSetOfTimeToFirstByte.getAndSet(false);
+ if (isFirstWrite) {
+ long timeToFirstByte = getRequestLatency();
+ metric.set(MetricDefinitions.TIME_TO_FIRST_BYTE, timeToFirstByte, context);
+ }
+ }
+
+ void failedWrite() {
+ metric.add(MetricDefinitions.NUM_FAILED_WRITES, 1, context);
+ }
+
+ void successfulResponse() {
+ setTimeToFirstByteFirstTime();
+
+ long requestLatency = getRequestLatency();
+
+ metric.set(MetricDefinitions.TOTAL_SUCCESSFUL_LATENCY, requestLatency, context);
+
+ metric.add(MetricDefinitions.NUM_SUCCESSFUL_RESPONSES, 1, context);
+ }
+
+ void failedResponse() {
+ setTimeToFirstByteFirstTime();
+
+ metric.set(MetricDefinitions.TOTAL_FAILED_LATENCY, getRequestLatency(), context);
+ metric.add(MetricDefinitions.NUM_FAILED_RESPONSES, 1, context);
+ }
+
+ void prematurelyClosed() {
+ metric.add(MetricDefinitions.NUM_PREMATURELY_CLOSED_CONNECTIONS, 1, context);
+ }
+
+ void successfulRead(int bytes_received) {
+ metric.set(MetricDefinitions.NUM_BYTES_RECEIVED, bytes_received, context);
+ }
+
+ private long getRequestLatency() {
+ return System.currentTimeMillis() - requestStartTime;
+ }
+
+ void uriLength(int length) {
+ metric.set(MetricDefinitions.URI_LENGTH, length, context);
+ }
+
+ void contentSize(int size) {
+ metric.set(MetricDefinitions.CONTENT_SIZE, size, context);
+ }
+}
diff --git a/container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/SecuredRedirectHandler.java b/container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/SecuredRedirectHandler.java
new file mode 100644
index 00000000000..e32c9d46deb
--- /dev/null
+++ b/container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/SecuredRedirectHandler.java
@@ -0,0 +1,58 @@
+// Copyright Verizon Media. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.jdisc.http.server.jetty;
+
+import com.yahoo.jdisc.http.ConnectorConfig;
+import org.eclipse.jetty.server.Request;
+import org.eclipse.jetty.server.handler.HandlerWrapper;
+import org.eclipse.jetty.util.URIUtil;
+
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import static com.yahoo.jdisc.http.server.jetty.HttpServletRequestUtils.getConnectorLocalPort;
+
+/**
+ * A secure redirect handler inspired by {@link org.eclipse.jetty.server.handler.SecuredRedirectHandler}.
+ *
+ * @author bjorncs
+ */
+class SecuredRedirectHandler extends HandlerWrapper {
+
+ private static final String HEALTH_CHECK_PATH = "/status.html";
+
+ private final Map<Integer, Integer> redirectMap;
+
+ SecuredRedirectHandler(List<ConnectorConfig> connectorConfigs) {
+ this.redirectMap = createRedirectMap(connectorConfigs);
+ }
+
+ @Override
+ public void handle(String target, Request request, HttpServletRequest servletRequest, HttpServletResponse servletResponse) throws IOException, ServletException {
+ int localPort = getConnectorLocalPort(servletRequest);
+ if (!redirectMap.containsKey(localPort)) {
+ _handler.handle(target, request, servletRequest, servletResponse);
+ return;
+ }
+ servletResponse.setContentLength(0);
+ if (!servletRequest.getRequestURI().equals(HEALTH_CHECK_PATH)) {
+ servletResponse.sendRedirect(
+ URIUtil.newURI("https", request.getServerName(), redirectMap.get(localPort), request.getRequestURI(), request.getQueryString()));
+ }
+ request.setHandled(true);
+ }
+
+ private static Map<Integer, Integer> createRedirectMap(List<ConnectorConfig> connectorConfigs) {
+ var redirectMap = new HashMap<Integer, Integer>();
+ for (ConnectorConfig connectorConfig : connectorConfigs) {
+ if (connectorConfig.secureRedirect().enabled()) {
+ redirectMap.put(connectorConfig.listenPort(), connectorConfig.secureRedirect().port());
+ }
+ }
+ return redirectMap;
+ }
+}
diff --git a/container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/ServerMetricReporter.java b/container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/ServerMetricReporter.java
new file mode 100644
index 00000000000..ba3694ffc2f
--- /dev/null
+++ b/container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/ServerMetricReporter.java
@@ -0,0 +1,115 @@
+// Copyright Verizon Media. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.jdisc.http.server.jetty;
+
+import com.yahoo.concurrent.DaemonThreadFactory;
+import com.yahoo.jdisc.Metric;
+import org.eclipse.jetty.io.ConnectionStatistics;
+import org.eclipse.jetty.server.Connector;
+import org.eclipse.jetty.server.Server;
+import org.eclipse.jetty.server.handler.AbstractHandlerContainer;
+import org.eclipse.jetty.server.handler.StatisticsHandler;
+import org.eclipse.jetty.util.thread.QueuedThreadPool;
+
+import java.time.Instant;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Reports server/connector specific metrics for Jdisc and Jetty
+ *
+ * @author bjorncs
+ */
+class ServerMetricReporter {
+
+ private final ScheduledExecutorService executor =
+ Executors.newScheduledThreadPool(1, new DaemonThreadFactory("jdisc-jetty-metric-reporter-"));
+ private final Metric metric;
+ private final Server jetty;
+
+ ServerMetricReporter(Metric metric, Server jetty) {
+ this.metric = metric;
+ this.jetty = jetty;
+ }
+
+ void start() {
+ executor.scheduleAtFixedRate(new ReporterTask(), 0, 2, TimeUnit.SECONDS);
+ }
+
+ void shutdown() {
+ try {
+ executor.shutdownNow();
+ executor.awaitTermination(10, TimeUnit.SECONDS);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ }
+
+ private class ReporterTask implements Runnable {
+
+ private final Instant timeStarted = Instant.now();
+
+ @Override
+ public void run() {
+ HttpResponseStatisticsCollector statisticsCollector = ((AbstractHandlerContainer) jetty.getHandler())
+ .getChildHandlerByClass(HttpResponseStatisticsCollector.class);
+ if (statisticsCollector != null) {
+ setServerMetrics(statisticsCollector);
+ }
+
+ // reset statisticsHandler to preserve earlier behavior
+ StatisticsHandler statisticsHandler = ((AbstractHandlerContainer) jetty.getHandler())
+ .getChildHandlerByClass(StatisticsHandler.class);
+ if (statisticsHandler != null) {
+ statisticsHandler.statsReset();
+ }
+
+ for (Connector connector : jetty.getConnectors()) {
+ setConnectorMetrics((JDiscServerConnector)connector);
+ }
+
+ setJettyThreadpoolMetrics();
+ }
+
+ private void setServerMetrics(HttpResponseStatisticsCollector statisticsCollector) {
+ long timeSinceStarted = System.currentTimeMillis() - timeStarted.toEpochMilli();
+ metric.set(MetricDefinitions.STARTED_MILLIS, timeSinceStarted, null);
+
+ addResponseMetrics(statisticsCollector);
+ }
+
+ private void addResponseMetrics(HttpResponseStatisticsCollector statisticsCollector) {
+ for (var metricEntry : statisticsCollector.takeStatistics()) {
+ Map<String, Object> dimensions = new HashMap<>();
+ dimensions.put(MetricDefinitions.METHOD_DIMENSION, metricEntry.method);
+ dimensions.put(MetricDefinitions.SCHEME_DIMENSION, metricEntry.scheme);
+ dimensions.put(MetricDefinitions.REQUEST_TYPE_DIMENSION, metricEntry.requestType);
+ metric.add(metricEntry.name, metricEntry.value, metric.createContext(dimensions));
+ }
+ }
+
+ private void setJettyThreadpoolMetrics() {
+ QueuedThreadPool threadpool = (QueuedThreadPool) jetty.getThreadPool();
+ metric.set(MetricDefinitions.JETTY_THREADPOOL_MAX_THREADS, threadpool.getMaxThreads(), null);
+ metric.set(MetricDefinitions.JETTY_THREADPOOL_MIN_THREADS, threadpool.getMinThreads(), null);
+ metric.set(MetricDefinitions.JETTY_THREADPOOL_RESERVED_THREADS, threadpool.getReservedThreads(), null);
+ metric.set(MetricDefinitions.JETTY_THREADPOOL_BUSY_THREADS, threadpool.getBusyThreads(), null);
+ metric.set(MetricDefinitions.JETTY_THREADPOOL_IDLE_THREADS, threadpool.getIdleThreads(), null);
+ metric.set(MetricDefinitions.JETTY_THREADPOOL_TOTAL_THREADS, threadpool.getThreads(), null);
+ metric.set(MetricDefinitions.JETTY_THREADPOOL_QUEUE_SIZE, threadpool.getQueueSize(), null);
+ }
+
+ private void setConnectorMetrics(JDiscServerConnector connector) {
+ ConnectionStatistics statistics = connector.getStatistics();
+ metric.set(MetricDefinitions.NUM_CONNECTIONS, statistics.getConnectionsTotal(), connector.getConnectorMetricContext());
+ metric.set(MetricDefinitions.NUM_OPEN_CONNECTIONS, statistics.getConnections(), connector.getConnectorMetricContext());
+ metric.set(MetricDefinitions.NUM_CONNECTIONS_OPEN_MAX, statistics.getConnectionsMax(), connector.getConnectorMetricContext());
+ metric.set(MetricDefinitions.CONNECTION_DURATION_MAX, statistics.getConnectionDurationMax(), connector.getConnectorMetricContext());
+ metric.set(MetricDefinitions.CONNECTION_DURATION_MEAN, statistics.getConnectionDurationMean(), connector.getConnectorMetricContext());
+ metric.set(MetricDefinitions.CONNECTION_DURATION_STD_DEV, statistics.getConnectionDurationStdDev(), connector.getConnectorMetricContext());
+ }
+
+ }
+}
diff --git a/container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/ServletOutputStreamWriter.java b/container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/ServletOutputStreamWriter.java
new file mode 100644
index 00000000000..b4d03385c3b
--- /dev/null
+++ b/container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/ServletOutputStreamWriter.java
@@ -0,0 +1,299 @@
+// Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.jdisc.http.server.jetty;
+
+import com.yahoo.jdisc.handler.CompletionHandler;
+
+import javax.servlet.ServletOutputStream;
+import javax.servlet.WriteListener;
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.Deque;
+import java.util.Optional;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.Executor;
+import java.util.function.Consumer;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+import static com.yahoo.jdisc.http.server.jetty.CompletionHandlerUtils.NOOP_COMPLETION_HANDLER;
+
+/**
+ * @author Tony Vaagenes
+ * @author bjorncs
+ */
+public class ServletOutputStreamWriter {
+ /** Rules:
+ * 1) Don't modify the output stream without isReady returning true (write/flush/close).
+ * Multiple modification calls without interleaving isReady calls are not allowed.
+ * 2) If isReady returned false, no other calls should be made until the write listener is invoked.
+ * 3) If the write listener sees isReady == false, it must not do any modifications before its next invocation.
+ */
+
+
+ private enum State {
+ NOT_STARTED,
+ WAITING_FOR_WRITE_POSSIBLE_CALLBACK,
+ WAITING_FOR_BUFFER,
+ WRITING_BUFFERS,
+ FINISHED_OR_ERROR
+ }
+
+ private static final Logger log = Logger.getLogger(ServletOutputStreamWriter.class.getName());
+
+ // If so, application code could fake a close by writing such a byte buffer.
+ // The problem can be solved by filtering out zero-length byte buffers from application code.
+ // Other ways to express this are also possible, e.g. with a 'closed' state checked when queue goes empty.
+ private static final ByteBuffer CLOSE_STREAM_BUFFER = ByteBuffer.allocate(0);
+
+ private final Object monitor = new Object();
+
+ // GuardedBy("monitor")
+ private State state = State.NOT_STARTED;
+
+ // GuardedBy("state")
+ private final ServletOutputStream outputStream;
+ private final Executor executor;
+
+ // GuardedBy("monitor")
+ private final Deque<ResponseContentPart> responseContentQueue = new ArrayDeque<>();
+
+ private final RequestMetricReporter metricReporter;
+
+ /**
+ * When this future completes there will be no more calls against the servlet output stream or servlet response.
+ * The framework is still allowed to invoke us though.
+ *
+ * The future might complete in the servlet framework thread, user thread or executor thread.
+ */
+ final CompletableFuture<Void> finishedFuture = new CompletableFuture<>();
+
+
+ public ServletOutputStreamWriter(ServletOutputStream outputStream, Executor executor, RequestMetricReporter metricReporter) {
+ this.outputStream = outputStream;
+ this.executor = executor;
+ this.metricReporter = metricReporter;
+ }
+
+ public void sendErrorContentAndCloseAsync(ByteBuffer errorContent) {
+ synchronized (monitor) {
+ // Assert that no content has been written as it is too late to write error response if the response is committed.
+ assertStateIs(state, State.NOT_STARTED);
+ queueErrorContent_holdingLock(errorContent);
+ state = State.WAITING_FOR_WRITE_POSSIBLE_CALLBACK;
+ outputStream.setWriteListener(writeListener);
+ }
+ }
+
+ private void queueErrorContent_holdingLock(ByteBuffer errorContent) {
+ responseContentQueue.addLast(new ResponseContentPart(errorContent, NOOP_COMPLETION_HANDLER));
+ responseContentQueue.addLast(new ResponseContentPart(CLOSE_STREAM_BUFFER, NOOP_COMPLETION_HANDLER));
+ }
+
+ public void writeBuffer(ByteBuffer buf, CompletionHandler handler) {
+ boolean thisThreadShouldWrite = false;
+
+ synchronized (monitor) {
+ if (state == State.FINISHED_OR_ERROR) {
+ executor.execute(() -> handler.failed(new IllegalStateException("ContentChannel already closed.")));
+ return;
+ }
+ responseContentQueue.addLast(new ResponseContentPart(buf, handler));
+ switch (state) {
+ case NOT_STARTED:
+ state = State.WAITING_FOR_WRITE_POSSIBLE_CALLBACK;
+ outputStream.setWriteListener(writeListener);
+ break;
+ case WAITING_FOR_WRITE_POSSIBLE_CALLBACK:
+ case WRITING_BUFFERS:
+ break;
+ case WAITING_FOR_BUFFER:
+ thisThreadShouldWrite = true;
+ state = State.WRITING_BUFFERS;
+ break;
+ default:
+ throw new IllegalStateException("Invalid state " + state);
+ }
+ }
+
+ if (thisThreadShouldWrite) {
+ writeBuffersInQueueToOutputStream();
+ }
+ }
+
+ public void close(CompletionHandler handler) {
+ writeBuffer(CLOSE_STREAM_BUFFER, handler);
+ }
+
+ public void close() {
+ close(NOOP_COMPLETION_HANDLER);
+ }
+
+ private void writeBuffersInQueueToOutputStream() {
+ boolean lastOperationWasFlush = false;
+
+ while (true) {
+ ResponseContentPart contentPart;
+
+ synchronized (monitor) {
+ if (state == State.FINISHED_OR_ERROR) {
+ return;
+ }
+ assertStateIs(state, State.WRITING_BUFFERS);
+
+ if (!outputStream.isReady()) {
+ state = State.WAITING_FOR_WRITE_POSSIBLE_CALLBACK;
+ return;
+ }
+
+ contentPart = responseContentQueue.pollFirst();
+
+ if (contentPart == null && lastOperationWasFlush) {
+ state = State.WAITING_FOR_BUFFER;
+ return;
+ }
+ }
+
+ try {
+ boolean isFlush = contentPart == null;
+ if (isFlush) {
+ outputStream.flush();
+ lastOperationWasFlush = true;
+ continue;
+ }
+ lastOperationWasFlush = false;
+
+ if (contentPart.buf == CLOSE_STREAM_BUFFER) {
+ callCompletionHandlerWhenDone(contentPart.handler, outputStream::close);
+ setFinished(Optional.empty());
+ return;
+ } else {
+ writeBufferToOutputStream(contentPart);
+ }
+ } catch (Throwable e) {
+ setFinished(Optional.of(e));
+ return;
+ }
+ }
+ }
+
+ private void setFinished(Optional<Throwable> e) {
+ synchronized (monitor) {
+ state = State.FINISHED_OR_ERROR;
+ if (!responseContentQueue.isEmpty()) {
+ failAllParts_holdingLock(e.orElse(new IllegalStateException("ContentChannel closed.")));
+ }
+ }
+
+ assert !Thread.holdsLock(monitor);
+ if (e.isPresent()) {
+ finishedFuture.completeExceptionally(e.get());
+ } else {
+ finishedFuture.complete(null);
+ }
+ }
+
+ private void failAllParts_holdingLock(Throwable e) {
+ assert Thread.holdsLock(monitor);
+
+ ArrayList<ResponseContentPart> failedParts = new ArrayList<>(responseContentQueue);
+ responseContentQueue.clear();
+
+ @SuppressWarnings("ThrowableInstanceNeverThrown")
+ RuntimeException failReason = new RuntimeException("Failing due to earlier ServletOutputStream write failure", e);
+
+ Consumer<ResponseContentPart> failCompletionHandler = responseContentPart ->
+ runCompletionHandler_logOnExceptions(
+ () -> responseContentPart.handler.failed(failReason));
+
+ executor.execute(
+ () -> failedParts.forEach(failCompletionHandler));
+ }
+
+ private void writeBufferToOutputStream(ResponseContentPart contentPart) throws Throwable {
+ callCompletionHandlerWhenDone(contentPart.handler, () -> {
+ ByteBuffer buffer = contentPart.buf;
+ final int bytesToSend = buffer.remaining();
+ try {
+ if (buffer.hasArray()) {
+ outputStream.write(buffer.array(), buffer.arrayOffset(), buffer.remaining());
+ } else {
+ final byte[] array = new byte[buffer.remaining()];
+ buffer.get(array);
+ outputStream.write(array);
+ }
+ metricReporter.successfulWrite(bytesToSend);
+ } catch (Throwable throwable) {
+ metricReporter.failedWrite();
+ throw throwable;
+ }
+ });
+ }
+
+ private static void callCompletionHandlerWhenDone(CompletionHandler handler, IORunnable runnable) throws Exception {
+ try {
+ runnable.run();
+ } catch (Throwable e) {
+ runCompletionHandler_logOnExceptions(() -> handler.failed(e));
+ throw e;
+ }
+ handler.completed(); //Might throw an exception, handling in the enclosing scope.
+ }
+
+ private static void runCompletionHandler_logOnExceptions(Runnable runnable) {
+ try {
+ runnable.run();
+ } catch (Throwable e) {
+ log.log(Level.WARNING, "Unexpected exception from CompletionHandler.", e);
+ }
+ }
+
+ private static void assertStateIs(State currentState, State expectedState) {
+ if (currentState != expectedState) {
+ AssertionError error = new AssertionError("Expected state " + expectedState + ", got state " + currentState);
+ log.log(Level.WARNING, "Assertion failed.", error);
+ throw error;
+ }
+ }
+
+ public void fail(Throwable t) {
+ setFinished(Optional.of(t));
+ }
+
+ private final WriteListener writeListener = new WriteListener() {
+ @Override
+ public void onWritePossible() throws IOException {
+ synchronized (monitor) {
+ if (state == State.FINISHED_OR_ERROR) {
+ return;
+ }
+
+ assertStateIs(state, State.WAITING_FOR_WRITE_POSSIBLE_CALLBACK);
+ state = State.WRITING_BUFFERS;
+ }
+
+ writeBuffersInQueueToOutputStream();
+ }
+
+ @Override
+ public void onError(Throwable t) {
+ setFinished(Optional.of(t));
+ }
+ };
+
+ private static class ResponseContentPart {
+ public final ByteBuffer buf;
+ public final CompletionHandler handler;
+
+ public ResponseContentPart(ByteBuffer buf, CompletionHandler handler) {
+ this.buf = buf;
+ this.handler = handler;
+ }
+ }
+
+ @FunctionalInterface
+ private interface IORunnable {
+ void run() throws IOException;
+ }
+}
diff --git a/container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/ServletRequestReader.java b/container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/ServletRequestReader.java
new file mode 100644
index 00000000000..1882448757a
--- /dev/null
+++ b/container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/ServletRequestReader.java
@@ -0,0 +1,270 @@
+// Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.jdisc.http.server.jetty;
+
+import com.google.common.base.Preconditions;
+import com.yahoo.jdisc.handler.CompletionHandler;
+import com.yahoo.jdisc.handler.ContentChannel;
+
+import javax.servlet.ReadListener;
+import javax.servlet.ServletInputStream;
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.Executor;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+/**
+ * Finished when either
+ * 1) There was an error
+ * 2) There is no more data AND the number of pending completion handler invocations is 0
+ *
+ * Stops reading when a failure has happened.
+ *
+ * The reason for not waiting for pending completions in error situations
+ * is that if the error is reported through the finishedFuture,
+ * error reporting might be async.
+ * Since we have tests that first reports errors and then closes the response content,
+ * it's important that errors are delivered synchronously.
+ */
+class ServletRequestReader implements ReadListener {
+
+ private enum State {
+ READING, ALL_DATA_READ, REQUEST_CONTENT_CLOSED
+ }
+
+ private static final Logger log = Logger.getLogger(ServletRequestReader.class.getName());
+
+ private static final int BUFFER_SIZE_BYTES = 8 * 1024;
+
+ private final Object monitor = new Object();
+
+ private final ServletInputStream servletInputStream;
+ private final ContentChannel requestContentChannel;
+
+ private final Executor executor;
+ private final RequestMetricReporter metricReporter;
+
+ private int bytesRead;
+
+ /**
+ * Rules:
+ * 1. If state != State.READING, then numberOfOutstandingUserCalls must not increase
+ * 2. The _first time_ (finishedFuture is completed OR all data is read) AND numberOfOutstandingUserCalls == 0,
+ * the request content channel should be closed
+ * 3. finishedFuture must not be completed when holding the monitor
+ * 4. completing finishedFuture with an exception must be done synchronously
+ * to prioritize failures being transported to the response.
+ * 5. All completion handlers (both for write and complete) must not be
+ * called from a user (request handler) owned thread
+ * (i.e. when being called from user code, don't call back into user code.)
+ */
+ // GuardedBy("monitor")
+ private State state = State.READING;
+
+ /**
+ * Number of calls that we're waiting for from user code.
+ * There are two classes of such calls:
+ * 1) calls to requestContentChannel.write that we're waiting for to complete
+ * 2) completion handlers given to requestContentChannel.write that the user must call.
+ *
+ * As long as we're waiting for such calls, we're not allowed to:
+ * - close the request content channel (currently only required by tests)
+ * - complete the finished future non-exceptionally,
+ * since then we would not be able to report writeCompletionHandler.failed(exception) calls
+ */
+ // GuardedBy("monitor")
+ private int numberOfOutstandingUserCalls = 0;
+
+ /**
+ * When this future completes there will be no more calls against the servlet input stream.
+ * The framework is still allowed to invoke us though.
+ *
+ * The future might complete in the servlet framework thread, user thread or executor thread.
+ *
+ * All completions of finishedFuture, except those done when closing the request content channel,
+ * must be followed by calls to either onAllDataRead or decreasePendingAndCloseRequestContentChannelConditionally.
+ * Those two functions will ensure that the request content channel is closed at the right time.
+ * If calls to those methods does not close the request content channel immediately,
+ * there is some outstanding completion callback that will later come in and complete the request.
+ */
+ final CompletableFuture<Void> finishedFuture = new CompletableFuture<>();
+
+ public ServletRequestReader(
+ ServletInputStream servletInputStream,
+ ContentChannel requestContentChannel,
+ Executor executor,
+ RequestMetricReporter metricReporter) {
+
+ Preconditions.checkNotNull(servletInputStream);
+ Preconditions.checkNotNull(requestContentChannel);
+ Preconditions.checkNotNull(executor);
+ Preconditions.checkNotNull(metricReporter);
+
+ this.servletInputStream = servletInputStream;
+ this.requestContentChannel = requestContentChannel;
+ this.executor = executor;
+ this.metricReporter = metricReporter;
+ }
+
+ @Override
+ public void onDataAvailable() throws IOException {
+ while (servletInputStream.isReady()) {
+ final byte[] buffer = new byte[BUFFER_SIZE_BYTES];
+ int numBytesRead;
+
+ synchronized (monitor) {
+ numBytesRead = servletInputStream.read(buffer);
+ if (numBytesRead < 0) {
+ // End of stream; there should be no more data available, ever.
+ return;
+ }
+ if (state != State.READING) {
+ //We have a failure, so no point in giving the buffer to the user.
+ assert finishedFuture.isCompletedExceptionally();
+ return;
+ }
+ //wait for both
+ // - requestContentChannel.write to finish
+ // - the write completion handler to be called
+ numberOfOutstandingUserCalls += 2;
+ bytesRead += numBytesRead;
+ }
+
+ try {
+ requestContentChannel.write(ByteBuffer.wrap(buffer, 0, numBytesRead), writeCompletionHandler);
+ metricReporter.successfulRead(numBytesRead);
+ }
+ catch (Throwable t) {
+ finishedFuture.completeExceptionally(t);
+ }
+ finally {
+ //decrease due to this method completing.
+ decreaseOutstandingUserCallsAndCloseRequestContentChannelConditionally();
+ }
+ }
+ }
+
+ private void decreaseOutstandingUserCallsAndCloseRequestContentChannelConditionally() {
+ boolean shouldCloseRequestContentChannel;
+
+ synchronized (monitor) {
+ assertStateNotEquals(state, State.REQUEST_CONTENT_CLOSED);
+
+
+ numberOfOutstandingUserCalls -= 1;
+
+ shouldCloseRequestContentChannel = numberOfOutstandingUserCalls == 0 &&
+ (finishedFuture.isDone() || state == State.ALL_DATA_READ);
+
+ if (shouldCloseRequestContentChannel) {
+ state = State.REQUEST_CONTENT_CLOSED;
+ }
+ }
+
+ if (shouldCloseRequestContentChannel) {
+ executor.execute(this::closeCompletionHandler_noThrow);
+ }
+ }
+
+ private void assertStateNotEquals(State state, State notExpectedState) {
+ if (state == notExpectedState) {
+ AssertionError e = new AssertionError("State should not be " + notExpectedState);
+ log.log(Level.WARNING,
+ "Assertion failed. " +
+ "numberOfOutstandingUserCalls = " + numberOfOutstandingUserCalls +
+ ", isDone = " + finishedFuture.isDone(),
+ e);
+ throw e;
+ }
+ }
+
+ @Override
+ public void onAllDataRead() {
+ doneReading();
+ }
+
+ private void doneReading() {
+ final boolean shouldCloseRequestContentChannel;
+
+ int bytesRead;
+ synchronized (monitor) {
+ if (state != State.READING) {
+ return;
+ }
+
+ state = State.ALL_DATA_READ;
+
+ shouldCloseRequestContentChannel = numberOfOutstandingUserCalls == 0;
+ if (shouldCloseRequestContentChannel) {
+ state = State.REQUEST_CONTENT_CLOSED;
+ }
+ bytesRead = this.bytesRead;
+ }
+
+ if (shouldCloseRequestContentChannel) {
+ closeCompletionHandler_noThrow();
+ }
+
+ metricReporter.contentSize(bytesRead);
+ }
+
+ private void closeCompletionHandler_noThrow() {
+ //Cannot complete finishedFuture directly in completed(), as any exceptions after this fact will be ignored.
+ // E.g.
+ // close(CompletionHandler completionHandler) {
+ // completionHandler.completed();
+ // throw new RuntimeException
+ // }
+
+ CompletableFuture<Void> completedCalledFuture = new CompletableFuture<>();
+
+ CompletionHandler closeCompletionHandler = new CompletionHandler() {
+ @Override
+ public void completed() {
+ completedCalledFuture.complete(null);
+ }
+
+ @Override
+ public void failed(final Throwable t) {
+ finishedFuture.completeExceptionally(t);
+ }
+ };
+
+ try {
+ requestContentChannel.close(closeCompletionHandler);
+ //if close did not cause an exception,
+ // is it safe to pipe the result of the completionHandlerInvokedFuture into finishedFuture
+ completedCalledFuture.whenComplete(this::setFinishedFuture);
+ } catch (final Throwable t) {
+ finishedFuture.completeExceptionally(t);
+ }
+ }
+
+ private void setFinishedFuture(Void result, Throwable throwable) {
+ if (throwable != null) {
+ finishedFuture.completeExceptionally(throwable);
+ } else {
+ finishedFuture.complete(null);
+ }
+ }
+
+ @Override
+ public void onError(final Throwable t) {
+ finishedFuture.completeExceptionally(t);
+ doneReading();
+ }
+
+ private final CompletionHandler writeCompletionHandler = new CompletionHandler() {
+ @Override
+ public void completed() {
+ decreaseOutstandingUserCallsAndCloseRequestContentChannelConditionally();
+ }
+
+ @Override
+ public void failed(final Throwable t) {
+ finishedFuture.completeExceptionally(t);
+ decreaseOutstandingUserCallsAndCloseRequestContentChannelConditionally();
+ }
+ };
+}
diff --git a/container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/ServletResponseController.java b/container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/ServletResponseController.java
new file mode 100644
index 00000000000..60b7878156f
--- /dev/null
+++ b/container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/ServletResponseController.java
@@ -0,0 +1,251 @@
+// Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.jdisc.http.server.jetty;
+
+import com.yahoo.jdisc.Response;
+import com.yahoo.jdisc.handler.BindingNotFoundException;
+import com.yahoo.jdisc.handler.CompletionHandler;
+import com.yahoo.jdisc.handler.ContentChannel;
+import com.yahoo.jdisc.handler.ResponseHandler;
+import com.yahoo.jdisc.http.HttpHeaders;
+import com.yahoo.jdisc.http.HttpResponse;
+import com.yahoo.jdisc.service.BindingSetNotFoundException;
+import org.eclipse.jetty.http.MimeTypes;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.io.IOException;
+import java.io.PrintWriter;
+import java.io.StringWriter;
+import java.nio.ByteBuffer;
+import java.util.Map;
+import java.util.Optional;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.Executor;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+import static com.yahoo.jdisc.http.server.jetty.CompletionHandlerUtils.NOOP_COMPLETION_HANDLER;
+
+/**
+ * @author Tony Vaagenes
+ * @author bjorncs
+ */
+public class ServletResponseController {
+
+ private static Logger log = Logger.getLogger(ServletResponseController.class.getName());
+
+ /**
+ * The servlet spec does not require (Http)ServletResponse nor ServletOutputStream to be thread-safe. Therefore,
+ * we must provide our own synchronization, since we may attempt to access these objects simultaneously from
+ * different threads. (The typical cause of this is when one thread is writing a response while another thread
+ * throws an exception, causing the request to fail with an error response).
+ */
+ private final Object monitor = new Object();
+
+ //servletResponse must not be modified after the response has been committed.
+ private final HttpServletRequest servletRequest;
+ private final HttpServletResponse servletResponse;
+ private final boolean developerMode;
+ private final ErrorResponseContentCreator errorResponseContentCreator = new ErrorResponseContentCreator();
+
+ //all calls to the servletOutputStreamWriter must hold the monitor first to ensure visibility of servletResponse changes.
+ private final ServletOutputStreamWriter servletOutputStreamWriter;
+
+ // GuardedBy("monitor")
+ private boolean responseCommitted = false;
+
+ public ServletResponseController(
+ HttpServletRequest servletRequest,
+ HttpServletResponse servletResponse,
+ Executor executor,
+ RequestMetricReporter metricReporter,
+ boolean developerMode) throws IOException {
+
+ this.servletRequest = servletRequest;
+ this.servletResponse = servletResponse;
+ this.developerMode = developerMode;
+ this.servletOutputStreamWriter =
+ new ServletOutputStreamWriter(servletResponse.getOutputStream(), executor, metricReporter);
+ }
+
+
+ private static int getStatusCode(Throwable t) {
+ if (t instanceof BindingNotFoundException) {
+ return HttpServletResponse.SC_NOT_FOUND;
+ } else if (t instanceof BindingSetNotFoundException) {
+ return HttpServletResponse.SC_NOT_FOUND;
+ } else if (t instanceof RequestException) {
+ return ((RequestException)t).getResponseStatus();
+ } else {
+ return HttpServletResponse.SC_INTERNAL_SERVER_ERROR;
+ }
+ }
+
+ private static String getReasonPhrase(Throwable t, boolean developerMode) {
+ if (developerMode) {
+ final StringWriter out = new StringWriter();
+ t.printStackTrace(new PrintWriter(out));
+ return out.toString();
+ } else if (t.getMessage() != null) {
+ return t.getMessage();
+ } else {
+ return t.toString();
+ }
+ }
+
+
+ public void trySendError(Throwable t) {
+ final boolean responseWasCommitted;
+ try {
+ synchronized (monitor) {
+ String reasonPhrase = getReasonPhrase(t, developerMode);
+ int statusCode = getStatusCode(t);
+ responseWasCommitted = responseCommitted;
+ if (!responseCommitted) {
+ responseCommitted = true;
+ sendErrorAsync(statusCode, reasonPhrase);
+ }
+ }
+ } catch (Throwable e) {
+ servletOutputStreamWriter.fail(t);
+ return;
+ }
+
+ //Must be evaluated after state transition for test purposes(See ConformanceTestException)
+ //Done outside the monitor since it causes a callback in tests.
+ if (responseWasCommitted) {
+ RuntimeException exceptionWithStackTrace = new RuntimeException(t);
+ log.log(Level.FINE, "Response already committed, can't change response code", exceptionWithStackTrace);
+ // TODO: should always have failed here, but that breaks test assumptions. Doing soft close instead.
+ //assert !Thread.holdsLock(monitor);
+ //servletOutputStreamWriter.fail(t);
+ servletOutputStreamWriter.close();
+ }
+
+ }
+
+ /**
+ * Async version of {@link org.eclipse.jetty.server.Response#sendError(int, String)}.
+ */
+ private void sendErrorAsync(int statusCode, String reasonPhrase) {
+ servletResponse.setHeader(HttpHeaders.Names.EXPIRES, null);
+ servletResponse.setHeader(HttpHeaders.Names.LAST_MODIFIED, null);
+ servletResponse.setHeader(HttpHeaders.Names.CACHE_CONTROL, null);
+ servletResponse.setHeader(HttpHeaders.Names.CONTENT_TYPE, null);
+ servletResponse.setHeader(HttpHeaders.Names.CONTENT_LENGTH, null);
+ setStatus(servletResponse, statusCode, Optional.of(reasonPhrase));
+
+ // If we are allowed to have a body
+ if (statusCode != HttpServletResponse.SC_NO_CONTENT &&
+ statusCode != HttpServletResponse.SC_NOT_MODIFIED &&
+ statusCode != HttpServletResponse.SC_PARTIAL_CONTENT &&
+ statusCode >= HttpServletResponse.SC_OK) {
+ servletResponse.setHeader(HttpHeaders.Names.CACHE_CONTROL, "must-revalidate,no-cache,no-store");
+ servletResponse.setContentType(MimeTypes.Type.TEXT_HTML_8859_1.toString());
+ byte[] errorContent = errorResponseContentCreator
+ .createErrorContent(servletRequest.getRequestURI(), statusCode, Optional.ofNullable(reasonPhrase));
+ servletResponse.setContentLength(errorContent.length);
+ servletOutputStreamWriter.sendErrorContentAndCloseAsync(ByteBuffer.wrap(errorContent));
+ } else {
+ servletResponse.setContentLength(0);
+ servletOutputStreamWriter.close();
+ }
+ }
+
+ /**
+ * When this future completes there will be no more calls against the servlet output stream or servlet response.
+ * The framework is still allowed to invoke us though.
+ *
+ * The future might complete in the servlet framework thread, user thread or executor thread.
+ */
+ public CompletableFuture<Void> finishedFuture() {
+ return servletOutputStreamWriter.finishedFuture;
+ }
+
+ private void setResponse(Response jdiscResponse) {
+ synchronized (monitor) {
+ servletRequest.setAttribute(HttpResponseStatisticsCollector.requestTypeAttribute, jdiscResponse.getRequestType());
+ if (responseCommitted) {
+ log.log(Level.FINE,
+ jdiscResponse.getError(),
+ () -> "Response already committed, can't change response code. " +
+ "From: " + servletResponse.getStatus() + ", To: " + jdiscResponse.getStatus());
+
+ //TODO: should throw an exception here, but this breaks unit tests.
+ //The failures will now instead happen when writing buffers.
+ servletOutputStreamWriter.close();
+ return;
+ }
+
+ setStatus_holdingLock(jdiscResponse, servletResponse);
+ setHeaders_holdingLock(jdiscResponse, servletResponse);
+ }
+ }
+
+ private static void setHeaders_holdingLock(Response jdiscResponse, HttpServletResponse servletResponse) {
+ for (final Map.Entry<String, String> entry : jdiscResponse.headers().entries()) {
+ servletResponse.addHeader(entry.getKey(), entry.getValue());
+ }
+
+ if (servletResponse.getContentType() == null) {
+ servletResponse.setContentType("text/plain;charset=utf-8");
+ }
+ }
+
+ private static void setStatus_holdingLock(Response jdiscResponse, HttpServletResponse servletResponse) {
+ if (jdiscResponse instanceof HttpResponse) {
+ setStatus(servletResponse, jdiscResponse.getStatus(), Optional.ofNullable(((HttpResponse) jdiscResponse).getMessage()));
+ } else {
+ setStatus(servletResponse, jdiscResponse.getStatus(), getErrorMessage(jdiscResponse));
+ }
+ }
+
+ @SuppressWarnings("deprecation")
+ private static void setStatus(HttpServletResponse response, int statusCode, Optional<String> reasonPhrase) {
+ if (reasonPhrase.isPresent()) {
+ // Sets the status line: a status code along with a custom message.
+ // Using a custom status message is deprecated in the Servlet API. No alternative exist.
+ response.setStatus(statusCode, reasonPhrase.get()); // DEPRECATED
+ } else {
+ response.setStatus(statusCode);
+ }
+ }
+
+ private static Optional<String> getErrorMessage(Response jdiscResponse) {
+ return Optional.ofNullable(jdiscResponse.getError()).flatMap(
+ error -> Optional.ofNullable(error.getMessage()));
+ }
+
+
+ private void commitResponse() {
+ synchronized (monitor) {
+ responseCommitted = true;
+ }
+ }
+
+ public final ResponseHandler responseHandler = new ResponseHandler() {
+ @Override
+ public ContentChannel handleResponse(Response response) {
+ setResponse(response);
+ return responseContentChannel;
+ }
+ };
+
+ public final ContentChannel responseContentChannel = new ContentChannel() {
+ @Override
+ public void write(ByteBuffer buf, CompletionHandler handler) {
+ commitResponse();
+ servletOutputStreamWriter.writeBuffer(buf, handlerOrNoopHandler(handler));
+ }
+
+ @Override
+ public void close(CompletionHandler handler) {
+ commitResponse();
+ servletOutputStreamWriter.close(handlerOrNoopHandler(handler));
+ }
+
+ private CompletionHandler handlerOrNoopHandler(CompletionHandler handler) {
+ return handler != null ? handler : NOOP_COMPLETION_HANDLER;
+ }
+ };
+}
diff --git a/container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/SslHandshakeFailedListener.java b/container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/SslHandshakeFailedListener.java
new file mode 100644
index 00000000000..822e1c2ffb8
--- /dev/null
+++ b/container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/SslHandshakeFailedListener.java
@@ -0,0 +1,52 @@
+// Copyright 2020 Oath Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.jdisc.http.server.jetty;
+
+import com.yahoo.jdisc.Metric;
+import org.eclipse.jetty.io.ssl.SslHandshakeListener;
+
+import javax.net.ssl.SSLHandshakeException;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Optional;
+import java.util.function.Predicate;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+import java.util.regex.Pattern;
+
+/**
+ * A {@link SslHandshakeListener} that reports metrics for SSL handshake failures.
+ *
+ * @author bjorncs
+ */
+class SslHandshakeFailedListener implements SslHandshakeListener {
+
+ private final static Logger log = Logger.getLogger(SslHandshakeFailedListener.class.getName());
+
+ private final Metric metric;
+ private final String connectorName;
+ private final int listenPort;
+
+ SslHandshakeFailedListener(Metric metric, String connectorName, int listenPort) {
+ this.metric = metric;
+ this.connectorName = connectorName;
+ this.listenPort = listenPort;
+ }
+
+ @Override
+ public void handshakeFailed(Event event, Throwable throwable) {
+ log.log(Level.FINE, throwable, () -> "Ssl handshake failed: " + throwable.getMessage());
+ String metricName = SslHandshakeFailure.fromSslHandshakeException((SSLHandshakeException) throwable)
+ .map(SslHandshakeFailure::metricName)
+ .orElse(MetricDefinitions.SSL_HANDSHAKE_FAILURE_UNKNOWN);
+ metric.add(metricName, 1L, metric.createContext(createDimensions(event)));
+ }
+
+ private Map<String, Object> createDimensions(Event event) {
+ Map<String, Object> dimensions = new HashMap<>();
+ dimensions.put(MetricDefinitions.NAME_DIMENSION, connectorName);
+ dimensions.put(MetricDefinitions.PORT_DIMENSION, listenPort);
+ Optional.ofNullable(event.getSSLEngine().getPeerHost())
+ .ifPresent(clientIp -> dimensions.put(MetricDefinitions.CLIENT_IP_DIMENSION, clientIp));
+ return Map.copyOf(dimensions);
+ }
+}
diff --git a/container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/SslHandshakeFailure.java b/container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/SslHandshakeFailure.java
new file mode 100644
index 00000000000..64f70564137
--- /dev/null
+++ b/container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/SslHandshakeFailure.java
@@ -0,0 +1,61 @@
+// Copyright Verizon Media. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.jdisc.http.server.jetty;
+
+import javax.net.ssl.SSLHandshakeException;
+import java.util.Optional;
+import java.util.function.Predicate;
+import java.util.regex.Pattern;
+
+/**
+ * Categorizes instances of {@link SSLHandshakeException}
+ *
+ * @author bjorncs
+ */
+enum SslHandshakeFailure {
+ INCOMPATIBLE_PROTOCOLS(
+ MetricDefinitions.SSL_HANDSHAKE_FAILURE_INCOMPATIBLE_PROTOCOLS,
+ "INCOMPATIBLE_CLIENT_PROTOCOLS",
+ "(Client requested protocol \\S+? is not enabled or supported in server context" +
+ "|The client supported protocol versions \\[.+?\\] are not accepted by server preferences \\[.+?\\])"),
+ INCOMPATIBLE_CIPHERS(
+ MetricDefinitions.SSL_HANDSHAKE_FAILURE_INCOMPATIBLE_CIPHERS,
+ "INCOMPATIBLE_CLIENT_CIPHER_SUITES",
+ "no cipher suites in common"),
+ MISSING_CLIENT_CERT(
+ MetricDefinitions.SSL_HANDSHAKE_FAILURE_MISSING_CLIENT_CERT,
+ "MISSING_CLIENT_CERTIFICATE",
+ "Empty (server|client) certificate chain"),
+ EXPIRED_CLIENT_CERTIFICATE(
+ MetricDefinitions.SSL_HANDSHAKE_FAILURE_EXPIRED_CLIENT_CERT,
+ "EXPIRED_CLIENT_CERTIFICATE",
+ // Note: this pattern will match certificates with too late notBefore as well
+ "PKIX path validation failed: java.security.cert.CertPathValidatorException: validity check failed"),
+ INVALID_CLIENT_CERT(
+ MetricDefinitions.SSL_HANDSHAKE_FAILURE_INVALID_CLIENT_CERT, // Includes mismatch of client certificate and private key
+ "INVALID_CLIENT_CERTIFICATE",
+ "(PKIX path (building|validation) failed: .+)|(Invalid CertificateVerify signature)");
+
+ private final String metricName;
+ private final String failureType;
+ private final Predicate<String> messageMatcher;
+
+ SslHandshakeFailure(String metricName, String failureType, String messagePattern) {
+ this.metricName = metricName;
+ this.failureType = failureType;
+ this.messageMatcher = Pattern.compile(messagePattern).asMatchPredicate();
+ }
+
+ String metricName() { return metricName; }
+ String failureType() { return failureType; }
+
+ static Optional<SslHandshakeFailure> fromSslHandshakeException(SSLHandshakeException exception) {
+ String message = exception.getMessage();
+ if (message == null || message.isBlank()) return Optional.empty();
+ for (SslHandshakeFailure failure : values()) {
+ if (failure.messageMatcher.test(message)) {
+ return Optional.of(failure);
+ }
+ }
+ return Optional.empty();
+ }
+}
diff --git a/container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/TlsClientAuthenticationEnforcer.java b/container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/TlsClientAuthenticationEnforcer.java
new file mode 100644
index 00000000000..10a6c4702b5
--- /dev/null
+++ b/container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/TlsClientAuthenticationEnforcer.java
@@ -0,0 +1,83 @@
+// Copyright 2019 Oath Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.jdisc.http.server.jetty;
+
+import com.yahoo.jdisc.Response;
+import com.yahoo.jdisc.http.ConnectorConfig;
+import com.yahoo.jdisc.http.servlet.ServletRequest;
+import org.eclipse.jetty.server.Request;
+import org.eclipse.jetty.server.handler.HandlerWrapper;
+
+import javax.servlet.DispatcherType;
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import static com.yahoo.jdisc.http.server.jetty.HttpServletRequestUtils.getConnectorLocalPort;
+
+/**
+ * A Jetty handler that enforces TLS client authentication with configurable white list.
+ *
+ * @author bjorncs
+ */
+class TlsClientAuthenticationEnforcer extends HandlerWrapper {
+
+ private final Map<Integer, List<String>> portToWhitelistedPathsMapping;
+
+ TlsClientAuthenticationEnforcer(List<ConnectorConfig> connectorConfigs) {
+ portToWhitelistedPathsMapping = createWhitelistMapping(connectorConfigs);
+ }
+
+ @Override
+ public void handle(String target, Request request, HttpServletRequest servletRequest, HttpServletResponse servletResponse) throws IOException, ServletException {
+ if (isHttpsRequest(request)
+ && !isRequestToWhitelistedBinding(servletRequest)
+ && !isClientAuthenticated(servletRequest)) {
+ servletResponse.sendError(
+ Response.Status.UNAUTHORIZED,
+ "Client did not present a x509 certificate, " +
+ "or presented a certificate not issued by any of the CA certificates in trust store.");
+ } else {
+ _handler.handle(target, request, servletRequest, servletResponse);
+ }
+ }
+
+ private static Map<Integer, List<String>> createWhitelistMapping(List<ConnectorConfig> connectorConfigs) {
+ var mapping = new HashMap<Integer, List<String>>();
+ for (ConnectorConfig connectorConfig : connectorConfigs) {
+ var enforcerConfig = connectorConfig.tlsClientAuthEnforcer();
+ if (enforcerConfig.enable()) {
+ mapping.put(connectorConfig.listenPort(), enforcerConfig.pathWhitelist());
+ }
+ }
+ return mapping;
+ }
+
+ private boolean isHttpsRequest(Request request) {
+ return request.getDispatcherType() == DispatcherType.REQUEST && request.getScheme().equalsIgnoreCase("https");
+ }
+
+ private boolean isRequestToWhitelistedBinding(HttpServletRequest servletRequest) {
+ int localPort = getConnectorLocalPort(servletRequest);
+ List<String> whiteListedPaths = getWhitelistedPathsForPort(localPort);
+ if (whiteListedPaths == null) {
+ return true; // enforcer not enabled
+ }
+ // Note: Same path definition as HttpRequestFactory.getUri()
+ return whiteListedPaths.contains(servletRequest.getRequestURI());
+ }
+
+ private List<String> getWhitelistedPathsForPort(int localPort) {
+ if (portToWhitelistedPathsMapping.containsKey(0) && portToWhitelistedPathsMapping.size() == 1) {
+ return portToWhitelistedPathsMapping.get(0); // for unit tests which uses 0 for listen port
+ }
+ return portToWhitelistedPathsMapping.get(localPort);
+ }
+
+ private boolean isClientAuthenticated(HttpServletRequest servletRequest) {
+ return servletRequest.getAttribute(ServletRequest.SERVLET_REQUEST_X509CERT) != null;
+ }
+}
diff --git a/container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/UnsupportedFilterInvoker.java b/container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/UnsupportedFilterInvoker.java
new file mode 100644
index 00000000000..ce52bccf52d
--- /dev/null
+++ b/container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/UnsupportedFilterInvoker.java
@@ -0,0 +1,32 @@
+// Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.jdisc.http.server.jetty;
+
+import com.yahoo.jdisc.handler.ResponseHandler;
+import com.yahoo.jdisc.http.filter.RequestFilter;
+import com.yahoo.jdisc.http.filter.ResponseFilter;
+
+import javax.servlet.http.HttpServletResponse;
+import javax.servlet.http.HttpServletRequest;
+import java.net.URI;
+
+/**
+ * @author Tony Vaagenes
+ */
+public class UnsupportedFilterInvoker implements FilterInvoker {
+ @Override
+ public HttpServletRequest invokeRequestFilterChain(RequestFilter requestFilterChain,
+ URI uri,
+ HttpServletRequest httpRequest,
+ ResponseHandler responseHandler) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public void invokeResponseFilterChain(
+ ResponseFilter responseFilterChain,
+ URI uri,
+ HttpServletRequest request,
+ HttpServletResponse response) {
+ throw new UnsupportedOperationException();
+ }
+}
diff --git a/container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/VoidConnectionLog.java b/container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/VoidConnectionLog.java
new file mode 100644
index 00000000000..5d33cc0835e
--- /dev/null
+++ b/container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/VoidConnectionLog.java
@@ -0,0 +1,16 @@
+// Copyright Verizon Media. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+
+package com.yahoo.jdisc.http.server.jetty;
+
+import com.yahoo.container.logging.ConnectionLog;
+import com.yahoo.container.logging.ConnectionLogEntry;
+
+/**
+ * @author mortent
+ */
+public class VoidConnectionLog implements ConnectionLog {
+
+ @Override
+ public void log(ConnectionLogEntry connectionLogEntry) {
+ }
+}
diff --git a/container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/VoidRequestLog.java b/container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/VoidRequestLog.java
new file mode 100644
index 00000000000..9db5ba99115
--- /dev/null
+++ b/container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/VoidRequestLog.java
@@ -0,0 +1,14 @@
+// Copyright Verizon Media. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.jdisc.http.server.jetty;
+
+import com.yahoo.container.logging.RequestLog;
+import com.yahoo.container.logging.RequestLogEntry;
+
+/**
+ * @author bjorncs
+ */
+public class VoidRequestLog implements RequestLog {
+
+ @Override public void log(RequestLogEntry entry) {}
+
+}
diff --git a/container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/package-info.java b/container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/package-info.java
new file mode 100644
index 00000000000..189751aa9c0
--- /dev/null
+++ b/container-core/src/main/java/com/yahoo/jdisc/http/server/jetty/package-info.java
@@ -0,0 +1,3 @@
+// Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+@com.yahoo.osgi.annotation.ExportPackage
+package com.yahoo.jdisc.http.server.jetty;