diff options
Diffstat (limited to 'container-core/src/main/java')
113 files changed, 11602 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. 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; |