aboutsummaryrefslogtreecommitdiffstats
path: root/container-core/src
diff options
context:
space:
mode:
Diffstat (limited to 'container-core/src')
-rw-r--r--container-core/src/main/java/com/yahoo/container/logging/AccessLog.java36
-rw-r--r--container-core/src/main/java/com/yahoo/container/logging/AccessLogEntry.java112
-rw-r--r--container-core/src/main/java/com/yahoo/container/logging/AccessLogHandler.java36
-rw-r--r--container-core/src/main/java/com/yahoo/container/logging/CircularArrayAccessLogKeeper.java48
-rw-r--r--container-core/src/main/java/com/yahoo/container/logging/ConnectionLog.java10
-rw-r--r--container-core/src/main/java/com/yahoo/container/logging/ConnectionLogEntry.java225
-rw-r--r--container-core/src/main/java/com/yahoo/container/logging/ConnectionLogHandler.java30
-rw-r--r--container-core/src/main/java/com/yahoo/container/logging/Coverage.java64
-rw-r--r--container-core/src/main/java/com/yahoo/container/logging/FileConnectionLog.java30
-rw-r--r--container-core/src/main/java/com/yahoo/container/logging/FormatUtil.java46
-rw-r--r--container-core/src/main/java/com/yahoo/container/logging/HitCounts.java78
-rw-r--r--container-core/src/main/java/com/yahoo/container/logging/JSONAccessLog.java27
-rw-r--r--container-core/src/main/java/com/yahoo/container/logging/JSONFormatter.java193
-rw-r--r--container-core/src/main/java/com/yahoo/container/logging/JsonConnectionLogWriter.java122
-rw-r--r--container-core/src/main/java/com/yahoo/container/logging/LogFileHandler.java563
-rw-r--r--container-core/src/main/java/com/yahoo/container/logging/LogFormatter.java191
-rw-r--r--container-core/src/main/java/com/yahoo/container/logging/LogWriter.java10
-rw-r--r--container-core/src/main/java/com/yahoo/container/logging/RequestLog.java13
-rw-r--r--container-core/src/main/java/com/yahoo/container/logging/RequestLogEntry.java186
-rw-r--r--container-core/src/main/java/com/yahoo/container/logging/RequestLogHandler.java9
-rw-r--r--container-core/src/main/java/com/yahoo/container/logging/TraceRenderer.java186
-rw-r--r--container-core/src/main/java/com/yahoo/container/logging/VespaAccessLog.java113
-rw-r--r--container-core/src/main/java/com/yahoo/container/logging/package-info.java5
-rw-r--r--container-core/src/main/java/com/yahoo/jdisc/http/CertificateStore.java26
-rw-r--r--container-core/src/main/java/com/yahoo/jdisc/http/Cookie.java250
-rw-r--r--container-core/src/main/java/com/yahoo/jdisc/http/HttpHeaders.java122
-rw-r--r--container-core/src/main/java/com/yahoo/jdisc/http/HttpRequest.java342
-rw-r--r--container-core/src/main/java/com/yahoo/jdisc/http/HttpResponse.java125
-rw-r--r--container-core/src/main/java/com/yahoo/jdisc/http/SecretStore.java23
-rw-r--r--container-core/src/main/java/com/yahoo/jdisc/http/cloud/package-info.java4
-rw-r--r--container-core/src/main/java/com/yahoo/jdisc/http/filter/DiscFilterRequest.java543
-rw-r--r--container-core/src/main/java/com/yahoo/jdisc/http/filter/DiscFilterResponse.java154
-rw-r--r--container-core/src/main/java/com/yahoo/jdisc/http/filter/FilterConfig.java42
-rw-r--r--container-core/src/main/java/com/yahoo/jdisc/http/filter/JDiscCookieWrapper.java79
-rw-r--r--container-core/src/main/java/com/yahoo/jdisc/http/filter/JdiscFilterRequest.java133
-rw-r--r--container-core/src/main/java/com/yahoo/jdisc/http/filter/JdiscFilterResponse.java67
-rw-r--r--container-core/src/main/java/com/yahoo/jdisc/http/filter/RequestFilter.java14
-rw-r--r--container-core/src/main/java/com/yahoo/jdisc/http/filter/RequestFilterBase.java9
-rw-r--r--container-core/src/main/java/com/yahoo/jdisc/http/filter/RequestView.java45
-rw-r--r--container-core/src/main/java/com/yahoo/jdisc/http/filter/ResponseFilter.java14
-rw-r--r--container-core/src/main/java/com/yahoo/jdisc/http/filter/ResponseFilterBase.java9
-rw-r--r--container-core/src/main/java/com/yahoo/jdisc/http/filter/SecurityFilterInvoker.java108
-rw-r--r--container-core/src/main/java/com/yahoo/jdisc/http/filter/SecurityRequestFilter.java13
-rw-r--r--container-core/src/main/java/com/yahoo/jdisc/http/filter/SecurityRequestFilterChain.java77
-rw-r--r--container-core/src/main/java/com/yahoo/jdisc/http/filter/SecurityResponseFilter.java8
-rw-r--r--container-core/src/main/java/com/yahoo/jdisc/http/filter/SecurityResponseFilterChain.java101
-rw-r--r--container-core/src/main/java/com/yahoo/jdisc/http/filter/ServletFilterRequest.java169
-rw-r--r--container-core/src/main/java/com/yahoo/jdisc/http/filter/ServletFilterResponse.java81
-rw-r--r--container-core/src/main/java/com/yahoo/jdisc/http/filter/chain/EmptyRequestFilter.java24
-rw-r--r--container-core/src/main/java/com/yahoo/jdisc/http/filter/chain/EmptyResponseFilter.java24
-rw-r--r--container-core/src/main/java/com/yahoo/jdisc/http/filter/chain/RequestFilterChain.java55
-rw-r--r--container-core/src/main/java/com/yahoo/jdisc/http/filter/chain/ResponseFilterChain.java54
-rw-r--r--container-core/src/main/java/com/yahoo/jdisc/http/filter/chain/ResponseHandlerGuard.java29
-rw-r--r--container-core/src/main/java/com/yahoo/jdisc/http/filter/chain/package-info.java5
-rw-r--r--container-core/src/main/java/com/yahoo/jdisc/http/filter/package-info.java7
-rw-r--r--container-core/src/main/java/com/yahoo/jdisc/http/package-info.java7
-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
-rw-r--r--container-core/src/main/java/com/yahoo/jdisc/http/servlet/ServletOrJdiscHttpRequest.java40
-rw-r--r--container-core/src/main/java/com/yahoo/jdisc/http/servlet/ServletOrJdiscHttpResponse.java23
-rw-r--r--container-core/src/main/java/com/yahoo/jdisc/http/servlet/ServletRequest.java272
-rw-r--r--container-core/src/main/java/com/yahoo/jdisc/http/servlet/ServletResponse.java66
-rw-r--r--container-core/src/main/java/com/yahoo/jdisc/http/servlet/package-info.java5
-rw-r--r--container-core/src/main/java/com/yahoo/jdisc/http/ssl/SslContextFactoryProvider.java21
-rw-r--r--container-core/src/main/java/com/yahoo/jdisc/http/ssl/impl/ConfiguredSslContextFactoryProvider.java138
-rw-r--r--container-core/src/main/java/com/yahoo/jdisc/http/ssl/impl/DefaultSslContextFactoryProvider.java79
-rw-r--r--container-core/src/main/java/com/yahoo/jdisc/http/ssl/impl/JDiscSslContextFactory.java37
-rw-r--r--container-core/src/main/java/com/yahoo/jdisc/http/ssl/impl/SslContextFactoryUtils.java32
-rw-r--r--container-core/src/main/java/com/yahoo/jdisc/http/ssl/impl/TlsContextBasedProvider.java42
-rw-r--r--container-core/src/main/java/com/yahoo/jdisc/http/ssl/impl/package-info.java8
-rw-r--r--container-core/src/main/java/com/yahoo/jdisc/http/ssl/package-info.java10
-rw-r--r--container-core/src/main/resources/configdefinitions/container.core.access-log.def23
-rw-r--r--container-core/src/main/resources/configdefinitions/container.logging.connection-log.def11
-rw-r--r--container-core/src/main/resources/configdefinitions/jdisc.http.client.jdisc.http.client.http-client.def36
-rw-r--r--container-core/src/main/resources/configdefinitions/jdisc.http.jdisc.http.connector.def127
-rw-r--r--container-core/src/main/resources/configdefinitions/jdisc.http.jdisc.http.server.def67
-rw-r--r--container-core/src/main/resources/configdefinitions/jdisc.http.jdisc.http.servlet-paths.def5
-rw-r--r--container-core/src/test/java/com/yahoo/container/logging/CircularArrayAccessLogKeeperTest.java42
-rw-r--r--container-core/src/test/java/com/yahoo/container/logging/JSONLogTestCase.java295
-rw-r--r--container-core/src/test/java/com/yahoo/container/logging/JsonConnectionLogWriterTest.java45
-rw-r--r--container-core/src/test/java/com/yahoo/container/logging/LogFileHandlerTestCase.java208
-rw-r--r--container-core/src/test/java/com/yahoo/container/logging/test/LogFormatterTestCase.java27
-rw-r--r--container-core/src/test/java/com/yahoo/jdisc/http/CookieTestCase.java238
-rw-r--r--container-core/src/test/java/com/yahoo/jdisc/http/HttpHeadersTestCase.java17
-rw-r--r--container-core/src/test/java/com/yahoo/jdisc/http/HttpRequestTestCase.java206
-rw-r--r--container-core/src/test/java/com/yahoo/jdisc/http/HttpResponseTestCase.java139
-rw-r--r--container-core/src/test/java/com/yahoo/jdisc/http/filter/DiscFilterRequestTest.java357
-rw-r--r--container-core/src/test/java/com/yahoo/jdisc/http/filter/DiscFilterResponseTest.java113
-rw-r--r--container-core/src/test/java/com/yahoo/jdisc/http/filter/EmptyRequestFilterTestCase.java48
-rw-r--r--container-core/src/test/java/com/yahoo/jdisc/http/filter/EmptyResponseFilterTestCase.java45
-rw-r--r--container-core/src/test/java/com/yahoo/jdisc/http/filter/JDiscCookieWrapperTest.java29
-rw-r--r--container-core/src/test/java/com/yahoo/jdisc/http/filter/RequestViewImplTest.java57
-rw-r--r--container-core/src/test/java/com/yahoo/jdisc/http/filter/ResponseHeaderFilter.java25
-rw-r--r--container-core/src/test/java/com/yahoo/jdisc/http/filter/SecurityRequestFilterChainTest.java145
-rw-r--r--container-core/src/test/java/com/yahoo/jdisc/http/filter/SecurityResponseFilterChainTest.java74
-rw-r--r--container-core/src/test/java/com/yahoo/jdisc/http/filter/ServletFilterRequestTest.java179
-rw-r--r--container-core/src/test/java/com/yahoo/jdisc/http/filter/ServletFilterResponseTest.java87
-rw-r--r--container-core/src/test/java/com/yahoo/jdisc/http/guiceModules/ConnectorFactoryRegistryModule.java54
-rw-r--r--container-core/src/test/java/com/yahoo/jdisc/http/guiceModules/ServletModule.java24
-rw-r--r--container-core/src/test/java/com/yahoo/jdisc/http/server/jetty/AccessLogRequestLogTest.java156
-rw-r--r--container-core/src/test/java/com/yahoo/jdisc/http/server/jetty/BlockingQueueRequestLog.java24
-rw-r--r--container-core/src/test/java/com/yahoo/jdisc/http/server/jetty/ConnectionThrottlerTest.java78
-rw-r--r--container-core/src/test/java/com/yahoo/jdisc/http/server/jetty/ConnectorFactoryTest.java83
-rw-r--r--container-core/src/test/java/com/yahoo/jdisc/http/server/jetty/ErrorResponseContentCreatorTest.java44
-rw-r--r--container-core/src/test/java/com/yahoo/jdisc/http/server/jetty/ExceptionWrapperTest.java51
-rw-r--r--container-core/src/test/java/com/yahoo/jdisc/http/server/jetty/FilterTestCase.java667
-rw-r--r--container-core/src/test/java/com/yahoo/jdisc/http/server/jetty/HttpRequestFactoryTest.java204
-rw-r--r--container-core/src/test/java/com/yahoo/jdisc/http/server/jetty/HttpResponseStatisticsCollectorTest.java221
-rw-r--r--container-core/src/test/java/com/yahoo/jdisc/http/server/jetty/HttpServerConformanceTest.java847
-rw-r--r--container-core/src/test/java/com/yahoo/jdisc/http/server/jetty/HttpServerTest.java1201
-rw-r--r--container-core/src/test/java/com/yahoo/jdisc/http/server/jetty/InMemoryConnectionLog.java25
-rw-r--r--container-core/src/test/java/com/yahoo/jdisc/http/server/jetty/InMemoryRequestLog.java20
-rw-r--r--container-core/src/test/java/com/yahoo/jdisc/http/server/jetty/JDiscHttpServletTest.java80
-rw-r--r--container-core/src/test/java/com/yahoo/jdisc/http/server/jetty/MetricConsumerMock.java28
-rw-r--r--container-core/src/test/java/com/yahoo/jdisc/http/server/jetty/SimpleHttpClient.java202
-rw-r--r--container-core/src/test/java/com/yahoo/jdisc/http/server/jetty/SslHandshakeFailedListenerTest.java42
-rw-r--r--container-core/src/test/java/com/yahoo/jdisc/http/server/jetty/TestDriver.java78
-rw-r--r--container-core/src/test/java/com/yahoo/jdisc/http/server/jetty/TestDrivers.java94
-rw-r--r--container-core/src/test/java/com/yahoo/jdisc/http/server/jetty/servlet/JDiscFilterForServletTest.java166
-rw-r--r--container-core/src/test/java/com/yahoo/jdisc/http/server/jetty/servlet/ServletAccessLoggingTest.java64
-rw-r--r--container-core/src/test/java/com/yahoo/jdisc/http/server/jetty/servlet/ServletTestBase.java132
-rw-r--r--container-core/src/test/java/com/yahoo/jdisc/http/ssl/impl/TlsContextBasedProviderTest.java71
164 files changed, 18903 insertions, 0 deletions
diff --git a/container-core/src/main/java/com/yahoo/container/logging/AccessLog.java b/container-core/src/main/java/com/yahoo/container/logging/AccessLog.java
new file mode 100644
index 00000000000..2d46c53bca7
--- /dev/null
+++ b/container-core/src/main/java/com/yahoo/container/logging/AccessLog.java
@@ -0,0 +1,36 @@
+// Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.container.logging;
+
+
+import com.google.inject.Inject;
+import com.yahoo.component.provider.ComponentRegistry;
+
+/**
+ * Logs to all the configured access logs.
+ *
+ * @author Tony Vaagenes
+ * @author bjorncs
+ */
+public class AccessLog implements RequestLog {
+
+ public static final AccessLog NONE_INSTANCE = new AccessLog(new ComponentRegistry<>());
+
+ private final ComponentRegistry<RequestLogHandler> implementers;
+
+ @Inject
+ public AccessLog(ComponentRegistry<RequestLogHandler> implementers) {
+ this.implementers = implementers;
+ }
+
+ public static AccessLog voidAccessLog() {
+ return NONE_INSTANCE;
+ }
+
+ @Override
+ public void log(RequestLogEntry entry) {
+ for (RequestLogHandler handler: implementers.allComponents()) {
+ handler.log(entry);
+ }
+ }
+
+}
diff --git a/container-core/src/main/java/com/yahoo/container/logging/AccessLogEntry.java b/container-core/src/main/java/com/yahoo/container/logging/AccessLogEntry.java
new file mode 100644
index 00000000000..42285fb85bb
--- /dev/null
+++ b/container-core/src/main/java/com/yahoo/container/logging/AccessLogEntry.java
@@ -0,0 +1,112 @@
+// Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.container.logging;
+
+import com.yahoo.collections.ListMap;
+import com.yahoo.yolean.trace.TraceNode;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.function.Function;
+
+import static java.util.stream.Collectors.toMap;
+
+/**
+ * <p>Information to be logged in the access log.</p>
+ *
+ * <p>This class contains the union of all information that can be
+ * logged with all the supported access log formats.</p>
+ *
+ * <p>The add methods can be called multiple times,
+ * but the parameters should be different for each
+ * invocation of the same method.</p>
+ *
+ * This class is thread-safe.
+ *
+ * @author Tony Vaagenes
+ * @author bakksjo
+ * @author bjorncs
+ */
+public class AccessLogEntry {
+
+ private final Object monitor = new Object();
+
+ private HitCounts hitCounts;
+ private TraceNode traceNode;
+ private ListMap<String,String> keyValues=null;
+
+ public void setHitCounts(final HitCounts hitCounts) {
+ synchronized (monitor) {
+ requireNull(this.hitCounts);
+ this.hitCounts = hitCounts;
+ }
+ }
+
+ public HitCounts getHitCounts() {
+ synchronized (monitor) {
+ return hitCounts;
+ }
+ }
+
+ public void addKeyValue(String key,String value) {
+ synchronized (monitor) {
+ if (keyValues == null) {
+ keyValues = new ListMap<>();
+ }
+ keyValues.put(key,value);
+ }
+ }
+
+ public Map<String, List<String>> getKeyValues() {
+ synchronized (monitor) {
+ if (keyValues == null) {
+ return null;
+ }
+
+ final Map<String, List<String>> newMapWithImmutableValues = mapValues(
+ keyValues.entrySet(),
+ valueList -> Collections.unmodifiableList(new ArrayList<>(valueList)));
+ return Collections.unmodifiableMap(newMapWithImmutableValues);
+ }
+ }
+
+ private static <K, V1, V2> Map<K, V2> mapValues(
+ final Set<Map.Entry<K, V1>> entrySet,
+ final Function<V1, V2> valueConverter) {
+ return entrySet.stream()
+ .collect(toMap(
+ entry -> entry.getKey(),
+ entry -> valueConverter.apply(entry.getValue())));
+ }
+
+ public void setTrace(TraceNode traceNode) {
+ synchronized (monitor) {
+ requireNull(this.traceNode);
+ this.traceNode = traceNode;
+ }
+ }
+
+ public TraceNode getTrace() {
+ synchronized (monitor) {
+ return traceNode;
+ }
+ }
+
+ @Override
+ public String toString() {
+ return "AccessLogEntry{" +
+ "hitCounts=" + hitCounts +
+ ", traceNode=" + traceNode +
+ ", keyValues=" + keyValues +
+ '}';
+ }
+
+ private static void requireNull(final Object value) {
+ if (value != null) {
+ throw new IllegalStateException("Attempt to overwrite field that has been assigned. Value: " + value);
+ }
+ }
+
+}
diff --git a/container-core/src/main/java/com/yahoo/container/logging/AccessLogHandler.java b/container-core/src/main/java/com/yahoo/container/logging/AccessLogHandler.java
new file mode 100644
index 00000000000..89aab1513ee
--- /dev/null
+++ b/container-core/src/main/java/com/yahoo/container/logging/AccessLogHandler.java
@@ -0,0 +1,36 @@
+// Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.container.logging;
+
+import com.yahoo.container.core.AccessLogConfig;
+
+/**
+ * @author Bjorn Borud
+ */
+class AccessLogHandler {
+
+ private final LogFileHandler<RequestLogEntry> logFileHandler;
+
+ AccessLogHandler(AccessLogConfig.FileHandler config, LogWriter<RequestLogEntry> logWriter) {
+ logFileHandler = new LogFileHandler<>(
+ toCompression(config), config.pattern(), config.rotation(),
+ config.symlink(), config.queueSize(), "request-logger", logWriter);
+ }
+
+ public void log(RequestLogEntry entry) {
+ logFileHandler.publish(entry);
+ }
+
+ private LogFileHandler.Compression toCompression(AccessLogConfig.FileHandler config) {
+ if (!config.compressOnRotation()) return LogFileHandler.Compression.NONE;
+ switch (config.compressionFormat()) {
+ case ZSTD: return LogFileHandler.Compression.ZSTD;
+ case GZIP: return LogFileHandler.Compression.GZIP;
+ default: throw new IllegalArgumentException(config.compressionFormat().toString());
+ }
+ }
+
+ void shutdown() {
+ logFileHandler.close();
+ logFileHandler.shutdown();
+ }
+}
diff --git a/container-core/src/main/java/com/yahoo/container/logging/CircularArrayAccessLogKeeper.java b/container-core/src/main/java/com/yahoo/container/logging/CircularArrayAccessLogKeeper.java
new file mode 100644
index 00000000000..dc749c71613
--- /dev/null
+++ b/container-core/src/main/java/com/yahoo/container/logging/CircularArrayAccessLogKeeper.java
@@ -0,0 +1,48 @@
+// Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.container.logging;
+
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.Deque;
+import java.util.List;
+
+/**
+ * This class keeps some information from the access log from the requests in memory. It is thread-safe.
+ *
+ * @author dybis
+ */
+public class CircularArrayAccessLogKeeper {
+ public static final int SIZE = 1000;
+ private final Deque<String> uris = new ArrayDeque<>(SIZE);
+ private final Object monitor = new Object();
+
+ /**
+ * This class is intended to be used with injection so it can be shared between other classes.
+ */
+ public CircularArrayAccessLogKeeper() {}
+
+ /**
+ * Creates a list of Uris.
+ * @return URIs as string
+ */
+ public List<String> getUris() {
+ final List<String> uriList = new ArrayList<>();
+ synchronized (monitor) {
+ uris.iterator().forEachRemaining(uri -> uriList.add(uri));
+ }
+ return uriList;
+ }
+
+ /**
+ * Add a new URI. It might remove an old entry to make space for new entry.
+ * @param uri uri as string
+ */
+ public void addUri(String uri) {
+ synchronized (monitor) {
+ if (uris.size() == SIZE) {
+ uris.pop();
+ }
+ uris.add(uri);
+ }
+ }
+}
diff --git a/container-core/src/main/java/com/yahoo/container/logging/ConnectionLog.java b/container-core/src/main/java/com/yahoo/container/logging/ConnectionLog.java
new file mode 100644
index 00000000000..310231a4a1e
--- /dev/null
+++ b/container-core/src/main/java/com/yahoo/container/logging/ConnectionLog.java
@@ -0,0 +1,10 @@
+// Copyright Verizon Media. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+
+package com.yahoo.container.logging;
+
+/**
+ * @author mortent
+ */
+public interface ConnectionLog {
+ void log(ConnectionLogEntry connectionLogEntry);
+}
diff --git a/container-core/src/main/java/com/yahoo/container/logging/ConnectionLogEntry.java b/container-core/src/main/java/com/yahoo/container/logging/ConnectionLogEntry.java
new file mode 100644
index 00000000000..6afe3b74329
--- /dev/null
+++ b/container-core/src/main/java/com/yahoo/container/logging/ConnectionLogEntry.java
@@ -0,0 +1,225 @@
+// Copyright Verizon Media. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+
+package com.yahoo.container.logging;
+
+import java.time.Instant;
+import java.util.List;
+import java.util.Optional;
+import java.util.UUID;
+
+/**
+ * @author mortent
+ */
+public class ConnectionLogEntry {
+
+ private final UUID id;
+ private final Instant timestamp;
+ private final Double durationSeconds;
+ private final String peerAddress;
+ private final Integer peerPort;
+ private final String localAddress;
+ private final Integer localPort;
+ private final String remoteAddress;
+ private final Integer remotePort;
+ private final Long httpBytesReceived;
+ private final Long httpBytesSent;
+ private final Long requests;
+ private final Long responses;
+ private final String sslSessionId;
+ private final String sslProtocol;
+ private final String sslCipherSuite;
+ private final String sslPeerSubject;
+ private final Instant sslPeerNotBefore;
+ private final Instant sslPeerNotAfter;
+ private final String sslSniServerName;
+ private final SslHandshakeFailure sslHandshakeFailure;
+
+
+ private ConnectionLogEntry(Builder builder) {
+ this.id = builder.id;
+ this.timestamp = builder.timestamp;
+ this.durationSeconds = builder.durationSeconds;
+ this.peerAddress = builder.peerAddress;
+ this.peerPort = builder.peerPort;
+ this.localAddress = builder.localAddress;
+ this.localPort = builder.localPort;
+ this.remoteAddress = builder.remoteAddress;
+ this.remotePort = builder.remotePort;
+ this.httpBytesReceived = builder.httpBytesReceived;
+ this.httpBytesSent = builder.httpBytesSent;
+ this.requests = builder.requests;
+ this.responses = builder.responses;
+ this.sslSessionId = builder.sslSessionId;
+ this.sslProtocol = builder.sslProtocol;
+ this.sslCipherSuite = builder.sslCipherSuite;
+ this.sslPeerSubject = builder.sslPeerSubject;
+ this.sslPeerNotBefore = builder.sslPeerNotBefore;
+ this.sslPeerNotAfter = builder.sslPeerNotAfter;
+ this.sslSniServerName = builder.sslSniServerName;
+ this.sslHandshakeFailure = builder.sslHandshakeFailure;
+ }
+
+ public static Builder builder(UUID id, Instant timestamp) {
+ return new Builder(id, timestamp);
+ }
+
+ public String id() { return id.toString(); }
+ public Instant timestamp() { return timestamp; }
+ public Optional<Double> durationSeconds() { return Optional.ofNullable(durationSeconds); }
+ public Optional<String> peerAddress() { return Optional.ofNullable(peerAddress); }
+ public Optional<Integer> peerPort() { return Optional.ofNullable(peerPort); }
+ public Optional<String> localAddress() { return Optional.ofNullable(localAddress); }
+ public Optional<Integer> localPort() { return Optional.ofNullable(localPort); }
+ public Optional<String> remoteAddress() { return Optional.ofNullable(remoteAddress); }
+ public Optional<Integer> remotePort() { return Optional.ofNullable(remotePort); }
+ public Optional<Long> httpBytesReceived() { return Optional.ofNullable(httpBytesReceived); }
+ public Optional<Long> httpBytesSent() { return Optional.ofNullable(httpBytesSent); }
+ public Optional<Long> requests() { return Optional.ofNullable(requests); }
+ public Optional<Long> responses() { return Optional.ofNullable(responses); }
+ public Optional<String> sslSessionId() { return Optional.ofNullable(sslSessionId); }
+ public Optional<String> sslProtocol() { return Optional.ofNullable(sslProtocol); }
+ public Optional<String> sslCipherSuite() { return Optional.ofNullable(sslCipherSuite); }
+ public Optional<String> sslPeerSubject() { return Optional.ofNullable(sslPeerSubject); }
+ public Optional<Instant> sslPeerNotBefore() { return Optional.ofNullable(sslPeerNotBefore); }
+ public Optional<Instant> sslPeerNotAfter() { return Optional.ofNullable(sslPeerNotAfter); }
+ public Optional<String> sslSniServerName() { return Optional.ofNullable(sslSniServerName); }
+ public Optional<SslHandshakeFailure> sslHandshakeFailure() { return Optional.ofNullable(sslHandshakeFailure); }
+
+ public static class SslHandshakeFailure {
+ private final String type;
+ private final List<ExceptionEntry> exceptionChain;
+
+ public SslHandshakeFailure(String type, List<ExceptionEntry> exceptionChain) {
+ this.type = type;
+ this.exceptionChain = List.copyOf(exceptionChain);
+ }
+
+ public String type() { return type; }
+ public List<ExceptionEntry> exceptionChain() { return exceptionChain; }
+
+ public static class ExceptionEntry {
+ private final String name;
+ private final String message;
+
+ public ExceptionEntry(String name, String message) {
+ this.name = name;
+ this.message = message;
+ }
+
+ public String name() { return name; }
+ public String message() { return message; }
+ }
+ }
+
+ public static class Builder {
+ private final UUID id;
+ private final Instant timestamp;
+ private Double durationSeconds;
+ private String peerAddress;
+ private Integer peerPort;
+ private String localAddress;
+ private Integer localPort;
+ private String remoteAddress;
+ private Integer remotePort;
+ private Long httpBytesReceived;
+ private Long httpBytesSent;
+ private Long requests;
+ private Long responses;
+ private String sslSessionId;
+ private String sslProtocol;
+ private String sslCipherSuite;
+ private String sslPeerSubject;
+ private Instant sslPeerNotBefore;
+ private Instant sslPeerNotAfter;
+ private String sslSniServerName;
+ private SslHandshakeFailure sslHandshakeFailure;
+
+
+ Builder(UUID id, Instant timestamp) {
+ this.id = id;
+ this.timestamp = timestamp;
+ }
+
+ public Builder withDuration(double durationSeconds) {
+ this.durationSeconds = durationSeconds;
+ return this;
+ }
+
+ public Builder withPeerAddress(String peerAddress) {
+ this.peerAddress = peerAddress;
+ return this;
+ }
+ public Builder withPeerPort(int peerPort) {
+ this.peerPort = peerPort;
+ return this;
+ }
+ public Builder withLocalAddress(String localAddress) {
+ this.localAddress = localAddress;
+ return this;
+ }
+ public Builder withLocalPort(int localPort) {
+ this.localPort = localPort;
+ return this;
+ }
+ public Builder withRemoteAddress(String remoteAddress) {
+ this.remoteAddress = remoteAddress;
+ return this;
+ }
+ public Builder withRemotePort(int remotePort) {
+ this.remotePort = remotePort;
+ return this;
+ }
+ public Builder withHttpBytesReceived(long bytesReceived) {
+ this.httpBytesReceived = bytesReceived;
+ return this;
+ }
+ public Builder withHttpBytesSent(long bytesSent) {
+ this.httpBytesSent = bytesSent;
+ return this;
+ }
+ public Builder withRequests(long requests) {
+ this.requests = requests;
+ return this;
+ }
+ public Builder withResponses(long responses) {
+ this.responses = responses;
+ return this;
+ }
+ public Builder withSslSessionId(String sslSessionId) {
+ this.sslSessionId = sslSessionId;
+ return this;
+ }
+ public Builder withSslProtocol(String sslProtocol) {
+ this.sslProtocol = sslProtocol;
+ return this;
+ }
+ public Builder withSslCipherSuite(String sslCipherSuite) {
+ this.sslCipherSuite = sslCipherSuite;
+ return this;
+ }
+ public Builder withSslPeerSubject(String sslPeerSubject) {
+ this.sslPeerSubject = sslPeerSubject;
+ return this;
+ }
+ public Builder withSslPeerNotBefore(Instant sslPeerNotBefore) {
+ this.sslPeerNotBefore = sslPeerNotBefore;
+ return this;
+ }
+ public Builder withSslPeerNotAfter(Instant sslPeerNotAfter) {
+ this.sslPeerNotAfter = sslPeerNotAfter;
+ return this;
+ }
+ public Builder withSslSniServerName(String sslSniServerName) {
+ this.sslSniServerName = sslSniServerName;
+ return this;
+ }
+ public Builder withSslHandshakeFailure(SslHandshakeFailure sslHandshakeFailure) {
+ this.sslHandshakeFailure = sslHandshakeFailure;
+ return this;
+ }
+
+ public ConnectionLogEntry build(){
+ return new ConnectionLogEntry(this);
+ }
+ }
+}
diff --git a/container-core/src/main/java/com/yahoo/container/logging/ConnectionLogHandler.java b/container-core/src/main/java/com/yahoo/container/logging/ConnectionLogHandler.java
new file mode 100644
index 00000000000..7a0e8aca95e
--- /dev/null
+++ b/container-core/src/main/java/com/yahoo/container/logging/ConnectionLogHandler.java
@@ -0,0 +1,30 @@
+// Copyright Verizon Media. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+
+package com.yahoo.container.logging;
+
+/**
+ * @author mortent
+ */
+class ConnectionLogHandler {
+ private final LogFileHandler<ConnectionLogEntry> logFileHandler;
+
+ public ConnectionLogHandler(String logDirectoryName, String clusterName, int queueSize, LogWriter<ConnectionLogEntry> logWriter) {
+ logFileHandler = new LogFileHandler<>(
+ LogFileHandler.Compression.ZSTD,
+ String.format("logs/vespa/%s/ConnectionLog.%s.%s", logDirectoryName, clusterName, "%Y%m%d%H%M%S"),
+ "0 60 ...",
+ String.format("ConnectionLog.%s", clusterName),
+ queueSize,
+ "connection-logger",
+ logWriter);
+ }
+
+ public void log(ConnectionLogEntry entry) {
+ logFileHandler.publish(entry);
+ }
+
+ public void shutdown() {
+ logFileHandler.close();
+ logFileHandler.shutdown();
+ }
+}
diff --git a/container-core/src/main/java/com/yahoo/container/logging/Coverage.java b/container-core/src/main/java/com/yahoo/container/logging/Coverage.java
new file mode 100644
index 00000000000..9d122b90641
--- /dev/null
+++ b/container-core/src/main/java/com/yahoo/container/logging/Coverage.java
@@ -0,0 +1,64 @@
+// Copyright 2020 Oath Inc. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.container.logging;
+
+/**
+ * Carry information about how the query covered the document corpus.
+ */
+public class Coverage {
+ private final long docs;
+ private final long active;
+ private final long soonActive;
+ private final int degradedReason;
+ private final static int DEGRADED_BY_MATCH_PHASE = 1;
+ private final static int DEGRADED_BY_TIMEOUT = 2;
+ private final static int DEGRADED_BY_ADAPTIVE_TIMEOUT = 4;
+ public Coverage(long docs, long active, long soonActive, int degradedReason) {
+ this.docs = docs;
+ this.active = active;
+ this.soonActive = soonActive;
+ this.degradedReason = degradedReason;
+ }
+
+ public long getDocs() {
+ return docs;
+ }
+
+ public long getActive() {
+ return active;
+ }
+
+ public static int toDegradation(boolean degradeByMatchPhase, boolean degradedByTimeout, boolean degradedByAdaptiveTimeout) {
+ int v = 0;
+ if (degradeByMatchPhase) {
+ v |= DEGRADED_BY_MATCH_PHASE;
+ }
+ if (degradedByTimeout) {
+ v |= DEGRADED_BY_TIMEOUT;
+ }
+ if (degradedByAdaptiveTimeout) {
+ v |= DEGRADED_BY_ADAPTIVE_TIMEOUT;
+ }
+ return v;
+ }
+
+ public long getSoonActive() { return soonActive; }
+
+ public boolean isDegraded() { return (degradedReason != 0) || isDegradedByNonIdealState(); }
+ public boolean isDegradedByMatchPhase() { return (degradedReason & DEGRADED_BY_MATCH_PHASE) != 0; }
+ public boolean isDegradedByTimeout() { return (degradedReason & DEGRADED_BY_TIMEOUT) != 0; }
+ public boolean isDegradedByAdapativeTimeout() { return (degradedReason & DEGRADED_BY_ADAPTIVE_TIMEOUT) != 0; }
+ public boolean isDegradedByNonIdealState() { return (degradedReason == 0) && (getResultPercentage() != 100);}
+
+ /**
+ * An int between 0 (inclusive) and 100 (inclusive) representing how many
+ * percent coverage the result sets this Coverage instance contains information
+ * about had.
+ */
+ public int getResultPercentage() {
+ if (docs < active) {
+ return (int) Math.round(docs * 100.0d / active);
+ }
+ return 100;
+ }
+
+}
diff --git a/container-core/src/main/java/com/yahoo/container/logging/FileConnectionLog.java b/container-core/src/main/java/com/yahoo/container/logging/FileConnectionLog.java
new file mode 100644
index 00000000000..7432c313286
--- /dev/null
+++ b/container-core/src/main/java/com/yahoo/container/logging/FileConnectionLog.java
@@ -0,0 +1,30 @@
+// Copyright Verizon Media. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+
+package com.yahoo.container.logging;
+
+import com.google.inject.Inject;
+import com.yahoo.component.AbstractComponent;
+
+/**
+ * @author mortent
+ */
+public class FileConnectionLog extends AbstractComponent implements ConnectionLog {
+
+ private final ConnectionLogHandler logHandler;
+
+ @Inject
+ public FileConnectionLog(ConnectionLogConfig config) {
+ logHandler = new ConnectionLogHandler(config.logDirectoryName(), config.cluster(), config.queueSize(), new JsonConnectionLogWriter());
+ }
+
+ @Override
+ public void log(ConnectionLogEntry connectionLogEntry) {
+ logHandler.log(connectionLogEntry);
+ }
+
+ @Override
+ public void deconstruct() {
+ logHandler.shutdown();
+ }
+
+} \ No newline at end of file
diff --git a/container-core/src/main/java/com/yahoo/container/logging/FormatUtil.java b/container-core/src/main/java/com/yahoo/container/logging/FormatUtil.java
new file mode 100644
index 00000000000..ee780ad2a83
--- /dev/null
+++ b/container-core/src/main/java/com/yahoo/container/logging/FormatUtil.java
@@ -0,0 +1,46 @@
+// Copyright Verizon Media. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.container.logging;
+
+import com.fasterxml.jackson.core.JsonGenerator;
+
+import java.io.IOException;
+import java.time.Duration;
+import java.time.Instant;
+
+/**
+ * @author bjorncs
+ */
+class FormatUtil {
+
+ private FormatUtil() {}
+
+ static void writeSecondsField(JsonGenerator generator, String fieldName, Instant instant) throws IOException {
+ writeSecondsField(generator, fieldName, instant.toEpochMilli());
+ }
+
+ static void writeSecondsField(JsonGenerator generator, String fieldName, Duration duration) throws IOException {
+ writeSecondsField(generator, fieldName, duration.toMillis());
+ }
+
+ static void writeSecondsField(JsonGenerator generator, String fieldName, double seconds) throws IOException {
+ writeSecondsField(generator, fieldName, (long)(seconds * 1000));
+ }
+
+ static void writeSecondsField(JsonGenerator generator, String fieldName, long milliseconds) throws IOException {
+ generator.writeFieldName(fieldName);
+ generator.writeRawValue(toSecondsString(milliseconds));
+ }
+
+ /** @return a string with number of seconds with 3 decimals */
+ static String toSecondsString(long milliseconds) {
+ StringBuilder builder = new StringBuilder().append(milliseconds / 1000L).append('.');
+ long decimals = milliseconds % 1000;
+ if (decimals < 100) {
+ builder.append('0');
+ if (decimals < 10) {
+ builder.append('0');
+ }
+ }
+ return builder.append(decimals).toString();
+ }
+}
diff --git a/container-core/src/main/java/com/yahoo/container/logging/HitCounts.java b/container-core/src/main/java/com/yahoo/container/logging/HitCounts.java
new file mode 100644
index 00000000000..fed12281962
--- /dev/null
+++ b/container-core/src/main/java/com/yahoo/container/logging/HitCounts.java
@@ -0,0 +1,78 @@
+// Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.container.logging;
+
+/**
+ * A wrapper for hit counts, modelled after a search system.
+ * Advanced database searches and similar could use these
+ * structures as well.
+ *
+ * @author <a href="mailto:steinar@yahoo-inc.com">Steinar Knutsen</a>
+ */
+public class HitCounts {
+
+ // see the javadoc for the accessors for short comments on each field
+ private final int retrievedHits;
+ private final int summaryCount;
+ private final long totalHitCount;
+ private final int requestedHits;
+ private final int requestedOffset;
+ private final Coverage coverage;
+
+ HitCounts(int retrievedHits, int summaryCount, long totalHitCount, int requestedHits, int requestedOffset) {
+ this(retrievedHits, summaryCount, totalHitCount, requestedHits, requestedOffset,
+ new Coverage(1,1,1,0));
+ }
+
+ public HitCounts(int retrievedHits, int summaryCount, long totalHitCount,
+ int requestedHits, int requestedOffset, Coverage coverage)
+ {
+
+ this.retrievedHits = retrievedHits;
+ this.summaryCount = summaryCount;
+ this.totalHitCount = totalHitCount;
+ this.requestedHits = requestedHits;
+ this.requestedOffset = requestedOffset;
+ this.coverage = coverage;
+ }
+
+ /**
+ * The number of hits returned by the server.
+ * Compare to getRequestedHits().
+ */
+ public int getRetrievedHitCount() {
+ return retrievedHits;
+ }
+
+ /**
+ * The number of hit summaries ("document contents") fetched.
+ */
+ public int getSummaryCount() {
+ return summaryCount;
+ }
+
+ /**
+ * The total number of matching hits
+ * for the request.
+ */
+ public long getTotalHitCount() {
+ return totalHitCount;
+ }
+
+ /**
+ * The number of hits requested by the user.
+ * Compare to getRetrievedHitCount().
+ */
+ public int getRequestedHits() {
+ return requestedHits;
+ }
+
+ /**
+ * The user requested offset into the linear mapping of the result space.
+ */
+ public int getRequestedOffset() {
+ return requestedOffset;
+ }
+
+ public Coverage getCoverage() { return coverage; }
+
+}
diff --git a/container-core/src/main/java/com/yahoo/container/logging/JSONAccessLog.java b/container-core/src/main/java/com/yahoo/container/logging/JSONAccessLog.java
new file mode 100644
index 00000000000..ece9d0d2c4a
--- /dev/null
+++ b/container-core/src/main/java/com/yahoo/container/logging/JSONAccessLog.java
@@ -0,0 +1,27 @@
+// Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.container.logging;
+
+import com.yahoo.component.AbstractComponent;
+import com.yahoo.container.core.AccessLogConfig;
+
+/**
+ * Log a message in Vespa JSON access log format.
+ *
+ * @author frodelu
+ * @author Tony Vaagenes
+ */
+public final class JSONAccessLog extends AbstractComponent implements RequestLogHandler {
+
+ private final AccessLogHandler logHandler;
+
+ public JSONAccessLog(AccessLogConfig config) {
+ logHandler = new AccessLogHandler(config.fileHandler(), new JSONFormatter());
+ }
+
+ @Override
+ public void log(RequestLogEntry entry) {
+ logHandler.log(entry);
+ }
+
+ @Override public void deconstruct() { logHandler.shutdown(); }
+}
diff --git a/container-core/src/main/java/com/yahoo/container/logging/JSONFormatter.java b/container-core/src/main/java/com/yahoo/container/logging/JSONFormatter.java
new file mode 100644
index 00000000000..680ee5acbd9
--- /dev/null
+++ b/container-core/src/main/java/com/yahoo/container/logging/JSONFormatter.java
@@ -0,0 +1,193 @@
+// Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.container.logging;
+
+import com.fasterxml.jackson.core.JsonEncoding;
+import com.fasterxml.jackson.core.JsonFactory;
+import com.fasterxml.jackson.core.JsonGenerator;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.yahoo.yolean.trace.TraceNode;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.security.Principal;
+import java.util.Collection;
+import java.util.Objects;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+/**
+ * Formatting of an {@link AccessLogEntry} in the Vespa JSON access log format.
+ *
+ * @author frodelu
+ */
+public class JSONFormatter implements LogWriter<RequestLogEntry> {
+ private static final String COVERAGE = "coverage";
+ private static final String COVERAGE_COVERAGE = "coverage";
+ private static final String COVERAGE_DOCUMENTS = "documents";
+ private static final String COVERAGE_DEGRADE = "degraded";
+ private static final String COVERAGE_DEGRADE_MATCHPHASE = "match-phase";
+ private static final String COVERAGE_DEGRADE_TIMEOUT = "timeout";
+ private static final String COVERAGE_DEGRADE_ADAPTIVE_TIMEOUT = "adaptive-timeout";
+ private static final String COVERAGE_DEGRADED_NON_IDEAL_STATE = "non-ideal-state";
+
+ private final JsonFactory generatorFactory;
+
+ private static Logger logger = Logger.getLogger(JSONFormatter.class.getName());
+
+ public JSONFormatter() {
+ generatorFactory = new JsonFactory(new ObjectMapper());
+ }
+
+ @Override
+ public void write(RequestLogEntry entry, OutputStream outputStream) throws IOException {
+ try (JsonGenerator generator = createJsonGenerator(outputStream)){
+ generator.writeStartObject();
+ String peerAddress = entry.peerAddress().get();
+ generator.writeStringField("ip", peerAddress);
+ long time = entry.timestamp().get().toEpochMilli();
+ FormatUtil.writeSecondsField(generator, "time", time);
+ FormatUtil.writeSecondsField(generator, "duration", entry.duration().get());
+ generator.writeNumberField("responsesize", entry.contentSize().orElse(0));
+ generator.writeNumberField("code", entry.statusCode().orElse(0));
+ generator.writeStringField("method", entry.httpMethod().orElse(""));
+ generator.writeStringField("uri", getNormalizedURI(entry.rawPath().orElse(null), entry.rawQuery().orElse(null)));
+ generator.writeStringField("version", entry.httpVersion().orElse(""));
+ generator.writeStringField("agent", entry.userAgent().orElse(""));
+ generator.writeStringField("host", entry.hostString().orElse(""));
+ generator.writeStringField("scheme", entry.scheme().orElse(null));
+ generator.writeNumberField("localport", entry.localPort().getAsInt());
+
+ String connectionId = entry.connectionId().orElse(null);
+ if (connectionId != null) {
+ generator.writeStringField("connection", connectionId);
+ }
+
+ Principal userPrincipal = entry.userPrincipal().orElse(null);
+ if (userPrincipal != null) {
+ generator.writeStringField("user-principal", userPrincipal.getName());
+ }
+
+ Principal sslPrincipal = entry.sslPrincipal().orElse(null);
+ if (sslPrincipal != null) {
+ generator.writeStringField("ssl-principal", sslPrincipal.getName());
+ }
+
+ String remoteAddress = entry.remoteAddress().orElse(null);
+ int remotePort = entry.remotePort().orElse(0);
+ // Only add remote address/port fields if relevant
+ if (remoteAddressDiffers(peerAddress, remoteAddress)) {
+ generator.writeStringField("remoteaddr", remoteAddress);
+ if (remotePort > 0) {
+ generator.writeNumberField("remoteport", remotePort);
+ }
+ }
+
+ // Only add peer address/port fields if relevant
+ if (peerAddress != null) {
+ generator.writeStringField("peeraddr", peerAddress);
+
+ int peerPort = entry.peerPort().getAsInt();
+ if (peerPort > 0 && peerPort != remotePort) {
+ generator.writeNumberField("peerport", peerPort);
+ }
+ }
+
+ TraceNode trace = entry.traceNode().orElse(null);
+ if (trace != null) {
+ long timestamp = trace.timestamp();
+ if (timestamp == 0L) {
+ timestamp = time;
+ }
+ trace.accept(new TraceRenderer(generator, timestamp));
+ }
+
+ // Only add search sub block of this is a search request
+ if (isSearchRequest(entry)) {
+ HitCounts hitCounts = entry.hitCounts().get();
+ generator.writeObjectFieldStart("search");
+ generator.writeNumberField("totalhits", getTotalHitCount(hitCounts));
+ generator.writeNumberField("hits", getRetrievedHitCount(hitCounts));
+ Coverage c = hitCounts.getCoverage();
+ if (c != null) {
+ generator.writeObjectFieldStart(COVERAGE);
+ generator.writeNumberField(COVERAGE_COVERAGE, c.getResultPercentage());
+ generator.writeNumberField(COVERAGE_DOCUMENTS, c.getDocs());
+ if (c.isDegraded()) {
+ generator.writeObjectFieldStart(COVERAGE_DEGRADE);
+ if (c.isDegradedByMatchPhase())
+ generator.writeBooleanField(COVERAGE_DEGRADE_MATCHPHASE, c.isDegradedByMatchPhase());
+ if (c.isDegradedByTimeout())
+ generator.writeBooleanField(COVERAGE_DEGRADE_TIMEOUT, c.isDegradedByTimeout());
+ if (c.isDegradedByAdapativeTimeout())
+ generator.writeBooleanField(COVERAGE_DEGRADE_ADAPTIVE_TIMEOUT, c.isDegradedByAdapativeTimeout());
+ if (c.isDegradedByNonIdealState())
+ generator.writeBooleanField(COVERAGE_DEGRADED_NON_IDEAL_STATE, c.isDegradedByNonIdealState());
+ generator.writeEndObject();
+ }
+ generator.writeEndObject();
+ }
+ generator.writeEndObject();
+ }
+
+ // Add key/value access log entries. Keys with single values are written as single
+ // string value fields while keys with multiple values are written as string arrays
+ Collection<String> keys = entry.extraAttributeKeys();
+ if (!keys.isEmpty()) {
+ generator.writeObjectFieldStart("attributes");
+ for (String key : keys) {
+ Collection<String> values = entry.extraAttributeValues(key);
+ if (values.size() == 1) {
+ generator.writeStringField(key, values.iterator().next());
+ } else {
+ generator.writeFieldName(key);
+ generator.writeStartArray();
+ for (String s : values) {
+ generator.writeString(s);
+ }
+ generator.writeEndArray();
+ }
+ }
+ generator.writeEndObject();
+ }
+
+ generator.writeEndObject();
+ } catch (IOException e) {
+ logger.log(Level.WARNING, "Unable to generate JSON access log entry: " + e.getMessage(), e);
+ }
+ }
+
+ private JsonGenerator createJsonGenerator(OutputStream outputStream) throws IOException {
+ return generatorFactory.createGenerator(outputStream, JsonEncoding.UTF8)
+ .configure(JsonGenerator.Feature.AUTO_CLOSE_TARGET, false)
+ .configure(JsonGenerator.Feature.FLUSH_PASSED_TO_STREAM, false);
+ }
+
+ private boolean remoteAddressDiffers(String ipV4Address, String remoteAddress) {
+ return remoteAddress != null && !Objects.equals(ipV4Address, remoteAddress);
+ }
+
+ private boolean isSearchRequest(RequestLogEntry entry) {
+ return entry != null && entry.hitCounts().isPresent();
+ }
+
+ private long getTotalHitCount(HitCounts counts) {
+ if (counts == null) {
+ return 0;
+ }
+
+ return counts.getTotalHitCount();
+ }
+
+ private int getRetrievedHitCount(HitCounts counts) {
+ if (counts == null) {
+ return 0;
+ }
+
+ return counts.getRetrievedHitCount();
+ }
+
+ private static String getNormalizedURI(String rawPath, String rawQuery) {
+ if (rawPath == null) return null;
+ return rawQuery != null ? rawPath + "?" + rawQuery : rawPath;
+ }
+}
diff --git a/container-core/src/main/java/com/yahoo/container/logging/JsonConnectionLogWriter.java b/container-core/src/main/java/com/yahoo/container/logging/JsonConnectionLogWriter.java
new file mode 100644
index 00000000000..158d2ec4ea6
--- /dev/null
+++ b/container-core/src/main/java/com/yahoo/container/logging/JsonConnectionLogWriter.java
@@ -0,0 +1,122 @@
+// Copyright Verizon Media. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.container.logging;
+
+import com.fasterxml.jackson.core.JsonEncoding;
+import com.fasterxml.jackson.core.JsonFactory;
+import com.fasterxml.jackson.core.JsonGenerator;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.yahoo.container.logging.ConnectionLogEntry.SslHandshakeFailure.ExceptionEntry;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.time.Instant;
+import java.util.Arrays;
+import java.util.Objects;
+import java.util.Optional;
+
+/**
+ * @author bjorncs
+ */
+class JsonConnectionLogWriter implements LogWriter<ConnectionLogEntry> {
+
+ private final JsonFactory jsonFactory = new JsonFactory(new ObjectMapper());
+
+ @Override
+ public void write(ConnectionLogEntry record, OutputStream outputStream) throws IOException {
+ try (JsonGenerator generator = createJsonGenerator(outputStream)) {
+ generator.writeStartObject();
+ generator.writeStringField("id", record.id());
+ generator.writeStringField("timestamp", record.timestamp().toString());
+
+ writeOptionalSeconds(generator, "duration", unwrap(record.durationSeconds()));
+ writeOptionalString(generator, "peerAddress", unwrap(record.peerAddress()));
+ writeOptionalInteger(generator, "peerPort", unwrap(record.peerPort()));
+ writeOptionalString(generator, "localAddress", unwrap(record.localAddress()));
+ writeOptionalInteger(generator, "localPort", unwrap(record.localPort()));
+ writeOptionalString(generator, "remoteAddress", unwrap(record.remoteAddress()));
+ writeOptionalInteger(generator, "remotePort", unwrap(record.remotePort()));
+ writeOptionalLong(generator, "httpBytesReceived", unwrap(record.httpBytesReceived()));
+ writeOptionalLong(generator, "httpBytesSent", unwrap(record.httpBytesSent()));
+ writeOptionalLong(generator, "requests", unwrap(record.requests()));
+ writeOptionalLong(generator, "responses", unwrap(record.responses()));
+
+ String sslProtocol = unwrap(record.sslProtocol());
+ String sslSessionId = unwrap(record.sslSessionId());
+ String sslCipherSuite = unwrap(record.sslCipherSuite());
+ String sslPeerSubject = unwrap(record.sslPeerSubject());
+ Instant sslPeerNotBefore = unwrap(record.sslPeerNotBefore());
+ Instant sslPeerNotAfter = unwrap(record.sslPeerNotAfter());
+ String sslSniServerName = unwrap(record.sslSniServerName());
+ ConnectionLogEntry.SslHandshakeFailure sslHandshakeFailure = unwrap(record.sslHandshakeFailure());
+
+ if (isAnyValuePresent(
+ sslProtocol, sslSessionId, sslCipherSuite, sslPeerSubject, sslPeerNotBefore, sslPeerNotAfter,
+ sslSniServerName, sslHandshakeFailure)) {
+ generator.writeObjectFieldStart("ssl");
+
+ writeOptionalString(generator, "protocol", sslProtocol);
+ writeOptionalString(generator, "sessionId", sslSessionId);
+ writeOptionalString(generator, "cipherSuite", sslCipherSuite);
+ writeOptionalString(generator, "peerSubject", sslPeerSubject);
+ writeOptionalTimestamp(generator, "peerNotBefore", sslPeerNotBefore);
+ writeOptionalTimestamp(generator, "peerNotAfter", sslPeerNotAfter);
+ writeOptionalString(generator, "sniServerName", sslSniServerName);
+
+ if (sslHandshakeFailure != null) {
+ generator.writeObjectFieldStart("handshake-failure");
+ generator.writeArrayFieldStart("exception");
+ for (ExceptionEntry entry : sslHandshakeFailure.exceptionChain()) {
+ generator.writeStartObject();
+ generator.writeStringField("cause", entry.name());
+ generator.writeStringField("message", entry.message());
+ generator.writeEndObject();
+ }
+ generator.writeEndArray();
+ generator.writeStringField("type", sslHandshakeFailure.type());
+ generator.writeEndObject();
+ }
+
+ generator.writeEndObject();
+ }
+ }
+ }
+
+ private void writeOptionalString(JsonGenerator generator, String name, String value) throws IOException {
+ if (value != null) {
+ generator.writeStringField(name, value);
+ }
+ }
+
+ private void writeOptionalInteger(JsonGenerator generator, String name, Integer value) throws IOException {
+ if (value != null) {
+ generator.writeNumberField(name, value);
+ }
+ }
+
+ private void writeOptionalLong(JsonGenerator generator, String name, Long value) throws IOException {
+ if (value != null) {
+ generator.writeNumberField(name, value);
+ }
+ }
+
+ private void writeOptionalTimestamp(JsonGenerator generator, String name, Instant value) throws IOException {
+ if (value != null) {
+ generator.writeStringField(name, value.toString());
+ }
+ }
+
+ private void writeOptionalSeconds(JsonGenerator generator, String name, Double value) throws IOException {
+ if (value != null) {
+ FormatUtil.writeSecondsField(generator, name, value);
+ }
+ }
+
+ private static boolean isAnyValuePresent(Object... values) { return Arrays.stream(values).anyMatch(Objects::nonNull); }
+ private static <T> T unwrap(Optional<T> maybeValue) { return maybeValue.orElse(null); }
+
+ private JsonGenerator createJsonGenerator(OutputStream outputStream) throws IOException {
+ return jsonFactory.createGenerator(outputStream, JsonEncoding.UTF8)
+ .configure(JsonGenerator.Feature.AUTO_CLOSE_TARGET, false)
+ .configure(JsonGenerator.Feature.FLUSH_PASSED_TO_STREAM, false);
+ }
+}
diff --git a/container-core/src/main/java/com/yahoo/container/logging/LogFileHandler.java b/container-core/src/main/java/com/yahoo/container/logging/LogFileHandler.java
new file mode 100644
index 00000000000..0f2a9e42eb8
--- /dev/null
+++ b/container-core/src/main/java/com/yahoo/container/logging/LogFileHandler.java
@@ -0,0 +1,563 @@
+// Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.container.logging;
+
+import com.yahoo.compress.ZstdOuputStream;
+import com.yahoo.io.NativeIO;
+import com.yahoo.log.LogFileDb;
+import com.yahoo.protect.Process;
+import com.yahoo.yolean.Exceptions;
+
+import java.io.BufferedOutputStream;
+import java.io.File;
+import java.io.FileDescriptor;
+import java.io.FileInputStream;
+import java.io.FileNotFoundException;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.nio.file.StandardCopyOption;
+import java.util.ArrayList;
+import java.util.Optional;
+import java.util.concurrent.BlockingQueue;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.LinkedBlockingQueue;
+import java.util.concurrent.TimeUnit;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+import java.util.zip.GZIPOutputStream;
+
+/**
+ * Implements log file naming/rotating logic for container logs.
+ *
+ * @author Bob Travis
+ * @author bjorncs
+ */
+class LogFileHandler <LOGTYPE> {
+
+ enum Compression {NONE, GZIP, ZSTD}
+
+ private final static Logger logger = Logger.getLogger(LogFileHandler.class.getName());
+ private final BlockingQueue<Operation<LOGTYPE>> logQueue;
+ final LogThread<LOGTYPE> logThread;
+
+ @FunctionalInterface private interface Pollable<T> { Operation<T> poll() throws InterruptedException; }
+
+ LogFileHandler(Compression compression, String filePattern, String rotationTimes, String symlinkName, int queueSize,
+ String threadName, LogWriter<LOGTYPE> logWriter) {
+ this(compression, filePattern, calcTimesMinutes(rotationTimes), symlinkName, queueSize, threadName, logWriter);
+ }
+
+ LogFileHandler(
+ Compression compression,
+ String filePattern,
+ long[] rotationTimes,
+ String symlinkName,
+ int queueSize,
+ String threadName,
+ LogWriter<LOGTYPE> logWriter) {
+ this.logQueue = new LinkedBlockingQueue<>(queueSize);
+ this.logThread = new LogThread<>(logWriter, filePattern, compression, rotationTimes, symlinkName, threadName, this::poll);
+ this.logThread.start();
+ }
+
+ private Operation<LOGTYPE> poll() throws InterruptedException {
+ return logQueue.poll(100, TimeUnit.MILLISECONDS);
+ }
+
+ /**
+ * Sends logrecord to file, first rotating file if needed.
+ *
+ * @param r logrecord to publish
+ */
+ public void publish(LOGTYPE r) {
+ addOperation(new Operation<>(r));
+ }
+
+ void publishAndWait(LOGTYPE r) {
+ addOperationAndWait(new Operation<>(r));
+ }
+
+ public void flush() {
+ addOperationAndWait(new Operation<>(Operation.Type.flush));
+ }
+
+ /**
+ * Force file rotation now, independent of schedule.
+ */
+ void rotateNow() {
+ addOperationAndWait(new Operation<>(Operation.Type.rotate));
+ }
+
+ public void close() {
+ addOperationAndWait(new Operation<>(Operation.Type.close));
+ }
+
+ private void addOperation(Operation<LOGTYPE> op) {
+ try {
+ logQueue.put(op);
+ } catch (InterruptedException e) {
+ }
+ }
+
+ private void addOperationAndWait(Operation<LOGTYPE> op) {
+ try {
+ logQueue.put(op);
+ op.countDownLatch.await();
+ } catch (InterruptedException e) {
+ }
+ }
+
+ /**
+ * Flushes all queued messages, interrupts the log thread in this and
+ * waits for it to end before returning
+ */
+ void shutdown() {
+ logThread.interrupt();
+ try {
+ logThread.executor.shutdownNow();
+ logThread.executor.awaitTermination(600, TimeUnit.SECONDS);
+ logThread.join();
+ } catch (InterruptedException e) {
+ }
+ }
+
+ /**
+ * Calculate rotation times array, given times in minutes, as "0 60 ..."
+ */
+ private static long[] calcTimesMinutes(String times) {
+ ArrayList<Long> list = new ArrayList<>(50);
+ int i = 0;
+ boolean etc = false;
+
+ while (i < times.length()) {
+ if (times.charAt(i) == ' ') {
+ i++;
+ continue;
+ } // skip spaces
+ int j = i; // start of string
+ i = times.indexOf(' ', i);
+ if (i == -1) i = times.length();
+ if (times.charAt(j) == '.' && times.substring(j, i).equals("...")) { // ...
+ etc = true;
+ break;
+ }
+ list.add(Long.valueOf(times.substring(j, i)));
+ }
+
+ int size = list.size();
+ long[] longtimes = new long[size];
+ for (i = 0; i < size; i++) {
+ longtimes[i] = list.get(i) // pick up value in minutes past midnight
+ * 60000; // and multiply to get millis
+ }
+
+ if (etc) { // fill out rest of day, same as final interval
+ long endOfDay = 24 * 60 * 60 * 1000;
+ long lasttime = longtimes[size - 1];
+ long interval = lasttime - longtimes[size - 2];
+ long moreneeded = (endOfDay - lasttime) / interval;
+ if (moreneeded > 0) {
+ int newsize = size + (int) moreneeded;
+ long[] temp = new long[newsize];
+ for (i = 0; i < size; i++) {
+ temp[i] = longtimes[i];
+ }
+ while (size < newsize) {
+ lasttime += interval;
+ temp[size++] = lasttime;
+ }
+ longtimes = temp;
+ }
+ }
+
+ return longtimes;
+ }
+
+ /**
+ * Only for unit testing. Do not use.
+ */
+ String getFileName() {
+ return logThread.fileName;
+ }
+
+ /**
+ * Handle logging and file operations
+ */
+ static class LogThread<LOGTYPE> extends Thread {
+ private final Pollable<LOGTYPE> operationProvider;
+ long lastFlush = 0;
+ private PageCacheFriendlyFileOutputStream fileOutput = null;
+ private long nextRotationTime = 0;
+ private final String filePattern; // default to current directory, ms time stamp
+ private volatile String fileName;
+ private final LogWriter<LOGTYPE> logWriter;
+ private final Compression compression;
+ private final long[] rotationTimes;
+ private final String symlinkName;
+ private final ExecutorService executor = createCompressionTaskExecutor();
+ private final NativeIO nativeIO = new NativeIO();
+
+
+ LogThread(LogWriter<LOGTYPE> logWriter,
+ String filePattern,
+ Compression compression,
+ long[] rotationTimes,
+ String symlinkName,
+ String threadName,
+ Pollable<LOGTYPE> operationProvider) {
+ super(threadName);
+ setDaemon(true);
+ this.logWriter = logWriter;
+ this.filePattern = filePattern;
+ this.compression = compression;
+ this.rotationTimes = rotationTimes;
+ this.symlinkName = (symlinkName != null && !symlinkName.isBlank()) ? symlinkName : null;
+ this.operationProvider = operationProvider;
+ }
+
+ private static ExecutorService createCompressionTaskExecutor() {
+ return Executors.newSingleThreadExecutor(runnable -> {
+ Thread thread = new Thread(runnable, "logfilehandler.compression");
+ thread.setDaemon(true);
+ thread.setPriority(Thread.MIN_PRIORITY);
+ return thread;
+ });
+ }
+
+ @Override
+ public void run() {
+ try {
+ handleLogOperations();
+ } catch (InterruptedException e) {
+ } catch (Exception e) {
+ Process.logAndDie("Failed storing log records", e);
+ }
+
+ internalFlush();
+ }
+
+ private void handleLogOperations() throws InterruptedException {
+ while (!isInterrupted()) {
+ Operation<LOGTYPE> r = operationProvider.poll();
+ if (r != null) {
+ if (r.type == Operation.Type.flush) {
+ internalFlush();
+ } else if (r.type == Operation.Type.close) {
+ internalClose();
+ } else if (r.type == Operation.Type.rotate) {
+ internalRotateNow();
+ lastFlush = System.nanoTime();
+ } else if (r.type == Operation.Type.log) {
+ internalPublish(r.log.get());
+ flushIfOld(3, TimeUnit.SECONDS);
+ }
+ r.countDownLatch.countDown();
+ } else {
+ flushIfOld(100, TimeUnit.MILLISECONDS);
+ }
+ }
+ }
+
+ private void flushIfOld(long age, TimeUnit unit) {
+ long now = System.nanoTime();
+ if (TimeUnit.NANOSECONDS.toMillis(now - lastFlush) > unit.toMillis(age)) {
+ internalFlush();
+ lastFlush = now;
+ }
+ }
+
+ private void internalFlush() {
+ try {
+ if (fileOutput != null) {
+ fileOutput.flush();
+ }
+ } catch (IOException e) {
+ logger.log(Level.WARNING, "Failed to flush file output: " + Exceptions.toMessageString(e), e);
+ }
+ }
+
+ private void internalClose() {
+ try {
+ if (fileOutput != null) {
+ fileOutput.flush();
+ fileOutput.close();
+ fileOutput = null;
+ }
+ } catch (Exception e) {
+ logger.log(Level.WARNING, "Got error while closing log file: " + e.getMessage(), e);
+ }
+ }
+
+ private void internalPublish(LOGTYPE r) {
+ // first check to see if new file needed.
+ // if so, use this.internalRotateNow() to do it
+
+ long now = System.currentTimeMillis();
+ if (nextRotationTime <= 0) {
+ nextRotationTime = getNextRotationTime(now); // lazy initialization
+ }
+ if (now > nextRotationTime || fileOutput == null) {
+ internalRotateNow();
+ }
+ try {
+ logWriter.write(r, fileOutput);
+ fileOutput.write('\n');
+ } catch (IOException e) {
+ logger.warning("Failed writing log record: " + Exceptions.toMessageString(e));
+ }
+ }
+
+ /**
+ * Find next rotation after specified time.
+ *
+ * @param now the specified time; if zero, current time is used.
+ * @return the next rotation time
+ */
+ long getNextRotationTime(long now) {
+ if (now <= 0) {
+ now = System.currentTimeMillis();
+ }
+ long nowTod = timeOfDayMillis(now);
+ long next = 0;
+ for (long rotationTime : rotationTimes) {
+ if (nowTod < rotationTime) {
+ next = rotationTime - nowTod + now;
+ break;
+ }
+ }
+ if (next == 0) { // didn't find one -- use 1st time 'tomorrow'
+ next = rotationTimes[0] + lengthOfDayMillis - nowTod + now;
+ }
+
+ return next;
+ }
+
+ private void checkAndCreateDir(String pathname) {
+ int lastSlash = pathname.lastIndexOf("/");
+ if (lastSlash > -1) {
+ String pathExcludingFilename = pathname.substring(0, lastSlash);
+ File filepath = new File(pathExcludingFilename);
+ if (!filepath.exists()) {
+ filepath.mkdirs();
+ }
+ }
+ }
+
+
+ // Throw InterruptedException upwards rather than relying on isInterrupted to stop the thread as
+ // isInterrupted() returns false after interruption in p.waitFor
+ private void internalRotateNow() {
+ // figure out new file name, then
+
+ String oldFileName = fileName;
+ long now = System.currentTimeMillis();
+ fileName = LogFormatter.insertDate(filePattern, now);
+ internalClose();
+ try {
+ checkAndCreateDir(fileName);
+ fileOutput = new PageCacheFriendlyFileOutputStream(nativeIO, Paths.get(fileName), 4 * 1024 * 1024);
+ LogFileDb.nowLoggingTo(fileName);
+ } catch (IOException e) {
+ throw new RuntimeException("Couldn't open log file '" + fileName + "'", e);
+ }
+
+ if(oldFileName == null) oldFileName = getOldFileNameFromSymlink(); // To compress previous file, if so configured
+ createSymlinkToCurrentFile();
+
+ nextRotationTime = 0; //figure it out later (lazy evaluation)
+ if ((oldFileName != null)) {
+ Path oldFile = Paths.get(oldFileName);
+ if (Files.exists(oldFile)) {
+ executor.execute(() -> runCompression(nativeIO, oldFile, compression));
+ }
+ }
+ }
+
+
+ private static void runCompression(NativeIO nativeIO, Path oldFile, Compression compression) {
+ switch (compression) {
+ case ZSTD:
+ runCompressionZstd(nativeIO, oldFile);
+ break;
+ case GZIP:
+ runCompressionGzip(nativeIO, oldFile);
+ break;
+ case NONE:
+ runCompressionNone(nativeIO, oldFile);
+ break;
+ default:
+ throw new IllegalArgumentException("Unknown compression " + compression);
+ }
+ }
+
+ private static void runCompressionNone(NativeIO nativeIO, Path oldFile) {
+ nativeIO.dropFileFromCache(oldFile.toFile());
+ }
+
+ private static void runCompressionZstd(NativeIO nativeIO, Path oldFile) {
+ try {
+ Path compressedFile = Paths.get(oldFile.toString() + ".zst");
+ int bufferSize = 2*1024*1024;
+ try (FileOutputStream fileOut = AtomicFileOutputStream.create(compressedFile);
+ ZstdOuputStream out = new ZstdOuputStream(fileOut, bufferSize);
+ FileInputStream in = new FileInputStream(oldFile.toFile())) {
+ pageFriendlyTransfer(nativeIO, out, fileOut.getFD(), in, bufferSize);
+ out.flush();
+ }
+ Files.delete(oldFile);
+ nativeIO.dropFileFromCache(compressedFile.toFile());
+ } catch (IOException e) {
+ logger.log(Level.WARNING, "Failed to compress log file with zstd: " + oldFile, e);
+ } finally {
+ nativeIO.dropFileFromCache(oldFile.toFile());
+ }
+ }
+
+ private static void runCompressionGzip(NativeIO nativeIO, Path oldFile) {
+ try {
+ Path gzippedFile = Paths.get(oldFile.toString() + ".gz");
+ try (FileOutputStream fileOut = AtomicFileOutputStream.create(gzippedFile);
+ GZIPOutputStream compressor = new GZIPOutputStream(fileOut, 0x100000);
+ FileInputStream inputStream = new FileInputStream(oldFile.toFile())) {
+ pageFriendlyTransfer(nativeIO, compressor, fileOut.getFD(), inputStream, 0x400000);
+ compressor.finish();
+ compressor.flush();
+ }
+ Files.delete(oldFile);
+ nativeIO.dropFileFromCache(gzippedFile.toFile());
+ } catch (IOException e) {
+ logger.log(Level.WARNING, "Failed to compress log file with gzip: " + oldFile, e);
+ } finally {
+ nativeIO.dropFileFromCache(oldFile.toFile());
+ }
+ }
+
+ private static void pageFriendlyTransfer(NativeIO nativeIO, OutputStream out, FileDescriptor outDescriptor, FileInputStream in, int bufferSize) throws IOException {
+ int read;
+ long totalBytesRead = 0;
+ byte[] buffer = new byte[bufferSize];
+ while ((read = in.read(buffer)) >= 0) {
+ out.write(buffer, 0, read);
+ if (read > 0) {
+ nativeIO.dropPartialFileFromCache(in.getFD(), totalBytesRead, read, false);
+ nativeIO.dropPartialFileFromCache(outDescriptor, totalBytesRead, read, false);
+ }
+ totalBytesRead += read;
+ }
+ }
+
+ /**
+ * Name files by date - create a symlink with a constant name to the newest file
+ */
+ private void createSymlinkToCurrentFile() {
+ if (symlinkName == null) return;
+ Path target = Paths.get(fileName);
+ Path link = target.resolveSibling(symlinkName);
+ try {
+ Files.deleteIfExists(link);
+ Files.createSymbolicLink(link, target.getFileName());
+ } catch (IOException e) {
+ logger.log(Level.WARNING, "Failed to create symbolic link to current log file: " + e.getMessage(), e);
+ }
+ }
+
+ private String getOldFileNameFromSymlink() {
+ if(symlinkName == null) return null;
+ try {
+ return Paths.get(fileName).resolveSibling(symlinkName).toRealPath().toString();
+ } catch (IOException e) {
+ return null;
+ }
+ }
+
+ private static final long lengthOfDayMillis = 24 * 60 * 60 * 1000;
+ private static long timeOfDayMillis(long time) {
+ return time % lengthOfDayMillis;
+ }
+
+ }
+
+ private static class Operation<LOGTYPE> {
+ enum Type {log, flush, close, rotate}
+
+ final Type type;
+
+ final Optional<LOGTYPE> log;
+ final CountDownLatch countDownLatch = new CountDownLatch(1);
+
+ Operation(Type type) {
+ this(type, Optional.empty());
+ }
+
+ Operation(LOGTYPE log) {
+ this(Type.log, Optional.of(log));
+ }
+
+ private Operation(Type type, Optional<LOGTYPE> log) {
+ this.type = type;
+ this.log = log;
+ }
+ }
+
+ /** File output stream that signals to kernel to drop previous pages after write */
+ private static class PageCacheFriendlyFileOutputStream extends OutputStream {
+
+ private final NativeIO nativeIO;
+ private final FileOutputStream fileOut;
+ private final BufferedOutputStream bufferedOut;
+ private final int bufferSize;
+ private long lastDropPosition = 0;
+
+ PageCacheFriendlyFileOutputStream(NativeIO nativeIO, Path file, int bufferSize) throws FileNotFoundException {
+ this.nativeIO = nativeIO;
+ this.fileOut = new FileOutputStream(file.toFile(), true);
+ this.bufferedOut = new BufferedOutputStream(fileOut, bufferSize);
+ this.bufferSize = bufferSize;
+ }
+
+ @Override public void write(byte[] b) throws IOException { bufferedOut.write(b); }
+ @Override public void write(byte[] b, int off, int len) throws IOException { bufferedOut.write(b, off, len); }
+ @Override public void write(int b) throws IOException { bufferedOut.write(b); }
+ @Override public void close() throws IOException { bufferedOut.close(); }
+
+ @Override
+ public void flush() throws IOException {
+ bufferedOut.flush();
+ long newPos = fileOut.getChannel().position();
+ if (newPos >= lastDropPosition + bufferSize) {
+ nativeIO.dropPartialFileFromCache(fileOut.getFD(), lastDropPosition, newPos, true);
+ lastDropPosition = newPos;
+ }
+ }
+ }
+
+ private static class AtomicFileOutputStream extends FileOutputStream {
+ private final Path path;
+ private final Path tmpPath;
+ private volatile boolean closed = false;
+
+ private AtomicFileOutputStream(Path path, Path tmpPath) throws FileNotFoundException {
+ super(tmpPath.toFile());
+ this.path = path;
+ this.tmpPath = tmpPath;
+ }
+
+ @Override
+ public synchronized void close() throws IOException {
+ super.close();
+ if (!closed) {
+ Files.move(tmpPath, path, StandardCopyOption.ATOMIC_MOVE);
+ closed = true;
+ }
+ }
+
+ private static AtomicFileOutputStream create(Path path) throws FileNotFoundException {
+ return new AtomicFileOutputStream(path, path.resolveSibling("." + path.getFileName() + ".tmp"));
+ }
+ }
+}
diff --git a/container-core/src/main/java/com/yahoo/container/logging/LogFormatter.java b/container-core/src/main/java/com/yahoo/container/logging/LogFormatter.java
new file mode 100644
index 00000000000..cc1dcb579aa
--- /dev/null
+++ b/container-core/src/main/java/com/yahoo/container/logging/LogFormatter.java
@@ -0,0 +1,191 @@
+// Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.container.logging;
+
+import java.text.DateFormat;
+import java.text.SimpleDateFormat;
+import java.util.Date;
+import java.util.Locale;
+import java.util.TimeZone;
+import java.util.logging.Formatter;
+import java.util.logging.LogRecord;
+
+
+/**
+ * Produces compact output format for prelude logs
+ *
+ * @author Bob Travis
+ */
+public class LogFormatter extends Formatter {
+
+ /** date format objects */
+ static SimpleDateFormat ddMMMyyyy;
+ static DateFormat dfMMM;
+ static SimpleDateFormat yyyyMMdd;
+
+ static {
+ ddMMMyyyy = new SimpleDateFormat("[dd/MMM/yyyy:HH:mm:ss Z]", Locale.US);
+ ddMMMyyyy.setTimeZone(TimeZone.getTimeZone("UTC"));
+
+ dfMMM = new SimpleDateFormat("MMM", Locale.US);
+ dfMMM.setTimeZone(TimeZone.getTimeZone("UTC"));
+
+ yyyyMMdd = new SimpleDateFormat("[yyyy-MM-dd HH:mm:ss]", Locale.US);
+ yyyyMMdd.setTimeZone(TimeZone.getTimeZone("UTC"));
+ }
+
+ /** Whether to strip down the message to only the message or not */
+ private boolean messageOnly = false;
+
+ /** Controls which of the available timestamp formats is used in all log records
+ */
+ private static final int timestampFormat = 2; // 0=millis, 1=mm/dd/yyyy, 2=yyyy-mm-dd
+
+ /**
+ * Standard constructor
+ */
+
+ public LogFormatter() {}
+
+ /**
+ * Make it possible to log stripped messages
+ */
+ public void messageOnly (boolean messageOnly) {
+ this.messageOnly = messageOnly;
+ }
+
+ public String format(LogRecord record) {
+
+ // if we don't want any other stuff we just return the message
+ if (messageOnly) {
+ return formatMessage(record);
+ }
+
+ String rawMsg = record.getMessage();
+ boolean isLogMsg =
+ rawMsg.charAt(0) == 'L'
+ && rawMsg.charAt(1) == 'O'
+ && rawMsg.charAt(2) == 'G'
+ && rawMsg.charAt(3) == ':';
+ String nameInsert =
+ (!isLogMsg)
+ ? record.getLevel().getName() + ": "
+ : "";
+ return (timeStamp(record)
+ + nameInsert
+ + formatMessage(record)
+ + "\n"
+ );
+ }
+
+ /**
+ * Public support methods
+ */
+
+ /**
+ * Static insertDate method will insert date fragments into a string
+ * based on '%x' pattern elements. Equivalents in SimpleDateFormatter patterns,
+ * with examples:
+ * <ul>
+ * <li>%Y YYYY 2003
+ * <li>%m MM 08
+ * <li>%x MMM Aug
+ * <li>%d dd 25
+ * <li>%H HH 14
+ * <li>%M mm 30
+ * <li>%S ss 35
+ * <li>%s SSS 123
+ * <li>%Z Z -0400
+ * </ul>
+ *Others:
+ * <ul>
+ * <li>%T Long.toString(time)
+ * <li>%% %
+ * </ul>
+ */
+ public static String insertDate(String pattern, long time) {
+ DateFormat df = new SimpleDateFormat("yyyy.MM.dd:HH:mm:ss.SSS Z", Locale.US);
+ df.setTimeZone(TimeZone.getTimeZone("UTC"));
+ Date date = new Date(time);
+ String datetime = df.format(date);
+ StringBuilder result = new StringBuilder();
+ int i=0;
+ while (i < pattern.length()) {
+ int j = pattern.indexOf('%',i);
+ if (j == -1 || j >= pattern.length()-1) { // done
+ result.append(pattern.substring(i)); // copy rest of pattern and quit
+ break;
+ }
+ result.append(pattern.substring(i, j));
+ switch (pattern.charAt(j+1)) {
+ case 'Y':
+ result.append(datetime.substring(0,4)); // year
+ break;
+ case 'm':
+ result.append(datetime.substring(5,7)); // month
+ break;
+ case 'd':
+ result.append(datetime.substring(8,10)); // day of month
+ break;
+ case 'H':
+ result.append(datetime.substring(11,13)); // hour
+ break;
+ case 'M':
+ result.append(datetime.substring(14,16)); // minute
+ break;
+ case 'S':
+ result.append(datetime.substring(17,19)); // second
+ break;
+ case 's':
+ result.append(datetime.substring(20,23)); // thousanths
+ break;
+ case 'Z':
+ result.append(datetime.substring(24)); // time zone string
+ break;
+ case 'T':
+ result.append(Long.toString(time)); //time in Millis
+ break;
+ case 'x':
+ result.append(capitalize(dfMMM.format(date)));
+ break;
+ case '%':
+ result.append("%%");
+ break;
+ default:
+ result.append("%"); // copy pattern escape and move on
+ j--; // only want to bump by one position....
+ break;
+ }
+ i = j+2;
+ }
+
+ return result.toString();
+ }
+
+ /**
+ * Private methods: timeStamp(LogRecord)
+ */
+ private String timeStamp(LogRecord record) {
+ Date date = new Date(record.getMillis());
+ String stamp;
+ switch (timestampFormat) {
+ case 0:
+ stamp = Long.toString(record.getMillis());
+ break;
+ case 1:
+ stamp = ddMMMyyyy.format(date);
+ break;
+ case 2:
+ default:
+ stamp = yyyyMMdd.format(date);
+ break;
+ }
+ return stamp;
+ }
+
+ /** Return the given string with the first letter in upper case */
+ private static String capitalize(String string) {
+ if (Character.isUpperCase(string.charAt(0))) return string;
+ return Character.toUpperCase(string.charAt(0)) + string.substring(1);
+ }
+
+}
diff --git a/container-core/src/main/java/com/yahoo/container/logging/LogWriter.java b/container-core/src/main/java/com/yahoo/container/logging/LogWriter.java
new file mode 100644
index 00000000000..15a983cfb43
--- /dev/null
+++ b/container-core/src/main/java/com/yahoo/container/logging/LogWriter.java
@@ -0,0 +1,10 @@
+// Copyright Verizon Media. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+
+package com.yahoo.container.logging;
+
+import java.io.IOException;
+import java.io.OutputStream;
+
+interface LogWriter <LOGTYPE> {
+ void write(LOGTYPE record, OutputStream outputStream) throws IOException;
+}
diff --git a/container-core/src/main/java/com/yahoo/container/logging/RequestLog.java b/container-core/src/main/java/com/yahoo/container/logging/RequestLog.java
new file mode 100644
index 00000000000..2090ba1b9f1
--- /dev/null
+++ b/container-core/src/main/java/com/yahoo/container/logging/RequestLog.java
@@ -0,0 +1,13 @@
+// Copyright Verizon Media. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.container.logging;
+
+/**
+ * Access logging for requests
+ *
+ * @author bjorncs
+ */
+public interface RequestLog {
+
+ void log(RequestLogEntry entry);
+
+}
diff --git a/container-core/src/main/java/com/yahoo/container/logging/RequestLogEntry.java b/container-core/src/main/java/com/yahoo/container/logging/RequestLogEntry.java
new file mode 100644
index 00000000000..819907fc9f1
--- /dev/null
+++ b/container-core/src/main/java/com/yahoo/container/logging/RequestLogEntry.java
@@ -0,0 +1,186 @@
+// Copyright Verizon Media. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.container.logging;
+
+import com.yahoo.yolean.trace.TraceNode;
+
+import java.security.Principal;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Optional;
+import java.util.OptionalInt;
+import java.util.OptionalLong;
+
+import static java.util.Objects.requireNonNull;
+
+/**
+ * A immutable request log entry
+ *
+ * @author bjorncs
+ */
+public class RequestLogEntry {
+
+ private final String connectionId;
+ private final Instant timestamp;
+ private final Duration duration;
+ private final int localPort;
+ private final String peerAddress;
+ private final int peerPort;
+ private final String remoteAddress;
+ private final int remotePort;
+ private final String userAgent;
+ private final String referer;
+ private final String httpMethod;
+ private final String httpVersion;
+ private final String hostString;
+ private final int statusCode;
+ private final long contentSize;
+ private final String scheme;
+ private final String rawPath;
+ private final String rawQuery;
+ private final Principal userPrincipal;
+ private final Principal sslPrincipal;
+ private final HitCounts hitCounts;
+ private final TraceNode traceNode;
+ private final Map<String, Collection<String>> extraAttributes;
+
+ private RequestLogEntry(Builder builder) {
+ this.connectionId = builder.connectionId;
+ this.timestamp = builder.timestamp;
+ this.duration = builder.duration;
+ this.localPort = builder.localPort;
+ this.peerAddress = builder.peerAddress;
+ this.peerPort = builder.peerPort;
+ this.remoteAddress = builder.remoteAddress;
+ this.remotePort = builder.remotePort;
+ this.userAgent = builder.userAgent;
+ this.referer = builder.referer;
+ this.httpMethod = builder.httpMethod;
+ this.httpVersion = builder.httpVersion;
+ this.hostString = builder.hostString;
+ this.statusCode = builder.statusCode;
+ this.contentSize = builder.contentSize;
+ this.scheme = builder.scheme;
+ this.rawPath = builder.rawPath;
+ this.rawQuery = builder.rawQuery;
+ this.userPrincipal = builder.userPrincipal;
+ this.sslPrincipal = builder.sslPrincipal;
+ this.hitCounts = builder.hitCounts;
+ this.traceNode = builder.traceNode;
+ this.extraAttributes = copyExtraAttributes(builder.extraAttributes);
+ }
+
+ public Optional<String> connectionId() { return Optional.ofNullable(connectionId); }
+ public Optional<Instant> timestamp() { return Optional.ofNullable(timestamp); }
+ public Optional<Duration> duration() { return Optional.ofNullable(duration); }
+ public OptionalInt localPort() { return optionalInt(localPort); }
+ public Optional<String> peerAddress() { return Optional.ofNullable(peerAddress); }
+ public OptionalInt peerPort() { return optionalInt(peerPort); }
+ public Optional<String> remoteAddress() { return Optional.ofNullable(remoteAddress); }
+ public OptionalInt remotePort() { return optionalInt(remotePort); }
+ public Optional<String> userAgent() { return Optional.ofNullable(userAgent); }
+ public Optional<String> referer() { return Optional.ofNullable(referer); }
+ public Optional<String> httpMethod() { return Optional.ofNullable(httpMethod); }
+ public Optional<String> httpVersion() { return Optional.ofNullable(httpVersion); }
+ public Optional<String> hostString() { return Optional.ofNullable(hostString); }
+ public OptionalInt statusCode() { return optionalInt(statusCode); }
+ public OptionalLong contentSize() { return optionalLong(contentSize); }
+ public Optional<String> scheme() { return Optional.ofNullable(scheme); }
+ public Optional<String> rawPath() { return Optional.ofNullable(rawPath); }
+ public Optional<String> rawQuery() { return Optional.ofNullable(rawQuery); }
+ public Optional<Principal> userPrincipal() { return Optional.ofNullable(userPrincipal); }
+ public Optional<Principal> sslPrincipal() { return Optional.ofNullable(sslPrincipal); }
+ public Optional<HitCounts> hitCounts() { return Optional.ofNullable(hitCounts); }
+ public Optional<TraceNode> traceNode() { return Optional.ofNullable(traceNode); }
+ public Collection<String> extraAttributeKeys() { return Collections.unmodifiableCollection(extraAttributes.keySet()); }
+ public Collection<String> extraAttributeValues(String key) { return Collections.unmodifiableCollection(extraAttributes.get(key)); }
+
+ private static OptionalInt optionalInt(int value) {
+ if (value == -1) return OptionalInt.empty();
+ return OptionalInt.of(value);
+ }
+
+ private static OptionalLong optionalLong(long value) {
+ if (value == -1) return OptionalLong.empty();
+ return OptionalLong.of(value);
+ }
+
+ private static Map<String, Collection<String>> copyExtraAttributes(Map<String, Collection<String>> extraAttributes) {
+ Map<String, Collection<String>> copy = new HashMap<>();
+ extraAttributes.forEach((key, value) -> copy.put(key, new ArrayList<>(value)));
+ return copy;
+ }
+
+ public static class Builder {
+
+ private String connectionId;
+ private Instant timestamp;
+ private Duration duration;
+ private int localPort = -1;
+ private String peerAddress;
+ private int peerPort = -1;
+ private String remoteAddress;
+ private int remotePort = -1;
+ private String userAgent;
+ private String referer;
+ private String httpMethod;
+ private String httpVersion;
+ private String hostString;
+ private int statusCode = -1;
+ private long contentSize = -1;
+ private String scheme;
+ private String rawPath;
+ private String rawQuery;
+ private Principal userPrincipal;
+ private HitCounts hitCounts;
+ private TraceNode traceNode;
+ private Principal sslPrincipal;
+ private final Map<String, Collection<String>> extraAttributes = new HashMap<>();
+
+ public Builder connectionId(String connectionId) { this.connectionId = requireNonNull(connectionId); return this; }
+ public Builder timestamp(Instant timestamp) { this.timestamp = requireNonNull(timestamp); return this; }
+ public Builder duration(Duration duration) { this.duration = requireNonNull(duration); return this; }
+ public Builder localPort(int localPort) { this.localPort = requireNonNegative(localPort); return this; }
+ public Builder peerAddress(String peerAddress) { this.peerAddress = requireNonNull(peerAddress); return this; }
+ public Builder peerPort(int peerPort) { this.peerPort = requireNonNegative(peerPort); return this; }
+ public Builder remoteAddress(String remoteAddress) { this.remoteAddress = requireNonNull(remoteAddress); return this; }
+ public Builder remotePort(int remotePort) { this.remotePort = requireNonNegative(remotePort); return this; }
+ public Builder userAgent(String userAgent) { this.userAgent = requireNonNull(userAgent); return this; }
+ public Builder referer(String referer) { this.referer = requireNonNull(referer); return this; }
+ public Builder httpMethod(String httpMethod) { this.httpMethod = requireNonNull(httpMethod); return this; }
+ public Builder httpVersion(String httpVersion) { this.httpVersion = requireNonNull(httpVersion); return this; }
+ public Builder hostString(String hostString) { this.hostString = requireNonNull(hostString); return this; }
+ public Builder statusCode(int statusCode) { this.statusCode = requireNonNegative(statusCode); return this; }
+ public Builder contentSize(long contentSize) { this.contentSize = requireNonNegative(contentSize); return this; }
+ public Builder scheme(String scheme) { this.scheme = requireNonNull(scheme); return this; }
+ public Builder rawPath(String rawPath) { this.rawPath = requireNonNull(rawPath); return this; }
+ public Builder rawQuery(String rawQuery) { this.rawQuery = requireNonNull(rawQuery); return this; }
+ public Builder userPrincipal(Principal userPrincipal) { this.userPrincipal = requireNonNull(userPrincipal); return this; }
+ public Builder sslPrincipal(Principal sslPrincipal) { this.sslPrincipal = requireNonNull(sslPrincipal); return this; }
+ public Builder hitCounts(HitCounts hitCounts) { this.hitCounts = requireNonNull(hitCounts); return this; }
+ public Builder traceNode(TraceNode traceNode) { this.traceNode = requireNonNull(traceNode); return this; }
+ public Builder addExtraAttribute(String key, String value) {
+ this.extraAttributes.computeIfAbsent(requireNonNull(key), __ -> new ArrayList<>()).add(requireNonNull(value));
+ return this;
+ }
+ public Builder addExtraAttributes(String key, Collection<String> values) {
+ this.extraAttributes.computeIfAbsent(requireNonNull(key), __ -> new ArrayList<>()).addAll(requireNonNull(values));
+ return this;
+ }
+ public RequestLogEntry build() { return new RequestLogEntry(this); }
+
+ private static int requireNonNegative(int value) {
+ if (value < 0) throw new IllegalArgumentException("Value must be non-negative: " + value);
+ return value;
+ }
+
+ private static long requireNonNegative(long value) {
+ if (value < 0) throw new IllegalArgumentException("Value must be non-negative: " + value);
+ return value;
+ }
+ }
+}
diff --git a/container-core/src/main/java/com/yahoo/container/logging/RequestLogHandler.java b/container-core/src/main/java/com/yahoo/container/logging/RequestLogHandler.java
new file mode 100644
index 00000000000..85df08e4abb
--- /dev/null
+++ b/container-core/src/main/java/com/yahoo/container/logging/RequestLogHandler.java
@@ -0,0 +1,9 @@
+// Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.container.logging;
+
+/**
+ * @author Tony Vaagenes
+ */
+public interface RequestLogHandler {
+ void log(RequestLogEntry entry);
+}
diff --git a/container-core/src/main/java/com/yahoo/container/logging/TraceRenderer.java b/container-core/src/main/java/com/yahoo/container/logging/TraceRenderer.java
new file mode 100644
index 00000000000..41b88e08c19
--- /dev/null
+++ b/container-core/src/main/java/com/yahoo/container/logging/TraceRenderer.java
@@ -0,0 +1,186 @@
+// Copyright Verizon Media. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.container.logging;
+
+import com.yahoo.data.access.Inspectable;
+import com.yahoo.data.access.Inspector;
+import com.yahoo.data.access.simple.JsonRender;
+import com.yahoo.yolean.trace.TraceNode;
+import com.yahoo.yolean.trace.TraceVisitor;
+import com.fasterxml.jackson.core.JsonGenerator;
+
+import java.io.IOException;
+
+public class TraceRenderer extends TraceVisitor {
+ private static final String TRACE_CHILDREN = "children";
+ private static final String TRACE_MESSAGE = "message";
+ private static final String TRACE_TIMESTAMP = "timestamp";
+ private static final String TRACE = "trace";
+
+ private final long basetime;
+ private final JsonGenerator generator;
+ private final FieldConsumer fieldConsumer;
+ private boolean hasFieldName = false;
+ int emittedChildNesting = 0;
+ int currentChildNesting = 0;
+ private boolean insideOpenObject = false;
+
+ public interface FieldConsumer {
+ void accept(Object object) throws IOException;
+ }
+
+ private static class Consumer implements FieldConsumer {
+ private final JsonGenerator generator;
+
+ Consumer(JsonGenerator generator) {
+ this.generator = generator;
+ }
+
+ @Override
+ public void accept(Object object) throws IOException {
+ if (object instanceof Inspectable) {
+ renderInspectorDirect(((Inspectable) object).inspect());
+ } else {
+ generator.writeObject(object);
+ }
+ }
+ private void renderInspectorDirect(Inspector data) throws IOException {
+ StringBuilder intermediate = new StringBuilder();
+ JsonRender.render(data, intermediate, true);
+ generator.writeRawValue(intermediate.toString());
+ }
+ }
+
+ TraceRenderer(JsonGenerator generator, long basetime) {
+ this(generator, new Consumer(generator), basetime);
+ }
+ public TraceRenderer(JsonGenerator generator, FieldConsumer consumer, long basetime) {
+ this.generator = generator;
+ this.fieldConsumer = consumer;
+ this.basetime = basetime;
+ }
+
+ @Override
+ public void entering(TraceNode node) {
+ ++currentChildNesting;
+ }
+
+ @Override
+ public void leaving(TraceNode node) {
+ conditionalEndObject();
+ if (currentChildNesting == emittedChildNesting) {
+ try {
+ generator.writeEndArray();
+ generator.writeEndObject();
+ } catch (IOException e) {
+ throw new TraceRenderWrapper(e);
+ }
+ --emittedChildNesting;
+ }
+ --currentChildNesting;
+ }
+
+ @Override
+ public void visit(TraceNode node) {
+ try {
+ doVisit(node.timestamp(), node.payload(), node.children().iterator().hasNext());
+ } catch (IOException e) {
+ throw new TraceRenderWrapper(e);
+ }
+ }
+
+ private void doVisit(long timestamp, Object payload, boolean hasChildren) throws IOException {
+ boolean dirty = false;
+ if (timestamp != 0L) {
+ header();
+ generator.writeStartObject();
+ generator.writeNumberField(TRACE_TIMESTAMP, timestamp - basetime);
+ dirty = true;
+ }
+ if (payload != null) {
+ if (!dirty) {
+ header();
+ generator.writeStartObject();
+ }
+ generator.writeFieldName(TRACE_MESSAGE);
+ fieldConsumer.accept(payload);
+ dirty = true;
+ }
+ if (dirty) {
+ if (!hasChildren) {
+ generator.writeEndObject();
+ } else {
+ setInsideOpenObject(true);
+ }
+ }
+ }
+ private void header() {
+ fieldName();
+ for (int i = 0; i < (currentChildNesting - emittedChildNesting); ++i) {
+ startChildArray();
+ }
+ emittedChildNesting = currentChildNesting;
+ }
+
+ private void startChildArray() {
+ try {
+ conditionalStartObject();
+ generator.writeArrayFieldStart(TRACE_CHILDREN);
+ } catch (IOException e) {
+ throw new TraceRenderWrapper(e);
+ }
+ }
+
+ private void conditionalStartObject() throws IOException {
+ if (!isInsideOpenObject()) {
+ generator.writeStartObject();
+ } else {
+ setInsideOpenObject(false);
+ }
+ }
+
+ private void conditionalEndObject() {
+ if (isInsideOpenObject()) {
+ // This triggers if we were inside a data node with payload and
+ // subnodes, but none of the subnodes contained data
+ try {
+ generator.writeEndObject();
+ setInsideOpenObject(false);
+ } catch (IOException e) {
+ throw new TraceRenderWrapper(e);
+ }
+ }
+ }
+
+ private void fieldName() {
+ if (hasFieldName) {
+ return;
+ }
+
+ try {
+ generator.writeFieldName(TRACE);
+ } catch (IOException e) {
+ throw new TraceRenderWrapper(e);
+ }
+ hasFieldName = true;
+ }
+
+ boolean isInsideOpenObject() {
+ return insideOpenObject;
+ }
+
+ void setInsideOpenObject(boolean insideOpenObject) {
+ this.insideOpenObject = insideOpenObject;
+ }
+ public static final class TraceRenderWrapper extends RuntimeException {
+
+ /**
+ * Should never be serialized, but this is still needed.
+ */
+ private static final long serialVersionUID = 2L;
+
+ TraceRenderWrapper(IOException wrapped) {
+ super(wrapped);
+ }
+
+ }
+}
diff --git a/container-core/src/main/java/com/yahoo/container/logging/VespaAccessLog.java b/container-core/src/main/java/com/yahoo/container/logging/VespaAccessLog.java
new file mode 100644
index 00000000000..254b7fe5385
--- /dev/null
+++ b/container-core/src/main/java/com/yahoo/container/logging/VespaAccessLog.java
@@ -0,0 +1,113 @@
+// Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.container.logging;
+
+import com.yahoo.component.AbstractComponent;
+import com.yahoo.container.core.AccessLogConfig;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.nio.charset.StandardCharsets;
+import java.text.SimpleDateFormat;
+import java.util.Date;
+import java.util.TimeZone;
+
+/**
+ * @author Bjorn Borud
+ * @author Oyvind Bakksjo
+ */
+public final class VespaAccessLog extends AbstractComponent implements RequestLogHandler, LogWriter<RequestLogEntry> {
+
+ private static final ThreadLocal<SimpleDateFormat> dateFormat = ThreadLocal.withInitial(VespaAccessLog::createDateFormat);
+
+ private final AccessLogHandler logHandler;
+
+ public VespaAccessLog(AccessLogConfig config) {
+ logHandler = new AccessLogHandler(config.fileHandler(), this);
+ }
+
+ private static SimpleDateFormat createDateFormat() {
+ SimpleDateFormat format = new SimpleDateFormat("[dd/MMM/yyyy:HH:mm:ss Z]");
+ format.setTimeZone(TimeZone.getTimeZone("UTC"));
+ return format;
+ }
+
+ private static String getDate() {
+ return dateFormat.get().format(new Date());
+ }
+
+ private String getRequest(final String httpMethod, final String rawPath, final String rawQuery, final String httpVersion) {
+ return httpMethod + " " + (rawQuery != null ? rawPath + "?" + rawQuery : rawPath) + " " + httpVersion;
+ }
+
+ private String getUser(String user) {
+ return (user == null) ? "-" : user;
+ }
+
+ private String toLogline(String ipAddr, String user, String request, String referer, String agent,
+ long durationMillis, long byteCount, HitCounts hitcounts, int returnCode)
+ {
+ long ms = Math.max(0L, durationMillis);
+ StringBuilder sb = new StringBuilder()
+ .append(ipAddr)
+ .append(" - ")
+ .append(getUser(user))
+ .append(' ')
+ .append(getDate())
+ .append(" \"")
+ .append(request)
+ .append("\" ")
+ .append(returnCode)
+ .append(' ')
+ .append(byteCount)
+ .append(" \"")
+ .append(referer)
+ .append("\" \"")
+ .append(agent)
+ .append("\" ")
+ .append(ms/1000)
+ .append('.');
+ decimalsOfSecondsFromMilliseconds(ms, sb);
+ sb.append(' ')
+ .append((hitcounts == null) ? 0 : hitcounts.getTotalHitCount())
+ .append(" 0.0 ")
+ .append((hitcounts == null) ? 0 : hitcounts.getSummaryCount());
+ return sb.toString();
+ }
+
+ private void decimalsOfSecondsFromMilliseconds(long ms, StringBuilder sb) {
+ long dec = ms % 1000;
+ String numbers = String.valueOf(dec);
+ if (dec <= 9) {
+ sb.append("00");
+ } else if (dec <= 99) {
+ sb.append('0');
+ }
+ sb.append(numbers);
+ }
+
+ @Override public void deconstruct() { logHandler.shutdown(); }
+
+ @Override
+ public void log(RequestLogEntry entry) {
+ logHandler.log(entry);
+ }
+
+ @Override
+ public void write(RequestLogEntry entry, OutputStream outputStream) throws IOException {
+ outputStream.write(
+ toLogline(
+ entry.peerAddress().get(),
+ null,
+ getRequest(
+ entry.httpMethod().orElse(null),
+ entry.rawPath().orElse(null),
+ entry.rawQuery().orElse(null),
+ entry.httpVersion().orElse(null)),
+ entry.referer().orElse(null),
+ entry.userAgent().orElse(null),
+ entry.duration().get().toMillis(),
+ entry.contentSize().orElse(0L),
+ entry.hitCounts().orElse(null),
+ entry.statusCode().orElse(0)).getBytes(StandardCharsets.UTF_8));
+ }
+}
diff --git a/container-core/src/main/java/com/yahoo/container/logging/package-info.java b/container-core/src/main/java/com/yahoo/container/logging/package-info.java
new file mode 100644
index 00000000000..fc2abb7b609
--- /dev/null
+++ b/container-core/src/main/java/com/yahoo/container/logging/package-info.java
@@ -0,0 +1,5 @@
+// Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+@ExportPackage
+package com.yahoo.container.logging;
+
+import com.yahoo.osgi.annotation.ExportPackage;
diff --git a/container-core/src/main/java/com/yahoo/jdisc/http/CertificateStore.java b/container-core/src/main/java/com/yahoo/jdisc/http/CertificateStore.java
new file mode 100644
index 00000000000..3a63726b951
--- /dev/null
+++ b/container-core/src/main/java/com/yahoo/jdisc/http/CertificateStore.java
@@ -0,0 +1,26 @@
+// 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;
+
+/**
+ * A store of certificates. An implementation can be plugged in to provide certificates to components who use it.
+ *
+ * @author bratseth
+ */
+public interface CertificateStore {
+
+ /** Returns a certificate for a given appid, using the default TTL and retry time */
+ default String getCertificate(String appid) { return getCertificate(appid, 0L, 0L); }
+
+ /** Returns a certificate for a given appid, using a TTL and default retry time */
+ default String getCertificate(String appid, long ttl) { return getCertificate(appid, ttl, 0L); }
+
+ /**
+ * Returns a certificate for a given appid, using a TTL and default retry time
+ *
+ * @param ttl certificate TTL in ms. Use the default TTL if set to 0
+ * @param retry if no certificate is found, allow access to cert DB again in
+ * "retry" ms. Use the default retry time if set to 0.
+ */
+ String getCertificate(String appid, long ttl, long retry);
+
+}
diff --git a/container-core/src/main/java/com/yahoo/jdisc/http/Cookie.java b/container-core/src/main/java/com/yahoo/jdisc/http/Cookie.java
new file mode 100644
index 00000000000..d882cf7a34a
--- /dev/null
+++ b/container-core/src/main/java/com/yahoo/jdisc/http/Cookie.java
@@ -0,0 +1,250 @@
+// 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;
+
+import org.eclipse.jetty.http.HttpCookie;
+import org.eclipse.jetty.server.CookieCutter;
+
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.Set;
+import java.util.concurrent.TimeUnit;
+import java.util.stream.Collectors;
+import java.util.stream.StreamSupport;
+
+import static java.util.stream.Collectors.toList;
+
+/**
+ * A RFC 6265 compliant cookie.
+ *
+ * Note: RFC 2109 and RFC 2965 is no longer supported. All fields that are not part of RFC 6265 are deprecated.
+ *
+ * @author Einar M R Rosenvinge
+ * @author bjorncs
+ */
+public class Cookie {
+
+ private final Set<Integer> ports = new HashSet<>();
+ private String name;
+ private String value;
+ private String domain;
+ private String path;
+ private SameSite sameSite;
+ private long maxAgeSeconds = Integer.MIN_VALUE;
+ private boolean secure;
+ private boolean httpOnly;
+ private boolean discard;
+
+ public Cookie() {
+ }
+
+ public Cookie(Cookie cookie) {
+ ports.addAll(cookie.ports);
+ name = cookie.name;
+ value = cookie.value;
+ domain = cookie.domain;
+ path = cookie.path;
+ sameSite = cookie.sameSite;
+ maxAgeSeconds = cookie.maxAgeSeconds;
+ secure = cookie.secure;
+ httpOnly = cookie.httpOnly;
+ discard = cookie.discard;
+ }
+
+ public Cookie(String name, String value) {
+ this.name = name;
+ this.value = value;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public Cookie setName(String name) {
+ this.name = name;
+ return this;
+ }
+
+ public String getValue() {
+ return value;
+ }
+
+ public Cookie setValue(String value) {
+ this.value = value;
+ return this;
+ }
+
+ public String getDomain() {
+ return domain;
+ }
+
+ public Cookie setDomain(String domain) {
+ this.domain = domain;
+ return this;
+ }
+
+ public String getPath() {
+ return path;
+ }
+
+ public Cookie setPath(String path) {
+ this.path = path;
+ return this;
+ }
+
+ public SameSite getSameSite() {
+ return sameSite;
+ }
+
+ public Cookie setSameSite(SameSite sameSite) {
+ this.sameSite = sameSite;
+ return this;
+ }
+
+ public int getMaxAge(TimeUnit unit) {
+ return (int)unit.convert(maxAgeSeconds, TimeUnit.SECONDS);
+ }
+
+ public Cookie setMaxAge(int maxAge, TimeUnit unit) {
+ this.maxAgeSeconds = maxAge >= 0 ? unit.toSeconds(maxAge) : Integer.MIN_VALUE;
+ return this;
+ }
+
+ public boolean isSecure() {
+ return secure;
+ }
+
+ public Cookie setSecure(boolean secure) {
+ this.secure = secure;
+ return this;
+ }
+
+ public boolean isHttpOnly() {
+ return httpOnly;
+ }
+
+ public Cookie setHttpOnly(boolean httpOnly) {
+ this.httpOnly = httpOnly;
+ return this;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+ Cookie cookie = (Cookie) o;
+ return maxAgeSeconds == cookie.maxAgeSeconds &&
+ secure == cookie.secure &&
+ httpOnly == cookie.httpOnly &&
+ discard == cookie.discard &&
+ sameSite == cookie.sameSite &&
+ Objects.equals(ports, cookie.ports) &&
+ Objects.equals(name, cookie.name) &&
+ Objects.equals(value, cookie.value) &&
+ Objects.equals(domain, cookie.domain) &&
+ Objects.equals(path, cookie.path);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(ports, name, value, domain, path, sameSite, maxAgeSeconds, secure, httpOnly, discard);
+ }
+
+ @Override
+ public String toString() {
+ StringBuilder ret = new StringBuilder();
+ ret.append(name).append("=").append(value);
+ return ret.toString();
+ }
+ // NOTE cookie encoding and decoding:
+ // The implementation uses Jetty for server-side (encoding of Set-Cookie and decoding of Cookie header),
+ // and java.net.HttpCookie for client-side (encoding of Cookie and decoding of Set-Cookie header).
+ //
+ // Implementation is RFC-6265 compliant.
+
+ public static String toCookieHeader(Iterable<? extends Cookie> cookies) {
+ return StreamSupport.stream(cookies.spliterator(), false)
+ .map(cookie -> {
+ java.net.HttpCookie httpCookie = new java.net.HttpCookie(cookie.getName(), cookie.getValue());
+ httpCookie.setDomain(cookie.getDomain());
+ httpCookie.setHttpOnly(cookie.isHttpOnly());
+ httpCookie.setMaxAge(cookie.getMaxAge(TimeUnit.SECONDS));
+ httpCookie.setPath(cookie.getPath());
+ httpCookie.setSecure(cookie.isSecure());
+ httpCookie.setVersion(0);
+ return httpCookie.toString();
+ })
+ .collect(Collectors.joining(";"));
+ }
+
+ public static List<Cookie> fromCookieHeader(String headerVal) {
+ CookieCutter cookieCutter = new CookieCutter();
+ cookieCutter.addCookieField(headerVal);
+ return Arrays.stream(cookieCutter.getCookies())
+ .map(servletCookie -> {
+ Cookie cookie = new Cookie();
+ cookie.setName(servletCookie.getName());
+ cookie.setValue(servletCookie.getValue());
+ cookie.setPath(servletCookie.getPath());
+ cookie.setDomain(servletCookie.getDomain());
+ cookie.setMaxAge(servletCookie.getMaxAge(), TimeUnit.SECONDS);
+ cookie.setSecure(servletCookie.getSecure());
+ cookie.setHttpOnly(servletCookie.isHttpOnly());
+ return cookie;
+ })
+ .collect(toList());
+ }
+
+ public static List<String> toSetCookieHeaders(Iterable<? extends Cookie> cookies) {
+ return StreamSupport.stream(cookies.spliterator(), false)
+ .map(cookie ->
+ new org.eclipse.jetty.http.HttpCookie(
+ cookie.getName(),
+ cookie.getValue(),
+ cookie.getDomain(),
+ cookie.getPath(),
+ cookie.getMaxAge(TimeUnit.SECONDS),
+ cookie.isHttpOnly(),
+ cookie.isSecure(),
+ null, /* comment */
+ 0, /* version */
+ Optional.ofNullable(cookie.getSameSite()).map(SameSite::jettySameSite).orElse(null)
+ ).getRFC6265SetCookie())
+ .collect(toList());
+ }
+
+ @Deprecated // TODO Vespa 8 Remove
+ public static List<String> toSetCookieHeaderAll(Iterable<? extends Cookie> cookies) {
+ return toSetCookieHeaders(cookies);
+ }
+
+ public static Cookie fromSetCookieHeader(String headerVal) {
+ return java.net.HttpCookie.parse(headerVal).stream()
+ .map(httpCookie -> {
+ Cookie cookie = new Cookie();
+ cookie.setName(httpCookie.getName());
+ cookie.setValue(httpCookie.getValue());
+ cookie.setDomain(httpCookie.getDomain());
+ cookie.setHttpOnly(httpCookie.isHttpOnly());
+ cookie.setMaxAge((int) httpCookie.getMaxAge(), TimeUnit.SECONDS);
+ cookie.setPath(httpCookie.getPath());
+ cookie.setSecure(httpCookie.getSecure());
+ return cookie;
+ })
+ .findFirst().get();
+ }
+
+ public enum SameSite {
+ NONE, STRICT, LAX;
+
+ HttpCookie.SameSite jettySameSite() {
+ return HttpCookie.SameSite.valueOf(name());
+ }
+
+ static SameSite fromJettySameSite(HttpCookie.SameSite jettySameSite) {
+ return valueOf(jettySameSite.name());
+ }
+ }
+}
diff --git a/container-core/src/main/java/com/yahoo/jdisc/http/HttpHeaders.java b/container-core/src/main/java/com/yahoo/jdisc/http/HttpHeaders.java
new file mode 100644
index 00000000000..039966133e8
--- /dev/null
+++ b/container-core/src/main/java/com/yahoo/jdisc/http/HttpHeaders.java
@@ -0,0 +1,122 @@
+// 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;
+
+/**
+ * @author Anirudha Khanna
+ */
+@SuppressWarnings("UnusedDeclaration")
+public class HttpHeaders {
+
+ public static final class Names {
+
+ public static final String ACCEPT = "Accept";
+ public static final String ACCEPT_CHARSET = "Accept-Charset";
+ public static final String ACCEPT_ENCODING = "Accept-Encoding";
+ public static final String ACCEPT_LANGUAGE = "Accept-Language";
+ public static final String ACCEPT_RANGES = "Accept-Ranges";
+ public static final String ACCEPT_PATCH = "Accept-Patch";
+ public static final String AGE = "Age";
+ public static final String ALLOW = "Allow";
+ public static final String AUTHORIZATION = "Authorization";
+ public static final String CACHE_CONTROL = "Cache-Control";
+ public static final String CONNECTION = "Connection";
+ public static final String CONTENT_BASE = "Content-Base";
+ public static final String CONTENT_ENCODING = "Content-Encoding";
+ public static final String CONTENT_LANGUAGE = "Content-Language";
+ public static final String CONTENT_LENGTH = "Content-Length";
+ public static final String CONTENT_LOCATION = "Content-Location";
+ public static final String CONTENT_TRANSFER_ENCODING = "Content-Transfer-Encoding";
+ public static final String CONTENT_MD5 = "Content-MD5";
+ public static final String CONTENT_RANGE = "Content-Range";
+ public static final String CONTENT_TYPE = "Content-Type";
+ public static final String COOKIE = "Cookie";
+ public static final String DATE = "Date";
+ public static final String ETAG = "ETag";
+ public static final String EXPECT = "Expect";
+ public static final String EXPIRES = "Expires";
+ public static final String FROM = "From";
+ public static final String HOST = "Host";
+ public static final String IF_MATCH = "If-Match";
+ public static final String IF_MODIFIED_SINCE = "If-Modified-Since";
+ public static final String IF_NONE_MATCH = "If-None-Match";
+ public static final String IF_RANGE = "If-Range";
+ public static final String IF_UNMODIFIED_SINCE = "If-Unmodified-Since";
+ public static final String LAST_MODIFIED = "Last-Modified";
+ public static final String LOCATION = "Location";
+ public static final String MAX_FORWARDS = "Max-Forwards";
+ public static final String ORIGIN = "Origin";
+ public static final String PRAGMA = "Pragma";
+ public static final String PROXY_AUTHENTICATE = "Proxy-Authenticate";
+ public static final String PROXY_AUTHORIZATION = "Proxy-Authorization";
+ public static final String RANGE = "Range";
+ public static final String REFERER = "Referer";
+ public static final String RETRY_AFTER = "Retry-After";
+ public static final String SEC_WEBSOCKET_KEY1 = "Sec-WebSocket-Key1";
+ public static final String SEC_WEBSOCKET_KEY2 = "Sec-WebSocket-Key2";
+ public static final String SEC_WEBSOCKET_LOCATION = "Sec-WebSocket-Location";
+ public static final String SEC_WEBSOCKET_ORIGIN = "Sec-WebSocket-Origin";
+ public static final String SEC_WEBSOCKET_PROTOCOL = "Sec-WebSocket-Protocol";
+ public static final String SEC_WEBSOCKET_VERSION = "Sec-WebSocket-Version";
+ public static final String SEC_WEBSOCKET_KEY = "Sec-WebSocket-Key";
+ public static final String SEC_WEBSOCKET_ACCEPT = "Sec-WebSocket-Accept";
+ public static final String SERVER = "Server";
+ public static final String SET_COOKIE = "Set-Cookie";
+ public static final String SET_COOKIE2 = "Set-Cookie2";
+ public static final String TE = "TE";
+ public static final String TRAILER = "Trailer";
+ public static final String TRANSFER_ENCODING = "Transfer-Encoding";
+ public static final String UPGRADE = "Upgrade";
+ public static final String USER_AGENT = "User-Agent";
+ public static final String VARY = "Vary";
+ public static final String VIA = "Via";
+ public static final String WARNING = "Warning";
+ public static final String WEBSOCKET_LOCATION = "WebSocket-Location";
+ public static final String WEBSOCKET_ORIGIN = "WebSocket-Origin";
+ public static final String WEBSOCKET_PROTOCOL = "WebSocket-Protocol";
+ public static final String WWW_AUTHENTICATE = "WWW-Authenticate";
+ public static final String X_DISABLE_CHUNKING = "X-JDisc-Disable-Chunking";
+ public static final String X_YAHOO_SERVING_HOST = "X-Yahoo-Serving-Host";
+
+ private Names() {
+ // hide
+ }
+ }
+
+ public static final class Values {
+
+ public static final String APPLICATION_X_WWW_FORM_URLENCODED = "application/x-www-form-urlencoded";
+ public static final String BASE64 = "base64";
+ public static final String BINARY = "binary";
+ public static final String BYTES = "bytes";
+ public static final String CHARSET = "charset";
+ public static final String CHUNKED = "chunked";
+ public static final String CLOSE = "close";
+ public static final String COMPRESS = "compress";
+ public static final String CONTINUE = "100-continue";
+ public static final String DEFLATE = "deflate";
+ public static final String GZIP = "gzip";
+ public static final String IDENTITY = "identity";
+ public static final String KEEP_ALIVE = "keep-alive";
+ public static final String MAX_AGE = "max-age";
+ public static final String MAX_STALE = "max-stale";
+ public static final String MIN_FRESH = "min-fresh";
+ public static final String MUST_REVALIDATE = "must-revalidate";
+ public static final String NO_CACHE = "no-cache";
+ public static final String NO_STORE = "no-store";
+ public static final String NO_TRANSFORM = "no-transform";
+ public static final String NONE = "none";
+ public static final String ONLY_IF_CACHED = "only-if-cached";
+ public static final String PRIVATE = "private";
+ public static final String PROXY_REVALIDATE = "proxy-revalidate";
+ public static final String PUBLIC = "public";
+ public static final String QUOTED_PRINTABLE = "quoted-printable";
+ public static final String S_MAXAGE = "s-maxage";
+ public static final String TRAILERS = "trailers";
+ public static final String UPGRADE = "Upgrade";
+ public static final String WEBSOCKET = "WebSocket";
+
+ private Values() {
+ // hide
+ }
+ }
+}
diff --git a/container-core/src/main/java/com/yahoo/jdisc/http/HttpRequest.java b/container-core/src/main/java/com/yahoo/jdisc/http/HttpRequest.java
new file mode 100644
index 00000000000..118c34245c0
--- /dev/null
+++ b/container-core/src/main/java/com/yahoo/jdisc/http/HttpRequest.java
@@ -0,0 +1,342 @@
+// 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;
+
+import com.yahoo.jdisc.HeaderFields;
+import com.yahoo.jdisc.Request;
+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.servlet.ServletOrJdiscHttpRequest;
+import com.yahoo.jdisc.service.CurrentContainer;
+import org.eclipse.jetty.http.HttpURI;
+import org.eclipse.jetty.util.MultiMap;
+
+import java.net.InetAddress;
+import java.net.InetSocketAddress;
+import java.net.SocketAddress;
+import java.net.URI;
+import java.security.Principal;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * A HTTP request.
+ *
+ * @author Anirudha Khanna
+ * @author Einar M R Rosenvinge
+ */
+public class HttpRequest extends Request implements ServletOrJdiscHttpRequest {
+
+ public enum Method {
+ OPTIONS,
+ GET,
+ HEAD,
+ POST,
+ PUT,
+ PATCH,
+ DELETE,
+ TRACE,
+ CONNECT
+ }
+
+ public enum Version {
+ HTTP_1_0("HTTP/1.0"),
+ HTTP_1_1("HTTP/1.1");
+
+ private final String str;
+
+ private Version(String str) {
+ this.str = str;
+ }
+
+ @Override
+ public String toString() {
+ return str;
+ }
+
+ public static Version fromString(String str) {
+ for (Version version : values()) {
+ if (version.str.equals(str)) {
+ return version;
+ }
+ }
+ throw new IllegalArgumentException(str);
+ }
+ }
+
+ private final HeaderFields trailers = new HeaderFields();
+ private final Map<String, List<String>> parameters = new HashMap<>();
+ private Principal principal;
+ private final long connectedAt;
+ private Method method;
+ private Version version;
+ private SocketAddress remoteAddress;
+ private URI proxyServer;
+ private Long connectionTimeout;
+
+ protected HttpRequest(CurrentContainer container, URI uri, Method method, Version version,
+ SocketAddress remoteAddress, Long connectedAtMillis)
+ {
+ super(container, uri);
+ try {
+ this.method = method;
+ this.version = version;
+ this.remoteAddress = remoteAddress;
+ this.parameters.putAll(getUriQueryParameters(uri));
+ if (connectedAtMillis != null) {
+ this.connectedAt = connectedAtMillis;
+ } else {
+ this.connectedAt = creationTime(TimeUnit.MILLISECONDS);
+ }
+ } catch (RuntimeException e) {
+ release();
+ throw e;
+ }
+ }
+
+ private HttpRequest(Request parent, URI uri, Method method, Version version) {
+ super(parent, uri);
+ try {
+ this.method = method;
+ this.version = version;
+ this.remoteAddress = null;
+ this.parameters.putAll(getUriQueryParameters(uri));
+ this.connectedAt = creationTime(TimeUnit.MILLISECONDS);
+ } catch (RuntimeException e) {
+ release();
+ throw e;
+ }
+ }
+
+ private static Map<String, List<String>> getUriQueryParameters(URI uri) {
+ MultiMap<String> queryParameters = new MultiMap<>();
+ new HttpURI(uri).decodeQueryTo(queryParameters);
+
+ // Do a deep copy so we do not leak Jetty classes outside
+ Map<String, List<String>> deepCopiedQueryParameters = new HashMap<>();
+ for (Map.Entry<String, List<String>> entry : queryParameters.entrySet()) {
+ deepCopiedQueryParameters.put(entry.getKey(), new ArrayList<>(entry.getValue()));
+ }
+ return deepCopiedQueryParameters;
+ }
+
+ public Method getMethod() {
+ return method;
+ }
+
+ public void setMethod(Method method) {
+ this.method = method;
+ }
+
+ public Version getVersion() {
+ return version;
+ }
+
+ /** Returns the remote address, or null if unresolved */
+ @Override
+ public String getRemoteHostAddress() {
+ if (remoteAddress instanceof InetSocketAddress) {
+ InetAddress remoteInetAddress = ((InetSocketAddress) remoteAddress).getAddress();
+ if (remoteInetAddress == null)
+ return null;
+ return remoteInetAddress.getHostAddress();
+ }
+ else {
+ throw new RuntimeException("Unknown SocketAddress class: " + remoteAddress.getClass().getName());
+ }
+ }
+
+ @Override
+ public String getRemoteHostName() {
+ if (remoteAddress instanceof InetSocketAddress) {
+ InetAddress remoteInetAddress = ((InetSocketAddress) remoteAddress).getAddress();
+ if (remoteInetAddress == null) return null; // not resolved; we have no network
+ return remoteInetAddress.getHostName();
+ }
+ else {
+ throw new RuntimeException("Unknown SocketAddress class: " + remoteAddress.getClass().getName());
+ }
+ }
+
+ @Override
+ public int getRemotePort() {
+ if (remoteAddress instanceof InetSocketAddress)
+ return ((InetSocketAddress) remoteAddress).getPort();
+ else
+ throw new RuntimeException("Unknown SocketAddress class: " + remoteAddress.getClass().getName());
+ }
+
+ public void setVersion(Version version) {
+ this.version = version;
+ }
+
+ public SocketAddress getRemoteAddress() {
+ return remoteAddress;
+ }
+
+ public void setRemoteAddress(SocketAddress remoteAddress) {
+ this.remoteAddress = remoteAddress;
+ }
+
+ public URI getProxyServer() {
+ return proxyServer;
+ }
+
+ public void setProxyServer(URI proxyServer) {
+ this.proxyServer = proxyServer;
+ }
+
+ /**
+ * <p>For server requests, this returns the timestamp of when the underlying HTTP channel was connected.
+ *
+ * <p>For client requests, this returns the same value as {@link #creationTime(java.util.concurrent.TimeUnit)}.</p>
+ *
+ * @param unit the unit to return the time in
+ * @return the timestamp of when the underlying HTTP channel was connected, or request creation time
+ */
+ @Override
+ public long getConnectedAt(TimeUnit unit) {
+ return unit.convert(connectedAt, TimeUnit.MILLISECONDS);
+ }
+
+ public Long getConnectionTimeout(TimeUnit unit) {
+ if (connectionTimeout == null) {
+ return null;
+ }
+ return unit.convert(connectionTimeout, TimeUnit.MILLISECONDS);
+ }
+
+ /**
+ * <p>Sets the allocated time that this HttpRequest is allowed to spend trying to connect to a remote host. This has
+ * no effect on an HttpRequest received by a {@link RequestHandler}. If no connection timeout is assigned to an
+ * HttpRequest, it defaults the connection-timeout in the client configuration.</p>
+ *
+ * <p><b>NOTE:</b> Where {@link Request#setTimeout(long, TimeUnit)} sets the expiration time between calling a
+ * RequestHandler and a {@link ResponseHandler}, this method sets the expiration time of the connect-operation as
+ * performed by the client.</p>
+ *
+ * @param timeout The allocated amount of time.
+ * @param unit The time unit of the <em>timeout</em> argument.
+ */
+ public void setConnectionTimeout(long timeout, TimeUnit unit) {
+ this.connectionTimeout = unit.toMillis(timeout);
+ }
+
+ public Map<String, List<String>> parameters() {
+ return parameters;
+ }
+
+ @Override
+ public void copyHeaders(HeaderFields target) {
+ target.addAll(headers());
+ }
+
+ public List<Cookie> decodeCookieHeader() {
+ List<String> cookies = headers().get(HttpHeaders.Names.COOKIE);
+ if (cookies == null) {
+ return Collections.emptyList();
+ }
+ List<Cookie> ret = new LinkedList<>();
+ for (String cookie : cookies) {
+ ret.addAll(Cookie.fromCookieHeader(cookie));
+ }
+ return ret;
+ }
+
+ public void encodeCookieHeader(List<Cookie> cookies) {
+ headers().put(HttpHeaders.Names.COOKIE, Cookie.toCookieHeader(cookies));
+ }
+
+ /**
+ * <p>Returns the set of trailer header fields of this HttpRequest. These are typically meta-data that should have
+ * been part of {@link #headers()}, but were not available prior to calling {@link #connect(ResponseHandler)}. You
+ * must NOT WRITE to these headers AFTER calling {@link ContentChannel#close(CompletionHandler)}, and you must NOT
+ * READ from these headers BEFORE {@link ContentChannel#close(CompletionHandler)} has been called.</p>
+ *
+ * <p><b>NOTE:</b> These headers are NOT thread-safe. You need to explicitly synchronized on the returned object to
+ * prevent concurrency issues such as ConcurrentModificationExceptions.</p>
+ *
+ * @return The trailer headers of this HttpRequest.
+ */
+ public HeaderFields trailers() {
+ return trailers;
+ }
+
+ /**
+ * Returns whether this request was <em>explicitly</em> chunked from the client.&nbsp;NOTE that there are cases
+ * where the underlying HTTP server library (Netty for the time being) will read the request in a chunked manner. An
+ * application MUST wait for {@link com.yahoo.jdisc.handler.ContentChannel#close(com.yahoo.jdisc.handler.CompletionHandler)}
+ * before it can actually know that it has received the entire request.
+ *
+ * @return true if this request was chunked from the client.
+ */
+ public boolean isChunked() {
+ return version == Version.HTTP_1_1 &&
+ headers().containsIgnoreCase(HttpHeaders.Names.TRANSFER_ENCODING, HttpHeaders.Values.CHUNKED);
+ }
+
+ public boolean hasChunkedResponse() {
+ return version == Version.HTTP_1_1 &&
+ !headers().isTrue(HttpHeaders.Names.X_DISABLE_CHUNKING);
+ }
+
+ public boolean isKeepAlive() {
+ if (headers().containsIgnoreCase(HttpHeaders.Names.CONNECTION, HttpHeaders.Values.KEEP_ALIVE)) {
+ return true;
+ }
+ if (headers().containsIgnoreCase(HttpHeaders.Names.CONNECTION, HttpHeaders.Values.CLOSE)) {
+ return false;
+ }
+ return version == Version.HTTP_1_1;
+ }
+
+ public Principal getUserPrincipal() {
+ return principal;
+ }
+
+ public void setUserPrincipal(Principal principal) {
+ this.principal = principal;
+ }
+
+ public static HttpRequest newServerRequest(CurrentContainer container, URI uri) {
+ return newServerRequest(container, uri, Method.GET);
+ }
+
+ public static HttpRequest newServerRequest(CurrentContainer container, URI uri, Method method) {
+ return newServerRequest(container, uri, method, Version.HTTP_1_1);
+ }
+
+ public static HttpRequest newServerRequest(CurrentContainer container, URI uri, Method method, Version version) {
+ return newServerRequest(container, uri, method, version, null);
+ }
+
+ public static HttpRequest newServerRequest(CurrentContainer container, URI uri, Method method, Version version,
+ SocketAddress remoteAddress) {
+ return new HttpRequest(container, uri, method, version, remoteAddress, null);
+ }
+
+ public static HttpRequest newServerRequest(CurrentContainer container, URI uri, Method method, Version version,
+ SocketAddress remoteAddress, long connectedAtMillis)
+ {
+ return new HttpRequest(container, uri, method, version, remoteAddress, connectedAtMillis);
+ }
+
+ public static HttpRequest newClientRequest(Request parent, URI uri) {
+ return newClientRequest(parent, uri, Method.GET);
+ }
+
+ public static HttpRequest newClientRequest(Request parent, URI uri, Method method) {
+ return newClientRequest(parent, uri, method, Version.HTTP_1_1);
+ }
+
+ public static HttpRequest newClientRequest(Request parent, URI uri, Method method, Version version) {
+ return new HttpRequest(parent, uri, method, version);
+ }
+
+}
diff --git a/container-core/src/main/java/com/yahoo/jdisc/http/HttpResponse.java b/container-core/src/main/java/com/yahoo/jdisc/http/HttpResponse.java
new file mode 100644
index 00000000000..f7138ba0e2b
--- /dev/null
+++ b/container-core/src/main/java/com/yahoo/jdisc/http/HttpResponse.java
@@ -0,0 +1,125 @@
+// 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;
+
+import com.yahoo.jdisc.HeaderFields;
+import com.yahoo.jdisc.Request;
+import com.yahoo.jdisc.Response;
+import com.yahoo.jdisc.handler.CompletionHandler;
+import com.yahoo.jdisc.handler.ContentChannel;
+import com.yahoo.jdisc.handler.ResponseHandler;
+import com.yahoo.jdisc.http.servlet.ServletOrJdiscHttpResponse;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.LinkedList;
+import java.util.List;
+
+/**
+ * A HTTP response.
+ *
+ * @author Einar M R Rosenvinge
+ */
+public class HttpResponse extends Response implements ServletOrJdiscHttpResponse {
+
+ private final HeaderFields trailers = new HeaderFields();
+ private boolean chunkedEncodingEnabled = true;
+ private String message;
+
+ public interface Status extends Response.Status {
+ int REQUEST_ENTITY_TOO_LARGE = REQUEST_TOO_LONG;
+ int REQUEST_RANGE_NOT_SATISFIABLE = REQUESTED_RANGE_NOT_SATISFIABLE;
+ }
+
+ protected HttpResponse(Request request, int status, String message, Throwable error) {
+ super(status, error);
+ this.message = message;
+ }
+
+ public boolean isChunkedEncodingEnabled() {
+ if (headers().contains(HttpHeaders.Names.TRANSFER_ENCODING, HttpHeaders.Values.CHUNKED)) {
+ return true;
+ }
+ if (headers().containsKey(HttpHeaders.Names.CONTENT_LENGTH)) {
+ return false;
+ }
+ return chunkedEncodingEnabled;
+ }
+
+ public void setChunkedEncodingEnabled(boolean chunkedEncodingEnabled) {
+ this.chunkedEncodingEnabled = chunkedEncodingEnabled;
+ }
+
+ public void setMessage(String message) {
+ this.message = message;
+ }
+
+ public String getMessage() {
+ return message;
+ }
+
+ @Override
+ public void copyHeaders(HeaderFields target) {
+ target.addAll(headers());
+ }
+
+ public List<Cookie> decodeSetCookieHeader() {
+ List<String> cookies = headers().get(HttpHeaders.Names.SET_COOKIE);
+ if (cookies == null) {
+ return Collections.emptyList();
+ }
+ List<Cookie> ret = new LinkedList<>();
+ for (String cookie : cookies) {
+ ret.add(Cookie.fromSetCookieHeader(cookie));
+ }
+ return ret;
+ }
+
+ public void encodeSetCookieHeader(List<Cookie> cookies) {
+ headers().remove(HttpHeaders.Names.SET_COOKIE);
+ for (Cookie cookie : cookies) {
+ headers().add(HttpHeaders.Names.SET_COOKIE, Cookie.toSetCookieHeaders(Arrays.asList(cookie)));
+ }
+ }
+
+ /**
+ * <p>Returns the set of trailer header fields of this HttpResponse. These are typically meta-data that should have
+ * been part of {@link #headers()}, but were not available prior to calling {@link
+ * ResponseHandler#handleResponse(Response)}. You must NOT WRITE to these headers AFTER calling {@link
+ * ContentChannel#close(CompletionHandler)}, and you must NOT READ from these headers BEFORE {@link
+ * ContentChannel#close(CompletionHandler)} has been called.</p>
+ *
+ * <p><b>NOTE:</b> These headers are NOT thread-safe. You need to explicitly synchronized on the returned object to
+ * prevent concurrency issues such as ConcurrentModificationExceptions.</p>
+ *
+ * @return The trailer headers of this HttpRequest.
+ */
+ public HeaderFields trailers() {
+ return trailers;
+ }
+
+ public static boolean isServerError(Response response) {
+ return (response.getStatus() >= 500) && (response.getStatus() < 600);
+ }
+
+ public static HttpResponse newInstance(int status) {
+ return new HttpResponse(null, status, null, null);
+ }
+
+ public static HttpResponse newInstance(int status, String message) {
+ return new HttpResponse(null, status, message, null);
+ }
+
+ public static HttpResponse newError(Request request, int status, Throwable error) {
+ return new HttpResponse(request, status, formatMessage(error), error);
+ }
+
+ public static HttpResponse newInternalServerError(Request request, Throwable error) {
+ return new HttpResponse(request, Status.INTERNAL_SERVER_ERROR, formatMessage(error), error);
+ }
+
+ private static String formatMessage(Throwable t) {
+ String msg = t.getMessage();
+ return msg != null ? msg : t.toString();
+ }
+
+}
diff --git a/container-core/src/main/java/com/yahoo/jdisc/http/SecretStore.java b/container-core/src/main/java/com/yahoo/jdisc/http/SecretStore.java
new file mode 100644
index 00000000000..4f739c5bd78
--- /dev/null
+++ b/container-core/src/main/java/com/yahoo/jdisc/http/SecretStore.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;
+
+/**
+ * An abstraction of a secret store for e.g passwords.
+ * Implementations can be plugged in to provide passwords for various keys.
+ *
+ * @author bratseth
+ * @author bjorncs
+ * @deprecated Use com.yahoo.container.jdisc.secretstore.SecretStore
+ */
+@Deprecated // Vespa 8
+public interface SecretStore {
+
+ /** Returns the secret for this key */
+ String getSecret(String key);
+
+ /** Returns the secret for this key and version */
+ default String getSecret(String key, int version) {
+ throw new UnsupportedOperationException("SecretStore implementation does not support versioned secrets");
+ }
+
+}
diff --git a/container-core/src/main/java/com/yahoo/jdisc/http/cloud/package-info.java b/container-core/src/main/java/com/yahoo/jdisc/http/cloud/package-info.java
new file mode 100644
index 00000000000..43da1a82077
--- /dev/null
+++ b/container-core/src/main/java/com/yahoo/jdisc/http/cloud/package-info.java
@@ -0,0 +1,4 @@
+// Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+@ExportPackage
+package com.yahoo.jdisc.http.cloud;
+import com.yahoo.osgi.annotation.ExportPackage;
diff --git a/container-core/src/main/java/com/yahoo/jdisc/http/filter/DiscFilterRequest.java b/container-core/src/main/java/com/yahoo/jdisc/http/filter/DiscFilterRequest.java
new file mode 100644
index 00000000000..f7ab399574c
--- /dev/null
+++ b/container-core/src/main/java/com/yahoo/jdisc/http/filter/DiscFilterRequest.java
@@ -0,0 +1,543 @@
+// 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.filter;
+
+import com.yahoo.jdisc.HeaderFields;
+import com.yahoo.jdisc.http.Cookie;
+import com.yahoo.jdisc.http.HttpHeaders;
+import com.yahoo.jdisc.http.HttpRequest;
+import com.yahoo.jdisc.http.HttpRequest.Version;
+import com.yahoo.jdisc.http.servlet.ServletOrJdiscHttpRequest;
+
+import java.net.InetSocketAddress;
+import java.net.URI;
+import java.security.Principal;
+import java.security.cert.X509Certificate;
+import java.text.ParseException;
+import java.text.SimpleDateFormat;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Date;
+import java.util.Enumeration;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+import java.util.regex.Pattern;
+
+/**
+ * The Request class on which all filters will operate upon.
+ * Test cases that need a concrete
+ * instance should create a {@link JdiscFilterRequest}.
+ */
+public abstract class DiscFilterRequest {
+
+ protected static final String HTTPS_PREFIX = "https";
+ protected static final int DEFAULT_HTTP_PORT = 80;
+ protected static final int DEFAULT_HTTPS_PORT = 443;
+
+ private final ServletOrJdiscHttpRequest parent;
+ protected final Map<String, List<String>> untreatedParams;
+ private final HeaderFields untreatedHeaders;
+ private List<Cookie> untreatedCookies = null;
+ private String remoteUser = null;
+ private String[] roles = null;
+ private boolean overrideIsUserInRole = false;
+
+ public DiscFilterRequest(ServletOrJdiscHttpRequest parent) {
+ this.parent = parent;
+
+ // save untreated headers from parent
+ untreatedHeaders = new HeaderFields();
+ parent.copyHeaders(untreatedHeaders);
+
+ untreatedParams = new HashMap<>(parent.parameters());
+ }
+
+ public abstract String getMethod();
+
+ public Version getVersion() {
+ return parent.getVersion();
+ }
+
+ public URI getUri() {
+ return parent.getUri();
+ }
+
+ public abstract void setUri(URI uri);
+
+ public HttpRequest getParentRequest() {
+ throw new UnsupportedOperationException("getParentRequest is not supported for " + parent.getClass().getName());
+ }
+
+ /**
+ * Returns the Internet Protocol (IP) address of the client
+ * or last proxy that sent the request.
+ */
+ public String getRemoteAddr() {
+ return parent.getRemoteHostAddress();
+ }
+
+ /**
+ * Set the IP address of the remote client associated with this Request.
+ */
+ public void setRemoteAddr(String remoteIpAddress) {
+ InetSocketAddress remoteAddress = new InetSocketAddress(remoteIpAddress, this.getRemotePort());
+ parent.setRemoteAddress(remoteAddress);
+ }
+
+ /**
+ * Returns the Internet Protocol (IP) address of the interface
+ * on which the request was received.
+ */
+ public String getLocalAddr() {
+ InetSocketAddress localAddress = localAddress();
+ if (localAddress.getAddress() == null) return null;
+ return localAddress.getAddress().getHostAddress();
+ }
+
+ private InetSocketAddress localAddress() {
+ int port = parent.getUri().getPort();
+ if (port < 0)
+ port = 0;
+ return new InetSocketAddress(parent.getUri().getHost(), port);
+ }
+
+ public Enumeration<String> getAttributeNames() {
+ return Collections.enumeration(parent.context().keySet());
+ }
+
+ public Object getAttribute(String name) {
+ return parent.context().get(name);
+ }
+
+ public void setAttribute(String name, Object value) {
+ parent.context().put(name, value);
+ }
+
+ public boolean containsAttribute(String name) {
+ return parent.context().containsKey(name);
+ }
+
+ public void removeAttribute(String name) {
+ parent.context().remove(name);
+ }
+
+ public abstract String getParameter(String name);
+
+ public abstract Enumeration<String> getParameterNames();
+
+ public List<String> getParameterNamesAsList() {
+ return new ArrayList<String>(parent.parameters().keySet());
+ }
+
+ public Enumeration<String> getParameterValues(String name) {
+ return Collections.enumeration(parent.parameters().get(name));
+ }
+
+ public List<String> getParameterValuesAsList(String name) {
+ return parent.parameters().get(name);
+ }
+
+ public Map<String,List<String>> getParameterMap() {
+ return parent.parameters();
+ }
+
+
+ /**
+ * Returns the hostName of remoteHost, or null if none
+ */
+ public String getRemoteHost() {
+ return parent.getRemoteHostName();
+ }
+
+ /**
+ * Returns the Internet Protocol (IP) port number of
+ * the interface on which the request was received.
+ */
+ public int getLocalPort() {
+ return localAddress().getPort();
+ }
+
+ /**
+ * Returns the port of remote host
+ */
+ public int getRemotePort() {
+ return parent.getRemotePort();
+ }
+
+ /**
+ * Returns a unmodifiable map of untreatedParameters from the
+ * parent request.
+ */
+ public Map<String, List<String>> getUntreatedParams() {
+ return Collections.unmodifiableMap(untreatedParams);
+ }
+
+
+ /**
+ * Returns the untreatedHeaders from
+ * parent request
+ */
+ public HeaderFields getUntreatedHeaders() {
+ return untreatedHeaders;
+ }
+
+ /**
+ * Returns the untreatedCookies from
+ * parent request
+ */
+ public List<Cookie> getUntreatedCookies() {
+ if (untreatedCookies == null) {
+ this.untreatedCookies = parent.decodeCookieHeader();
+ }
+ return Collections.unmodifiableList(untreatedCookies);
+ }
+
+ /**
+ * Sets a header with the given name and value.
+ * If the header had already been set, the new value overwrites the previous one.
+ */
+ public abstract void addHeader(String name, String value);
+
+ public long getDateHeader(String name) {
+ String value = getHeader(name);
+ if (value == null)
+ return -1L;
+
+ Date date = null;
+ for (int i = 0; (date == null) && (i < formats.length); i++) {
+ try {
+ date = formats[i].parse(value);
+ } catch (ParseException e) {
+ }
+ }
+ if (date == null) {
+ return -1L;
+ }
+
+ return date.getTime();
+ }
+
+ public abstract String getHeader(String name);
+
+ public abstract Enumeration<String> getHeaderNames();
+
+ public abstract List<String> getHeaderNamesAsList();
+
+ public abstract Enumeration<String> getHeaders(String name);
+
+ public abstract List<String> getHeadersAsList(String name);
+
+ public abstract void removeHeaders(String name);
+
+ /**
+ * Sets a header with the given name and value.
+ * If the header had already been set, the new value overwrites the previous one.
+ *
+ */
+ public abstract void setHeaders(String name, String value);
+
+ /**
+ * Sets a header with the given name and value.
+ * If the header had already been set, the new value overwrites the previous one.
+ *
+ */
+ public abstract void setHeaders(String name, List<String> values);
+
+ public int getIntHeader(String name) {
+ String value = getHeader(name);
+ if (value == null) {
+ return -1;
+ } else {
+ return Integer.parseInt(value);
+ }
+ }
+
+
+ public List<Cookie> getCookies() {
+ return parent.decodeCookieHeader();
+ }
+
+ public void setCookies(List<Cookie> cookies) {
+ parent.encodeCookieHeader(cookies);
+ }
+
+ public long getConnectedAt(TimeUnit unit) {
+ return parent.getConnectedAt(unit);
+ }
+
+ public String getProtocol() {
+ return getVersion().name();
+ }
+
+ /**
+ * Returns the query string that is contained in the request URL.
+ * Returns the undecoded value uri.getRawQuery()
+ */
+ public String getQueryString() {
+ return getUri().getRawQuery();
+ }
+
+ /**
+ * Returns the login of the user making this request,
+ * if the user has been authenticated, or null if the user has not been authenticated.
+ */
+ public String getRemoteUser() {
+ return remoteUser;
+ }
+
+ public String getRequestURI() {
+ return getUri().getRawPath();
+ }
+
+ public String getRequestedSessionId() {
+ return null;
+ }
+
+ public String getScheme() {
+ return getUri().getScheme();
+ }
+
+ public void setScheme(String scheme, boolean isSecure) {
+ String uri = getUri().toString();
+ String arr [] = uri.split("://");
+ URI newUri = URI.create(scheme + "://" + arr[1]);
+ setUri(newUri);
+ }
+
+ public String getServerName() {
+ return getUri().getHost();
+ }
+
+ public int getServerPort() {
+ int port = getUri().getPort();
+ if(port == -1) {
+ if(isSecure()) {
+ port = DEFAULT_HTTPS_PORT;
+ }
+ else {
+ port = DEFAULT_HTTP_PORT;
+ }
+ }
+
+ return port;
+ }
+
+ public abstract Principal getUserPrincipal();
+
+ public boolean isSecure() {
+ if(getScheme().equalsIgnoreCase(HTTPS_PREFIX)) {
+ return true;
+ }
+ return false;
+ }
+
+
+ /**
+ * Returns a boolean indicating whether the authenticated user
+ * is included in the specified logical "role".
+ */
+ public boolean isUserInRole(String role) {
+ if (overrideIsUserInRole) {
+ if (roles != null) {
+ for (String role1 : roles) {
+ if (role1 != null && role1.trim().length() > 0) {
+ String userRole = role1.trim();
+ if (userRole.equals(role)) {
+ return true;
+ }
+ }
+ }
+ }
+ return false;
+ }
+ else {
+ return false;
+ }
+ }
+
+ public void setOverrideIsUserInRole(boolean overrideIsUserInRole) {
+ this.overrideIsUserInRole = overrideIsUserInRole;
+ }
+
+ public void setRemoteHost(String remoteAddr) { }
+
+ public void setRemoteUser(String remoteUser) {
+ this.remoteUser = remoteUser;
+ }
+
+ public abstract void setUserPrincipal(Principal principal);
+
+ /**
+ * @return The client certificate chain in ascending order of trust. The first certificate is the one sent from the client.
+ * Returns an empty list if the client did not provide a certificate.
+ */
+ public abstract List<X509Certificate> getClientCertificateChain();
+
+ public void setUserRoles(String[] roles) {
+ this.roles = roles;
+ }
+
+ /**
+ * Returns the content-type for the request
+ */
+ public String getContentType() {
+ return getHeader(HttpHeaders.Names.CONTENT_TYPE);
+ }
+
+
+ /**
+ * Get character encoding
+ */
+ public String getCharacterEncoding() {
+ return getCharsetFromContentType(this.getContentType());
+ }
+
+ /**
+ * Set character encoding
+ */
+ public void setCharacterEncoding(String encoding) {
+ String charEncoding = setCharsetFromContentType(this.getContentType(), encoding);
+ if (charEncoding != null && !charEncoding.isEmpty()) {
+ removeHeaders(HttpHeaders.Names.CONTENT_TYPE);
+ setHeaders(HttpHeaders.Names.CONTENT_TYPE, charEncoding);
+ }
+ }
+
+ /**
+ * Can be called multiple times to add Cookies
+ */
+ public void addCookie(JDiscCookieWrapper cookie) {
+ if (cookie != null) {
+ List<Cookie> cookies = new ArrayList<>();
+ // Get current set of cookies first
+ List<Cookie> c = getCookies();
+ if (c != null && !c.isEmpty()) {
+ cookies.addAll(c);
+ }
+ cookies.add(cookie.getCookie());
+ setCookies(cookies);
+ }
+ }
+
+ public abstract void clearCookies();
+
+ public JDiscCookieWrapper[] getWrappedCookies() {
+ List<Cookie> cookies = getCookies();
+ if (cookies == null) {
+ return null;
+ }
+ List<JDiscCookieWrapper> cookieWrapper = new ArrayList<>(cookies.size());
+ for(Cookie cookie : cookies) {
+ cookieWrapper.add(JDiscCookieWrapper.wrap(cookie));
+ }
+
+ return cookieWrapper.toArray(new JDiscCookieWrapper[cookieWrapper.size()]);
+ }
+
+ private String setCharsetFromContentType(String contentType,String charset) {
+ String newContentType = "";
+ if (contentType == null)
+ return (null);
+ int start = contentType.indexOf("charset=");
+ if (start < 0) {
+ //No charset present:
+ newContentType = contentType + ";charset=" + charset;
+ return newContentType;
+ }
+ String encoding = contentType.substring(start + 8);
+ int end = encoding.indexOf(';');
+ if (end >= 0) {
+ newContentType = contentType.substring(0,start);
+ newContentType = newContentType + "charset=" + charset;
+ newContentType = newContentType + encoding.substring(end,encoding.length());
+ }
+ else {
+ newContentType = contentType.substring(0,start);
+ newContentType = newContentType + "charset=" + charset;
+ }
+
+ return (newContentType.trim());
+
+ }
+
+ private String getCharsetFromContentType(String contentType) {
+
+ if (contentType == null)
+ return (null);
+ int start = contentType.indexOf("charset=");
+ if (start < 0)
+ return (null);
+ String encoding = contentType.substring(start + 8);
+ int end = encoding.indexOf(';');
+ if (end >= 0)
+ encoding = encoding.substring(0, end);
+ encoding = encoding.trim();
+ if ((encoding.length() > 2) && (encoding.startsWith("\""))
+ && (encoding.endsWith("\"")))
+ encoding = encoding.substring(1, encoding.length() - 1);
+ return (encoding.trim());
+
+ }
+
+ public static boolean isMultipart(DiscFilterRequest request) {
+ if (request == null) {
+ return false;
+ }
+
+ String contentType = request.getContentType();
+
+ if (contentType == null) {
+ return false;
+ }
+
+ String[] parts = Pattern.compile(";").split(contentType);
+ if (parts.length == 0) {
+ return false;
+ }
+
+ for (String part : parts) {
+ if ("multipart/form-data".equals(part)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ protected static ThreadLocalSimpleDateFormat formats[] = {
+ new ThreadLocalSimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss zzz", Locale.US),
+ new ThreadLocalSimpleDateFormat("EEEEEE, dd-MMM-yy HH:mm:ss zzz", Locale.US),
+ new ThreadLocalSimpleDateFormat("EEE MMMM d HH:mm:ss yyyy", Locale.US) };
+
+ /**
+ * The set of SimpleDateFormat formats to use in getDateHeader().
+ *
+ * Notice that because SimpleDateFormat is not thread-safe, we can't declare
+ * formats[] as a static variable.
+ */
+ protected static final class ThreadLocalSimpleDateFormat extends ThreadLocal<SimpleDateFormat> {
+
+ private final String format;
+ private final Locale locale;
+
+ public ThreadLocalSimpleDateFormat(String format, Locale locale) {
+ super();
+ this.format = format;
+ this.locale = locale;
+ }
+
+ // @see java.lang.ThreadLocal#initialValue()
+ @Override
+ protected SimpleDateFormat initialValue() {
+ return new SimpleDateFormat(format, locale);
+ }
+
+ public Date parse(String value) throws ParseException {
+ return get().parse(value);
+ }
+
+ }
+
+}
diff --git a/container-core/src/main/java/com/yahoo/jdisc/http/filter/DiscFilterResponse.java b/container-core/src/main/java/com/yahoo/jdisc/http/filter/DiscFilterResponse.java
new file mode 100644
index 00000000000..4e8b779c516
--- /dev/null
+++ b/container-core/src/main/java/com/yahoo/jdisc/http/filter/DiscFilterResponse.java
@@ -0,0 +1,154 @@
+// 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.filter;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Enumeration;
+import java.util.List;
+
+import com.yahoo.jdisc.http.servlet.ServletOrJdiscHttpResponse;
+
+import com.yahoo.jdisc.HeaderFields;
+import com.yahoo.jdisc.http.Cookie;
+
+
+import com.yahoo.jdisc.http.HttpResponse;
+
+/**
+ * This class was made abstract from 5.27. Test cases that need
+ * a concrete instance should create a {@link JdiscFilterResponse}.
+ *
+ * @author tejalk
+ */
+public abstract class DiscFilterResponse {
+
+ private final ServletOrJdiscHttpResponse parent;
+ private final HeaderFields untreatedHeaders;
+ private final List<Cookie> untreatedCookies;
+
+ public DiscFilterResponse(ServletOrJdiscHttpResponse parent) {
+ this.parent = parent;
+
+ this.untreatedHeaders = new HeaderFields();
+ parent.copyHeaders(untreatedHeaders);
+
+ this.untreatedCookies = getCookies();
+ }
+
+ /* Attributes on the response are only used for unit testing.
+ * There is no such thing as 'attributes' in the underlying response. */
+
+ public Enumeration<String> getAttributeNames() {
+ return Collections.enumeration(parent.context().keySet());
+ }
+
+ public Object getAttribute(String name) {
+ return parent.context().get(name);
+ }
+
+ public void setAttribute(String name, Object value) {
+ parent.context().put(name, value);
+ }
+
+ public void removeAttribute(String name) {
+ parent.context().remove(name);
+ }
+
+ /**
+ * Returns the untreatedHeaders from the parent request
+ */
+ public HeaderFields getUntreatedHeaders() {
+ return untreatedHeaders;
+ }
+
+ /**
+ * Returns the untreatedCookies from the parent request
+ */
+ public List<Cookie> getUntreatedCookies() {
+ return untreatedCookies;
+ }
+
+ /**
+ * Sets a header with the given name and value.
+ * <p>
+ * If the header had already been set, the new value overwrites the previous one.
+ */
+ public abstract void setHeader(String name, String value);
+
+ public abstract void removeHeaders(String name);
+
+ /**
+ * Sets a header with the given name and value.
+ * <p>
+ * If the header had already been set, the new value overwrites the previous one.
+ */
+ public abstract void setHeaders(String name, String value);
+
+ /**
+ * Sets a header with the given name and value.
+ * <p>
+ * If the header had already been set, the new value overwrites the previous one.
+ */
+ public abstract void setHeaders(String name, List<String> values);
+
+ /**
+ * Adds a header with the given name and value
+ * @see com.yahoo.jdisc.HeaderFields#add
+ */
+ public abstract void addHeader(String name, String value);
+
+ public abstract String getHeader(String name);
+
+ public List<Cookie> getCookies() {
+ return parent.decodeSetCookieHeader();
+ }
+
+ public abstract void setCookies(List<Cookie> cookies);
+
+ public int getStatus() {
+ return parent.getStatus();
+ }
+
+ public abstract void setStatus(int status);
+
+ /**
+ * Return the parent HttpResponse
+ */
+ public HttpResponse getParentResponse() {
+ if (parent instanceof HttpResponse)
+ return (HttpResponse)parent;
+ throw new UnsupportedOperationException(
+ "getParentResponse is not supported for " + parent.getClass().getName());
+ }
+
+ public void addCookie(JDiscCookieWrapper cookie) {
+ if(cookie != null) {
+ List<Cookie> cookies = new ArrayList<>();
+ //Get current set of cookies first
+ List<Cookie> c = getCookies();
+ if((c != null) && (! c.isEmpty())) {
+ cookies.addAll(c);
+ }
+ cookies.add(cookie.getCookie());
+ setCookies(cookies);
+ }
+ }
+
+ /**
+ * This method does not actually send the response as it
+ * does not have access to responseHandler but
+ * just sets the status. The methodName is misleading
+ * for historical reasons.
+ */
+ public void sendError(int errorCode) throws IOException {
+ setStatus(errorCode);
+ }
+
+ public void setCookie(String name, String value) {
+ Cookie cookie = new Cookie(name, value);
+ setCookies(Arrays.asList(cookie));
+ }
+
+ }
diff --git a/container-core/src/main/java/com/yahoo/jdisc/http/filter/FilterConfig.java b/container-core/src/main/java/com/yahoo/jdisc/http/filter/FilterConfig.java
new file mode 100644
index 00000000000..af9e2b5e99a
--- /dev/null
+++ b/container-core/src/main/java/com/yahoo/jdisc/http/filter/FilterConfig.java
@@ -0,0 +1,42 @@
+// 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.filter;
+
+import java.util.Collection;
+
+/**
+ * Legacy filter config. Prefer to use a regular stringly typed config class for new filters.
+ *
+ * @author tejalk
+ */
+public interface FilterConfig {
+
+ /** Returns the filter-name of this filter */
+ String getFilterName();
+
+ /** Returns the filter-class of this filter */
+ String getFilterClass();
+
+ /**
+ * Returns a String containing the value of the
+ * named initialization parameter, or null if
+ * the parameter does not exist.
+ *
+ * @param name a String specifying the name of the initialization parameter
+ * @return a String containing the value of the initialization parameter
+ */
+ String getInitParameter(String name);
+
+ /**
+ * Returns the boolean value of the init parameter. If not present returns default value
+ *
+ * @return boolean value of init parameter
+ */
+ boolean getBooleanInitParameter(String name, boolean defaultValue);
+
+ /**
+ * Returns the names of the filter's initialization parameters as an Collection of String objects,
+ * or an empty Collection if the filter has no initialization parameters.
+ */
+ Collection<String> getInitParameterNames();
+
+}
diff --git a/container-core/src/main/java/com/yahoo/jdisc/http/filter/JDiscCookieWrapper.java b/container-core/src/main/java/com/yahoo/jdisc/http/filter/JDiscCookieWrapper.java
new file mode 100644
index 00000000000..2b9c650d545
--- /dev/null
+++ b/container-core/src/main/java/com/yahoo/jdisc/http/filter/JDiscCookieWrapper.java
@@ -0,0 +1,79 @@
+// 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.filter;
+
+import com.yahoo.jdisc.http.Cookie;
+
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Wrapper of Cookie.
+ *
+ * @author Tejal Knot
+ *
+ */
+public class JDiscCookieWrapper {
+
+ private Cookie cookie;
+
+ protected JDiscCookieWrapper(Cookie cookie) {
+ this.cookie = cookie;
+ }
+
+ public static JDiscCookieWrapper wrap(Cookie cookie) {
+ return new JDiscCookieWrapper(cookie);
+ }
+
+ public String getDomain() {
+ return cookie.getDomain();
+ }
+
+ public int getMaxAge() {
+ return cookie.getMaxAge(TimeUnit.SECONDS);
+ }
+
+ public String getName() {
+ return cookie.getName();
+ }
+
+ public String getPath() {
+ return cookie.getPath();
+ }
+
+ public boolean getSecure() {
+ return cookie.isSecure();
+ }
+
+ public String getValue() {
+ return cookie.getValue();
+ }
+
+ public void setDomain(String pattern) {
+ cookie.setDomain(pattern);
+ }
+
+ public void setMaxAge(int expiry) {
+ cookie.setMaxAge(expiry, TimeUnit.SECONDS);
+ }
+
+ public void setPath(String uri) {
+ cookie.setPath(uri);
+ }
+
+ public void setSecure(boolean flag) {
+ cookie.setSecure(flag);
+ }
+
+ public void setValue(String newValue) {
+ cookie.setValue(newValue);
+ }
+
+ /**
+ * Return com.yahoo.jdisc.http.Cookie
+ *
+ * @return - cookie
+ */
+ public Cookie getCookie() {
+ return cookie;
+ }
+
+}
diff --git a/container-core/src/main/java/com/yahoo/jdisc/http/filter/JdiscFilterRequest.java b/container-core/src/main/java/com/yahoo/jdisc/http/filter/JdiscFilterRequest.java
new file mode 100644
index 00000000000..f8d9e6b2642
--- /dev/null
+++ b/container-core/src/main/java/com/yahoo/jdisc/http/filter/JdiscFilterRequest.java
@@ -0,0 +1,133 @@
+// 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.filter;
+
+import com.yahoo.jdisc.http.HttpHeaders;
+import com.yahoo.jdisc.http.HttpRequest;
+import com.yahoo.jdisc.http.servlet.ServletRequest;
+
+import java.net.URI;
+import java.security.Principal;
+import java.security.cert.X509Certificate;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Enumeration;
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * JDisc implementation of a filter request.
+ *
+ * @since 5.27
+ */
+public class JdiscFilterRequest extends DiscFilterRequest {
+
+ private final HttpRequest parent;
+
+ public JdiscFilterRequest(HttpRequest parent) {
+ super(parent);
+ this.parent = parent;
+ }
+
+ public HttpRequest getParentRequest() {
+ return parent;
+ }
+
+ public void setUri(URI uri) {
+ parent.setUri(uri);
+ }
+
+ @Override
+ public String getMethod() {
+ return parent.getMethod().name();
+ }
+
+ @Override
+ public String getParameter(String name) {
+ if(parent.parameters().containsKey(name)) {
+ return parent.parameters().get(name).get(0);
+ }
+ else {
+ return null;
+ }
+ }
+
+ @Override
+ public Enumeration<String> getParameterNames() {
+ return Collections.enumeration(parent.parameters().keySet());
+ }
+
+ @Override
+ public void addHeader(String name, String value) {
+ parent.headers().add(name, value);
+ }
+
+ @Override
+ public String getHeader(String name) {
+ List<String> values = parent.headers().get(name);
+ if (values == null || values.isEmpty()) {
+ return null;
+ }
+ return values.get(values.size() - 1);
+ }
+
+ public Enumeration<String> getHeaderNames() {
+ return Collections.enumeration(parent.headers().keySet());
+ }
+
+ public List<String> getHeaderNamesAsList() {
+ return new ArrayList<String>(parent.headers().keySet());
+ }
+
+ @Override
+ public Enumeration<String> getHeaders(String name) {
+ return Collections.enumeration(getHeadersAsList(name));
+ }
+
+ public List<String> getHeadersAsList(String name) {
+ List<String> values = parent.headers().get(name);
+ if(values == null) {
+ return Collections.<String>emptyList();
+ }
+ return parent.headers().get(name);
+ }
+
+ @Override
+ public void removeHeaders(String name) {
+ parent.headers().remove(name);
+ }
+
+ @Override
+ public void setHeaders(String name, String value) {
+ parent.headers().put(name, value);
+ }
+
+ @Override
+ public void setHeaders(String name, List<String> values) {
+ parent.headers().put(name, values);
+ }
+
+ @Override
+ public Principal getUserPrincipal() {
+ return parent.getUserPrincipal();
+ }
+
+ @Override
+ public void setUserPrincipal(Principal principal) {
+ this.parent.setUserPrincipal(principal);
+ }
+
+ @Override
+ public List<X509Certificate> getClientCertificateChain() {
+ return Optional.ofNullable(parent.context().get(ServletRequest.JDISC_REQUEST_X509CERT))
+ .map(X509Certificate[].class::cast)
+ .map(Arrays::asList)
+ .orElse(Collections.emptyList());
+ }
+
+ @Override
+ public void clearCookies() {
+ parent.headers().remove(HttpHeaders.Names.COOKIE);
+ }
+
+}
diff --git a/container-core/src/main/java/com/yahoo/jdisc/http/filter/JdiscFilterResponse.java b/container-core/src/main/java/com/yahoo/jdisc/http/filter/JdiscFilterResponse.java
new file mode 100644
index 00000000000..ff81359f93c
--- /dev/null
+++ b/container-core/src/main/java/com/yahoo/jdisc/http/filter/JdiscFilterResponse.java
@@ -0,0 +1,67 @@
+// 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.filter;
+
+import com.yahoo.jdisc.http.Cookie;
+import com.yahoo.jdisc.http.HttpResponse;
+
+import java.util.List;
+
+/**
+ * JDisc implementation of a filter request.
+ *
+ * @since 5.27
+ */
+public class JdiscFilterResponse extends DiscFilterResponse {
+
+ private final HttpResponse parent;
+
+ public JdiscFilterResponse(HttpResponse parent) {
+ super(parent);
+ this.parent = parent;
+ }
+
+ @Override
+ public void setStatus(int status) {
+ parent.setStatus(status);
+ }
+
+ @Override
+ public void setHeader(String name, String value) {
+ parent.headers().put(name, value);
+ }
+
+ @Override
+ public void removeHeaders(String name) {
+ parent.headers().remove(name);
+ }
+
+ @Override
+ public void setHeaders(String name, String value) {
+ parent.headers().put(name, value);
+ }
+
+ @Override
+ public void setHeaders(String name, List<String> values) {
+ parent.headers().put(name, values);
+ }
+
+ @Override
+ public void addHeader(String name, String value) {
+ parent.headers().add(name, value);
+ }
+
+ @Override
+ public String getHeader(String name) {
+ List<String> values = parent.headers().get(name);
+ if (values == null || values.isEmpty()) {
+ return null;
+ }
+ return values.get(values.size() - 1);
+ }
+
+ @Override
+ public void setCookies(List<Cookie> cookies) {
+ parent.encodeSetCookieHeader(cookies);
+ }
+
+}
diff --git a/container-core/src/main/java/com/yahoo/jdisc/http/filter/RequestFilter.java b/container-core/src/main/java/com/yahoo/jdisc/http/filter/RequestFilter.java
new file mode 100644
index 00000000000..977e3ab5d1d
--- /dev/null
+++ b/container-core/src/main/java/com/yahoo/jdisc/http/filter/RequestFilter.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.filter;
+
+import com.yahoo.jdisc.handler.ResponseHandler;
+import com.yahoo.jdisc.http.HttpRequest;
+
+/**
+ * @author Einar M R Rosenvinge
+ */
+public interface RequestFilter extends com.yahoo.jdisc.SharedResource, RequestFilterBase {
+
+ void filter(HttpRequest request, ResponseHandler handler);
+
+}
diff --git a/container-core/src/main/java/com/yahoo/jdisc/http/filter/RequestFilterBase.java b/container-core/src/main/java/com/yahoo/jdisc/http/filter/RequestFilterBase.java
new file mode 100644
index 00000000000..4eb7091f378
--- /dev/null
+++ b/container-core/src/main/java/com/yahoo/jdisc/http/filter/RequestFilterBase.java
@@ -0,0 +1,9 @@
+// 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.filter;
+
+/**
+ * @author gjoranv
+ * @since 2.4
+ */
+public interface RequestFilterBase {
+}
diff --git a/container-core/src/main/java/com/yahoo/jdisc/http/filter/RequestView.java b/container-core/src/main/java/com/yahoo/jdisc/http/filter/RequestView.java
new file mode 100644
index 00000000000..e5e7ae1ef56
--- /dev/null
+++ b/container-core/src/main/java/com/yahoo/jdisc/http/filter/RequestView.java
@@ -0,0 +1,45 @@
+// 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.filter;
+
+import com.yahoo.jdisc.http.HttpRequest.Method;
+
+import java.net.URI;
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * Read-only view of the request for use by SecurityResponseFilters.
+ *
+ * @author Tony Vaagenes
+ */
+public interface RequestView {
+
+ /**
+ * Returns a named attribute.
+ *
+ * @see <a href="http://docs.oracle.com/javaee/7/api/javax/servlet/ServletRequest.html#getAttribute%28java.lang.String%29">javax.servlet.ServletRequest.getAttribute(java.lang.String)</a>
+ * @see com.yahoo.jdisc.Request#context()
+ * @return the named data associated with the request that are private to this runtime (not exposed to the client)
+ */
+ Object getAttribute(String name);
+
+ /**
+ * Returns an immutable view of all values of a named header field.
+ * Returns an empty list if no such header is present.
+ */
+ List<String> getHeaders(String name);
+
+ /**
+ * Convenience method for retrieving the first value of a named header field.
+ * Returns empty if the header is not set, or if the value list is empty.
+ */
+ Optional<String> getFirstHeader(String name);
+
+ /**
+ * Returns the Http method. Only present if the underlying request has http-like semantics.
+ */
+ Optional<Method> getMethod();
+
+ URI getUri();
+
+}
diff --git a/container-core/src/main/java/com/yahoo/jdisc/http/filter/ResponseFilter.java b/container-core/src/main/java/com/yahoo/jdisc/http/filter/ResponseFilter.java
new file mode 100644
index 00000000000..44fe7d9fcf1
--- /dev/null
+++ b/container-core/src/main/java/com/yahoo/jdisc/http/filter/ResponseFilter.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.filter;
+
+import com.yahoo.jdisc.Request;
+import com.yahoo.jdisc.Response;
+import com.yahoo.jdisc.SharedResource;
+
+/**
+ * @author <a href="mailto:einarmr@yahoo-inc.com">Einar M R Rosenvinge</a>
+ */
+public interface ResponseFilter extends SharedResource, ResponseFilterBase {
+
+ public void filter(Response response, Request request);
+}
diff --git a/container-core/src/main/java/com/yahoo/jdisc/http/filter/ResponseFilterBase.java b/container-core/src/main/java/com/yahoo/jdisc/http/filter/ResponseFilterBase.java
new file mode 100644
index 00000000000..b869c882351
--- /dev/null
+++ b/container-core/src/main/java/com/yahoo/jdisc/http/filter/ResponseFilterBase.java
@@ -0,0 +1,9 @@
+// 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.filter;
+
+/**
+ * @author gjoranv
+ * @since 2.4
+ */
+public interface ResponseFilterBase {
+}
diff --git a/container-core/src/main/java/com/yahoo/jdisc/http/filter/SecurityFilterInvoker.java b/container-core/src/main/java/com/yahoo/jdisc/http/filter/SecurityFilterInvoker.java
new file mode 100644
index 00000000000..cbed273b7ee
--- /dev/null
+++ b/container-core/src/main/java/com/yahoo/jdisc/http/filter/SecurityFilterInvoker.java
@@ -0,0 +1,108 @@
+// 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.filter;
+
+import com.google.common.annotations.Beta;
+import com.yahoo.jdisc.handler.ResponseHandler;
+import com.yahoo.jdisc.http.HttpRequest.Method;
+import com.yahoo.jdisc.http.servlet.ServletRequest;
+
+import com.yahoo.jdisc.http.servlet.ServletResponse;
+import com.yahoo.jdisc.http.server.jetty.FilterInvoker;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.net.URI;
+import java.util.Collections;
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * Only intended for internal vespa use.
+ *
+ * Runs JDisc security filter without using JDisc request/response.
+ * Only intended to be used in a servlet context, as the error messages are tailored for that.
+ *
+ * Assumes that SecurityResponseFilters mutate DiscFilterResponse in the thread they are invoked from.
+ *
+ * @author Tony Vaagenes
+ */
+@Beta
+public class SecurityFilterInvoker implements FilterInvoker {
+
+ /**
+ * Returns the servlet request to be used in any servlets invoked after this.
+ */
+ @Override
+ public HttpServletRequest invokeRequestFilterChain(RequestFilter requestFilterChain,
+ URI uri, HttpServletRequest httpRequest,
+ ResponseHandler responseHandler) {
+
+ SecurityRequestFilterChain securityChain = cast(SecurityRequestFilterChain.class, requestFilterChain).
+ orElseThrow(SecurityFilterInvoker::newUnsupportedOperationException);
+
+ ServletRequest wrappedRequest = new ServletRequest(httpRequest, uri);
+ securityChain.filter(new ServletFilterRequest(wrappedRequest), responseHandler);
+ return wrappedRequest;
+ }
+
+ @Override
+ public void invokeResponseFilterChain(
+ ResponseFilter responseFilterChain,
+ URI uri,
+ HttpServletRequest request,
+ HttpServletResponse response) {
+
+ SecurityResponseFilterChain securityChain = cast(SecurityResponseFilterChain.class, responseFilterChain).
+ orElseThrow(SecurityFilterInvoker::newUnsupportedOperationException);
+
+ ServletFilterResponse wrappedResponse = new ServletFilterResponse(new ServletResponse(response));
+ securityChain.filter(new ServletRequestView(uri, request), wrappedResponse);
+ }
+
+ private static UnsupportedOperationException newUnsupportedOperationException() {
+ return new UnsupportedOperationException(
+ "Filter type not supported. If a request is handled by servlets or jax-rs, then any filters invoked for that request must be security filters.");
+ }
+
+ private <T> Optional<T> cast(Class<T> securityFilterChainClass, Object filter) {
+ return (securityFilterChainClass.isInstance(filter))?
+ Optional.of(securityFilterChainClass.cast(filter)):
+ Optional.empty();
+ }
+
+ private static class ServletRequestView implements RequestView {
+ private final HttpServletRequest request;
+ private final URI uri;
+
+ public ServletRequestView(URI uri, HttpServletRequest request) {
+ this.request = request;
+ this.uri = uri;
+ }
+
+ @Override
+ public Object getAttribute(String name) {
+ return request.getAttribute(name);
+ }
+
+ @Override
+ public List<String> getHeaders(String name) {
+ return Collections.unmodifiableList(Collections.list(request.getHeaders(name)));
+ }
+
+ @Override
+ public Optional<String> getFirstHeader(String name) {
+ return getHeaders(name).stream().findFirst();
+ }
+
+ @Override
+ public Optional<Method> getMethod() {
+ return Optional.of(Method.valueOf(request.getMethod()));
+ }
+
+ @Override
+ public URI getUri() {
+ return uri;
+ }
+ }
+
+}
diff --git a/container-core/src/main/java/com/yahoo/jdisc/http/filter/SecurityRequestFilter.java b/container-core/src/main/java/com/yahoo/jdisc/http/filter/SecurityRequestFilter.java
new file mode 100644
index 00000000000..e6f4add49de
--- /dev/null
+++ b/container-core/src/main/java/com/yahoo/jdisc/http/filter/SecurityRequestFilter.java
@@ -0,0 +1,13 @@
+// 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.filter;
+
+import com.yahoo.jdisc.handler.ResponseHandler;
+
+/**
+ * @author Simon Thoresen Hult
+ */
+public interface SecurityRequestFilter extends RequestFilterBase {
+
+ void filter(DiscFilterRequest request, ResponseHandler handler);
+
+}
diff --git a/container-core/src/main/java/com/yahoo/jdisc/http/filter/SecurityRequestFilterChain.java b/container-core/src/main/java/com/yahoo/jdisc/http/filter/SecurityRequestFilterChain.java
new file mode 100644
index 00000000000..2d97bbdc494
--- /dev/null
+++ b/container-core/src/main/java/com/yahoo/jdisc/http/filter/SecurityRequestFilterChain.java
@@ -0,0 +1,77 @@
+// 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.filter;
+
+import com.yahoo.jdisc.AbstractResource;
+import com.yahoo.jdisc.Response;
+import com.yahoo.jdisc.handler.ContentChannel;
+import com.yahoo.jdisc.handler.ResponseHandler;
+
+import com.yahoo.jdisc.http.HttpRequest;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * Implementation of TypedFilterChain for DiscFilterRequest
+ *
+ * @author tejalk
+ */
+public final class SecurityRequestFilterChain extends AbstractResource implements RequestFilter {
+
+ private final List<SecurityRequestFilter> filters = new ArrayList<>();
+
+ private SecurityRequestFilterChain(Iterable<? extends SecurityRequestFilter> filters) {
+ for (SecurityRequestFilter filter : filters) {
+ this.filters.add(filter);
+ }
+ }
+
+ @Override
+ public void filter(HttpRequest request, ResponseHandler responseHandler) {
+ DiscFilterRequest discFilterRequest = new JdiscFilterRequest(request);
+ filter(discFilterRequest, responseHandler);
+ }
+
+ public void filter(DiscFilterRequest request, ResponseHandler responseHandler) {
+ ResponseHandlerGuard guard = new ResponseHandlerGuard(responseHandler);
+ for (int i = 0, len = filters.size(); i < len && !guard.isDone(); ++i) {
+ filters.get(i).filter(request, guard);
+ }
+ }
+
+ public static RequestFilter newInstance(SecurityRequestFilter... filters) {
+ return newInstance(Arrays.asList(filters));
+ }
+
+ public static RequestFilter newInstance(List<? extends SecurityRequestFilter> filters) {
+ return new SecurityRequestFilterChain(filters);
+ }
+
+ private static class ResponseHandlerGuard implements ResponseHandler {
+
+ private final ResponseHandler responseHandler;
+ private boolean done = false;
+
+ public ResponseHandlerGuard(ResponseHandler handler) {
+ this.responseHandler = handler;
+ }
+
+ @Override
+ public ContentChannel handleResponse(Response response) {
+ done = true;
+ return responseHandler.handleResponse(response);
+ }
+
+ public boolean isDone() {
+ return done;
+ }
+ }
+
+ /** Returns an unmodifiable view of the filters in this */
+ public List<SecurityRequestFilter> getFilters() {
+ return Collections.unmodifiableList(filters);
+ }
+
+}
diff --git a/container-core/src/main/java/com/yahoo/jdisc/http/filter/SecurityResponseFilter.java b/container-core/src/main/java/com/yahoo/jdisc/http/filter/SecurityResponseFilter.java
new file mode 100644
index 00000000000..aa4f7d29b89
--- /dev/null
+++ b/container-core/src/main/java/com/yahoo/jdisc/http/filter/SecurityResponseFilter.java
@@ -0,0 +1,8 @@
+// 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.filter;
+
+public interface SecurityResponseFilter extends ResponseFilterBase {
+
+ void filter(DiscFilterResponse response, RequestView request);
+
+}
diff --git a/container-core/src/main/java/com/yahoo/jdisc/http/filter/SecurityResponseFilterChain.java b/container-core/src/main/java/com/yahoo/jdisc/http/filter/SecurityResponseFilterChain.java
new file mode 100644
index 00000000000..d45b406a375
--- /dev/null
+++ b/container-core/src/main/java/com/yahoo/jdisc/http/filter/SecurityResponseFilterChain.java
@@ -0,0 +1,101 @@
+// 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.filter;
+
+import java.net.URI;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Optional;
+
+import com.yahoo.jdisc.AbstractResource;
+import com.yahoo.jdisc.Request;
+import com.yahoo.jdisc.Response;
+import com.yahoo.jdisc.http.HttpRequest;
+import com.yahoo.jdisc.http.HttpResponse;
+
+/**
+ * Implementation of TypedFilterChain for DiscFilterResponse
+ * @author tejalk
+ *
+ */
+public class SecurityResponseFilterChain extends AbstractResource implements ResponseFilter {
+
+ private final List<SecurityResponseFilter> filters = new ArrayList<>();
+
+ private SecurityResponseFilterChain(Iterable<? extends SecurityResponseFilter> filters) {
+ for (SecurityResponseFilter filter : filters) {
+ this.filters.add(filter);
+ }
+ }
+
+ @Override
+ public void filter(Response response, Request request) {
+ if(response instanceof HttpResponse) {
+ DiscFilterResponse discFilterResponse = new JdiscFilterResponse((HttpResponse)response);
+ RequestView requestView = new RequestViewImpl(request);
+ filter(requestView, discFilterResponse);
+ }
+
+ }
+
+ public void filter(RequestView requestView, DiscFilterResponse response) {
+ for (SecurityResponseFilter filter : filters) {
+ filter.filter(response, requestView);
+ }
+ }
+
+ public static ResponseFilter newInstance(SecurityResponseFilter... filters) {
+ return newInstance(Arrays.asList(filters));
+ }
+
+ public static ResponseFilter newInstance(List<? extends SecurityResponseFilter> filters) {
+ return new SecurityResponseFilterChain(filters);
+ }
+
+ /** Returns an unmodifiable view of the filters in this */
+ public List<SecurityResponseFilter> getFilters() {
+ return Collections.unmodifiableList(filters);
+ }
+
+ static class RequestViewImpl implements RequestView {
+
+ private final Request request;
+ private final Optional<HttpRequest.Method> method;
+
+ public RequestViewImpl(Request request) {
+ this.request = request;
+ method = request instanceof HttpRequest ?
+ Optional.of(((HttpRequest) request).getMethod()):
+ Optional.empty();
+ }
+
+ @Override
+ public Object getAttribute(String name) {
+ return request.context().get(name);
+ }
+
+ @Override
+ public List<String> getHeaders(String name) {
+ List<String> headers = request.headers().get(name);
+ return headers == null ? Collections.emptyList() : Collections.unmodifiableList(headers);
+ }
+
+ @Override
+ public Optional<String> getFirstHeader(String name) {
+ return getHeaders(name).stream().findFirst();
+ }
+
+ @Override
+ public Optional<HttpRequest.Method> getMethod() {
+ return method;
+ }
+
+ @Override
+ public URI getUri() {
+ return request.getUri();
+ }
+
+ }
+
+}
diff --git a/container-core/src/main/java/com/yahoo/jdisc/http/filter/ServletFilterRequest.java b/container-core/src/main/java/com/yahoo/jdisc/http/filter/ServletFilterRequest.java
new file mode 100644
index 00000000000..f06f9e256ff
--- /dev/null
+++ b/container-core/src/main/java/com/yahoo/jdisc/http/filter/ServletFilterRequest.java
@@ -0,0 +1,169 @@
+// 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.filter;
+
+import com.yahoo.jdisc.http.HttpHeaders;
+import com.yahoo.jdisc.http.servlet.ServletRequest;
+
+import java.io.UnsupportedEncodingException;
+import java.net.URI;
+import java.security.Principal;
+import java.security.cert.X509Certificate;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Enumeration;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Optional;
+import java.util.Set;
+
+/**
+ * Servlet implementation for JDisc filter requests.
+ */
+class ServletFilterRequest extends DiscFilterRequest {
+
+ private final ServletRequest parent;
+
+ public ServletFilterRequest(ServletRequest parent) {
+ super(parent);
+ this.parent = parent;
+ }
+
+ ServletRequest getServletRequest() {
+ return parent;
+ }
+
+ public void setUri(URI uri) {
+ parent.setUri(uri);
+ }
+
+ @Override
+ public String getMethod() {
+ return parent.getRequest().getMethod();
+ }
+
+ @Override
+ public void setRemoteAddr(String remoteIpAddress) {
+ throw new UnsupportedOperationException(
+ "Setting remote address is not supported for " + this.getClass().getName());
+ }
+
+ @Override
+ public Enumeration<String> getAttributeNames() {
+ Set<String> names = new HashSet<>(Collections.list(super.getAttributeNames()));
+ names.addAll(Collections.list(parent.getRequest().getAttributeNames()));
+ return Collections.enumeration(names);
+ }
+
+ @Override
+ public Object getAttribute(String name) {
+ Object jdiscAttribute = super.getAttribute(name);
+ return jdiscAttribute != null ?
+ jdiscAttribute :
+ parent.getRequest().getAttribute(name);
+ }
+
+ @Override
+ public void setAttribute(String name, Object value) {
+ super.setAttribute(name, value);
+ parent.getRequest().setAttribute(name, value);
+ }
+
+ @Override
+ public boolean containsAttribute(String name) {
+ return super.containsAttribute(name)
+ || parent.getRequest().getAttribute(name) != null;
+ }
+
+ @Override
+ public void removeAttribute(String name) {
+ super.removeAttribute(name);
+ parent.getRequest().removeAttribute(name);
+ }
+
+ @Override
+ public String getParameter(String name) {
+ return parent.getParameter(name);
+ }
+
+ @Override
+ public Enumeration<String> getParameterNames() {
+ return parent.getParameterNames();
+ }
+
+ @Override
+ public void addHeader(String name, String value) {
+ parent.addHeader(name, value);
+ }
+
+ @Override
+ public String getHeader(String name) {
+ return parent.getHeader(name);
+ }
+
+ @Override
+ public Enumeration<String> getHeaderNames() {
+ return parent.getHeaderNames();
+ }
+
+ public List<String> getHeaderNamesAsList() {
+ return Collections.list(getHeaderNames());
+ }
+
+ @Override
+ public Enumeration<String> getHeaders(String name) {
+ return parent.getHeaders(name);
+ }
+
+ @Override
+ public List<String> getHeadersAsList(String name) {
+ return Collections.list(getHeaders(name));
+ }
+
+ @Override
+ public void setHeaders(String name, String value) {
+ parent.setHeaders(name, value);
+ }
+
+ @Override
+ public void setHeaders(String name, List<String> values) {
+ parent.setHeaders(name, values);
+ }
+
+ @Override
+ public Principal getUserPrincipal() {
+ return parent.getUserPrincipal();
+ }
+
+ @Override
+ public void setUserPrincipal(Principal principal) {
+ parent.setUserPrincipal(principal);
+ }
+
+ @Override
+ public List<X509Certificate> getClientCertificateChain() {
+ return Optional.ofNullable(parent.getRequest().getAttribute(ServletRequest.SERVLET_REQUEST_X509CERT))
+ .map(X509Certificate[].class::cast)
+ .map(Arrays::asList)
+ .orElse(Collections.emptyList());
+ }
+
+ @Override
+ public void removeHeaders(String name) {
+ parent.removeHeaders(name);
+ }
+
+ @Override
+ public void clearCookies() {
+ parent.removeHeaders(HttpHeaders.Names.COOKIE);
+ }
+
+ @Override
+ public void setCharacterEncoding(String encoding) {
+ super.setCharacterEncoding(encoding);
+ try {
+ parent.setCharacterEncoding(encoding);
+ } catch (UnsupportedEncodingException e) {
+ throw new RuntimeException("Encoding not supported: " + encoding, e);
+ }
+ }
+}
diff --git a/container-core/src/main/java/com/yahoo/jdisc/http/filter/ServletFilterResponse.java b/container-core/src/main/java/com/yahoo/jdisc/http/filter/ServletFilterResponse.java
new file mode 100644
index 00000000000..b603e7776f1
--- /dev/null
+++ b/container-core/src/main/java/com/yahoo/jdisc/http/filter/ServletFilterResponse.java
@@ -0,0 +1,81 @@
+// 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.filter;
+
+import com.google.common.collect.Iterables;
+import com.yahoo.jdisc.http.Cookie;
+import com.yahoo.jdisc.http.HttpHeaders;
+import com.yahoo.jdisc.http.servlet.ServletResponse;
+
+import javax.servlet.http.HttpServletResponse;
+import java.util.Collection;
+import java.util.List;
+
+/**
+ * Servlet implementation for JDisc filter responses.
+ */
+class ServletFilterResponse extends DiscFilterResponse {
+
+ private final ServletResponse parent;
+
+ public ServletFilterResponse(ServletResponse parent) {
+ super(parent);
+ this.parent = parent;
+ }
+
+ ServletResponse getServletResponse() {
+ return parent;
+ }
+
+ public void setStatus(int status) {
+ parent.setStatus(status);
+ }
+
+ @Override
+ public void setHeader(String name, String value) {
+ parent.setHeader(name, value);
+ }
+
+ @Override
+ public void removeHeaders(String name) {
+ HttpServletResponse parentResponse = parent.getResponse();
+ if (parentResponse instanceof org.eclipse.jetty.server.Response) {
+ org.eclipse.jetty.server.Response jettyResponse = (org.eclipse.jetty.server.Response)parentResponse;
+ jettyResponse.getHttpFields().remove(name);
+ } else {
+ throw new UnsupportedOperationException(
+ "Cannot remove headers for response of type " + parentResponse.getClass().getName());
+ }
+ }
+
+ // Why have a setHeaders that takes a single string?
+ @Override
+ public void setHeaders(String name, String value) {
+ parent.setHeader(name, value);
+ }
+
+ @Override
+ public void setHeaders(String name, List<String> values) {
+ for (String value : values)
+ parent.addHeader(name, value);
+ }
+
+ @Override
+ public void addHeader(String name, String value) {
+ parent.addHeader(name, value);
+ }
+
+ @Override
+ public String getHeader(String name) {
+ Collection<String> headers = parent.getHeaders(name);
+ return headers.isEmpty()
+ ? null
+ : Iterables.getLast(headers);
+ }
+
+ @Override
+ public void setCookies(List<Cookie> cookies) {
+ removeHeaders(HttpHeaders.Names.SET_COOKIE);
+ List<String> setCookieHeaders = Cookie.toSetCookieHeaders(cookies);
+ setCookieHeaders.forEach(cookie -> addHeader(HttpHeaders.Names.SET_COOKIE, cookie));
+ }
+}
diff --git a/container-core/src/main/java/com/yahoo/jdisc/http/filter/chain/EmptyRequestFilter.java b/container-core/src/main/java/com/yahoo/jdisc/http/filter/chain/EmptyRequestFilter.java
new file mode 100644
index 00000000000..e1834fd8b7d
--- /dev/null
+++ b/container-core/src/main/java/com/yahoo/jdisc/http/filter/chain/EmptyRequestFilter.java
@@ -0,0 +1,24 @@
+// 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.filter.chain;
+
+import com.yahoo.jdisc.NoopSharedResource;
+import com.yahoo.jdisc.handler.ResponseHandler;
+import com.yahoo.jdisc.http.HttpRequest;
+import com.yahoo.jdisc.http.filter.RequestFilter;
+
+/**
+ * @author <a href="mailto:einarmr@yahoo-inc.com">Einar M R Rosenvinge</a>
+ */
+public final class EmptyRequestFilter extends NoopSharedResource implements RequestFilter {
+
+ public static final RequestFilter INSTANCE = new EmptyRequestFilter();
+
+ private EmptyRequestFilter() {
+ // hide
+ }
+
+ @Override
+ public void filter(HttpRequest request, ResponseHandler handler) {
+
+ }
+}
diff --git a/container-core/src/main/java/com/yahoo/jdisc/http/filter/chain/EmptyResponseFilter.java b/container-core/src/main/java/com/yahoo/jdisc/http/filter/chain/EmptyResponseFilter.java
new file mode 100644
index 00000000000..5ce3f6a496f
--- /dev/null
+++ b/container-core/src/main/java/com/yahoo/jdisc/http/filter/chain/EmptyResponseFilter.java
@@ -0,0 +1,24 @@
+// 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.filter.chain;
+
+import com.yahoo.jdisc.NoopSharedResource;
+import com.yahoo.jdisc.Request;
+import com.yahoo.jdisc.Response;
+import com.yahoo.jdisc.http.filter.ResponseFilter;
+
+/**
+ * @author <a href="mailto:einarmr@yahoo-inc.com">Einar M R Rosenvinge</a>
+ */
+public final class EmptyResponseFilter extends NoopSharedResource implements ResponseFilter {
+
+ public static final ResponseFilter INSTANCE = new EmptyResponseFilter();
+
+ private EmptyResponseFilter() {
+ // hide
+ }
+
+ @Override
+ public void filter(Response response, Request request) {
+
+ }
+}
diff --git a/container-core/src/main/java/com/yahoo/jdisc/http/filter/chain/RequestFilterChain.java b/container-core/src/main/java/com/yahoo/jdisc/http/filter/chain/RequestFilterChain.java
new file mode 100644
index 00000000000..85f71777cf3
--- /dev/null
+++ b/container-core/src/main/java/com/yahoo/jdisc/http/filter/chain/RequestFilterChain.java
@@ -0,0 +1,55 @@
+// 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.filter.chain;
+
+import com.yahoo.jdisc.AbstractResource;
+import com.yahoo.jdisc.application.ResourcePool;
+import com.yahoo.jdisc.handler.ResponseHandler;
+import com.yahoo.jdisc.http.HttpRequest;
+import com.yahoo.jdisc.http.filter.RequestFilter;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+/**
+ * @author <a href="mailto:einarmr@yahoo-inc.com">Einar M R Rosenvinge</a>
+ */
+public final class RequestFilterChain extends AbstractResource implements RequestFilter {
+
+ private final List<RequestFilter> filters = new ArrayList<>();
+ private final ResourcePool filterReferences = new ResourcePool();
+
+ private RequestFilterChain(Iterable<? extends RequestFilter> filters) {
+ for (RequestFilter filter : filters) {
+ this.filters.add(filter);
+ filterReferences.retain(filter);
+ }
+ }
+
+ @Override
+ public void filter(HttpRequest request, ResponseHandler responseHandler) {
+ ResponseHandlerGuard guard = new ResponseHandlerGuard(responseHandler);
+ for (int i = 0, len = filters.size(); i < len && !guard.isDone(); ++i) {
+ filters.get(i).filter(request, guard);
+ }
+ }
+
+ @Override
+ protected void destroy() {
+ filterReferences.release();
+ }
+
+ public static RequestFilter newInstance(RequestFilter... filters) {
+ return newInstance(Arrays.asList(filters));
+ }
+
+ public static RequestFilter newInstance(List<? extends RequestFilter> filters) {
+ if (filters.size() == 0) {
+ return EmptyRequestFilter.INSTANCE;
+ }
+ if (filters.size() == 1) {
+ return filters.get(0);
+ }
+ return new RequestFilterChain(filters);
+ }
+}
diff --git a/container-core/src/main/java/com/yahoo/jdisc/http/filter/chain/ResponseFilterChain.java b/container-core/src/main/java/com/yahoo/jdisc/http/filter/chain/ResponseFilterChain.java
new file mode 100644
index 00000000000..5c5eda1f139
--- /dev/null
+++ b/container-core/src/main/java/com/yahoo/jdisc/http/filter/chain/ResponseFilterChain.java
@@ -0,0 +1,54 @@
+// 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.filter.chain;
+
+import com.yahoo.jdisc.AbstractResource;
+import com.yahoo.jdisc.Request;
+import com.yahoo.jdisc.Response;
+import com.yahoo.jdisc.application.ResourcePool;
+import com.yahoo.jdisc.http.filter.ResponseFilter;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+/**
+ * @author Simon Thoresen Hult
+ */
+public final class ResponseFilterChain extends AbstractResource implements ResponseFilter {
+
+ private final List<ResponseFilter> filters = new ArrayList<>();
+ private final ResourcePool filterReferences = new ResourcePool();
+
+ private ResponseFilterChain(Iterable<? extends ResponseFilter> filters) {
+ for (ResponseFilter filter : filters) {
+ this.filters.add(filter);
+ filterReferences.retain(filter);
+ }
+ }
+
+ @Override
+ public void filter(Response response, Request request) {
+ for (ResponseFilter filter : filters) {
+ filter.filter(response, request);
+ }
+ }
+
+ @Override
+ protected void destroy() {
+ filterReferences.release();
+ }
+
+ public static ResponseFilter newInstance(ResponseFilter... filters) {
+ return newInstance(Arrays.asList(filters));
+ }
+
+ public static ResponseFilter newInstance(List<? extends ResponseFilter> filters) {
+ if (filters.size() == 0) {
+ return EmptyResponseFilter.INSTANCE;
+ }
+ if (filters.size() == 1) {
+ return filters.get(0);
+ }
+ return new ResponseFilterChain(filters);
+ }
+}
diff --git a/container-core/src/main/java/com/yahoo/jdisc/http/filter/chain/ResponseHandlerGuard.java b/container-core/src/main/java/com/yahoo/jdisc/http/filter/chain/ResponseHandlerGuard.java
new file mode 100644
index 00000000000..02600683e27
--- /dev/null
+++ b/container-core/src/main/java/com/yahoo/jdisc/http/filter/chain/ResponseHandlerGuard.java
@@ -0,0 +1,29 @@
+// 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.filter.chain;
+
+import com.yahoo.jdisc.Response;
+import com.yahoo.jdisc.handler.ContentChannel;
+import com.yahoo.jdisc.handler.ResponseHandler;
+
+/**
+ * @author Simon Thoresen Hult
+ */
+final class ResponseHandlerGuard implements ResponseHandler {
+
+ private final ResponseHandler responseHandler;
+ private boolean done = false;
+
+ public ResponseHandlerGuard(ResponseHandler handler) {
+ this.responseHandler = handler;
+ }
+
+ @Override
+ public ContentChannel handleResponse(Response response) {
+ done = true;
+ return responseHandler.handleResponse(response);
+ }
+
+ public boolean isDone() {
+ return done;
+ }
+}
diff --git a/container-core/src/main/java/com/yahoo/jdisc/http/filter/chain/package-info.java b/container-core/src/main/java/com/yahoo/jdisc/http/filter/chain/package-info.java
new file mode 100644
index 00000000000..540a1be7b73
--- /dev/null
+++ b/container-core/src/main/java/com/yahoo/jdisc/http/filter/chain/package-info.java
@@ -0,0 +1,5 @@
+// Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+@ExportPackage
+package com.yahoo.jdisc.http.filter.chain;
+
+import com.yahoo.osgi.annotation.ExportPackage;
diff --git a/container-core/src/main/java/com/yahoo/jdisc/http/filter/package-info.java b/container-core/src/main/java/com/yahoo/jdisc/http/filter/package-info.java
new file mode 100644
index 00000000000..e97d447adbb
--- /dev/null
+++ b/container-core/src/main/java/com/yahoo/jdisc/http/filter/package-info.java
@@ -0,0 +1,7 @@
+// Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+@PublicApi
+@ExportPackage
+package com.yahoo.jdisc.http.filter;
+
+import com.yahoo.api.annotations.PublicApi;
+import com.yahoo.osgi.annotation.ExportPackage;
diff --git a/container-core/src/main/java/com/yahoo/jdisc/http/package-info.java b/container-core/src/main/java/com/yahoo/jdisc/http/package-info.java
new file mode 100644
index 00000000000..b8bd76483cf
--- /dev/null
+++ b/container-core/src/main/java/com/yahoo/jdisc/http/package-info.java
@@ -0,0 +1,7 @@
+// Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+@PublicApi
+@ExportPackage
+package com.yahoo.jdisc.http;
+
+import com.yahoo.api.annotations.PublicApi;
+import com.yahoo.osgi.annotation.ExportPackage;
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;
diff --git a/container-core/src/main/java/com/yahoo/jdisc/http/servlet/ServletOrJdiscHttpRequest.java b/container-core/src/main/java/com/yahoo/jdisc/http/servlet/ServletOrJdiscHttpRequest.java
new file mode 100644
index 00000000000..eaac2b1c415
--- /dev/null
+++ b/container-core/src/main/java/com/yahoo/jdisc/http/servlet/ServletOrJdiscHttpRequest.java
@@ -0,0 +1,40 @@
+// 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.servlet;
+
+import com.yahoo.jdisc.HeaderFields;
+import com.yahoo.jdisc.http.Cookie;
+import com.yahoo.jdisc.http.HttpRequest;
+
+import java.net.SocketAddress;
+import java.net.URI;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * Common interface for JDisc and servlet http requests.
+ */
+public interface ServletOrJdiscHttpRequest {
+
+ void copyHeaders(HeaderFields target);
+
+ Map<String, List<String>> parameters();
+
+ URI getUri();
+
+ HttpRequest.Version getVersion();
+
+ String getRemoteHostAddress();
+ String getRemoteHostName();
+ int getRemotePort();
+
+ void setRemoteAddress(SocketAddress remoteAddress);
+
+ Map<String, Object> context();
+
+ List<Cookie> decodeCookieHeader();
+
+ void encodeCookieHeader(List<Cookie> cookies);
+
+ long getConnectedAt(TimeUnit unit);
+}
diff --git a/container-core/src/main/java/com/yahoo/jdisc/http/servlet/ServletOrJdiscHttpResponse.java b/container-core/src/main/java/com/yahoo/jdisc/http/servlet/ServletOrJdiscHttpResponse.java
new file mode 100644
index 00000000000..a24ada05b3d
--- /dev/null
+++ b/container-core/src/main/java/com/yahoo/jdisc/http/servlet/ServletOrJdiscHttpResponse.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.servlet;
+
+import com.yahoo.jdisc.HeaderFields;
+import com.yahoo.jdisc.http.Cookie;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Common interface for JDisc and servlet http responses.
+ */
+public interface ServletOrJdiscHttpResponse {
+
+ public void copyHeaders(HeaderFields target);
+
+ public int getStatus();
+
+ public Map<String, Object> context();
+
+ public List<Cookie> decodeSetCookieHeader();
+
+}
diff --git a/container-core/src/main/java/com/yahoo/jdisc/http/servlet/ServletRequest.java b/container-core/src/main/java/com/yahoo/jdisc/http/servlet/ServletRequest.java
new file mode 100644
index 00000000000..c945dc6d8b6
--- /dev/null
+++ b/container-core/src/main/java/com/yahoo/jdisc/http/servlet/ServletRequest.java
@@ -0,0 +1,272 @@
+// 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.servlet;
+
+import com.google.common.collect.ImmutableMap;
+import com.yahoo.jdisc.HeaderFields;
+import com.yahoo.jdisc.http.Cookie;
+import com.yahoo.jdisc.http.HttpHeaders;
+import com.yahoo.jdisc.http.HttpRequest;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletRequestWrapper;
+import java.net.InetSocketAddress;
+import java.net.SocketAddress;
+import java.net.URI;
+import java.security.Principal;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Enumeration;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.TimeUnit;
+
+import static com.yahoo.jdisc.http.server.jetty.HttpServletRequestUtils.getConnection;
+
+/**
+ * Mutable wrapper to use a {@link javax.servlet.http.HttpServletRequest}
+ * with JDisc security filters.
+ * <p>
+ * You might find it tempting to remove e.g. the getParameter... methods,
+ * but keep in mind that this IS-A servlet request and must provide the
+ * full api of such a request for use outside the "JDisc filter world".
+ */
+public class ServletRequest extends HttpServletRequestWrapper implements ServletOrJdiscHttpRequest {
+
+ public static final String JDISC_REQUEST_PRINCIPAL = "jdisc.request.principal";
+ public static final String JDISC_REQUEST_X509CERT = "jdisc.request.X509Certificate";
+ public static final String JDISC_REQUEST_CHAIN = "jdisc.request.chain";
+ public static final String JDISC_RESPONSE_CHAIN = "jdisc.response.chain";
+ public static final String SERVLET_REQUEST_X509CERT = "javax.servlet.request.X509Certificate";
+ public static final String SERVLET_REQUEST_SSL_SESSION_ID = "javax.servlet.request.ssl_session_id";
+ public static final String SERVLET_REQUEST_CIPHER_SUITE = "javax.servlet.request.cipher_suite";
+
+ private final HttpServletRequest request;
+ private final HeaderFields headerFields;
+ private final Set<String> removedHeaders = new HashSet<>();
+ private final Map<String, Object> context = new HashMap<>();
+ private final Map<String, List<String>> parameters = new HashMap<>();
+ private final long connectedAt;
+
+ private URI uri;
+ private String remoteHostAddress;
+ private String remoteHostName;
+ private int remotePort;
+
+ public ServletRequest(HttpServletRequest request, URI uri) {
+ super(request);
+ this.request = request;
+
+ this.uri = uri;
+
+ super.getParameterMap().forEach(
+ (key, values) -> parameters.put(key, Arrays.asList(values)));
+
+ remoteHostAddress = request.getRemoteAddr();
+ remoteHostName = request.getRemoteHost();
+ remotePort = request.getRemotePort();
+ connectedAt = getConnection(request).getCreatedTimeStamp();
+
+ headerFields = new HeaderFields();
+ Enumeration<String> parentHeaders = request.getHeaderNames();
+ while (parentHeaders.hasMoreElements()) {
+ String name = parentHeaders.nextElement();
+ Enumeration<String> values = request.getHeaders(name);
+ while (values.hasMoreElements()) {
+ headerFields.add(name, values.nextElement());
+ }
+ }
+ }
+
+ public HttpServletRequest getRequest() {
+ return request;
+ }
+
+ @Override
+ public Map<String, List<String>> parameters() {
+ return parameters;
+ }
+
+ /* We cannot just return the parameter map from the request, as the map
+ * may have been modified by the JDisc filters. */
+ @Override
+ public Map<String, String[]> getParameterMap() {
+ Map<String, String[]> parameterMap = new HashMap<>();
+ parameters().forEach(
+ (key, values) ->
+ parameterMap.put(key, values.toArray(new String[values.size()]))
+ );
+ return ImmutableMap.copyOf(parameterMap);
+ }
+
+ @Override
+ public String getParameter(String name) {
+ return parameters().containsKey(name) ?
+ parameters().get(name).get(0) :
+ null;
+ }
+
+ @Override
+ public Enumeration<String> getParameterNames() {
+ return Collections.enumeration(parameters.keySet());
+ }
+
+ @Override
+ public String[] getParameterValues(String name) {
+ List<String> values = parameters().get(name);
+ return values != null ?
+ values.toArray(new String[values.size()]) :
+ null;
+ }
+
+ @Override
+ public void copyHeaders(HeaderFields target) {
+ target.addAll(headerFields);
+ }
+
+ @Override
+ public Enumeration<String> getHeaders(String name) {
+ if (removedHeaders.contains(name))
+ return null;
+
+ /* We don't need to merge headerFields and the servlet request's headers
+ * because setHeaders() replaces the old value. There is no 'addHeader(s)'. */
+ List<String> headerFields = this.headerFields.get(name);
+ return headerFields == null || headerFields.isEmpty() ?
+ super.getHeaders(name) :
+ Collections.enumeration(headerFields);
+ }
+
+ @Override
+ public String getHeader(String name) {
+ if (removedHeaders.contains(name))
+ return null;
+
+ String headerField = headerFields.getFirst(name);
+ return headerField != null ?
+ headerField :
+ super.getHeader(name);
+ }
+
+ @Override
+ public Enumeration<String> getHeaderNames() {
+ Set<String> names = new HashSet<>(Collections.list(super.getHeaderNames()));
+ names.addAll(headerFields.keySet());
+ names.removeAll(removedHeaders);
+ return Collections.enumeration(names);
+ }
+
+ public void addHeader(String name, String value) {
+ headerFields.add(name, value);
+ removedHeaders.remove(name);
+ }
+
+ public void setHeaders(String name, String value) {
+ headerFields.put(name, value);
+ removedHeaders.remove(name);
+ }
+
+ public void setHeaders(String name, List<String> values) {
+ headerFields.put(name, values);
+ removedHeaders.remove(name);
+ }
+
+ public void removeHeaders(String name) {
+ headerFields.remove(name);
+ removedHeaders.add(name);
+ }
+
+ @Override
+ public URI getUri() {
+ return uri;
+ }
+
+ public void setUri(URI uri) {
+ this.uri = uri;
+ }
+
+ @Override
+ public HttpRequest.Version getVersion() {
+ String protocol = request.getProtocol();
+ try {
+ return HttpRequest.Version.fromString(protocol);
+ } catch (NullPointerException | IllegalArgumentException e) {
+ throw new RuntimeException("Servlet request protocol '" + protocol +
+ "' could not be mapped to a JDisc http version.", e);
+ }
+ }
+
+ @Override
+ public String getRemoteHostAddress() {
+ return remoteHostAddress;
+ }
+
+ @Override
+ public String getRemoteHostName() {
+ return remoteHostName;
+ }
+
+ @Override
+ public int getRemotePort() {
+ return remotePort;
+ }
+
+ @Override
+ public void setRemoteAddress(SocketAddress remoteAddress) {
+ if (remoteAddress instanceof InetSocketAddress) {
+ remoteHostAddress = ((InetSocketAddress) remoteAddress).getAddress().getHostAddress();
+ remoteHostName = ((InetSocketAddress) remoteAddress).getAddress().getHostName();
+ remotePort = ((InetSocketAddress) remoteAddress).getPort();
+ } else
+ throw new RuntimeException("Unknown SocketAddress class: " + remoteHostAddress.getClass().getName());
+
+ }
+
+ @Override
+ public Map<String, Object> context() {
+ return context;
+ }
+
+ @Override
+ public javax.servlet.http.Cookie[] getCookies() {
+ return decodeCookieHeader().stream().
+ map(jdiscCookie -> new javax.servlet.http.Cookie(jdiscCookie.getName(), jdiscCookie.getValue())).
+ toArray(javax.servlet.http.Cookie[]::new);
+ }
+
+ @Override
+ public List<Cookie> decodeCookieHeader() {
+ Enumeration<String> cookies = getHeaders(HttpHeaders.Names.COOKIE);
+ if (cookies == null)
+ return Collections.emptyList();
+
+ List<Cookie> ret = new LinkedList<>();
+ while(cookies.hasMoreElements())
+ ret.addAll(Cookie.fromCookieHeader(cookies.nextElement()));
+
+ return ret;
+ }
+
+ @Override
+ public void encodeCookieHeader(List<Cookie> cookies) {
+ setHeaders(HttpHeaders.Names.COOKIE, Cookie.toCookieHeader(cookies));
+ }
+
+ @Override
+ public long getConnectedAt(TimeUnit unit) {
+ return unit.convert(connectedAt, TimeUnit.MILLISECONDS);
+ }
+
+ @Override
+ public Principal getUserPrincipal() {
+ // NOTE: The principal from the underlying servlet request is ignored. JDisc filters are the source-of-truth.
+ return (Principal) request.getAttribute(JDISC_REQUEST_PRINCIPAL);
+ }
+
+ public void setUserPrincipal(Principal principal) {
+ request.setAttribute(JDISC_REQUEST_PRINCIPAL, principal);
+ }
+}
diff --git a/container-core/src/main/java/com/yahoo/jdisc/http/servlet/ServletResponse.java b/container-core/src/main/java/com/yahoo/jdisc/http/servlet/ServletResponse.java
new file mode 100644
index 00000000000..48c8f577de9
--- /dev/null
+++ b/container-core/src/main/java/com/yahoo/jdisc/http/servlet/ServletResponse.java
@@ -0,0 +1,66 @@
+// 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.servlet;
+
+import com.yahoo.jdisc.HeaderFields;
+import com.yahoo.jdisc.http.Cookie;
+import com.yahoo.jdisc.http.HttpHeaders;
+
+import javax.servlet.http.HttpServletResponse;
+import javax.servlet.http.HttpServletResponseWrapper;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * JDisc wrapper to use a {@link javax.servlet.http.HttpServletResponse}
+ * with JDisc security filters.
+ */
+public class ServletResponse extends HttpServletResponseWrapper implements ServletOrJdiscHttpResponse {
+
+ private final HttpServletResponse response;
+ private final Map<String, Object> context = new HashMap<>();
+
+ public ServletResponse(HttpServletResponse response) {
+ super(response);
+ this.response = response;
+ }
+
+ public HttpServletResponse getResponse() {
+ return response;
+ }
+
+ @Override
+ public int getStatus() {
+ return response.getStatus();
+ }
+
+ @Override
+ public Map<String, Object> context() {
+ return context;
+ }
+
+ @Override
+ public void copyHeaders(HeaderFields target) {
+ response.getHeaderNames().forEach( header ->
+ target.add(header, new ArrayList<>(response.getHeaders(header)))
+ );
+ }
+
+ @Override
+ public List<Cookie> decodeSetCookieHeader() {
+ Collection<String> cookies = getHeaders(HttpHeaders.Names.SET_COOKIE);
+ if (cookies == null) {
+ return Collections.emptyList();
+ }
+ List<Cookie> ret = new LinkedList<>();
+ for (String cookie : cookies) {
+ ret.add(Cookie.fromSetCookieHeader(cookie));
+ }
+ return ret;
+ }
+
+}
diff --git a/container-core/src/main/java/com/yahoo/jdisc/http/servlet/package-info.java b/container-core/src/main/java/com/yahoo/jdisc/http/servlet/package-info.java
new file mode 100644
index 00000000000..0120f164cae
--- /dev/null
+++ b/container-core/src/main/java/com/yahoo/jdisc/http/servlet/package-info.java
@@ -0,0 +1,5 @@
+// Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+@ExportPackage
+package com.yahoo.jdisc.http.servlet;
+
+import com.yahoo.osgi.annotation.ExportPackage;
diff --git a/container-core/src/main/java/com/yahoo/jdisc/http/ssl/SslContextFactoryProvider.java b/container-core/src/main/java/com/yahoo/jdisc/http/ssl/SslContextFactoryProvider.java
new file mode 100644
index 00000000000..c364116e0af
--- /dev/null
+++ b/container-core/src/main/java/com/yahoo/jdisc/http/ssl/SslContextFactoryProvider.java
@@ -0,0 +1,21 @@
+// 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.ssl;
+
+import org.eclipse.jetty.util.ssl.SslContextFactory;
+
+/**
+ * A provider that is used to configure SSL connectors in JDisc
+ *
+ * @author bjorncs
+ */
+public interface SslContextFactoryProvider extends AutoCloseable {
+
+ /**
+ * This method is called once for each SSL connector.
+ *
+ * @return returns an instance of {@link SslContextFactory} for a given JDisc http server
+ */
+ SslContextFactory getInstance(String containerId, int port);
+
+ @Override default void close() {}
+}
diff --git a/container-core/src/main/java/com/yahoo/jdisc/http/ssl/impl/ConfiguredSslContextFactoryProvider.java b/container-core/src/main/java/com/yahoo/jdisc/http/ssl/impl/ConfiguredSslContextFactoryProvider.java
new file mode 100644
index 00000000000..90848f1dfd4
--- /dev/null
+++ b/container-core/src/main/java/com/yahoo/jdisc/http/ssl/impl/ConfiguredSslContextFactoryProvider.java
@@ -0,0 +1,138 @@
+// 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.ssl.impl;
+
+import com.yahoo.jdisc.http.ConnectorConfig;
+import com.yahoo.jdisc.http.ConnectorConfig.Ssl.ClientAuth;
+import com.yahoo.jdisc.http.ssl.SslContextFactoryProvider;
+import com.yahoo.security.KeyUtils;
+import com.yahoo.security.SslContextBuilder;
+import com.yahoo.security.X509CertificateUtils;
+import com.yahoo.security.tls.AutoReloadingX509KeyManager;
+import com.yahoo.security.tls.TlsContext;
+import org.eclipse.jetty.util.ssl.SslContextFactory;
+
+import javax.net.ssl.SSLContext;
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Paths;
+import java.security.PrivateKey;
+import java.security.cert.X509Certificate;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+
+import static com.yahoo.jdisc.http.ssl.impl.SslContextFactoryUtils.setEnabledCipherSuites;
+import static com.yahoo.jdisc.http.ssl.impl.SslContextFactoryUtils.setEnabledProtocols;
+
+/**
+ * An implementation of {@link SslContextFactoryProvider} that uses the {@link ConnectorConfig} to construct a {@link SslContextFactory}.
+ *
+ * @author bjorncs
+ */
+public class ConfiguredSslContextFactoryProvider implements SslContextFactoryProvider {
+
+ private volatile AutoReloadingX509KeyManager keyManager;
+ private final ConnectorConfig connectorConfig;
+
+ public ConfiguredSslContextFactoryProvider(ConnectorConfig connectorConfig) {
+ validateConfig(connectorConfig.ssl());
+ this.connectorConfig = connectorConfig;
+ }
+
+ @Override
+ public SslContextFactory getInstance(String containerId, int port) {
+ ConnectorConfig.Ssl sslConfig = connectorConfig.ssl();
+ if (!sslConfig.enabled()) throw new IllegalStateException();
+
+ SslContextBuilder builder = new SslContextBuilder();
+ if (sslConfig.certificateFile().isBlank() || sslConfig.privateKeyFile().isBlank()) {
+ PrivateKey privateKey = KeyUtils.fromPemEncodedPrivateKey(getPrivateKey(sslConfig));
+ List<X509Certificate> certificates = X509CertificateUtils.certificateListFromPem(getCertificate(sslConfig));
+ builder.withKeyStore(privateKey, certificates);
+ } else {
+ keyManager = AutoReloadingX509KeyManager.fromPemFiles(Paths.get(sslConfig.privateKeyFile()), Paths.get(sslConfig.certificateFile()));
+ builder.withKeyManager(keyManager);
+ }
+ List<X509Certificate> caCertificates = getCaCertificates(sslConfig)
+ .map(X509CertificateUtils::certificateListFromPem)
+ .orElse(List.of());
+ builder.withTrustStore(caCertificates);
+
+ SSLContext sslContext = builder.build();
+
+ SslContextFactory.Server factory = new SslContextFactory.Server();
+ factory.setSslContext(sslContext);
+
+ factory.setNeedClientAuth(sslConfig.clientAuth() == ClientAuth.Enum.NEED_AUTH);
+ factory.setWantClientAuth(sslConfig.clientAuth() == ClientAuth.Enum.WANT_AUTH);
+
+ List<String> protocols = !sslConfig.enabledProtocols().isEmpty()
+ ? sslConfig.enabledProtocols()
+ : new ArrayList<>(TlsContext.getAllowedProtocols(sslContext));
+ setEnabledProtocols(factory, sslContext, protocols);
+
+ List<String> ciphers = !sslConfig.enabledCipherSuites().isEmpty()
+ ? sslConfig.enabledCipherSuites()
+ : new ArrayList<>(TlsContext.getAllowedCipherSuites(sslContext));
+ setEnabledCipherSuites(factory, sslContext, ciphers);
+
+ return factory;
+ }
+
+ @Override
+ public void close() {
+ if (keyManager != null) {
+ keyManager.close();
+ }
+ }
+
+ private static void validateConfig(ConnectorConfig.Ssl config) {
+ if (!config.enabled()) return;
+
+ if(hasBoth(config.certificate(), config.certificateFile()))
+ throw new IllegalArgumentException("Specified both certificate and certificate file.");
+
+ if(hasBoth(config.privateKey(), config.privateKeyFile()))
+ throw new IllegalArgumentException("Specified both private key and private key file.");
+
+ if(hasNeither(config.certificate(), config.certificateFile()))
+ throw new IllegalArgumentException("Specified neither certificate or certificate file.");
+
+ if(hasNeither(config.privateKey(), config.privateKeyFile()))
+ throw new IllegalArgumentException("Specified neither private key or private key file.");
+ }
+
+ private static boolean hasBoth(String a, String b) { return !a.isBlank() && !b.isBlank(); }
+ private static boolean hasNeither(String a, String b) { return a.isBlank() && b.isBlank(); }
+
+ private static Optional<String> getCaCertificates(ConnectorConfig.Ssl sslConfig) {
+ if (!sslConfig.caCertificate().isBlank()) {
+ return Optional.of(sslConfig.caCertificate());
+ } else if (!sslConfig.caCertificateFile().isBlank()) {
+ return Optional.of(readToString(sslConfig.caCertificateFile()));
+ } else {
+ return Optional.empty();
+ }
+ }
+
+ private static String getPrivateKey(ConnectorConfig.Ssl config) {
+ if(!config.privateKey().isBlank()) return config.privateKey();
+ return readToString(config.privateKeyFile());
+ }
+
+ private static String getCertificate(ConnectorConfig.Ssl config) {
+ if(!config.certificate().isBlank()) return config.certificate();
+ return readToString(config.certificateFile());
+ }
+
+ private static String readToString(String filename) {
+ try {
+ return Files.readString(Paths.get(filename), StandardCharsets.UTF_8);
+ } catch (IOException e) {
+ throw new UncheckedIOException(e);
+ }
+ }
+
+}
diff --git a/container-core/src/main/java/com/yahoo/jdisc/http/ssl/impl/DefaultSslContextFactoryProvider.java b/container-core/src/main/java/com/yahoo/jdisc/http/ssl/impl/DefaultSslContextFactoryProvider.java
new file mode 100644
index 00000000000..7395d2307af
--- /dev/null
+++ b/container-core/src/main/java/com/yahoo/jdisc/http/ssl/impl/DefaultSslContextFactoryProvider.java
@@ -0,0 +1,79 @@
+// 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.ssl.impl;
+
+import com.google.inject.Inject;
+import com.yahoo.component.AbstractComponent;
+import com.yahoo.jdisc.http.ConnectorConfig;
+import com.yahoo.jdisc.http.ssl.SslContextFactoryProvider;
+import com.yahoo.security.tls.ConfigFileBasedTlsContext;
+import com.yahoo.security.tls.PeerAuthentication;
+import com.yahoo.security.tls.TlsContext;
+import com.yahoo.security.tls.TransportSecurityUtils;
+import org.eclipse.jetty.util.ssl.SslContextFactory;
+
+import java.nio.file.Path;
+
+/**
+ * The default implementation of {@link SslContextFactoryProvider} to be injected into connectors without explicit ssl configuration.
+ *
+ * @author bjorncs
+ */
+public class DefaultSslContextFactoryProvider extends AbstractComponent implements SslContextFactoryProvider {
+
+ private final SslContextFactoryProvider instance;
+
+ @Inject
+ public DefaultSslContextFactoryProvider(ConnectorConfig connectorConfig) {
+ this.instance = TransportSecurityUtils.getConfigFile()
+ .map(configFile -> createTlsContextBasedProvider(connectorConfig, configFile))
+ .orElseGet(ThrowingSslContextFactoryProvider::new);
+ }
+
+ private static SslContextFactoryProvider createTlsContextBasedProvider(ConnectorConfig connectorConfig, Path configFile) {
+ return new StaticTlsContextBasedProvider(
+ new ConfigFileBasedTlsContext(
+ configFile, TransportSecurityUtils.getInsecureAuthorizationMode(), getPeerAuthenticationMode(connectorConfig)));
+ }
+
+ /**
+ * Allows white-listing of user provided uri paths.
+ * JDisc will delegate the enforcement of peer authentication from the TLS to the HTTP layer if {@link ConnectorConfig.TlsClientAuthEnforcer#enable()} is true.
+ */
+ private static PeerAuthentication getPeerAuthenticationMode(ConnectorConfig connectorConfig) {
+ return connectorConfig.tlsClientAuthEnforcer().enable()
+ ? PeerAuthentication.WANT
+ : PeerAuthentication.NEED;
+ }
+
+ @Override
+ public SslContextFactory getInstance(String containerId, int port) {
+ return instance.getInstance(containerId, port);
+ }
+
+ @Override
+ public void deconstruct() {
+ instance.close();
+ }
+
+ private static class ThrowingSslContextFactoryProvider implements SslContextFactoryProvider {
+ @Override
+ public SslContextFactory getInstance(String containerId, int port) {
+ throw new UnsupportedOperationException();
+ }
+ }
+
+ private static class StaticTlsContextBasedProvider extends TlsContextBasedProvider {
+ final TlsContext tlsContext;
+
+ StaticTlsContextBasedProvider(TlsContext tlsContext) {
+ this.tlsContext = tlsContext;
+ }
+
+ @Override
+ protected TlsContext getTlsContext(String containerId, int port) {
+ return tlsContext;
+ }
+
+ @Override public void deconstruct() { tlsContext.close(); }
+ }
+} \ No newline at end of file
diff --git a/container-core/src/main/java/com/yahoo/jdisc/http/ssl/impl/JDiscSslContextFactory.java b/container-core/src/main/java/com/yahoo/jdisc/http/ssl/impl/JDiscSslContextFactory.java
new file mode 100644
index 00000000000..006a282e1e0
--- /dev/null
+++ b/container-core/src/main/java/com/yahoo/jdisc/http/ssl/impl/JDiscSslContextFactory.java
@@ -0,0 +1,37 @@
+// 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.ssl.impl;
+
+import org.eclipse.jetty.util.resource.Resource;
+import org.eclipse.jetty.util.security.CertificateUtils;
+import org.eclipse.jetty.util.ssl.SslContextFactory;
+
+import java.security.KeyStore;
+import java.util.Objects;
+
+/**
+ * A modified {@link SslContextFactory} that allows passwordless truststore in combination with password protected keystore.
+ *
+ * @author bjorncs
+ */
+class JDiscSslContextFactory extends SslContextFactory.Server {
+
+ private String trustStorePassword;
+
+ @Override
+ public void setTrustStorePassword(String password) {
+ super.setTrustStorePassword(password);
+ this.trustStorePassword = password;
+ }
+
+
+ // Overriden to stop Jetty from using the keystore password if no truststore password is specified.
+ @Override
+ protected KeyStore loadTrustStore(Resource resource) throws Exception {
+ return CertificateUtils.getKeyStore(
+ resource != null ? resource : getKeyStoreResource(),
+ Objects.toString(getTrustStoreType(), getKeyStoreType()),
+ Objects.toString(getTrustStoreProvider(), getKeyStoreProvider()),
+ trustStorePassword);
+ }
+
+}
diff --git a/container-core/src/main/java/com/yahoo/jdisc/http/ssl/impl/SslContextFactoryUtils.java b/container-core/src/main/java/com/yahoo/jdisc/http/ssl/impl/SslContextFactoryUtils.java
new file mode 100644
index 00000000000..a0172668cbb
--- /dev/null
+++ b/container-core/src/main/java/com/yahoo/jdisc/http/ssl/impl/SslContextFactoryUtils.java
@@ -0,0 +1,32 @@
+// 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.ssl.impl;
+
+import org.eclipse.jetty.util.ssl.SslContextFactory;
+
+import javax.net.ssl.SSLContext;
+import java.util.Arrays;
+import java.util.List;
+
+/**
+ * @author bjorncs
+ */
+class SslContextFactoryUtils {
+
+ static void setEnabledCipherSuites(SslContextFactory factory, SSLContext sslContext, List<String> enabledCiphers) {
+ String[] supportedCiphers = sslContext.getSupportedSSLParameters().getCipherSuites();
+ factory.setIncludeCipherSuites(enabledCiphers.toArray(String[]::new));
+ factory.setExcludeCipherSuites(createExclusionList(enabledCiphers, supportedCiphers));
+ }
+
+ static void setEnabledProtocols(SslContextFactory factory, SSLContext sslContext, List<String> enabledProtocols) {
+ String[] supportedProtocols = sslContext.getSupportedSSLParameters().getProtocols();
+ factory.setIncludeProtocols(enabledProtocols.toArray(String[]::new));
+ factory.setExcludeProtocols(createExclusionList(enabledProtocols, supportedProtocols));
+ }
+
+ private static String[] createExclusionList(List<String> enabledValues, String[] supportedValues) {
+ return Arrays.stream(supportedValues)
+ .filter(supportedValue -> !enabledValues.contains(supportedValue))
+ .toArray(String[]::new);
+ }
+}
diff --git a/container-core/src/main/java/com/yahoo/jdisc/http/ssl/impl/TlsContextBasedProvider.java b/container-core/src/main/java/com/yahoo/jdisc/http/ssl/impl/TlsContextBasedProvider.java
new file mode 100644
index 00000000000..93d4f1dca3f
--- /dev/null
+++ b/container-core/src/main/java/com/yahoo/jdisc/http/ssl/impl/TlsContextBasedProvider.java
@@ -0,0 +1,42 @@
+// 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.ssl.impl;
+
+import com.yahoo.component.AbstractComponent;
+import com.yahoo.jdisc.http.ssl.SslContextFactoryProvider;
+import com.yahoo.security.tls.TlsContext;
+import org.eclipse.jetty.util.ssl.SslContextFactory;
+
+import javax.net.ssl.SSLContext;
+import javax.net.ssl.SSLParameters;
+import java.util.List;
+
+import static com.yahoo.jdisc.http.ssl.impl.SslContextFactoryUtils.setEnabledCipherSuites;
+import static com.yahoo.jdisc.http.ssl.impl.SslContextFactoryUtils.setEnabledProtocols;
+
+/**
+ * A {@link SslContextFactoryProvider} that creates {@link SslContextFactory} instances from {@link TlsContext} instances.
+ *
+ * @author bjorncs
+ */
+public abstract class TlsContextBasedProvider extends AbstractComponent implements SslContextFactoryProvider {
+
+ protected abstract TlsContext getTlsContext(String containerId, int port);
+
+ @Override
+ public final SslContextFactory getInstance(String containerId, int port) {
+ TlsContext tlsContext = getTlsContext(containerId, port);
+ SSLContext sslContext = tlsContext.context();
+ SSLParameters parameters = tlsContext.parameters();
+
+ SslContextFactory.Server sslContextFactory = new SslContextFactory.Server();
+ sslContextFactory.setSslContext(sslContext);
+
+ sslContextFactory.setNeedClientAuth(parameters.getNeedClientAuth());
+ sslContextFactory.setWantClientAuth(parameters.getWantClientAuth());
+
+ setEnabledProtocols(sslContextFactory, sslContext, List.of(parameters.getProtocols()));
+ setEnabledCipherSuites(sslContextFactory, sslContext, List.of(parameters.getCipherSuites()));
+
+ return sslContextFactory;
+ }
+}
diff --git a/container-core/src/main/java/com/yahoo/jdisc/http/ssl/impl/package-info.java b/container-core/src/main/java/com/yahoo/jdisc/http/ssl/impl/package-info.java
new file mode 100644
index 00000000000..f337e9d010b
--- /dev/null
+++ b/container-core/src/main/java/com/yahoo/jdisc/http/ssl/impl/package-info.java
@@ -0,0 +1,8 @@
+// Copyright 2018 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+/**
+ * @author bjorncs
+ */
+@ExportPackage
+package com.yahoo.jdisc.http.ssl.impl;
+
+import com.yahoo.osgi.annotation.ExportPackage; \ No newline at end of file
diff --git a/container-core/src/main/java/com/yahoo/jdisc/http/ssl/package-info.java b/container-core/src/main/java/com/yahoo/jdisc/http/ssl/package-info.java
new file mode 100644
index 00000000000..085e9dedf20
--- /dev/null
+++ b/container-core/src/main/java/com/yahoo/jdisc/http/ssl/package-info.java
@@ -0,0 +1,10 @@
+// Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+/**
+ * @author bjorncs
+ */
+@PublicApi
+@ExportPackage
+package com.yahoo.jdisc.http.ssl;
+
+import com.yahoo.api.annotations.PublicApi;
+import com.yahoo.osgi.annotation.ExportPackage;
diff --git a/container-core/src/main/resources/configdefinitions/container.core.access-log.def b/container-core/src/main/resources/configdefinitions/container.core.access-log.def
new file mode 100644
index 00000000000..69058b3d8da
--- /dev/null
+++ b/container-core/src/main/resources/configdefinitions/container.core.access-log.def
@@ -0,0 +1,23 @@
+# Copyright Verizon Media. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+namespace=container.core
+
+# File name patterns supporting the expected time variables, e.g. ".%Y%m%d%H%M%S"
+fileHandler.pattern string
+
+# When should rotation happen, in minutes after midnight
+# Does this really need to be configurable?
+# Could just configure "every N minutes" instead
+fileHandler.rotation string default="0 60 ..."
+
+# Use this as the name of the symlink created pointing to the newest file in the "date" naming scheme.
+# This is ignored if the sequence naming scheme is used.
+fileHandler.symlink string default=""
+
+# compress the previous access log after rotation
+fileHandler.compressOnRotation bool default=true
+
+# Compression format
+fileHandler.compressionFormat enum {GZIP, ZSTD} default=GZIP
+
+# Max queue length of file handler
+fileHandler.queueSize int default=10000
diff --git a/container-core/src/main/resources/configdefinitions/container.logging.connection-log.def b/container-core/src/main/resources/configdefinitions/container.logging.connection-log.def
new file mode 100644
index 00000000000..65b632c9008
--- /dev/null
+++ b/container-core/src/main/resources/configdefinitions/container.logging.connection-log.def
@@ -0,0 +1,11 @@
+# Copyright Verizon Media. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+namespace=container.logging
+
+# Name of the cluster
+cluster string
+
+# Log directory name
+logDirectoryName string default="qrs"
+
+# Max queue length of file handler
+queueSize int default=10000 \ No newline at end of file
diff --git a/container-core/src/main/resources/configdefinitions/jdisc.http.client.jdisc.http.client.http-client.def b/container-core/src/main/resources/configdefinitions/jdisc.http.client.jdisc.http.client.http-client.def
new file mode 100644
index 00000000000..8f99fccec94
--- /dev/null
+++ b/container-core/src/main/resources/configdefinitions/jdisc.http.client.jdisc.http.client.http-client.def
@@ -0,0 +1,36 @@
+# Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+namespace=jdisc.http.client
+
+userAgent string default = "JDisc/1.0"
+chunkedEncodingEnabled bool default = false
+compressionEnabled bool default = false
+connectionPoolEnabled bool default = true
+followRedirects bool default = false
+removeQueryParamsOnRedirect bool default = true
+sslConnectionPoolEnabled bool default = true
+proxyServer string default = ""
+useProxyProperties bool default = false
+useRawUri bool default = false
+compressionLevel int default = -1
+maxNumConnections int default = -1
+maxNumConnectionsPerHost int default = -1
+maxNumRedirects int default = 5
+maxNumRetries int default = 0
+connectionTimeout double default = 60
+idleConnectionInPoolTimeout double default = 60
+idleConnectionTimeout double default = 60
+idleWebSocketTimeout double default = 15
+requestTimeout double default = 60
+
+ssl.enabled bool default = false
+ssl.keyStoreType string default = "JKS"
+
+# Vespa home is prepended is path is relative
+ssl.keyStorePath string default = "jdisc_container/keyStore.jks"
+
+# Vespa home is prepended is path is relative
+ssl.trustStorePath string default = "conf/jdisc_container/trustStore.jks"
+
+ssl.keyDBKey string default = "jdisc_container"
+ssl.algorithm string default = "SunX509"
+ssl.protocol string default = "TLS"
diff --git a/container-core/src/main/resources/configdefinitions/jdisc.http.jdisc.http.connector.def b/container-core/src/main/resources/configdefinitions/jdisc.http.jdisc.http.connector.def
new file mode 100644
index 00000000000..055e5ad62d2
--- /dev/null
+++ b/container-core/src/main/resources/configdefinitions/jdisc.http.jdisc.http.connector.def
@@ -0,0 +1,127 @@
+# Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+namespace=jdisc.http
+
+# The TCP port to listen to for this connector.
+listenPort int default=0
+
+# The connector name
+name string default="default"
+
+# The header field cache size.
+headerCacheSize int default=512
+
+# The size of the buffer into which response content is aggregated before being sent to the client.
+outputBufferSize int default=65536
+
+# The maximum size of a request header.
+requestHeaderSize int default=65536
+
+# The maximum size of a response header.
+responseHeaderSize int default=65536
+
+# The accept queue size (also known as accept backlog).
+acceptQueueSize int default=0
+
+# Whether the server socket reuses addresses.
+reuseAddress bool default=true
+
+# The maximum idle time for a connection, which roughly translates to the Socket.setSoTimeout(int).
+idleTimeout double default=180.0
+
+# DEPRECATED - Ignored, no longer in use
+stopTimeout double default = 30.0
+# TODO Vespa 8 Remove stop timeout
+
+# Whether or not to have socket keep alive turned on.
+tcpKeepAliveEnabled bool default=false
+
+# Enable/disable TCP_NODELAY (disable/enable Nagle's algorithm).
+tcpNoDelay bool default=true
+
+# Whether to enable connection throttling. New connections will be dropped when a threshold is exceeded.
+throttling.enabled bool default=false
+
+# Max number of connections.
+throttling.maxConnections int default=-1
+
+# Max memory utilization as a value between 0 and 1.
+throttling.maxHeapUtilization double default=-1.0
+
+# Max connection accept rate per second.
+throttling.maxAcceptRate int default=-1
+
+# Idle timeout in seconds applied to endpoints when a threshold is exceeded.
+throttling.idleTimeout double default=-1.0
+
+# Whether to enable TLS on connector when Vespa is configured with TLS.
+# The connector will implicitly enable TLS if set to 'true' and Vespa TLS is enabled.
+implicitTlsEnabled bool default=true
+
+# Whether to enable SSL for this connector.
+ssl.enabled bool default=false
+
+# File with private key in PEM format. Specify either this or privateKey, but not both
+ssl.privateKeyFile string default=""
+
+# Private key in PEM format. Specify either this or privateKeyFile, but not both
+ssl.privateKey string default=""
+
+# File with certificate in PEM format. Specify either this or certificate, but not both
+ssl.certificateFile string default=""
+
+# Certificate in PEM format. Specify either this or certificateFile, but not both
+ssl.certificate string default=""
+
+# with trusted CA certificates in PEM format. Used to verify clients
+# - this is the name of a file on the local container file system
+# - only one of caCertificateFile and caCertificate
+ssl.caCertificateFile string default=""
+
+# with trusted CA certificates in PEM format. Used to verify clients
+# - this is the actual certificates instead of a pointer to the file
+# - only one of caCertificateFile and caCertificate
+ssl.caCertificate string default=""
+
+# Client authentication mode. See SSLEngine.getNeedClientAuth()/getWantClientAuth() for details.
+ssl.clientAuth enum { DISABLED, WANT_AUTH, NEED_AUTH } default=DISABLED
+
+# List of enabled cipher suites. JDisc will use Vespa default if empty.
+ssl.enabledCipherSuites[] string
+
+# List of enabled TLS protocol versions. JDisc will use Vespa default if empty.
+ssl.enabledProtocols[] string
+
+# Enforce TLS client authentication for https requests at the http layer.
+# Intended to be used with connectors with optional client authentication enabled.
+# 401 status code is returned for requests from non-authenticated clients.
+tlsClientAuthEnforcer.enable bool default=false
+
+# Paths where client authentication should not be enforced. To be used in combination with WANT_AUTH. Typically used for health checks.
+tlsClientAuthEnforcer.pathWhitelist[] string
+
+# Use connector only for proxying '/status.html' health checks. Any ssl configuration will be ignored if this option is enabled.
+healthCheckProxy.enable bool default=false
+
+# Which port to proxy
+healthCheckProxy.port int default=8080
+
+# Low-level timeout for proxy client (socket connect, socket read, connection pool). Aggregate timeout will be longer.
+healthCheckProxy.clientTimeout double default=1.0
+
+# Enable PROXY protocol V1/V2 support (only for https connectors).
+proxyProtocol.enabled bool default=false
+
+# Allow https in parallel with proxy protocol
+proxyProtocol.mixedMode bool default=false
+
+# Redirect all requests to https port
+secureRedirect.enabled bool default=false
+
+# Target port for redirect
+secureRedirect.port int default=443
+
+# Maximum number of request per connection before server marks connections as non-persistent. Set to '0' to disable.
+maxRequestsPerConnection int default=0
+
+# Maximum number of seconds a connection can live before it's marked as non-persistent. Set to '0' to disable.
+maxConnectionLife double default=0.0
diff --git a/container-core/src/main/resources/configdefinitions/jdisc.http.jdisc.http.server.def b/container-core/src/main/resources/configdefinitions/jdisc.http.jdisc.http.server.def
new file mode 100644
index 00000000000..049080dedbd
--- /dev/null
+++ b/container-core/src/main/resources/configdefinitions/jdisc.http.jdisc.http.server.def
@@ -0,0 +1,67 @@
+# Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+namespace=jdisc.http
+
+# Whether to enable developer mode, where stack traces etc are visible in response bodies.
+developerMode bool default=false
+
+# The gzip compression level to use, if compression is enabled in a request.
+responseCompressionLevel int default=6
+
+# DEPRECATED - Ignored, no longer in use.
+httpKeepAliveEnabled bool default=true
+# TODO Vespa 8 Remove httpKeepAliveEnabled
+
+# Maximum number of request per http connection before server will hangup.
+# Naming taken from apache http server.
+# 0 means never hangup.
+# DEPRECATED - Ignored, no longer in use. Use similar parameter in connector config instead.
+maxKeepAliveRequests int default=0
+# TODO Vespa 8 Remove maxKeepAliveRequests
+
+# Whether the request body of POSTed forms should be removed (form parameters are available as request parameters).
+removeRawPostBodyForWwwUrlEncodedPost bool default=false
+
+# The component ID of a filter
+filter[].id string
+
+# The binding of a filter
+filter[].binding string
+
+# Filter id for a default filter (chain)
+defaultFilters[].filterId string
+
+# The local port which the default filter should be applied to
+defaultFilters[].localPort int
+
+# Reject all requests not handled by a request filter (chain)
+strictFiltering bool default = false
+
+# Max number of threads in underlying Jetty pool
+maxWorkerThreads int default = 200
+
+# Min number of threads in underlying Jetty pool
+minWorkerThreads int default = 8
+
+# Stop timeout in seconds. The maximum allowed time to process in-flight requests during server shutdown. Setting it to 0 disable graceful shutdown.
+stopTimeout double default = 30.0
+
+# Enable embedded JMX server. Note: only accessible through the loopback interface.
+jmx.enabled bool default = false
+
+# Listen port for the JMX server.
+jmx.listenPort int default = 1099
+
+# Paths that should be reported with monitoring dimensions where applicable
+metric.monitoringHandlerPaths[] string
+
+# Paths that should be reported with search dimensions where applicable
+metric.searchHandlerPaths[] string
+
+# HTTP request headers that contain remote address
+accessLog.remoteAddressHeaders[] string
+
+# HTTP request headers that contain remote port
+accessLog.remotePortHeaders[] string
+
+# Whether to enable jdisc connection log
+connectionLog.enabled bool default=false
diff --git a/container-core/src/main/resources/configdefinitions/jdisc.http.jdisc.http.servlet-paths.def b/container-core/src/main/resources/configdefinitions/jdisc.http.jdisc.http.servlet-paths.def
new file mode 100644
index 00000000000..86707b027be
--- /dev/null
+++ b/container-core/src/main/resources/configdefinitions/jdisc.http.jdisc.http.servlet-paths.def
@@ -0,0 +1,5 @@
+# Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+namespace=jdisc.http
+
+# path by servlet componentId
+servlets{}.path string
diff --git a/container-core/src/test/java/com/yahoo/container/logging/CircularArrayAccessLogKeeperTest.java b/container-core/src/test/java/com/yahoo/container/logging/CircularArrayAccessLogKeeperTest.java
new file mode 100644
index 00000000000..5d9509eb045
--- /dev/null
+++ b/container-core/src/test/java/com/yahoo/container/logging/CircularArrayAccessLogKeeperTest.java
@@ -0,0 +1,42 @@
+// Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.container.logging;
+
+import org.junit.Test;
+
+import static org.hamcrest.CoreMatchers.hasItems;
+import static org.hamcrest.Matchers.contains;
+import static org.hamcrest.core.Is.is;
+import static org.hamcrest.core.IsCollectionContaining.hasItem;
+import static org.hamcrest.core.IsNot.not;
+import static org.junit.Assert.assertThat;
+
+public class CircularArrayAccessLogKeeperTest {
+ private CircularArrayAccessLogKeeper circularArrayAccessLogKeeper = new CircularArrayAccessLogKeeper();
+
+ @Test
+ public void testSizeIsCroppedCorrectly() {
+ for (int i = 0; i < CircularArrayAccessLogKeeper.SIZE - 1; i++) {
+ circularArrayAccessLogKeeper.addUri(String.valueOf(i));
+ }
+ assertThat(circularArrayAccessLogKeeper.getUris().size(), is(CircularArrayAccessLogKeeper.SIZE -1));
+ circularArrayAccessLogKeeper.addUri("foo");
+ assertThat(circularArrayAccessLogKeeper.getUris().size(), is(CircularArrayAccessLogKeeper.SIZE));
+ circularArrayAccessLogKeeper.addUri("bar");
+ assertThat(circularArrayAccessLogKeeper.getUris().size(), is(CircularArrayAccessLogKeeper.SIZE));
+ assertThat(circularArrayAccessLogKeeper.getUris(), hasItems("1", "2", "3", "foo", "bar"));
+ assertThat(circularArrayAccessLogKeeper.getUris(), not(hasItem("0")));
+ }
+
+ @Test
+ public void testEmpty() {
+ assertThat(circularArrayAccessLogKeeper.getUris().size(), is(0));
+ }
+
+ @Test
+ public void testSomeItems() {
+ circularArrayAccessLogKeeper.addUri("a");
+ circularArrayAccessLogKeeper.addUri("b");
+ circularArrayAccessLogKeeper.addUri("b");
+ assertThat(circularArrayAccessLogKeeper.getUris(), contains("a", "b", "b"));
+ }
+}
diff --git a/container-core/src/test/java/com/yahoo/container/logging/JSONLogTestCase.java b/container-core/src/test/java/com/yahoo/container/logging/JSONLogTestCase.java
new file mode 100644
index 00000000000..cb3d1d0a12f
--- /dev/null
+++ b/container-core/src/test/java/com/yahoo/container/logging/JSONLogTestCase.java
@@ -0,0 +1,295 @@
+// Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.container.logging;
+
+import com.yahoo.yolean.trace.TraceNode;
+import org.junit.Test;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.time.Duration;
+import java.time.Instant;
+
+import static com.yahoo.test.json.JsonTestHelper.assertJsonEquals;
+
+
+/**
+ * @author frodelu
+ */
+public class JSONLogTestCase {
+
+ private static String ipAddress = "152.200.54.243";
+
+ private RequestLogEntry.Builder newRequestLogEntry(final String query) {
+ return newRequestLogEntry(query, new Coverage(100,100,100,0));
+ }
+ private RequestLogEntry.Builder newRequestLogEntry(final String query, Coverage coverage) {
+ return new RequestLogEntry.Builder()
+ .rawQuery("query=" + query)
+ .rawPath("")
+ .peerAddress(ipAddress)
+ .httpMethod("GET")
+ .httpVersion("HTTP/1.1")
+ .userAgent("Mozilla/4.05 [en] (Win95; I)")
+ .hitCounts(new HitCounts(0, 10, 1234, 0, 10, coverage))
+ .hostString("localhost")
+ .statusCode(200)
+ .timestamp(Instant.ofEpochMilli(920880005023L))
+ .duration(Duration.ofMillis(122))
+ .contentSize(9875)
+ .localPort(0)
+ .peerPort(0);
+ }
+
+ @Test
+ public void test_json_log_entry() {
+ RequestLogEntry entry = newRequestLogEntry("test").build();
+
+ String expectedOutput =
+ "{\"ip\":\"152.200.54.243\"," +
+ "\"peeraddr\":\"152.200.54.243\"," +
+ "\"time\":920880005.023," +
+ "\"duration\":0.122," +
+ "\"responsesize\":9875," +
+ "\"code\":200," +
+ "\"method\":\"GET\"," +
+ "\"uri\":\"?query=test\"," +
+ "\"version\":\"HTTP/1.1\"," +
+ "\"agent\":\"Mozilla/4.05 [en] (Win95; I)\"," +
+ "\"host\":\"localhost\"," +
+ "\"scheme\":null," +
+ "\"localport\":0," +
+ "\"search\":{" +
+ "\"totalhits\":1234," +
+ "\"hits\":0," +
+ "\"coverage\":{\"coverage\":100,\"documents\":100}" +
+ "}" +
+ "}";
+
+ assertJsonEquals(formatEntry(entry), expectedOutput);
+ }
+ @Test
+ public void test_json_of_trace() {
+ TraceNode root = new TraceNode("root", 7);
+ RequestLogEntry entry = newRequestLogEntry("test")
+ .traceNode(root)
+ .build();
+
+ String expectedOutput =
+ "{\"ip\":\"152.200.54.243\"," +
+ "\"peeraddr\":\"152.200.54.243\"," +
+ "\"time\":920880005.023," +
+ "\"duration\":0.122," +
+ "\"responsesize\":9875," +
+ "\"code\":200," +
+ "\"method\":\"GET\"," +
+ "\"uri\":\"?query=test\"," +
+ "\"version\":\"HTTP/1.1\"," +
+ "\"agent\":\"Mozilla/4.05 [en] (Win95; I)\"," +
+ "\"host\":\"localhost\"," +
+ "\"scheme\":null," +
+ "\"localport\":0," +
+ "\"trace\":{\"timestamp\":0,\"message\":\"root\"}," +
+ "\"search\":{" +
+ "\"totalhits\":1234," +
+ "\"hits\":0," +
+ "\"coverage\":{\"coverage\":100,\"documents\":100}" +
+ "}" +
+ "}";
+
+ assertJsonEquals(formatEntry(entry), expectedOutput);
+ }
+ @Test
+ public void test_with_keyvalues() {
+ RequestLogEntry entry = newRequestLogEntry("test")
+ .addExtraAttribute("singlevalue", "value1")
+ .addExtraAttribute("multivalue", "value2")
+ .addExtraAttribute("multivalue", "value3")
+ .build();
+
+ String expectedOutput =
+ "{\"ip\":\"152.200.54.243\"," +
+ "\"peeraddr\":\"152.200.54.243\"," +
+ "\"time\":920880005.023," +
+ "\"duration\":0.122," +
+ "\"responsesize\":9875," +
+ "\"code\":200," +
+ "\"method\":\"GET\"," +
+ "\"uri\":\"?query=test\"," +
+ "\"version\":\"HTTP/1.1\"," +
+ "\"agent\":\"Mozilla/4.05 [en] (Win95; I)\"," +
+ "\"host\":\"localhost\"," +
+ "\"scheme\":null," +
+ "\"localport\":0," +
+ "\"search\":{" +
+ "\"totalhits\":1234," +
+ "\"hits\":0," +
+ "\"coverage\":{\"coverage\":100,\"documents\":100}" +
+ "}," +
+ "\"attributes\":{" +
+ "\"singlevalue\":\"value1\"," +
+ "\"multivalue\":[\"value2\",\"value3\"]}" +
+ "}";
+
+ assertJsonEquals(formatEntry(entry), expectedOutput);
+
+ }
+
+ @Test
+ public void test_with_remoteaddrport() throws Exception {
+ RequestLogEntry entry = newRequestLogEntry("test")
+ .remoteAddress("FE80:0000:0000:0000:0202:B3FF:FE1E:8329")
+ .build();
+
+ String expectedOutput =
+ "{\"ip\":\"152.200.54.243\"," +
+ "\"peeraddr\":\"152.200.54.243\"," +
+ "\"time\":920880005.023," +
+ "\"duration\":0.122," +
+ "\"responsesize\":9875," +
+ "\"code\":200," +
+ "\"method\":\"GET\"," +
+ "\"uri\":\"?query=test\"," +
+ "\"version\":\"HTTP/1.1\"," +
+ "\"agent\":\"Mozilla/4.05 [en] (Win95; I)\"," +
+ "\"host\":\"localhost\"," +
+ "\"scheme\":null," +
+ "\"localport\":0," +
+ "\"remoteaddr\":\"FE80:0000:0000:0000:0202:B3FF:FE1E:8329\"," +
+ "\"search\":{" +
+ "\"totalhits\":1234," +
+ "\"hits\":0," +
+ "\"coverage\":{\"coverage\":100,\"documents\":100}" +
+ "}" +
+ "}";
+
+ assertJsonEquals(formatEntry(entry), expectedOutput);
+
+ // Add remote port and verify
+ entry = newRequestLogEntry("test")
+ .remoteAddress("FE80:0000:0000:0000:0202:B3FF:FE1E:8329")
+ .remotePort(1234)
+ .build();
+
+ expectedOutput =
+ "{\"ip\":\"152.200.54.243\"," +
+ "\"peeraddr\":\"152.200.54.243\"," +
+ "\"time\":920880005.023," +
+ "\"duration\":0.122," +
+ "\"responsesize\":9875," +
+ "\"code\":200," +
+ "\"method\":\"GET\"," +
+ "\"uri\":\"?query=test\"," +
+ "\"version\":\"HTTP/1.1\"," +
+ "\"agent\":\"Mozilla/4.05 [en] (Win95; I)\"," +
+ "\"host\":\"localhost\"," +
+ "\"scheme\":null," +
+ "\"localport\":0," +
+ "\"remoteaddr\":\"FE80:0000:0000:0000:0202:B3FF:FE1E:8329\"," +
+ "\"remoteport\":1234," +
+ "\"search\":{" +
+ "\"totalhits\":1234," +
+ "\"hits\":0," +
+ "\"coverage\":{\"coverage\":100,\"documents\":100}" +
+ "}" +
+ "}";
+
+ assertJsonEquals(formatEntry(entry), expectedOutput);
+ }
+
+ @Test
+ public void test_remote_address_same_as_ip_address() throws Exception {
+ RequestLogEntry entry = newRequestLogEntry("test").build();
+ RequestLogEntry entrywithremote = newRequestLogEntry("test")
+ .remoteAddress(entry.peerAddress().get())
+ .build();
+ JSONFormatter formatter = new JSONFormatter();
+ assertJsonEquals(formatEntry(entry), formatEntry(entrywithremote));
+ }
+
+ @Test
+ public void test_useragent_with_quotes() {
+ RequestLogEntry entry = new RequestLogEntry.Builder()
+ .rawQuery("query=test")
+ .rawPath("")
+ .peerAddress(ipAddress)
+ .httpMethod("GET")
+ .httpVersion("HTTP/1.1")
+ .userAgent("Mozilla/4.05 [en] (Win95; I; \"Best Browser Ever\")")
+ .hitCounts(new HitCounts(0, 10, 1234, 0, 10, new Coverage(100, 200, 200, 0)))
+ .hostString("localhost")
+ .statusCode(200)
+ .timestamp(Instant.ofEpochMilli(920880005023L))
+ .duration(Duration.ofMillis(122))
+ .contentSize(9875)
+ .localPort(0)
+ .peerPort(0)
+ .build();
+
+ String expectedOutput =
+ "{\"ip\":\"152.200.54.243\"," +
+ "\"peeraddr\":\"152.200.54.243\"," +
+ "\"time\":920880005.023," +
+ "\"duration\":0.122," +
+ "\"responsesize\":9875," +
+ "\"code\":200," +
+ "\"method\":\"GET\"," +
+ "\"uri\":\"?query=test\"," +
+ "\"version\":\"HTTP/1.1\"," +
+ "\"agent\":\"Mozilla/4.05 [en] (Win95; I; \\\"Best Browser Ever\\\")\"," +
+ "\"host\":\"localhost\"," +
+ "\"scheme\":null," +
+ "\"localport\":0," +
+ "\"search\":{" +
+ "\"totalhits\":1234," +
+ "\"hits\":0," +
+ "\"coverage\":{\"coverage\":50,\"documents\":100,\"degraded\":{\"non-ideal-state\":true}}" +
+ "}" +
+ "}";
+
+ assertJsonEquals(formatEntry(entry), expectedOutput);
+ }
+
+ private void verifyCoverage(String coverage, RequestLogEntry entry) {
+ assertJsonEquals(formatEntry(entry),
+ "{\"ip\":\"152.200.54.243\"," +
+ "\"peeraddr\":\"152.200.54.243\"," +
+ "\"time\":920880005.023," +
+ "\"duration\":0.122," +
+ "\"responsesize\":9875," +
+ "\"code\":200," +
+ "\"method\":\"GET\"," +
+ "\"uri\":\"?query=test\"," +
+ "\"version\":\"HTTP/1.1\"," +
+ "\"agent\":\"Mozilla/4.05 [en] (Win95; I)\"," +
+ "\"host\":\"localhost\"," +
+ "\"scheme\":null," +
+ "\"localport\":0," +
+ "\"search\":{" +
+ "\"totalhits\":1234," +
+ "\"hits\":0," +
+ coverage +
+ "}" +
+ "}");
+ }
+
+ @Test
+ public void test_with_coverage_degradation() {
+ verifyCoverage("\"coverage\":{\"coverage\":50,\"documents\":100,\"degraded\":{\"non-ideal-state\":true}}",
+ newRequestLogEntry("test", new Coverage(100,200,200,0)).build());
+ verifyCoverage("\"coverage\":{\"coverage\":50,\"documents\":100,\"degraded\":{\"match-phase\":true}}",
+ newRequestLogEntry("test", new Coverage(100,200,200,1)).build());
+ verifyCoverage("\"coverage\":{\"coverage\":50,\"documents\":100,\"degraded\":{\"timeout\":true}}",
+ newRequestLogEntry("test", new Coverage(100,200,200,2)).build());
+ verifyCoverage("\"coverage\":{\"coverage\":50,\"documents\":100,\"degraded\":{\"adaptive-timeout\":true}}",
+ newRequestLogEntry("test", new Coverage(100,200,200,4)).build());
+ }
+
+ private String formatEntry(RequestLogEntry entry) {
+ try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
+ new JSONFormatter().write(entry, outputStream);
+ return outputStream.toString();
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ }
+ }
+}
diff --git a/container-core/src/test/java/com/yahoo/container/logging/JsonConnectionLogWriterTest.java b/container-core/src/test/java/com/yahoo/container/logging/JsonConnectionLogWriterTest.java
new file mode 100644
index 00000000000..75bc0c915d3
--- /dev/null
+++ b/container-core/src/test/java/com/yahoo/container/logging/JsonConnectionLogWriterTest.java
@@ -0,0 +1,45 @@
+// Copyright Verizon Media. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.container.logging;
+
+import com.yahoo.test.json.JsonTestHelper;
+import org.junit.jupiter.api.Test;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.time.Instant;
+import java.util.List;
+import java.util.UUID;
+
+/**
+ * @author bjorncs
+ */
+class JsonConnectionLogWriterTest {
+
+ @Test
+ void test_serialization() throws IOException {
+ var id = UUID.randomUUID();
+ var instant = Instant.parse("2021-01-13T12:12:12Z");
+ ConnectionLogEntry entry = ConnectionLogEntry.builder(id, instant)
+ .withPeerPort(1234)
+ .withSslHandshakeFailure(new ConnectionLogEntry.SslHandshakeFailure("UNKNOWN",
+ List.of(
+ new ConnectionLogEntry.SslHandshakeFailure.ExceptionEntry("javax.net.ssl.SSLHandshakeException", "message"),
+ new ConnectionLogEntry.SslHandshakeFailure.ExceptionEntry("java.io.IOException", "cause message"))))
+ .build();
+ String expectedJson = "{" +
+ "\"id\":\""+id.toString()+"\"," +
+ "\"timestamp\":\"2021-01-13T12:12:12Z\"," +
+ "\"peerPort\":1234," +
+ "\"ssl\":{\"handshake-failure\":{\"exception\":[" +
+ "{\"cause\":\"javax.net.ssl.SSLHandshakeException\",\"message\":\"message\"}," +
+ "{\"cause\":\"java.io.IOException\",\"message\":\"cause message\"}" +
+ "],\"type\":\"UNKNOWN\"}}}";
+
+ JsonConnectionLogWriter writer = new JsonConnectionLogWriter();
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ writer.write(entry, out);
+ String actualJson = out.toString(StandardCharsets.UTF_8);
+ JsonTestHelper.assertJsonEquals(actualJson, expectedJson);
+ }
+} \ No newline at end of file
diff --git a/container-core/src/test/java/com/yahoo/container/logging/LogFileHandlerTestCase.java b/container-core/src/test/java/com/yahoo/container/logging/LogFileHandlerTestCase.java
new file mode 100644
index 00000000000..dad8f5e3f90
--- /dev/null
+++ b/container-core/src/test/java/com/yahoo/container/logging/LogFileHandlerTestCase.java
@@ -0,0 +1,208 @@
+// Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.container.logging;
+
+import com.yahoo.compress.ZstdCompressor;
+import com.yahoo.container.logging.LogFileHandler.Compression;
+import com.yahoo.io.IOUtils;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.text.DateFormat;
+import java.text.SimpleDateFormat;
+import java.util.Date;
+import java.util.function.BiFunction;
+import java.util.logging.Formatter;
+import java.util.logging.Level;
+import java.util.logging.LogRecord;
+import java.util.zip.GZIPInputStream;
+
+import static com.yahoo.yolean.Exceptions.uncheck;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.Assert.assertNotEquals;
+
+/**
+ * @author Bob Travis
+ * @author bjorncs
+ */
+public class LogFileHandlerTestCase {
+
+ @Rule
+ public TemporaryFolder temporaryFolder = new TemporaryFolder();
+
+ @Test
+ public void testIt() throws IOException {
+ File root = temporaryFolder.newFolder("logfilehandlertest");
+
+ String pattern = root.getAbsolutePath() + "/logfilehandlertest.%Y%m%d%H%M%S";
+ long[] rTimes = {1000, 2000, 10000};
+ LogFileHandler<String> h = new LogFileHandler<>(Compression.NONE, pattern, rTimes, null, 2048, "thread-name", new StringLogWriter());
+ long now = System.currentTimeMillis();
+ long millisPerDay = 60*60*24*1000;
+ long tomorrowDays = (now / millisPerDay) +1;
+ long tomorrowMillis = tomorrowDays * millisPerDay;
+
+ assertThat(tomorrowMillis+1000).isEqualTo(h.logThread.getNextRotationTime(tomorrowMillis));
+ assertThat(tomorrowMillis+10000).isEqualTo(h.logThread.getNextRotationTime(tomorrowMillis+3000));
+ String message = "test";
+ h.publish(message);
+ h.publish( "another test");
+ h.rotateNow();
+ h.publish(message);
+ h.flush();
+ h.shutdown();
+ }
+
+ @Test
+ public void testSimpleLogging() throws IOException {
+ File logFile = temporaryFolder.newFile("testLogFileG1.txt");
+
+ //create logfilehandler
+ LogFileHandler<String> h = new LogFileHandler<>(Compression.NONE, logFile.getAbsolutePath(), "0 5 ...", null, 2048, "thread-name", new StringLogWriter());
+
+ //write log
+ h.publish("testDeleteFileFirst1");
+ h.flush();
+ h.shutdown();
+ }
+
+ @Test
+ public void testDeleteFileDuringLogging() throws IOException {
+ File logFile = temporaryFolder.newFile("testLogFileG2.txt");
+
+ //create logfilehandler
+ LogFileHandler<String> h = new LogFileHandler<>(Compression.NONE, logFile.getAbsolutePath(), "0 5 ...", null, 2048, "thread-name", new StringLogWriter());
+
+ //write log
+ h.publish("testDeleteFileDuringLogging1");
+ h.flush();
+
+ //delete log file
+ logFile.delete();
+
+ //write log again
+ h.publish("testDeleteFileDuringLogging2");
+ h.flush();
+ h.shutdown();
+ }
+
+ @Test(timeout = /*5 minutes*/300_000)
+ public void testSymlink() throws IOException, InterruptedException {
+ File root = temporaryFolder.newFolder("testlogforsymlinkchecking");
+ Formatter formatter = new Formatter() {
+ public String format(LogRecord r) {
+ DateFormat df = new SimpleDateFormat("yyyy.MM.dd:HH:mm:ss.SSS");
+ String timeStamp = df.format(new Date(r.getMillis()));
+ return ("[" + timeStamp + "]" + " " + formatMessage(r));
+ }
+ };
+ LogFileHandler<String> handler = new LogFileHandler<>(
+ Compression.NONE, root.getAbsolutePath() + "/logfilehandlertest.%Y%m%d%H%M%S%s", new long[]{0}, "symlink", 2048, "thread-name", new StringLogWriter());
+
+ String message = formatter.format(new LogRecord(Level.INFO, "test"));
+ handler.publishAndWait(message);
+ String firstFile = handler.getFileName();
+ handler.rotateNow();
+ String secondFileName = handler.getFileName();
+ assertNotEquals(firstFile, secondFileName);
+
+ String longMessage = formatter.format(new LogRecord(Level.INFO, "string which is way longer than the word test"));
+ handler.publish(longMessage);
+ handler.flush();
+ assertThat(Files.size(Paths.get(firstFile))).isEqualTo(31);
+ final long expectedSecondFileLength = 72;
+
+ long symlinkFileLength = Files.size(root.toPath().resolve("symlink"));
+ assertThat(symlinkFileLength).isEqualTo(expectedSecondFileLength);
+ handler.shutdown();
+ }
+
+ @Test(timeout = /*5 minutes*/300_000)
+ public void compresses_previous_log_file() throws InterruptedException, IOException {
+ File root = temporaryFolder.newFolder("compressespreviouslogfile");
+ LogFileHandler<String> firstHandler = new LogFileHandler<>(
+ Compression.ZSTD, root.getAbsolutePath() + "/compressespreviouslogfile.%Y%m%d%H%M%S%s", new long[]{0}, "symlink", 2048, "thread-name", new StringLogWriter());
+ firstHandler.publishAndWait("test");
+ firstHandler.shutdown();
+
+ assertThat(Files.size(Paths.get(firstHandler.getFileName()))).isEqualTo(5);
+ assertThat(root.toPath().resolve("symlink").toRealPath().toString()).isEqualTo(firstHandler.getFileName());
+
+ LogFileHandler<String> secondHandler = new LogFileHandler<>(
+ Compression.ZSTD, root.getAbsolutePath() + "/compressespreviouslogfile.%Y%m%d%H%M%S%s", new long[]{0}, "symlink", 2048, "thread-name", new StringLogWriter());
+ secondHandler.publishAndWait("test");
+ secondHandler.rotateNow();
+
+ assertThat(root.toPath().resolve("symlink").toRealPath().toString()).isEqualTo(secondHandler.getFileName());
+
+ while (Files.exists(root.toPath().resolve(firstHandler.getFileName()))) Thread.sleep(1);
+
+ assertThat(Files.exists(Paths.get(firstHandler.getFileName() + ".zst"))).isTrue();
+ secondHandler.shutdown();
+ }
+
+ @Test(timeout = /*5 minutes*/300_000)
+ public void testcompression_gzip() throws InterruptedException, IOException {
+ testcompression(
+ Compression.GZIP, "gz",
+ (compressedFile, __) -> uncheck(() -> new String(new GZIPInputStream(Files.newInputStream(compressedFile)).readAllBytes())));
+ }
+
+ @Test(timeout = /*5 minutes*/300_000)
+ public void testcompression_zstd() throws InterruptedException, IOException {
+ testcompression(
+ Compression.ZSTD, "zst",
+ (compressedFile, uncompressedSize) -> uncheck(() -> {
+ ZstdCompressor zstdCompressor = new ZstdCompressor();
+ byte[] uncompressedBytes = new byte[uncompressedSize];
+ byte[] compressedBytes = Files.readAllBytes(compressedFile);
+ zstdCompressor.decompress(compressedBytes, 0, compressedBytes.length, uncompressedBytes, 0, uncompressedBytes.length);
+ return new String(uncompressedBytes);
+ }));
+ }
+
+ private void testcompression(Compression compression,
+ String fileExtension,
+ BiFunction<Path, Integer, String> decompressor) throws IOException, InterruptedException {
+ File root = temporaryFolder.newFolder("testcompression" + compression.name());
+
+ LogFileHandler<String> h = new LogFileHandler<>(
+ compression, root.getAbsolutePath() + "/logfilehandlertest.%Y%m%d%H%M%S%s", new long[]{0}, null, 2048, "thread-name", new StringLogWriter());
+ int logEntries = 10000;
+ for (int i = 0; i < logEntries; i++) {
+ h.publish("test");
+ }
+ h.flush();
+ String f1 = h.getFileName();
+ assertThat(f1).startsWith(root.getAbsolutePath() + "/logfilehandlertest.");
+ File uncompressed = new File(f1);
+ File compressed = new File(f1 + "." + fileExtension);
+ assertThat(uncompressed).exists();
+ assertThat(compressed).doesNotExist();
+ String content = IOUtils.readFile(uncompressed);
+ assertThat(content).hasLineCount(logEntries);
+ h.rotateNow();
+ while (uncompressed.exists()) {
+ Thread.sleep(1);
+ }
+ assertThat(compressed).exists();
+ String uncompressedContent = decompressor.apply(compressed.toPath(), content.getBytes().length);
+ assertThat(uncompressedContent).isEqualTo(content);
+ h.shutdown();
+ }
+
+ static class StringLogWriter implements LogWriter<String> {
+
+ @Override
+ public void write(String record, OutputStream outputStream) throws IOException {
+ outputStream.write(record.getBytes(StandardCharsets.UTF_8));
+ }
+ }
+}
diff --git a/container-core/src/test/java/com/yahoo/container/logging/test/LogFormatterTestCase.java b/container-core/src/test/java/com/yahoo/container/logging/test/LogFormatterTestCase.java
new file mode 100644
index 00000000000..ecacf95d100
--- /dev/null
+++ b/container-core/src/test/java/com/yahoo/container/logging/test/LogFormatterTestCase.java
@@ -0,0 +1,27 @@
+// Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
+package com.yahoo.container.logging.test;
+
+import com.yahoo.container.logging.LogFormatter;
+import org.junit.Test;
+
+import java.util.Date;
+
+import static org.junit.Assert.assertEquals;
+
+/**
+ * @author Bob Travis
+ */
+public class LogFormatterTestCase {
+
+ @Test
+ public void testIt() {
+ java.util.TimeZone.setDefault(java.util.TimeZone.getTimeZone("UTC"));
+ @SuppressWarnings("deprecation")
+ long time = new Date(103,7,25,13,30,35).getTime();
+ String result = LogFormatter.insertDate("test%Y%m%d%H%M%S%x",time);
+ assertEquals("test20030825133035Aug",result);
+ result = LogFormatter.insertDate("test%s%T",time);
+ assertEquals("test000"+time, result);
+ }
+
+}
diff --git a/container-core/src/test/java/com/yahoo/jdisc/http/CookieTestCase.java b/container-core/src/test/java/com/yahoo/jdisc/http/CookieTestCase.java
new file mode 100644
index 00000000000..dbdce5c704e
--- /dev/null
+++ b/container-core/src/test/java/com/yahoo/jdisc/http/CookieTestCase.java
@@ -0,0 +1,238 @@
+// 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;
+
+import org.junit.Test;
+
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.containsInAnyOrder;
+import static org.hamcrest.Matchers.equalTo;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+/**
+ * @author Simon Thoresen Hult
+ * @author bjorncs
+ */
+public class CookieTestCase {
+
+ @Test
+ public void requireThatDefaultValuesAreSane() {
+ Cookie cookie = new Cookie("foo", "bar");
+ assertEquals("foo", cookie.getName());
+ assertEquals("bar", cookie.getValue());
+ assertEquals(null, cookie.getDomain());
+ assertEquals(Integer.MIN_VALUE, cookie.getMaxAge(TimeUnit.SECONDS));
+ assertEquals(null, cookie.getPath());
+ assertEquals(false, cookie.isHttpOnly());
+ assertEquals(false, cookie.isSecure());
+ }
+
+ @Test
+ public void requireThatAccessorsWork() {
+ final Cookie cookie = new Cookie();
+ cookie.setName("foo");
+ assertEquals("foo", cookie.getName());
+ cookie.setName("bar");
+ assertEquals("bar", cookie.getName());
+
+ cookie.setValue("foo");
+ assertEquals("foo", cookie.getValue());
+ cookie.setValue("bar");
+ assertEquals("bar", cookie.getValue());
+
+ cookie.setDomain("foo");
+ assertEquals("foo", cookie.getDomain());
+ cookie.setDomain("bar");
+ assertEquals("bar", cookie.getDomain());
+
+ cookie.setPath("foo");
+ assertEquals("foo", cookie.getPath());
+ cookie.setPath("bar");
+ assertEquals("bar", cookie.getPath());
+
+ cookie.setMaxAge(69, TimeUnit.DAYS);
+ assertEquals(69, cookie.getMaxAge(TimeUnit.DAYS));
+ assertEquals(TimeUnit.DAYS.toHours(69), cookie.getMaxAge(TimeUnit.HOURS));
+
+ cookie.setSecure(true);
+ assertTrue(cookie.isSecure());
+ cookie.setSecure(false);
+ assertFalse(cookie.isSecure());
+
+ cookie.setHttpOnly(true);
+ assertTrue(cookie.isHttpOnly());
+ cookie.setHttpOnly(false);
+ assertFalse(cookie.isHttpOnly());
+ }
+
+ @Test
+ public void requireThatCopyConstructorWorks() {
+ final Cookie lhs = newSetCookie("foo");
+ final Cookie rhs = new Cookie(lhs);
+ assertEquals(rhs.getName(), rhs.getName());
+ assertEquals(rhs.getValue(), rhs.getValue());
+ assertEquals(rhs.getDomain(), rhs.getDomain());
+ assertEquals(rhs.getPath(), rhs.getPath());
+ assertEquals(rhs.getMaxAge(TimeUnit.MILLISECONDS), rhs.getMaxAge(TimeUnit.MILLISECONDS));
+ assertEquals(rhs.isSecure(), rhs.isSecure());
+ assertEquals(rhs.isHttpOnly(), rhs.isHttpOnly());
+ }
+
+ @Test
+ public void requireThatHashCodeIsImplemented() {
+ final Cookie cookie = newCookie("foo");
+ assertFalse(cookie.hashCode() == new Cookie().hashCode());
+ assertEquals(cookie.hashCode(), cookie.hashCode());
+ assertEquals(cookie.hashCode(), new Cookie(cookie).hashCode());
+ }
+
+ @Test
+ public void requireThatEqualsIsImplemented() {
+ final Cookie cookie = newCookie("foo");
+ assertFalse(cookie.equals(new Cookie()));
+ assertEquals(cookie, cookie);
+ assertEquals(cookie, new Cookie(cookie));
+ }
+
+ @Test
+ public void requireThatCookieCanBeEncoded() {
+ assertEncodeCookie(
+ "foo.name=foo.value",
+ List.of(newCookie("foo")));
+ assertEncodeCookie(
+ "foo.name=foo.value;bar.name=bar.value",
+ List.of(newCookie("foo"), newCookie("bar")));
+ }
+
+ @Test
+ public void requireThatSetCookieCanBeEncoded() {
+ assertEncodeSetCookie(
+ List.of("foo.name=foo.value; Path=path; Domain=domain; Secure; HttpOnly",
+ "foo.name=foo.value; Path=path; Domain=domain; Secure; HttpOnly; SameSite=None"),
+ List.of(newSetCookie("foo"),
+ newSetCookie("foo").setSameSite(Cookie.SameSite.NONE)));
+ }
+
+ @Test
+ public void requireThatCookieCanBeDecoded() {
+ final Cookie foo = new Cookie();
+ foo.setName("foo.name");
+ foo.setValue("foo.value");
+ assertDecodeCookie(List.of(newCookie("foo")), "foo.name=foo.value");
+
+ final Cookie bar = new Cookie();
+ bar.setName("bar.name");
+ bar.setValue("bar.value");
+ assertDecodeCookie(List.of(foo, bar),"foo.name=foo.value; bar.name=bar.value");
+ }
+
+ @Test
+ public void requireThatSetCookieCanBeDecoded() {
+ final Cookie foo = new Cookie();
+ foo.setName("foo.name");
+ foo.setValue("foo.value");
+ foo.setPath("path");
+ foo.setDomain("domain");
+ foo.setMaxAge(0, TimeUnit.SECONDS);
+ foo.setSecure(true);
+ foo.setHttpOnly(true);
+ assertDecodeSetCookie(foo, "foo.name=foo.value;Max-Age=0;Path=path;Domain=domain;Secure;HTTPOnly;");
+
+ final Cookie bar = new Cookie();
+ bar.setName("bar.name");
+ bar.setValue("bar.value");
+ bar.setPath("path");
+ bar.setDomain("domain");
+ bar.setMaxAge(0, TimeUnit.SECONDS);
+ assertDecodeSetCookie(bar, "bar.name=bar.value;Max-Age=0;Path=path;Domain=domain;");
+ }
+
+ @Test
+ public void requireThatCookieDecoderWorksForGenericValidCookies() {
+ Cookie.fromCookieHeader("Y=v=1&n=8es5opih9ljtk&l=og0_iedeh0qqvqqr/o&p=m2g2rs6012000000&r=pv&lg=en-US&intl=" +
+ "us&np=1; T=z=h.nzPBhSP4PBVd5JqacVnIbNjU1NAY2TjYzNzVOTjYzNzM0Mj&a=YAE&sk=DAALShmNQ" +
+ "vhoZV&ks=EAABsibvMK6ejwn0uUoS4rC9w--~E&d=c2wBTVRJeU13RXhPVEUwTURJNU9URTBNRFF6TlRJ" +
+ "NU5nLS0BYQFZQUUBZwE1VkNHT0w3VUVDTklJVEdRR1FXT0pOSkhEQQFzY2lkAWNOUnZIbEc3ZHZoVHlWZ" +
+ "0NoXzEwYkxhOVdzcy0Bb2sBWlcwLQF0aXABWUhwTmVDAXp6AWgubnpQQkE3RQ--");
+ }
+
+ @Test
+ public void requireThatCookieDecoderWorksForYInvalidCookies() {
+ Cookie.fromCookieHeader("Y=v=1&n=77nkr5t7o4nqn&l=og0_iedeh0qqvqqr/o&p=m2g2rs6012000000&r=pv&lg=en-US&intl=" +
+ "us&np=1; T=z=05nzPB0NP4PBN/n0gwc1AWGNjU1NAY2TjYzNzVOTjYzNzM0Mj&a=QAE&sk=DAA4R2svo" +
+ "osjIa&ks=EAAj3nBQFkN4ZmuhqFxJdNoaQ--~E&d=c2wBTVRJeU13RXhPVEUwTURJNU9URTBNRFF6TlRJ" +
+ "NU5nLS0BYQFRQUUBZwE1VkNHT0w3VUVDTklJVEdRR1FXT0pOSkhEQQFzY2lkAUpPalRXOEVsUDZrR3RHT" +
+ "VZkX29CWk53clJIQS0BdGlwAVlIcE5lQwF6egEwNW56UEJBN0U-");
+ }
+
+ @Test
+ public void requireThatCookieDecoderWorksForYValidCookies() {
+ Cookie.fromCookieHeader("Y=v=1&n=3767k6te5aj2s&l=1v4u3001uw2ys00q0rw0qrw34q0x5s3u/o&p=030vvit012000000&iz=" +
+ "&r=pu&lg=en-US,it-IT,it&intl=it&np=1; T=z=m38yPBmLk3PBWvehTPBhBHYNU5OBjQ3NE5ONU5P" +
+ "NDY0NzU0M0&a=IAE&sk=DAAAx5URYgbhQ6&ks=EAA4rTgdlAGeMQmdYeM_VehGg--~E&d=c2wBTWprNUF" +
+ "UTXdNems1TWprNE16RXpNREl6TkRneAFhAUlBRQFnAUVJSlNMSzVRM1pWNVNLQVBNRkszQTRaWDZBAXNj" +
+ "aWQBSUlyZW5paXp4NS4zTUZMMDVlSVhuMjZKYUcwLQFvawFaVzAtAWFsAW1hcmlvYXByZWFAeW1haWwuY" +
+ "29tAXp6AW0zOHlQQkE3RQF0aXABaXRZOFRE");
+ }
+
+ @Test
+ public void requireThatCookieDecoderWorksForGenericInvalidCookies() {
+ Cookie.fromCookieHeader("Y=v=1&n=e92s5cq8qbs6h&l=3kdb0f.3@i126be10b.d4j/o&p=m1f2qgmb13000107&r=g5&lg=en-US" +
+ "&intl=us; T=z=TXp3OBTrQ8OBFMcj3GBpFSyNk83TgY2MjMwN04zMDMw&a=YAE&sk=DAAVfaNwLeISrX" +
+ "&ks=EAAOeNNgY8c5hV8YzPYmnrW7w--~E&d=c2wBTVRnd09RRXhOVFEzTURrME56UTMBYQFZQUUBZwFMQ" +
+ "U5NT0Q2UjY2Q0I1STY0R0tKSUdVQVlRRQFvawFaVzAtAXRpcAFMTlRUdkMBenoBVFhwM09CQTdF&af=QU" +
+ "FBQ0FDQURBd0FCMUNCOUFJQUJBQ0FEQU1IME1nTWhNbiZ0cz0xMzIzMjEwMTk1JnBzPVA1d3NYakh0aVk" +
+ "2UDMuUGZ6WkdTT2ctLQ--");
+ }
+
+ @Test
+ public void requireMappingBetweenSameSiteAndJettySameSite() {
+ for (var jdiscSameSite : Cookie.SameSite.values()) {
+ assertEquals(jdiscSameSite, Cookie.SameSite.fromJettySameSite(jdiscSameSite.jettySameSite()));
+ }
+
+ for (var jettySameSite : org.eclipse.jetty.http.HttpCookie.SameSite.values()) {
+ assertEquals(jettySameSite, Cookie.SameSite.fromJettySameSite(jettySameSite).jettySameSite());
+ }
+ }
+
+ private static void assertEncodeCookie(String expectedResult, List<Cookie> cookies) {
+ String actual = Cookie.toCookieHeader(cookies);
+ String expectedResult1 = expectedResult;
+ assertThat(actual, equalTo(expectedResult1));
+ }
+
+ private static void assertEncodeSetCookie(List<String> expectedResult, List<Cookie> cookies) {
+ assertThat(Cookie.toSetCookieHeaders(cookies), containsInAnyOrder(expectedResult.toArray()));
+ }
+
+ private static void assertDecodeCookie(List<Cookie> expected, String toDecode) {
+ assertThat(Cookie.fromCookieHeader(toDecode), containsInAnyOrder(expected.toArray()));
+ }
+
+ private static void assertDecodeSetCookie(final Cookie expected, String toDecode) {
+ assertThat(Cookie.fromSetCookieHeader(toDecode), equalTo(expected));
+ }
+
+ private static Cookie newCookie(final String name) {
+ final Cookie cookie = new Cookie();
+ cookie.setName(name + ".name");
+ cookie.setValue(name + ".value");
+ return cookie;
+ }
+
+ private static Cookie newSetCookie(String name) {
+ final Cookie cookie = new Cookie();
+ cookie.setName(name + ".name");
+ cookie.setValue(name + ".value");
+ cookie.setDomain("domain");
+ cookie.setPath("path");
+ cookie.setSecure(true);
+ cookie.setHttpOnly(true);
+ return cookie;
+ }
+}
diff --git a/container-core/src/test/java/com/yahoo/jdisc/http/HttpHeadersTestCase.java b/container-core/src/test/java/com/yahoo/jdisc/http/HttpHeadersTestCase.java
new file mode 100644
index 00000000000..d8ce4a6da0c
--- /dev/null
+++ b/container-core/src/test/java/com/yahoo/jdisc/http/HttpHeadersTestCase.java
@@ -0,0 +1,17 @@
+// 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;
+
+import org.junit.Test;
+
+import static org.junit.Assert.assertEquals;
+
+/**
+ * @author Simon Thoresen Hult
+ */
+public class HttpHeadersTestCase {
+
+ @Test
+ public void requireThatHeadersDoNotChange() {
+ assertEquals("X-JDisc-Disable-Chunking", HttpHeaders.Names.X_DISABLE_CHUNKING);
+ }
+}
diff --git a/container-core/src/test/java/com/yahoo/jdisc/http/HttpRequestTestCase.java b/container-core/src/test/java/com/yahoo/jdisc/http/HttpRequestTestCase.java
new file mode 100644
index 00000000000..a3cb31d5ecb
--- /dev/null
+++ b/container-core/src/test/java/com/yahoo/jdisc/http/HttpRequestTestCase.java
@@ -0,0 +1,206 @@
+// 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;
+
+import com.yahoo.jdisc.Container;
+import com.yahoo.jdisc.Request;
+import com.yahoo.jdisc.service.CurrentContainer;
+import org.junit.Test;
+
+import java.net.InetSocketAddress;
+import java.net.URI;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * @author Simon Thoresen Hult
+ */
+public class HttpRequestTestCase {
+
+ @Test
+ public void requireThatSimpleServerConstructorsUseReasonableDefaults() {
+ URI uri = URI.create("http://localhost/");
+ HttpRequest request = HttpRequest.newServerRequest(mockContainer(), uri);
+ assertTrue(request.isServerRequest());
+ assertEquals(uri, request.getUri());
+ assertEquals(HttpRequest.Method.GET, request.getMethod());
+ assertEquals(HttpRequest.Version.HTTP_1_1, request.getVersion());
+
+ request = HttpRequest.newServerRequest(mockContainer(), uri, HttpRequest.Method.POST);
+ assertTrue(request.isServerRequest());
+ assertEquals(uri, request.getUri());
+ assertEquals(HttpRequest.Method.POST, request.getMethod());
+ assertEquals(HttpRequest.Version.HTTP_1_1, request.getVersion());
+
+ request = HttpRequest.newServerRequest(mockContainer(), uri, HttpRequest.Method.POST, HttpRequest.Version.HTTP_1_0);
+ assertTrue(request.isServerRequest());
+ assertEquals(uri, request.getUri());
+ assertEquals(HttpRequest.Method.POST, request.getMethod());
+ assertEquals(HttpRequest.Version.HTTP_1_0, request.getVersion());
+ }
+
+ @Test
+ public void requireThatSimpleClientConstructorsUseReasonableDefaults() {
+ Request parent = new Request(mockContainer(), URI.create("http://localhost/"));
+
+ URI uri = URI.create("http://remotehost/");
+ HttpRequest request = HttpRequest.newClientRequest(parent, uri);
+ assertFalse(request.isServerRequest());
+ assertEquals(uri, request.getUri());
+ assertEquals(HttpRequest.Method.GET, request.getMethod());
+ assertEquals(HttpRequest.Version.HTTP_1_1, request.getVersion());
+
+ request = HttpRequest.newClientRequest(parent, uri, HttpRequest.Method.POST);
+ assertFalse(request.isServerRequest());
+ assertEquals(uri, request.getUri());
+ assertEquals(HttpRequest.Method.POST, request.getMethod());
+ assertEquals(HttpRequest.Version.HTTP_1_1, request.getVersion());
+
+ request = HttpRequest.newClientRequest(parent, uri, HttpRequest.Method.POST, HttpRequest.Version.HTTP_1_0);
+ assertFalse(request.isServerRequest());
+ assertEquals(uri, request.getUri());
+ assertEquals(HttpRequest.Method.POST, request.getMethod());
+ assertEquals(HttpRequest.Version.HTTP_1_0, request.getVersion());
+ }
+
+ @Test
+ public void requireThatAccessorsWork() {
+ URI uri = URI.create("http://localhost/path?foo=bar&foo=baz&cox=69");
+ InetSocketAddress address = new InetSocketAddress("remotehost", 69);
+ final HttpRequest request = HttpRequest.newServerRequest(mockContainer(), uri, HttpRequest.Method.GET,
+ HttpRequest.Version.HTTP_1_1, address, 1L);
+ assertEquals(uri, request.getUri());
+ request.setUri(uri = URI.create("http://remotehost/"));
+ assertEquals(uri, request.getUri());
+
+ assertEquals(HttpRequest.Method.GET, request.getMethod());
+ request.setMethod(HttpRequest.Method.CONNECT);
+ assertEquals(HttpRequest.Method.CONNECT, request.getMethod());
+
+ assertEquals(HttpRequest.Version.HTTP_1_1, request.getVersion());
+ request.setVersion(HttpRequest.Version.HTTP_1_0);
+ assertEquals(HttpRequest.Version.HTTP_1_0, request.getVersion());
+
+ assertEquals(address, request.getRemoteAddress());
+ request.setRemoteAddress(address = new InetSocketAddress("localhost", 96));
+ assertEquals(address, request.getRemoteAddress());
+
+ final URI proxy = URI.create("http://proxyhost/");
+ request.setProxyServer(proxy);
+ assertEquals(proxy, request.getProxyServer());
+
+ assertNull(request.getConnectionTimeout(TimeUnit.MILLISECONDS));
+ request.setConnectionTimeout(1, TimeUnit.SECONDS);
+ assertEquals(Long.valueOf(1000), request.getConnectionTimeout(TimeUnit.MILLISECONDS));
+
+ assertEquals(Arrays.asList("bar", "baz"), request.parameters().get("foo"));
+ assertEquals(Collections.singletonList("69"), request.parameters().get("cox"));
+ request.parameters().put("cox", Arrays.asList("6", "9"));
+ assertEquals(Arrays.asList("bar", "baz"), request.parameters().get("foo"));
+ assertEquals(Arrays.asList("6", "9"), request.parameters().get("cox"));
+
+ assertEquals(1L, request.getConnectedAt(TimeUnit.MILLISECONDS));
+ }
+
+ @Test
+ public void requireThatHttp10EncodingIsNeverChunked() throws Exception {
+ final HttpRequest request = newRequest(HttpRequest.Version.HTTP_1_0);
+ assertFalse(request.isChunked());
+ request.headers().add(HttpHeaders.Names.TRANSFER_ENCODING, HttpHeaders.Values.CHUNKED);
+ assertFalse(request.isChunked());
+ }
+
+ @Test
+ public void requireThatHttp11EncodingIsNotChunkedByDefault() throws Exception {
+ final HttpRequest request = newRequest(HttpRequest.Version.HTTP_1_1);
+ assertFalse(request.isChunked());
+ }
+
+ @Test
+ public void requireThatHttp11EncodingCanBeChunked() throws Exception {
+ final HttpRequest request = newRequest(HttpRequest.Version.HTTP_1_1);
+ request.headers().add(HttpHeaders.Names.TRANSFER_ENCODING, HttpHeaders.Values.CHUNKED);
+ assertTrue(request.isChunked());
+ }
+
+ @Test
+ public void requireThatHttp10ConnectionIsAlwaysClose() throws Exception {
+ final HttpRequest request = newRequest(HttpRequest.Version.HTTP_1_0);
+ assertFalse(request.isKeepAlive());
+ request.headers().add(HttpHeaders.Names.CONNECTION, HttpHeaders.Values.KEEP_ALIVE);
+ assertTrue(request.isKeepAlive());
+ }
+
+ @Test
+ public void requireThatHttp11ConnectionIsKeepAliveByDefault() throws Exception {
+ final HttpRequest request = newRequest(HttpRequest.Version.HTTP_1_1);
+ assertTrue(request.isKeepAlive());
+ }
+
+ @Test
+ public void requireThatHttp11ConnectionCanBeClose() throws Exception {
+ final HttpRequest request = newRequest(HttpRequest.Version.HTTP_1_1);
+ request.headers().add(HttpHeaders.Names.CONNECTION, HttpHeaders.Values.CLOSE);
+ assertFalse(request.isKeepAlive());
+ }
+
+ @Test
+ public void requireThatHttp10NeverHasChunkedResponse() throws Exception {
+ final HttpRequest request = newRequest(HttpRequest.Version.HTTP_1_0);
+ assertFalse(request.hasChunkedResponse());
+ }
+
+ @Test
+ public void requireThatHttp11HasDefaultChunkedResponse() throws Exception {
+ final HttpRequest request = newRequest(HttpRequest.Version.HTTP_1_1);
+ assertTrue(request.hasChunkedResponse());
+ }
+
+ @Test
+ public void requireThatHttp11CanDisableChunkedResponse() throws Exception {
+ final HttpRequest request = newRequest(HttpRequest.Version.HTTP_1_0);
+ request.headers().add(com.yahoo.jdisc.http.HttpHeaders.Names.X_DISABLE_CHUNKING, "true");
+ assertFalse(request.hasChunkedResponse());
+ }
+
+ @Test
+ public void requireThatCookieHeaderCanBeEncoded() throws Exception {
+ final HttpRequest request = newRequest(HttpRequest.Version.HTTP_1_0);
+ final List<Cookie> cookies = Collections.singletonList(new Cookie("foo", "bar"));
+ request.encodeCookieHeader(cookies);
+ final List<String> headers = request.headers().get(com.yahoo.jdisc.http.HttpHeaders.Names.COOKIE);
+ assertEquals(1, headers.size());
+ assertEquals(Cookie.toCookieHeader(cookies), headers.get(0));
+ }
+
+ @Test
+ public void requireThatCookieHeaderCanBeDecoded() throws Exception {
+ final HttpRequest request = newRequest(HttpRequest.Version.HTTP_1_0);
+ final List<Cookie> cookies = Collections.singletonList(new Cookie("foo", "bar"));
+ request.encodeCookieHeader(cookies);
+ assertEquals(cookies, request.decodeCookieHeader());
+ }
+
+ private static HttpRequest newRequest(final HttpRequest.Version version) throws Exception {
+ return HttpRequest.newServerRequest(
+ mockContainer(),
+ new URI("http://localhost:1234/status.html"),
+ HttpRequest.Method.GET,
+ version);
+ }
+
+ private static CurrentContainer mockContainer() {
+ final CurrentContainer currentContainer = mock(CurrentContainer.class);
+ when(currentContainer.newReference(any(URI.class))).thenReturn(mock(Container.class));
+ return currentContainer;
+ }
+}
diff --git a/container-core/src/test/java/com/yahoo/jdisc/http/HttpResponseTestCase.java b/container-core/src/test/java/com/yahoo/jdisc/http/HttpResponseTestCase.java
new file mode 100644
index 00000000000..61499200f3c
--- /dev/null
+++ b/container-core/src/test/java/com/yahoo/jdisc/http/HttpResponseTestCase.java
@@ -0,0 +1,139 @@
+// 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;
+
+import com.yahoo.jdisc.Container;
+import com.yahoo.jdisc.Request;
+import com.yahoo.jdisc.Response;
+import com.yahoo.jdisc.service.CurrentContainer;
+import org.junit.Test;
+
+import java.net.URI;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertSame;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * @author Simon Thoresen Hult
+ */
+public class HttpResponseTestCase {
+
+ @Test
+ public void requireThatAccessorsWork() throws Exception {
+ final HttpResponse response = newResponse(6, "foo");
+ assertEquals(6, response.getStatus());
+ assertEquals("foo", response.getMessage());
+ assertNull(response.getError());
+ assertTrue(response.isChunkedEncodingEnabled());
+
+ response.setStatus(9);
+ assertEquals(9, response.getStatus());
+
+ response.setMessage("bar");
+ assertEquals("bar", response.getMessage());
+
+ final Throwable err = new Throwable();
+ response.setError(err);
+ assertSame(err, response.getError());
+
+ response.setChunkedEncodingEnabled(false);
+ assertFalse(response.isChunkedEncodingEnabled());
+ }
+
+ @Test
+ public void requireThatStatusCodesDoNotChange() {
+ assertEquals(HttpResponse.Status.CREATED, 201);
+ assertEquals(HttpResponse.Status.ACCEPTED, 202);
+ assertEquals(HttpResponse.Status.NON_AUTHORITATIVE_INFORMATION, 203);
+ assertEquals(HttpResponse.Status.NO_CONTENT, 204);
+ assertEquals(HttpResponse.Status.RESET_CONTENT, 205);
+ assertEquals(HttpResponse.Status.PARTIAL_CONTENT, 206);
+
+ assertEquals(HttpResponse.Status.MULTIPLE_CHOICES, 300);
+ assertEquals(HttpResponse.Status.SEE_OTHER, 303);
+ assertEquals(HttpResponse.Status.NOT_MODIFIED, 304);
+ assertEquals(HttpResponse.Status.USE_PROXY, 305);
+
+ assertEquals(HttpResponse.Status.PAYMENT_REQUIRED, 402);
+ assertEquals(HttpResponse.Status.PROXY_AUTHENTICATION_REQUIRED, 407);
+ assertEquals(HttpResponse.Status.CONFLICT, 409);
+ assertEquals(HttpResponse.Status.GONE, 410);
+ assertEquals(HttpResponse.Status.LENGTH_REQUIRED, 411);
+ assertEquals(HttpResponse.Status.PRECONDITION_FAILED, 412);
+ assertEquals(HttpResponse.Status.REQUEST_ENTITY_TOO_LARGE, 413);
+ assertEquals(HttpResponse.Status.REQUEST_URI_TOO_LONG, 414);
+ assertEquals(HttpResponse.Status.UNSUPPORTED_MEDIA_TYPE, 415);
+ assertEquals(HttpResponse.Status.REQUEST_RANGE_NOT_SATISFIABLE, 416);
+ assertEquals(HttpResponse.Status.EXPECTATION_FAILED, 417);
+
+ assertEquals(HttpResponse.Status.BAD_GATEWAY, 502);
+ assertEquals(HttpResponse.Status.GATEWAY_TIMEOUT, 504);
+ }
+
+ @Test
+ public void requireThat5xxIsServerError() {
+ for (int i = 0; i < 999; ++i) {
+ assertEquals(i >= 500 && i < 600, HttpResponse.isServerError(new Response(i)));
+ }
+ }
+
+ @Test
+ public void requireThatCookieHeaderCanBeEncoded() throws Exception {
+ final HttpResponse response = newResponse(69, "foo");
+ final List<Cookie> cookies = Collections.singletonList(new Cookie("foo", "bar"));
+ response.encodeSetCookieHeader(cookies);
+ final List<String> headers = response.headers().get(HttpHeaders.Names.SET_COOKIE);
+ assertEquals(1, headers.size());
+ assertEquals(Cookie.toSetCookieHeaders(cookies), headers);
+ }
+
+ @Test
+ public void requireThatMultipleCookieHeadersCanBeEncoded() throws Exception {
+ final HttpResponse response = newResponse(69, "foo");
+ final List<Cookie> cookies = Arrays.asList(new Cookie("foo", "bar"), new Cookie("baz", "cox"));
+ response.encodeSetCookieHeader(cookies);
+ final List<String> headers = response.headers().get(HttpHeaders.Names.SET_COOKIE);
+ assertEquals(2, headers.size());
+ assertEquals(Cookie.toSetCookieHeaders(Arrays.asList(new Cookie("foo", "bar"), new Cookie("baz", "cox"))),
+ headers);
+ }
+
+ @Test
+ public void requireThatCookieHeaderCanBeDecoded() throws Exception {
+ final HttpResponse response = newResponse(69, "foo");
+ final List<Cookie> cookies = Collections.singletonList(new Cookie("foo", "bar"));
+ response.encodeSetCookieHeader(cookies);
+ assertEquals(cookies, response.decodeSetCookieHeader());
+ }
+
+ @Test
+ public void requireThatMultipleCookieHeadersCanBeDecoded() throws Exception {
+ final HttpResponse response = newResponse(69, "foo");
+ final List<Cookie> cookies = Arrays.asList(new Cookie("foo", "bar"), new Cookie("baz", "cox"));
+ response.encodeSetCookieHeader(cookies);
+ assertEquals(cookies, response.decodeSetCookieHeader());
+ }
+
+ private static HttpResponse newResponse(final int status, final String message) throws Exception {
+ final Request request = HttpRequest.newServerRequest(
+ mockContainer(),
+ new URI("http://localhost:1234/status.html"),
+ HttpRequest.Method.GET,
+ HttpRequest.Version.HTTP_1_1);
+ return HttpResponse.newInstance(status, message);
+ }
+
+ private static CurrentContainer mockContainer() {
+ final CurrentContainer currentContainer = mock(CurrentContainer.class);
+ when(currentContainer.newReference(any(URI.class))).thenReturn(mock(Container.class));
+ return currentContainer;
+ }
+}
diff --git a/container-core/src/test/java/com/yahoo/jdisc/http/filter/DiscFilterRequestTest.java b/container-core/src/test/java/com/yahoo/jdisc/http/filter/DiscFilterRequestTest.java
new file mode 100644
index 00000000000..1c05a3f3db2
--- /dev/null
+++ b/container-core/src/test/java/com/yahoo/jdisc/http/filter/DiscFilterRequestTest.java
@@ -0,0 +1,357 @@
+// 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.filter;
+
+import com.yahoo.jdisc.HeaderFields;
+import com.yahoo.jdisc.http.Cookie;
+import com.yahoo.jdisc.http.HttpHeaders;
+import com.yahoo.jdisc.http.HttpRequest;
+import com.yahoo.jdisc.http.HttpRequest.Version;
+import com.yahoo.jdisc.test.TestDriver;
+import org.junit.Assert;
+import org.junit.Test;
+
+import java.net.InetSocketAddress;
+import java.net.URI;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Enumeration;
+import java.util.List;
+
+import static org.junit.Assert.assertTrue;
+
+public class DiscFilterRequestTest {
+
+ private static HttpRequest newRequest(URI uri, HttpRequest.Method method, HttpRequest.Version version) {
+ InetSocketAddress address = new InetSocketAddress("example.yahoo.com", 69);
+ TestDriver driver = TestDriver.newSimpleApplicationInstanceWithoutOsgi();
+ driver.activateContainer(driver.newContainerBuilder());
+ HttpRequest request = HttpRequest.newServerRequest(driver, uri, method, version, address);
+ request.release();
+ assertTrue(driver.close());
+ return request;
+ }
+
+ @Test
+ public void testRequestConstruction(){
+ URI uri = URI.create("http://localhost:8080/test?param1=abc");
+ HttpRequest httpReq = newRequest(uri, HttpRequest.Method.GET, HttpRequest.Version.HTTP_1_1);
+ httpReq.headers().add(HttpHeaders.Names.CONTENT_TYPE, "text/html;charset=UTF-8");
+ httpReq.headers().add("X-Custom-Header", "custom_header");
+ List<Cookie> cookies = new ArrayList<Cookie>();
+ cookies.add(new Cookie("XYZ", "value"));
+ cookies.add(new Cookie("ABC", "value"));
+ httpReq.encodeCookieHeader(cookies);
+ DiscFilterRequest request = new JdiscFilterRequest(httpReq);
+ Assert.assertSame(request.getParentRequest(), httpReq);
+ Assert.assertEquals(request.getHeader("X-Custom-Header"),"custom_header");
+ Assert.assertEquals(request.getHeader(HttpHeaders.Names.CONTENT_TYPE),"text/html;charset=UTF-8");
+
+ List<Cookie> c = request.getCookies();
+ Assert.assertNotNull(c);
+ Assert.assertEquals(c.size(), 2);
+
+ Assert.assertEquals(request.getParameter("param1"),"abc");
+ Assert.assertNull(request.getParameter("param2"));
+ Assert.assertEquals(request.getVersion(),Version.HTTP_1_1);
+ Assert.assertEquals(request.getProtocol(),Version.HTTP_1_1.name());
+ Assert.assertNull(request.getRequestedSessionId());
+ }
+
+ @Test
+ public void testRequestConstruction2() {
+ URI uri = URI.create("http://localhost:8080/test");
+ HttpRequest httpReq = newRequest(uri, HttpRequest.Method.GET, HttpRequest.Version.HTTP_1_1);
+ httpReq.headers().add("some-header", "some-value");
+ DiscFilterRequest request = new JdiscFilterRequest(httpReq);
+
+ request.addHeader("some-header", "some-value");
+ String value = request.getUntreatedHeaders().get("some-header").get(0);
+ Assert.assertEquals(value,"some-value");
+ }
+
+ @Test
+ public void testRequestAttributes() {
+ URI uri = URI.create("http://localhost:8080/test");
+ HttpRequest httpReq = newRequest(uri, HttpRequest.Method.GET, HttpRequest.Version.HTTP_1_1);
+ DiscFilterRequest request = new JdiscFilterRequest(httpReq);
+ request.setAttribute("some_attr", "some_value");
+
+ Assert.assertEquals(request.containsAttribute("some_attr"),true);
+
+ Assert.assertEquals(request.getAttribute("some_attr"),"some_value");
+
+ }
+
+ @Test
+ public void testGetAttributeNames() {
+ URI uri = URI.create("http://localhost:8080/test");
+ HttpRequest httpReq = newRequest(uri, HttpRequest.Method.GET, HttpRequest.Version.HTTP_1_1);
+ DiscFilterRequest request = new JdiscFilterRequest(httpReq);
+ request.setAttribute("some_attr_1", "some_value1");
+ request.setAttribute("some_attr_2", "some_value2");
+
+ Enumeration<String> e = request.getAttributeNames();
+ List<String> attrList = Collections.list(e);
+ Assert.assertEquals(2, attrList.size());
+ Assert.assertEquals(attrList.contains("some_attr_1"), true);
+ Assert.assertEquals(attrList.contains("some_attr_2"), true);
+
+ }
+
+ @Test
+ public void testRemoveAttribute() {
+ URI uri = URI.create("http://localhost:8080/test");
+ HttpRequest httpReq = newRequest(uri, HttpRequest.Method.GET, HttpRequest.Version.HTTP_1_1);
+ DiscFilterRequest request = new JdiscFilterRequest(httpReq);
+ request.setAttribute("some_attr", "some_value");
+
+ Assert.assertEquals(request.containsAttribute("some_attr"),true);
+
+ request.removeAttribute("some_attr");
+
+ Assert.assertEquals(request.containsAttribute("some_attr"),false);
+ }
+
+ @Test
+ public void testGetIntHeader() {
+ URI uri = URI.create("http://localhost:8080/test");
+ HttpRequest httpReq = newRequest(uri, HttpRequest.Method.GET, HttpRequest.Version.HTTP_1_1);
+ DiscFilterRequest request = new JdiscFilterRequest(httpReq);
+
+ Assert.assertEquals(-1, request.getIntHeader("int_header"));
+
+ request.addHeader("int_header", String.valueOf(5));
+
+ Assert.assertEquals(5, request.getIntHeader("int_header"));
+ }
+
+ @Test
+ public void testDateHeader() {
+ URI uri = URI.create("http://localhost:8080/test");
+ HttpRequest httpReq = newRequest(uri, HttpRequest.Method.GET, HttpRequest.Version.HTTP_1_1);
+ DiscFilterRequest request = new JdiscFilterRequest(httpReq);
+
+
+ Assert.assertEquals(-1, request.getDateHeader(HttpHeaders.Names.IF_MODIFIED_SINCE));
+
+ request.addHeader(HttpHeaders.Names.IF_MODIFIED_SINCE, "Sat, 29 Oct 1994 19:43:31 GMT");
+
+ Assert.assertEquals(783459811000L, request.getDateHeader(HttpHeaders.Names.IF_MODIFIED_SINCE));
+ }
+
+ @Test
+ public void testParameterAPIsAsList() {
+ URI uri = URI.create("http://example.yahoo.com:8080/test?param1=abc&param2=xyz&param2=pqr");
+ HttpRequest httpReq = newRequest(uri, HttpRequest.Method.GET, HttpRequest.Version.HTTP_1_1);
+ DiscFilterRequest request = new JdiscFilterRequest(httpReq);
+ Assert.assertEquals(request.getParameter("param1"),"abc");
+
+ List<String> values = request.getParameterValuesAsList("param2");
+ Assert.assertEquals(values.get(0),"xyz");
+ Assert.assertEquals(values.get(1),"pqr");
+
+ List<String> paramNames = request.getParameterNamesAsList();
+ Assert.assertEquals(paramNames.size(), 2);
+
+ }
+
+ @Test
+ public void testParameterAPI(){
+ URI uri = URI.create("http://example.yahoo.com:8080/test?param1=abc&param2=xyz&param2=pqr");
+ HttpRequest httpReq = newRequest(uri, HttpRequest.Method.GET, HttpRequest.Version.HTTP_1_1);
+ DiscFilterRequest request = new JdiscFilterRequest(httpReq);
+ Assert.assertEquals(request.getParameter("param1"),"abc");
+
+ Enumeration<String> values = request.getParameterValues("param2");
+ List<String> valuesList = Collections.list(values);
+ Assert.assertEquals(valuesList.get(0),"xyz");
+ Assert.assertEquals(valuesList.get(1),"pqr");
+
+ Enumeration<String> paramNames = request.getParameterNames();
+ List<String> paramNamesList = Collections.list(paramNames);
+ Assert.assertEquals(paramNamesList.size(), 2);
+ }
+
+ @Test
+ public void testGetHeaderNamesAsList() {
+ URI uri = URI.create("http://localhost:8080/test");
+ HttpRequest httpReq = newRequest(uri, HttpRequest.Method.GET, HttpRequest.Version.HTTP_1_1);
+ httpReq.headers().add(HttpHeaders.Names.CONTENT_TYPE, "multipart/form-data");
+ httpReq.headers().add("header_1", "value1");
+ httpReq.headers().add("header_2", "value2");
+ DiscFilterRequest request = new JdiscFilterRequest(httpReq);
+
+ Assert.assertEquals(request.getHeaderNamesAsList() instanceof List, true);
+ Assert.assertEquals(request.getHeaderNamesAsList().size(), 3);
+ }
+
+ @Test
+ public void testGetHeadersAsList() {
+ URI uri = URI.create("http://localhost:8080/test");
+ HttpRequest httpReq = newRequest(uri, HttpRequest.Method.GET, HttpRequest.Version.HTTP_1_1);
+ DiscFilterRequest request = new JdiscFilterRequest(httpReq);
+
+ Assert.assertEquals(request.getHeaderNamesAsList() instanceof List, true);
+ Assert.assertEquals(request.getHeaderNamesAsList().size(), 0);
+
+ httpReq.headers().add("header_1", "value1");
+ httpReq.headers().add("header_1", "value2");
+
+ Assert.assertEquals(request.getHeadersAsList("header_1").size(), 2);
+ }
+
+ @Test
+ public void testIsMultipart() {
+
+ URI uri = URI.create("http://localhost:8080/test");
+ HttpRequest httpReq = newRequest(uri, HttpRequest.Method.GET, HttpRequest.Version.HTTP_1_1);
+ httpReq.headers().add(HttpHeaders.Names.CONTENT_TYPE, "multipart/form-data");
+ DiscFilterRequest request = new JdiscFilterRequest(httpReq);
+
+ Assert.assertEquals(true,DiscFilterRequest.isMultipart(request));
+
+ httpReq = newRequest(uri, HttpRequest.Method.GET, HttpRequest.Version.HTTP_1_1);
+ httpReq.headers().add(HttpHeaders.Names.CONTENT_TYPE, "text/html;charset=UTF-8");
+ request = new JdiscFilterRequest(httpReq);
+
+ Assert.assertEquals(DiscFilterRequest.isMultipart(request),false);
+
+ Assert.assertEquals(DiscFilterRequest.isMultipart(null),false);
+
+
+ httpReq = newRequest(uri, HttpRequest.Method.GET, HttpRequest.Version.HTTP_1_1);
+ request = new JdiscFilterRequest(httpReq);
+ Assert.assertEquals(DiscFilterRequest.isMultipart(request),false);
+ }
+
+ @Test
+ public void testGetRemotePortLocalPort() {
+
+ URI uri = URI.create("http://example.yahoo.com:8080/test");
+ HttpRequest httpReq = newRequest(uri, HttpRequest.Method.GET, HttpRequest.Version.HTTP_1_1);
+ DiscFilterRequest request = new JdiscFilterRequest(httpReq);
+
+ Assert.assertEquals(69, request.getRemotePort());
+ Assert.assertEquals(8080, request.getLocalPort());
+
+ if (request.getRemoteHost() != null) // if we have network
+ Assert.assertEquals("example.yahoo.com", request.getRemoteHost());
+
+ request.setRemoteAddr("1.1.1.1");
+
+ Assert.assertEquals("1.1.1.1",request.getRemoteAddr());
+ }
+
+ @Test
+ public void testCharacterEncoding() throws Exception {
+ URI uri = URI.create("http://example.yahoo.com:8080/test");
+ HttpRequest httpReq = newRequest(uri, HttpRequest.Method.GET, HttpRequest.Version.HTTP_1_1);
+ DiscFilterRequest request = new JdiscFilterRequest(httpReq);
+ request.setHeaders(HttpHeaders.Names.CONTENT_TYPE, "text/html;charset=UTF-8");
+
+ Assert.assertEquals(request.getCharacterEncoding(), "UTF-8");
+
+ httpReq = newRequest(uri, HttpRequest.Method.GET, HttpRequest.Version.HTTP_1_1);
+ request = new JdiscFilterRequest(httpReq);
+ request.setHeaders(HttpHeaders.Names.CONTENT_TYPE, "text/html");
+ request.setCharacterEncoding("UTF-8");
+
+ Assert.assertEquals(request.getCharacterEncoding(),"UTF-8");
+
+ Assert.assertEquals(request.getHeader(HttpHeaders.Names.CONTENT_TYPE),"text/html;charset=UTF-8");
+ }
+
+ @Test
+ public void testSetScheme() throws Exception {
+ URI uri = URI.create("https://example.yahoo.com:8080/test");
+ HttpRequest httpReq = newRequest(uri, HttpRequest.Method.GET, HttpRequest.Version.HTTP_1_1);
+ DiscFilterRequest request = new JdiscFilterRequest(httpReq);
+
+ request.setScheme("http", true);
+ System.out.println(request.getUri().toString());
+ Assert.assertEquals(request.getUri().toString(), "http://example.yahoo.com:8080/test");
+ }
+
+ @Test
+ public void testGetServerPort() throws Exception {
+ URI uri = URI.create("http://example.yahoo.com/test");
+ HttpRequest httpReq = newRequest(uri, HttpRequest.Method.GET, HttpRequest.Version.HTTP_1_1);
+ DiscFilterRequest request = new JdiscFilterRequest(httpReq);
+ Assert.assertEquals(request.getServerPort(), 80);
+
+ request.setUri(URI.create("https://example.yahoo.com/test"));
+ Assert.assertEquals(request.getServerPort(), 443);
+
+ }
+
+ @Test
+ public void testIsSecure() throws Exception {
+ URI uri = URI.create("http://example.yahoo.com/test");
+ HttpRequest httpReq = newRequest(uri, HttpRequest.Method.GET, HttpRequest.Version.HTTP_1_1);
+ DiscFilterRequest request = new JdiscFilterRequest(httpReq);
+ Assert.assertEquals(request.isSecure(), false);
+
+ request.setUri(URI.create("https://example.yahoo.com/test"));
+ Assert.assertEquals(request.isSecure(), true);
+
+ }
+
+ @Test
+ public void requireThatUnresolvableRemoteAddressesAreSupported() {
+ URI uri = URI.create("http://doesnotresolve.zzz:8080/test");
+ HttpRequest httpReq = newRequest(uri, HttpRequest.Method.GET, HttpRequest.Version.HTTP_1_1);
+ DiscFilterRequest request = new JdiscFilterRequest(httpReq);
+ Assert.assertNull(request.getLocalAddr());
+ }
+
+ @Test
+ public void testGetUntreatedHeaders() {
+ URI uri = URI.create("http://example.yahoo.com/test");
+ HttpRequest httpReq = newRequest(uri, HttpRequest.Method.GET, HttpRequest.Version.HTTP_1_1);
+ httpReq.headers().add("key1", "value1");
+ httpReq.headers().add("key2", Arrays.asList("value1","value2"));
+
+ DiscFilterRequest request = new JdiscFilterRequest(httpReq);
+ HeaderFields headers = request.getUntreatedHeaders();
+ Assert.assertEquals(headers.keySet().size(), 2);
+ Assert.assertEquals(headers.get("key1").get(0), "value1" );
+ Assert.assertEquals(headers.get("key2").get(0), "value1" );
+ Assert.assertEquals(headers.get("key2").get(1), "value2" );
+ }
+
+ @Test
+ public void testClearCookies() throws Exception {
+ URI uri = URI.create("http://example.yahoo.com/test");
+ HttpRequest httpReq = newRequest(uri, HttpRequest.Method.GET, HttpRequest.Version.HTTP_1_1);
+ httpReq.headers().put(HttpHeaders.Names.COOKIE, "XYZ=value");
+ DiscFilterRequest request = new JdiscFilterRequest(httpReq);
+ request.clearCookies();
+ Assert.assertNull(request.getHeader(HttpHeaders.Names.COOKIE));
+ }
+
+ @Test
+ public void testGetWrapedCookies() throws Exception {
+ URI uri = URI.create("http://example.yahoo.com/test");
+ HttpRequest httpReq = newRequest(uri, HttpRequest.Method.GET, HttpRequest.Version.HTTP_1_1);
+ httpReq.headers().put(HttpHeaders.Names.COOKIE, "XYZ=value");
+ DiscFilterRequest request = new JdiscFilterRequest(httpReq);
+ JDiscCookieWrapper[] wrappers = request.getWrappedCookies();
+ Assert.assertEquals(wrappers.length ,1);
+ Assert.assertEquals(wrappers[0].getName(), "XYZ");
+ Assert.assertEquals(wrappers[0].getValue(), "value");
+ }
+
+ @Test
+ public void testAddCookie() {
+ URI uri = URI.create("http://example.yahoo.com/test");
+ HttpRequest httpReq = newRequest(uri, HttpRequest.Method.GET, HttpRequest.Version.HTTP_1_1);
+ DiscFilterRequest request = new JdiscFilterRequest(httpReq);
+ request.addCookie(JDiscCookieWrapper.wrap(new Cookie("name", "value")));
+
+ List<Cookie> cookies = request.getCookies();
+ Assert.assertEquals(cookies.size(), 1);
+ Assert.assertEquals(cookies.get(0).getName(), "name");
+ Assert.assertEquals(cookies.get(0).getValue(), "value");
+ }
+}
diff --git a/container-core/src/test/java/com/yahoo/jdisc/http/filter/DiscFilterResponseTest.java b/container-core/src/test/java/com/yahoo/jdisc/http/filter/DiscFilterResponseTest.java
new file mode 100644
index 00000000000..b349cb8d803
--- /dev/null
+++ b/container-core/src/test/java/com/yahoo/jdisc/http/filter/DiscFilterResponseTest.java
@@ -0,0 +1,113 @@
+// 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.filter;
+
+import com.yahoo.jdisc.Request;
+import com.yahoo.jdisc.http.Cookie;
+import com.yahoo.jdisc.http.HttpRequest;
+import com.yahoo.jdisc.http.HttpResponse;
+import com.yahoo.jdisc.test.TestDriver;
+import org.junit.Assert;
+import org.junit.Test;
+
+import java.net.InetSocketAddress;
+import java.net.URI;
+import java.util.Collections;
+import java.util.List;
+
+import static org.junit.Assert.assertTrue;
+
+public class DiscFilterResponseTest {
+
+ private static HttpRequest newRequest(URI uri, HttpRequest.Method method, HttpRequest.Version version) {
+ InetSocketAddress address = new InetSocketAddress("localhost", 69);
+ TestDriver driver = TestDriver.newSimpleApplicationInstanceWithoutOsgi();
+ driver.activateContainer(driver.newContainerBuilder());
+ HttpRequest request = HttpRequest.newServerRequest(driver, uri, method, version, address);
+ request.release();
+ assertTrue(driver.close());
+ return request;
+ }
+
+ public static HttpResponse newResponse(Request request, int status) {
+ return HttpResponse.newInstance(status);
+ }
+
+ @Test
+ public void testGetSetStatus() {
+ HttpRequest request = newRequest(URI.create("http://localhost:8080/echo"),
+ HttpRequest.Method.GET, HttpRequest.Version.HTTP_1_1);
+ DiscFilterResponse response = new JdiscFilterResponse(HttpResponse.newInstance(HttpResponse.Status.OK));
+
+ Assert.assertEquals(response.getStatus(), HttpResponse.Status.OK);
+ response.setStatus(HttpResponse.Status.REQUEST_TIMEOUT);
+ Assert.assertEquals(response.getStatus(), HttpResponse.Status.REQUEST_TIMEOUT);
+ }
+
+ @Test
+ public void testAttributes() {
+ HttpRequest request = newRequest(URI.create("http://localhost:8080/echo"),
+ HttpRequest.Method.GET, HttpRequest.Version.HTTP_1_1);
+ DiscFilterResponse response = new JdiscFilterResponse(HttpResponse.newInstance(HttpResponse.Status.OK));
+ response.setAttribute("attr_1", "value1");
+ Assert.assertEquals(response.getAttribute("attr_1"), "value1");
+ List<String> list = Collections.list(response.getAttributeNames());
+ Assert.assertEquals(list.get(0), "attr_1");
+ response.removeAttribute("attr_1");
+ Assert.assertNull(response.getAttribute("attr_1"));
+ }
+
+ @Test
+ public void testAddHeader() {
+ HttpRequest request = newRequest(URI.create("http://localhost:8080/echo"),
+ HttpRequest.Method.GET, HttpRequest.Version.HTTP_1_1);
+ DiscFilterResponse response = new JdiscFilterResponse(HttpResponse.newInstance(HttpResponse.Status.OK));
+ response.addHeader("header1", "value1");
+ Assert.assertEquals(response.getHeader("header1"), "value1");
+ }
+
+ @Test
+ public void testAddCookie() {
+ URI uri = URI.create("http://example.com/test");
+ HttpRequest httpReq = newRequest(uri, HttpRequest.Method.GET, HttpRequest.Version.HTTP_1_1);
+ HttpResponse httpResp = newResponse(httpReq, 200);
+ DiscFilterResponse response = new JdiscFilterResponse(httpResp);
+ response.addCookie(JDiscCookieWrapper.wrap(new Cookie("name", "value")));
+
+ List<Cookie> cookies = response.getCookies();
+ Assert.assertEquals(cookies.size(),1);
+ Assert.assertEquals(cookies.get(0).getName(),"name");
+ }
+
+ @Test
+ public void testSetCookie() {
+ URI uri = URI.create("http://example.com/test");
+ HttpRequest httpReq = newRequest(uri, HttpRequest.Method.GET, HttpRequest.Version.HTTP_1_1);
+ HttpResponse httpResp = newResponse(httpReq, 200);
+ DiscFilterResponse response = new JdiscFilterResponse(httpResp);
+ response.setCookie("name", "value");
+ List<Cookie> cookies = response.getCookies();
+ Assert.assertEquals(cookies.size(),1);
+ Assert.assertEquals(cookies.get(0).getName(),"name");
+
+ }
+
+ @Test
+ public void testSetHeader() {
+ URI uri = URI.create("http://example.com/test");
+ HttpRequest httpReq = newRequest(uri, HttpRequest.Method.GET, HttpRequest.Version.HTTP_1_1);
+ HttpResponse httpResp = newResponse(httpReq, 200);
+ DiscFilterResponse response = new JdiscFilterResponse(httpResp);
+ response.setHeader("name", "value");
+ Assert.assertEquals(response.getHeader("name"), "value");
+ }
+
+ @Test
+ public void testGetParentResponse() {
+ URI uri = URI.create("http://example.com/test");
+ HttpRequest httpReq = newRequest(uri, HttpRequest.Method.GET, HttpRequest.Version.HTTP_1_1);
+ HttpResponse httpResp = newResponse(httpReq, 200);
+ DiscFilterResponse response = new JdiscFilterResponse(httpResp);
+ Assert.assertSame(response.getParentResponse(), httpResp);
+ }
+
+}
diff --git a/container-core/src/test/java/com/yahoo/jdisc/http/filter/EmptyRequestFilterTestCase.java b/container-core/src/test/java/com/yahoo/jdisc/http/filter/EmptyRequestFilterTestCase.java
new file mode 100644
index 00000000000..f4418e74169
--- /dev/null
+++ b/container-core/src/test/java/com/yahoo/jdisc/http/filter/EmptyRequestFilterTestCase.java
@@ -0,0 +1,48 @@
+// 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.filter;
+
+import com.yahoo.jdisc.Container;
+import com.yahoo.jdisc.handler.ResponseHandler;
+import com.yahoo.jdisc.http.HttpRequest;
+import com.yahoo.jdisc.http.filter.chain.EmptyRequestFilter;
+import com.yahoo.jdisc.service.CurrentContainer;
+import org.junit.Test;
+
+import java.net.URI;
+import java.util.concurrent.TimeUnit;
+
+import static com.yahoo.jdisc.http.HttpRequest.Method;
+import static com.yahoo.jdisc.http.HttpRequest.Version;
+import static org.junit.Assert.assertEquals;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * @author Simon Thoresen Hult
+ */
+public class EmptyRequestFilterTestCase {
+
+ @Test
+ public void requireThatEmptyFilterDoesNothing() throws Exception {
+ final HttpRequest lhs = newRequest(Method.GET, "/status.html", Version.HTTP_1_1);
+ final HttpRequest rhs = newRequest(Method.GET, "/status.html", Version.HTTP_1_1);
+
+ EmptyRequestFilter.INSTANCE.filter(rhs, mock(ResponseHandler.class));
+
+ assertEquals(lhs.headers(), rhs.headers());
+ assertEquals(lhs.context(), rhs.context());
+ assertEquals(lhs.getTimeout(TimeUnit.MILLISECONDS), rhs.getTimeout(TimeUnit.MILLISECONDS));
+ assertEquals(lhs.parameters(), rhs.parameters());
+ assertEquals(lhs.getMethod(), rhs.getMethod());
+ assertEquals(lhs.getVersion(), rhs.getVersion());
+ assertEquals(lhs.getRemoteAddress(), rhs.getRemoteAddress());
+ }
+
+ private static HttpRequest newRequest(
+ final Method method, final String uri, final Version version) {
+ final CurrentContainer currentContainer = mock(CurrentContainer.class);
+ when(currentContainer.newReference(any(URI.class))).thenReturn(mock(Container.class));
+ return HttpRequest.newServerRequest(currentContainer, URI.create(uri), method, version);
+ }
+}
diff --git a/container-core/src/test/java/com/yahoo/jdisc/http/filter/EmptyResponseFilterTestCase.java b/container-core/src/test/java/com/yahoo/jdisc/http/filter/EmptyResponseFilterTestCase.java
new file mode 100644
index 00000000000..e6d7259ea41
--- /dev/null
+++ b/container-core/src/test/java/com/yahoo/jdisc/http/filter/EmptyResponseFilterTestCase.java
@@ -0,0 +1,45 @@
+// 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.filter;
+
+import com.yahoo.jdisc.Container;
+import com.yahoo.jdisc.Response;
+import com.yahoo.jdisc.http.HttpRequest;
+import com.yahoo.jdisc.http.HttpResponse;
+import com.yahoo.jdisc.http.filter.chain.EmptyResponseFilter;
+import com.yahoo.jdisc.service.CurrentContainer;
+import org.junit.Test;
+
+import java.net.URI;
+
+import static com.yahoo.jdisc.http.HttpRequest.Method;
+import static com.yahoo.jdisc.http.HttpRequest.Version;
+import static org.junit.Assert.assertEquals;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * @author Simon Thoresen Hult
+ */
+public class EmptyResponseFilterTestCase {
+
+ @Test
+ public void requireThatEmptyFilterDoesNothing() throws Exception {
+ final HttpRequest request = newRequest(Method.GET, "/status.html", Version.HTTP_1_1);
+ final HttpResponse lhs = HttpResponse.newInstance(Response.Status.OK);
+ final HttpResponse rhs = HttpResponse.newInstance(Response.Status.OK);
+
+ EmptyResponseFilter.INSTANCE.filter(lhs, null);
+
+ assertEquals(lhs.headers(), rhs.headers());
+ assertEquals(lhs.context(), rhs.context());
+ assertEquals(lhs.getError(), rhs.getError());
+ assertEquals(lhs.getMessage(), rhs.getMessage());
+ }
+
+ private static HttpRequest newRequest(final Method method, final String uri, final Version version) {
+ final CurrentContainer currentContainer = mock(CurrentContainer.class);
+ when(currentContainer.newReference(any(URI.class))).thenReturn(mock(Container.class));
+ return HttpRequest.newServerRequest(currentContainer, URI.create(uri), method, version);
+ }
+}
diff --git a/container-core/src/test/java/com/yahoo/jdisc/http/filter/JDiscCookieWrapperTest.java b/container-core/src/test/java/com/yahoo/jdisc/http/filter/JDiscCookieWrapperTest.java
new file mode 100644
index 00000000000..9948e5bfe7f
--- /dev/null
+++ b/container-core/src/test/java/com/yahoo/jdisc/http/filter/JDiscCookieWrapperTest.java
@@ -0,0 +1,29 @@
+// 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.filter;
+
+import com.yahoo.jdisc.http.Cookie;
+import org.junit.Assert;
+import org.junit.Test;
+
+import java.util.concurrent.TimeUnit;
+
+public class JDiscCookieWrapperTest {
+
+ @Test
+ public void requireThatWrapWorks() {
+ Cookie cookie = new Cookie("name", "value");
+ JDiscCookieWrapper wrapper = JDiscCookieWrapper.wrap(cookie);
+
+ wrapper.setDomain("yahoo.com");
+ wrapper.setMaxAge(10);
+ wrapper.setPath("/path");
+
+ Assert.assertEquals(wrapper.getName(), cookie.getName());
+ Assert.assertEquals(wrapper.getValue(), cookie.getValue());
+ Assert.assertEquals(wrapper.getDomain(), cookie.getDomain());
+ Assert.assertEquals(wrapper.getMaxAge(), cookie.getMaxAge(TimeUnit.SECONDS));
+ Assert.assertEquals(wrapper.getPath(), cookie.getPath());
+ Assert.assertEquals(wrapper.getSecure(), cookie.isSecure());
+
+ }
+}
diff --git a/container-core/src/test/java/com/yahoo/jdisc/http/filter/RequestViewImplTest.java b/container-core/src/test/java/com/yahoo/jdisc/http/filter/RequestViewImplTest.java
new file mode 100644
index 00000000000..ec0e0a33d35
--- /dev/null
+++ b/container-core/src/test/java/com/yahoo/jdisc/http/filter/RequestViewImplTest.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.filter;
+
+import com.google.common.collect.Lists;
+import com.yahoo.jdisc.HeaderFields;
+import com.yahoo.jdisc.Request;
+import com.yahoo.jdisc.http.filter.SecurityResponseFilterChain.RequestViewImpl;
+import org.junit.Test;
+
+import static org.junit.Assert.assertEquals;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * @author gjoranv
+ */
+public class RequestViewImplTest {
+
+ @Test
+ public void header_from_the_parent_request_is_available() throws Exception {
+ final String HEADER = "single-header";
+
+ HeaderFields parentHeaders = new HeaderFields();
+ parentHeaders.add(HEADER, "value");
+
+ RequestView requestView = newRequestView(parentHeaders);
+
+ assertEquals(requestView.getFirstHeader(HEADER).get(), "value");
+ assertEquals(requestView.getHeaders(HEADER).size(), 1);
+ assertEquals(requestView.getHeaders(HEADER).get(0), "value");
+ }
+
+
+ @Test
+ public void multi_value_header_from_the_parent_request_is_available() throws Exception {
+ final String HEADER = "list-header";
+
+ HeaderFields parentHeaders = new HeaderFields();
+ parentHeaders.add(HEADER, Lists.newArrayList("one", "two"));
+
+ RequestView requestView = newRequestView(parentHeaders);
+
+ assertEquals(requestView.getHeaders(HEADER).size(), 2);
+ assertEquals(requestView.getHeaders(HEADER).get(0), "one");
+ assertEquals(requestView.getHeaders(HEADER).get(1), "two");
+
+ assertEquals(requestView.getFirstHeader(HEADER).get(), "one");
+ }
+
+ private static RequestView newRequestView(HeaderFields parentHeaders) {
+ Request request = mock(Request.class);
+ when(request.headers()).thenReturn(parentHeaders);
+
+ return new RequestViewImpl(request);
+ }
+
+}
diff --git a/container-core/src/test/java/com/yahoo/jdisc/http/filter/ResponseHeaderFilter.java b/container-core/src/test/java/com/yahoo/jdisc/http/filter/ResponseHeaderFilter.java
new file mode 100644
index 00000000000..3855c3a494b
--- /dev/null
+++ b/container-core/src/test/java/com/yahoo/jdisc/http/filter/ResponseHeaderFilter.java
@@ -0,0 +1,25 @@
+// 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.filter;
+
+import com.yahoo.jdisc.AbstractResource;
+import com.yahoo.jdisc.Request;
+import com.yahoo.jdisc.Response;
+
+/**
+ * @author Simon Thoresen Hult
+ */
+public class ResponseHeaderFilter extends AbstractResource implements ResponseFilter {
+
+ private final String key;
+ private final String val;
+
+ public ResponseHeaderFilter(String key, String val) {
+ this.key = key;
+ this.val = val;
+ }
+
+ @Override
+ public void filter(Response response, Request request) {
+ response.headers().add(key, val);
+ }
+}
diff --git a/container-core/src/test/java/com/yahoo/jdisc/http/filter/SecurityRequestFilterChainTest.java b/container-core/src/test/java/com/yahoo/jdisc/http/filter/SecurityRequestFilterChainTest.java
new file mode 100644
index 00000000000..be19313dee2
--- /dev/null
+++ b/container-core/src/test/java/com/yahoo/jdisc/http/filter/SecurityRequestFilterChainTest.java
@@ -0,0 +1,145 @@
+// 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.filter;
+
+import com.yahoo.jdisc.AbstractResource;
+import com.yahoo.jdisc.Response;
+import com.yahoo.jdisc.handler.CompletionHandler;
+import com.yahoo.jdisc.handler.ContentChannel;
+import com.yahoo.jdisc.handler.ResponseDispatch;
+import com.yahoo.jdisc.handler.ResponseHandler;
+import com.yahoo.jdisc.http.HttpRequest;
+import com.yahoo.jdisc.test.TestDriver;
+import org.junit.Assert;
+import org.junit.Test;
+
+import java.net.InetSocketAddress;
+import java.net.URI;
+import java.nio.ByteBuffer;
+import java.util.ArrayList;
+import java.util.List;
+
+import static org.junit.Assert.assertEquals;
+
+/**
+ * @author bjorncs
+ */
+public class SecurityRequestFilterChainTest {
+
+
+ private static HttpRequest newRequest(URI uri, HttpRequest.Method method, HttpRequest.Version version) {
+ InetSocketAddress address = new InetSocketAddress("java.corp.yahoo.com", 69);
+ TestDriver driver = TestDriver.newSimpleApplicationInstanceWithoutOsgi();
+ driver.activateContainer(driver.newContainerBuilder());
+ HttpRequest request = HttpRequest.newServerRequest(driver, uri, method, version, address);
+ request.release();
+ Assert.assertTrue(driver.close());
+ return request;
+ }
+
+ @Test
+ public void testFilterChainConstruction() {
+ SecurityRequestFilterChain chain = (SecurityRequestFilterChain)SecurityRequestFilterChain.newInstance();
+ assertEquals(chain.getFilters().size(),0);
+
+ List<SecurityRequestFilter> requestFilters = new ArrayList<SecurityRequestFilter>();
+ chain = (SecurityRequestFilterChain)SecurityRequestFilterChain.newInstance();
+
+ chain = (SecurityRequestFilterChain)SecurityRequestFilterChain.newInstance(new RequestHeaderFilter("abc", "xyz"),
+ new RequestHeaderFilter("pqr", "def"));
+
+ assertEquals(chain instanceof SecurityRequestFilterChain, true);
+ }
+
+
+ @Test
+ public void testFilterChainRun() {
+ RequestFilter chain = SecurityRequestFilterChain.newInstance(new RequestHeaderFilter("abc", "xyz"),
+ new RequestHeaderFilter("pqr", "def"));
+
+ assertEquals(chain instanceof SecurityRequestFilterChain, true);
+ ResponseHandler handler = newResponseHandler();
+ HttpRequest request = newRequest(URI.create("http://test/test"), HttpRequest.Method.GET, HttpRequest.Version.HTTP_1_1);
+ chain.filter(request, handler);
+ Assert.assertTrue(request.headers().contains("abc", "xyz"));
+ Assert.assertTrue(request.headers().contains("pqr", "def"));
+ }
+
+ @Test
+ public void testFilterChainResponds() {
+ RequestFilter chain = SecurityRequestFilterChain.newInstance(
+ new MyFilter(),
+ new RequestHeaderFilter("abc", "xyz"),
+ new RequestHeaderFilter("pqr", "def"));
+
+ assertEquals(chain instanceof SecurityRequestFilterChain, true);
+ ResponseHandler handler = newResponseHandler();
+ HttpRequest request = newRequest(URI.create("http://test/test"), HttpRequest.Method.GET, HttpRequest.Version.HTTP_1_1);
+ chain.filter(request, handler);
+ Response response = getResponse(handler);
+ Assert.assertNotNull(response);
+ Assert.assertTrue(!request.headers().contains("abc", "xyz"));
+ Assert.assertTrue(!request.headers().contains("pqr", "def"));
+ }
+
+ private class RequestHeaderFilter extends AbstractResource implements SecurityRequestFilter {
+
+ private final String key;
+ private final String val;
+
+ public RequestHeaderFilter(String key, String val) {
+ this.key = key;
+ this.val = val;
+ }
+
+ @Override
+ public void filter(DiscFilterRequest request, ResponseHandler handler) {
+ request.setHeaders(key, val);
+ }
+ }
+
+ private class MyFilter extends AbstractResource implements SecurityRequestFilter {
+
+ @Override
+ public void filter(DiscFilterRequest request, ResponseHandler handler) {
+ ResponseDispatch.newInstance(Response.Status.FORBIDDEN).dispatch(handler);
+ }
+ }
+
+ private static ResponseHandler newResponseHandler() {
+ return new NonWorkingResponseHandler();
+ }
+
+ private static Response getResponse(ResponseHandler handler) {
+ return ((NonWorkingResponseHandler) handler).getResponse();
+ }
+
+ private static class NonWorkingResponseHandler implements ResponseHandler {
+
+ private Response response = null;
+
+ @Override
+ public ContentChannel handleResponse(Response response) {
+ this.response = response;
+ return new NonWorkingContentChannel();
+ }
+
+ public Response getResponse() {
+ return response;
+ }
+ }
+
+ private static class NonWorkingContentChannel implements ContentChannel {
+
+ @Override
+ public void close(CompletionHandler handler) {
+
+ }
+
+ @Override
+ public void write(ByteBuffer buf, CompletionHandler handler) {
+
+ }
+
+ }
+
+}
diff --git a/container-core/src/test/java/com/yahoo/jdisc/http/filter/SecurityResponseFilterChainTest.java b/container-core/src/test/java/com/yahoo/jdisc/http/filter/SecurityResponseFilterChainTest.java
new file mode 100644
index 00000000000..25291de5cc1
--- /dev/null
+++ b/container-core/src/test/java/com/yahoo/jdisc/http/filter/SecurityResponseFilterChainTest.java
@@ -0,0 +1,74 @@
+// 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.filter;
+
+import com.yahoo.jdisc.AbstractResource;
+import com.yahoo.jdisc.Response;
+import com.yahoo.jdisc.http.HttpRequest;
+import com.yahoo.jdisc.http.HttpResponse;
+import com.yahoo.jdisc.test.TestDriver;
+import org.junit.Test;
+
+import java.net.InetSocketAddress;
+import java.net.URI;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
+/**
+ * @author bjorncs
+ */
+public class SecurityResponseFilterChainTest {
+ private static HttpRequest newRequest(URI uri, HttpRequest.Method method, HttpRequest.Version version) {
+ InetSocketAddress address = new InetSocketAddress("java.corp.yahoo.com", 69);
+ TestDriver driver = TestDriver.newSimpleApplicationInstanceWithoutOsgi();
+ driver.activateContainer(driver.newContainerBuilder());
+ HttpRequest request = HttpRequest.newServerRequest(driver, uri, method, version, address);
+ request.release();
+ assertTrue(driver.close());
+ return request;
+ }
+
+ @Test
+ public void testFilterChainConstruction() {
+ SecurityResponseFilterChain chain = (SecurityResponseFilterChain)SecurityResponseFilterChain.newInstance();
+ assertEquals(chain.getFilters().size(),0);
+
+ chain = (SecurityResponseFilterChain)SecurityResponseFilterChain.newInstance(new ResponseHeaderFilter("abc", "xyz"),
+ new ResponseHeaderFilter("pqr", "def"));
+
+ assertEquals(chain instanceof SecurityResponseFilterChain, true);
+ }
+
+ @Test
+ public void testFilterChainRun() {
+ URI uri = URI.create("http://localhost:8080/echo");
+ HttpRequest request = newRequest(uri, HttpRequest.Method.GET, HttpRequest.Version.HTTP_1_1);
+ Response response = HttpResponse.newInstance(Response.Status.OK);
+
+ ResponseFilter chain = SecurityResponseFilterChain.newInstance(new ResponseHeaderFilter("abc", "xyz"),
+ new ResponseHeaderFilter("pqr", "def"));
+ chain.filter(response, null);
+ assertTrue(response.headers().contains("abc", "xyz"));
+ assertTrue(response.headers().contains("pqr", "def"));
+ }
+
+ private class ResponseHeaderFilter extends AbstractResource implements SecurityResponseFilter {
+
+ private final String key;
+ private final String val;
+
+ public ResponseHeaderFilter(String key, String val) {
+ this.key = key;
+ this.val = val;
+ }
+
+ @Override
+ public void filter(DiscFilterResponse response, RequestView request) {
+ response.setHeaders(key, val);
+ }
+
+ }
+
+
+
+}
diff --git a/container-core/src/test/java/com/yahoo/jdisc/http/filter/ServletFilterRequestTest.java b/container-core/src/test/java/com/yahoo/jdisc/http/filter/ServletFilterRequestTest.java
new file mode 100644
index 00000000000..3052902f174
--- /dev/null
+++ b/container-core/src/test/java/com/yahoo/jdisc/http/filter/ServletFilterRequestTest.java
@@ -0,0 +1,179 @@
+// 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.filter;
+
+import com.yahoo.jdisc.http.Cookie;
+import com.yahoo.jdisc.http.HttpHeaders;
+import com.yahoo.jdisc.http.servlet.ServletRequest;
+import org.eclipse.jetty.server.HttpConnection;
+import org.junit.Before;
+import org.junit.Test;
+import org.mockito.Mockito;
+import org.springframework.mock.web.MockHttpServletRequest;
+
+import java.net.URI;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+import static com.yahoo.jdisc.http.HttpRequest.Version;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.Mockito.when;
+
+/**
+ * Test the parts of the DiscFilterRequest API that are implemented
+ * by ServletFilterRequest, both directly and indirectly via
+ * {@link com.yahoo.jdisc.http.servlet.ServletRequest}.
+ *
+ * @author gjoranv
+ * @since 5.27
+ */
+public class ServletFilterRequestTest {
+
+ private final String host = "host1";
+ private final int port = 8080;
+ private final String path = "/path1";
+ private final String paramName = "param1";
+ private final String paramValue = "p1";
+ private final String listParamName = "listParam";
+ private final String[] listParamValue = new String[]{"1", "2"};
+ private final String headerName = "header1";
+ private final String headerValue = "h1";
+ private final String attributeName = "attribute1";
+ private final String attributeValue = "a1";
+
+ private URI uri;
+ private DiscFilterRequest filterRequest;
+ private ServletRequest parentRequest;
+
+ @Before
+ public void init() throws Exception {
+ uri = new URI("http", null, host, port, path, paramName + "=" + paramValue, null);
+
+ filterRequest = new ServletFilterRequest(newServletRequest());
+ parentRequest = ((ServletFilterRequest)filterRequest).getServletRequest();
+ }
+
+ private ServletRequest newServletRequest() throws Exception {
+ MockHttpServletRequest parent = new MockHttpServletRequest("GET", uri.toString());
+ parent.setProtocol(Version.HTTP_1_1.toString());
+ parent.setRemoteHost(host);
+ parent.setRemotePort(port);
+ parent.setParameter(paramName, paramValue);
+ parent.setParameter(listParamName, listParamValue);
+ parent.addHeader(headerName, headerValue);
+ parent.setAttribute(attributeName, attributeValue);
+ HttpConnection connection = Mockito.mock(HttpConnection.class);
+ when(connection.getCreatedTimeStamp()).thenReturn(System.currentTimeMillis());
+ parent.setAttribute("org.eclipse.jetty.server.HttpConnection", connection);
+ return new ServletRequest(parent, uri);
+ }
+
+ @Test
+ public void parent_properties_are_propagated_to_disc_filter_request() throws Exception {
+ assertEquals(filterRequest.getVersion(), Version.HTTP_1_1);
+ assertEquals(filterRequest.getMethod(), "GET");
+ assertEquals(filterRequest.getUri(), uri);
+ assertEquals(filterRequest.getRemoteHost(), host);
+ assertEquals(filterRequest.getRemotePort(), port);
+ assertEquals(filterRequest.getRequestURI(), path); // getRequestUri return only the path by design
+
+ assertEquals(filterRequest.getParameter(paramName), paramValue);
+ assertEquals(filterRequest.getParameterMap().get(paramName),
+ Collections.singletonList(paramValue));
+ assertEquals(filterRequest.getParameterValuesAsList(listParamName), Arrays.asList(listParamValue));
+
+ assertEquals(filterRequest.getHeader(headerName), headerValue);
+ assertEquals(filterRequest.getAttribute(attributeName), attributeValue);
+ }
+
+ @Test
+ public void untreatedHeaders_is_populated_from_the_parent_request() {
+ assertEquals(filterRequest.getUntreatedHeaders().getFirst(headerName), headerValue);
+ }
+
+ @Test
+ public void uri_can_be_set() throws Exception {
+ URI newUri = new URI("http", null, host, port + 1, path, paramName + "=" + paramValue, null);
+ filterRequest.setUri(newUri);
+
+ assertEquals(filterRequest.getUri(), newUri);
+ assertEquals(parentRequest.getUri(), newUri);
+ }
+
+ @Test
+ public void attributes_can_be_set() throws Exception {
+ String name = "newAttribute";
+ String value = name + "Value";
+ filterRequest.setAttribute(name, value);
+
+ assertEquals(filterRequest.getAttribute(name), value);
+ assertEquals(parentRequest.getAttribute(name), value);
+ }
+
+ @Test
+ public void attributes_can_be_removed() {
+ filterRequest.removeAttribute(attributeName);
+
+ assertEquals(filterRequest.getAttribute(attributeName), null);
+ assertEquals(parentRequest.getAttribute(attributeName), null);
+ }
+
+ @Test
+ public void headers_can_be_set() throws Exception {
+ String name = "myHeader";
+ String value = name + "Value";
+ filterRequest.setHeaders(name, value);
+
+ assertEquals(filterRequest.getHeader(name), value);
+ assertEquals(parentRequest.getHeader(name), value);
+ }
+
+ @Test
+ public void headers_can_be_removed() throws Exception {
+ filterRequest.removeHeaders(headerName);
+
+ assertEquals(filterRequest.getHeader(headerName), null);
+ assertEquals(parentRequest.getHeader(headerName), null);
+ }
+
+ @Test
+ public void headers_can_be_added() {
+ String value = "h2";
+ filterRequest.addHeader(headerName, value);
+
+ List<String> expected = Arrays.asList(headerValue, value);
+ assertEquals(filterRequest.getHeadersAsList(headerName), expected);
+ assertEquals(Collections.list(parentRequest.getHeaders(headerName)), expected);
+ }
+
+ @Test
+ public void cookies_can_be_added_and_removed() {
+ Cookie cookie = new Cookie("name", "value");
+ filterRequest.addCookie(JDiscCookieWrapper.wrap(cookie));
+
+ assertEquals(filterRequest.getCookies(), Collections.singletonList(cookie));
+ assertEquals(parentRequest.getCookies().length, 1);
+
+ javax.servlet.http.Cookie servletCookie = parentRequest.getCookies()[0];
+ assertEquals(servletCookie.getName(), cookie.getName());
+ assertEquals(servletCookie.getValue(), cookie.getValue());
+
+ filterRequest.clearCookies();
+ assertTrue(filterRequest.getCookies().isEmpty());
+ assertEquals(parentRequest.getCookies().length, 0);
+ }
+
+ @Test
+ public void character_encoding_can_be_set() throws Exception {
+ // ContentType must be non-null before setting character encoding
+ filterRequest.setHeaders(HttpHeaders.Names.CONTENT_TYPE, "");
+
+ String encoding = "myEncoding";
+ filterRequest.setCharacterEncoding(encoding);
+
+ assertTrue(filterRequest.getCharacterEncoding().contains(encoding));
+ assertTrue(parentRequest.getCharacterEncoding().contains(encoding));
+ }
+
+}
diff --git a/container-core/src/test/java/com/yahoo/jdisc/http/filter/ServletFilterResponseTest.java b/container-core/src/test/java/com/yahoo/jdisc/http/filter/ServletFilterResponseTest.java
new file mode 100644
index 00000000000..a2bc2badea3
--- /dev/null
+++ b/container-core/src/test/java/com/yahoo/jdisc/http/filter/ServletFilterResponseTest.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.filter;
+
+import com.yahoo.jdisc.http.Cookie;
+import com.yahoo.jdisc.http.HttpHeaders;
+import com.yahoo.jdisc.http.servlet.ServletResponse;
+import org.junit.Before;
+import org.junit.Test;
+
+import java.util.Arrays;
+
+import static org.junit.Assert.assertEquals;
+
+/**
+ * @author gjoranv
+ * @since 5.27
+ */
+public class ServletFilterResponseTest {
+
+ private final String headerName = "header1";
+ private final String headerValue = "h1";
+
+ private DiscFilterResponse filterResponse;
+ private ServletResponse parentResponse;
+
+ @Before
+ public void init() throws Exception {
+ filterResponse = new ServletFilterResponse(newServletResponse());
+ parentResponse = ((ServletFilterResponse)filterResponse).getServletResponse();
+
+ }
+
+ private ServletResponse newServletResponse() throws Exception {
+ MockServletResponse parent = new MockServletResponse();
+ parent.addHeader(headerName, headerValue);
+ return new ServletResponse(parent);
+ }
+
+
+ @Test
+ public void headers_can_be_set() throws Exception {
+ String name = "myHeader";
+ String value = name + "Value";
+ filterResponse.setHeaders(name, value);
+
+ assertEquals(filterResponse.getHeader(name), value);
+ assertEquals(parentResponse.getHeader(name), value);
+ }
+
+ @Test
+ public void headers_can_be_added() throws Exception {
+ String newValue = "h2";
+ filterResponse.addHeader(headerName, newValue);
+
+ // The DiscFilterResponse has no getHeaders()
+ assertEquals(filterResponse.getHeader(headerName), newValue);
+
+ assertEquals(parentResponse.getHeaders(headerName), Arrays.asList(headerValue, newValue));
+ }
+
+ @Test
+ public void headers_can_be_removed() throws Exception {
+ filterResponse.removeHeaders(headerName);
+
+ assertEquals(filterResponse.getHeader(headerName), null);
+ assertEquals(parentResponse.getHeader(headerName), null);
+ }
+
+ @Test
+ public void set_cookie_overwrites_old_values() {
+ Cookie to_be_removed = new Cookie("to-be-removed", "");
+ Cookie to_keep = new Cookie("to-keep", "");
+ filterResponse.setCookie(to_be_removed.getName(), to_be_removed.getValue());
+ filterResponse.setCookie(to_keep.getName(), to_keep.getValue());
+
+ assertEquals(filterResponse.getCookies(), Arrays.asList(to_keep));
+ assertEquals(parentResponse.getHeaders(HttpHeaders.Names.SET_COOKIE), Arrays.asList(to_keep.toString()));
+ }
+
+
+ private static class MockServletResponse extends org.eclipse.jetty.server.Response {
+ private MockServletResponse() {
+ super(null, null);
+ }
+ }
+
+}
diff --git a/container-core/src/test/java/com/yahoo/jdisc/http/guiceModules/ConnectorFactoryRegistryModule.java b/container-core/src/test/java/com/yahoo/jdisc/http/guiceModules/ConnectorFactoryRegistryModule.java
new file mode 100644
index 00000000000..cc2a00c08c6
--- /dev/null
+++ b/container-core/src/test/java/com/yahoo/jdisc/http/guiceModules/ConnectorFactoryRegistryModule.java
@@ -0,0 +1,54 @@
+// 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.guiceModules;
+
+import com.google.inject.Binder;
+import com.google.inject.Module;
+import com.google.inject.Provides;
+import com.yahoo.component.ComponentId;
+import com.yahoo.component.provider.ComponentRegistry;
+import com.yahoo.jdisc.http.ConnectorConfig;
+import com.yahoo.jdisc.http.ConnectorConfig.Builder;
+
+import com.yahoo.jdisc.http.server.jetty.ConnectorFactory;
+import com.yahoo.jdisc.http.ssl.impl.ConfiguredSslContextFactoryProvider;
+
+/**
+ * Guice module for test ConnectorFactories
+ *
+ * @author Tony Vaagenes
+ */
+public class ConnectorFactoryRegistryModule implements Module {
+
+ private final Builder connectorConfigBuilder;
+
+ public ConnectorFactoryRegistryModule(Builder connectorConfigBuilder) {
+ this.connectorConfigBuilder = connectorConfigBuilder;
+ }
+
+ public ConnectorFactoryRegistryModule() {
+ this(new Builder());
+ }
+
+ @Provides
+ public ComponentRegistry<ConnectorFactory> connectorFactoryComponentRegistry() {
+ ComponentRegistry<ConnectorFactory> registry = new ComponentRegistry<>();
+ registry.register(ComponentId.createAnonymousComponentId("connector-factory"),
+ new StaticKeyDbConnectorFactory(new ConnectorConfig(connectorConfigBuilder)));
+
+ registry.freeze();
+ return registry;
+ }
+
+ @Override
+ public void configure(Binder binder) {
+ }
+
+ private static class StaticKeyDbConnectorFactory extends ConnectorFactory {
+
+ public StaticKeyDbConnectorFactory(ConnectorConfig connectorConfig) {
+ super(connectorConfig, new ConfiguredSslContextFactoryProvider(connectorConfig));
+ }
+
+ }
+
+}
diff --git a/container-core/src/test/java/com/yahoo/jdisc/http/guiceModules/ServletModule.java b/container-core/src/test/java/com/yahoo/jdisc/http/guiceModules/ServletModule.java
new file mode 100644
index 00000000000..dd6511d1f88
--- /dev/null
+++ b/container-core/src/test/java/com/yahoo/jdisc/http/guiceModules/ServletModule.java
@@ -0,0 +1,24 @@
+// 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.guiceModules;
+
+import com.google.inject.Binder;
+import com.google.inject.Module;
+import com.google.inject.Provides;
+import com.yahoo.component.provider.ComponentRegistry;
+
+import org.eclipse.jetty.servlet.ServletHolder;
+
+/**
+ * @author Tony Vaagenes
+ */
+public class ServletModule implements Module {
+ @Override
+ public void configure(Binder binder) {
+ }
+
+ @Provides
+ public ComponentRegistry<ServletHolder> servletHolderComponentRegistry() {
+ return new ComponentRegistry<>();
+ }
+
+}
diff --git a/container-core/src/test/java/com/yahoo/jdisc/http/server/jetty/AccessLogRequestLogTest.java b/container-core/src/test/java/com/yahoo/jdisc/http/server/jetty/AccessLogRequestLogTest.java
new file mode 100644
index 00000000000..6370912af48
--- /dev/null
+++ b/container-core/src/test/java/com/yahoo/jdisc/http/server/jetty/AccessLogRequestLogTest.java
@@ -0,0 +1,156 @@
+// 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.container.logging.RequestLog;
+import com.yahoo.container.logging.RequestLogEntry;
+import com.yahoo.jdisc.http.ConnectorConfig;
+import com.yahoo.jdisc.http.ServerConfig;
+import org.eclipse.jetty.http.MetaData;
+import org.eclipse.jetty.server.HttpChannel;
+import org.eclipse.jetty.server.HttpConnection;
+import org.eclipse.jetty.server.Request;
+import org.eclipse.jetty.server.Response;
+import org.junit.Test;
+
+import java.util.List;
+import java.util.Optional;
+
+import static org.hamcrest.CoreMatchers.is;
+import static org.hamcrest.CoreMatchers.not;
+import static org.hamcrest.CoreMatchers.nullValue;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * @author Oyvind Bakksjo
+ * @author bjorncs
+ */
+public class AccessLogRequestLogTest {
+ @Test
+ public void requireThatQueryWithUnquotedSpecialCharactersIsHandled() {
+ final Request jettyRequest = createRequestMock();
+ when(jettyRequest.getRequestURI()).thenReturn("/search/");
+ when(jettyRequest.getQueryString()).thenReturn("query=year:>2010");
+
+ InMemoryRequestLog requestLog = new InMemoryRequestLog();
+ doAccessLoggingOfRequest(requestLog, jettyRequest);
+ RequestLogEntry entry = requestLog.entries().get(0);
+
+ assertThat(entry.rawPath().get(), is(not(nullValue())));
+ assertTrue(entry.rawQuery().isPresent());
+ }
+
+ @Test
+ public void requireThatDoubleQuotingIsNotPerformed() {
+ final Request jettyRequest = createRequestMock();
+ final String path = "/search/";
+ when(jettyRequest.getRequestURI()).thenReturn(path);
+ final String query = "query=year%252010+%3B&customParameter=something";
+ when(jettyRequest.getQueryString()).thenReturn(query);
+
+ InMemoryRequestLog requestLog = new InMemoryRequestLog();
+ doAccessLoggingOfRequest(requestLog, jettyRequest);
+ RequestLogEntry entry = requestLog.entries().get(0);
+
+ assertThat(entry.rawPath().get(), is(path));
+ assertThat(entry.rawQuery().get(), is(query));
+
+ }
+
+ @Test
+ public void raw_path_and_query_are_set_from_request() {
+ Request jettyRequest = createRequestMock();
+ String rawPath = "//search/";
+ when(jettyRequest.getRequestURI()).thenReturn(rawPath);
+ String rawQuery = "q=%%2";
+ when(jettyRequest.getQueryString()).thenReturn(rawQuery);
+
+ InMemoryRequestLog requestLog = new InMemoryRequestLog();
+ doAccessLoggingOfRequest(requestLog, jettyRequest);
+ RequestLogEntry entry = requestLog.entries().get(0);
+ assertThat(entry.rawPath().get(), is(rawPath));
+ Optional<String> actualRawQuery = entry.rawQuery();
+ assertThat(actualRawQuery.isPresent(), is(true));
+ assertThat(actualRawQuery.get(), is(rawQuery));
+ }
+
+ @Test
+ public void verify_x_forwarded_for_precedence () {
+ Request jettyRequest = createRequestMock();
+ when(jettyRequest.getRequestURI()).thenReturn("//search/");
+ when(jettyRequest.getQueryString()).thenReturn("q=%%2");
+ when(jettyRequest.getHeader("x-forwarded-for")).thenReturn("1.2.3.4");
+ when(jettyRequest.getHeader("y-ra")).thenReturn("2.3.4.5");
+
+ InMemoryRequestLog requestLog = new InMemoryRequestLog();
+ doAccessLoggingOfRequest(requestLog, jettyRequest);
+ RequestLogEntry entry = requestLog.entries().get(0);
+ assertThat(entry.remoteAddress().get(), is("1.2.3.4"));
+ }
+
+ @Test
+ public void verify_x_forwarded_port_precedence () {
+ Request jettyRequest = createRequestMock();
+ when(jettyRequest.getRequestURI()).thenReturn("//search/");
+ when(jettyRequest.getQueryString()).thenReturn("q=%%2");
+ when(jettyRequest.getHeader("X-Forwarded-Port")).thenReturn("80");
+ when(jettyRequest.getHeader("y-rp")).thenReturn("8080");
+
+ InMemoryRequestLog requestLog = new InMemoryRequestLog();
+ doAccessLoggingOfRequest(requestLog, jettyRequest);
+ RequestLogEntry entry = requestLog.entries().get(0);
+ assertThat(entry.remotePort().getAsInt(), is(80));
+ }
+
+ @Test
+ public void defaults_to_peer_port_if_remote_port_header_is_invalid() {
+ final Request jettyRequest = createRequestMock();
+ when(jettyRequest.getRequestURI()).thenReturn("/search/");
+ when(jettyRequest.getHeader("X-Forwarded-Port")).thenReturn("8o8o");
+ when(jettyRequest.getRemotePort()).thenReturn(80);
+
+ InMemoryRequestLog requestLog = new InMemoryRequestLog();
+ doAccessLoggingOfRequest(requestLog, jettyRequest);
+ RequestLogEntry entry = requestLog.entries().get(0);
+ assertFalse(entry.remotePort().isPresent());
+ assertThat(entry.peerPort().getAsInt(), is(80));
+ }
+
+ private void doAccessLoggingOfRequest(RequestLog requestLog, Request jettyRequest) {
+ ServerConfig.AccessLog config = new ServerConfig.AccessLog(
+ new ServerConfig.AccessLog.Builder()
+ .remoteAddressHeaders(List.of("x-forwarded-for", "y-ra"))
+ .remotePortHeaders(List.of("X-Forwarded-Port", "y-rp")));
+ new AccessLogRequestLog(requestLog, config).log(jettyRequest, createResponseMock());
+ }
+
+ private static Request createRequestMock() {
+ JDiscServerConnector serverConnector = mock(JDiscServerConnector.class);
+ int localPort = 1234;
+ when(serverConnector.connectorConfig()).thenReturn(new ConnectorConfig(new ConnectorConfig.Builder().listenPort(localPort)));
+ when(serverConnector.getLocalPort()).thenReturn(localPort);
+ HttpConnection httpConnection = mock(HttpConnection.class);
+ when(httpConnection.getConnector()).thenReturn(serverConnector);
+ Request request = mock(Request.class);
+ when(request.getMethod()).thenReturn("GET");
+ when(request.getRemoteAddr()).thenReturn("localhost");
+ when(request.getRemotePort()).thenReturn(12345);
+ when(request.getProtocol()).thenReturn("HTTP/1.1");
+ when(request.getScheme()).thenReturn("http");
+ when(request.getTimeStamp()).thenReturn(0L);
+ when(request.getAttribute(JDiscHttpServlet.ATTRIBUTE_NAME_ACCESS_LOG_ENTRY)).thenReturn(new AccessLogEntry());
+ when(request.getAttribute("org.eclipse.jetty.server.HttpConnection")).thenReturn(httpConnection);
+ return request;
+ }
+
+ private Response createResponseMock() {
+ Response response = mock(Response.class);
+ when(response.getHttpChannel()).thenReturn(mock(HttpChannel.class));
+ when(response.getCommittedMetaData()).thenReturn(mock(MetaData.Response.class));
+ return response;
+ }
+}
diff --git a/container-core/src/test/java/com/yahoo/jdisc/http/server/jetty/BlockingQueueRequestLog.java b/container-core/src/test/java/com/yahoo/jdisc/http/server/jetty/BlockingQueueRequestLog.java
new file mode 100644
index 00000000000..c1a2bea8ac4
--- /dev/null
+++ b/container-core/src/test/java/com/yahoo/jdisc/http/server/jetty/BlockingQueueRequestLog.java
@@ -0,0 +1,24 @@
+// 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;
+
+import java.time.Duration;
+import java.util.concurrent.BlockingQueue;
+import java.util.concurrent.LinkedBlockingDeque;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * @author bjorncs
+ */
+class BlockingQueueRequestLog implements RequestLog {
+
+ private final BlockingQueue<RequestLogEntry> entries = new LinkedBlockingDeque<>();
+
+ @Override public void log(RequestLogEntry entry) { entries.offer(entry); }
+
+ RequestLogEntry poll(Duration timeout) throws InterruptedException {
+ return entries.poll(timeout.toMillis(), TimeUnit.MILLISECONDS);
+ }
+}
diff --git a/container-core/src/test/java/com/yahoo/jdisc/http/server/jetty/ConnectionThrottlerTest.java b/container-core/src/test/java/com/yahoo/jdisc/http/server/jetty/ConnectionThrottlerTest.java
new file mode 100644
index 00000000000..65eb7e1c145
--- /dev/null
+++ b/container-core/src/test/java/com/yahoo/jdisc/http/server/jetty/ConnectionThrottlerTest.java
@@ -0,0 +1,78 @@
+// 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.server.AbstractConnector;
+import org.eclipse.jetty.util.component.AbstractLifeCycle;
+import org.eclipse.jetty.util.statistic.RateStatistic;
+import org.eclipse.jetty.util.thread.Scheduler;
+import org.junit.Test;
+
+import java.util.concurrent.TimeUnit;
+
+import static org.junit.Assert.assertNotNull;
+import static org.mockito.ArgumentMatchers.anyBoolean;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+import static org.mockito.internal.verification.VerificationModeFactory.times;
+
+/**
+ * @author bjorncs
+ */
+public class ConnectionThrottlerTest {
+
+ @Test
+ public void throttles_when_any_resource_check_exceeds_configured_threshold() {
+ Runtime runtime = mock(Runtime.class);
+ when(runtime.maxMemory()).thenReturn(100l);
+ RateStatistic rateStatistic = new RateStatistic(1, TimeUnit.HOURS);
+ MockScheduler scheduler = new MockScheduler();
+ ConnectorConfig.Throttling config = new ConnectorConfig.Throttling(new ConnectorConfig.Throttling.Builder()
+ .maxHeapUtilization(0.8)
+ .maxAcceptRate(1));
+
+ AbstractConnector connector = mock(AbstractConnector.class);
+
+ ConnectionThrottler throttler = new ConnectionThrottler(runtime, rateStatistic, scheduler, connector, config);
+
+ // Heap utilization above configured threshold, but connection rate below threshold.
+ when(runtime.freeMemory()).thenReturn(10l);
+ when(connector.isAccepting()).thenReturn(true);
+ throttler.onAccepting(null);
+ assertNotNull(scheduler.task);
+ verify(connector).setAccepting(false);
+
+ // Heap utilization below threshold, but connection rate above threshold.
+ when(runtime.freeMemory()).thenReturn(80l);
+ rateStatistic.record();
+ rateStatistic.record(); // above accept rate limit (2 > 1)
+ scheduler.task.run(); // run unthrottleIfBelowThresholds()
+ verify(connector, times(1)).setAccepting(anyBoolean()); // verify setAccepting has not been called any mores times
+
+ // Both heap utilization and accept rate below threshold
+ when(runtime.freeMemory()).thenReturn(80l);
+ when(connector.isAccepting()).thenReturn(false);
+ rateStatistic.reset();
+ scheduler.task.run(); // run unthrottleIfBelowThresholds()
+ verify(connector).setAccepting(true);
+
+ // Both heap utilization and accept rate below threshold
+ when(connector.isAccepting()).thenReturn(true);
+ when(runtime.freeMemory()).thenReturn(80l);
+ rateStatistic.record();
+ throttler.onAccepting(null);
+ verify(connector, times(2)).setAccepting(anyBoolean()); // verify setAccepting has not been called any mores times
+ }
+
+ private static class MockScheduler extends AbstractLifeCycle implements Scheduler {
+ Runnable task;
+
+ @Override
+ public Task schedule(Runnable task, long delay, TimeUnit units) {
+ this.task = task;
+ return () -> false;
+ }
+ }
+
+} \ No newline at end of file
diff --git a/container-core/src/test/java/com/yahoo/jdisc/http/server/jetty/ConnectorFactoryTest.java b/container-core/src/test/java/com/yahoo/jdisc/http/server/jetty/ConnectorFactoryTest.java
new file mode 100644
index 00000000000..df794c7ecb8
--- /dev/null
+++ b/container-core/src/test/java/com/yahoo/jdisc/http/server/jetty/ConnectorFactoryTest.java
@@ -0,0 +1,83 @@
+// 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 com.yahoo.jdisc.http.ServerConfig;
+import com.yahoo.jdisc.http.ssl.impl.ConfiguredSslContextFactoryProvider;
+import org.eclipse.jetty.server.Request;
+import org.eclipse.jetty.server.Server;
+import org.eclipse.jetty.server.handler.AbstractHandler;
+import org.junit.Test;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.io.IOException;
+import java.util.Map;
+
+import static org.hamcrest.CoreMatchers.equalTo;
+
+/**
+ * @author Einar M R Rosenvinge
+ */
+public class ConnectorFactoryTest {
+
+ @Test
+ public void requireThatServerCanBindChannel() throws Exception {
+ Server server = new Server();
+ try {
+ ConnectorConfig config = new ConnectorConfig(new ConnectorConfig.Builder());
+ ConnectorFactory factory = createConnectorFactory(config);
+ JettyConnectionLogger connectionLogger = new JettyConnectionLogger(
+ new ServerConfig.ConnectionLog.Builder().enabled(false).build(),
+ new VoidConnectionLog());
+ JDiscServerConnector connector =
+ (JDiscServerConnector)factory.createConnector(new DummyMetric(), server, connectionLogger);
+ server.addConnector(connector);
+ server.setHandler(new HelloWorldHandler());
+ server.start();
+
+ SimpleHttpClient client = new SimpleHttpClient(null, connector.getLocalPort(), false);
+ SimpleHttpClient.RequestExecutor ex = client.newGet("/blaasdfnb");
+ SimpleHttpClient.ResponseValidator val = ex.execute();
+ val.expectContent(equalTo("Hello world"));
+ } finally {
+ try {
+ server.stop();
+ } catch (Exception e) {
+ //ignore
+ }
+ }
+ }
+
+ private static ConnectorFactory createConnectorFactory(ConnectorConfig config) {
+ return new ConnectorFactory(config, new ConfiguredSslContextFactoryProvider(config));
+ }
+
+ private static class HelloWorldHandler extends AbstractHandler {
+ @Override
+ public void handle(String target, Request baseRequest, HttpServletRequest request, HttpServletResponse response) throws IOException {
+ response.getWriter().write("Hello world");
+ response.getWriter().flush();
+ response.getWriter().close();
+ baseRequest.setHandled(true);
+ }
+ }
+
+ private static class DummyMetric implements Metric {
+ @Override
+ public void set(String key, Number val, Context ctx) { }
+
+ @Override
+ public void add(String key, Number val, Context ctx) { }
+
+ @Override
+ public Context createContext(Map<String, ?> properties) {
+ return new DummyContext();
+ }
+ }
+
+ private static class DummyContext implements Metric.Context {
+ }
+
+}
diff --git a/container-core/src/test/java/com/yahoo/jdisc/http/server/jetty/ErrorResponseContentCreatorTest.java b/container-core/src/test/java/com/yahoo/jdisc/http/server/jetty/ErrorResponseContentCreatorTest.java
new file mode 100644
index 00000000000..d66f22801f7
--- /dev/null
+++ b/container-core/src/test/java/com/yahoo/jdisc/http/server/jetty/ErrorResponseContentCreatorTest.java
@@ -0,0 +1,44 @@
+// 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.junit.Test;
+
+import javax.servlet.http.HttpServletResponse;
+import java.nio.charset.StandardCharsets;
+import java.util.Optional;
+
+import static org.junit.Assert.assertEquals;
+
+
+/**
+ * @author bjorncs
+ */
+public class ErrorResponseContentCreatorTest {
+
+ @Test
+ public void response_content_matches_expected_string() {
+ String expectedHtml =
+ "<html>\n" +
+ "<head>\n" +
+ "<meta http-equiv=\"Content-Type\" content=\"text/html;charset=ISO-8859-1\"/>\n" +
+ "<title>Error 200</title>\n" +
+ "</head>\n" +
+ "<body>\n" +
+ "<h2>HTTP ERROR: 200</h2>\n" +
+ "<p>Problem accessing http://foo.bar. Reason:\n" +
+ "<pre> My custom error message</pre></p>\n" +
+ "<hr/>\n" +
+ "</body>\n" +
+ "</html>\n";
+
+ ErrorResponseContentCreator c = new ErrorResponseContentCreator();
+ byte[] rawContent = c.createErrorContent(
+ "http://foo.bar",
+ HttpServletResponse.SC_OK,
+ Optional.of("My custom error message"));
+ String actualHtml = new String(rawContent, StandardCharsets.ISO_8859_1);
+ assertEquals(expectedHtml, actualHtml);
+ }
+
+}
diff --git a/container-core/src/test/java/com/yahoo/jdisc/http/server/jetty/ExceptionWrapperTest.java b/container-core/src/test/java/com/yahoo/jdisc/http/server/jetty/ExceptionWrapperTest.java
new file mode 100644
index 00000000000..de8df283afe
--- /dev/null
+++ b/container-core/src/test/java/com/yahoo/jdisc/http/server/jetty/ExceptionWrapperTest.java
@@ -0,0 +1,51 @@
+// 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.junit.Test;
+
+import static org.hamcrest.CoreMatchers.equalTo;
+import static org.hamcrest.MatcherAssert.assertThat;
+
+/**
+ * Check basic error message formatting. Do note these tests are sensitive to
+ * the line numbering in this file. (And that's a feature, not a bug.)
+ *
+ * @author <a href="mailto:steinar@yahoo-inc.com">Steinar Knutsen</a>
+ */
+public class ExceptionWrapperTest {
+
+ @Test
+ public final void requireNoMessageIsOK() {
+ final Throwable t = new Throwable();
+ final ExceptionWrapper e = new ExceptionWrapper(t);
+ final String expected = "Throwable() at com.yahoo.jdisc.http.server.jetty.ExceptionWrapperTest(ExceptionWrapperTest.java:19)";
+
+ assertThat(e.getMessage(), equalTo(expected));
+ }
+
+ @Test
+ public final void requireAllWrappedLevelsShowUp() {
+ final Throwable t0 = new Throwable("t0");
+ final Throwable t1 = new Throwable("t1", t0);
+ final Throwable t2 = new Throwable("t2", t1);
+ final ExceptionWrapper e = new ExceptionWrapper(t2);
+ final String expected = "Throwable(\"t2\") at com.yahoo.jdisc.http.server.jetty.ExceptionWrapperTest(ExceptionWrapperTest.java:30):"
+ + " Throwable(\"t1\") at com.yahoo.jdisc.http.server.jetty.ExceptionWrapperTest(ExceptionWrapperTest.java:29):"
+ + " Throwable(\"t0\") at com.yahoo.jdisc.http.server.jetty.ExceptionWrapperTest(ExceptionWrapperTest.java:28)";
+
+ assertThat(e.getMessage(), equalTo(expected));
+ }
+
+ @Test
+ public final void requireMixOfMessageAndNoMessageWorks() {
+ final Throwable t0 = new Throwable("t0");
+ final Throwable t1 = new Throwable(t0);
+ final Throwable t2 = new Throwable("t2", t1);
+ final ExceptionWrapper e = new ExceptionWrapper(t2);
+ final String expected = "Throwable(\"t2\") at com.yahoo.jdisc.http.server.jetty.ExceptionWrapperTest(ExceptionWrapperTest.java:43):"
+ + " Throwable(\"java.lang.Throwable: t0\") at com.yahoo.jdisc.http.server.jetty.ExceptionWrapperTest(ExceptionWrapperTest.java:42):"
+ + " Throwable(\"t0\") at com.yahoo.jdisc.http.server.jetty.ExceptionWrapperTest(ExceptionWrapperTest.java:41)";
+
+ assertThat(e.getMessage(), equalTo(expected));
+ }
+}
diff --git a/container-core/src/test/java/com/yahoo/jdisc/http/server/jetty/FilterTestCase.java b/container-core/src/test/java/com/yahoo/jdisc/http/server/jetty/FilterTestCase.java
new file mode 100644
index 00000000000..a67656dd5ca
--- /dev/null
+++ b/container-core/src/test/java/com/yahoo/jdisc/http/server/jetty/FilterTestCase.java
@@ -0,0 +1,667 @@
+// 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.AbstractModule;
+import com.google.inject.util.Modules;
+import com.yahoo.container.logging.ConnectionLog;
+import com.yahoo.container.logging.RequestLog;
+import com.yahoo.jdisc.AbstractResource;
+import com.yahoo.jdisc.Request;
+import com.yahoo.jdisc.ResourceReference;
+import com.yahoo.jdisc.Response;
+import com.yahoo.jdisc.handler.AbstractRequestHandler;
+import com.yahoo.jdisc.handler.CompletionHandler;
+import com.yahoo.jdisc.handler.ContentChannel;
+import com.yahoo.jdisc.handler.ResponseDispatch;
+import com.yahoo.jdisc.handler.ResponseHandler;
+import com.yahoo.jdisc.http.ConnectorConfig;
+import com.yahoo.jdisc.http.HttpRequest;
+import com.yahoo.jdisc.http.HttpResponse;
+import com.yahoo.jdisc.http.ServerConfig;
+import com.yahoo.jdisc.http.ServletPathsConfig;
+import com.yahoo.jdisc.http.filter.RequestFilter;
+import com.yahoo.jdisc.http.filter.ResponseFilter;
+import com.yahoo.jdisc.http.filter.ResponseHeaderFilter;
+import com.yahoo.jdisc.http.filter.chain.RequestFilterChain;
+import com.yahoo.jdisc.http.filter.chain.ResponseFilterChain;
+import com.yahoo.jdisc.http.guiceModules.ConnectorFactoryRegistryModule;
+import org.junit.Test;
+import org.mockito.ArgumentCaptor;
+
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicReference;
+
+import static org.hamcrest.CoreMatchers.containsString;
+import static org.hamcrest.CoreMatchers.is;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.isNull;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * @author Oyvind Bakksjo
+ * @author bjorncs
+ */
+public class FilterTestCase {
+ @Test
+ public void requireThatRequestFilterIsNotRunOnUnboundPath() throws Exception {
+ RequestFilterMockBase filter = mock(RequestFilterMockBase.class);
+ FilterBindings filterBindings = new FilterBindings.Builder()
+ .addRequestFilter("my-request-filter", filter)
+ .addRequestFilterBinding("my-request-filter", "http://*/filtered/*")
+ .build();
+ final MyRequestHandler requestHandler = new MyRequestHandler();
+ final TestDriver testDriver = newDriver(requestHandler, filterBindings);
+
+ testDriver.client().get("/status.html");
+
+ assertThat(requestHandler.awaitInvocation(), is(true));
+ verify(filter, never()).filter(any(HttpRequest.class), any(ResponseHandler.class));
+
+ assertThat(testDriver.close(), is(true));
+ }
+
+ @Test
+ public void requireThatRequestFilterIsRunOnBoundPath() throws Exception {
+ final RequestFilter filter = mock(RequestFilterMockBase.class);
+ FilterBindings filterBindings = new FilterBindings.Builder()
+ .addRequestFilter("my-request-filter", filter)
+ .addRequestFilterBinding("my-request-filter", "http://*/filtered/*")
+ .build();
+ final MyRequestHandler requestHandler = new MyRequestHandler();
+ final TestDriver testDriver = newDriver(requestHandler, filterBindings);
+
+ testDriver.client().get("/filtered/status.html");
+
+ assertThat(requestHandler.awaitInvocation(), is(true));
+ verify(filter, times(1)).filter(any(HttpRequest.class), any(ResponseHandler.class));
+
+ assertThat(testDriver.close(), is(true));
+ }
+
+ @Test
+ public void requireThatRequestFilterChangesAreSeenByRequestHandler() throws Exception {
+ final RequestFilter filter = new HeaderRequestFilter("foo", "bar");
+ FilterBindings filterBindings = new FilterBindings.Builder()
+ .addRequestFilter("my-request-filter", filter)
+ .addRequestFilterBinding("my-request-filter", "http://*/*")
+ .build();
+ final MyRequestHandler requestHandler = new MyRequestHandler();
+ final TestDriver testDriver = newDriver(requestHandler, filterBindings);
+
+ testDriver.client().get("status.html");
+
+ assertThat(requestHandler.awaitInvocation(), is(true));
+ assertThat(requestHandler.getHeaderMap().get("foo").get(0), is("bar"));
+
+ assertThat(testDriver.close(), is(true));
+ }
+
+ @Test
+ public void requireThatRequestFilterCanRespond() throws Exception {
+ FilterBindings filterBindings = new FilterBindings.Builder()
+ .addRequestFilter("my-request-filter", new RespondForbiddenFilter())
+ .addRequestFilterBinding("my-request-filter", "http://*/*")
+ .build();
+ final MyRequestHandler requestHandler = new MyRequestHandler();
+ final TestDriver testDriver = newDriver(requestHandler, filterBindings);
+
+ testDriver.client().get("/status.html").expectStatusCode(is(Response.Status.FORBIDDEN));
+
+ assertThat(requestHandler.hasBeenInvokedYet(), is(false));
+
+ assertThat(testDriver.close(), is(true));
+ }
+
+ @Test
+ public void requireThatFilterCanHaveNullCompletionHandler() throws Exception {
+ final int responseStatus = Response.Status.OK;
+ final String responseMessage = "Excellent";
+ FilterBindings filterBindings = new FilterBindings.Builder()
+ .addRequestFilter("my-request-filter", new NullCompletionHandlerFilter(responseStatus, responseMessage))
+ .addRequestFilterBinding("my-request-filter", "http://*/*")
+ .build();
+ final MyRequestHandler requestHandler = new MyRequestHandler();
+ final TestDriver testDriver = newDriver(requestHandler, filterBindings);
+
+ testDriver.client().get("/status.html")
+ .expectStatusCode(is(responseStatus))
+ .expectContent(is(responseMessage));
+
+ assertThat(requestHandler.hasBeenInvokedYet(), is(false));
+
+ assertThat(testDriver.close(), is(true));
+ }
+
+ @Test
+ public void requireThatRequestFilterExecutionIsExceptionSafe() throws Exception {
+ FilterBindings filterBindings = new FilterBindings.Builder()
+ .addRequestFilter("my-request-filter", new ThrowingRequestFilter())
+ .addRequestFilterBinding("my-request-filter", "http://*/*")
+ .build();
+ final MyRequestHandler requestHandler = new MyRequestHandler();
+ final TestDriver testDriver = newDriver(requestHandler, filterBindings);
+
+ testDriver.client().get("/status.html").expectStatusCode(is(Response.Status.INTERNAL_SERVER_ERROR));
+
+ assertThat(requestHandler.hasBeenInvokedYet(), is(false));
+
+ assertThat(testDriver.close(), is(true));
+ }
+
+ @Test
+ public void requireThatResponseFilterIsNotRunOnUnboundPath() throws Exception {
+ final ResponseFilter filter = mock(ResponseFilterMockBase.class);
+ FilterBindings filterBindings = new FilterBindings.Builder()
+ .addResponseFilter("my-response-filter", filter)
+ .addResponseFilterBinding("my-response-filter", "http://*/filtered/*")
+ .build();
+ final MyRequestHandler requestHandler = new MyRequestHandler();
+ final TestDriver testDriver = newDriver(requestHandler, filterBindings);
+
+ testDriver.client().get("/status.html");
+
+ assertThat(requestHandler.awaitInvocation(), is(true));
+ verify(filter, never()).filter(any(Response.class), any(Request.class));
+
+ assertThat(testDriver.close(), is(true));
+ }
+
+ @Test
+ public void requireThatResponseFilterIsRunOnBoundPath() throws Exception {
+ final ResponseFilter filter = mock(ResponseFilterMockBase.class);
+ FilterBindings filterBindings = new FilterBindings.Builder()
+ .addResponseFilter("my-response-filter", filter)
+ .addResponseFilterBinding("my-response-filter", "http://*/filtered/*")
+ .build();
+ final MyRequestHandler requestHandler = new MyRequestHandler();
+ final TestDriver testDriver = newDriver(requestHandler, filterBindings);
+
+ testDriver.client().get("/filtered/status.html");
+
+ assertThat(requestHandler.awaitInvocation(), is(true));
+ verify(filter, times(1)).filter(any(Response.class), any(Request.class));
+
+ assertThat(testDriver.close(), is(true));
+ }
+
+ @Test
+ public void requireThatResponseFilterChangesAreWrittenToResponse() throws Exception {
+ FilterBindings filterBindings = new FilterBindings.Builder()
+ .addResponseFilter("my-response-filter", new HeaderResponseFilter("foo", "bar"))
+ .addResponseFilterBinding("my-response-filter", "http://*/*")
+ .build();
+ final MyRequestHandler requestHandler = new MyRequestHandler();
+ final TestDriver testDriver = newDriver(requestHandler, filterBindings);
+
+ testDriver.client().get("/status.html")
+ .expectHeader("foo", is("bar"));
+
+ assertThat(requestHandler.awaitInvocation(), is(true));
+
+ assertThat(testDriver.close(), is(true));
+ }
+
+ @Test
+ public void requireThatResponseFilterExecutionIsExceptionSafe() throws Exception {
+ FilterBindings filterBindings = new FilterBindings.Builder()
+ .addResponseFilter("my-response-filter", new ThrowingResponseFilter())
+ .addResponseFilterBinding("my-response-filter", "http://*/*")
+ .build();
+ final MyRequestHandler requestHandler = new MyRequestHandler();
+ final TestDriver testDriver = newDriver(requestHandler, filterBindings);
+
+ testDriver.client().get("/status.html").expectStatusCode(is(Response.Status.INTERNAL_SERVER_ERROR));
+
+ assertThat(requestHandler.awaitInvocation(), is(true));
+
+ assertThat(testDriver.close(), is(true));
+ }
+
+ @Test
+ public void requireThatRequestFilterAndResponseFilterCanBindToSamePath() throws Exception {
+ final RequestFilter requestFilter = mock(RequestFilterMockBase.class);
+ final ResponseFilter responseFilter = mock(ResponseFilterMockBase.class);
+ final String uriPattern = "http://*/*";
+ FilterBindings filterBindings = new FilterBindings.Builder()
+ .addRequestFilter("my-request-filter", requestFilter)
+ .addRequestFilterBinding("my-request-filter", uriPattern)
+ .addResponseFilter("my-response-filter", responseFilter)
+ .addResponseFilterBinding("my-response-filter", uriPattern)
+ .build();
+ final MyRequestHandler requestHandler = new MyRequestHandler();
+ final TestDriver testDriver = newDriver(requestHandler, filterBindings);
+
+ testDriver.client().get("/status.html");
+
+ assertThat(requestHandler.awaitInvocation(), is(true));
+ verify(requestFilter, times(1)).filter(any(HttpRequest.class), any(ResponseHandler.class));
+ verify(responseFilter, times(1)).filter(any(Response.class), any(Request.class));
+
+ assertThat(testDriver.close(), is(true));
+ }
+
+ @Test
+ public void requireThatResponseFromRequestFilterGoesThroughResponseFilter() throws Exception {
+ FilterBindings filterBindings = new FilterBindings.Builder()
+ .addRequestFilter("my-request-filter", new RespondForbiddenFilter())
+ .addRequestFilterBinding("my-request-filter", "http://*/*")
+ .addResponseFilter("my-response-filter", new HeaderResponseFilter("foo", "bar"))
+ .addResponseFilterBinding("my-response-filter", "http://*/*")
+ .build();
+ final MyRequestHandler requestHandler = new MyRequestHandler();
+ final TestDriver testDriver = newDriver(requestHandler, filterBindings);
+
+ testDriver.client().get("/status.html")
+ .expectStatusCode(is(Response.Status.FORBIDDEN))
+ .expectHeader("foo", is("bar"));
+
+ assertThat(requestHandler.hasBeenInvokedYet(), is(false));
+
+ assertThat(testDriver.close(), is(true));
+ }
+
+ @Test
+ public void requireThatRequestFilterChainRetainsFilters() {
+ final RequestFilter requestFilter1 = mock(RequestFilter.class);
+ final RequestFilter requestFilter2 = mock(RequestFilter.class);
+
+ verify(requestFilter1, never()).refer();
+ verify(requestFilter2, never()).refer();
+ final ResourceReference reference1 = mock(ResourceReference.class);
+ final ResourceReference reference2 = mock(ResourceReference.class);
+ when(requestFilter1.refer()).thenReturn(reference1);
+ when(requestFilter2.refer()).thenReturn(reference2);
+ final RequestFilter chain = RequestFilterChain.newInstance(requestFilter1, requestFilter2);
+ verify(requestFilter1, times(1)).refer();
+ verify(requestFilter2, times(1)).refer();
+
+ verify(reference1, never()).close();
+ verify(reference2, never()).close();
+ chain.release();
+ verify(reference1, times(1)).close();
+ verify(reference2, times(1)).close();
+ }
+
+ @Test
+ public void requireThatRequestFilterChainIsRun() throws Exception {
+ final RequestFilter requestFilter1 = mock(RequestFilter.class);
+ final RequestFilter requestFilter2 = mock(RequestFilter.class);
+ final RequestFilter requestFilterChain = RequestFilterChain.newInstance(requestFilter1, requestFilter2);
+ final HttpRequest request = null;
+ final ResponseHandler responseHandler = null;
+ requestFilterChain.filter(request, responseHandler);
+ verify(requestFilter1).filter(isNull(), any(ResponseHandler.class));
+ verify(requestFilter2).filter(isNull(), any(ResponseHandler.class));
+ }
+
+ @Test
+ public void requireThatRequestFilterChainCallsFilterWithOriginalRequest() throws Exception {
+ final RequestFilter requestFilter = mock(RequestFilter.class);
+ final RequestFilter requestFilterChain = RequestFilterChain.newInstance(requestFilter);
+ final HttpRequest request = mock(HttpRequest.class);
+ final ResponseHandler responseHandler = null;
+ requestFilterChain.filter(request, responseHandler);
+
+ // Check that the filter is called with the same request argument as the chain was,
+ // in a manner that allows the request object to be wrapped.
+ final ArgumentCaptor<HttpRequest> requestCaptor = ArgumentCaptor.forClass(HttpRequest.class);
+ verify(requestFilter).filter(requestCaptor.capture(), isNull());
+ verify(request, never()).getUri();
+ requestCaptor.getValue().getUri();
+ verify(request, times(1)).getUri();
+ }
+
+ @Test
+ public void requireThatRequestFilterChainCallsFilterWithOriginalResponseHandler() throws Exception {
+ final RequestFilter requestFilter = mock(RequestFilter.class);
+ final RequestFilter requestFilterChain = RequestFilterChain.newInstance(requestFilter);
+ final HttpRequest request = null;
+ final ResponseHandler responseHandler = mock(ResponseHandler.class);
+ requestFilterChain.filter(request, responseHandler);
+
+ // Check that the filter is called with the same response handler argument as the chain was,
+ // in a manner that allows the handler object to be wrapped.
+ final ArgumentCaptor<ResponseHandler> responseHandlerCaptor = ArgumentCaptor.forClass(ResponseHandler.class);
+ verify(requestFilter).filter(isNull(), responseHandlerCaptor.capture());
+ verify(responseHandler, never()).handleResponse(any(Response.class));
+ responseHandlerCaptor.getValue().handleResponse(mock(Response.class));
+ verify(responseHandler, times(1)).handleResponse(any(Response.class));
+ }
+
+ @Test
+ public void requireThatRequestFilterCanTerminateChain() throws Exception {
+ final RequestFilter requestFilter1 = new RespondForbiddenFilter();
+ final RequestFilter requestFilter2 = mock(RequestFilter.class);
+ final RequestFilter requestFilterChain = RequestFilterChain.newInstance(requestFilter1, requestFilter2);
+ final HttpRequest request = null;
+ final ResponseHandler responseHandler = mock(ResponseHandler.class);
+ when(responseHandler.handleResponse(any(Response.class))).thenReturn(mock(ContentChannel.class));
+
+ requestFilterChain.filter(request, responseHandler);
+
+ verify(requestFilter2, never()).filter(any(HttpRequest.class), any(ResponseHandler.class));
+
+ final ArgumentCaptor<Response> responseCaptor = ArgumentCaptor.forClass(Response.class);
+ verify(responseHandler).handleResponse(responseCaptor.capture());
+ assertThat(responseCaptor.getValue().getStatus(), is(Response.Status.FORBIDDEN));
+ }
+
+ @Test
+ public void requireThatResponseFilterChainRetainsFilters() {
+ final ResponseFilter responseFilter1 = mock(ResponseFilter.class);
+ final ResponseFilter responseFilter2 = mock(ResponseFilter.class);
+
+ verify(responseFilter1, never()).refer();
+ verify(responseFilter2, never()).refer();
+ final ResourceReference reference1 = mock(ResourceReference.class);
+ final ResourceReference reference2 = mock(ResourceReference.class);
+ when(responseFilter1.refer()).thenReturn(reference1);
+ when(responseFilter2.refer()).thenReturn(reference2);
+ final ResponseFilter chain = ResponseFilterChain.newInstance(responseFilter1, responseFilter2);
+ verify(responseFilter1, times(1)).refer();
+ verify(responseFilter2, times(1)).refer();
+
+ verify(reference1, never()).close();
+ verify(reference2, never()).close();
+ chain.release();
+ verify(reference1, times(1)).close();
+ verify(reference2, times(1)).close();
+ }
+
+ @Test
+ public void requireThatResponseFilterChainIsRun() {
+ final ResponseFilter responseFilter1 = new ResponseHeaderFilter("foo", "bar");
+ final ResponseFilter responseFilter2 = mock(ResponseFilter.class);
+ final int statusCode = Response.Status.BAD_GATEWAY;
+ final Response response = new Response(statusCode);
+ final Request request = null;
+
+ ResponseFilterChain.newInstance(responseFilter1, responseFilter2).filter(response, request);
+
+ final ArgumentCaptor<Response> responseCaptor = ArgumentCaptor.forClass(Response.class);
+ verify(responseFilter2).filter(responseCaptor.capture(), isNull());
+ assertThat(responseCaptor.getValue().getStatus(), is(statusCode));
+ assertThat(responseCaptor.getValue().headers().getFirst("foo"), is("bar"));
+
+ assertThat(response.getStatus(), is(statusCode));
+ assertThat(response.headers().getFirst("foo"), is("bar"));
+ }
+
+ @Test
+ public void requireThatDefaultRequestFilterChainIsRunIfNoOtherFilterChainMatches() throws IOException, InterruptedException {
+ RequestFilter filterWithBinding = mock(RequestFilter.class);
+ RequestFilter defaultFilter = mock(RequestFilter.class);
+ String defaultFilterId = "default-request-filter";
+ FilterBindings filterBindings = new FilterBindings.Builder()
+ .addRequestFilter("my-request-filter", filterWithBinding)
+ .addRequestFilterBinding("my-request-filter", "http://*/filtered/*")
+ .addRequestFilter(defaultFilterId, defaultFilter)
+ .setRequestFilterDefaultForPort(defaultFilterId, 0)
+ .build();
+ MyRequestHandler requestHandler = new MyRequestHandler();
+ TestDriver testDriver = newDriver(requestHandler, filterBindings);
+
+ testDriver.client().get("/status.html");
+
+ assertThat(requestHandler.awaitInvocation(), is(true));
+ verify(defaultFilter, times(1)).filter(any(HttpRequest.class), any(ResponseHandler.class));
+ verify(filterWithBinding, never()).filter(any(HttpRequest.class), any(ResponseHandler.class));
+
+ assertThat(testDriver.close(), is(true));
+ }
+
+ @Test
+ public void requireThatDefaultResponseFilterChainIsRunIfNoOtherFilterChainMatches() throws IOException, InterruptedException {
+ ResponseFilter filterWithBinding = mock(ResponseFilter.class);
+ ResponseFilter defaultFilter = mock(ResponseFilter.class);
+ String defaultFilterId = "default-response-filter";
+ FilterBindings filterBindings = new FilterBindings.Builder()
+ .addResponseFilter("my-response-filter", filterWithBinding)
+ .addResponseFilterBinding("my-response-filter", "http://*/filtered/*")
+ .addResponseFilter(defaultFilterId, defaultFilter)
+ .setResponseFilterDefaultForPort(defaultFilterId, 0)
+ .build();
+ MyRequestHandler requestHandler = new MyRequestHandler();
+ TestDriver testDriver = newDriver(requestHandler, filterBindings);
+
+ testDriver.client().get("/status.html");
+
+ assertThat(requestHandler.awaitInvocation(), is(true));
+ verify(defaultFilter, times(1)).filter(any(Response.class), any(Request.class));
+ verify(filterWithBinding, never()).filter(any(Response.class), any(Request.class));
+
+ assertThat(testDriver.close(), is(true));
+ }
+
+ @Test
+ public void requireThatRequestFilterWithBindingMatchHasPrecedenceOverDefaultFilter() throws IOException, InterruptedException {
+ RequestFilterMockBase filterWithBinding = mock(RequestFilterMockBase.class);
+ RequestFilterMockBase defaultFilter = mock(RequestFilterMockBase.class);
+ String defaultFilterId = "default-request-filter";
+ FilterBindings filterBindings = new FilterBindings.Builder()
+ .addRequestFilter("my-request-filter", filterWithBinding)
+ .addRequestFilterBinding("my-request-filter", "http://*/filtered/*")
+ .addRequestFilter(defaultFilterId, defaultFilter)
+ .setRequestFilterDefaultForPort(defaultFilterId, 0)
+ .build();
+ MyRequestHandler requestHandler = new MyRequestHandler();
+ TestDriver testDriver = newDriver(requestHandler, filterBindings);
+
+ testDriver.client().get("/filtered/status.html");
+
+ assertThat(requestHandler.awaitInvocation(), is(true));
+ verify(defaultFilter, never()).filter(any(HttpRequest.class), any(ResponseHandler.class));
+ verify(filterWithBinding).filter(any(HttpRequest.class), any(ResponseHandler.class));
+
+ assertThat(testDriver.close(), is(true));
+ }
+
+ @Test
+ public void requireThatResponseFilterWithBindingMatchHasPrecedenceOverDefaultFilter() throws IOException, InterruptedException {
+ ResponseFilter filterWithBinding = mock(ResponseFilter.class);
+ ResponseFilter defaultFilter = mock(ResponseFilter.class);
+ String defaultFilterId = "default-response-filter";
+ FilterBindings filterBindings = new FilterBindings.Builder()
+ .addResponseFilter("my-response-filter", filterWithBinding)
+ .addResponseFilterBinding("my-response-filter", "http://*/filtered/*")
+ .addResponseFilter(defaultFilterId, defaultFilter)
+ .setResponseFilterDefaultForPort(defaultFilterId, 0)
+ .build();
+ MyRequestHandler requestHandler = new MyRequestHandler();
+ TestDriver testDriver = newDriver(requestHandler, filterBindings);
+
+ testDriver.client().get("/filtered/status.html");
+
+ assertThat(requestHandler.awaitInvocation(), is(true));
+ verify(defaultFilter, never()).filter(any(Response.class), any(Request.class));
+ verify(filterWithBinding, times(1)).filter(any(Response.class), any(Request.class));
+
+ assertThat(testDriver.close(), is(true));
+ }
+
+ @Test
+ public void requireThatMetricAreReported() throws IOException, InterruptedException {
+ FilterBindings filterBindings = new FilterBindings.Builder()
+ .addRequestFilter("my-request-filter", mock(RequestFilter.class))
+ .addRequestFilterBinding("my-request-filter", "http://*/*")
+ .build();
+ MetricConsumerMock metricConsumerMock = new MetricConsumerMock();
+ MyRequestHandler requestHandler = new MyRequestHandler();
+ TestDriver testDriver = newDriver(requestHandler, filterBindings, metricConsumerMock, false);
+
+ testDriver.client().get("/status.html");
+ assertThat(requestHandler.awaitInvocation(), is(true));
+ verify(metricConsumerMock.mockitoMock())
+ .add(MetricDefinitions.FILTERING_REQUEST_HANDLED, 1L, MetricConsumerMock.STATIC_CONTEXT);
+ verify(metricConsumerMock.mockitoMock(), never())
+ .add(MetricDefinitions.FILTERING_REQUEST_UNHANDLED, 1L, MetricConsumerMock.STATIC_CONTEXT);
+ verify(metricConsumerMock.mockitoMock(), never())
+ .add(MetricDefinitions.FILTERING_RESPONSE_HANDLED, 1L, MetricConsumerMock.STATIC_CONTEXT);
+ verify(metricConsumerMock.mockitoMock())
+ .add(MetricDefinitions.FILTERING_RESPONSE_UNHANDLED, 1L, MetricConsumerMock.STATIC_CONTEXT);
+ assertThat(testDriver.close(), is(true));
+ }
+
+ @Test
+ public void requireThatStrictFilteringRejectsRequestsNotMatchingFilterChains() throws IOException {
+ RequestFilter filter = mock(RequestFilter.class);
+ FilterBindings filterBindings = new FilterBindings.Builder()
+ .addRequestFilter("my-request-filter", filter)
+ .addRequestFilterBinding("my-request-filter", "http://*/filtered/*")
+ .build();
+ MyRequestHandler requestHandler = new MyRequestHandler();
+ TestDriver testDriver = newDriver(requestHandler, filterBindings, new MetricConsumerMock(), true);
+
+ testDriver.client().get("/unfiltered/")
+ .expectStatusCode(is(Response.Status.FORBIDDEN))
+ .expectContent(containsString("Request did not match any request filter chain"));
+ verify(filter, never()).filter(any(), any());
+ assertThat(testDriver.close(), is(true));
+ }
+
+ private static TestDriver newDriver(MyRequestHandler requestHandler, FilterBindings filterBindings) {
+ return newDriver(requestHandler, filterBindings, new MetricConsumerMock(), false);
+ }
+
+ private static TestDriver newDriver(
+ MyRequestHandler requestHandler,
+ FilterBindings filterBindings,
+ MetricConsumerMock metricConsumer,
+ boolean strictFiltering) {
+ return TestDriver.newInstance(
+ JettyHttpServer.class,
+ requestHandler,
+ newFilterModule(filterBindings, metricConsumer, strictFiltering));
+ }
+
+ private static com.google.inject.Module newFilterModule(
+ FilterBindings filterBindings, MetricConsumerMock metricConsumer, boolean strictFiltering) {
+ return Modules.combine(
+ new AbstractModule() {
+ @Override
+ protected void configure() {
+
+ bind(FilterBindings.class).toInstance(filterBindings);
+ bind(ServerConfig.class).toInstance(new ServerConfig(new ServerConfig.Builder().strictFiltering(strictFiltering)));
+ bind(ConnectorConfig.class).toInstance(new ConnectorConfig(new ConnectorConfig.Builder()));
+ bind(ServletPathsConfig.class).toInstance(new ServletPathsConfig(new ServletPathsConfig.Builder()));
+ bind(ConnectionLog.class).toInstance(new VoidConnectionLog());
+ bind(RequestLog.class).toInstance(new VoidRequestLog());
+ }
+ },
+ new ConnectorFactoryRegistryModule(),
+ metricConsumer.asGuiceModule());
+ }
+
+ private static abstract class RequestFilterMockBase extends AbstractResource implements RequestFilter {}
+ private static abstract class ResponseFilterMockBase extends AbstractResource implements ResponseFilter {}
+
+ private static class MyRequestHandler extends AbstractRequestHandler {
+ private final CountDownLatch invocationLatch = new CountDownLatch(1);
+ private final AtomicReference<Map<String, List<String>>> headerCopy = new AtomicReference<>(null);
+
+ @Override
+ public ContentChannel handleRequest(final Request request, final ResponseHandler handler) {
+ try {
+ headerCopy.set(new HashMap<String, List<String>>(request.headers()));
+ ResponseDispatch.newInstance(Response.Status.OK).dispatch(handler);
+ return null;
+ } finally {
+ invocationLatch.countDown();
+ }
+ }
+
+ public boolean hasBeenInvokedYet() {
+ return invocationLatch.getCount() == 0L;
+ }
+
+ public boolean awaitInvocation() throws InterruptedException {
+ return invocationLatch.await(60, TimeUnit.SECONDS);
+ }
+
+ public Map<String, List<String>> getHeaderMap() {
+ return headerCopy.get();
+ }
+ }
+
+ private static class RespondForbiddenFilter extends AbstractResource implements RequestFilter {
+ @Override
+ public void filter(final HttpRequest request, final ResponseHandler handler) {
+ ResponseDispatch.newInstance(Response.Status.FORBIDDEN).dispatch(handler);
+ }
+ }
+
+ private static class ThrowingRequestFilter extends AbstractResource implements RequestFilter {
+ @Override
+ public void filter(final HttpRequest request, final ResponseHandler handler) {
+ throw new RuntimeException();
+ }
+ }
+
+ private static class ThrowingResponseFilter extends AbstractResource implements ResponseFilter {
+ @Override
+ public void filter(final Response response, final Request request) {
+ throw new RuntimeException();
+ }
+ }
+
+ private static class HeaderRequestFilter extends AbstractResource implements RequestFilter {
+ private final String key;
+ private final String val;
+
+ public HeaderRequestFilter(final String key, final String val) {
+ this.key = key;
+ this.val = val;
+ }
+
+ @Override
+ public void filter(final HttpRequest request, final ResponseHandler handler) {
+ request.headers().add(key, val);
+ }
+ }
+
+ private static class HeaderResponseFilter extends AbstractResource implements ResponseFilter {
+ private final String key;
+ private final String val;
+
+ public HeaderResponseFilter(final String key, final String val) {
+ this.key = key;
+ this.val = val;
+ }
+
+ @Override
+ public void filter(final Response response, final Request request) {
+ response.headers().add(key, val);
+ }
+ }
+
+ public class NullCompletionHandlerFilter extends AbstractResource implements RequestFilter {
+ private final int responseStatus;
+ private final String responseMessage;
+
+ public NullCompletionHandlerFilter(final int responseStatus, final String responseMessage) {
+ this.responseStatus = responseStatus;
+ this.responseMessage = responseMessage;
+ }
+
+ @Override
+ public void filter(final HttpRequest request, final ResponseHandler responseHandler) {
+ final HttpResponse response = HttpResponse.newInstance(responseStatus);
+ final ContentChannel channel = responseHandler.handleResponse(response);
+ final CompletionHandler completionHandler = null;
+ channel.write(ByteBuffer.wrap(responseMessage.getBytes()), completionHandler);
+ channel.close(null);
+ }
+ }
+}
diff --git a/container-core/src/test/java/com/yahoo/jdisc/http/server/jetty/HttpRequestFactoryTest.java b/container-core/src/test/java/com/yahoo/jdisc/http/server/jetty/HttpRequestFactoryTest.java
new file mode 100644
index 00000000000..9c1348004ee
--- /dev/null
+++ b/container-core/src/test/java/com/yahoo/jdisc/http/server/jetty/HttpRequestFactoryTest.java
@@ -0,0 +1,204 @@
+// 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.Key;
+import com.yahoo.jdisc.Container;
+import com.yahoo.jdisc.References;
+import com.yahoo.jdisc.ResourceReference;
+import com.yahoo.jdisc.Response;
+import com.yahoo.jdisc.handler.RequestHandler;
+import com.yahoo.jdisc.http.ConnectorConfig;
+import com.yahoo.jdisc.http.HttpRequest;
+import com.yahoo.jdisc.service.CurrentContainer;
+import org.eclipse.jetty.server.HttpConnection;
+import org.junit.Test;
+
+import javax.servlet.http.HttpServletRequest;
+import java.net.URI;
+
+import static org.hamcrest.CoreMatchers.equalTo;
+import static org.hamcrest.CoreMatchers.is;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.fail;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * @author Steinar Knutsen
+ * @author bjorncs
+ */
+public class HttpRequestFactoryTest {
+
+ private static final int LOCAL_PORT = 80;
+
+ @Test
+ public void testLegalURIs() {
+ {
+ URI uri = HttpRequestFactory.getUri(createMockRequest("https", "host", null, null));
+ assertEquals("https", uri.getScheme());
+ assertEquals("host", uri.getHost());
+ assertEquals("", uri.getRawPath());
+ assertNull(uri.getRawQuery());
+ }
+ {
+ URI uri = HttpRequestFactory.getUri(createMockRequest("https", "host", "", ""));
+ assertEquals("https", uri.getScheme());
+ assertEquals("host", uri.getHost());
+ assertEquals("", uri.getRawPath());
+ assertEquals("", uri.getRawQuery());
+ }
+ {
+ URI uri = HttpRequestFactory.getUri(createMockRequest("http", "host.a1-2-3", "", ""));
+ assertEquals("http", uri.getScheme());
+ assertEquals("host.a1-2-3", uri.getHost());
+ assertEquals("", uri.getRawPath());
+ assertEquals("", uri.getRawQuery());
+ }
+ {
+ URI uri = HttpRequestFactory.getUri(createMockRequest("https", "host", "/:1/../1=.", ""));
+ assertEquals("https", uri.getScheme());
+ assertEquals("host", uri.getHost());
+ assertEquals("/:1/../1=.", uri.getRawPath());
+ assertEquals("", uri.getRawQuery());
+ }
+ {
+ URI uri = HttpRequestFactory.getUri(createMockRequest("https", "host", "", "a=/../&?="));
+ assertEquals("https", uri.getScheme());
+ assertEquals("host", uri.getHost());
+ assertEquals("", uri.getRawPath());
+ assertEquals("a=/../&?=", uri.getRawQuery());
+ }
+ }
+
+ @Test
+ public void testIllegalQuery() {
+ try {
+ HttpRequestFactory.newJDiscRequest(
+ new MockContainer(),
+ createMockRequest("http", "example.com", "/search", "query=\"contains_quotes\""));
+ fail("Above statement should throw");
+ } catch (RequestException e) {
+ assertThat(e.getResponseStatus(), is(Response.Status.BAD_REQUEST));
+ }
+ }
+
+ @Test
+ public final void illegal_host_throws_requestexception1() {
+ try {
+ HttpRequestFactory.newJDiscRequest(
+ new MockContainer(),
+ createMockRequest("http", "?", "/foo", ""));
+ fail("Above statement should throw");
+ } catch (RequestException e) {
+ assertThat(e.getResponseStatus(), is(Response.Status.BAD_REQUEST));
+ }
+ }
+
+ @Test
+ public final void illegal_host_throws_requestexception2() {
+ try {
+ HttpRequestFactory.newJDiscRequest(
+ new MockContainer(),
+ createMockRequest("http", ".", "/foo", ""));
+ fail("Above statement should throw");
+ } catch (RequestException e) {
+ assertThat(e.getResponseStatus(), is(Response.Status.BAD_REQUEST));
+ }
+ }
+
+ @Test
+ public final void illegal_host_throws_requestexception3() {
+ try {
+ HttpRequestFactory.newJDiscRequest(
+ new MockContainer(),
+ createMockRequest("http", "*", "/foo", ""));
+ fail("Above statement should throw");
+ } catch (RequestException e) {
+ assertThat(e.getResponseStatus(), is(Response.Status.BAD_REQUEST));
+ }
+ }
+
+ @Test
+ public final void illegal_unicode_in_query_throws_requestexception() {
+ try {
+ HttpRequestFactory.newJDiscRequest(
+ new MockContainer(),
+ createMockRequest("http", "example.com", "/search", "query=%c0%ae"));
+ fail("Above statement should throw");
+ } catch (RequestException e) {
+ assertThat(e.getResponseStatus(), is(Response.Status.BAD_REQUEST));
+ assertThat(e.getMessage(), equalTo("URL violates RFC 2396: Not valid UTF8! byte C0 in state 0"));
+ }
+ }
+
+ @Test
+ public void request_uri_uses_local_port() {
+ HttpRequest request = HttpRequestFactory.newJDiscRequest(
+ new MockContainer(),
+ createMockRequest("https", "example.com", "/search", "query=value"));
+ assertEquals(LOCAL_PORT, request.getUri().getPort());
+ }
+
+ private static HttpServletRequest createMockRequest(String scheme, String serverName, String path, String queryString) {
+ HttpServletRequest request = mock(HttpServletRequest.class);
+ HttpConnection connection = mock(HttpConnection.class);
+ JDiscServerConnector connector = mock(JDiscServerConnector.class);
+ when(connector.connectorConfig()).thenReturn(new ConnectorConfig(new ConnectorConfig.Builder().listenPort(LOCAL_PORT)));
+ when(connector.getLocalPort()).thenReturn(LOCAL_PORT);
+ when(connection.getCreatedTimeStamp()).thenReturn(System.currentTimeMillis());
+ when(connection.getConnector()).thenReturn(connector);
+ when(request.getAttribute("org.eclipse.jetty.server.HttpConnection")).thenReturn(connection);
+ when(request.getProtocol()).thenReturn("HTTP/1.1");
+ when(request.getScheme()).thenReturn(scheme);
+ when(request.getServerName()).thenReturn(serverName);
+ when(request.getRemoteAddr()).thenReturn("127.0.0.1");
+ when(request.getRemotePort()).thenReturn(1234);
+ when(request.getLocalPort()).thenReturn(LOCAL_PORT);
+ when(request.getMethod()).thenReturn("GET");
+ when(request.getQueryString()).thenReturn(queryString);
+ when(request.getRequestURI()).thenReturn(path);
+ return request;
+ }
+
+ private static final class MockContainer implements CurrentContainer {
+
+ @Override
+ public Container newReference(URI uri) {
+ return new Container() {
+
+ @Override
+ public RequestHandler resolveHandler(com.yahoo.jdisc.Request request) {
+ return null;
+ }
+
+ @Override
+ public <T> T getInstance(Key<T> tKey) {
+ return null;
+ }
+
+ @Override
+ public <T> T getInstance(Class<T> tClass) {
+ return null;
+ }
+
+ @Override
+ public ResourceReference refer() {
+ return References.NOOP_REFERENCE;
+ }
+
+ @Override
+ public void release() {
+
+ }
+
+ @Override
+ public long currentTimeMillis() {
+ return 0;
+ }
+ };
+ }
+ }
+
+}
diff --git a/container-core/src/test/java/com/yahoo/jdisc/http/server/jetty/HttpResponseStatisticsCollectorTest.java b/container-core/src/test/java/com/yahoo/jdisc/http/server/jetty/HttpResponseStatisticsCollectorTest.java
new file mode 100644
index 00000000000..bb92d75bed5
--- /dev/null
+++ b/container-core/src/test/java/com/yahoo/jdisc/http/server/jetty/HttpResponseStatisticsCollectorTest.java
@@ -0,0 +1,221 @@
+// 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.server.jetty.HttpResponseStatisticsCollector.StatisticsEntry;
+import org.eclipse.jetty.http.HttpFields;
+import org.eclipse.jetty.http.HttpURI;
+import org.eclipse.jetty.http.HttpVersion;
+import org.eclipse.jetty.http.MetaData;
+import org.eclipse.jetty.http.MetaData.Response;
+import org.eclipse.jetty.server.AbstractConnector;
+import org.eclipse.jetty.server.Connector;
+import org.eclipse.jetty.server.HttpChannel;
+import org.eclipse.jetty.server.HttpConfiguration;
+import org.eclipse.jetty.server.HttpTransport;
+import org.eclipse.jetty.server.Request;
+import org.eclipse.jetty.server.Server;
+import org.eclipse.jetty.server.handler.AbstractHandler;
+import org.eclipse.jetty.util.Callback;
+import org.junit.Before;
+import org.junit.Test;
+
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.util.List;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.equalTo;
+
+/**
+ * @author ollivir
+ */
+public class HttpResponseStatisticsCollectorTest {
+
+ private Connector connector;
+ private List<String> monitoringPaths = List.of("/status.html");
+ private List<String> searchPaths = List.of("/search");
+ private HttpResponseStatisticsCollector collector = new HttpResponseStatisticsCollector(monitoringPaths, searchPaths);
+ private int httpResponseCode = 500;
+
+ @Test
+ public void statistics_are_aggregated_by_category() {
+ testRequest("http", 300, "GET");
+ testRequest("http", 301, "GET");
+ testRequest("http", 200, "GET");
+
+ var stats = collector.takeStatistics();
+ assertStatisticsEntryPresent(stats, "http", "GET", MetricDefinitions.RESPONSES_2XX, 1L);
+ assertStatisticsEntryPresent(stats, "http", "GET", MetricDefinitions.RESPONSES_3XX, 2L);
+ }
+
+ @Test
+ public void statistics_are_grouped_by_http_method_and_scheme() {
+ testRequest("http", 200, "GET");
+ testRequest("http", 200, "PUT");
+ testRequest("http", 200, "POST");
+ testRequest("http", 200, "POST");
+ testRequest("http", 404, "GET");
+ testRequest("https", 404, "GET");
+ testRequest("https", 200, "POST");
+ testRequest("https", 200, "POST");
+ testRequest("https", 200, "POST");
+ testRequest("https", 200, "POST");
+
+ var stats = collector.takeStatistics();
+ assertStatisticsEntryPresent(stats, "http", "GET", MetricDefinitions.RESPONSES_2XX, 1L);
+ assertStatisticsEntryPresent(stats, "http", "GET", MetricDefinitions.RESPONSES_4XX, 1L);
+ assertStatisticsEntryPresent(stats, "http", "PUT", MetricDefinitions.RESPONSES_2XX, 1L);
+ assertStatisticsEntryPresent(stats, "http", "POST", MetricDefinitions.RESPONSES_2XX, 2L);
+ assertStatisticsEntryPresent(stats, "https", "GET", MetricDefinitions.RESPONSES_4XX, 1L);
+ assertStatisticsEntryPresent(stats, "https", "POST", MetricDefinitions.RESPONSES_2XX, 4L);
+ }
+
+ @Test
+ public void statistics_include_grouped_and_single_statuscodes() {
+ testRequest("http", 401, "GET");
+ testRequest("http", 404, "GET");
+ testRequest("http", 403, "GET");
+
+ var stats = collector.takeStatistics();
+ assertStatisticsEntryPresent(stats, "http", "GET", MetricDefinitions.RESPONSES_4XX, 3L);
+ assertStatisticsEntryPresent(stats, "http", "GET", MetricDefinitions.RESPONSES_401, 1L);
+ assertStatisticsEntryPresent(stats, "http", "GET", MetricDefinitions.RESPONSES_403, 1L);
+
+ }
+
+ @Test
+ public void retrieving_statistics_resets_the_counters() {
+ testRequest("http", 200, "GET");
+ testRequest("http", 200, "GET");
+
+ var stats = collector.takeStatistics();
+ assertStatisticsEntryPresent(stats, "http", "GET", MetricDefinitions.RESPONSES_2XX, 2L);
+
+ testRequest("http", 200, "GET");
+
+ stats = collector.takeStatistics();
+ assertStatisticsEntryPresent(stats, "http", "GET", MetricDefinitions.RESPONSES_2XX, 1L);
+ }
+
+ @Test
+ public void statistics_include_request_type_dimension() {
+ testRequest("http", 200, "GET", "/search");
+ testRequest("http", 200, "POST", "/search");
+ testRequest("http", 200, "POST", "/feed");
+ testRequest("http", 200, "GET", "/status.html?foo=bar");
+
+ var stats = collector.takeStatistics();
+ assertStatisticsEntryWithRequestTypePresent(stats, "http", "GET", MetricDefinitions.RESPONSES_2XX, "monitoring", 1L);
+ assertStatisticsEntryWithRequestTypePresent(stats, "http", "GET", MetricDefinitions.RESPONSES_2XX, "read", 1L);
+ assertStatisticsEntryWithRequestTypePresent(stats, "http", "POST", MetricDefinitions.RESPONSES_2XX, "read", 1L);
+ assertStatisticsEntryWithRequestTypePresent(stats, "http", "POST", MetricDefinitions.RESPONSES_2XX, "write", 1L);
+
+ testRequest("http", 200, "GET");
+
+ stats = collector.takeStatistics();
+ assertStatisticsEntryPresent(stats, "http", "GET", MetricDefinitions.RESPONSES_2XX, 1L);
+ }
+
+ @Test
+ public void request_type_can_be_set_explicitly() {
+ testRequest("http", 200, "GET", "/search", com.yahoo.jdisc.Request.RequestType.WRITE);
+
+ var stats = collector.takeStatistics();
+ assertStatisticsEntryWithRequestTypePresent(stats, "http", "GET", MetricDefinitions.RESPONSES_2XX, "write", 1L);
+ }
+
+ @Before
+ public void initializeCollector() throws Exception {
+ Server server = new Server();
+ connector = new AbstractConnector(server, null, null, null, 0) {
+ @Override
+ protected void accept(int acceptorID) throws IOException, InterruptedException {
+ }
+
+ @Override
+ public Object getTransport() {
+ return null;
+ }
+ };
+ collector.setHandler(new AbstractHandler() {
+ @Override
+ public void handle(String target, Request baseRequest, HttpServletRequest request, HttpServletResponse response)
+ throws IOException, ServletException {
+ baseRequest.setHandled(true);
+ baseRequest.getResponse().setStatus(httpResponseCode);
+ }
+ });
+ server.setHandler(collector);
+ server.start();
+ }
+
+ private Request testRequest(String scheme, int responseCode, String httpMethod) {
+ return testRequest(scheme, responseCode, httpMethod, "foo/bar");
+ }
+ private Request testRequest(String scheme, int responseCode, String httpMethod, String path) {
+ return testRequest(scheme, responseCode, httpMethod, path, null);
+ }
+ private Request testRequest(String scheme, int responseCode, String httpMethod, String path,
+ com.yahoo.jdisc.Request.RequestType explicitRequestType) {
+ HttpChannel channel = new HttpChannel(connector, new HttpConfiguration(), null, new DummyTransport());
+ MetaData.Request metaData = new MetaData.Request(httpMethod, new HttpURI(scheme + "://" + path), HttpVersion.HTTP_1_1, new HttpFields());
+ Request req = channel.getRequest();
+ if (explicitRequestType != null)
+ req.setAttribute("requestType", explicitRequestType);
+ req.setMetaData(metaData);
+
+ this.httpResponseCode = responseCode;
+ channel.handle();
+ return req;
+ }
+
+ private static void assertStatisticsEntryPresent(List<StatisticsEntry> result, String scheme, String method, String name, long expectedValue) {
+ long value = result.stream()
+ .filter(entry -> entry.method.equals(method) && entry.scheme.equals(scheme) && entry.name.equals(name))
+ .mapToLong(entry -> entry.value)
+ .findAny()
+ .orElseThrow(() -> new AssertionError(String.format("Not matching entry in result (scheme=%s, method=%s, name=%s)", scheme, method, name)));
+ assertThat(value, equalTo(expectedValue));
+ }
+
+ private static void assertStatisticsEntryWithRequestTypePresent(List<StatisticsEntry> result, String scheme, String method, String name, String requestType, long expectedValue) {
+ long value = result.stream()
+ .filter(entry -> entry.method.equals(method) && entry.scheme.equals(scheme) && entry.name.equals(name) && entry.requestType.equals(requestType))
+ .mapToLong(entry -> entry.value)
+ .reduce(Long::sum)
+ .orElseThrow(() -> new AssertionError(String.format("Not matching entry in result (scheme=%s, method=%s, name=%s, type=%s)", scheme, method, name, requestType)));
+ assertThat(value, equalTo(expectedValue));
+ }
+
+ private final class DummyTransport implements HttpTransport {
+ @Override
+ public void send(Response info, boolean head, ByteBuffer content, boolean lastContent, Callback callback) {
+ callback.succeeded();
+ }
+
+ @Override
+ public boolean isPushSupported() {
+ return false;
+ }
+
+ @Override
+ public boolean isOptimizedForDirectBuffers() {
+ return false;
+ }
+
+ @Override
+ public void push(MetaData.Request request) {
+ }
+
+ @Override
+ public void onCompleted() {
+ }
+
+ @Override
+ public void abort(Throwable failure) {
+ }
+ }
+}
diff --git a/container-core/src/test/java/com/yahoo/jdisc/http/server/jetty/HttpServerConformanceTest.java b/container-core/src/test/java/com/yahoo/jdisc/http/server/jetty/HttpServerConformanceTest.java
new file mode 100644
index 00000000000..5659dfc2d3c
--- /dev/null
+++ b/container-core/src/test/java/com/yahoo/jdisc/http/server/jetty/HttpServerConformanceTest.java
@@ -0,0 +1,847 @@
+// 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.AbstractModule;
+import com.google.inject.Module;
+import com.google.inject.util.Modules;
+import com.yahoo.container.logging.ConnectionLog;
+import com.yahoo.container.logging.RequestLog;
+import com.yahoo.jdisc.http.ServerConfig;
+import com.yahoo.jdisc.http.ServletPathsConfig;
+import com.yahoo.jdisc.http.guiceModules.ConnectorFactoryRegistryModule;
+import com.yahoo.jdisc.test.ServerProviderConformanceTest;
+import org.apache.http.HttpResponse;
+import org.apache.http.HttpVersion;
+import org.apache.http.ProtocolVersion;
+import org.apache.http.client.HttpClient;
+import org.apache.http.client.methods.HttpGet;
+import org.apache.http.client.methods.HttpPost;
+import org.apache.http.client.methods.HttpUriRequest;
+import org.apache.http.entity.StringEntity;
+import org.apache.http.impl.client.HttpClientBuilder;
+import org.apache.http.util.EntityUtils;
+import org.hamcrest.Description;
+import org.hamcrest.Matcher;
+import org.hamcrest.TypeSafeMatcher;
+import org.junit.AfterClass;
+import org.junit.BeforeClass;
+import org.junit.Test;
+
+import java.net.URI;
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
+import java.util.Collections;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+import java.util.regex.Pattern;
+
+import static com.yahoo.jdisc.Response.Status.INTERNAL_SERVER_ERROR;
+import static com.yahoo.jdisc.Response.Status.NOT_FOUND;
+import static com.yahoo.jdisc.Response.Status.OK;
+import static org.apache.http.HttpStatus.SC_INTERNAL_SERVER_ERROR;
+import static org.apache.http.HttpStatus.SC_NOT_FOUND;
+import static org.cthul.matchers.CthulMatchers.containsPattern;
+import static org.cthul.matchers.CthulMatchers.matchesPattern;
+import static org.hamcrest.CoreMatchers.any;
+import static org.hamcrest.CoreMatchers.anyOf;
+import static org.hamcrest.CoreMatchers.containsString;
+import static org.hamcrest.CoreMatchers.is;
+import static org.hamcrest.MatcherAssert.assertThat;
+
+/**
+ * @author Simon Thoresen Hult
+ */
+public class HttpServerConformanceTest extends ServerProviderConformanceTest {
+
+ private static final Logger log = Logger.getLogger(HttpServerConformanceTest.class.getName());
+
+ private static final String REQUEST_CONTENT = "myRequestContent";
+ private static final String RESPONSE_CONTENT = "myResponseContent";
+
+ @SuppressWarnings("LoggerInitializedWithForeignClass")
+ private static Logger httpRequestDispatchLogger = Logger.getLogger(HttpRequestDispatch.class.getName());
+ private static Level httpRequestDispatchLoggerOriginalLevel;
+
+ /*
+ * Reduce logging of every stack trace for {@link ServerProviderConformanceTest.ConformanceException} thrown.
+ * This makes the log more readable and the test faster as well.
+ */
+ @BeforeClass
+ public static void reduceExcessiveLogging() {
+ httpRequestDispatchLoggerOriginalLevel = httpRequestDispatchLogger.getLevel();
+ httpRequestDispatchLogger.setLevel(Level.SEVERE);
+ }
+
+ @AfterClass
+ public static void restoreExcessiveLogging() {
+ httpRequestDispatchLogger.setLevel(httpRequestDispatchLoggerOriginalLevel);
+ }
+
+ @AfterClass
+ public static void reportDiagnostics() {
+ System.out.println(
+ "After " + HttpServerConformanceTest.class.getSimpleName()
+ + ": #threads=" + Thread.getAllStackTraces().size());
+ }
+
+ @Override
+ @Test
+ public void testContainerNotReadyException() throws Throwable {
+ new TestRunner().expect(errorWithReason(is(SC_INTERNAL_SERVER_ERROR), containsString("Container not ready.")))
+ .execute();
+ }
+
+ @Override
+ @Test
+ public void testBindingSetNotFoundException() throws Throwable {
+ new TestRunner().expect(errorWithReason(is(SC_NOT_FOUND), containsString("No binding set named 'unknown'.")))
+ .execute();
+ }
+
+ @Override
+ @Test
+ public void testNoBindingSetSelectedException() throws Throwable {
+ final Pattern reasonPattern = Pattern.compile(".*No binding set selected for URI 'http://.+/status.html'\\.");
+ new TestRunner().expect(errorWithReason(is(SC_INTERNAL_SERVER_ERROR), matchesPattern(reasonPattern)))
+ .execute();
+ }
+
+ @Override
+ @Test
+ public void testBindingNotFoundException() throws Throwable {
+ final Pattern contentPattern = Pattern.compile("No binding for URI 'http://.+/status.html'\\.");
+ new TestRunner().expect(errorWithReason(is(NOT_FOUND), containsPattern(contentPattern)))
+ .execute();
+ }
+
+ @Override
+ @Test
+ public void testRequestHandlerWithSyncCloseResponse() throws Throwable {
+ new TestRunner().expect(success())
+ .execute();
+ }
+
+ @Override
+ @Test
+ public void testRequestHandlerWithSyncWriteResponse() throws Throwable {
+ new TestRunner().expect(success())
+ .execute();
+ }
+
+ @Override
+ @Test
+ public void testRequestHandlerWithSyncHandleResponse() throws Throwable {
+ new TestRunner().expect(success())
+ .execute();
+ }
+
+ @Override
+ @Test
+ public void testRequestHandlerWithAsyncHandleResponse() throws Throwable {
+ new TestRunner().expect(success())
+ .execute();
+ }
+
+ @Override
+ @Test
+ public void testRequestException() throws Throwable {
+ new TestRunner().expect(serverError())
+ .execute();
+ }
+
+ @Override
+ @Test
+ public void testRequestExceptionWithSyncCloseResponse() throws Throwable {
+ new TestRunner().expect(success())
+ .execute();
+ }
+
+ @Override
+ @Test
+ public void testRequestExceptionWithSyncWriteResponse() throws Throwable {
+ new TestRunner().expect(success())
+ .execute();
+ }
+
+ @Override
+ @Test
+ public void testRequestNondeterministicExceptionWithSyncHandleResponse() throws Throwable {
+ new TestRunner().expect(anyOf(success(), serverError()))
+ .execute();
+ }
+
+ @Override
+ @Test
+ public void testRequestExceptionBeforeResponseWriteWithSyncHandleResponse() throws Throwable {
+ new TestRunner().expect(serverError())
+ .execute();
+ }
+
+ @Override
+ @Test
+ public void testRequestExceptionAfterResponseWriteWithSyncHandleResponse() throws Throwable {
+ new TestRunner().expect(success())
+ .execute();
+ }
+
+ @Override
+ @Test
+ public void testRequestNondeterministicExceptionWithAsyncHandleResponse() throws Throwable {
+ new TestRunner().expect(anyOf(successNoContent(), serverError()))
+ .execute();
+ }
+
+ @Override
+ @Test
+ public void testRequestExceptionBeforeResponseWriteWithAsyncHandleResponse() throws Throwable {
+ new TestRunner().expect(serverError())
+ .execute();
+ }
+
+ @Override
+ @Test
+ public void testRequestExceptionAfterResponseCloseNoContentWithAsyncHandleResponse() throws Throwable {
+ new TestRunner().expect(successNoContent())
+ .execute();
+ }
+
+ @Override
+ @Test
+ public void testRequestExceptionAfterResponseWriteWithAsyncHandleResponse() throws Throwable {
+ new TestRunner().expect(success())
+ .execute();
+ }
+
+ @Override
+ @Test
+ public void testRequestContentWriteWithSyncCompletion() throws Throwable {
+ new TestRunner().expect(success())
+ .execute();
+ }
+
+ @Override
+ @Test
+ public void testRequestContentWriteWithAsyncCompletion() throws Throwable {
+ new TestRunner().expect(success())
+ .execute();
+ }
+
+ @Override
+ @Test
+ public void testRequestContentWriteWithNondeterministicSyncFailure() throws Throwable {
+ new TestRunner().expect(anyOf(success(), serverError()))
+ .execute();
+ }
+
+ @Override
+ @Test
+ public void testRequestContentWriteWithSyncFailureBeforeResponseWrite() throws Throwable {
+ new TestRunner().expect(serverError())
+ .execute();
+ }
+
+ @Override
+ @Test
+ public void testRequestContentWriteWithSyncFailureAfterResponseWrite() throws Throwable {
+ new TestRunner().expect(success())
+ .execute();
+ }
+
+ @Override
+ @Test
+ public void testRequestContentWriteWithNondeterministicAsyncFailure() throws Throwable {
+ new TestRunner().expect(anyOf(success(), serverError()))
+ .execute();
+ }
+
+ @Override
+ @Test
+ public void testRequestContentWriteWithAsyncFailureBeforeResponseWrite() throws Throwable {
+ new TestRunner().expect(serverError())
+ .execute();
+ }
+
+ @Override
+ @Test
+ public void testRequestContentWriteWithAsyncFailureAfterResponseWrite() throws Throwable {
+ new TestRunner().expect(success())
+ .execute();
+ }
+
+ @Override
+ @Test
+ public void testRequestContentWriteWithAsyncFailureAfterResponseCloseNoContent() throws Throwable {
+ new TestRunner().expect(successNoContent())
+ .execute();
+ }
+
+ @Override
+ @Test
+ public void testRequestContentWriteNondeterministicException() throws Throwable {
+ new TestRunner().expect(anyOf(success(), serverError(), successNoContent()))
+ .execute();
+ }
+
+ @Override
+ @Test
+ public void testRequestContentWriteExceptionBeforeResponseWrite() throws Throwable {
+ new TestRunner().expect(serverError())
+ .execute();
+ }
+
+ @Override
+ @Test
+ public void testRequestContentWriteExceptionAfterResponseWrite() throws Throwable {
+ new TestRunner().expect(success())
+ .execute();
+ }
+
+ @Override
+ @Test
+ public void testRequestContentWriteExceptionAfterResponseCloseNoContent() throws Throwable {
+ new TestRunner().expect(successNoContent())
+ .execute();
+ }
+
+ @Override
+ @Test
+ public void testRequestContentWriteNondeterministicExceptionWithSyncCompletion() throws Throwable {
+ new TestRunner().expect(anyOf(success(), serverError()))
+ .execute();
+ }
+
+ @Override
+ @Test
+ public void testRequestContentWriteExceptionBeforeResponseWriteWithSyncCompletion() throws Throwable {
+ new TestRunner().expect(serverError())
+ .execute();
+ }
+
+ @Override
+ @Test
+ public void testRequestContentWriteExceptionAfterResponseWriteWithSyncCompletion() throws Throwable {
+ new TestRunner().expect(anyOf(success(), successNoContent()))
+ .execute();
+ }
+
+ @Override
+ @Test
+ public void testRequestContentWriteExceptionAfterResponseCloseNoContentWithSyncCompletion() throws Throwable {
+ new TestRunner().expect(anyOf(success(), successNoContent()))
+ .execute();
+ }
+
+ @Override
+ @Test
+ public void testRequestContentWriteNondeterministicExceptionWithAsyncCompletion() throws Throwable {
+ new TestRunner()
+ .expect(anyOf(success(), successNoContent(), serverError()))
+ .execute();
+ }
+
+ @Override
+ @Test
+ public void testRequestContentWriteExceptionBeforeResponseWriteWithAsyncCompletion() throws Throwable {
+ new TestRunner().expect(serverError())
+ .execute();
+ }
+
+ @Override
+ @Test
+ public void testRequestContentWriteExceptionAfterResponseWriteWithAsyncCompletion() throws Throwable {
+ new TestRunner().expect(success())
+ .execute();
+ }
+
+ @Override
+ @Test
+ public void testRequestContentWriteExceptionAfterResponseCloseNoContentWithAsyncCompletion() throws Throwable {
+ new TestRunner().expect(successNoContent())
+ .execute();
+ }
+
+ @Override
+ @Test
+ public void testRequestContentWriteExceptionWithNondeterministicSyncFailure() throws Throwable {
+ new TestRunner().expect(anyOf(success(), successNoContent(), serverError()))
+ .execute();
+ }
+
+ @Override
+ @Test
+ public void testRequestContentWriteExceptionWithSyncFailureBeforeResponseWrite() throws Throwable {
+ new TestRunner().expect(serverError())
+ .execute();
+ }
+
+ @Override
+ @Test
+ public void testRequestContentWriteExceptionWithSyncFailureAfterResponseWrite() throws Throwable {
+ new TestRunner().expect(success())
+ .execute();
+ }
+
+ @Override
+ @Test
+ public void testRequestContentWriteExceptionWithSyncFailureAfterResponseCloseNoContent() throws Throwable {
+ new TestRunner().expect(successNoContent())
+ .execute();
+ }
+
+ @Override
+ @Test
+ public void testRequestContentWriteExceptionWithNondeterministicAsyncFailure() throws Throwable {
+ new TestRunner().expect(anyOf(success(), serverError()))
+ .execute();
+ }
+
+ @Override
+ @Test
+ public void testRequestContentWriteExceptionWithAsyncFailureBeforeResponseWrite() throws Throwable {
+ new TestRunner().expect(serverError())
+ .execute();
+ }
+
+ @Override
+ @Test
+ public void testRequestContentWriteExceptionWithAsyncFailureAfterResponseWrite() throws Throwable {
+ new TestRunner().expect(success())
+ .execute();
+ }
+
+ @Override
+ @Test
+ public void testRequestContentWriteExceptionWithAsyncFailureAfterResponseCloseNoContent() throws Throwable {
+ new TestRunner().expect(successNoContent())
+ .execute();
+ }
+
+ @Override
+ @Test
+ public void testRequestContentCloseWithSyncCompletion() throws Throwable {
+ new TestRunner().expect(success())
+ .execute();
+ }
+
+ @Override
+ @Test
+ public void testRequestContentCloseWithAsyncCompletion() throws Throwable {
+ new TestRunner().expect(success())
+ .execute();
+ }
+
+ @Override
+ @Test
+ public void testRequestContentCloseWithNondeterministicSyncFailure() throws Throwable {
+ new TestRunner().expect(anyOf(success(), successNoContent(), serverError()))
+ .execute();
+ }
+
+ @Override
+ @Test
+ public void testRequestContentCloseWithSyncFailureBeforeResponseWrite() throws Throwable {
+ new TestRunner().expect(serverError())
+ .execute();
+ }
+
+ @Override
+ @Test
+ public void testRequestContentCloseWithSyncFailureAfterResponseWrite() throws Throwable {
+ new TestRunner().expect(success())
+ .execute();
+ }
+
+ @Override
+ @Test
+ public void testRequestContentCloseWithSyncFailureAfterResponseCloseNoContent() throws Throwable {
+ new TestRunner().expect(successNoContent())
+ .execute();
+ }
+
+ @Override
+ @Test
+ public void testRequestContentCloseWithNondeterministicAsyncFailure() throws Throwable {
+ new TestRunner().expect(anyOf(success(), successNoContent(), serverError()))
+ .execute();
+ }
+
+ @Override
+ @Test
+ public void testRequestContentCloseWithAsyncFailureBeforeResponseWrite() throws Throwable {
+ new TestRunner().expect(serverError())
+ .execute();
+ }
+
+ @Override
+ @Test
+ public void testRequestContentCloseWithAsyncFailureAfterResponseWrite() throws Throwable {
+ new TestRunner().expect(success())
+ .execute();
+ }
+
+ @Override
+ @Test
+ public void testRequestContentCloseWithAsyncFailureAfterResponseCloseNoContent() throws Throwable {
+ new TestRunner().expect(successNoContent())
+ .execute();
+ }
+
+ @Override
+ @Test
+ public void testRequestContentCloseNondeterministicException() throws Throwable {
+ new TestRunner().expect(anyOf(success(), successNoContent(), serverError()))
+ .execute();
+ }
+
+ @Override
+ @Test
+ public void testRequestContentCloseExceptionBeforeResponseWrite() throws Throwable {
+ new TestRunner().expect(serverError())
+ .execute();
+ }
+
+ @Override
+ @Test
+ public void testRequestContentCloseExceptionAfterResponseWrite() throws Throwable {
+ new TestRunner().expect(success())
+ .execute();
+ }
+
+ @Override
+ @Test
+ public void testRequestContentCloseExceptionAfterResponseCloseNoContent() throws Throwable {
+ new TestRunner().expect(successNoContent())
+ .execute();
+ }
+
+ @Override
+ @Test
+ public void testRequestContentCloseNondeterministicExceptionWithSyncCompletion() throws Throwable {
+ new TestRunner().expect(anyOf(success(), serverError(), successNoContent()))
+ .execute();
+ }
+
+ @Override
+ @Test
+ public void testRequestContentCloseExceptionBeforeResponseWriteWithSyncCompletion() throws Throwable {
+ new TestRunner().expect(serverError())
+ .execute();
+ }
+
+ @Override
+ @Test
+ public void testRequestContentCloseExceptionAfterResponseWriteWithSyncCompletion() throws Throwable {
+ new TestRunner().expect(success())
+ .execute();
+ }
+
+ @Override
+ @Test
+ public void testRequestContentCloseExceptionAfterResponseCloseNoContentWithSyncCompletion() throws Throwable {
+ new TestRunner().expect(successNoContent())
+ .execute();
+ }
+
+ @Override
+ @Test
+ public void testRequestContentCloseNondeterministicExceptionWithAsyncCompletion() throws Throwable {
+ new TestRunner().expect(anyOf(success(), serverError(), successNoContent()))
+ .execute();
+ }
+
+ @Override
+ @Test
+ public void testRequestContentCloseExceptionBeforeResponseWriteWithAsyncCompletion() throws Throwable {
+ new TestRunner().expect(serverError())
+ .execute();
+ }
+
+ @Override
+ @Test
+ public void testRequestContentCloseExceptionAfterResponseWriteWithAsyncCompletion() throws Throwable {
+ new TestRunner().expect(success())
+ .execute();
+ }
+
+ @Override
+ @Test
+ public void testRequestContentCloseExceptionAfterResponseCloseNoContentWithAsyncCompletion() throws Throwable {
+ new TestRunner().expect(successNoContent())
+ .execute();
+ }
+
+ @Override
+ @Test
+ public void testRequestContentCloseNondeterministicExceptionWithSyncFailure() throws Throwable {
+ new TestRunner().expect(anyOf(success(), successNoContent(), serverError()))
+ .execute();
+ }
+
+ @Override
+ @Test
+ public void testRequestContentCloseExceptionBeforeResponseWriteWithSyncFailure() throws Throwable {
+ new TestRunner().expect(serverError())
+ .execute();
+ }
+
+ @Override
+ @Test
+ public void testRequestContentCloseExceptionAfterResponseWriteWithSyncFailure() throws Throwable {
+ new TestRunner().expect(success())
+ .execute();
+ }
+
+ @Override
+ @Test
+ public void testRequestContentCloseExceptionAfterResponseCloseNoContentWithSyncFailure() throws Throwable {
+ new TestRunner().expect(successNoContent())
+ .execute();
+ }
+
+ @Override
+ @Test
+ public void testRequestContentCloseNondeterministicExceptionWithAsyncFailure() throws Throwable {
+ new TestRunner().expect(anyOf(success(), successNoContent(), serverError()))
+ .execute();
+ }
+
+ @Override
+ @Test
+ public void testRequestContentCloseExceptionBeforeResponseWriteWithAsyncFailure() throws Throwable {
+ new TestRunner().expect(serverError())
+ .execute();
+ }
+
+ @Override
+ @Test
+ public void testRequestContentCloseExceptionAfterResponseWriteWithAsyncFailure() throws Throwable {
+ new TestRunner().expect(success())
+ .execute();
+ }
+
+ @Override
+ @Test
+ public void testRequestContentCloseExceptionAfterResponseCloseNoContentWithAsyncFailure() throws Throwable {
+ new TestRunner().expect(successNoContent())
+ .execute();
+ }
+
+ @Override
+ @Test
+ public void testResponseWriteCompletionException() throws Throwable {
+ new TestRunner().expect(success())
+ .execute();
+ }
+
+ @Override
+ @Test
+ public void testResponseCloseCompletionException() throws Throwable {
+ new TestRunner().expect(success())
+ .execute();
+ }
+
+ @Override
+ @Test
+ public void testResponseCloseCompletionExceptionNoContent() throws Throwable {
+ new TestRunner().expect(successNoContent())
+ .execute();
+ }
+
+ private static Matcher<ResponseGist> success() {
+ final Matcher<Integer> expectedStatusCode = is(OK);
+ final Matcher<String> expectedReasonPhrase = is("OK");
+ final Matcher<String> expectedContent = is(RESPONSE_CONTENT);
+ return responseMatcher(expectedStatusCode, expectedReasonPhrase, expectedContent);
+ }
+
+ private static Matcher<ResponseGist> successNoContent() {
+ final Matcher<Integer> expectedStatusCode = is(OK);
+ final Matcher<String> expectedReasonPhrase = is("OK");
+ final Matcher<String> expectedContent = is("");
+ return responseMatcher(expectedStatusCode, expectedReasonPhrase, expectedContent);
+ }
+
+ private static Matcher<ResponseGist> serverError() {
+ final Matcher<Integer> expectedStatusCode = is(INTERNAL_SERVER_ERROR);
+ final Matcher<String> expectedReasonPhrase = any(String.class);
+ final Matcher<String> expectedContent = containsString(ConformanceException.class.getSimpleName());
+ return responseMatcher(expectedStatusCode, expectedReasonPhrase, expectedContent);
+ }
+
+ private static Matcher<ResponseGist> errorWithReason(
+ final Matcher<Integer> expectedStatusCode, final Matcher<String> expectedReasonPhrase) {
+ final Matcher<String> expectedContent = any(String.class);
+ return responseMatcher(expectedStatusCode, expectedReasonPhrase, expectedContent);
+ }
+
+ private static Matcher<ResponseGist> responseMatcher(
+ final Matcher<Integer> expectedStatusCode,
+ final Matcher<String> expectedReasonPhrase,
+ final Matcher<String> expectedContent) {
+ return new TypeSafeMatcher<ResponseGist>() {
+ @Override
+ public void describeTo(final Description description) {
+ description.appendText("status code ");
+ expectedStatusCode.describeTo(description);
+ description.appendText(", reason ");
+ expectedReasonPhrase.describeTo(description);
+ description.appendText(" and content ");
+ expectedContent.describeTo(description);
+ }
+
+ @Override
+ protected void describeMismatchSafely(
+ final ResponseGist response, final Description mismatchDescription) {
+ mismatchDescription.appendText(" status code was ").appendValue(response.getStatusCode())
+ .appendText(", reason was ").appendValue(response.getReasonPhrase())
+ .appendText(" and content was ").appendValue(response.getContent());
+ }
+
+ @Override
+ protected boolean matchesSafely(final ResponseGist response) {
+ return expectedStatusCode.matches(response.getStatusCode())
+ && expectedReasonPhrase.matches(response.getReasonPhrase())
+ && expectedContent.matches(response.getContent());
+ }
+ };
+ }
+
+ private static class ResponseGist {
+ private final int statusCode;
+ private final String content;
+ private String reasonPhrase;
+
+ public ResponseGist(int statusCode, String reasonPhrase, String content) {
+ this.statusCode = statusCode;
+ this.reasonPhrase = reasonPhrase;
+ this.content = content;
+ }
+
+ public int getStatusCode() {
+ return statusCode;
+ }
+
+ public String getContent() {
+ return content;
+ }
+
+ public String getReasonPhrase() {
+ return reasonPhrase;
+ }
+
+ @Override
+ public String toString() {
+ return "ResponseGist {"
+ + " statusCode=" + statusCode
+ + " reasonPhrase=" + reasonPhrase
+ + " content=" + content
+ + " }";
+ }
+ }
+
+ private class TestRunner implements Adapter<JettyHttpServer, ClientProxy, Future<HttpResponse>> {
+
+ private Matcher<ResponseGist> expectedResponse = null;
+ HttpVersion requestVersion;
+ private final ExecutorService executorService = Executors.newSingleThreadExecutor();
+
+ void execute() throws Throwable {
+ requestVersion = HttpVersion.HTTP_1_0;
+ runTest(this);
+
+ requestVersion = HttpVersion.HTTP_1_1;
+ runTest(this);
+
+ executorService.shutdown();
+ }
+
+ TestRunner expect(final Matcher<ResponseGist> matcher) {
+ expectedResponse = matcher;
+ return this;
+ }
+
+ @Override
+ public Module newConfigModule() {
+ return Modules.combine(
+ new AbstractModule() {
+ @Override
+ protected void configure() {
+ bind(FilterBindings.class)
+ .toInstance(new FilterBindings.Builder().build());
+ bind(ServerConfig.class)
+ .toInstance(new ServerConfig(new ServerConfig.Builder()));
+ bind(ServletPathsConfig.class)
+ .toInstance(new ServletPathsConfig(new ServletPathsConfig.Builder()));
+ bind(ConnectionLog.class)
+ .toInstance(new VoidConnectionLog());
+ bind(RequestLog.class)
+ .toInstance(new VoidRequestLog());
+ }
+ },
+ new ConnectorFactoryRegistryModule());
+ }
+
+ @Override
+ public Class<JettyHttpServer> getServerProviderClass() {
+ return JettyHttpServer.class;
+ }
+
+ @Override
+ public ClientProxy newClient(final JettyHttpServer server) throws Throwable {
+ return new ClientProxy(server.getListenPort(), requestVersion);
+ }
+
+ @Override
+ public Future<HttpResponse> executeRequest(
+ final ClientProxy client,
+ final boolean withRequestContent) throws Throwable {
+ final HttpUriRequest request;
+ final URI requestUri = URI.create("http://localhost:" + client.listenPort + "/status.html");
+ if (!withRequestContent) {
+ HttpGet httpGet = new HttpGet(requestUri);
+ httpGet.setProtocolVersion(client.requestVersion);
+ request = httpGet;
+ } else {
+ final HttpPost post = new HttpPost(requestUri);
+ post.setEntity(new StringEntity(REQUEST_CONTENT, StandardCharsets.UTF_8));
+ post.setProtocolVersion(client.requestVersion);
+ request = post;
+ }
+ log.fine(() -> "executorService:"
+ + " .isShutDown()=" + executorService.isShutdown()
+ + " .isTerminated()=" + executorService.isTerminated());
+ return executorService.submit(() -> client.delegate.execute(request));
+ }
+
+ @Override
+ public Iterable<ByteBuffer> newResponseContent() {
+ return Collections.singleton(StandardCharsets.UTF_8.encode(RESPONSE_CONTENT));
+ }
+
+ @Override
+ public void validateResponse(final Future<HttpResponse> responseFuture) throws Throwable {
+ final HttpResponse response = responseFuture.get();
+ final ResponseGist responseGist = new ResponseGist(
+ response.getStatusLine().getStatusCode(),
+ response.getStatusLine().getReasonPhrase(),
+ EntityUtils.toString(response.getEntity(), StandardCharsets.UTF_8));
+ assertThat(responseGist, expectedResponse);
+ }
+ }
+
+ private static class ClientProxy {
+
+ final HttpClient delegate;
+ final int listenPort;
+ final ProtocolVersion requestVersion;
+
+ ClientProxy(final int listenPort, final HttpVersion requestVersion) {
+ this.delegate = HttpClientBuilder.create().build();
+ this.requestVersion = requestVersion;
+ this.listenPort = listenPort;
+ }
+ }
+}
diff --git a/container-core/src/test/java/com/yahoo/jdisc/http/server/jetty/HttpServerTest.java b/container-core/src/test/java/com/yahoo/jdisc/http/server/jetty/HttpServerTest.java
new file mode 100644
index 00000000000..c00525a3ddc
--- /dev/null
+++ b/container-core/src/test/java/com/yahoo/jdisc/http/server/jetty/HttpServerTest.java
@@ -0,0 +1,1201 @@
+// 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.AbstractModule;
+import com.google.inject.Module;
+import com.yahoo.container.logging.ConnectionLog;
+import com.yahoo.container.logging.ConnectionLogEntry;
+import com.yahoo.container.logging.ConnectionLogEntry.SslHandshakeFailure.ExceptionEntry;
+import com.yahoo.container.logging.RequestLog;
+import com.yahoo.container.logging.RequestLogEntry;
+import com.yahoo.jdisc.References;
+import com.yahoo.jdisc.Request;
+import com.yahoo.jdisc.Response;
+import com.yahoo.jdisc.application.BindingSetSelector;
+import com.yahoo.jdisc.application.MetricConsumer;
+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.ResponseDispatch;
+import com.yahoo.jdisc.handler.ResponseHandler;
+import com.yahoo.jdisc.http.ConnectorConfig;
+import com.yahoo.jdisc.http.ConnectorConfig.Throttling;
+import com.yahoo.jdisc.http.Cookie;
+import com.yahoo.jdisc.http.HttpRequest;
+import com.yahoo.jdisc.http.HttpResponse;
+import com.yahoo.jdisc.http.ServerConfig;
+import com.yahoo.jdisc.http.server.jetty.TestDrivers.TlsClientAuth;
+import com.yahoo.jdisc.service.BindingSetNotFoundException;
+import com.yahoo.security.KeyUtils;
+import com.yahoo.security.Pkcs10Csr;
+import com.yahoo.security.Pkcs10CsrBuilder;
+import com.yahoo.security.SslContextBuilder;
+import com.yahoo.security.X509CertificateBuilder;
+import com.yahoo.security.X509CertificateUtils;
+import com.yahoo.security.tls.TlsContext;
+import org.apache.http.conn.ssl.NoopHostnameVerifier;
+import org.apache.http.entity.ContentType;
+import org.apache.http.entity.mime.FormBodyPart;
+import org.apache.http.entity.mime.content.StringBody;
+import org.assertj.core.api.Assertions;
+import org.eclipse.jetty.client.HttpClient;
+import org.eclipse.jetty.client.ProxyProtocolClientConnectionFactory.V1;
+import org.eclipse.jetty.client.ProxyProtocolClientConnectionFactory.V2;
+import org.eclipse.jetty.client.api.ContentResponse;
+import org.eclipse.jetty.server.handler.AbstractHandlerContainer;
+import org.eclipse.jetty.util.ssl.SslContextFactory;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+
+import javax.net.ssl.SSLContext;
+import javax.net.ssl.SSLException;
+import javax.net.ssl.SSLHandshakeException;
+import javax.security.auth.x500.X500Principal;
+import java.io.IOException;
+import java.math.BigInteger;
+import java.net.BindException;
+import java.net.URI;
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.security.KeyPair;
+import java.security.PrivateKey;
+import java.security.cert.X509Certificate;
+import java.time.Duration;
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Map;
+import java.util.TreeMap;
+import java.util.UUID;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeUnit;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+import java.util.regex.Pattern;
+
+import static com.yahoo.jdisc.Response.Status.GATEWAY_TIMEOUT;
+import static com.yahoo.jdisc.Response.Status.INTERNAL_SERVER_ERROR;
+import static com.yahoo.jdisc.Response.Status.NOT_FOUND;
+import static com.yahoo.jdisc.Response.Status.OK;
+import static com.yahoo.jdisc.Response.Status.REQUEST_URI_TOO_LONG;
+import static com.yahoo.jdisc.Response.Status.UNAUTHORIZED;
+import static com.yahoo.jdisc.Response.Status.UNSUPPORTED_MEDIA_TYPE;
+import static com.yahoo.jdisc.http.HttpHeaders.Names.CONNECTION;
+import static com.yahoo.jdisc.http.HttpHeaders.Names.CONTENT_TYPE;
+import static com.yahoo.jdisc.http.HttpHeaders.Names.COOKIE;
+import static com.yahoo.jdisc.http.HttpHeaders.Names.X_DISABLE_CHUNKING;
+import static com.yahoo.jdisc.http.HttpHeaders.Values.APPLICATION_X_WWW_FORM_URLENCODED;
+import static com.yahoo.jdisc.http.HttpHeaders.Values.CLOSE;
+import static com.yahoo.jdisc.http.server.jetty.SimpleHttpClient.ResponseValidator;
+import static com.yahoo.security.KeyAlgorithm.EC;
+import static com.yahoo.security.SignatureAlgorithm.SHA256_WITH_ECDSA;
+import static org.cthul.matchers.CthulMatchers.containsPattern;
+import static org.cthul.matchers.CthulMatchers.matchesPattern;
+import static org.hamcrest.CoreMatchers.containsString;
+import static org.hamcrest.CoreMatchers.instanceOf;
+import static org.hamcrest.CoreMatchers.is;
+import static org.hamcrest.CoreMatchers.startsWith;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.anyOf;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotEquals;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+import static org.junit.Assume.assumeTrue;
+import static org.mockito.Mockito.atLeast;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * @author Oyvind Bakksjo
+ * @author Simon Thoresen Hult
+ * @author bjorncs
+ */
+public class HttpServerTest {
+
+ private static final Logger log = Logger.getLogger(HttpServerTest.class.getName());
+
+ @Rule
+ public TemporaryFolder tmpFolder = new TemporaryFolder();
+
+ @Test
+ public void requireThatServerCanListenToRandomPort() throws Exception {
+ final TestDriver driver = TestDrivers.newInstance(mockRequestHandler());
+ assertNotEquals(0, driver.server().getListenPort());
+ assertTrue(driver.close());
+ }
+
+ @Test
+ public void requireThatServerCanNotListenToBoundPort() throws Exception {
+ final TestDriver driver = TestDrivers.newInstance(mockRequestHandler());
+ try {
+ TestDrivers.newConfiguredInstance(
+ mockRequestHandler(),
+ new ServerConfig.Builder(),
+ new ConnectorConfig.Builder()
+ .listenPort(driver.server().getListenPort())
+ );
+ } catch (final Throwable t) {
+ assertThat(t.getCause(), instanceOf(BindException.class));
+ }
+ assertTrue(driver.close());
+ }
+
+ @Test
+ public void requireThatBindingSetNotFoundReturns404() throws Exception {
+ final TestDriver driver = TestDrivers.newConfiguredInstance(
+ mockRequestHandler(),
+ new ServerConfig.Builder()
+ .developerMode(true),
+ new ConnectorConfig.Builder(),
+ newBindingSetSelector("unknown"));
+ driver.client().get("/status.html")
+ .expectStatusCode(is(NOT_FOUND))
+ .expectContent(containsPattern(Pattern.compile(
+ Pattern.quote(BindingSetNotFoundException.class.getName()) +
+ ": No binding set named &apos;unknown&apos;\\.\n\tat .+",
+ Pattern.DOTALL | Pattern.MULTILINE)));
+ assertTrue(driver.close());
+ }
+
+ @Test
+ public void requireThatTooLongInitLineReturns414() throws Exception {
+ final TestDriver driver = TestDrivers.newConfiguredInstance(
+ mockRequestHandler(),
+ new ServerConfig.Builder(),
+ new ConnectorConfig.Builder()
+ .requestHeaderSize(1));
+ driver.client().get("/status.html")
+ .expectStatusCode(is(REQUEST_URI_TOO_LONG));
+ assertTrue(driver.close());
+ }
+
+ @Test
+ public void requireThatAccessLogIsCalledForRequestRejectedByJetty() throws Exception {
+ BlockingQueueRequestLog requestLogMock = new BlockingQueueRequestLog();
+ final TestDriver driver = TestDrivers.newConfiguredInstance(
+ mockRequestHandler(),
+ new ServerConfig.Builder(),
+ new ConnectorConfig.Builder().requestHeaderSize(1),
+ binder -> binder.bind(RequestLog.class).toInstance(requestLogMock));
+ driver.client().get("/status.html")
+ .expectStatusCode(is(REQUEST_URI_TOO_LONG));
+ RequestLogEntry entry = requestLogMock.poll(Duration.ofSeconds(30));
+ assertEquals(414, entry.statusCode().getAsInt());
+ assertThat(driver.close(), is(true));
+ }
+
+ @Test
+ public void requireThatServerCanEcho() throws Exception {
+ final TestDriver driver = TestDrivers.newInstance(new EchoRequestHandler());
+ driver.client().get("/status.html")
+ .expectStatusCode(is(OK));
+ assertTrue(driver.close());
+ }
+
+ @Test
+ public void requireThatServerCanEchoCompressed() throws Exception {
+ final TestDriver driver = TestDrivers.newInstance(new EchoRequestHandler());
+ SimpleHttpClient client = driver.newClient(true);
+ client.get("/status.html")
+ .expectStatusCode(is(OK));
+ assertTrue(driver.close());
+ }
+
+ @Test
+ public void requireThatServerCanHandleMultipleRequests() throws Exception {
+ final TestDriver driver = TestDrivers.newInstance(new EchoRequestHandler());
+ driver.client().get("/status.html")
+ .expectStatusCode(is(OK));
+ driver.client().get("/status.html")
+ .expectStatusCode(is(OK));
+ assertTrue(driver.close());
+ }
+
+ @Test
+ public void requireThatFormPostWorks() throws Exception {
+ final TestDriver driver = TestDrivers.newInstance(new ParameterPrinterRequestHandler());
+ final String requestContent = generateContent('a', 30);
+ final ResponseValidator response =
+ driver.client().newPost("/status.html")
+ .addHeader(CONTENT_TYPE, APPLICATION_X_WWW_FORM_URLENCODED)
+ .setContent(requestContent)
+ .execute();
+ response.expectStatusCode(is(OK))
+ .expectContent(startsWith('{' + requestContent + "=[]}"));
+ assertTrue(driver.close());
+ }
+
+ @Test
+ public void requireThatFormPostDoesNotRemoveContentByDefault() throws Exception {
+ final TestDriver driver = TestDrivers.newInstance(new ParameterPrinterRequestHandler());
+ final ResponseValidator response =
+ driver.client().newPost("/status.html")
+ .addHeader(CONTENT_TYPE, APPLICATION_X_WWW_FORM_URLENCODED)
+ .setContent("foo=bar")
+ .execute();
+ response.expectStatusCode(is(OK))
+ .expectContent(is("{foo=[bar]}foo=bar"));
+ assertTrue(driver.close());
+ }
+
+ @Test
+ public void requireThatFormPostKeepsContentWhenConfiguredTo() throws Exception {
+ final TestDriver driver = newDriverWithFormPostContentRemoved(new ParameterPrinterRequestHandler(), false);
+ final ResponseValidator response =
+ driver.client().newPost("/status.html")
+ .addHeader(CONTENT_TYPE, APPLICATION_X_WWW_FORM_URLENCODED)
+ .setContent("foo=bar")
+ .execute();
+ response.expectStatusCode(is(OK))
+ .expectContent(is("{foo=[bar]}foo=bar"));
+ assertTrue(driver.close());
+ }
+
+ @Test
+ public void requireThatFormPostRemovesContentWhenConfiguredTo() throws Exception {
+ final TestDriver driver = newDriverWithFormPostContentRemoved(new ParameterPrinterRequestHandler(), true);
+ final ResponseValidator response =
+ driver.client().newPost("/status.html")
+ .addHeader(CONTENT_TYPE, APPLICATION_X_WWW_FORM_URLENCODED)
+ .setContent("foo=bar")
+ .execute();
+ response.expectStatusCode(is(OK))
+ .expectContent(is("{foo=[bar]}"));
+ assertTrue(driver.close());
+ }
+
+ @Test
+ public void requireThatFormPostWithCharsetSpecifiedWorks() throws Exception {
+ final TestDriver driver = TestDrivers.newInstance(new ParameterPrinterRequestHandler());
+ final String requestContent = generateContent('a', 30);
+ final ResponseValidator response =
+ driver.client().newPost("/status.html")
+ .addHeader(X_DISABLE_CHUNKING, "true")
+ .addHeader(CONTENT_TYPE, APPLICATION_X_WWW_FORM_URLENCODED + ";charset=UTF-8")
+ .setContent(requestContent)
+ .execute();
+ response.expectStatusCode(is(OK))
+ .expectContent(startsWith('{' + requestContent + "=[]}"));
+ assertTrue(driver.close());
+ }
+
+ @Test
+ public void requireThatEmptyFormPostWorks() throws Exception {
+ final TestDriver driver = TestDrivers.newInstance(new ParameterPrinterRequestHandler());
+ final ResponseValidator response =
+ driver.client().newPost("/status.html")
+ .addHeader(CONTENT_TYPE, APPLICATION_X_WWW_FORM_URLENCODED)
+ .execute();
+ response.expectStatusCode(is(OK))
+ .expectContent(is("{}"));
+ assertTrue(driver.close());
+ }
+
+ @Test
+ public void requireThatFormParametersAreParsed() throws Exception {
+ final TestDriver driver = TestDrivers.newInstance(new ParameterPrinterRequestHandler());
+ final ResponseValidator response =
+ driver.client().newPost("/status.html")
+ .addHeader(CONTENT_TYPE, APPLICATION_X_WWW_FORM_URLENCODED)
+ .setContent("a=b&c=d")
+ .execute();
+ response.expectStatusCode(is(OK))
+ .expectContent(startsWith("{a=[b], c=[d]}"));
+ assertTrue(driver.close());
+ }
+
+ @Test
+ public void requireThatUriParametersAreParsed() throws Exception {
+ final TestDriver driver = TestDrivers.newInstance(new ParameterPrinterRequestHandler());
+ final ResponseValidator response =
+ driver.client().newPost("/status.html?a=b&c=d")
+ .addHeader(CONTENT_TYPE, APPLICATION_X_WWW_FORM_URLENCODED)
+ .execute();
+ response.expectStatusCode(is(OK))
+ .expectContent(is("{a=[b], c=[d]}"));
+ assertTrue(driver.close());
+ }
+
+ @Test
+ public void requireThatFormAndUriParametersAreMerged() throws Exception {
+ final TestDriver driver = TestDrivers.newInstance(new ParameterPrinterRequestHandler());
+ final ResponseValidator response =
+ driver.client().newPost("/status.html?a=b&c=d1")
+ .addHeader(CONTENT_TYPE, APPLICATION_X_WWW_FORM_URLENCODED)
+ .setContent("c=d2&e=f")
+ .execute();
+ response.expectStatusCode(is(OK))
+ .expectContent(startsWith("{a=[b], c=[d1, d2], e=[f]}"));
+ assertTrue(driver.close());
+ }
+
+ @Test
+ public void requireThatFormCharsetIsHonored() throws Exception {
+ final TestDriver driver = newDriverWithFormPostContentRemoved(new ParameterPrinterRequestHandler(), true);
+ final ResponseValidator response =
+ driver.client().newPost("/status.html")
+ .addHeader(CONTENT_TYPE, APPLICATION_X_WWW_FORM_URLENCODED + ";charset=ISO-8859-1")
+ .setBinaryContent(new byte[]{66, (byte) 230, 114, 61, 98, 108, (byte) 229})
+ .execute();
+ response.expectStatusCode(is(OK))
+ .expectContent(is("{B\u00e6r=[bl\u00e5]}"));
+ assertTrue(driver.close());
+ }
+
+ @Test
+ public void requireThatUnknownFormCharsetIsTreatedAsBadRequest() throws Exception {
+ final TestDriver driver = TestDrivers.newInstance(new ParameterPrinterRequestHandler());
+ final ResponseValidator response =
+ driver.client().newPost("/status.html")
+ .addHeader(CONTENT_TYPE, APPLICATION_X_WWW_FORM_URLENCODED + ";charset=FLARBA-GARBA-7")
+ .setContent("a=b")
+ .execute();
+ response.expectStatusCode(is(UNSUPPORTED_MEDIA_TYPE));
+ assertTrue(driver.close());
+ }
+
+ @Test
+ public void requireThatFormPostWithPercentEncodedContentIsDecoded() throws Exception {
+ final TestDriver driver = TestDrivers.newInstance(new ParameterPrinterRequestHandler());
+ final ResponseValidator response =
+ driver.client().newPost("/status.html")
+ .addHeader(CONTENT_TYPE, APPLICATION_X_WWW_FORM_URLENCODED)
+ .setContent("%20%3D%C3%98=%22%25+")
+ .execute();
+ response.expectStatusCode(is(OK))
+ .expectContent(startsWith("{ =\u00d8=[\"% ]}"));
+ assertTrue(driver.close());
+ }
+
+ @Test
+ public void requireThatFormPostWithThrowingHandlerIsExceptionSafe() throws Exception {
+ final TestDriver driver = TestDrivers.newInstance(new ThrowingHandler());
+ final ResponseValidator response =
+ driver.client().newPost("/status.html")
+ .addHeader(CONTENT_TYPE, APPLICATION_X_WWW_FORM_URLENCODED)
+ .setContent("a=b")
+ .execute();
+ response.expectStatusCode(is(INTERNAL_SERVER_ERROR));
+ assertTrue(driver.close());
+ }
+
+ @Test
+ public void requireThatMultiPostWorks() throws Exception {
+ // This is taken from tcpdump of bug 5433352 and reassembled here to see that httpserver passes things on.
+ final String startTxtContent = "this is a test for POST.";
+ final String updaterConfContent
+ = "identifier = updater\n"
+ + "server_type = gds\n";
+ final TestDriver driver = TestDrivers.newInstance(new EchoRequestHandler());
+ final ResponseValidator response =
+ driver.client().newPost("/status.html")
+ .setMultipartContent(
+ newFileBody("", "start.txt", startTxtContent),
+ newFileBody("", "updater.conf", updaterConfContent))
+ .execute();
+ response.expectStatusCode(is(OK))
+ .expectContent(containsString(startTxtContent))
+ .expectContent(containsString(updaterConfContent));
+ }
+
+ @Test
+ public void requireThatRequestCookiesAreReceived() throws Exception {
+ final TestDriver driver = TestDrivers.newInstance(new CookiePrinterRequestHandler());
+ final ResponseValidator response =
+ driver.client().newPost("/status.html")
+ .addHeader(COOKIE, "foo=bar")
+ .execute();
+ response.expectStatusCode(is(OK))
+ .expectContent(containsString("[foo=bar]"));
+ assertTrue(driver.close());
+ }
+
+ @Test
+ public void requireThatSetCookieHeaderIsCorrect() throws Exception {
+ final TestDriver driver = TestDrivers.newInstance(new CookieSetterRequestHandler(
+ new Cookie("foo", "bar")
+ .setDomain(".localhost")
+ .setHttpOnly(true)
+ .setPath("/foopath")
+ .setSecure(true)));
+ driver.client().get("/status.html")
+ .expectStatusCode(is(OK))
+ .expectHeader("Set-Cookie",
+ is("foo=bar; Path=/foopath; Domain=.localhost; Secure; HttpOnly"));
+ assertTrue(driver.close());
+ }
+
+ @Test
+ public void requireThatTimeoutWorks() throws Exception {
+ final UnresponsiveHandler requestHandler = new UnresponsiveHandler();
+ final TestDriver driver = TestDrivers.newInstance(requestHandler);
+ driver.client().get("/status.html")
+ .expectStatusCode(is(GATEWAY_TIMEOUT));
+ ResponseDispatch.newInstance(OK).dispatch(requestHandler.responseHandler);
+ assertTrue(driver.close());
+ }
+
+ // Header with no value is disallowed by https://tools.ietf.org/html/rfc7230#section-3.2
+ // Details in https://github.com/eclipse/jetty.project/issues/1116
+ @Test
+ public void requireThatHeaderWithNullValueIsOmitted() throws Exception {
+ final TestDriver driver = TestDrivers.newInstance(new EchoWithHeaderRequestHandler("X-Foo", null));
+ driver.client().get("/status.html")
+ .expectStatusCode(is(OK))
+ .expectNoHeader("X-Foo");
+ assertTrue(driver.close());
+ }
+
+ // Header with empty value is allowed by https://tools.ietf.org/html/rfc7230#section-3.2
+ // Details in https://github.com/eclipse/jetty.project/issues/1116
+ @Test
+ public void requireThatHeaderWithEmptyValueIsAllowed() throws Exception {
+ final TestDriver driver = TestDrivers.newInstance(new EchoWithHeaderRequestHandler("X-Foo", ""));
+ driver.client().get("/status.html")
+ .expectStatusCode(is(OK))
+ .expectHeader("X-Foo", is(""));
+ assertTrue(driver.close());
+ }
+
+ @Test
+ public void requireThatNoConnectionHeaderMeansKeepAliveInHttp11KeepAliveDisabled() throws Exception {
+ final TestDriver driver = TestDrivers.newInstance(new EchoWithHeaderRequestHandler(CONNECTION, CLOSE));
+ driver.client().get("/status.html")
+ .expectHeader(CONNECTION, is(CLOSE));
+ assertThat(driver.close(), is(true));
+ }
+
+ @Test
+ public void requireThatConnectionIsClosedAfterXRequests() throws Exception {
+ final int MAX_KEEPALIVE_REQUESTS = 100;
+ final TestDriver driver = TestDrivers.newConfiguredInstance(new EchoRequestHandler(),
+ new ServerConfig.Builder(),
+ new ConnectorConfig.Builder().maxRequestsPerConnection(MAX_KEEPALIVE_REQUESTS));
+ for (int i = 0; i < MAX_KEEPALIVE_REQUESTS - 1; i++) {
+ driver.client().get("/status.html")
+ .expectStatusCode(is(OK))
+ .expectNoHeader(CONNECTION);
+ }
+ driver.client().get("/status.html")
+ .expectStatusCode(is(OK))
+ .expectHeader(CONNECTION, is(CLOSE));
+ assertTrue(driver.close());
+ }
+
+ @Test
+ public void requireThatServerCanRespondToSslRequest() throws Exception {
+ Path privateKeyFile = tmpFolder.newFile().toPath();
+ Path certificateFile = tmpFolder.newFile().toPath();
+ generatePrivateKeyAndCertificate(privateKeyFile, certificateFile);
+
+ final TestDriver driver = TestDrivers.newInstanceWithSsl(new EchoRequestHandler(), certificateFile, privateKeyFile, TlsClientAuth.WANT);
+ driver.client().get("/status.html")
+ .expectStatusCode(is(OK));
+ assertTrue(driver.close());
+ }
+
+ @Test
+ public void requireThatTlsClientAuthenticationEnforcerRejectsRequestsForNonWhitelistedPaths() throws IOException {
+ Path privateKeyFile = tmpFolder.newFile().toPath();
+ Path certificateFile = tmpFolder.newFile().toPath();
+ generatePrivateKeyAndCertificate(privateKeyFile, certificateFile);
+ TestDriver driver = TestDrivers.newInstanceWithSsl(new EchoRequestHandler(), certificateFile, privateKeyFile, TlsClientAuth.WANT);
+
+ SSLContext trustStoreOnlyCtx = new SslContextBuilder()
+ .withTrustStore(certificateFile)
+ .build();
+
+ new SimpleHttpClient(trustStoreOnlyCtx, driver.server().getListenPort(), false)
+ .get("/dummy.html")
+ .expectStatusCode(is(UNAUTHORIZED));
+
+ assertTrue(driver.close());
+ }
+
+ @Test
+ public void requireThatTlsClientAuthenticationEnforcerAllowsRequestForWhitelistedPaths() throws IOException {
+ Path privateKeyFile = tmpFolder.newFile().toPath();
+ Path certificateFile = tmpFolder.newFile().toPath();
+ generatePrivateKeyAndCertificate(privateKeyFile, certificateFile);
+ TestDriver driver = TestDrivers.newInstanceWithSsl(new EchoRequestHandler(), certificateFile, privateKeyFile, TlsClientAuth.WANT);
+
+ SSLContext trustStoreOnlyCtx = new SslContextBuilder()
+ .withTrustStore(certificateFile)
+ .build();
+
+ new SimpleHttpClient(trustStoreOnlyCtx, driver.server().getListenPort(), false)
+ .get("/status.html")
+ .expectStatusCode(is(OK));
+
+ assertTrue(driver.close());
+ }
+
+ @Test
+ public void requireThatConnectedAtReturnsNonZero() throws Exception {
+ final TestDriver driver = TestDrivers.newInstance(new ConnectedAtRequestHandler());
+ driver.client().get("/status.html")
+ .expectStatusCode(is(OK))
+ .expectContent(matchesPattern("\\d{13,}"));
+ assertThat(driver.close(), is(true));
+ }
+
+ @Test
+ public void requireThatGzipEncodingRequestsAreAutomaticallyDecompressed() throws Exception {
+ TestDriver driver = TestDrivers.newInstance(new ParameterPrinterRequestHandler());
+ String requestContent = generateContent('a', 30);
+ ResponseValidator response = driver.client().newPost("/status.html")
+ .addHeader(CONTENT_TYPE, APPLICATION_X_WWW_FORM_URLENCODED)
+ .setGzipContent(requestContent)
+ .execute();
+ response.expectStatusCode(is(OK))
+ .expectContent(startsWith('{' + requestContent + "=[]}"));
+ assertTrue(driver.close());
+ }
+
+ @Test
+ public void requireThatResponseStatsAreCollected() throws Exception {
+ RequestTypeHandler handler = new RequestTypeHandler();
+ TestDriver driver = TestDrivers.newInstance(handler);
+ HttpResponseStatisticsCollector statisticsCollector = ((AbstractHandlerContainer) driver.server().server().getHandler())
+ .getChildHandlerByClass(HttpResponseStatisticsCollector.class);
+
+ {
+ List<HttpResponseStatisticsCollector.StatisticsEntry> stats = statisticsCollector.takeStatistics();
+ assertEquals(0, stats.size());
+ }
+
+ {
+ driver.client().newPost("/status.html").execute();
+ var entry = waitForStatistics(statisticsCollector);
+ assertEquals("http", entry.scheme);
+ assertEquals("POST", entry.method);
+ assertEquals("http.status.2xx", entry.name);
+ assertEquals("write", entry.requestType);
+ assertEquals(1, entry.value);
+ }
+
+ {
+ driver.client().newGet("/status.html").execute();
+ var entry = waitForStatistics(statisticsCollector);
+ assertEquals("http", entry.scheme);
+ assertEquals("GET", entry.method);
+ assertEquals("http.status.2xx", entry.name);
+ assertEquals("read", entry.requestType);
+ assertEquals(1, entry.value);
+ }
+
+ {
+ handler.setRequestType(Request.RequestType.READ);
+ driver.client().newPost("/status.html").execute();
+ var entry = waitForStatistics(statisticsCollector);
+ assertEquals("Handler overrides request type", "read", entry.requestType);
+ }
+
+ assertTrue(driver.close());
+ }
+
+ private HttpResponseStatisticsCollector.StatisticsEntry waitForStatistics(HttpResponseStatisticsCollector
+ statisticsCollector) {
+ List<HttpResponseStatisticsCollector.StatisticsEntry> entries = Collections.emptyList();
+ int tries = 0;
+ while (entries.isEmpty() && tries < 10000) {
+ entries = statisticsCollector.takeStatistics();
+ if (entries.isEmpty())
+ try {Thread.sleep(100); } catch (InterruptedException e) {}
+ tries++;
+ }
+ assertEquals(1, entries.size());
+ return entries.get(0);
+ }
+
+ @Test
+ public void requireThatConnectionThrottleDoesNotBlockConnectionsBelowThreshold() throws Exception {
+ TestDriver driver = TestDrivers.newConfiguredInstance(
+ new EchoRequestHandler(),
+ new ServerConfig.Builder(),
+ new ConnectorConfig.Builder()
+ .throttling(new Throttling.Builder()
+ .enabled(true)
+ .maxAcceptRate(10)
+ .maxHeapUtilization(1.0)
+ .maxConnections(10)));
+ driver.client().get("/status.html")
+ .expectStatusCode(is(OK));
+ assertTrue(driver.close());
+ }
+
+ @Test
+ public void requireThatMetricIsIncrementedWhenClientIsMissingCertificateOnHandshake() throws IOException {
+ Path privateKeyFile = tmpFolder.newFile().toPath();
+ Path certificateFile = tmpFolder.newFile().toPath();
+ generatePrivateKeyAndCertificate(privateKeyFile, certificateFile);
+ var metricConsumer = new MetricConsumerMock();
+ InMemoryConnectionLog connectionLog = new InMemoryConnectionLog();
+ TestDriver driver = createSslTestDriver(certificateFile, privateKeyFile, metricConsumer, connectionLog);
+
+ SSLContext clientCtx = new SslContextBuilder()
+ .withTrustStore(certificateFile)
+ .build();
+ assertHttpsRequestTriggersSslHandshakeException(
+ driver, clientCtx, null, null, "Received fatal alert: bad_certificate");
+ verify(metricConsumer.mockitoMock(), atLeast(1))
+ .add(MetricDefinitions.SSL_HANDSHAKE_FAILURE_MISSING_CLIENT_CERT, 1L, MetricConsumerMock.STATIC_CONTEXT);
+ assertTrue(driver.close());
+ Assertions.assertThat(connectionLog.logEntries()).hasSize(1);
+ assertSslHandshakeFailurePresent(
+ connectionLog.logEntries().get(0), SSLHandshakeException.class, SslHandshakeFailure.MISSING_CLIENT_CERT.failureType());
+ }
+
+ @Test
+ public void requireThatMetricIsIncrementedWhenClientUsesIncompatibleTlsVersion() throws IOException {
+ Path privateKeyFile = tmpFolder.newFile().toPath();
+ Path certificateFile = tmpFolder.newFile().toPath();
+ generatePrivateKeyAndCertificate(privateKeyFile, certificateFile);
+ var metricConsumer = new MetricConsumerMock();
+ InMemoryConnectionLog connectionLog = new InMemoryConnectionLog();
+ TestDriver driver = createSslTestDriver(certificateFile, privateKeyFile, metricConsumer, connectionLog);
+
+ SSLContext clientCtx = new SslContextBuilder()
+ .withTrustStore(certificateFile)
+ .withKeyStore(privateKeyFile, certificateFile)
+ .build();
+
+ boolean tlsv11Enabled = List.of(clientCtx.getDefaultSSLParameters().getProtocols()).contains("TLSv1.1");
+ assumeTrue("TLSv1.1 must be enabled in installed JDK", tlsv11Enabled);
+
+ assertHttpsRequestTriggersSslHandshakeException(driver, clientCtx, "TLSv1.1", null, "protocol");
+ verify(metricConsumer.mockitoMock(), atLeast(1))
+ .add(MetricDefinitions.SSL_HANDSHAKE_FAILURE_INCOMPATIBLE_PROTOCOLS, 1L, MetricConsumerMock.STATIC_CONTEXT);
+ assertTrue(driver.close());
+ Assertions.assertThat(connectionLog.logEntries()).hasSize(1);
+ assertSslHandshakeFailurePresent(
+ connectionLog.logEntries().get(0), SSLHandshakeException.class, SslHandshakeFailure.INCOMPATIBLE_PROTOCOLS.failureType());
+ }
+
+ @Test
+ public void requireThatMetricIsIncrementedWhenClientUsesIncompatibleCiphers() throws IOException {
+ Path privateKeyFile = tmpFolder.newFile().toPath();
+ Path certificateFile = tmpFolder.newFile().toPath();
+ generatePrivateKeyAndCertificate(privateKeyFile, certificateFile);
+ var metricConsumer = new MetricConsumerMock();
+ InMemoryConnectionLog connectionLog = new InMemoryConnectionLog();
+ TestDriver driver = createSslTestDriver(certificateFile, privateKeyFile, metricConsumer, connectionLog);
+
+ SSLContext clientCtx = new SslContextBuilder()
+ .withTrustStore(certificateFile)
+ .withKeyStore(privateKeyFile, certificateFile)
+ .build();
+
+ assertHttpsRequestTriggersSslHandshakeException(
+ driver, clientCtx, null, "TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA", "Received fatal alert: handshake_failure");
+ verify(metricConsumer.mockitoMock(), atLeast(1))
+ .add(MetricDefinitions.SSL_HANDSHAKE_FAILURE_INCOMPATIBLE_CIPHERS, 1L, MetricConsumerMock.STATIC_CONTEXT);
+ assertTrue(driver.close());
+ Assertions.assertThat(connectionLog.logEntries()).hasSize(1);
+ assertSslHandshakeFailurePresent(
+ connectionLog.logEntries().get(0), SSLHandshakeException.class, SslHandshakeFailure.INCOMPATIBLE_CIPHERS.failureType());
+ }
+
+ @Test
+ public void requireThatMetricIsIncrementedWhenClientUsesInvalidCertificateInHandshake() throws IOException {
+ Path serverPrivateKeyFile = tmpFolder.newFile().toPath();
+ Path serverCertificateFile = tmpFolder.newFile().toPath();
+ generatePrivateKeyAndCertificate(serverPrivateKeyFile, serverCertificateFile);
+ var metricConsumer = new MetricConsumerMock();
+ InMemoryConnectionLog connectionLog = new InMemoryConnectionLog();
+ TestDriver driver = createSslTestDriver(serverCertificateFile, serverPrivateKeyFile, metricConsumer, connectionLog);
+
+ Path clientPrivateKeyFile = tmpFolder.newFile().toPath();
+ Path clientCertificateFile = tmpFolder.newFile().toPath();
+ generatePrivateKeyAndCertificate(clientPrivateKeyFile, clientCertificateFile);
+
+ SSLContext clientCtx = new SslContextBuilder()
+ .withKeyStore(clientPrivateKeyFile, clientCertificateFile)
+ .withTrustStore(serverCertificateFile)
+ .build();
+
+ assertHttpsRequestTriggersSslHandshakeException(
+ driver, clientCtx, null, null, "Received fatal alert: certificate_unknown");
+ verify(metricConsumer.mockitoMock(), atLeast(1))
+ .add(MetricDefinitions.SSL_HANDSHAKE_FAILURE_INVALID_CLIENT_CERT, 1L, MetricConsumerMock.STATIC_CONTEXT);
+ assertTrue(driver.close());
+ Assertions.assertThat(connectionLog.logEntries()).hasSize(1);
+ assertSslHandshakeFailurePresent(
+ connectionLog.logEntries().get(0), SSLHandshakeException.class, SslHandshakeFailure.INVALID_CLIENT_CERT.failureType());
+ }
+
+ @Test
+ public void requireThatMetricIsIncrementedWhenClientUsesExpiredCertificateInHandshake() throws IOException {
+ Path rootPrivateKeyFile = tmpFolder.newFile().toPath();
+ Path rootCertificateFile = tmpFolder.newFile().toPath();
+ Path privateKeyFile = tmpFolder.newFile().toPath();
+ Path certificateFile = tmpFolder.newFile().toPath();
+ Instant notAfter = Instant.now().minus(100, ChronoUnit.DAYS);
+ generatePrivateKeyAndCertificate(rootPrivateKeyFile, rootCertificateFile, privateKeyFile, certificateFile, notAfter);
+ var metricConsumer = new MetricConsumerMock();
+ InMemoryConnectionLog connectionLog = new InMemoryConnectionLog();
+ TestDriver driver = createSslTestDriver(rootCertificateFile, rootPrivateKeyFile, metricConsumer, connectionLog);
+
+ SSLContext clientCtx = new SslContextBuilder()
+ .withTrustStore(rootCertificateFile)
+ .withKeyStore(privateKeyFile, certificateFile)
+ .build();
+
+ assertHttpsRequestTriggersSslHandshakeException(
+ driver, clientCtx, null, null, "Received fatal alert: certificate_unknown");
+ verify(metricConsumer.mockitoMock(), atLeast(1))
+ .add(MetricDefinitions.SSL_HANDSHAKE_FAILURE_EXPIRED_CLIENT_CERT, 1L, MetricConsumerMock.STATIC_CONTEXT);
+ assertTrue(driver.close());
+ Assertions.assertThat(connectionLog.logEntries()).hasSize(1);
+
+ }
+
+ @Test
+ public void requireThatProxyProtocolIsAcceptedAndActualRemoteAddressStoredInAccessLog() throws Exception {
+ Path privateKeyFile = tmpFolder.newFile().toPath();
+ Path certificateFile = tmpFolder.newFile().toPath();
+ generatePrivateKeyAndCertificate(privateKeyFile, certificateFile);
+ InMemoryRequestLog requestLogMock = new InMemoryRequestLog();
+ InMemoryConnectionLog connectionLog = new InMemoryConnectionLog();
+ TestDriver driver = createSslWithProxyProtocolTestDriver(certificateFile, privateKeyFile, requestLogMock, /*mixedMode*/connectionLog, false);
+
+ String proxiedRemoteAddress = "192.168.0.100";
+ int proxiedRemotePort = 12345;
+ sendJettyClientRequest(driver, certificateFile, new V1.Tag(proxiedRemoteAddress, proxiedRemotePort));
+ sendJettyClientRequest(driver, certificateFile, new V2.Tag(proxiedRemoteAddress, proxiedRemotePort));
+ assertTrue(driver.close());
+
+ assertEquals(2, requestLogMock.entries().size());
+ assertLogEntryHasRemote(requestLogMock.entries().get(0), proxiedRemoteAddress, proxiedRemotePort);
+ assertLogEntryHasRemote(requestLogMock.entries().get(1), proxiedRemoteAddress, proxiedRemotePort);
+ Assertions.assertThat(connectionLog.logEntries()).hasSize(2);
+ assertLogEntryHasRemote(connectionLog.logEntries().get(0), proxiedRemoteAddress, proxiedRemotePort);
+ assertLogEntryHasRemote(connectionLog.logEntries().get(1), proxiedRemoteAddress, proxiedRemotePort);
+ }
+
+ @Test
+ public void requireThatConnectorWithProxyProtocolMixedEnabledAcceptsBothProxyProtocolAndHttps() throws Exception {
+ Path privateKeyFile = tmpFolder.newFile().toPath();
+ Path certificateFile = tmpFolder.newFile().toPath();
+ generatePrivateKeyAndCertificate(privateKeyFile, certificateFile);
+ InMemoryRequestLog requestLogMock = new InMemoryRequestLog();
+ InMemoryConnectionLog connectionLog = new InMemoryConnectionLog();
+ TestDriver driver = createSslWithProxyProtocolTestDriver(certificateFile, privateKeyFile, requestLogMock, /*mixedMode*/connectionLog, true);
+
+ String proxiedRemoteAddress = "192.168.0.100";
+ sendJettyClientRequest(driver, certificateFile, null);
+ sendJettyClientRequest(driver, certificateFile, new V2.Tag(proxiedRemoteAddress, 12345));
+ assertTrue(driver.close());
+
+ assertEquals(2, requestLogMock.entries().size());
+ assertLogEntryHasRemote(requestLogMock.entries().get(0), "127.0.0.1", 0);
+ assertLogEntryHasRemote(requestLogMock.entries().get(1), proxiedRemoteAddress, 0);
+ Assertions.assertThat(connectionLog.logEntries()).hasSize(2);
+ assertLogEntryHasRemote(connectionLog.logEntries().get(0), null, 0);
+ assertLogEntryHasRemote(connectionLog.logEntries().get(1), proxiedRemoteAddress, 12345);
+ }
+
+ @Test
+ public void requireThatJdiscLocalPortPropertyIsNotOverriddenByProxyProtocol() throws Exception {
+ Path privateKeyFile = tmpFolder.newFile().toPath();
+ Path certificateFile = tmpFolder.newFile().toPath();
+ generatePrivateKeyAndCertificate(privateKeyFile, certificateFile);
+ InMemoryRequestLog requestLogMock = new InMemoryRequestLog();
+ InMemoryConnectionLog connectionLog = new InMemoryConnectionLog();
+ TestDriver driver = createSslWithProxyProtocolTestDriver(certificateFile, privateKeyFile, requestLogMock, connectionLog, /*mixedMode*/false);
+
+ String proxiedRemoteAddress = "192.168.0.100";
+ int proxiedRemotePort = 12345;
+ String proxyLocalAddress = "10.0.0.10";
+ int proxyLocalPort = 23456;
+ V2.Tag v2Tag = new V2.Tag(V2.Tag.Command.PROXY, null, V2.Tag.Protocol.STREAM,
+ proxiedRemoteAddress, proxiedRemotePort, proxyLocalAddress, proxyLocalPort, null);
+ ContentResponse response = sendJettyClientRequest(driver, certificateFile, v2Tag);
+ assertTrue(driver.close());
+
+ int clientPort = Integer.parseInt(response.getHeaders().get("Jdisc-Local-Port"));
+ assertNotEquals(proxyLocalPort, clientPort);
+ assertNotEquals(proxyLocalPort, connectionLog.logEntries().get(0).localPort().get().intValue());
+ }
+
+ @Test
+ public void requireThatConnectionIsTrackedInConnectionLog() throws Exception {
+ Path privateKeyFile = tmpFolder.newFile().toPath();
+ Path certificateFile = tmpFolder.newFile().toPath();
+ generatePrivateKeyAndCertificate(privateKeyFile, certificateFile);
+ InMemoryConnectionLog connectionLog = new InMemoryConnectionLog();
+ Module overrideModule = binder -> binder.bind(ConnectionLog.class).toInstance(connectionLog);
+ TestDriver driver = TestDrivers.newInstanceWithSsl(new EchoRequestHandler(), certificateFile, privateKeyFile, TlsClientAuth.NEED, overrideModule);
+ int listenPort = driver.server().getListenPort();
+ driver.client().get("/status.html");
+ assertTrue(driver.close());
+ List<ConnectionLogEntry> logEntries = connectionLog.logEntries();
+ Assertions.assertThat(logEntries).hasSize(1);
+ ConnectionLogEntry logEntry = logEntries.get(0);
+ assertEquals(4, UUID.fromString(logEntry.id()).version());
+ Assertions.assertThat(logEntry.timestamp()).isAfter(Instant.EPOCH);
+ Assertions.assertThat(logEntry.requests()).hasValue(1L);
+ Assertions.assertThat(logEntry.responses()).hasValue(1L);
+ Assertions.assertThat(logEntry.peerAddress()).hasValue("127.0.0.1");
+ Assertions.assertThat(logEntry.localAddress()).hasValue("127.0.0.1");
+ Assertions.assertThat(logEntry.localPort()).hasValue(listenPort);
+ Assertions.assertThat(logEntry.httpBytesReceived()).hasValueSatisfying(value -> Assertions.assertThat(value).isPositive());
+ Assertions.assertThat(logEntry.httpBytesSent()).hasValueSatisfying(value -> Assertions.assertThat(value).isPositive());
+ Assertions.assertThat(logEntry.sslProtocol()).hasValueSatisfying(TlsContext.ALLOWED_PROTOCOLS::contains);
+ Assertions.assertThat(logEntry.sslPeerSubject()).hasValue("CN=localhost");
+ Assertions.assertThat(logEntry.sslCipherSuite()).hasValueSatisfying(cipher -> Assertions.assertThat(cipher).isNotBlank());
+ Assertions.assertThat(logEntry.sslSessionId()).hasValueSatisfying(sessionId -> Assertions.assertThat(sessionId).hasSize(64));
+ Assertions.assertThat(logEntry.sslPeerNotBefore()).hasValue(Instant.EPOCH);
+ Assertions.assertThat(logEntry.sslPeerNotAfter()).hasValue(Instant.EPOCH.plus(100_000, ChronoUnit.DAYS));
+ }
+
+ private ContentResponse sendJettyClientRequest(TestDriver testDriver, Path certificateFile, Object tag)
+ throws Exception {
+ HttpClient client = createJettyHttpClient(certificateFile);
+ try {
+ int maxAttempts = 3;
+ for (int attempt = 0; attempt < maxAttempts; attempt++) {
+ try {
+ ContentResponse response = client.newRequest(URI.create("https://localhost:" + testDriver.server().getListenPort() + "/"))
+ .tag(tag)
+ .send();
+ assertEquals(200, response.getStatus());
+ return response;
+ } catch (ExecutionException e) {
+ // Retry when the server closes the connection before the TLS handshake is completed. This have been observed in CI.
+ // We have been unable to reproduce this locally. The cause is therefor currently unknown.
+ log.log(Level.WARNING, String.format("Attempt %d failed: %s", attempt, e.getMessage()), e);
+ Thread.sleep(10);
+ }
+ }
+ throw new AssertionError("Failed to send request, see log for details");
+ } finally {
+ client.stop();
+ }
+ }
+
+ // Using Jetty's http client as Apache httpclient does not support the proxy-protocol v1/v2.
+ private static HttpClient createJettyHttpClient(Path certificateFile) throws Exception {
+ SslContextFactory.Client clientSslCtxFactory = new SslContextFactory.Client();
+ clientSslCtxFactory.setHostnameVerifier(NoopHostnameVerifier.INSTANCE);
+ clientSslCtxFactory.setSslContext(new SslContextBuilder().withTrustStore(certificateFile).build());
+
+ HttpClient client = new HttpClient(clientSslCtxFactory);
+ client.start();
+ return client;
+ }
+
+ private static void assertLogEntryHasRemote(RequestLogEntry entry, String expectedAddress, int expectedPort) {
+ assertEquals(expectedAddress, entry.peerAddress().get());
+ if (expectedPort > 0) {
+ assertEquals(expectedPort, entry.peerPort().getAsInt());
+ }
+ }
+
+ private static void assertLogEntryHasRemote(ConnectionLogEntry entry, String expectedAddress, int expectedPort) {
+ if (expectedAddress != null) {
+ Assertions.assertThat(entry.remoteAddress()).hasValue(expectedAddress);
+ } else {
+ Assertions.assertThat(entry.remoteAddress()).isEmpty();
+ }
+ if (expectedPort > 0) {
+ Assertions.assertThat(entry.remotePort()).hasValue(expectedPort);
+ } else {
+ Assertions.assertThat(entry.remotePort()).isEmpty();
+ }
+ }
+
+ private static void assertSslHandshakeFailurePresent(
+ ConnectionLogEntry entry, Class<? extends SSLHandshakeException> expectedException, String expectedType) {
+ Assertions.assertThat(entry.sslHandshakeFailure()).isPresent();
+ ConnectionLogEntry.SslHandshakeFailure failure = entry.sslHandshakeFailure().get();
+ assertEquals(expectedType, failure.type());
+ ExceptionEntry exceptionEntry = failure.exceptionChain().get(0);
+ assertEquals(expectedException.getName(), exceptionEntry.name());
+ }
+
+ private static TestDriver createSslWithProxyProtocolTestDriver(
+ Path certificateFile, Path privateKeyFile, RequestLog requestLog,
+ ConnectionLog connectionLog, boolean mixedMode) {
+ ConnectorConfig.Builder connectorConfig = new ConnectorConfig.Builder()
+ .proxyProtocol(new ConnectorConfig.ProxyProtocol.Builder()
+ .enabled(true)
+ .mixedMode(mixedMode))
+ .ssl(new ConnectorConfig.Ssl.Builder()
+ .enabled(true)
+ .privateKeyFile(privateKeyFile.toString())
+ .certificateFile(certificateFile.toString())
+ .caCertificateFile(certificateFile.toString()));
+ return TestDrivers.newConfiguredInstance(
+ new EchoRequestHandler(),
+ new ServerConfig.Builder().connectionLog(new ServerConfig.ConnectionLog.Builder().enabled(true)),
+ connectorConfig,
+ binder -> {
+ binder.bind(RequestLog.class).toInstance(requestLog);
+ binder.bind(ConnectionLog.class).toInstance(connectionLog);
+ });
+ }
+
+ private static TestDriver createSslTestDriver(
+ Path serverCertificateFile, Path serverPrivateKeyFile, MetricConsumerMock metricConsumer, InMemoryConnectionLog connectionLog) throws IOException {
+ Module extraModule = binder -> {
+ binder.bind(MetricConsumer.class).toInstance(metricConsumer.mockitoMock());
+ binder.bind(ConnectionLog.class).toInstance(connectionLog);
+ };
+ return TestDrivers.newInstanceWithSsl(
+ new EchoRequestHandler(), serverCertificateFile, serverPrivateKeyFile, TlsClientAuth.NEED, extraModule);
+ }
+
+ private static void assertHttpsRequestTriggersSslHandshakeException(
+ TestDriver testDriver,
+ SSLContext sslContext,
+ String protocolOverride,
+ String cipherOverride,
+ String expectedExceptionSubstring) throws IOException {
+ List<String> protocols = protocolOverride != null ? List.of(protocolOverride) : null;
+ List<String> ciphers = cipherOverride != null ? List.of(cipherOverride) : null;
+ try (var client = new SimpleHttpClient(sslContext, protocols, ciphers, testDriver.server().getListenPort(), false)) {
+ client.get("/status.html");
+ fail("SSLHandshakeException expected");
+ } catch (SSLHandshakeException e) {
+ assertThat(e.getMessage(), containsString(expectedExceptionSubstring));
+ } catch (SSLException e) {
+ // This exception is thrown if Apache httpclient's write thread detects the handshake failure before the read thread.
+ log.log(Level.WARNING, "Client failed to get a proper TLS handshake response: " + e.getMessage(), e);
+ // Only ignore a subset of exceptions
+ assertThat(e.getMessage(), anyOf(containsString("readHandshakeRecord"), containsString("Broken pipe")));
+ }
+ }
+
+ private static void generatePrivateKeyAndCertificate(Path privateKeyFile, Path certificateFile) throws IOException {
+ KeyPair keyPair = KeyUtils.generateKeypair(EC);
+ Files.writeString(privateKeyFile, KeyUtils.toPem(keyPair.getPrivate()));
+
+ X509Certificate certificate = X509CertificateBuilder
+ .fromKeypair(
+ keyPair, new X500Principal("CN=localhost"), Instant.EPOCH, Instant.EPOCH.plus(100_000, ChronoUnit.DAYS), SHA256_WITH_ECDSA, BigInteger.ONE)
+ .build();
+ Files.writeString(certificateFile, X509CertificateUtils.toPem(certificate));
+ }
+
+ private static void generatePrivateKeyAndCertificate(Path rootPrivateKeyFile, Path rootCertificateFile,
+ Path privateKeyFile, Path certificateFile, Instant notAfter) throws IOException {
+ generatePrivateKeyAndCertificate(rootPrivateKeyFile, rootCertificateFile);
+ X509Certificate rootCertificate = X509CertificateUtils.fromPem(Files.readString(rootCertificateFile));
+ PrivateKey privateKey = KeyUtils.fromPemEncodedPrivateKey(Files.readString(rootPrivateKeyFile));
+
+ KeyPair keyPair = KeyUtils.generateKeypair(EC);
+ Files.writeString(privateKeyFile, KeyUtils.toPem(keyPair.getPrivate()));
+ Pkcs10Csr csr = Pkcs10CsrBuilder.fromKeypair(new X500Principal("CN=myclient"), keyPair, SHA256_WITH_ECDSA).build();
+ X509Certificate certificate = X509CertificateBuilder
+ .fromCsr(csr, rootCertificate.getSubjectX500Principal(), Instant.EPOCH, notAfter, privateKey, SHA256_WITH_ECDSA, BigInteger.ONE)
+ .build();
+ Files.writeString(certificateFile, X509CertificateUtils.toPem(certificate));
+ }
+
+ private static RequestHandler mockRequestHandler() {
+ final RequestHandler mockRequestHandler = mock(RequestHandler.class);
+ when(mockRequestHandler.refer()).thenReturn(References.NOOP_REFERENCE);
+ return mockRequestHandler;
+ }
+
+ private static String generateContent(final char c, final int len) {
+ final StringBuilder ret = new StringBuilder(len);
+ for (int i = 0; i < len; ++i) {
+ ret.append(c);
+ }
+ return ret.toString();
+ }
+
+ private static TestDriver newDriverWithFormPostContentRemoved(RequestHandler requestHandler,
+ boolean removeFormPostBody) throws Exception {
+ return TestDrivers.newConfiguredInstance(
+ requestHandler,
+ new ServerConfig.Builder()
+ .removeRawPostBodyForWwwUrlEncodedPost(removeFormPostBody),
+ new ConnectorConfig.Builder());
+ }
+
+ private static FormBodyPart newFileBody(final String parameterName, final String fileName, final String fileContent) {
+ return new FormBodyPart(
+ parameterName,
+ new StringBody(fileContent, ContentType.TEXT_PLAIN) {
+ @Override
+ public String getFilename() {
+ return fileName;
+ }
+
+ @Override
+ public String getTransferEncoding() {
+ return "binary";
+ }
+
+ @Override
+ public String getMimeType() {
+ return "";
+ }
+
+ @Override
+ public String getCharset() {
+ return null;
+ }
+ });
+ }
+
+ private static class ConnectedAtRequestHandler extends AbstractRequestHandler {
+
+ @Override
+ public ContentChannel handleRequest(final Request request, final ResponseHandler handler) {
+ final HttpRequest httpRequest = (HttpRequest)request;
+ final String connectedAt = String.valueOf(httpRequest.getConnectedAt(TimeUnit.MILLISECONDS));
+ final ContentChannel ch = handler.handleResponse(new Response(OK));
+ ch.write(ByteBuffer.wrap(connectedAt.getBytes(StandardCharsets.UTF_8)), null);
+ ch.close(null);
+ return null;
+ }
+ }
+
+ private static class CookieSetterRequestHandler extends AbstractRequestHandler {
+
+ final Cookie cookie;
+
+ CookieSetterRequestHandler(final Cookie cookie) {
+ this.cookie = cookie;
+ }
+
+ @Override
+ public ContentChannel handleRequest(final Request request, final ResponseHandler handler) {
+ final HttpResponse response = HttpResponse.newInstance(OK);
+ response.encodeSetCookieHeader(Collections.singletonList(cookie));
+ ResponseDispatch.newInstance(response).dispatch(handler);
+ return null;
+ }
+ }
+
+ private static class CookiePrinterRequestHandler extends AbstractRequestHandler {
+
+ @Override
+ public ContentChannel handleRequest(final Request request, final ResponseHandler handler) {
+ final List<Cookie> cookies = new ArrayList<>(((HttpRequest)request).decodeCookieHeader());
+ Collections.sort(cookies, new CookieComparator());
+ final ContentChannel out = ResponseDispatch.newInstance(Response.Status.OK).connect(handler);
+ out.write(StandardCharsets.UTF_8.encode(cookies.toString()), null);
+ out.close(null);
+ return null;
+ }
+ }
+
+ private static class ParameterPrinterRequestHandler extends AbstractRequestHandler {
+
+ private static final CompletionHandler NULL_COMPLETION_HANDLER = null;
+
+ @Override
+ public ContentChannel handleRequest(Request request, ResponseHandler handler) {
+ Map<String, List<String>> parameters = new TreeMap<>(((HttpRequest)request).parameters());
+ ContentChannel responseContentChannel = ResponseDispatch.newInstance(Response.Status.OK).connect(handler);
+ responseContentChannel.write(ByteBuffer.wrap(parameters.toString().getBytes(StandardCharsets.UTF_8)),
+ NULL_COMPLETION_HANDLER);
+
+ // Have the request content written back to the response.
+ return responseContentChannel;
+ }
+ }
+
+ private static class RequestTypeHandler extends AbstractRequestHandler {
+
+ private Request.RequestType requestType = null;
+
+ public void setRequestType(Request.RequestType requestType) {
+ this.requestType = requestType;
+ }
+
+ @Override
+ public ContentChannel handleRequest(Request request, ResponseHandler handler) {
+ Response response = new Response(OK);
+ response.setRequestType(requestType);
+ return handler.handleResponse(response);
+ }
+ }
+
+ private static class ThrowingHandler extends AbstractRequestHandler {
+ @Override
+ public ContentChannel handleRequest(final Request request, final ResponseHandler handler) {
+ throw new RuntimeException("Deliberately thrown exception");
+ }
+ }
+
+ private static class UnresponsiveHandler extends AbstractRequestHandler {
+
+ ResponseHandler responseHandler;
+
+ @Override
+ public ContentChannel handleRequest(final Request request, final ResponseHandler handler) {
+ request.setTimeout(100, TimeUnit.MILLISECONDS);
+ responseHandler = handler;
+ return null;
+ }
+ }
+
+ private static class EchoRequestHandler extends AbstractRequestHandler {
+
+ @Override
+ public ContentChannel handleRequest(final Request request, final ResponseHandler handler) {
+ int port = request.getUri().getPort();
+ Response response = new Response(OK);
+ response.headers().put("Jdisc-Local-Port", Integer.toString(port));
+ return handler.handleResponse(response);
+ }
+ }
+
+ private static class EchoWithHeaderRequestHandler extends AbstractRequestHandler {
+
+ final String headerName;
+ final String headerValue;
+
+ EchoWithHeaderRequestHandler(final String headerName, final String headerValue) {
+ this.headerName = headerName;
+ this.headerValue = headerValue;
+ }
+
+ @Override
+ public ContentChannel handleRequest(final Request request, final ResponseHandler handler) {
+ final Response response = new Response(OK);
+ response.headers().add(headerName, headerValue);
+ return handler.handleResponse(response);
+ }
+ }
+
+ private static Module newBindingSetSelector(final String setName) {
+ return new AbstractModule() {
+
+ @Override
+ protected void configure() {
+ bind(BindingSetSelector.class).toInstance(new BindingSetSelector() {
+
+ @Override
+ public String select(final URI uri) {
+ return setName;
+ }
+ });
+ }
+ };
+ }
+
+ private static class CookieComparator implements Comparator<Cookie> {
+
+ @Override
+ public int compare(final Cookie lhs, final Cookie rhs) {
+ return lhs.getName().compareTo(rhs.getName());
+ }
+ }
+
+}
diff --git a/container-core/src/test/java/com/yahoo/jdisc/http/server/jetty/InMemoryConnectionLog.java b/container-core/src/test/java/com/yahoo/jdisc/http/server/jetty/InMemoryConnectionLog.java
new file mode 100644
index 00000000000..6d1baf0423f
--- /dev/null
+++ b/container-core/src/test/java/com/yahoo/jdisc/http/server/jetty/InMemoryConnectionLog.java
@@ -0,0 +1,25 @@
+// 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 java.util.List;
+import java.util.concurrent.CopyOnWriteArrayList;
+
+/**
+ * A {@link ConnectionLog} that aggregates log entries in memory
+ *
+ * @author bjorncs
+ */
+class InMemoryConnectionLog implements ConnectionLog {
+
+ private final List<ConnectionLogEntry> logEntries = new CopyOnWriteArrayList<>();
+
+ @Override
+ public void log(ConnectionLogEntry entry) {
+ logEntries.add(entry);
+ }
+
+ List<ConnectionLogEntry> logEntries() { return logEntries; }
+}
diff --git a/container-core/src/test/java/com/yahoo/jdisc/http/server/jetty/InMemoryRequestLog.java b/container-core/src/test/java/com/yahoo/jdisc/http/server/jetty/InMemoryRequestLog.java
new file mode 100644
index 00000000000..b87ec5e8b8b
--- /dev/null
+++ b/container-core/src/test/java/com/yahoo/jdisc/http/server/jetty/InMemoryRequestLog.java
@@ -0,0 +1,20 @@
+// 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;
+
+import java.util.List;
+import java.util.concurrent.CopyOnWriteArrayList;
+
+/**
+ * @author bjorncs
+ */
+public class InMemoryRequestLog implements RequestLog {
+
+ private final List<RequestLogEntry> entries = new CopyOnWriteArrayList<>();
+
+ @Override public void log(RequestLogEntry entry) { entries.add(entry); }
+
+ List<RequestLogEntry> entries() { return entries; }
+}
diff --git a/container-core/src/test/java/com/yahoo/jdisc/http/server/jetty/JDiscHttpServletTest.java b/container-core/src/test/java/com/yahoo/jdisc/http/server/jetty/JDiscHttpServletTest.java
new file mode 100644
index 00000000000..230f59cbb34
--- /dev/null
+++ b/container-core/src/test/java/com/yahoo/jdisc/http/server/jetty/JDiscHttpServletTest.java
@@ -0,0 +1,80 @@
+// 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.Response;
+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 org.apache.http.client.methods.HttpDelete;
+import org.apache.http.client.methods.HttpGet;
+import org.apache.http.client.methods.HttpHead;
+import org.apache.http.client.methods.HttpOptions;
+import org.apache.http.client.methods.HttpPatch;
+import org.apache.http.client.methods.HttpPost;
+import org.apache.http.client.methods.HttpPut;
+import org.apache.http.client.methods.HttpRequestBase;
+import org.apache.http.client.methods.HttpTrace;
+import org.junit.Test;
+
+import java.io.IOException;
+import java.net.URI;
+
+import static com.yahoo.jdisc.Response.Status.METHOD_NOT_ALLOWED;
+import static com.yahoo.jdisc.Response.Status.OK;
+import static org.hamcrest.CoreMatchers.is;
+import static org.hamcrest.MatcherAssert.assertThat;
+
+/**
+ * @author Simon Thoresen Hult
+ */
+public class JDiscHttpServletTest {
+
+ @Test
+ public void requireThatServerRespondsToAllMethods() throws Exception {
+ final TestDriver driver = TestDrivers.newInstance(newEchoHandler());
+ final URI uri = driver.client().newUri("/status.html");
+ driver.client().execute(new HttpGet(uri))
+ .expectStatusCode(is(OK));
+ driver.client().execute(new HttpPost(uri))
+ .expectStatusCode(is(OK));
+ driver.client().execute(new HttpHead(uri))
+ .expectStatusCode(is(OK));
+ driver.client().execute(new HttpPut(uri))
+ .expectStatusCode(is(OK));
+ driver.client().execute(new HttpDelete(uri))
+ .expectStatusCode(is(OK));
+ driver.client().execute(new HttpOptions(uri))
+ .expectStatusCode(is(OK));
+ driver.client().execute(new HttpTrace(uri))
+ .expectStatusCode(is(OK));
+ driver.client().execute(new HttpPatch(uri))
+ .expectStatusCode(is(OK));
+ assertThat(driver.close(), is(true));
+ }
+
+ @Test
+ public void requireThatServerResponds405ToUnknownMethods() throws IOException {
+ TestDriver driver = TestDrivers.newInstance(newEchoHandler());
+ final URI uri = driver.client().newUri("/status.html");
+ driver.client().execute(new UnknownMethodHttpRequest(uri))
+ .expectStatusCode(is(METHOD_NOT_ALLOWED));
+ assertThat(driver.close(), is(true));
+ }
+
+ private static RequestHandler newEchoHandler() {
+ return new AbstractRequestHandler() {
+
+ @Override
+ public ContentChannel handleRequest(final Request request, final ResponseHandler handler) {
+ return handler.handleResponse(new Response(OK));
+ }
+ };
+ }
+
+ private static class UnknownMethodHttpRequest extends HttpRequestBase {
+ UnknownMethodHttpRequest(URI uri) { setURI(uri); }
+ @Override public String getMethod() { return "UNKNOWN_METHOD"; }
+ }
+}
diff --git a/container-core/src/test/java/com/yahoo/jdisc/http/server/jetty/MetricConsumerMock.java b/container-core/src/test/java/com/yahoo/jdisc/http/server/jetty/MetricConsumerMock.java
new file mode 100644
index 00000000000..f839d83a800
--- /dev/null
+++ b/container-core/src/test/java/com/yahoo/jdisc/http/server/jetty/MetricConsumerMock.java
@@ -0,0 +1,28 @@
+// 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.google.inject.Module;
+import com.yahoo.jdisc.Metric;
+import com.yahoo.jdisc.application.MetricConsumer;
+
+import static org.mockito.ArgumentMatchers.anyMap;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * @author bjorncs
+ */
+class MetricConsumerMock {
+
+ static final Metric.Context STATIC_CONTEXT = new Metric.Context() {};
+
+ private final MetricConsumer mockitoMock = mock(MetricConsumer.class);
+
+ MetricConsumerMock() {
+ when(mockitoMock.createContext(anyMap())).thenReturn(STATIC_CONTEXT);
+ }
+
+ MetricConsumer mockitoMock() { return mockitoMock; }
+ Module asGuiceModule() { return binder -> binder.bind(MetricConsumer.class).toInstance(mockitoMock); }
+
+}
diff --git a/container-core/src/test/java/com/yahoo/jdisc/http/server/jetty/SimpleHttpClient.java b/container-core/src/test/java/com/yahoo/jdisc/http/server/jetty/SimpleHttpClient.java
new file mode 100644
index 00000000000..f1d710bd10f
--- /dev/null
+++ b/container-core/src/test/java/com/yahoo/jdisc/http/server/jetty/SimpleHttpClient.java
@@ -0,0 +1,202 @@
+// 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 org.apache.http.Header;
+import org.apache.http.HttpEntity;
+import org.apache.http.HttpResponse;
+import org.apache.http.client.entity.GzipCompressingEntity;
+import org.apache.http.client.methods.CloseableHttpResponse;
+import org.apache.http.client.methods.HttpGet;
+import org.apache.http.client.methods.HttpPost;
+import org.apache.http.client.methods.HttpUriRequest;
+import org.apache.http.config.Registry;
+import org.apache.http.config.RegistryBuilder;
+import org.apache.http.conn.socket.ConnectionSocketFactory;
+import org.apache.http.conn.ssl.DefaultHostnameVerifier;
+import org.apache.http.conn.ssl.SSLConnectionSocketFactory;
+import org.apache.http.entity.ByteArrayEntity;
+import org.apache.http.entity.StringEntity;
+import org.apache.http.entity.mime.FormBodyPart;
+import org.apache.http.entity.mime.MultipartEntityBuilder;
+import org.apache.http.impl.client.CloseableHttpClient;
+import org.apache.http.impl.client.HttpClientBuilder;
+import org.apache.http.impl.conn.BasicHttpClientConnectionManager;
+import org.apache.http.util.EntityUtils;
+import org.hamcrest.Matcher;
+import org.hamcrest.MatcherAssert;
+
+import javax.net.ssl.SSLContext;
+import java.io.IOException;
+import java.net.URI;
+import java.nio.charset.StandardCharsets;
+import java.util.Arrays;
+import java.util.List;
+
+import static org.hamcrest.CoreMatchers.is;
+import static org.hamcrest.CoreMatchers.not;
+import static org.hamcrest.CoreMatchers.nullValue;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.junit.Assert.assertNotNull;
+
+/**
+ * A simple http client for testing
+ *
+ * @author Simon Thoresen Hult
+ * @author bjorncs
+ */
+public class SimpleHttpClient implements AutoCloseable {
+
+ private final CloseableHttpClient delegate;
+ private final String scheme;
+ private final int listenPort;
+
+ public SimpleHttpClient(SSLContext sslContext, int listenPort, boolean useCompression) {
+ this(sslContext, null, null, listenPort, useCompression);
+ }
+
+ public SimpleHttpClient(SSLContext sslContext, List<String> enabledProtocols, List<String> enabledCiphers,
+ int listenPort, boolean useCompression) {
+ HttpClientBuilder builder = HttpClientBuilder.create();
+ if (!useCompression) {
+ builder.disableContentCompression();
+ }
+ if (sslContext != null) {
+ SSLConnectionSocketFactory sslConnectionFactory = new SSLConnectionSocketFactory(
+ sslContext,
+ toArray(enabledProtocols),
+ toArray(enabledCiphers),
+ new DefaultHostnameVerifier());
+ builder.setSSLSocketFactory(sslConnectionFactory);
+
+ Registry<ConnectionSocketFactory> registry = RegistryBuilder.<ConnectionSocketFactory>create()
+ .register("https", sslConnectionFactory)
+ .build();
+ builder.setConnectionManager(new BasicHttpClientConnectionManager(registry));
+ scheme = "https";
+ } else {
+ scheme = "http";
+ }
+ this.delegate = builder.build();
+ this.listenPort = listenPort;
+ }
+
+ private static String[] toArray(List<String> list) {
+ return list != null ? list.toArray(new String[0]) : null;
+ }
+
+ public URI newUri(final String path) {
+ return URI.create(scheme + "://localhost:" + listenPort + path);
+ }
+
+ public RequestExecutor newGet(String path) {
+ return newRequest(new HttpGet(newUri(path)));
+ }
+
+ public RequestExecutor newPost(String path) {
+ return newRequest(new HttpPost(newUri(path)));
+ }
+
+ public RequestExecutor newRequest(HttpUriRequest request) {
+ return new RequestExecutor().setRequest(request);
+ }
+
+ public ResponseValidator execute(HttpUriRequest request) throws IOException {
+ return newRequest(request).execute();
+ }
+
+ public ResponseValidator get(String path) throws IOException {
+ return newGet(path).execute();
+ }
+
+ @Override
+ public void close() throws IOException {
+ delegate.close();
+ }
+
+ public class RequestExecutor {
+
+ private HttpUriRequest request;
+ private HttpEntity entity;
+
+ public RequestExecutor setRequest(final HttpUriRequest request) {
+ this.request = request;
+ return this;
+ }
+
+ public RequestExecutor addHeader(final String name, final String value) {
+ this.request.addHeader(name, value);
+ return this;
+ }
+
+ public RequestExecutor setContent(final String content) {
+ this.entity = new StringEntity(content, StandardCharsets.UTF_8);
+ return this;
+ }
+
+ public RequestExecutor setGzipContent(String content) {
+ this.entity = new GzipCompressingEntity(new StringEntity(content, StandardCharsets.UTF_8));
+ return this;
+ }
+
+ public RequestExecutor setBinaryContent(final byte[] content) {
+ this.entity = new ByteArrayEntity(content);
+ return this;
+ }
+
+ public RequestExecutor setMultipartContent(final FormBodyPart... parts) {
+ MultipartEntityBuilder builder = MultipartEntityBuilder.create();
+ Arrays.stream(parts).forEach(part -> builder.addPart(part.getName(), part.getBody()));
+ this.entity = builder.build();
+ return this;
+ }
+
+ public ResponseValidator execute() throws IOException {
+ if (entity != null) {
+ ((HttpPost)request).setEntity(entity);
+ }
+ try (CloseableHttpResponse response = delegate.execute(request)){
+ return new ResponseValidator(response);
+ }
+ }
+ }
+
+ public static class ResponseValidator {
+
+ private final HttpResponse response;
+ private final String content;
+
+ public ResponseValidator(HttpResponse response) throws IOException {
+ this.response = response;
+
+ HttpEntity entity = response.getEntity();
+ this.content = entity == null ? null : EntityUtils.toString(entity, StandardCharsets.UTF_8);
+ }
+
+ public ResponseValidator expectStatusCode(Matcher<Integer> matcher) {
+ MatcherAssert.assertThat(response.getStatusLine().getStatusCode(), matcher);
+ return this;
+ }
+
+ public ResponseValidator expectHeader(String headerName, Matcher<String> matcher) {
+ Header firstHeader = response.getFirstHeader(headerName);
+ String headerValue = firstHeader != null ? firstHeader.getValue() : null;
+ MatcherAssert.assertThat(headerValue, matcher);
+ assertNotNull(firstHeader);
+ return this;
+ }
+
+ public ResponseValidator expectNoHeader(String headerName) {
+ Header firstHeader = response.getFirstHeader(headerName);
+ assertThat(firstHeader, is(nullValue()));
+ return this;
+ }
+
+ public ResponseValidator expectContent(final Matcher<String> matcher) {
+ MatcherAssert.assertThat(content, matcher);
+ return this;
+ }
+
+ }
+
+}
diff --git a/container-core/src/test/java/com/yahoo/jdisc/http/server/jetty/SslHandshakeFailedListenerTest.java b/container-core/src/test/java/com/yahoo/jdisc/http/server/jetty/SslHandshakeFailedListenerTest.java
new file mode 100644
index 00000000000..20f050d715d
--- /dev/null
+++ b/container-core/src/test/java/com/yahoo/jdisc/http/server/jetty/SslHandshakeFailedListenerTest.java
@@ -0,0 +1,42 @@
+// 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 org.eclipse.jetty.io.ssl.SslHandshakeListener;
+import org.junit.Test;
+
+import javax.net.ssl.SSLEngine;
+import javax.net.ssl.SSLHandshakeException;
+import java.util.Map;
+
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * @author mortent
+ */
+public class SslHandshakeFailedListenerTest {
+
+ private Metric metrics = mock(Metric.class);
+ SslHandshakeFailedListener listener = new SslHandshakeFailedListener(metrics, "connector", 1234);
+
+ @Test
+ public void includes_client_ip_dimension_present_when_peer_available() {
+ listener.handshakeFailed(handshakeEvent(true), new SSLHandshakeException("Empty server certificate chain"));
+ verify(metrics).createContext(eq(Map.of("clientIp", "127.0.0.1", "serverName", "connector", "serverPort", 1234)));
+ }
+
+ @Test
+ public void does_not_include_client_ip_dimension_present_when_peer_unavailable() {
+ listener.handshakeFailed(handshakeEvent(false), new SSLHandshakeException("Empty server certificate chain"));
+ verify(metrics).createContext(eq(Map.of("serverName", "connector", "serverPort", 1234)));
+ }
+
+ private SslHandshakeListener.Event handshakeEvent(boolean includePeer) {
+ var sslEngine = mock(SSLEngine.class);
+ if(includePeer) when(sslEngine.getPeerHost()).thenReturn("127.0.0.1");
+ return new SslHandshakeListener.Event(sslEngine);
+ }
+}
diff --git a/container-core/src/test/java/com/yahoo/jdisc/http/server/jetty/TestDriver.java b/container-core/src/test/java/com/yahoo/jdisc/http/server/jetty/TestDriver.java
new file mode 100644
index 00000000000..49928a5679e
--- /dev/null
+++ b/container-core/src/test/java/com/yahoo/jdisc/http/server/jetty/TestDriver.java
@@ -0,0 +1,78 @@
+// 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.Module;
+import com.yahoo.jdisc.application.ContainerBuilder;
+import com.yahoo.jdisc.handler.RequestHandler;
+import com.yahoo.jdisc.http.ConnectorConfig;
+import com.yahoo.security.SslContextBuilder;
+
+import javax.net.ssl.SSLContext;
+import java.nio.file.Paths;
+
+import static com.yahoo.yolean.Exceptions.uncheck;
+
+/**
+ * Provides functionality for setting up a jdisc container with an HTTP server and handlers.
+ *
+ * @author Simon Thoresen Hult
+ * @author bakksjo
+ */
+public class TestDriver {
+
+ private final com.yahoo.jdisc.test.TestDriver driver;
+ private final JettyHttpServer server;
+ private final SimpleHttpClient client;
+
+ private TestDriver(com.yahoo.jdisc.test.TestDriver driver, JettyHttpServer server, SimpleHttpClient client) {
+ this.driver = driver;
+ this.server = server;
+ this.client = client;
+ }
+
+ public static TestDriver newInstance(Class<? extends JettyHttpServer> serverClass,
+ RequestHandler requestHandler,
+ Module testConfig) {
+ com.yahoo.jdisc.test.TestDriver driver =
+ com.yahoo.jdisc.test.TestDriver.newSimpleApplicationInstance(testConfig);
+ ContainerBuilder builder = driver.newContainerBuilder();
+ JettyHttpServer server = builder.getInstance(serverClass);
+ builder.serverProviders().install(server);
+ builder.serverBindings().bind("http://*/*", requestHandler);
+ driver.activateContainer(builder);
+ server.start();
+
+ SimpleHttpClient client = new SimpleHttpClient(newSslContext(builder), server.getListenPort(), false);
+ return new TestDriver(driver, server, client);
+ }
+
+ public boolean close() {
+ server.close();
+ server.release();
+ uncheck(client::close);
+ return driver.close();
+ }
+
+ public JettyHttpServer server() { return server; }
+
+ public SimpleHttpClient client() { return client; }
+
+ public SimpleHttpClient newClient(final boolean useCompression) {
+ return new SimpleHttpClient(newSslContext(), server.getListenPort(), useCompression);
+ }
+
+ public SSLContext newSslContext() {
+ return newSslContext(driver.newContainerBuilder());
+ }
+
+ private static SSLContext newSslContext(ContainerBuilder builder) {
+ ConnectorConfig.Ssl sslConfig = builder.getInstance(ConnectorConfig.class).ssl();
+ if (!sslConfig.enabled()) return null;
+
+ return new SslContextBuilder()
+ .withKeyStore(Paths.get(sslConfig.privateKeyFile()), Paths.get(sslConfig.certificateFile()))
+ .withTrustStore(Paths.get(sslConfig.caCertificateFile()))
+ .build();
+ }
+
+}
diff --git a/container-core/src/test/java/com/yahoo/jdisc/http/server/jetty/TestDrivers.java b/container-core/src/test/java/com/yahoo/jdisc/http/server/jetty/TestDrivers.java
new file mode 100644
index 00000000000..7d7530c32e0
--- /dev/null
+++ b/container-core/src/test/java/com/yahoo/jdisc/http/server/jetty/TestDrivers.java
@@ -0,0 +1,94 @@
+// 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.AbstractModule;
+import com.google.inject.Module;
+import com.google.inject.util.Modules;
+import com.yahoo.container.logging.ConnectionLog;
+import com.yahoo.container.logging.RequestLog;
+import com.yahoo.jdisc.handler.RequestHandler;
+import com.yahoo.jdisc.http.ConnectorConfig;
+import com.yahoo.jdisc.http.ServerConfig;
+import com.yahoo.jdisc.http.ServletPathsConfig;
+import com.yahoo.jdisc.http.guiceModules.ConnectorFactoryRegistryModule;
+import com.yahoo.jdisc.http.guiceModules.ServletModule;
+
+import java.nio.file.Path;
+
+/**
+ * @author Simon Thoresen Hult
+ * @author bjorncs
+ */
+public class TestDrivers {
+
+ public static TestDriver newConfiguredInstance(RequestHandler requestHandler,
+ ServerConfig.Builder serverConfig,
+ ConnectorConfig.Builder connectorConfig,
+ Module... guiceModules) {
+ return TestDriver.newInstance(
+ JettyHttpServer.class,
+ requestHandler,
+ newConfigModule(serverConfig, connectorConfig, guiceModules));
+ }
+
+ public static TestDriver newInstance(RequestHandler requestHandler, Module... guiceModules) {
+ return TestDriver.newInstance(
+ JettyHttpServer.class,
+ requestHandler,
+ newConfigModule(
+ new ServerConfig.Builder(),
+ new ConnectorConfig.Builder(),
+ guiceModules
+ ));
+ }
+
+ public enum TlsClientAuth { NEED, WANT }
+
+ public static TestDriver newInstanceWithSsl(RequestHandler requestHandler,
+ Path certificateFile,
+ Path privateKeyFile,
+ TlsClientAuth tlsClientAuth,
+ Module... guiceModules) {
+ return TestDriver.newInstance(
+ JettyHttpServer.class,
+ requestHandler,
+ newConfigModule(
+ new ServerConfig.Builder().connectionLog(new ServerConfig.ConnectionLog.Builder().enabled(true)),
+ new ConnectorConfig.Builder()
+ .tlsClientAuthEnforcer(
+ new ConnectorConfig.TlsClientAuthEnforcer.Builder()
+ .enable(true)
+ .pathWhitelist("/status.html"))
+ .ssl(new ConnectorConfig.Ssl.Builder()
+ .enabled(true)
+ .clientAuth(tlsClientAuth == TlsClientAuth.NEED
+ ? ConnectorConfig.Ssl.ClientAuth.Enum.NEED_AUTH
+ : ConnectorConfig.Ssl.ClientAuth.Enum.WANT_AUTH)
+ .privateKeyFile(privateKeyFile.toString())
+ .certificateFile(certificateFile.toString())
+ .caCertificateFile(certificateFile.toString())),
+ guiceModules));
+ }
+
+ private static Module newConfigModule(ServerConfig.Builder serverConfig,
+ ConnectorConfig.Builder connectorConfigBuilder,
+ Module... guiceModules) {
+ return Modules.override(
+ Modules.combine(
+ new AbstractModule() {
+ @Override
+ protected void configure() {
+ bind(ServletPathsConfig.class).toInstance(new ServletPathsConfig(new ServletPathsConfig.Builder()));
+ bind(ServerConfig.class).toInstance(new ServerConfig(serverConfig));
+ bind(ConnectorConfig.class).toInstance(new ConnectorConfig(connectorConfigBuilder));
+ bind(FilterBindings.class).toInstance(new FilterBindings.Builder().build());
+ bind(ConnectionLog.class).toInstance(new VoidConnectionLog());
+ bind(RequestLog.class).toInstance(new VoidRequestLog());
+ }
+ },
+ new ConnectorFactoryRegistryModule(connectorConfigBuilder),
+ new ServletModule()))
+ .with(guiceModules);
+ }
+
+}
diff --git a/container-core/src/test/java/com/yahoo/jdisc/http/server/jetty/servlet/JDiscFilterForServletTest.java b/container-core/src/test/java/com/yahoo/jdisc/http/server/jetty/servlet/JDiscFilterForServletTest.java
new file mode 100644
index 00000000000..16969a47b84
--- /dev/null
+++ b/container-core/src/test/java/com/yahoo/jdisc/http/server/jetty/servlet/JDiscFilterForServletTest.java
@@ -0,0 +1,166 @@
+// 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.servlet;
+
+import com.google.inject.AbstractModule;
+import com.google.inject.Module;
+import com.google.inject.util.Modules;
+import com.yahoo.jdisc.AbstractResource;
+import com.yahoo.jdisc.Request;
+import com.yahoo.jdisc.Response;
+import com.yahoo.jdisc.handler.ContentChannel;
+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.server.jetty.FilterBindings;
+import com.yahoo.jdisc.http.server.jetty.FilterInvoker;
+import com.yahoo.jdisc.http.server.jetty.SimpleHttpClient.ResponseValidator;
+import com.yahoo.jdisc.http.server.jetty.TestDriver;
+import com.yahoo.jdisc.http.server.jetty.TestDrivers;
+import org.junit.Test;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.io.IOException;
+import java.net.URI;
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
+
+import static org.hamcrest.CoreMatchers.containsString;
+import static org.hamcrest.CoreMatchers.is;
+
+/**
+ * @author Tony Vaagenes
+ * @author bjorncs
+ */
+public class JDiscFilterForServletTest extends ServletTestBase {
+ @Test
+ public void request_filter_can_return_response() throws IOException, InterruptedException {
+ TestDriver testDriver = requestFilterTestDriver();
+ ResponseValidator response = httpGet(testDriver, TestServlet.PATH).execute();
+
+ response.expectContent(containsString(TestRequestFilter.responseContent));
+ }
+
+ @Test
+ public void request_can_be_forwarded_through_request_filter_to_servlet() throws IOException {
+ TestDriver testDriver = requestFilterTestDriver();
+ ResponseValidator response = httpGet(testDriver, TestServlet.PATH).
+ addHeader(TestRequestFilter.BYPASS_FILTER_HEADER, Boolean.TRUE.toString()).
+ execute();
+
+ response.expectContent(containsString(TestServlet.RESPONSE_CONTENT));
+ }
+
+ @Test
+ public void response_filter_can_modify_response() throws IOException {
+ TestDriver testDriver = responseFilterTestDriver();
+ ResponseValidator response = httpGet(testDriver, TestServlet.PATH).execute();
+
+ response.expectHeader(TestResponseFilter.INVOKED_HEADER, is(Boolean.TRUE.toString()));
+ }
+
+ @Test
+ public void response_filter_is_run_on_empty_sync_response() throws IOException {
+ TestDriver testDriver = responseFilterTestDriver();
+ ResponseValidator response = httpGet(testDriver, NoContentTestServlet.PATH).execute();
+
+ response.expectHeader(TestResponseFilter.INVOKED_HEADER, is(Boolean.TRUE.toString()));
+ }
+
+ @Test
+ public void response_filter_is_run_on_empty_async_response() throws IOException {
+ TestDriver testDriver = responseFilterTestDriver();
+ ResponseValidator response = httpGet(testDriver, NoContentTestServlet.PATH).
+ addHeader(NoContentTestServlet.HEADER_ASYNC, Boolean.TRUE.toString()).
+ execute();
+
+ response.expectHeader(TestResponseFilter.INVOKED_HEADER, is(Boolean.TRUE.toString()));
+ }
+
+ private TestDriver requestFilterTestDriver() throws IOException {
+ FilterBindings filterBindings = new FilterBindings.Builder()
+ .addRequestFilter("my-request-filter", new TestRequestFilter())
+ .addRequestFilterBinding("my-request-filter", "http://*/*")
+ .build();
+ return TestDrivers.newInstance(dummyRequestHandler, bindings(filterBindings));
+ }
+
+ private TestDriver responseFilterTestDriver() throws IOException {
+ FilterBindings filterBindings = new FilterBindings.Builder()
+ .addResponseFilter("my-response-filter", new TestResponseFilter())
+ .addResponseFilterBinding("my-response-filter", "http://*/*")
+ .build();
+ return TestDrivers.newInstance(dummyRequestHandler, bindings(filterBindings));
+ }
+
+
+
+ private Module bindings(FilterBindings filterBindings) {
+ return Modules.combine(
+ new AbstractModule() {
+ @Override
+ protected void configure() {
+ bind(FilterBindings.class).toInstance(filterBindings);
+ bind(FilterInvoker.class).toInstance(new FilterInvoker() {
+ @Override
+ public HttpServletRequest invokeRequestFilterChain(
+ RequestFilter requestFilter,
+ URI uri,
+ HttpServletRequest httpRequest,
+ ResponseHandler responseHandler) {
+ TestRequestFilter filter = (TestRequestFilter) requestFilter;
+ filter.runAsSecurityFilter(httpRequest, responseHandler);
+ return httpRequest;
+ }
+
+ @Override
+ public void invokeResponseFilterChain(
+ ResponseFilter responseFilter,
+ URI uri,
+ HttpServletRequest request,
+ HttpServletResponse response) {
+
+ TestResponseFilter filter = (TestResponseFilter) responseFilter;
+ filter.runAsSecurityFilter(request, response);
+ }
+ });
+ }
+ },
+ guiceModule());
+ }
+
+ static class TestRequestFilter extends AbstractResource implements RequestFilter {
+ static final String simpleName = TestRequestFilter.class.getSimpleName();
+ static final String responseContent = "Rejected by " + simpleName;
+ static final String BYPASS_FILTER_HEADER = "BYPASS_HEADER" + simpleName;
+
+ @Override
+ public void filter(HttpRequest request, ResponseHandler handler) {
+ throw new UnsupportedOperationException();
+ }
+
+ public void runAsSecurityFilter(HttpServletRequest request, ResponseHandler responseHandler) {
+ if (Boolean.parseBoolean(request.getHeader(BYPASS_FILTER_HEADER)))
+ return;
+
+ ContentChannel contentChannel = responseHandler.handleResponse(new Response(500));
+ contentChannel.write(ByteBuffer.wrap(responseContent.getBytes(StandardCharsets.UTF_8)), null);
+ contentChannel.close(null);
+ }
+ }
+
+
+ static class TestResponseFilter extends AbstractResource implements ResponseFilter {
+ static final String INVOKED_HEADER = TestResponseFilter.class.getSimpleName() + "_INVOKED_HEADER";
+
+ @Override
+ public void filter(Response response, Request request) {
+ throw new UnsupportedClassVersionError();
+ }
+
+ public void runAsSecurityFilter(HttpServletRequest request, HttpServletResponse response) {
+ response.addHeader(INVOKED_HEADER, Boolean.TRUE.toString());
+ }
+ }
+}
diff --git a/container-core/src/test/java/com/yahoo/jdisc/http/server/jetty/servlet/ServletAccessLoggingTest.java b/container-core/src/test/java/com/yahoo/jdisc/http/server/jetty/servlet/ServletAccessLoggingTest.java
new file mode 100644
index 00000000000..a533a447f6a
--- /dev/null
+++ b/container-core/src/test/java/com/yahoo/jdisc/http/server/jetty/servlet/ServletAccessLoggingTest.java
@@ -0,0 +1,64 @@
+// 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.servlet;
+
+import com.google.inject.AbstractModule;
+import com.google.inject.Module;
+import com.google.inject.util.Modules;
+import com.yahoo.container.logging.AccessLog;
+import com.yahoo.container.logging.RequestLog;
+import com.yahoo.container.logging.RequestLogEntry;
+import com.yahoo.jdisc.http.server.jetty.TestDriver;
+import com.yahoo.jdisc.http.server.jetty.TestDrivers;
+import org.junit.Test;
+import org.mockito.verification.VerificationMode;
+
+import java.io.IOException;
+import java.util.concurrent.TimeUnit;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.timeout;
+import static org.mockito.Mockito.verify;
+
+/**
+ * @author bakksjo
+ * @author bjorncs
+ */
+public class ServletAccessLoggingTest extends ServletTestBase {
+ private static final long MAX_LOG_WAIT_TIME_MILLIS = TimeUnit.SECONDS.toMillis(60);
+
+ @Test
+ public void accessLogIsInvokedForNonJDiscServlet() throws Exception {
+ final AccessLog accessLog = mock(AccessLog.class);
+ final TestDriver testDriver = newTestDriver(accessLog);
+ httpGet(testDriver, TestServlet.PATH).execute();
+ verifyCallsLog(accessLog, timeout(MAX_LOG_WAIT_TIME_MILLIS).times(1));
+ }
+
+ @Test
+ public void accessLogIsInvokedForJDiscServlet() throws Exception {
+ final AccessLog accessLog = mock(AccessLog.class);
+ final TestDriver testDriver = newTestDriver(accessLog);
+ testDriver.client().newGet("/status.html").execute();
+ verifyCallsLog(accessLog, timeout(MAX_LOG_WAIT_TIME_MILLIS).times(1));
+ }
+
+ private void verifyCallsLog(RequestLog requestLog, final VerificationMode verificationMode) {
+ verify(requestLog, verificationMode).log(any(RequestLogEntry.class));
+ }
+
+ private TestDriver newTestDriver(RequestLog requestLog) throws IOException {
+ return TestDrivers.newInstance(dummyRequestHandler, bindings(requestLog));
+ }
+
+ private Module bindings(RequestLog requestLog) {
+ return Modules.combine(
+ new AbstractModule() {
+ @Override
+ protected void configure() {
+ bind(RequestLog.class).toInstance(requestLog);
+ }
+ },
+ guiceModule());
+ }
+}
diff --git a/container-core/src/test/java/com/yahoo/jdisc/http/server/jetty/servlet/ServletTestBase.java b/container-core/src/test/java/com/yahoo/jdisc/http/server/jetty/servlet/ServletTestBase.java
new file mode 100644
index 00000000000..54bfe8c026d
--- /dev/null
+++ b/container-core/src/test/java/com/yahoo/jdisc/http/server/jetty/servlet/ServletTestBase.java
@@ -0,0 +1,132 @@
+// 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.servlet;
+
+import com.google.inject.AbstractModule;
+import com.google.inject.Module;
+import com.google.inject.TypeLiteral;
+import com.yahoo.component.ComponentId;
+import com.yahoo.component.provider.ComponentRegistry;
+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.ServletPathsConfig;
+import com.yahoo.jdisc.http.ServletPathsConfig.Servlets.Builder;
+import com.yahoo.jdisc.http.server.jetty.SimpleHttpClient.RequestExecutor;
+import com.yahoo.jdisc.http.server.jetty.TestDriver;
+import org.eclipse.jetty.servlet.ServletHolder;
+
+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.io.PrintWriter;
+import java.util.List;
+
+/**
+ * @author Tony Vaagenes
+ * @author bakksjo
+ */
+public class ServletTestBase {
+
+ private static class ServletInstance {
+ final ComponentId componentId; final String path; final HttpServlet instance;
+
+ ServletInstance(ComponentId componentId, String path, HttpServlet instance) {
+ this.componentId = componentId;
+ this.path = path;
+ this.instance = instance;
+ }
+ }
+
+ private final List<ServletInstance> servlets = List.of(
+ new ServletInstance(TestServlet.ID, TestServlet.PATH, new TestServlet()),
+ new ServletInstance(NoContentTestServlet.ID, NoContentTestServlet.PATH, new NoContentTestServlet()));
+
+ protected RequestExecutor httpGet(TestDriver testDriver, String path) {
+ return testDriver.client().newGet("/" + path);
+ }
+
+ protected ServletPathsConfig createServletPathConfig() {
+ ServletPathsConfig.Builder configBuilder = new ServletPathsConfig.Builder();
+
+ servlets.forEach(servlet ->
+ configBuilder.servlets(
+ servlet.componentId.stringValue(),
+ new Builder().path(servlet.path)));
+
+ return new ServletPathsConfig(configBuilder);
+ }
+
+ protected ComponentRegistry<ServletHolder> servlets() {
+ ComponentRegistry<ServletHolder> result = new ComponentRegistry<>();
+
+ servlets.forEach(servlet ->
+ result.register(servlet.componentId, new ServletHolder(servlet.instance)));
+
+ result.freeze();
+ return result;
+ }
+
+ protected Module guiceModule() {
+ return new AbstractModule() {
+ @Override
+ protected void configure() {
+ bind(new TypeLiteral<ComponentRegistry<ServletHolder>>(){}).toInstance(servlets());
+ bind(ServletPathsConfig.class).toInstance(createServletPathConfig());
+ }
+ };
+ }
+
+ protected static class TestServlet extends HttpServlet {
+ static final String PATH = "servlet/test-servlet";
+ static final ComponentId ID = ComponentId.fromString("test-servlet");
+ static final String RESPONSE_CONTENT = "Response from " + TestServlet.class.getSimpleName();
+
+ @Override
+ protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
+ response.setContentType("text/plain");
+ PrintWriter writer = response.getWriter();
+ writer.write(RESPONSE_CONTENT);
+ writer.close();
+ }
+ }
+
+ @WebServlet(asyncSupported = true)
+ protected static class NoContentTestServlet extends HttpServlet {
+ static final String HEADER_ASYNC = "HEADER_ASYNC";
+
+ static final String PATH = "servlet/no-content-test-servlet";
+ static final ComponentId ID = ComponentId.fromString("no-content-test-servlet");
+
+ @Override
+ protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
+ if (request.getHeader(HEADER_ASYNC) != null) {
+ asyncGet(request);
+ }
+ }
+
+ private void asyncGet(HttpServletRequest request) {
+ request.startAsync().start(() -> {
+ try {
+ Thread.sleep(100);
+ } catch (InterruptedException e) {
+ log("Interrupted", e);
+ } finally {
+ request.getAsyncContext().complete();
+ }
+ });
+ }
+ }
+
+
+ protected static final RequestHandler dummyRequestHandler = new AbstractRequestHandler() {
+ @Override
+ public ContentChannel handleRequest(Request request, ResponseHandler handler) {
+ throw new UnsupportedOperationException();
+ }
+ };
+}
diff --git a/container-core/src/test/java/com/yahoo/jdisc/http/ssl/impl/TlsContextBasedProviderTest.java b/container-core/src/test/java/com/yahoo/jdisc/http/ssl/impl/TlsContextBasedProviderTest.java
new file mode 100644
index 00000000000..eb292199ea2
--- /dev/null
+++ b/container-core/src/test/java/com/yahoo/jdisc/http/ssl/impl/TlsContextBasedProviderTest.java
@@ -0,0 +1,71 @@
+// 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.ssl.impl;
+
+import com.yahoo.security.KeyUtils;
+import com.yahoo.security.X509CertificateBuilder;
+import com.yahoo.security.tls.AuthorizationMode;
+import com.yahoo.security.tls.DefaultTlsContext;
+import com.yahoo.security.tls.HostnameVerification;
+import com.yahoo.security.tls.PeerAuthentication;
+import com.yahoo.security.tls.TlsContext;
+import com.yahoo.security.tls.policy.AuthorizedPeers;
+import org.eclipse.jetty.util.ssl.SslContextFactory;
+import org.junit.Test;
+
+import javax.security.auth.x500.X500Principal;
+import java.math.BigInteger;
+import java.security.KeyPair;
+import java.security.cert.X509Certificate;
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+import java.util.List;
+import java.util.Set;
+
+import static com.yahoo.security.KeyAlgorithm.EC;
+import static com.yahoo.security.SignatureAlgorithm.SHA256_WITH_ECDSA;
+import static org.junit.Assert.assertArrayEquals;
+import static org.junit.Assert.assertNotNull;
+
+/**
+ * @author bjorncs
+ */
+public class TlsContextBasedProviderTest {
+
+ @Test
+ public void creates_sslcontextfactory_from_tlscontext() {
+ TlsContext tlsContext = createTlsContext();
+ var provider = new SimpleTlsContextBasedProvider(tlsContext);
+ SslContextFactory sslContextFactory = provider.getInstance("dummyContainerId", 8080);
+ assertNotNull(sslContextFactory);
+ assertArrayEquals(tlsContext.parameters().getCipherSuites(), sslContextFactory.getIncludeCipherSuites());
+ }
+
+ private static TlsContext createTlsContext() {
+ KeyPair keyPair = KeyUtils.generateKeypair(EC);
+ X509Certificate certificate = X509CertificateBuilder
+ .fromKeypair(
+ keyPair,
+ new X500Principal("CN=dummy"),
+ Instant.EPOCH,
+ Instant.EPOCH.plus(100000, ChronoUnit.DAYS),
+ SHA256_WITH_ECDSA,
+ BigInteger.ONE)
+ .build();
+ return new DefaultTlsContext(
+ List.of(certificate), keyPair.getPrivate(), List.of(certificate), new AuthorizedPeers(Set.of()), AuthorizationMode.ENFORCE, PeerAuthentication.NEED, HostnameVerification.ENABLED);
+ }
+
+ private static class SimpleTlsContextBasedProvider extends TlsContextBasedProvider {
+ final TlsContext tlsContext;
+
+ SimpleTlsContextBasedProvider(TlsContext tlsContext) {
+ this.tlsContext = tlsContext;
+ }
+
+ @Override
+ protected TlsContext getTlsContext(String containerId, int port) {
+ return tlsContext;
+ }
+
+ }
+} \ No newline at end of file