diff --git a/test-proxy/pom.xml b/test-proxy/pom.xml new file mode 100644 index 0000000..ec1059b --- /dev/null +++ b/test-proxy/pom.xml @@ -0,0 +1,67 @@ + + + + 4.0.0 + + com.olexyn.test.proxy + test-proxy + 0.1 + war + + test-proxy Maven Webapp + + http://www.example.com + + + UTF-8 + 1.7 + 1.7 + + + + + junit + junit + 4.11 + test + + + + + test-proxy + + + + maven-clean-plugin + 3.1.0 + + + + maven-resources-plugin + 3.0.2 + + + maven-compiler-plugin + 3.8.0 + + + maven-surefire-plugin + 2.22.1 + + + maven-war-plugin + 3.2.2 + + + maven-install-plugin + 2.5.2 + + + maven-deploy-plugin + 2.8.2 + + + + + diff --git a/test-proxy/src/main/java/module-info.java b/test-proxy/src/main/java/module-info.java new file mode 100644 index 0000000..7457afe --- /dev/null +++ b/test-proxy/src/main/java/module-info.java @@ -0,0 +1,36 @@ +// +// ======================================================================== +// Copyright (c) 1995-2020 Mort Bay Consulting Pty Ltd and others. +// +// This program and the accompanying materials are made available under +// the terms of the Eclipse Public License 2.0 which is available at +// https://www.eclipse.org/legal/epl-2.0 +// +// This Source Code may also be made available under the following +// Secondary Licenses when the conditions for such availability set +// forth in the Eclipse Public License, v. 2.0 are satisfied: +// the Apache License v2.0 which is available at +// https://www.apache.org/licenses/LICENSE-2.0 +// +// SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 +// ======================================================================== +// + +// This module is a mixed bag of things. +// There are some utility classes that only depend on Servlet APIs, +// but other utility classes that depend on some Jetty module. +module org.eclipse.jetty.servlets +{ + exports org.eclipse.jetty.servlets; + + requires transitive jetty.servlet.api; + requires org.slf4j; + + // Only required if using CloseableDoSFilter. + requires static org.eclipse.jetty.io; + // Only required if using DoSFilter, PushCacheFilter, etc. + requires static org.eclipse.jetty.http; + requires static org.eclipse.jetty.server; + // Only required if using CrossOriginFilter, DoSFilter, etc. + requires static org.eclipse.jetty.util; +} diff --git a/test-proxy/src/main/java/servlets/CGI.java b/test-proxy/src/main/java/servlets/CGI.java new file mode 100644 index 0000000..c462e7a --- /dev/null +++ b/test-proxy/src/main/java/servlets/CGI.java @@ -0,0 +1,572 @@ +// +// ======================================================================== +// Copyright (c) 1995-2020 Mort Bay Consulting Pty Ltd and others. +// +// This program and the accompanying materials are made available under +// the terms of the Eclipse Public License 2.0 which is available at +// https://www.eclipse.org/legal/epl-2.0 +// +// This Source Code may also be made available under the following +// Secondary Licenses when the conditions for such availability set +// forth in the Eclipse Public License, v. 2.0 are satisfied: +// the Apache License v2.0 which is available at +// https://www.apache.org/licenses/LICENSE-2.0 +// +// SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 +// ======================================================================== +// + +package servlets; + +import org.eclipse.jetty.http.HttpMethod; +import org.eclipse.jetty.util.IO; +import org.eclipse.jetty.util.MultiMap; +import org.eclipse.jetty.util.StringUtil; +import org.eclipse.jetty.util.UrlEncoded; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.servlet.AsyncContext; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServlet; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.*; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.util.Enumeration; +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; + +/** + * CGI Servlet. + *

+ * The following init parameters are used to configure this servlet: + *

+ *
cgibinResourceBase
+ *
Path to the cgi bin directory if set or it will default to the resource base of the context.
+ *
resourceBase
+ *
An alias for cgibinResourceBase.
+ *
cgibinResourceBaseIsRelative
+ *
If true then cgibinResourceBase is relative to the webapp (eg "WEB-INF/cgi")
+ *
commandPrefix
+ *
may be used to set a prefix to all commands passed to exec. This can be used on systems that need assistance to execute a + * particular file type. For example on windows this can be set to "perl" so that perl scripts are executed.
+ *
Path
+ *
passed to the exec environment as PATH.
+ *
ENV_*
+ *
used to set an arbitrary environment variable with the name stripped of the leading ENV_ and using the init parameter value
+ *
useFullPath
+ *
If true, the full URI path within the context is used for the exec command, otherwise a search is done for a partial URL that matches an exec Command
+ *
ignoreExitState
+ *
If true then do not act on a non-zero exec exit status")
+ *
+ */ +public class CGI extends HttpServlet +{ + private static final long serialVersionUID = -6182088932884791074L; + + private static final Logger LOG = LoggerFactory.getLogger(CGI.class); + + private boolean _ok; + private File _docRoot; + private boolean _cgiBinProvided; + private String _path; + private String _cmdPrefix; + private boolean _useFullPath; + private EnvList _env; + private boolean _ignoreExitState; + private boolean _relative; + + @Override + public void init() throws ServletException + { + _env = new EnvList(); + _cmdPrefix = getInitParameter("commandPrefix"); + _useFullPath = Boolean.parseBoolean(getInitParameter("useFullPath")); + _relative = Boolean.parseBoolean(getInitParameter("cgibinResourceBaseIsRelative")); + + String tmp = getInitParameter("cgibinResourceBase"); + if (tmp != null) + _cgiBinProvided = true; + else + { + tmp = getInitParameter("resourceBase"); + if (tmp != null) + _cgiBinProvided = true; + else + tmp = getServletContext().getRealPath("/"); + } + + if (_relative && _cgiBinProvided) + { + tmp = getServletContext().getRealPath(tmp); + } + + if (tmp == null) + { + LOG.warn("CGI: no CGI bin !"); + return; + } + + File dir = new File(tmp); + if (!dir.exists()) + { + LOG.warn("CGI: CGI bin does not exist - " + dir); + return; + } + + if (!dir.canRead()) + { + LOG.warn("CGI: CGI bin is not readable - " + dir); + return; + } + + if (!dir.isDirectory()) + { + LOG.warn("CGI: CGI bin is not a directory - " + dir); + return; + } + + try + { + _docRoot = dir.getCanonicalFile(); + } + catch (IOException e) + { + LOG.warn("CGI: CGI bin failed - " + dir, e); + return; + } + + _path = getInitParameter("Path"); + if (_path != null) + _env.set("PATH", _path); + + _ignoreExitState = "true".equalsIgnoreCase(getInitParameter("ignoreExitState")); + Enumeration e = getInitParameterNames(); + while (e.hasMoreElements()) + { + String n = e.nextElement(); + if (n != null && n.startsWith("ENV_")) + _env.set(n.substring(4), getInitParameter(n)); + } + if (!_env.envMap.containsKey("SystemRoot")) + { + String os = System.getProperty("os.name"); + if (os != null && os.toLowerCase(Locale.ENGLISH).contains("windows")) + { + _env.set("SystemRoot", "C:\\WINDOWS"); + } + } + + _ok = true; + } + + @Override + public void service(HttpServletRequest req, HttpServletResponse res) throws ServletException, IOException + { + if (!_ok) + { + res.sendError(HttpServletResponse.SC_SERVICE_UNAVAILABLE); + return; + } + + if (LOG.isDebugEnabled()) + { + LOG.debug("CGI: ContextPath : " + req.getContextPath()); + LOG.debug("CGI: ServletPath : " + req.getServletPath()); + LOG.debug("CGI: PathInfo : " + req.getPathInfo()); + LOG.debug("CGI: _docRoot : " + _docRoot); + LOG.debug("CGI: _path : " + _path); + LOG.debug("CGI: _ignoreExitState: " + _ignoreExitState); + } + + // pathInContext may actually comprises scriptName/pathInfo...We will + // walk backwards up it until we find the script - the rest must + // be the pathInfo; + String pathInContext = (_relative ? "" : StringUtil.nonNull(req.getServletPath())) + StringUtil.nonNull(req.getPathInfo()); + File execCmd = new File(_docRoot, pathInContext); + String pathInfo = pathInContext; + + if (!_useFullPath) + { + String path = pathInContext; + String info = ""; + + // Search docroot for a matching execCmd + while ((path.endsWith("/") || !execCmd.exists()) && path.length() >= 0) + { + int index = path.lastIndexOf('/'); + path = path.substring(0, index); + info = pathInContext.substring(index, pathInContext.length()); + execCmd = new File(_docRoot, path); + } + + if (path.length() == 0 || !execCmd.exists() || execCmd.isDirectory() || !execCmd.getCanonicalPath().equals(execCmd.getAbsolutePath())) + { + res.sendError(404); + } + + pathInfo = info; + } + exec(execCmd, pathInfo, req, res); + } + + /** + * executes the CGI process + * + * @param command the command to execute, this command is prefixed by + * the context parameter "commandPrefix". + * @param pathInfo The PATH_INFO to process, + * see http://docs.oracle.com/javaee/6/api/javax/servlet/http/HttpServletRequest.html#getPathInfo%28%29. Cannot be null + * @param req the HTTP request + * @param res the HTTP response + * @throws IOException if the execution of the CGI process throws + */ + private void exec(File command, String pathInfo, HttpServletRequest req, HttpServletResponse res) throws IOException + { + assert req != null; + assert res != null; + assert pathInfo != null; + assert command != null; + + if (LOG.isDebugEnabled()) + { + LOG.debug("CGI: script is " + command); + LOG.debug("CGI: pathInfo is " + pathInfo); + } + + String bodyFormEncoded = null; + if ((HttpMethod.POST.is(req.getMethod()) || HttpMethod.PUT.is(req.getMethod())) && "application/x-www-form-urlencoded".equals(req.getContentType())) + { + MultiMap parameterMap = new MultiMap<>(); + Enumeration names = req.getParameterNames(); + while (names.hasMoreElements()) + { + String parameterName = names.nextElement(); + parameterMap.addValues(parameterName, req.getParameterValues(parameterName)); + } + + String characterEncoding = req.getCharacterEncoding(); + Charset charset = characterEncoding != null + ? Charset.forName(characterEncoding) : StandardCharsets.UTF_8; + bodyFormEncoded = UrlEncoded.encode(parameterMap, charset, true); + } + + EnvList env = new EnvList(_env); + // these ones are from "The WWW Common Gateway Interface Version 1.1" + // look at : + // http://Web.Golux.Com/coar/cgi/draft-coar-cgi-v11-03-clean.html#6.1.1 + env.set("AUTH_TYPE", req.getAuthType()); + + int contentLen = req.getContentLength(); + if (contentLen < 0) + contentLen = 0; + if (bodyFormEncoded != null) + { + env.set("CONTENT_LENGTH", Integer.toString(bodyFormEncoded.length())); + } + else + { + env.set("CONTENT_LENGTH", Integer.toString(contentLen)); + } + env.set("CONTENT_TYPE", req.getContentType()); + env.set("GATEWAY_INTERFACE", "CGI/1.1"); + if (pathInfo.length() > 0) + { + env.set("PATH_INFO", pathInfo); + } + + String pathTranslated = req.getPathTranslated(); + if ((pathTranslated == null) || (pathTranslated.length() == 0)) + pathTranslated = pathInfo; + env.set("PATH_TRANSLATED", pathTranslated); + env.set("QUERY_STRING", req.getQueryString()); + env.set("REMOTE_ADDR", req.getRemoteAddr()); + env.set("REMOTE_HOST", req.getRemoteHost()); + + // The identity information reported about the connection by a + // RFC 1413 [11] request to the remote agent, if + // available. Servers MAY choose not to support this feature, or + // not to request the data for efficiency reasons. + // "REMOTE_IDENT" => "NYI" + env.set("REMOTE_USER", req.getRemoteUser()); + env.set("REQUEST_METHOD", req.getMethod()); + + String scriptPath; + String scriptName; + // use docRoot for scriptPath, too + if (_cgiBinProvided) + { + scriptPath = command.getAbsolutePath(); + scriptName = scriptPath.substring(_docRoot.getAbsolutePath().length()); + } + else + { + String requestURI = req.getRequestURI(); + scriptName = requestURI.substring(0, requestURI.length() - pathInfo.length()); + scriptPath = getServletContext().getRealPath(scriptName); + } + env.set("SCRIPT_FILENAME", scriptPath); + env.set("SCRIPT_NAME", scriptName); + + env.set("SERVER_NAME", req.getServerName()); + env.set("SERVER_PORT", Integer.toString(req.getServerPort())); + env.set("SERVER_PROTOCOL", req.getProtocol()); + env.set("SERVER_SOFTWARE", getServletContext().getServerInfo()); + + Enumeration enm = req.getHeaderNames(); + while (enm.hasMoreElements()) + { + String name = enm.nextElement(); + if (name.equalsIgnoreCase("Proxy")) + continue; + String value = req.getHeader(name); + env.set("HTTP_" + StringUtil.replace(name.toUpperCase(Locale.ENGLISH), '-', '_'), value); + } + + // these extra ones were from printenv on www.dev.nomura.co.uk + env.set("HTTPS", (req.isSecure() ? "ON" : "OFF")); + // "DOCUMENT_ROOT" => root + "/docs", + // "SERVER_URL" => "NYI - http://us0245", + // "TZ" => System.getProperty("user.timezone"), + + // are we meant to decode args here? or does the script get them + // via PATH_INFO? if we are, they should be decoded and passed + // into exec here... + String absolutePath = command.getAbsolutePath(); + String execCmd = absolutePath; + + // escape the execCommand + if (execCmd.length() > 0 && execCmd.charAt(0) != '"' && execCmd.contains(" ")) + execCmd = "\"" + execCmd + "\""; + + if (_cmdPrefix != null) + execCmd = _cmdPrefix + " " + execCmd; + + LOG.debug("Environment: " + env.getExportString()); + LOG.debug("Command: " + execCmd); + + final Process p = Runtime.getRuntime().exec(execCmd, env.getEnvArray(), _docRoot); + + // hook processes input to browser's output (async) + if (bodyFormEncoded != null) + writeProcessInput(p, bodyFormEncoded); + else if (contentLen > 0) + writeProcessInput(p, req.getInputStream(), contentLen); + + // hook processes output to browser's input (sync) + // if browser closes stream, we should detect it and kill process... + OutputStream os = null; + AsyncContext async = req.startAsync(); + try + { + async.start(new Runnable() + { + @Override + public void run() + { + try + { + IO.copy(p.getErrorStream(), System.err); + } + catch (IOException e) + { + LOG.warn("Unable to copy error stream", e); + } + } + }); + + // read any headers off the top of our input stream + // NOTE: Multiline header items not supported! + String line = null; + InputStream inFromCgi = p.getInputStream(); + + // br=new BufferedReader(new InputStreamReader(inFromCgi)); + // while ((line=br.readLine())!=null) + while ((line = getTextLineFromStream(inFromCgi)).length() > 0) + { + if (!line.startsWith("HTTP")) + { + int k = line.indexOf(':'); + if (k > 0) + { + String key = line.substring(0, k).trim(); + String value = line.substring(k + 1).trim(); + if ("Location".equals(key)) + { + res.sendRedirect(res.encodeRedirectURL(value)); + } + else if ("Status".equals(key)) + { + String[] token = value.split(" "); + int status = Integer.parseInt(token[0]); + res.setStatus(status); + } + else + { + // add remaining header items to our response header + res.addHeader(key, value); + } + } + } + } + // copy cgi content to response stream... + os = res.getOutputStream(); + IO.copy(inFromCgi, os); + p.waitFor(); + + if (!_ignoreExitState) + { + int exitValue = p.exitValue(); + if (0 != exitValue) + { + LOG.warn("Non-zero exit status (" + exitValue + ") from CGI program: " + absolutePath); + if (!res.isCommitted()) + res.sendError(500, "Failed to exec CGI"); + } + } + } + catch (IOException e) + { + // browser has probably closed its input stream - we + // terminate and clean up... + LOG.debug("CGI: Client closed connection!", e); + } + catch (InterruptedException ex) + { + LOG.debug("CGI: interrupted!"); + } + finally + { + IO.close(os); + p.destroy(); + // LOG.debug("CGI: terminated!"); + async.complete(); + } + } + + private static void writeProcessInput(final Process p, final String input) + { + new Thread(new Runnable() + { + @Override + public void run() + { + try + { + try (Writer outToCgi = new OutputStreamWriter(p.getOutputStream())) + { + outToCgi.write(input); + } + } + catch (IOException e) + { + LOG.debug("Unable to write out to CGI", e); + } + } + }).start(); + } + + private static void writeProcessInput(final Process p, final InputStream input, final int len) + { + if (len <= 0) + return; + + new Thread(new Runnable() + { + @Override + public void run() + { + try + { + try (OutputStream outToCgi = p.getOutputStream()) + { + IO.copy(input, outToCgi, len); + } + } + catch (IOException e) + { + LOG.debug("Unable to write out to CGI", e); + } + } + }).start(); + } + + /** + * Utility method to get a line of text from the input stream. + * + * @param is the input stream + * @return the line of text + * @throws IOException if reading from the input stream throws + */ + private static String getTextLineFromStream(InputStream is) throws IOException + { + StringBuilder buffer = new StringBuilder(); + int b; + + while ((b = is.read()) != -1 && b != '\n') + { + buffer.append((char)b); + } + return buffer.toString().trim(); + } + + /** + * private utility class that manages the Environment passed to exec. + */ + private static class EnvList + { + private Map envMap; + + EnvList() + { + envMap = new HashMap<>(); + } + + EnvList(EnvList l) + { + envMap = new HashMap<>(l.envMap); + } + + /** + * Set a name/value pair, null values will be treated as an empty String + * + * @param name the name + * @param value the value + */ + public void set(String name, String value) + { + envMap.put(name, name + "=" + StringUtil.nonNull(value)); + } + + /** + * Get representation suitable for passing to exec. + * + * @return the env map as an array + */ + public String[] getEnvArray() + { + return envMap.values().toArray(new String[envMap.size()]); + } + + public String getExportString() + { + StringBuilder sb = new StringBuilder(); + for (String variable : getEnvArray()) + { + sb.append("export \""); + sb.append(variable); + sb.append("\"; "); + } + return sb.toString(); + } + + @Override + public String toString() + { + return envMap.toString(); + } + } +} diff --git a/test-proxy/src/main/java/servlets/CloseableDoSFilter.java b/test-proxy/src/main/java/servlets/CloseableDoSFilter.java new file mode 100644 index 0000000..dd4264b --- /dev/null +++ b/test-proxy/src/main/java/servlets/CloseableDoSFilter.java @@ -0,0 +1,39 @@ +// +// ======================================================================== +// Copyright (c) 1995-2020 Mort Bay Consulting Pty Ltd and others. +// +// This program and the accompanying materials are made available under +// the terms of the Eclipse Public License 2.0 which is available at +// https://www.eclipse.org/legal/epl-2.0 +// +// This Source Code may also be made available under the following +// Secondary Licenses when the conditions for such availability set +// forth in the Eclipse Public License, v. 2.0 are satisfied: +// the Apache License v2.0 which is available at +// https://www.apache.org/licenses/LICENSE-2.0 +// +// SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 +// ======================================================================== +// + +package servlets; + +import org.eclipse.jetty.server.Request; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +/** + * This is an extension to {@link DoSFilter} that uses Jetty APIs to + * abruptly close the connection when the request times out. + */ + +public class CloseableDoSFilter extends DoSFilter +{ + @Override + protected void onRequestTimeout(HttpServletRequest request, HttpServletResponse response, Thread handlingThread) + { + Request baseRequest = Request.getBaseRequest(request); + baseRequest.getHttpChannel().getEndPoint().close(); + } +} diff --git a/test-proxy/src/main/java/servlets/ConcatServlet.java b/test-proxy/src/main/java/servlets/ConcatServlet.java new file mode 100644 index 0000000..6af12af --- /dev/null +++ b/test-proxy/src/main/java/servlets/ConcatServlet.java @@ -0,0 +1,147 @@ +// +// ======================================================================== +// Copyright (c) 1995-2020 Mort Bay Consulting Pty Ltd and others. +// +// This program and the accompanying materials are made available under +// the terms of the Eclipse Public License 2.0 which is available at +// https://www.eclipse.org/legal/epl-2.0 +// +// This Source Code may also be made available under the following +// Secondary Licenses when the conditions for such availability set +// forth in the Eclipse Public License, v. 2.0 are satisfied: +// the Apache License v2.0 which is available at +// https://www.apache.org/licenses/LICENSE-2.0 +// +// SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 +// ======================================================================== +// + +package servlets; + +import org.eclipse.jetty.util.URIUtil; + +import javax.servlet.RequestDispatcher; +import javax.servlet.ServletContext; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServlet; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +/** + *

This servlet may be used to concatenate multiple resources into + * a single response.

+ *

It is intended to be used to load multiple + * javascript or css files, but may be used for any content of the + * same mime type that can be meaningfully concatenated.

+ *

The servlet uses {@link RequestDispatcher#include(javax.servlet.ServletRequest, javax.servlet.ServletResponse)} + * to combine the requested content, so dynamically generated content + * may be combined (Eg engine.js for DWR).

+ *

The servlet uses parameter names of the query string as resource names + * relative to the context root. So these script tags:

+ *
+ * <script type="text/javascript" src="../js/behaviour.js"></script>
+ * <script type="text/javascript" src="../js/ajax.js"></script>
+ * <script type="text/javascript" src="../chat/chat.js"></script>
+ * 
+ *

can be replaced with the single tag (with the {@code ConcatServlet} + * mapped to {@code /concat}):

+ *
+ * <script type="text/javascript" src="../concat?/js/behaviour.js&/js/ajax.js&/chat/chat.js"></script>
+ * 
+ *

The {@link ServletContext#getMimeType(String)} method is used to determine the + * mime type of each resource. If the types of all resources do not match, then a 415 + * UNSUPPORTED_MEDIA_TYPE error is returned.

+ *

If the init parameter {@code development} is set to {@code true} then the servlet + * will run in development mode and the content will be concatenated on every request.

+ *

Otherwise the init time of the servlet is used as the lastModifiedTime of the combined content + * and If-Modified-Since requests are handled with 304 NOT Modified responses if + * appropriate. This means that when not in development mode, the servlet must be + * restarted before changed content will be served.

+ */ +public class ConcatServlet extends HttpServlet +{ + private boolean _development; + private long _lastModified; + + @Override + public void init() throws ServletException + { + _lastModified = System.currentTimeMillis(); + _development = Boolean.parseBoolean(getInitParameter("development")); + } + + /* + * @return The start time of the servlet unless in development mode, in which case -1 is returned. + */ + @Override + protected long getLastModified(HttpServletRequest req) + { + return _development ? -1 : _lastModified; + } + + @Override + protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException + { + String query = request.getQueryString(); + if (query == null) + { + response.sendError(HttpServletResponse.SC_NO_CONTENT); + return; + } + + List dispatchers = new ArrayList<>(); + String[] parts = query.split("\\&"); + String type = null; + for (String part : parts) + { + String path = URIUtil.canonicalPath(URIUtil.decodePath(part)); + if (path == null) + { + response.sendError(HttpServletResponse.SC_NOT_FOUND); + return; + } + + // Verify that the path is not protected. + if (startsWith(path, "/WEB-INF/") || startsWith(path, "/META-INF/")) + { + response.sendError(HttpServletResponse.SC_NOT_FOUND); + return; + } + + String t = getServletContext().getMimeType(path); + if (t != null) + { + if (type == null) + { + type = t; + } + else if (!type.equals(t)) + { + response.sendError(HttpServletResponse.SC_UNSUPPORTED_MEDIA_TYPE); + return; + } + } + + RequestDispatcher dispatcher = getServletContext().getRequestDispatcher(path); + if (dispatcher != null) + dispatchers.add(dispatcher); + } + + if (type != null) + response.setContentType(type); + + for (RequestDispatcher dispatcher : dispatchers) + { + dispatcher.include(request, response); + } + } + + private boolean startsWith(String path, String prefix) + { + // Case insensitive match. + return prefix.regionMatches(true, 0, path, 0, prefix.length()); + } +} diff --git a/test-proxy/src/main/java/servlets/CrossOriginFilter.java b/test-proxy/src/main/java/servlets/CrossOriginFilter.java new file mode 100644 index 0000000..80167cc --- /dev/null +++ b/test-proxy/src/main/java/servlets/CrossOriginFilter.java @@ -0,0 +1,505 @@ +// +// ======================================================================== +// Copyright (c) 1995-2020 Mort Bay Consulting Pty Ltd and others. +// +// This program and the accompanying materials are made available under +// the terms of the Eclipse Public License 2.0 which is available at +// https://www.eclipse.org/legal/epl-2.0 +// +// This Source Code may also be made available under the following +// Secondary Licenses when the conditions for such availability set +// forth in the Eclipse Public License, v. 2.0 are satisfied: +// the Apache License v2.0 which is available at +// https://www.apache.org/licenses/LICENSE-2.0 +// +// SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 +// ======================================================================== +// + +package servlets; + +import org.eclipse.jetty.util.StringUtil; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.servlet.*; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.*; +import java.util.regex.Pattern; + +/** + * Implementation of the + * cross-origin resource sharing. + *

+ * A typical example is to use this filter to allow cross-domain + * cometd communication using the standard + * long polling transport instead of the JSONP transport (that is less + * efficient and less reactive to failures). + *

+ * This filter allows the following configuration parameters: + *

+ *
allowedOrigins
+ *
a comma separated list of origins that are + * allowed to access the resources. Default value is *, meaning all + * origins. Note that using wild cards can result in security problems + * for requests identifying hosts that do not exist. + *

+ * If an allowed origin contains one or more * characters (for example + * http://*.domain.com), then "*" characters are converted to ".*", "." + * characters are escaped to "\." and the resulting allowed origin + * interpreted as a regular expression. + *

+ * Allowed origins can therefore be more complex expressions such as + * https?://*.domain.[a-z]{3} that matches http or https, multiple subdomains + * and any 3 letter top-level domain (.com, .net, .org, etc.).

+ * + *
allowedTimingOrigins
+ *
a comma separated list of origins that are + * allowed to time the resource. Default value is the empty string, meaning + * no origins. + *

+ * The check whether the timing header is set, will be performed only if + * the user gets general access to the resource using the allowedOrigins. + * + *

allowedMethods
+ *
a comma separated list of HTTP methods that + * are allowed to be used when accessing the resources. Default value is + * GET,POST,HEAD
+ * + * + *
allowedHeaders
+ *
a comma separated list of HTTP headers that + * are allowed to be specified when accessing the resources. Default value + * is X-Requested-With,Content-Type,Accept,Origin. If the value is a single "*", + * this means that any headers will be accepted.
+ * + *
preflightMaxAge
+ *
the number of seconds that preflight requests + * can be cached by the client. Default value is 1800 seconds, or 30 + * minutes
+ * + *
allowCredentials
+ *
a boolean indicating if the resource allows + * requests with credentials. Default value is true
+ * + *
exposedHeaders
+ *
a comma separated list of HTTP headers that + * are allowed to be exposed on the client. Default value is the + * empty list
+ * + *
chainPreflight
+ *
if true preflight requests are chained to their + * target resource for normal handling (as an OPTION request). Otherwise the + * filter will response to the preflight. Default is true.
+ * + *
+ * A typical configuration could be: + *
+ * <web-app ...>
+ *     ...
+ *     <filter>
+ *         <filter-name>cross-origin</filter-name>
+ *         <filter-class>org.eclipse.jetty.servlets.CrossOriginFilter</filter-class>
+ *     </filter>
+ *     <filter-mapping>
+ *         <filter-name>cross-origin</filter-name>
+ *         <url-pattern>/cometd/*</url-pattern>
+ *     </filter-mapping>
+ *     ...
+ * </web-app>
+ * 
+ */ +public class CrossOriginFilter implements Filter +{ + private static final Logger LOG = LoggerFactory.getLogger(CrossOriginFilter.class); + + // Request headers + private static final String ORIGIN_HEADER = "Origin"; + public static final String ACCESS_CONTROL_REQUEST_METHOD_HEADER = "Access-Control-Request-Method"; + public static final String ACCESS_CONTROL_REQUEST_HEADERS_HEADER = "Access-Control-Request-Headers"; + // Response headers + public static final String ACCESS_CONTROL_ALLOW_ORIGIN_HEADER = "Access-Control-Allow-Origin"; + public static final String ACCESS_CONTROL_ALLOW_METHODS_HEADER = "Access-Control-Allow-Methods"; + public static final String ACCESS_CONTROL_ALLOW_HEADERS_HEADER = "Access-Control-Allow-Headers"; + public static final String ACCESS_CONTROL_MAX_AGE_HEADER = "Access-Control-Max-Age"; + public static final String ACCESS_CONTROL_ALLOW_CREDENTIALS_HEADER = "Access-Control-Allow-Credentials"; + public static final String ACCESS_CONTROL_EXPOSE_HEADERS_HEADER = "Access-Control-Expose-Headers"; + public static final String TIMING_ALLOW_ORIGIN_HEADER = "Timing-Allow-Origin"; + // Implementation constants + public static final String ALLOWED_ORIGINS_PARAM = "allowedOrigins"; + public static final String ALLOWED_TIMING_ORIGINS_PARAM = "allowedTimingOrigins"; + public static final String ALLOWED_METHODS_PARAM = "allowedMethods"; + public static final String ALLOWED_HEADERS_PARAM = "allowedHeaders"; + public static final String PREFLIGHT_MAX_AGE_PARAM = "preflightMaxAge"; + public static final String ALLOW_CREDENTIALS_PARAM = "allowCredentials"; + public static final String EXPOSED_HEADERS_PARAM = "exposedHeaders"; + public static final String OLD_CHAIN_PREFLIGHT_PARAM = "forwardPreflight"; + public static final String CHAIN_PREFLIGHT_PARAM = "chainPreflight"; + private static final String ANY_ORIGIN = "*"; + private static final String DEFAULT_ALLOWED_ORIGINS = "*"; + private static final String DEFAULT_ALLOWED_TIMING_ORIGINS = ""; + private static final List SIMPLE_HTTP_METHODS = Arrays.asList("GET", "POST", "HEAD"); + private static final List DEFAULT_ALLOWED_METHODS = Arrays.asList("GET", "POST", "HEAD"); + private static final List DEFAULT_ALLOWED_HEADERS = Arrays.asList("X-Requested-With", "Content-Type", "Accept", "Origin"); + + private boolean anyOriginAllowed; + private boolean anyTimingOriginAllowed; + private boolean anyHeadersAllowed; + private Set allowedOrigins = new HashSet(); + private List allowedOriginPatterns = new ArrayList(); + private Set allowedTimingOrigins = new HashSet(); + private List allowedTimingOriginPatterns = new ArrayList(); + private List allowedMethods = new ArrayList(); + private List allowedHeaders = new ArrayList(); + private List exposedHeaders = new ArrayList(); + private int preflightMaxAge; + private boolean allowCredentials; + private boolean chainPreflight; + + @Override + public void init(FilterConfig config) throws ServletException + { + String allowedOriginsConfig = config.getInitParameter(ALLOWED_ORIGINS_PARAM); + String allowedTimingOriginsConfig = config.getInitParameter(ALLOWED_TIMING_ORIGINS_PARAM); + + anyOriginAllowed = generateAllowedOrigins(allowedOrigins, allowedOriginPatterns, allowedOriginsConfig, DEFAULT_ALLOWED_ORIGINS); + anyTimingOriginAllowed = generateAllowedOrigins(allowedTimingOrigins, allowedTimingOriginPatterns, allowedTimingOriginsConfig, DEFAULT_ALLOWED_TIMING_ORIGINS); + + String allowedMethodsConfig = config.getInitParameter(ALLOWED_METHODS_PARAM); + if (allowedMethodsConfig == null) + allowedMethods.addAll(DEFAULT_ALLOWED_METHODS); + else + allowedMethods.addAll(Arrays.asList(StringUtil.csvSplit(allowedMethodsConfig))); + + String allowedHeadersConfig = config.getInitParameter(ALLOWED_HEADERS_PARAM); + if (allowedHeadersConfig == null) + allowedHeaders.addAll(DEFAULT_ALLOWED_HEADERS); + else if ("*".equals(allowedHeadersConfig)) + anyHeadersAllowed = true; + else + allowedHeaders.addAll(Arrays.asList(StringUtil.csvSplit(allowedHeadersConfig))); + + String preflightMaxAgeConfig = config.getInitParameter(PREFLIGHT_MAX_AGE_PARAM); + if (preflightMaxAgeConfig == null) + preflightMaxAgeConfig = "1800"; // Default is 30 minutes + try + { + preflightMaxAge = Integer.parseInt(preflightMaxAgeConfig); + } + catch (NumberFormatException x) + { + LOG.info("Cross-origin filter, could not parse '{}' parameter as integer: {}", PREFLIGHT_MAX_AGE_PARAM, preflightMaxAgeConfig); + } + + String allowedCredentialsConfig = config.getInitParameter(ALLOW_CREDENTIALS_PARAM); + if (allowedCredentialsConfig == null) + allowedCredentialsConfig = "true"; + allowCredentials = Boolean.parseBoolean(allowedCredentialsConfig); + + String exposedHeadersConfig = config.getInitParameter(EXPOSED_HEADERS_PARAM); + if (exposedHeadersConfig == null) + exposedHeadersConfig = ""; + exposedHeaders.addAll(Arrays.asList(StringUtil.csvSplit(exposedHeadersConfig))); + + String chainPreflightConfig = config.getInitParameter(OLD_CHAIN_PREFLIGHT_PARAM); + if (chainPreflightConfig != null) + LOG.warn("DEPRECATED CONFIGURATION: Use " + CHAIN_PREFLIGHT_PARAM + " instead of " + OLD_CHAIN_PREFLIGHT_PARAM); + else + chainPreflightConfig = config.getInitParameter(CHAIN_PREFLIGHT_PARAM); + if (chainPreflightConfig == null) + chainPreflightConfig = "true"; + chainPreflight = Boolean.parseBoolean(chainPreflightConfig); + + if (LOG.isDebugEnabled()) + { + LOG.debug("Cross-origin filter configuration: " + + ALLOWED_ORIGINS_PARAM + " = " + allowedOriginsConfig + ", " + + ALLOWED_TIMING_ORIGINS_PARAM + " = " + allowedTimingOriginsConfig + ", " + + ALLOWED_METHODS_PARAM + " = " + allowedMethodsConfig + ", " + + ALLOWED_HEADERS_PARAM + " = " + allowedHeadersConfig + ", " + + PREFLIGHT_MAX_AGE_PARAM + " = " + preflightMaxAgeConfig + ", " + + ALLOW_CREDENTIALS_PARAM + " = " + allowedCredentialsConfig + "," + + EXPOSED_HEADERS_PARAM + " = " + exposedHeadersConfig + "," + + CHAIN_PREFLIGHT_PARAM + " = " + chainPreflightConfig + ); + } + } + + private boolean generateAllowedOrigins(Set allowedOriginStore, List allowedOriginPatternStore, String allowedOriginsConfig, String defaultOrigin) + { + if (allowedOriginsConfig == null) + allowedOriginsConfig = defaultOrigin; + String[] allowedOrigins = StringUtil.csvSplit(allowedOriginsConfig); + for (String allowedOrigin : allowedOrigins) + { + if (allowedOrigin.length() > 0) + { + if (ANY_ORIGIN.equals(allowedOrigin)) + { + allowedOriginStore.clear(); + allowedOriginPatternStore.clear(); + return true; + } + else if (allowedOrigin.contains("*")) + { + allowedOriginPatternStore.add(Pattern.compile(parseAllowedWildcardOriginToRegex(allowedOrigin))); + } + else + { + allowedOriginStore.add(allowedOrigin); + } + } + } + return false; + } + + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException + { + handle((HttpServletRequest)request, (HttpServletResponse)response, chain); + } + + private void handle(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException + { + String origin = request.getHeader(ORIGIN_HEADER); + // Is it a cross origin request ? + if (origin != null && isEnabled(request)) + { + if (anyOriginAllowed || originMatches(allowedOrigins, allowedOriginPatterns, origin)) + { + if (isSimpleRequest(request)) + { + LOG.debug("Cross-origin request to {} is a simple cross-origin request", request.getRequestURI()); + handleSimpleResponse(request, response, origin); + } + else if (isPreflightRequest(request)) + { + LOG.debug("Cross-origin request to {} is a preflight cross-origin request", request.getRequestURI()); + handlePreflightResponse(request, response, origin); + if (chainPreflight) + LOG.debug("Preflight cross-origin request to {} forwarded to application", request.getRequestURI()); + else + return; + } + else + { + LOG.debug("Cross-origin request to {} is a non-simple cross-origin request", request.getRequestURI()); + handleSimpleResponse(request, response, origin); + } + + if (anyTimingOriginAllowed || originMatches(allowedTimingOrigins, allowedTimingOriginPatterns, origin)) + { + response.setHeader(TIMING_ALLOW_ORIGIN_HEADER, origin); + } + else + { + LOG.debug("Cross-origin request to " + request.getRequestURI() + " with origin " + origin + " does not match allowed timing origins " + allowedTimingOrigins); + } + } + else + { + LOG.debug("Cross-origin request to " + request.getRequestURI() + " with origin " + origin + " does not match allowed origins " + allowedOrigins); + } + } + + chain.doFilter(request, response); + } + + protected boolean isEnabled(HttpServletRequest request) + { + // WebSocket clients such as Chrome 5 implement a version of the WebSocket + // protocol that does not accept extra response headers on the upgrade response + for (Enumeration connections = request.getHeaders("Connection"); connections.hasMoreElements(); ) + { + String connection = (String)connections.nextElement(); + if ("Upgrade".equalsIgnoreCase(connection)) + { + for (Enumeration upgrades = request.getHeaders("Upgrade"); upgrades.hasMoreElements(); ) + { + String upgrade = (String)upgrades.nextElement(); + if ("WebSocket".equalsIgnoreCase(upgrade)) + return false; + } + } + } + return true; + } + + private boolean originMatches(Set allowedOrigins, List allowedOriginPatterns, String originList) + { + if (originList.trim().length() == 0) + return false; + + String[] origins = originList.split(" "); + for (String origin : origins) + { + if (origin.trim().length() == 0) + continue; + + if (allowedOrigins.contains(origin)) + return true; + + for (Pattern allowedOrigin : allowedOriginPatterns) + { + if (allowedOrigin.matcher(origin).matches()) + return true; + } + } + return false; + } + + private String parseAllowedWildcardOriginToRegex(String allowedOrigin) + { + String regex = StringUtil.replace(allowedOrigin, ".", "\\."); + return StringUtil.replace(regex, "*", ".*"); // we want to be greedy here to match multiple subdomains, thus we use .* + } + + private boolean isSimpleRequest(HttpServletRequest request) + { + String method = request.getMethod(); + if (SIMPLE_HTTP_METHODS.contains(method)) + { + // TODO: implement better detection of simple headers + // The specification says that for a request to be simple, custom request headers must be simple. + // Here for simplicity I just check if there is a Access-Control-Request-Method header, + // which is required for preflight requests + return request.getHeader(ACCESS_CONTROL_REQUEST_METHOD_HEADER) == null; + } + return false; + } + + private boolean isPreflightRequest(HttpServletRequest request) + { + String method = request.getMethod(); + if (!"OPTIONS".equalsIgnoreCase(method)) + return false; + if (request.getHeader(ACCESS_CONTROL_REQUEST_METHOD_HEADER) == null) + return false; + return true; + } + + private void handleSimpleResponse(HttpServletRequest request, HttpServletResponse response, String origin) + { + response.setHeader(ACCESS_CONTROL_ALLOW_ORIGIN_HEADER, origin); + //W3C CORS spec http://www.w3.org/TR/cors/#resource-implementation + response.addHeader("Vary", ORIGIN_HEADER); + if (allowCredentials) + response.setHeader(ACCESS_CONTROL_ALLOW_CREDENTIALS_HEADER, "true"); + if (!exposedHeaders.isEmpty()) + response.setHeader(ACCESS_CONTROL_EXPOSE_HEADERS_HEADER, commify(exposedHeaders)); + } + + private void handlePreflightResponse(HttpServletRequest request, HttpServletResponse response, String origin) + { + boolean methodAllowed = isMethodAllowed(request); + + if (!methodAllowed) + return; + List headersRequested = getAccessControlRequestHeaders(request); + boolean headersAllowed = areHeadersAllowed(headersRequested); + if (!headersAllowed) + return; + response.setHeader(ACCESS_CONTROL_ALLOW_ORIGIN_HEADER, origin); + //W3C CORS spec http://www.w3.org/TR/cors/#resource-implementation + if (!anyOriginAllowed) + response.addHeader("Vary", ORIGIN_HEADER); + if (allowCredentials) + response.setHeader(ACCESS_CONTROL_ALLOW_CREDENTIALS_HEADER, "true"); + if (preflightMaxAge > 0) + response.setHeader(ACCESS_CONTROL_MAX_AGE_HEADER, String.valueOf(preflightMaxAge)); + response.setHeader(ACCESS_CONTROL_ALLOW_METHODS_HEADER, commify(allowedMethods)); + if (anyHeadersAllowed) + response.setHeader(ACCESS_CONTROL_ALLOW_HEADERS_HEADER, commify(headersRequested)); + else + response.setHeader(ACCESS_CONTROL_ALLOW_HEADERS_HEADER, commify(allowedHeaders)); + } + + private boolean isMethodAllowed(HttpServletRequest request) + { + String accessControlRequestMethod = request.getHeader(ACCESS_CONTROL_REQUEST_METHOD_HEADER); + LOG.debug("{} is {}", ACCESS_CONTROL_REQUEST_METHOD_HEADER, accessControlRequestMethod); + boolean result = false; + if (accessControlRequestMethod != null) + result = allowedMethods.contains(accessControlRequestMethod); + LOG.debug("Method {} is" + (result ? "" : " not") + " among allowed methods {}", accessControlRequestMethod, allowedMethods); + return result; + } + + private List getAccessControlRequestHeaders(HttpServletRequest request) + { + String accessControlRequestHeaders = request.getHeader(ACCESS_CONTROL_REQUEST_HEADERS_HEADER); + LOG.debug("{} is {}", ACCESS_CONTROL_REQUEST_HEADERS_HEADER, accessControlRequestHeaders); + if (accessControlRequestHeaders == null) + return Collections.emptyList(); + + List requestedHeaders = new ArrayList(); + String[] headers = StringUtil.csvSplit(accessControlRequestHeaders); + for (String header : headers) + { + String h = header.trim(); + if (h.length() > 0) + requestedHeaders.add(h); + } + return requestedHeaders; + } + + private boolean areHeadersAllowed(List requestedHeaders) + { + if (anyHeadersAllowed) + { + LOG.debug("Any header is allowed"); + return true; + } + + boolean result = true; + for (String requestedHeader : requestedHeaders) + { + boolean headerAllowed = false; + for (String allowedHeader : allowedHeaders) + { + if (requestedHeader.equalsIgnoreCase(allowedHeader.trim())) + { + headerAllowed = true; + break; + } + } + if (!headerAllowed) + { + result = false; + break; + } + } + LOG.debug("Headers [{}] are" + (result ? "" : " not") + " among allowed headers {}", requestedHeaders, allowedHeaders); + return result; + } + + private String commify(List strings) + { + StringBuilder builder = new StringBuilder(); + for (int i = 0; i < strings.size(); ++i) + { + if (i > 0) + builder.append(","); + String string = strings.get(i); + builder.append(string); + } + return builder.toString(); + } + + @Override + public void destroy() + { + anyOriginAllowed = false; + anyTimingOriginAllowed = false; + allowedOrigins.clear(); + allowedOriginPatterns.clear(); + allowedTimingOrigins.clear(); + allowedTimingOriginPatterns.clear(); + allowedMethods.clear(); + allowedHeaders.clear(); + preflightMaxAge = 0; + allowCredentials = false; + } +} diff --git a/test-proxy/src/main/java/servlets/DataRateLimitedServlet.java b/test-proxy/src/main/java/servlets/DataRateLimitedServlet.java new file mode 100644 index 0000000..dace47b --- /dev/null +++ b/test-proxy/src/main/java/servlets/DataRateLimitedServlet.java @@ -0,0 +1,313 @@ +// +// ======================================================================== +// Copyright (c) 1995-2020 Mort Bay Consulting Pty Ltd and others. +// +// This program and the accompanying materials are made available under +// the terms of the Eclipse Public License 2.0 which is available at +// https://www.eclipse.org/legal/epl-2.0 +// +// This Source Code may also be made available under the following +// Secondary Licenses when the conditions for such availability set +// forth in the Eclipse Public License, v. 2.0 are satisfied: +// the Apache License v2.0 which is available at +// https://www.apache.org/licenses/LICENSE-2.0 +// +// SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 +// ======================================================================== +// + +package servlets; + +import org.eclipse.jetty.server.HttpOutput; +import org.eclipse.jetty.util.ProcessorUtils; + +import javax.servlet.AsyncContext; +import javax.servlet.ServletException; +import javax.servlet.ServletOutputStream; +import javax.servlet.WriteListener; +import javax.servlet.http.HttpServlet; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.RandomAccessFile; +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel.MapMode; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ScheduledThreadPoolExecutor; +import java.util.concurrent.TimeUnit; + +/** + * A servlet that uses the Servlet 3.1 asynchronous IO API to server + * static content at a limited data rate. + *

+ * Two implementations are supported:

    + *
  • The StandardDataStream impl uses only standard + * APIs, but produces more garbage due to the byte[] nature of the API. + *
  • the JettyDataStream impl uses a Jetty API to write a ByteBuffer + * and thus allow the efficient use of file mapped buffers without any + * temporary buffer copies (I did tell the JSR that this was a good idea to + * have in the standard!). + *
+ *

+ * The data rate is controlled by setting init parameters: + *

+ *
buffersize
The amount of data in bytes written per write
+ *
pause
The period in ms to wait after a write before attempting another
+ *
pool
The size of the thread pool used to service the writes (defaults to available processors)
+ *
+ * Thus if buffersize = 1024 and pause = 100, the data rate will be limited to 10KB per second. + */ +public class DataRateLimitedServlet extends HttpServlet +{ + private static final long serialVersionUID = -4771757707068097025L; + private int buffersize = 8192; + private long pauseNS = TimeUnit.MILLISECONDS.toNanos(100); + ScheduledThreadPoolExecutor scheduler; + private final ConcurrentHashMap cache = new ConcurrentHashMap<>(); + + @Override + public void init() throws ServletException + { + // read the init params + String tmp = getInitParameter("buffersize"); + if (tmp != null) + buffersize = Integer.parseInt(tmp); + tmp = getInitParameter("pause"); + if (tmp != null) + pauseNS = TimeUnit.MILLISECONDS.toNanos(Integer.parseInt(tmp)); + tmp = getInitParameter("pool"); + int pool = tmp == null ? ProcessorUtils.availableProcessors() : Integer.parseInt(tmp); + + // Create and start a shared scheduler. + scheduler = new ScheduledThreadPoolExecutor(pool); + } + + @Override + public void destroy() + { + scheduler.shutdown(); + } + + @Override + protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException + { + // Get the path of the static resource to serve. + String info = request.getPathInfo(); + + // We don't handle directories + if (info.endsWith("/")) + { + response.sendError(503, "directories not supported"); + return; + } + + // Set the mime type of the response + String contentType = getServletContext().getMimeType(info); + response.setContentType(contentType == null ? "application/x-data" : contentType); + + // Look for a matching file path + String path = request.getPathTranslated(); + + // If we have a file path and this is a jetty response, we can use the JettyStream impl + ServletOutputStream out = response.getOutputStream(); + if (path != null && out instanceof HttpOutput) + { + // If the file exists + File file = new File(path); + if (file.exists() && file.canRead()) + { + // Set the content length + response.setContentLengthLong(file.length()); + + // Look for a file mapped buffer in the cache + ByteBuffer mapped = cache.get(path); + + // Handle cache miss + if (mapped == null) + { + // TODO implement LRU cache flush + try (RandomAccessFile raf = new RandomAccessFile(file, "r")) + { + ByteBuffer buf = raf.getChannel().map(MapMode.READ_ONLY, 0, raf.length()); + mapped = cache.putIfAbsent(path, buf); + if (mapped == null) + mapped = buf; + } + } + + // start async request handling + AsyncContext async = request.startAsync(); + + // Set a JettyStream as the write listener to write the content asynchronously. + out.setWriteListener(new JettyDataStream(mapped, async, out)); + return; + } + } + + // Jetty API was not used, so lets try the standards approach + + // Can we find the content as an input stream + InputStream content = getServletContext().getResourceAsStream(info); + if (content == null) + { + response.sendError(404); + return; + } + + // Set a StandardStream as he write listener to write the content asynchronously + out.setWriteListener(new StandardDataStream(content, request.startAsync(), out)); + } + + /** + * A standard API Stream writer + */ + private final class StandardDataStream implements WriteListener, Runnable + { + private final InputStream content; + private final AsyncContext async; + private final ServletOutputStream out; + + private StandardDataStream(InputStream content, AsyncContext async, ServletOutputStream out) + { + this.content = content; + this.async = async; + this.out = out; + } + + @Override + public void onWritePossible() throws IOException + { + // If we are able to write + if (out.isReady()) + { + // Allocated a copy buffer for each write, so as to not hold while paused + // TODO put these buffers into a pool + byte[] buffer = new byte[buffersize]; + + // read some content into the copy buffer + int len = content.read(buffer); + + // If we are at EOF + if (len < 0) + { + // complete the async lifecycle + async.complete(); + return; + } + + // write out the copy buffer. This will be an asynchronous write + // and will always return immediately without blocking. If a subsequent + // call to out.isReady() returns false, then this onWritePossible method + // will be called back when a write is possible. + out.write(buffer, 0, len); + + // Schedule a timer callback to pause writing. Because isReady() is not called, + // a onWritePossible callback is no scheduled. + scheduler.schedule(this, pauseNS, TimeUnit.NANOSECONDS); + } + } + + @Override + public void run() + { + try + { + // When the pause timer wakes up, call onWritePossible. Either isReady() will return + // true and another chunk of content will be written, or it will return false and the + // onWritePossible() callback will be scheduled when a write is next possible. + onWritePossible(); + } + catch (Exception e) + { + onError(e); + } + } + + @Override + public void onError(Throwable t) + { + getServletContext().log("Async Error", t); + async.complete(); + } + } + + /** + * A Jetty API DataStream + */ + private final class JettyDataStream implements WriteListener, Runnable + { + private final ByteBuffer content; + private final int limit; + private final AsyncContext async; + private final HttpOutput out; + + private JettyDataStream(ByteBuffer content, AsyncContext async, ServletOutputStream out) + { + // Make a readonly copy of the passed buffer. This uses the same underlying content + // without a copy, but gives this instance its own position and limit. + this.content = content.asReadOnlyBuffer(); + // remember the ultimate limit. + this.limit = this.content.limit(); + this.async = async; + this.out = (HttpOutput)out; + } + + @Override + public void onWritePossible() throws IOException + { + // If we are able to write + if (out.isReady()) + { + // Position our buffers limit to allow only buffersize bytes to be written + int l = content.position() + buffersize; + // respect the ultimate limit + if (l > limit) + l = limit; + content.limit(l); + + // if all content has been written + if (!content.hasRemaining()) + { + // complete the async lifecycle + async.complete(); + return; + } + + // write our limited buffer. This will be an asynchronous write + // and will always return immediately without blocking. If a subsequent + // call to out.isReady() returns false, then this onWritePossible method + // will be called back when a write is possible. + out.write(content); + + // Schedule a timer callback to pause writing. Because isReady() is not called, + // a onWritePossible callback is not scheduled. + scheduler.schedule(this, pauseNS, TimeUnit.NANOSECONDS); + } + } + + @Override + public void run() + { + try + { + // When the pause timer wakes up, call onWritePossible. Either isReady() will return + // true and another chunk of content will be written, or it will return false and the + // onWritePossible() callback will be scheduled when a write is next possible. + onWritePossible(); + } + catch (Exception e) + { + onError(e); + } + } + + @Override + public void onError(Throwable t) + { + getServletContext().log("Async Error", t); + async.complete(); + } + } +} diff --git a/test-proxy/src/main/java/servlets/DoSFilter.java b/test-proxy/src/main/java/servlets/DoSFilter.java new file mode 100644 index 0000000..bad473f --- /dev/null +++ b/test-proxy/src/main/java/servlets/DoSFilter.java @@ -0,0 +1,1329 @@ +// +// ======================================================================== +// Copyright (c) 1995-2020 Mort Bay Consulting Pty Ltd and others. +// +// This program and the accompanying materials are made available under +// the terms of the Eclipse Public License 2.0 which is available at +// https://www.eclipse.org/legal/epl-2.0 +// +// This Source Code may also be made available under the following +// Secondary Licenses when the conditions for such availability set +// forth in the Eclipse Public License, v. 2.0 are satisfied: +// the Apache License v2.0 which is available at +// https://www.apache.org/licenses/LICENSE-2.0 +// +// SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 +// ======================================================================== +// + +package servlets; + +import org.eclipse.jetty.http.HttpStatus; +import org.eclipse.jetty.server.handler.ContextHandler; +import org.eclipse.jetty.util.StringUtil; +import org.eclipse.jetty.util.annotation.ManagedAttribute; +import org.eclipse.jetty.util.annotation.ManagedObject; +import org.eclipse.jetty.util.annotation.ManagedOperation; +import org.eclipse.jetty.util.annotation.Name; +import org.eclipse.jetty.util.thread.ScheduledExecutorScheduler; +import org.eclipse.jetty.util.thread.Scheduler; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.servlet.*; +import javax.servlet.http.*; +import java.io.IOException; +import java.io.Serializable; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.Queue; +import java.util.concurrent.*; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Denial of Service filter + *

+ * This filter is useful for limiting + * exposure to abuse from request flooding, whether malicious, or as a result of + * a misconfigured client. + *

+ * The filter keeps track of the number of requests from a connection per + * second. If a limit is exceeded, the request is either rejected, delayed, or + * throttled. + *

+ * When a request is throttled, it is placed in a priority queue. Priority is + * given first to authenticated users and users with an HttpSession, then + * connections which can be identified by their IP addresses. Connections with + * no way to identify them are given lowest priority. + *

+ * The {@link #extractUserId(ServletRequest request)} function should be + * implemented, in order to uniquely identify authenticated users. + *

+ * The following init parameters control the behavior of the filter: + *

+ *
maxRequestsPerSec
+ *
the maximum number of requests from a connection per + * second. Requests in excess of this are first delayed, + * then throttled.
+ *
delayMs
+ *
is the delay given to all requests over the rate limit, + * before they are considered at all. -1 means just reject request, + * 0 means no delay, otherwise it is the delay.
+ *
maxWaitMs
+ *
how long to blocking wait for the throttle semaphore.
+ *
throttledRequests
+ *
is the number of requests over the rate limit able to be + * considered at once.
+ *
throttleMs
+ *
how long to async wait for semaphore.
+ *
maxRequestMs
+ *
how long to allow this request to run.
+ *
maxIdleTrackerMs
+ *
how long to keep track of request rates for a connection, + * before deciding that the user has gone away, and discarding it
+ *
insertHeaders
+ *
if true , insert the DoSFilter headers into the response. Defaults to true.
+ *
trackSessions
+ *
if true, usage rate is tracked by session if a session exists. Defaults to true.
+ *
remotePort
+ *
if true and session tracking is not used, then rate is tracked by IP+port (effectively connection). Defaults to false.
+ *
ipWhitelist
+ *
a comma-separated list of IP addresses that will not be rate limited
+ *
managedAttr
+ *
if set to true, then this servlet is set as a {@link ServletContext} attribute with the + * filter name as the attribute name. This allows context external mechanism (eg JMX via {@link ContextHandler#MANAGED_ATTRIBUTES}) to + * manage the configuration of the filter.
+ *
tooManyCode
+ *
The status code to send if there are too many requests. By default is 429 (too many requests), but 503 (Unavailable) is + * another option
+ *
+ *

+ * This filter should be configured for {@link DispatcherType#REQUEST} and {@link DispatcherType#ASYNC} and with + * {@code true}. + *

+ */ +@ManagedObject("limits exposure to abuse from request flooding, whether malicious, or as a result of a misconfigured client") +public class DoSFilter implements Filter +{ + private static final Logger LOG = LoggerFactory.getLogger(DoSFilter.class); + + private static final String IPv4_GROUP = "(\\d{1,3})"; + private static final Pattern IPv4_PATTERN = Pattern.compile(IPv4_GROUP + "\\." + IPv4_GROUP + "\\." + IPv4_GROUP + "\\." + IPv4_GROUP); + private static final String IPv6_GROUP = "(\\p{XDigit}{1,4})"; + private static final Pattern IPv6_PATTERN = Pattern.compile(IPv6_GROUP + ":" + IPv6_GROUP + ":" + IPv6_GROUP + ":" + IPv6_GROUP + ":" + IPv6_GROUP + ":" + IPv6_GROUP + ":" + IPv6_GROUP + ":" + IPv6_GROUP); + private static final Pattern CIDR_PATTERN = Pattern.compile("([^/]+)/(\\d+)"); + + private static final String __TRACKER = "DoSFilter.Tracker"; + private static final String __THROTTLED = "DoSFilter.Throttled"; + + private static final int __DEFAULT_MAX_REQUESTS_PER_SEC = 25; + private static final int __DEFAULT_DELAY_MS = 100; + private static final int __DEFAULT_THROTTLE = 5; + private static final int __DEFAULT_MAX_WAIT_MS = 50; + private static final long __DEFAULT_THROTTLE_MS = 30000L; + private static final long __DEFAULT_MAX_REQUEST_MS_INIT_PARAM = 30000L; + private static final long __DEFAULT_MAX_IDLE_TRACKER_MS_INIT_PARAM = 30000L; + + static final String MANAGED_ATTR_INIT_PARAM = "managedAttr"; + static final String MAX_REQUESTS_PER_S_INIT_PARAM = "maxRequestsPerSec"; + static final String DELAY_MS_INIT_PARAM = "delayMs"; + static final String THROTTLED_REQUESTS_INIT_PARAM = "throttledRequests"; + static final String MAX_WAIT_INIT_PARAM = "maxWaitMs"; + static final String THROTTLE_MS_INIT_PARAM = "throttleMs"; + static final String MAX_REQUEST_MS_INIT_PARAM = "maxRequestMs"; + static final String MAX_IDLE_TRACKER_MS_INIT_PARAM = "maxIdleTrackerMs"; + static final String INSERT_HEADERS_INIT_PARAM = "insertHeaders"; + static final String TRACK_SESSIONS_INIT_PARAM = "trackSessions"; + static final String REMOTE_PORT_INIT_PARAM = "remotePort"; + static final String IP_WHITELIST_INIT_PARAM = "ipWhitelist"; + static final String ENABLED_INIT_PARAM = "enabled"; + static final String TOO_MANY_CODE = "tooManyCode"; + + private static final int USER_AUTH = 2; + private static final int USER_SESSION = 2; + private static final int USER_IP = 1; + private static final int USER_UNKNOWN = 0; + + private final String _suspended = "DoSFilter@" + Integer.toHexString(hashCode()) + ".SUSPENDED"; + private final String _resumed = "DoSFilter@" + Integer.toHexString(hashCode()) + ".RESUMED"; + private final ConcurrentHashMap _rateTrackers = new ConcurrentHashMap<>(); + private final List _whitelist = new CopyOnWriteArrayList<>(); + private int _tooManyCode; + private volatile long _delayMs; + private volatile long _throttleMs; + private volatile long _maxWaitMs; + private volatile long _maxRequestMs; + private volatile long _maxIdleTrackerMs; + private volatile boolean _insertHeaders; + private volatile boolean _trackSessions; + private volatile boolean _remotePort; + private volatile boolean _enabled; + private volatile String _name; + private Semaphore _passes; + private volatile int _throttledRequests; + private volatile int _maxRequestsPerSec; + private Queue[] _queues; + private AsyncListener[] _listeners; + private Scheduler _scheduler; + private ServletContext _context; + + @Override + public void init(FilterConfig filterConfig) throws ServletException + { + _queues = new Queue[getMaxPriority() + 1]; + _listeners = new AsyncListener[_queues.length]; + for (int p = 0; p < _queues.length; p++) + { + _queues[p] = new ConcurrentLinkedQueue<>(); + _listeners[p] = new DoSAsyncListener(p); + } + + _rateTrackers.clear(); + + int maxRequests = __DEFAULT_MAX_REQUESTS_PER_SEC; + String parameter = filterConfig.getInitParameter(MAX_REQUESTS_PER_S_INIT_PARAM); + if (parameter != null) + maxRequests = Integer.parseInt(parameter); + setMaxRequestsPerSec(maxRequests); + + long delay = __DEFAULT_DELAY_MS; + parameter = filterConfig.getInitParameter(DELAY_MS_INIT_PARAM); + if (parameter != null) + delay = Long.parseLong(parameter); + setDelayMs(delay); + + int throttledRequests = __DEFAULT_THROTTLE; + parameter = filterConfig.getInitParameter(THROTTLED_REQUESTS_INIT_PARAM); + if (parameter != null) + throttledRequests = Integer.parseInt(parameter); + setThrottledRequests(throttledRequests); + + long maxWait = __DEFAULT_MAX_WAIT_MS; + parameter = filterConfig.getInitParameter(MAX_WAIT_INIT_PARAM); + if (parameter != null) + maxWait = Long.parseLong(parameter); + setMaxWaitMs(maxWait); + + long throttle = __DEFAULT_THROTTLE_MS; + parameter = filterConfig.getInitParameter(THROTTLE_MS_INIT_PARAM); + if (parameter != null) + throttle = Long.parseLong(parameter); + setThrottleMs(throttle); + + long maxRequestMs = __DEFAULT_MAX_REQUEST_MS_INIT_PARAM; + parameter = filterConfig.getInitParameter(MAX_REQUEST_MS_INIT_PARAM); + if (parameter != null) + maxRequestMs = Long.parseLong(parameter); + setMaxRequestMs(maxRequestMs); + + long maxIdleTrackerMs = __DEFAULT_MAX_IDLE_TRACKER_MS_INIT_PARAM; + parameter = filterConfig.getInitParameter(MAX_IDLE_TRACKER_MS_INIT_PARAM); + if (parameter != null) + maxIdleTrackerMs = Long.parseLong(parameter); + setMaxIdleTrackerMs(maxIdleTrackerMs); + + String whiteList = ""; + parameter = filterConfig.getInitParameter(IP_WHITELIST_INIT_PARAM); + if (parameter != null) + whiteList = parameter; + setWhitelist(whiteList); + + parameter = filterConfig.getInitParameter(INSERT_HEADERS_INIT_PARAM); + setInsertHeaders(parameter == null || Boolean.parseBoolean(parameter)); + + parameter = filterConfig.getInitParameter(TRACK_SESSIONS_INIT_PARAM); + setTrackSessions(parameter == null || Boolean.parseBoolean(parameter)); + + parameter = filterConfig.getInitParameter(REMOTE_PORT_INIT_PARAM); + setRemotePort(parameter != null && Boolean.parseBoolean(parameter)); + + parameter = filterConfig.getInitParameter(ENABLED_INIT_PARAM); + setEnabled(parameter == null || Boolean.parseBoolean(parameter)); + + parameter = filterConfig.getInitParameter(TOO_MANY_CODE); + setTooManyCode(parameter == null ? 429 : Integer.parseInt(parameter)); + + setName(filterConfig.getFilterName()); + _context = filterConfig.getServletContext(); + if (_context != null) + { + _context.setAttribute(filterConfig.getFilterName(), this); + } + + _scheduler = startScheduler(); + } + + protected Scheduler startScheduler() throws ServletException + { + try + { + Scheduler result = new ScheduledExecutorScheduler(String.format("DoS-Scheduler-%x", hashCode()), false); + result.start(); + return result; + } + catch (Exception x) + { + throw new ServletException(x); + } + } + + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain filterChain) throws IOException, ServletException + { + doFilter((HttpServletRequest)request, (HttpServletResponse)response, filterChain); + } + + protected void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws IOException, ServletException + { + if (!isEnabled()) + { + filterChain.doFilter(request, response); + return; + } + + // Look for the rate tracker for this request. + RateTracker tracker = (RateTracker)request.getAttribute(__TRACKER); + if (tracker == null) + { + // This is the first time we have seen this request. + if (LOG.isDebugEnabled()) + LOG.debug("Filtering {}", request); + + // Get a rate tracker associated with this request, and record one hit. + tracker = getRateTracker(request); + + // Calculate the rate and check if it is over the allowed limit + final boolean overRateLimit = tracker.isRateExceeded(System.currentTimeMillis()); + + // Pass it through if we are not currently over the rate limit. + if (!overRateLimit) + { + if (LOG.isDebugEnabled()) + LOG.debug("Allowing {}", request); + doFilterChain(filterChain, request, response); + return; + } + + // We are over the limit. + + // So either reject it, delay it or throttle it. + long delayMs = getDelayMs(); + boolean insertHeaders = isInsertHeaders(); + switch ((int)delayMs) + { + case -1: + { + // Reject this request. + LOG.warn("DOS ALERT: Request rejected ip={}, session={}, user={}", request.getRemoteAddr(), request.getRequestedSessionId(), request.getUserPrincipal()); + if (insertHeaders) + response.addHeader("DoSFilter", "unavailable"); + response.sendError(getTooManyCode()); + return; + } + case 0: + { + // Fall through to throttle the request. + LOG.warn("DOS ALERT: Request throttled ip={}, session={}, user={}", request.getRemoteAddr(), request.getRequestedSessionId(), request.getUserPrincipal()); + request.setAttribute(__TRACKER, tracker); + break; + } + default: + { + // Insert a delay before throttling the request, + // using the suspend+timeout mechanism of AsyncContext. + LOG.warn("DOS ALERT: Request delayed={}ms, ip={}, session={}, user={}", delayMs, request.getRemoteAddr(), request.getRequestedSessionId(), request.getUserPrincipal()); + if (insertHeaders) + response.addHeader("DoSFilter", "delayed"); + request.setAttribute(__TRACKER, tracker); + AsyncContext asyncContext = request.startAsync(); + if (delayMs > 0) + asyncContext.setTimeout(delayMs); + asyncContext.addListener(new DoSTimeoutAsyncListener()); + return; + } + } + } + + if (LOG.isDebugEnabled()) + LOG.debug("Throttling {}", request); + + // Throttle the request. + boolean accepted = false; + try + { + // Check if we can afford to accept another request at this time. + accepted = _passes.tryAcquire(getMaxWaitMs(), TimeUnit.MILLISECONDS); + if (!accepted) + { + // We were not accepted, so either we suspend to wait, + // or if we were woken up we insist or we fail. + Boolean throttled = (Boolean)request.getAttribute(__THROTTLED); + long throttleMs = getThrottleMs(); + if (!Boolean.TRUE.equals(throttled) && throttleMs > 0) + { + int priority = getPriority(request, tracker); + request.setAttribute(__THROTTLED, Boolean.TRUE); + if (isInsertHeaders()) + response.addHeader("DoSFilter", "throttled"); + AsyncContext asyncContext = request.startAsync(); + request.setAttribute(_suspended, Boolean.TRUE); + asyncContext.setTimeout(throttleMs); + asyncContext.addListener(_listeners[priority]); + _queues[priority].add(asyncContext); + if (LOG.isDebugEnabled()) + LOG.debug("Throttled {}, {}ms", request, throttleMs); + return; + } + + Boolean resumed = (Boolean)request.getAttribute(_resumed); + if (Boolean.TRUE.equals(resumed)) + { + // We were resumed, we wait for the next pass. + _passes.acquire(); + accepted = true; + } + } + + // If we were accepted (either immediately or after throttle)... + if (accepted) + { + // ...call the chain. + if (LOG.isDebugEnabled()) + LOG.debug("Allowing {}", request); + doFilterChain(filterChain, request, response); + } + else + { + // ...otherwise fail the request. + if (LOG.isDebugEnabled()) + LOG.debug("Rejecting {}", request); + if (isInsertHeaders()) + response.addHeader("DoSFilter", "unavailable"); + response.sendError(getTooManyCode()); + } + } + catch (InterruptedException e) + { + LOG.trace("IGNORED", e); + response.sendError(getTooManyCode()); + } + finally + { + if (accepted) + { + try + { + // Wake up the next highest priority request. + for (int p = _queues.length - 1; p >= 0; --p) + { + AsyncContext asyncContext = _queues[p].poll(); + if (asyncContext != null) + { + ServletRequest candidate = asyncContext.getRequest(); + Boolean suspended = (Boolean)candidate.getAttribute(_suspended); + if (Boolean.TRUE.equals(suspended)) + { + if (LOG.isDebugEnabled()) + LOG.debug("Resuming {}", request); + candidate.setAttribute(_resumed, Boolean.TRUE); + asyncContext.dispatch(); + break; + } + } + } + } + finally + { + _passes.release(); + } + } + } + } + + protected void doFilterChain(FilterChain chain, final HttpServletRequest request, final HttpServletResponse response) throws IOException, ServletException + { + final Thread thread = Thread.currentThread(); + Runnable requestTimeout = () -> onRequestTimeout(request, response, thread); + Scheduler.Task task = _scheduler.schedule(requestTimeout, getMaxRequestMs(), TimeUnit.MILLISECONDS); + try + { + chain.doFilter(request, response); + } + finally + { + task.cancel(); + } + } + + /** + * Invoked when the request handling exceeds {@link #getMaxRequestMs()}. + *

+ * By default, an HTTP 503 response is returned and the handling thread is interrupted. + * + * @param request the current request + * @param response the current response + * @param handlingThread the handling thread + */ + protected void onRequestTimeout(HttpServletRequest request, HttpServletResponse response, Thread handlingThread) + { + try + { + if (LOG.isDebugEnabled()) + LOG.debug("Timing out {}", request); + try + { + response.sendError(HttpStatus.SERVICE_UNAVAILABLE_503); + } + catch (IllegalStateException ise) + { + LOG.trace("IGNORED", ise); + // abort instead + response.sendError(-1); + } + } + catch (Throwable x) + { + LOG.info("Failed to sendError", x); + } + + handlingThread.interrupt(); + } + + /** + * Get priority for this request, based on user type + * + * @param request the current request + * @param tracker the rate tracker for this request + * @return the priority for this request + */ + private int getPriority(HttpServletRequest request, RateTracker tracker) + { + if (extractUserId(request) != null) + return USER_AUTH; + if (tracker != null) + return tracker.getType(); + return USER_UNKNOWN; + } + + /** + * @return the maximum priority that we can assign to a request + */ + protected int getMaxPriority() + { + return USER_AUTH; + } + + private void schedule(RateTracker tracker) + { + _scheduler.schedule(tracker, getMaxIdleTrackerMs(), TimeUnit.MILLISECONDS); + } + + /** + * Return a request rate tracker associated with this connection; keeps + * track of this connection's request rate. If this is not the first request + * from this connection, return the existing object with the stored stats. + * If it is the first request, then create a new request tracker. + *

+ * Assumes that each connection has an identifying characteristic, and goes + * through them in order, taking the first that matches: user id (logged + * in), session id, client IP address. Unidentifiable connections are lumped + * into one. + *

+ * When a session expires, its rate tracker is automatically deleted. + * + * @param request the current request + * @return the request rate tracker for the current connection + */ + RateTracker getRateTracker(ServletRequest request) + { + HttpSession session = ((HttpServletRequest)request).getSession(false); + + String loadId = extractUserId(request); + final int type; + if (loadId != null) + { + type = USER_AUTH; + } + else + { + if (isTrackSessions() && session != null && !session.isNew()) + { + loadId = session.getId(); + type = USER_SESSION; + } + else + { + loadId = isRemotePort() ? createRemotePortId(request) : request.getRemoteAddr(); + type = USER_IP; + } + } + + RateTracker tracker = _rateTrackers.get(loadId); + + if (tracker == null) + { + boolean allowed = checkWhitelist(request.getRemoteAddr()); + int maxRequestsPerSec = getMaxRequestsPerSec(); + tracker = allowed ? new FixedRateTracker(_context, _name, loadId, type, maxRequestsPerSec) + : new RateTracker(_context, _name, loadId, type, maxRequestsPerSec); + tracker.setContext(_context); + RateTracker existing = _rateTrackers.putIfAbsent(loadId, tracker); + if (existing != null) + tracker = existing; + + if (type == USER_IP) + { + // USER_IP expiration from _rateTrackers is handled by the _scheduler + _scheduler.schedule(tracker, getMaxIdleTrackerMs(), TimeUnit.MILLISECONDS); + } + else if (session != null) + { + // USER_SESSION expiration from _rateTrackers are handled by the HttpSessionBindingListener + session.setAttribute(__TRACKER, tracker); + } + } + + return tracker; + } + + private void addToRateTracker(RateTracker tracker) + { + _rateTrackers.put(tracker.getId(), tracker); + } + + public void removeFromRateTracker(String id) + { + _rateTrackers.remove(id); + } + + protected boolean checkWhitelist(String candidate) + { + for (String address : _whitelist) + { + if (address.contains("/")) + { + if (subnetMatch(address, candidate)) + return true; + } + else + { + if (address.equals(candidate)) + return true; + } + } + return false; + } + + protected boolean subnetMatch(String subnetAddress, String address) + { + Matcher cidrMatcher = CIDR_PATTERN.matcher(subnetAddress); + if (!cidrMatcher.matches()) + return false; + + String subnet = cidrMatcher.group(1); + int prefix; + try + { + prefix = Integer.parseInt(cidrMatcher.group(2)); + } + catch (NumberFormatException x) + { + LOG.info("Ignoring malformed CIDR address {}", subnetAddress); + return false; + } + + byte[] subnetBytes = addressToBytes(subnet); + if (subnetBytes == null) + { + LOG.info("Ignoring malformed CIDR address {}", subnetAddress); + return false; + } + byte[] addressBytes = addressToBytes(address); + if (addressBytes == null) + { + LOG.info("Ignoring malformed remote address {}", address); + return false; + } + + // Comparing IPv4 with IPv6 ? + int length = subnetBytes.length; + if (length != addressBytes.length) + return false; + + byte[] mask = prefixToBytes(prefix, length); + + for (int i = 0; i < length; ++i) + { + if ((subnetBytes[i] & mask[i]) != (addressBytes[i] & mask[i])) + return false; + } + + return true; + } + + private byte[] addressToBytes(String address) + { + Matcher ipv4Matcher = IPv4_PATTERN.matcher(address); + if (ipv4Matcher.matches()) + { + byte[] result = new byte[4]; + for (int i = 0; i < result.length; ++i) + { + result[i] = Integer.valueOf(ipv4Matcher.group(i + 1)).byteValue(); + } + return result; + } + else + { + Matcher ipv6Matcher = IPv6_PATTERN.matcher(address); + if (ipv6Matcher.matches()) + { + byte[] result = new byte[16]; + for (int i = 0; i < result.length; i += 2) + { + int word = Integer.parseInt(ipv6Matcher.group(i / 2 + 1), 16); + result[i] = (byte)((word & 0xFF00) >>> 8); + result[i + 1] = (byte)(word & 0xFF); + } + return result; + } + } + return null; + } + + private byte[] prefixToBytes(int prefix, int length) + { + byte[] result = new byte[length]; + int index = 0; + while (prefix / 8 > 0) + { + result[index] = -1; + prefix -= 8; + ++index; + } + + if (index == result.length) + return result; + + // Sets the _prefix_ most significant bits to 1 + result[index] = (byte)~((1 << (8 - prefix)) - 1); + return result; + } + + @Override + public void destroy() + { + LOG.debug("Destroy {}", this); + stopScheduler(); + _rateTrackers.clear(); + _whitelist.clear(); + } + + protected void stopScheduler() + { + try + { + _scheduler.stop(); + } + catch (Exception x) + { + LOG.trace("IGNORED", x); + } + } + + /** + * Returns the user id, used to track this connection. + * This SHOULD be overridden by subclasses. + * + * @param request the current request + * @return a unique user id, if logged in; otherwise null. + */ + protected String extractUserId(ServletRequest request) + { + return null; + } + + /** + * Get maximum number of requests from a connection per + * second. Requests in excess of this are first delayed, + * then throttled. + * + * @return maximum number of requests + */ + @ManagedAttribute("maximum number of requests allowed from a connection per second") + public int getMaxRequestsPerSec() + { + return _maxRequestsPerSec; + } + + /** + * Get maximum number of requests from a connection per + * second. Requests in excess of this are first delayed, + * then throttled. + * + * @param value maximum number of requests + */ + public void setMaxRequestsPerSec(int value) + { + _maxRequestsPerSec = value; + } + + /** + * Get delay (in milliseconds) that is applied to all requests + * over the rate limit, before they are considered at all. + * + * @return the delay in milliseconds + */ + @ManagedAttribute("delay applied to all requests over the rate limit (in ms)") + public long getDelayMs() + { + return _delayMs; + } + + /** + * Set delay (in milliseconds) that is applied to all requests + * over the rate limit, before they are considered at all. + * + * @param value delay (in milliseconds), 0 - no delay, -1 - reject request + */ + public void setDelayMs(long value) + { + _delayMs = value; + } + + /** + * Get maximum amount of time (in milliseconds) the filter will + * blocking wait for the throttle semaphore. + * + * @return maximum wait time + */ + @ManagedAttribute("maximum time the filter will block waiting throttled connections, (0 for no delay, -1 to reject requests)") + public long getMaxWaitMs() + { + return _maxWaitMs; + } + + /** + * Set maximum amount of time (in milliseconds) the filter will + * blocking wait for the throttle semaphore. + * + * @param value maximum wait time + */ + public void setMaxWaitMs(long value) + { + _maxWaitMs = value; + } + + /** + * Get number of requests over the rate limit able to be + * considered at once. + * + * @return number of requests + */ + @ManagedAttribute("number of requests over rate limit") + public int getThrottledRequests() + { + return _throttledRequests; + } + + /** + * Set number of requests over the rate limit able to be + * considered at once. + * + * @param value number of requests + */ + public void setThrottledRequests(int value) + { + int permits = _passes == null ? 0 : _passes.availablePermits(); + _passes = new Semaphore((value - _throttledRequests + permits), true); + _throttledRequests = value; + } + + /** + * Get amount of time (in milliseconds) to async wait for semaphore. + * + * @return wait time + */ + @ManagedAttribute("amount of time to async wait for semaphore") + public long getThrottleMs() + { + return _throttleMs; + } + + /** + * Set amount of time (in milliseconds) to async wait for semaphore. + * + * @param value wait time + */ + public void setThrottleMs(long value) + { + _throttleMs = value; + } + + /** + * Get maximum amount of time (in milliseconds) to allow + * the request to process. + * + * @return maximum processing time + */ + @ManagedAttribute("maximum time to allow requests to process (in ms)") + public long getMaxRequestMs() + { + return _maxRequestMs; + } + + /** + * Set maximum amount of time (in milliseconds) to allow + * the request to process. + * + * @param value maximum processing time + */ + public void setMaxRequestMs(long value) + { + _maxRequestMs = value; + } + + /** + * Get maximum amount of time (in milliseconds) to keep track + * of request rates for a connection, before deciding that + * the user has gone away, and discarding it. + * + * @return maximum tracking time + */ + @ManagedAttribute("maximum time to track of request rates for connection before discarding") + public long getMaxIdleTrackerMs() + { + return _maxIdleTrackerMs; + } + + /** + * Set maximum amount of time (in milliseconds) to keep track + * of request rates for a connection, before deciding that + * the user has gone away, and discarding it. + * + * @param value maximum tracking time + */ + public void setMaxIdleTrackerMs(long value) + { + _maxIdleTrackerMs = value; + } + + /** + * The unique name of the filter when there is more than + * one DosFilter instance. + * + * @return the name + */ + public String getName() + { + return _name; + } + + /** + * @param name the name to set + */ + public void setName(String name) + { + _name = name; + } + + /** + * Check flag to insert the DoSFilter headers into the response. + * + * @return value of the flag + */ + @ManagedAttribute("inser DoSFilter headers in response") + public boolean isInsertHeaders() + { + return _insertHeaders; + } + + /** + * Set flag to insert the DoSFilter headers into the response. + * + * @param value value of the flag + */ + public void setInsertHeaders(boolean value) + { + _insertHeaders = value; + } + + /** + * Get flag to have usage rate tracked by session if a session exists. + * + * @return value of the flag + */ + @ManagedAttribute("usage rate is tracked by session if one exists") + public boolean isTrackSessions() + { + return _trackSessions; + } + + /** + * Set flag to have usage rate tracked by session if a session exists. + * + * @param value value of the flag + */ + public void setTrackSessions(boolean value) + { + _trackSessions = value; + } + + /** + * Get flag to have usage rate tracked by IP+port (effectively connection) + * if session tracking is not used. + * + * @return value of the flag + */ + @ManagedAttribute("usage rate is tracked by IP+port is session tracking not used") + public boolean isRemotePort() + { + return _remotePort; + } + + /** + * Set flag to have usage rate tracked by IP+port (effectively connection) + * if session tracking is not used. + * + * @param value value of the flag + */ + public void setRemotePort(boolean value) + { + _remotePort = value; + } + + /** + * @return whether this filter is enabled + */ + @ManagedAttribute("whether this filter is enabled") + public boolean isEnabled() + { + return _enabled; + } + + /** + * @param enabled whether this filter is enabled + */ + public void setEnabled(boolean enabled) + { + _enabled = enabled; + } + + public int getTooManyCode() + { + return _tooManyCode; + } + + public void setTooManyCode(int tooManyCode) + { + _tooManyCode = tooManyCode; + } + + /** + * Get a list of IP addresses that will not be rate limited. + * + * @return comma-separated whitelist + */ + @ManagedAttribute("list of IPs that will not be rate limited") + public String getWhitelist() + { + StringBuilder result = new StringBuilder(); + for (Iterator iterator = _whitelist.iterator(); iterator.hasNext(); ) + { + String address = iterator.next(); + result.append(address); + if (iterator.hasNext()) + result.append(","); + } + return result.toString(); + } + + /** + * Set a list of IP addresses that will not be rate limited. + * + * @param commaSeparatedList comma-separated whitelist + */ + public void setWhitelist(String commaSeparatedList) + { + List result = new ArrayList<>(); + for (String address : StringUtil.csvSplit(commaSeparatedList)) + { + addWhitelistAddress(result, address); + } + clearWhitelist(); + _whitelist.addAll(result); + LOG.debug("Whitelisted IP addresses: {}", result); + } + + /** + * Clears the list of whitelisted IP addresses + */ + @ManagedOperation("clears the list of IP addresses that will not be rate limited") + public void clearWhitelist() + { + _whitelist.clear(); + } + + /** + * Adds the given IP address, either in the form of a dotted decimal notation A.B.C.D + * or in the CIDR notation A.B.C.D/M, to the list of whitelisted IP addresses. + * + * @param address the address to add + * @return whether the address was added to the list + * @see #removeWhitelistAddress(String) + */ + @ManagedOperation("adds an IP address that will not be rate limited") + public boolean addWhitelistAddress(@Name("address") String address) + { + return addWhitelistAddress(_whitelist, address); + } + + private boolean addWhitelistAddress(List list, String address) + { + address = address.trim(); + return address.length() > 0 && list.add(address); + } + + /** + * Removes the given address from the list of whitelisted IP addresses. + * + * @param address the address to remove + * @return whether the address was removed from the list + * @see #addWhitelistAddress(String) + */ + @ManagedOperation("removes an IP address that will not be rate limited") + public boolean removeWhitelistAddress(@Name("address") String address) + { + return _whitelist.remove(address); + } + + /** + * A RateTracker is associated with a connection, and stores request rate + * data. + */ + static class RateTracker implements Runnable, HttpSessionBindingListener, HttpSessionActivationListener, Serializable + { + private static final long serialVersionUID = 3534663738034577872L; + + protected final String _filterName; + protected transient ServletContext _context; + protected final String _id; + protected final int _type; + protected final long[] _timestamps; + + protected int _next; + + public RateTracker(ServletContext context, String filterName, String id, int type, int maxRequestsPerSecond) + { + _context = context; + _filterName = filterName; + _id = id; + _type = type; + _timestamps = new long[maxRequestsPerSecond]; + _next = 0; + } + + /** + * @param now the time now (in milliseconds) + * @return the current calculated request rate over the last second + */ + public boolean isRateExceeded(long now) + { + final long last; + synchronized (this) + { + last = _timestamps[_next]; + _timestamps[_next] = now; + _next = (_next + 1) % _timestamps.length; + } + + return last != 0 && (now - last) < 1000L; + } + + public String getId() + { + return _id; + } + + public int getType() + { + return _type; + } + + @Override + public void valueBound(HttpSessionBindingEvent event) + { + if (LOG.isDebugEnabled()) + LOG.debug("Value bound: {}", getId()); + _context = event.getSession().getServletContext(); + } + + @Override + public void valueUnbound(HttpSessionBindingEvent event) + { + //take the tracker out of the list of trackers + DoSFilter filter = (DoSFilter)event.getSession().getServletContext().getAttribute(_filterName); + removeFromRateTrackers(filter, _id); + _context = null; + } + + @Override + public void sessionWillPassivate(HttpSessionEvent se) + { + //take the tracker of the list of trackers (if its still there) + DoSFilter filter = (DoSFilter)se.getSession().getServletContext().getAttribute(_filterName); + removeFromRateTrackers(filter, _id); + _context = null; + } + + @Override + public void sessionDidActivate(HttpSessionEvent se) + { + RateTracker tracker = (RateTracker)se.getSession().getAttribute(__TRACKER); + ServletContext context = se.getSession().getServletContext(); + tracker.setContext(context); + DoSFilter filter = (DoSFilter)context.getAttribute(_filterName); + if (filter == null) + { + LOG.info("No filter {} for rate tracker {}", _filterName, tracker); + return; + } + addToRateTrackers(filter, tracker); + } + + public void setContext(ServletContext context) + { + _context = context; + } + + protected void removeFromRateTrackers(DoSFilter filter, String id) + { + if (filter == null) + return; + + filter.removeFromRateTracker(id); + if (LOG.isDebugEnabled()) + LOG.debug("Tracker removed: {}", getId()); + } + + private void addToRateTrackers(DoSFilter filter, RateTracker tracker) + { + if (filter == null) + return; + filter.addToRateTracker(tracker); + } + + @Override + public void run() + { + if (_context == null) + { + LOG.warn("Unknkown context for rate tracker {}", this); + return; + } + + int latestIndex = _next == 0 ? (_timestamps.length - 1) : (_next - 1); + long last = _timestamps[latestIndex]; + boolean hasRecentRequest = last != 0 && (System.currentTimeMillis() - last) < 1000L; + + DoSFilter filter = (DoSFilter)_context.getAttribute(_filterName); + + if (hasRecentRequest) + { + if (filter != null) + filter.schedule(this); + else + LOG.warn("No filter {}", _filterName); + } + else + removeFromRateTrackers(filter, _id); + } + + @Override + public String toString() + { + return "RateTracker/" + _id + "/" + _type; + } + } + + private static class FixedRateTracker extends RateTracker + { + public FixedRateTracker(ServletContext context, String filterName, String id, int type, int numRecentRequestsTracked) + { + super(context, filterName, id, type, numRecentRequestsTracked); + } + + @Override + public boolean isRateExceeded(long now) + { + // rate limit is never exceeded, but we keep track of the request timestamps + // so that we know whether there was recent activity on this tracker + // and whether it should be expired + synchronized (this) + { + _timestamps[_next] = now; + _next = (_next + 1) % _timestamps.length; + } + + return false; + } + + @Override + public String toString() + { + return "Fixed" + super.toString(); + } + } + + private static class DoSTimeoutAsyncListener implements AsyncListener + { + @Override + public void onStartAsync(AsyncEvent event) + { + } + + @Override + public void onComplete(AsyncEvent event) + { + } + + @Override + public void onTimeout(AsyncEvent event) throws IOException + { + event.getAsyncContext().dispatch(); + } + + @Override + public void onError(AsyncEvent event) + { + } + } + + private class DoSAsyncListener extends DoSTimeoutAsyncListener + { + private final int priority; + + public DoSAsyncListener(int priority) + { + this.priority = priority; + } + + @Override + public void onTimeout(AsyncEvent event) throws IOException + { + _queues[priority].remove(event.getAsyncContext()); + super.onTimeout(event); + } + } + + private String createRemotePortId(final ServletRequest request) + { + final String addr = request.getRemoteAddr(); + final int port = request.getRemotePort(); + if (addr.contains(":")) + return "[" + addr + "]:" + port; + return addr + ":" + port; + } +} diff --git a/test-proxy/src/main/java/servlets/EventSource.java b/test-proxy/src/main/java/servlets/EventSource.java new file mode 100644 index 0000000..5bf2390 --- /dev/null +++ b/test-proxy/src/main/java/servlets/EventSource.java @@ -0,0 +1,108 @@ +// +// ======================================================================== +// Copyright (c) 1995-2020 Mort Bay Consulting Pty Ltd and others. +// +// This program and the accompanying materials are made available under +// the terms of the Eclipse Public License 2.0 which is available at +// https://www.eclipse.org/legal/epl-2.0 +// +// This Source Code may also be made available under the following +// Secondary Licenses when the conditions for such availability set +// forth in the Eclipse Public License, v. 2.0 are satisfied: +// the Apache License v2.0 which is available at +// https://www.apache.org/licenses/LICENSE-2.0 +// +// SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 +// ======================================================================== +// + +package servlets; + +import java.io.IOException; + +/** + *

{@link EventSource} is the passive half of an event source connection, as defined by the + * EventSource Specification.

+ *

{@link EventSource.Emitter} is the active half of the connection and allows to operate on the connection.

+ *

{@link EventSource} allows applications to be notified of events happening on the connection; + * two events are being notified: the opening of the event source connection, where method + * {@link EventSource#onOpen(Emitter)} is invoked, and the closing of the event source connection, + * where method {@link EventSource#onClose()} is invoked.

+ * + * @see EventSourceServlet + */ +public interface EventSource +{ + /** + *

Callback method invoked when an event source connection is opened.

+ * + * @param emitter the {@link Emitter} instance that allows to operate on the connection + * @throws IOException if the implementation of the method throws such exception + */ + public void onOpen(Emitter emitter) throws IOException; + + /** + *

Callback method invoked when an event source connection is closed.

+ */ + public void onClose(); + + /** + *

{@link Emitter} is the active half of an event source connection, and allows applications + * to operate on the connection by sending events, data or comments, or by closing the connection.

+ *

An {@link Emitter} instance will be created for each new event source connection.

+ *

{@link Emitter} instances are fully thread safe and can be used from multiple threads.

+ */ + public interface Emitter + { + /** + *

Sends a named event with data to the client.

+ *

When invoked as: event("foo", "bar"), the client will receive the lines:

+ *
+         * event: foo
+         * data: bar
+         * 
+ * + * @param name the event name + * @param data the data to be sent + * @throws IOException if an I/O failure occurred + * @see #data(String) + */ + public void event(String name, String data) throws IOException; + + /** + *

Sends a default event with data to the client.

+ *

When invoked as: data("baz"), the client will receive the line:

+ *
+         * data: baz
+         * 
+ *

When invoked as: data("foo\r\nbar\rbaz\nbax"), the client will receive the lines:

+ *
+         * data: foo
+         * data: bar
+         * data: baz
+         * data: bax
+         * 
+ * + * @param data the data to be sent + * @throws IOException if an I/O failure occurred + */ + public void data(String data) throws IOException; + + /** + *

Sends a comment to the client.

+ *

When invoked as: comment("foo"), the client will receive the line:

+ *
+         * : foo
+         * 
+ * + * @param comment the comment to send + * @throws IOException if an I/O failure occurred + */ + public void comment(String comment) throws IOException; + + /** + *

Closes this event source connection.

+ */ + public void close(); + } +} diff --git a/test-proxy/src/main/java/servlets/EventSourceServlet.java b/test-proxy/src/main/java/servlets/EventSourceServlet.java new file mode 100644 index 0000000..9344fa7 --- /dev/null +++ b/test-proxy/src/main/java/servlets/EventSourceServlet.java @@ -0,0 +1,236 @@ +// +// ======================================================================== +// Copyright (c) 1995-2020 Mort Bay Consulting Pty Ltd and others. +// +// This program and the accompanying materials are made available under +// the terms of the Eclipse Public License 2.0 which is available at +// https://www.eclipse.org/legal/epl-2.0 +// +// This Source Code may also be made available under the following +// Secondary Licenses when the conditions for such availability set +// forth in the Eclipse Public License, v. 2.0 are satisfied: +// the Apache License v2.0 which is available at +// https://www.apache.org/licenses/LICENSE-2.0 +// +// SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 +// ======================================================================== +// + +package servlets; + +import javax.servlet.AsyncContext; +import javax.servlet.ServletException; +import javax.servlet.ServletOutputStream; +import javax.servlet.http.HttpServlet; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.BufferedReader; +import java.io.IOException; +import java.io.StringReader; +import java.nio.charset.StandardCharsets; +import java.util.Enumeration; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +/** + *

A servlet that implements the event source protocol, + * also known as "server sent events".

+ *

This servlet must be subclassed to implement abstract method {@link #newEventSource(HttpServletRequest)} + * to return an instance of {@link EventSource} that allows application to listen for event source events + * and to emit event source events.

+ *

This servlet supports the following configuration parameters:

+ *
    + *
  • heartBeatPeriod, that specifies the heartbeat period, in seconds, used to check + * whether the connection has been closed by the client; defaults to 10 seconds.
  • + *
+ * + *

NOTE: there is currently no support for last-event-id.

+ */ +public abstract class EventSourceServlet extends HttpServlet +{ + private static final byte[] CRLF = new byte[]{'\r', '\n'}; + private static final byte[] EVENT_FIELD = "event: ".getBytes(StandardCharsets.UTF_8); + private static final byte[] DATA_FIELD = "data: ".getBytes(StandardCharsets.UTF_8); + private static final byte[] COMMENT_FIELD = ": ".getBytes(StandardCharsets.UTF_8); + + private ScheduledExecutorService scheduler; + private int heartBeatPeriod = 10; + + @Override + public void init() throws ServletException + { + String heartBeatPeriodParam = getServletConfig().getInitParameter("heartBeatPeriod"); + if (heartBeatPeriodParam != null) + heartBeatPeriod = Integer.parseInt(heartBeatPeriodParam); + scheduler = Executors.newSingleThreadScheduledExecutor(); + } + + @Override + public void destroy() + { + if (scheduler != null) + scheduler.shutdown(); + } + + @Override + protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException + { + @SuppressWarnings("unchecked") Enumeration acceptValues = request.getHeaders("Accept"); + while (acceptValues.hasMoreElements()) + { + String accept = acceptValues.nextElement(); + if (accept.equals("text/event-stream")) + { + EventSource eventSource = newEventSource(request); + if (eventSource == null) + { + response.sendError(HttpServletResponse.SC_SERVICE_UNAVAILABLE); + } + else + { + respond(request, response); + AsyncContext async = request.startAsync(); + // Infinite timeout because the continuation is never resumed, + // but only completed on close + async.setTimeout(0); + EventSourceEmitter emitter = new EventSourceEmitter(eventSource, async); + emitter.scheduleHeartBeat(); + open(eventSource, emitter); + } + return; + } + } + super.doGet(request, response); + } + + protected abstract EventSource newEventSource(HttpServletRequest request); + + protected void respond(HttpServletRequest request, HttpServletResponse response) throws IOException + { + response.setStatus(HttpServletResponse.SC_OK); + response.setCharacterEncoding(StandardCharsets.UTF_8.name()); + response.setContentType("text/event-stream"); + // By adding this header, and not closing the connection, + // we disable HTTP chunking, and we can use write()+flush() + // to send data in the text/event-stream protocol + response.addHeader("Connection", "close"); + response.flushBuffer(); + } + + protected void open(EventSource eventSource, EventSource.Emitter emitter) throws IOException + { + eventSource.onOpen(emitter); + } + + protected class EventSourceEmitter implements EventSource.Emitter, Runnable + { + private final EventSource eventSource; + private final AsyncContext async; + private final ServletOutputStream output; + private Future heartBeat; + private boolean closed; + + public EventSourceEmitter(EventSource eventSource, AsyncContext async) throws IOException + { + this.eventSource = eventSource; + this.async = async; + this.output = async.getResponse().getOutputStream(); + } + + @Override + public void event(String name, String data) throws IOException + { + synchronized (this) + { + output.write(EVENT_FIELD); + output.write(name.getBytes(StandardCharsets.UTF_8)); + output.write(CRLF); + data(data); + } + } + + @Override + public void data(String data) throws IOException + { + synchronized (this) + { + BufferedReader reader = new BufferedReader(new StringReader(data)); + String line; + while ((line = reader.readLine()) != null) + { + output.write(DATA_FIELD); + output.write(line.getBytes(StandardCharsets.UTF_8)); + output.write(CRLF); + } + output.write(CRLF); + flush(); + } + } + + @Override + public void comment(String comment) throws IOException + { + synchronized (this) + { + output.write(COMMENT_FIELD); + output.write(comment.getBytes(StandardCharsets.UTF_8)); + output.write(CRLF); + output.write(CRLF); + flush(); + } + } + + @Override + public void run() + { + // If the other peer closes the connection, the first + // flush() should generate a TCP reset that is detected + // on the second flush() + try + { + synchronized (this) + { + output.write('\r'); + flush(); + output.write('\n'); + flush(); + } + // We could write, reschedule heartbeat + scheduleHeartBeat(); + } + catch (IOException x) + { + // The other peer closed the connection + close(); + eventSource.onClose(); + } + } + + protected void flush() throws IOException + { + async.getResponse().flushBuffer(); + } + + @Override + public void close() + { + synchronized (this) + { + closed = true; + heartBeat.cancel(false); + } + async.complete(); + } + + private void scheduleHeartBeat() + { + synchronized (this) + { + if (!closed) + heartBeat = scheduler.schedule(this, heartBeatPeriod, TimeUnit.SECONDS); + } + } + } +} diff --git a/test-proxy/src/main/java/servlets/HeaderFilter.java b/test-proxy/src/main/java/servlets/HeaderFilter.java new file mode 100644 index 0000000..461aac8 --- /dev/null +++ b/test-proxy/src/main/java/servlets/HeaderFilter.java @@ -0,0 +1,194 @@ +// +// ======================================================================== +// Copyright (c) 1995-2020 Mort Bay Consulting Pty Ltd and others. +// +// This program and the accompanying materials are made available under +// the terms of the Eclipse Public License 2.0 which is available at +// https://www.eclipse.org/legal/epl-2.0 +// +// This Source Code may also be made available under the following +// Secondary Licenses when the conditions for such availability set +// forth in the Eclipse Public License, v. 2.0 are satisfied: +// the Apache License v2.0 which is available at +// https://www.apache.org/licenses/LICENSE-2.0 +// +// SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 +// ======================================================================== +// + +package servlets; + +import org.eclipse.jetty.util.StringUtil; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.servlet.*; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +/** + * Header Filter + *

+ * This filter sets or adds a header to the response. + *

+ * The {@code headerConfig} init param is a CSV of actions to perform on headers, with the following syntax:
+ * [action] [header name]: [header value]
+ * [action] can be one of set, add, setDate, or addDate
+ * The date actions will add the header value in milliseconds to the current system time before setting a date header. + *

+ * Below is an example value for headerConfig:
+ * + *

+ * set X-Frame-Options: DENY,
+ * "add Cache-Control: no-cache, no-store, must-revalidate",
+ * setDate Expires: 31540000000,
+ * addDate Date: 0
+ * 
+ * + * @see IncludeExcludeBasedFilter + */ +public class HeaderFilter extends IncludeExcludeBasedFilter +{ + private List _configuredHeaders = new ArrayList<>(); + private static final Logger LOG = LoggerFactory.getLogger(HeaderFilter.class); + + @Override + public void init(FilterConfig filterConfig) throws ServletException + { + super.init(filterConfig); + String headerConfig = filterConfig.getInitParameter("headerConfig"); + + if (headerConfig != null) + { + String[] configs = StringUtil.csvSplit(headerConfig); + for (String config : configs) + { + _configuredHeaders.add(parseHeaderConfiguration(config)); + } + } + + if (LOG.isDebugEnabled()) + LOG.debug(this.toString()); + } + + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException + { + HttpServletRequest httpRequest = (HttpServletRequest)request; + HttpServletResponse httpResponse = (HttpServletResponse)response; + + if (super.shouldFilter(httpRequest, httpResponse)) + { + for (ConfiguredHeader header : _configuredHeaders) + { + if (header.isDate()) + { + long headerValue = System.currentTimeMillis() + header.getMsOffset(); + if (header.isAdd()) + { + httpResponse.addDateHeader(header.getName(), headerValue); + } + else + { + httpResponse.setDateHeader(header.getName(), headerValue); + } + } + else // constant header value + { + if (header.isAdd()) + { + httpResponse.addHeader(header.getName(), header.getValue()); + } + else + { + httpResponse.setHeader(header.getName(), header.getValue()); + } + } + } + } + + chain.doFilter(request, response); + } + + @Override + public String toString() + { + StringBuilder sb = new StringBuilder(); + sb.append(super.toString()).append("\n"); + sb.append("configured headers:\n"); + for (ConfiguredHeader c : _configuredHeaders) + { + sb.append(c).append("\n"); + } + + return sb.toString(); + } + + private ConfiguredHeader parseHeaderConfiguration(String config) + { + String[] configTokens = config.trim().split(" ", 2); + String method = configTokens[0].trim(); + String header = configTokens[1]; + String[] headerTokens = header.trim().split(":", 2); + String headerName = headerTokens[0].trim(); + String headerValue = headerTokens[1].trim(); + ConfiguredHeader configuredHeader = new ConfiguredHeader(headerName, headerValue, method.startsWith("add"), method.endsWith("Date")); + return configuredHeader; + } + + private static class ConfiguredHeader + { + private String _name; + private String _value; + private long _msOffset; + private boolean _add; + private boolean _date; + + public ConfiguredHeader(String name, String value, boolean add, boolean date) + { + _name = name; + _value = value; + _add = add; + _date = date; + + if (_date) + { + _msOffset = Long.parseLong(_value); + } + } + + public String getName() + { + return _name; + } + + public String getValue() + { + return _value; + } + + public boolean isAdd() + { + return _add; + } + + public boolean isDate() + { + return _date; + } + + public long getMsOffset() + { + return _msOffset; + } + + @Override + public String toString() + { + return (_add ? "add" : "set") + (_date ? "Date" : "") + " " + _name + ": " + _value; + } + } +} diff --git a/test-proxy/src/main/java/servlets/IncludeExcludeBasedFilter.java b/test-proxy/src/main/java/servlets/IncludeExcludeBasedFilter.java new file mode 100644 index 0000000..77405f6 --- /dev/null +++ b/test-proxy/src/main/java/servlets/IncludeExcludeBasedFilter.java @@ -0,0 +1,185 @@ +// +// ======================================================================== +// Copyright (c) 1995-2020 Mort Bay Consulting Pty Ltd and others. +// +// This program and the accompanying materials are made available under +// the terms of the Eclipse Public License 2.0 which is available at +// https://www.eclipse.org/legal/epl-2.0 +// +// This Source Code may also be made available under the following +// Secondary Licenses when the conditions for such availability set +// forth in the Eclipse Public License, v. 2.0 are satisfied: +// the Apache License v2.0 which is available at +// https://www.apache.org/licenses/LICENSE-2.0 +// +// SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 +// ======================================================================== +// + +package servlets; + +import org.eclipse.jetty.http.MimeTypes; +import org.eclipse.jetty.http.pathmap.PathSpecSet; +import org.eclipse.jetty.util.IncludeExclude; +import org.eclipse.jetty.util.IncludeExcludeSet; +import org.eclipse.jetty.util.StringUtil; +import org.eclipse.jetty.util.URIUtil; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.servlet.Filter; +import javax.servlet.FilterConfig; +import javax.servlet.ServletContext; +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +/** + * Include Exclude Based Filter + *

+ * This is an abstract filter which helps with filtering based on include/exclude of paths, mime types, and/or http methods. + *

+ * Use the {@link #shouldFilter(HttpServletRequest, HttpServletResponse)} method to determine if a request/response should be filtered. If mime types are used, + * it should be called after {@link javax.servlet.FilterChain#doFilter(javax.servlet.ServletRequest, javax.servlet.ServletResponse)} since the mime type may not + * be written until then. + * + * Supported init params: + *

    + *
  • includedPaths - CSV of path specs to include
  • + *
  • excludedPaths - CSV of path specs to exclude
  • + *
  • includedMimeTypes - CSV of mime types to include
  • + *
  • excludedMimeTypes - CSV of mime types to exclude
  • + *
  • includedHttpMethods - CSV of http methods to include
  • + *
  • excludedHttpMethods - CSV of http methods to exclude
  • + *
+ *

+ * Path spec rules: + *

    + *
  • If the spec starts with '^' the spec is assumed to be a regex based path spec and will match with normal Java regex rules.
  • + *
  • If the spec starts with '/' the spec is assumed to be a Servlet url-pattern rules path spec for either an exact match or prefix based + * match.
  • + *
  • If the spec starts with '*.' the spec is assumed to be a Servlet url-pattern rules path spec for a suffix based match.
  • + *
  • All other syntaxes are unsupported.
  • + *
+ *

+ * CSVs are parsed with {@link StringUtil#csvSplit(String)} + * + * @see PathSpecSet + * @see IncludeExcludeSet + */ +public abstract class IncludeExcludeBasedFilter implements Filter +{ + private final IncludeExclude _mimeTypes = new IncludeExclude<>(); + private final IncludeExclude _httpMethods = new IncludeExclude<>(); + private final IncludeExclude _paths = new IncludeExclude<>(PathSpecSet.class); + private static final Logger LOG = LoggerFactory.getLogger(IncludeExcludeBasedFilter.class); + + @Override + public void init(FilterConfig filterConfig) throws ServletException + { + final String includedPaths = filterConfig.getInitParameter("includedPaths"); + final String excludedPaths = filterConfig.getInitParameter("excludedPaths"); + final String includedMimeTypes = filterConfig.getInitParameter("includedMimeTypes"); + final String excludedMimeTypes = filterConfig.getInitParameter("excludedMimeTypes"); + final String includedHttpMethods = filterConfig.getInitParameter("includedHttpMethods"); + final String excludedHttpMethods = filterConfig.getInitParameter("excludedHttpMethods"); + + if (includedPaths != null) + { + _paths.include(StringUtil.csvSplit(includedPaths)); + } + if (excludedPaths != null) + { + _paths.exclude(StringUtil.csvSplit(excludedPaths)); + } + if (includedMimeTypes != null) + { + _mimeTypes.include(StringUtil.csvSplit(includedMimeTypes)); + } + if (excludedMimeTypes != null) + { + _mimeTypes.exclude(StringUtil.csvSplit(excludedMimeTypes)); + } + if (includedHttpMethods != null) + { + _httpMethods.include(StringUtil.csvSplit(includedHttpMethods)); + } + if (excludedHttpMethods != null) + { + _httpMethods.exclude(StringUtil.csvSplit(excludedHttpMethods)); + } + } + + protected String guessMimeType(HttpServletRequest httpRequest, HttpServletResponse httpResponse) + { + String contentType = httpResponse.getContentType(); + LOG.debug("Content Type is: {}", contentType); + + String mimeType = ""; + if (contentType != null) + { + mimeType = MimeTypes.getContentTypeWithoutCharset(contentType); + LOG.debug("Mime Type is: {}", mimeType); + } + else + { + String requestUrl = httpRequest.getPathInfo(); + mimeType = MimeTypes.getDefaultMimeByExtension(requestUrl); + + if (mimeType == null) + { + mimeType = ""; + } + + LOG.debug("Guessed mime type is {}", mimeType); + } + + return mimeType; + } + + protected boolean shouldFilter(HttpServletRequest httpRequest, HttpServletResponse httpResponse) + { + String httpMethod = httpRequest.getMethod(); + LOG.debug("HTTP method is: {}", httpMethod); + if (!_httpMethods.test(httpMethod)) + { + LOG.debug("should not apply filter because HTTP method does not match"); + return false; + } + + String mimeType = guessMimeType(httpRequest, httpResponse); + + if (!_mimeTypes.test(mimeType)) + { + LOG.debug("should not apply filter because mime type does not match"); + return false; + } + + ServletContext context = httpRequest.getServletContext(); + String path = context == null ? httpRequest.getRequestURI() : URIUtil.addPaths(httpRequest.getServletPath(), httpRequest.getPathInfo()); + LOG.debug("Path is: {}", path); + if (!_paths.test(path)) + { + LOG.debug("should not apply filter because path does not match"); + return false; + } + + return true; + } + + @Override + public void destroy() + { + } + + @Override + public String toString() + { + StringBuilder sb = new StringBuilder(); + sb.append("filter configuration:\n"); + sb.append("paths:\n").append(_paths).append("\n"); + sb.append("mime types:\n").append(_mimeTypes).append("\n"); + sb.append("http methods:\n").append(_httpMethods); + return sb.toString(); + } +} diff --git a/test-proxy/src/main/java/servlets/PushCacheFilter.java b/test-proxy/src/main/java/servlets/PushCacheFilter.java new file mode 100644 index 0000000..d78a544 --- /dev/null +++ b/test-proxy/src/main/java/servlets/PushCacheFilter.java @@ -0,0 +1,306 @@ +// +// ======================================================================== +// Copyright (c) 1995-2020 Mort Bay Consulting Pty Ltd and others. +// +// This program and the accompanying materials are made available under +// the terms of the Eclipse Public License 2.0 which is available at +// https://www.eclipse.org/legal/epl-2.0 +// +// This Source Code may also be made available under the following +// Secondary Licenses when the conditions for such availability set +// forth in the Eclipse Public License, v. 2.0 are satisfied: +// the Apache License v2.0 which is available at +// https://www.apache.org/licenses/LICENSE-2.0 +// +// SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 +// ======================================================================== +// + +package servlets; + +import org.eclipse.jetty.http.*; +import org.eclipse.jetty.util.StringUtil; +import org.eclipse.jetty.util.annotation.ManagedAttribute; +import org.eclipse.jetty.util.annotation.ManagedObject; +import org.eclipse.jetty.util.annotation.ManagedOperation; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.servlet.*; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.PushBuilder; +import java.io.IOException; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; + +/** + *

A filter that builds a cache of secondary resources associated + * to primary resources.

+ *

A typical request for a primary resource such as {@code index.html} + * is immediately followed by a number of requests for secondary resources. + * Secondary resource requests will have a {@code Referer} HTTP header + * that points to {@code index.html}, which is used to associate the secondary + * resource to the primary resource.

+ *

Only secondary resources that are requested within a (small) time period + * from the request of the primary resource are associated with the primary + * resource.

+ *

This allows to build a cache of secondary resources associated with + * primary resources. When a request for a primary resource arrives, associated + * secondary resources are pushed to the client, unless the request carries + * {@code If-xxx} header that hint that the client has the resources in its + * cache.

+ *

If the init param useQueryInKey is set, then the query string is used as + * as part of the key to identify a resource

+ */ +@ManagedObject("Push cache based on the HTTP 'Referer' header") +public class PushCacheFilter implements Filter +{ + private static final Logger LOG = LoggerFactory.getLogger(PushCacheFilter.class); + + private final Set _ports = new HashSet<>(); + private final Set _hosts = new HashSet<>(); + private final ConcurrentMap _cache = new ConcurrentHashMap<>(); + private long _associatePeriod = 4000L; + private int _maxAssociations = 16; + private long _renew = System.nanoTime(); + private boolean _useQueryInKey; + + @Override + public void init(FilterConfig config) throws ServletException + { + String associatePeriod = config.getInitParameter("associatePeriod"); + if (associatePeriod != null) + _associatePeriod = Long.parseLong(associatePeriod); + + String maxAssociations = config.getInitParameter("maxAssociations"); + if (maxAssociations != null) + _maxAssociations = Integer.parseInt(maxAssociations); + + String hosts = config.getInitParameter("hosts"); + if (hosts != null) + Collections.addAll(_hosts, StringUtil.csvSplit(hosts)); + + String ports = config.getInitParameter("ports"); + if (ports != null) + for (String p : StringUtil.csvSplit(ports)) + { + _ports.add(Integer.parseInt(p)); + } + + _useQueryInKey = Boolean.parseBoolean(config.getInitParameter("useQueryInKey")); + + // Expose for JMX. + config.getServletContext().setAttribute(config.getFilterName(), this); + + if (LOG.isDebugEnabled()) + LOG.debug("period={} max={} hosts={} ports={}", _associatePeriod, _maxAssociations, _hosts, _ports); + } + + @Override + public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException + { + HttpServletRequest request = (HttpServletRequest)req; + + PushBuilder pushBuilder = request.newPushBuilder(); + if (HttpVersion.fromString(request.getProtocol()).getVersion() < 20 || + !HttpMethod.GET.is(request.getMethod()) || + pushBuilder == null) + { + chain.doFilter(req, resp); + return; + } + + long now = System.nanoTime(); + + boolean conditional = false; + String referrer = null; + List headerNames = Collections.list(request.getHeaderNames()); + for (String headerName : headerNames) + { + if (HttpHeader.IF_MATCH.is(headerName) || + HttpHeader.IF_MODIFIED_SINCE.is(headerName) || + HttpHeader.IF_NONE_MATCH.is(headerName) || + HttpHeader.IF_UNMODIFIED_SINCE.is(headerName)) + { + conditional = true; + break; + } + else if (HttpHeader.REFERER.is(headerName)) + { + referrer = request.getHeader(headerName); + } + } + + if (LOG.isDebugEnabled()) + LOG.debug("{} {} referrer={} conditional={}", request.getMethod(), request.getRequestURI(), referrer, conditional); + + String path = request.getRequestURI(); + String query = request.getQueryString(); + if (_useQueryInKey && query != null) + path += "?" + query; + if (referrer != null) + { + HttpURI referrerURI = new HttpURI(referrer); + String host = referrerURI.getHost(); + int port = referrerURI.getPort(); + if (port <= 0) + { + String scheme = referrerURI.getScheme(); + if (scheme != null) + port = HttpScheme.HTTPS.is(scheme) ? 443 : 80; + else + port = request.isSecure() ? 443 : 80; + } + + boolean referredFromHere = !_hosts.isEmpty() ? _hosts.contains(host) : host.equals(request.getServerName()); + referredFromHere &= !_ports.isEmpty() ? _ports.contains(port) : port == request.getServerPort(); + + if (referredFromHere) + { + if (HttpMethod.GET.is(request.getMethod())) + { + String referrerPath = _useQueryInKey ? referrerURI.getPathQuery() : referrerURI.getPath(); + if (referrerPath == null) + referrerPath = "/"; + if (referrerPath.startsWith(request.getContextPath() + "/")) + { + if (!referrerPath.equals(path)) + { + PrimaryResource primaryResource = _cache.get(referrerPath); + if (primaryResource != null) + { + long primaryTimestamp = primaryResource._timestamp.get(); + if (primaryTimestamp != 0) + { + if (now - primaryTimestamp < TimeUnit.MILLISECONDS.toNanos(_associatePeriod)) + { + Set associated = primaryResource._associated; + // Not strictly concurrent-safe, just best effort to limit associations. + if (associated.size() <= _maxAssociations) + { + if (associated.add(path)) + { + if (LOG.isDebugEnabled()) + LOG.debug("Associated {} to {}", path, referrerPath); + } + } + else + { + if (LOG.isDebugEnabled()) + LOG.debug("Not associated {} to {}, exceeded max associations of {}", path, referrerPath, _maxAssociations); + } + } + else + { + if (LOG.isDebugEnabled()) + LOG.debug("Not associated {} to {}, outside associate period of {}ms", path, referrerPath, _associatePeriod); + } + } + } + } + else + { + if (LOG.isDebugEnabled()) + LOG.debug("Not associated {} to {}, referring to self", path, referrerPath); + } + } + else + { + if (LOG.isDebugEnabled()) + LOG.debug("Not associated {} to {}, different context", path, referrerPath); + } + } + } + else + { + if (LOG.isDebugEnabled()) + LOG.debug("External referrer {}", referrer); + } + } + + PrimaryResource primaryResource = _cache.get(path); + if (primaryResource == null) + { + PrimaryResource r = new PrimaryResource(); + primaryResource = _cache.putIfAbsent(path, r); + primaryResource = primaryResource == null ? r : primaryResource; + primaryResource._timestamp.compareAndSet(0, now); + if (LOG.isDebugEnabled()) + LOG.debug("Cached primary resource {}", path); + } + else + { + long last = primaryResource._timestamp.get(); + if (last < _renew && primaryResource._timestamp.compareAndSet(last, now)) + { + primaryResource._associated.clear(); + if (LOG.isDebugEnabled()) + LOG.debug("Clear associated resources for {}", path); + } + } + + // Push associated resources. + if (!conditional && !primaryResource._associated.isEmpty()) + { + // Breadth-first push of associated resources. + Queue queue = new ArrayDeque<>(); + queue.offer(primaryResource); + while (!queue.isEmpty()) + { + PrimaryResource parent = queue.poll(); + for (String childPath : parent._associated) + { + PrimaryResource child = _cache.get(childPath); + if (child != null) + queue.offer(child); + + if (LOG.isDebugEnabled()) + LOG.debug("Pushing {} for {}", childPath, path); + pushBuilder.path(childPath).push(); + } + } + } + + chain.doFilter(request, resp); + } + + @Override + public void destroy() + { + clearPushCache(); + } + + @ManagedAttribute("The push cache contents") + public Map getPushCache() + { + Map result = new HashMap<>(); + for (Map.Entry entry : _cache.entrySet()) + { + PrimaryResource resource = entry.getValue(); + String value = String.format("size=%d: %s", resource._associated.size(), new TreeSet<>(resource._associated)); + result.put(entry.getKey(), value); + } + return result; + } + + @ManagedOperation(value = "Renews the push cache contents", impact = "ACTION") + public void renewPushCache() + { + _renew = System.nanoTime(); + } + + @ManagedOperation(value = "Clears the push cache contents", impact = "ACTION") + public void clearPushCache() + { + _cache.clear(); + } + + private static class PrimaryResource + { + private final Set _associated = Collections.newSetFromMap(new ConcurrentHashMap<>()); + private final AtomicLong _timestamp = new AtomicLong(); + } +} diff --git a/test-proxy/src/main/java/servlets/PushSessionCacheFilter.java b/test-proxy/src/main/java/servlets/PushSessionCacheFilter.java new file mode 100644 index 0000000..9d71bb9 --- /dev/null +++ b/test-proxy/src/main/java/servlets/PushSessionCacheFilter.java @@ -0,0 +1,194 @@ +// +// ======================================================================== +// Copyright (c) 1995-2020 Mort Bay Consulting Pty Ltd and others. +// +// This program and the accompanying materials are made available under +// the terms of the Eclipse Public License 2.0 which is available at +// https://www.eclipse.org/legal/epl-2.0 +// +// This Source Code may also be made available under the following +// Secondary Licenses when the conditions for such availability set +// forth in the Eclipse Public License, v. 2.0 are satisfied: +// the Apache License v2.0 which is available at +// https://www.apache.org/licenses/LICENSE-2.0 +// +// SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 +// ======================================================================== +// + +package servlets; + +import org.eclipse.jetty.http.HttpHeader; +import org.eclipse.jetty.http.HttpURI; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.servlet.*; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import javax.servlet.http.HttpSession; +import javax.servlet.http.PushBuilder; +import java.io.IOException; +import java.util.ArrayDeque; +import java.util.Queue; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.TimeUnit; + +public class PushSessionCacheFilter implements Filter +{ + private static final String RESPONSE_ATTR = "PushSessionCacheFilter.response"; + private static final String TARGET_ATTR = "PushSessionCacheFilter.target"; + private static final String TIMESTAMP_ATTR = "PushSessionCacheFilter.timestamp"; + private static final Logger LOG = LoggerFactory.getLogger(PushSessionCacheFilter.class); + private final ConcurrentMap _cache = new ConcurrentHashMap<>(); + private long _associateDelay = 5000L; + + @Override + public void init(FilterConfig config) throws ServletException + { + if (config.getInitParameter("associateDelay") != null) + _associateDelay = Long.parseLong(config.getInitParameter("associateDelay")); + + // Add a listener that is used to collect information + // about associated resource, etags and modified dates. + config.getServletContext().addListener(new ServletRequestListener() + { + // Collect information when request is destroyed. + @Override + public void requestDestroyed(ServletRequestEvent sre) + { + HttpServletRequest request = (HttpServletRequest)sre.getServletRequest(); + Target target = (Target)request.getAttribute(TARGET_ATTR); + if (target == null) + return; + + // Update conditional data. + HttpServletResponse response = (HttpServletResponse)request.getAttribute(RESPONSE_ATTR); + target._etag = response.getHeader(HttpHeader.ETAG.asString()); + target._lastModified = response.getHeader(HttpHeader.LAST_MODIFIED.asString()); + + if (LOG.isDebugEnabled()) + LOG.debug("Served {} for {}", response.getStatus(), request.getRequestURI()); + + // Does this request have a referer? + String referer = request.getHeader(HttpHeader.REFERER.asString()); + if (referer != null) + { + // Is the referer from this contexts? + HttpURI refererUri = new HttpURI(referer); + if (request.getServerName().equals(refererUri.getHost())) + { + Target refererTarget = _cache.get(refererUri.getPath()); + if (refererTarget != null) + { + HttpSession session = request.getSession(); + @SuppressWarnings("unchecked") ConcurrentHashMap timestamps = (ConcurrentHashMap)session.getAttribute(TIMESTAMP_ATTR); + Long last = timestamps.get(refererTarget._path); + if (last != null && TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - last) < _associateDelay) + { + if (refererTarget._associated.putIfAbsent(target._path, target) == null) + { + if (LOG.isDebugEnabled()) + LOG.debug("ASSOCIATE {}->{}", refererTarget._path, target._path); + } + } + } + } + } + } + + @Override + public void requestInitialized(ServletRequestEvent sre) + { + } + }); + } + + @Override + public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException + { + req.setAttribute(RESPONSE_ATTR, resp); + HttpServletRequest request = (HttpServletRequest)req; + String uri = request.getRequestURI(); + + if (LOG.isDebugEnabled()) + LOG.debug("{} {}", request.getMethod(), uri); + + HttpSession session = request.getSession(true); + + // find the target for this resource + Target target = _cache.get(uri); + if (target == null) + { + Target t = new Target(uri); + target = _cache.putIfAbsent(uri, t); + target = target == null ? t : target; + } + request.setAttribute(TARGET_ATTR, target); + + // Set the timestamp for this resource in this session + @SuppressWarnings("unchecked") ConcurrentHashMap timestamps = (ConcurrentHashMap)session.getAttribute(TIMESTAMP_ATTR); + if (timestamps == null) + { + timestamps = new ConcurrentHashMap<>(); + session.setAttribute(TIMESTAMP_ATTR, timestamps); + } + timestamps.put(uri, System.nanoTime()); + + // Push any associated resources. + PushBuilder builder = request.newPushBuilder(); + if (builder != null && !target._associated.isEmpty()) + { + boolean conditional = request.getHeader(HttpHeader.IF_NONE_MATCH.asString()) != null || + request.getHeader(HttpHeader.IF_MODIFIED_SINCE.asString()) != null; + // Breadth-first push of associated resources. + Queue queue = new ArrayDeque<>(); + queue.offer(target); + while (!queue.isEmpty()) + { + Target parent = queue.poll(); + builder.addHeader("X-Pusher", PushSessionCacheFilter.class.toString()); + for (Target child : parent._associated.values()) + { + queue.offer(child); + + String path = child._path; + if (LOG.isDebugEnabled()) + LOG.debug("PUSH {} <- {}", path, uri); + + builder.path(path) + .setHeader(HttpHeader.IF_NONE_MATCH.asString(), conditional ? child._etag : null) + .setHeader(HttpHeader.IF_MODIFIED_SINCE.asString(), conditional ? child._lastModified : null); + } + } + } + + chain.doFilter(req, resp); + } + + @Override + public void destroy() + { + _cache.clear(); + } + + private static class Target + { + private final String _path; + private final ConcurrentMap _associated = new ConcurrentHashMap<>(); + private volatile String _etag; + private volatile String _lastModified; + + private Target(String path) + { + _path = path; + } + + @Override + public String toString() + { + return String.format("Target{p=%s,e=%s,m=%s,a=%d}", _path, _etag, _lastModified, _associated.size()); + } + } +} diff --git a/test-proxy/src/main/java/servlets/PutFilter.java b/test-proxy/src/main/java/servlets/PutFilter.java new file mode 100644 index 0000000..85137f3 --- /dev/null +++ b/test-proxy/src/main/java/servlets/PutFilter.java @@ -0,0 +1,356 @@ +// +// ======================================================================== +// Copyright (c) 1995-2020 Mort Bay Consulting Pty Ltd and others. +// +// This program and the accompanying materials are made available under +// the terms of the Eclipse Public License 2.0 which is available at +// https://www.eclipse.org/legal/epl-2.0 +// +// This Source Code may also be made available under the following +// Secondary Licenses when the conditions for such availability set +// forth in the Eclipse Public License, v. 2.0 are satisfied: +// the Apache License v2.0 which is available at +// https://www.apache.org/licenses/LICENSE-2.0 +// +// SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 +// ======================================================================== +// + +package servlets; + +import org.eclipse.jetty.util.IO; +import org.eclipse.jetty.util.StringUtil; +import org.eclipse.jetty.util.URIUtil; + +import javax.servlet.*; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import javax.servlet.http.HttpServletResponseWrapper; +import java.io.*; +import java.net.URI; +import java.net.URISyntaxException; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; + +/** + * PutFilter + * + * A Filter that handles PUT, DELETE and MOVE methods. + * Files are hidden during PUT operations, so that 404's result. + * + * The following init parameters pay be used:
    + *
  • baseURI - The file URI of the document root for put content. + *
  • delAllowed - boolean, if true DELETE and MOVE methods are supported. + *
  • putAtomic - boolean, if true PUT files are written to a temp location and moved into place. + *
+ */ +public class PutFilter implements Filter +{ + public static final String __PUT = "PUT"; + public static final String __DELETE = "DELETE"; + public static final String __MOVE = "MOVE"; + public static final String __OPTIONS = "OPTIONS"; + + Set _operations = new HashSet(); + private ConcurrentMap _hidden = new ConcurrentHashMap(); + + private ServletContext _context; + private String _baseURI; + private boolean _delAllowed; + private boolean _putAtomic; + private File _tmpdir; + + @Override + public void init(FilterConfig config) throws ServletException + { + _context = config.getServletContext(); + + _tmpdir = (File)_context.getAttribute("javax.servlet.context.tempdir"); + + if (_context.getRealPath("/") == null) + throw new UnavailableException("Packed war"); + + String b = config.getInitParameter("baseURI"); + if (b != null) + { + _baseURI = b; + } + else + { + File base = new File(_context.getRealPath("/")); + _baseURI = base.toURI().toString(); + } + + _delAllowed = getInitBoolean(config, "delAllowed"); + _putAtomic = getInitBoolean(config, "putAtomic"); + + _operations.add(__OPTIONS); + _operations.add(__PUT); + if (_delAllowed) + { + _operations.add(__DELETE); + _operations.add(__MOVE); + } + } + + private boolean getInitBoolean(FilterConfig config, String name) + { + String value = config.getInitParameter(name); + return value != null && value.length() > 0 && (value.startsWith("t") || value.startsWith("T") || value.startsWith("y") || value.startsWith("Y") || value.startsWith("1")); + } + + @Override + public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException + { + HttpServletRequest request = (HttpServletRequest)req; + HttpServletResponse response = (HttpServletResponse)res; + + String servletPath = request.getServletPath(); + String pathInfo = request.getPathInfo(); + String pathInContext = URIUtil.addPaths(servletPath, pathInfo); + + String resource = URIUtil.addPaths(_baseURI, pathInContext); + + String method = request.getMethod(); + boolean op = _operations.contains(method); + + if (op) + { + File file = null; + try + { + if (method.equals(__OPTIONS)) + handleOptions(chain, request, response); + else + { + file = new File(new URI(resource)); + boolean exists = file.exists(); + if (exists && !passConditionalHeaders(request, response, file)) + return; + + if (method.equals(__PUT)) + handlePut(request, response, pathInContext, file); + else if (method.equals(__DELETE)) + handleDelete(request, response, pathInContext, file); + else if (method.equals(__MOVE)) + handleMove(request, response, pathInContext, file); + else + throw new IllegalStateException(); + } + } + catch (Exception e) + { + _context.log(e.toString(), e); + response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); + } + } + else + { + if (isHidden(pathInContext)) + response.sendError(HttpServletResponse.SC_NOT_FOUND); + else + chain.doFilter(request, response); + return; + } + } + + private boolean isHidden(String pathInContext) + { + return _hidden.containsKey(pathInContext); + } + + @Override + public void destroy() + { + } + + public void handlePut(HttpServletRequest request, HttpServletResponse response, String pathInContext, File file) throws ServletException, IOException + { + boolean exists = file.exists(); + if (pathInContext.endsWith("/")) + { + if (!exists) + { + if (!file.mkdirs()) + response.sendError(HttpServletResponse.SC_FORBIDDEN); + else + { + response.setStatus(HttpServletResponse.SC_CREATED); + response.flushBuffer(); + } + } + else + { + response.setStatus(HttpServletResponse.SC_OK); + response.flushBuffer(); + } + } + else + { + boolean ok = false; + try + { + _hidden.put(pathInContext, pathInContext); + File parent = file.getParentFile(); + parent.mkdirs(); + int toRead = request.getContentLength(); + InputStream in = request.getInputStream(); + + if (_putAtomic) + { + File tmp = File.createTempFile(file.getName(), null, _tmpdir); + try (OutputStream out = new FileOutputStream(tmp, false)) + { + if (toRead >= 0) + IO.copy(in, out, toRead); + else + IO.copy(in, out); + } + + if (!tmp.renameTo(file)) + throw new IOException("rename from " + tmp + " to " + file + " failed"); + } + else + { + try (OutputStream out = new FileOutputStream(file, false)) + { + if (toRead >= 0) + IO.copy(in, out, toRead); + else + IO.copy(in, out); + } + } + + response.setStatus(exists ? HttpServletResponse.SC_OK : HttpServletResponse.SC_CREATED); + response.flushBuffer(); + ok = true; + } + catch (Exception ex) + { + _context.log(ex.toString(), ex); + response.sendError(HttpServletResponse.SC_FORBIDDEN); + } + finally + { + if (!ok) + { + try + { + if (file.exists()) + file.delete(); + } + catch (Exception e) + { + _context.log(e.toString(), e); + } + } + _hidden.remove(pathInContext); + } + } + } + + public void handleDelete(HttpServletRequest request, HttpServletResponse response, String pathInContext, File file) throws ServletException, IOException + { + try + { + // delete the file + if (file.delete()) + { + response.setStatus(HttpServletResponse.SC_NO_CONTENT); + response.flushBuffer(); + } + else + response.sendError(HttpServletResponse.SC_FORBIDDEN); + } + catch (SecurityException sex) + { + _context.log(sex.toString(), sex); + response.sendError(HttpServletResponse.SC_FORBIDDEN); + } + } + + public void handleMove(HttpServletRequest request, HttpServletResponse response, String pathInContext, File file) + throws ServletException, IOException, URISyntaxException + { + String newPath = URIUtil.canonicalEncodedPath(request.getHeader("new-uri")); + if (newPath == null) + { + response.sendError(HttpServletResponse.SC_BAD_REQUEST); + return; + } + + String contextPath = request.getContextPath(); + if (contextPath != null && !newPath.startsWith(contextPath)) + { + response.sendError(HttpServletResponse.SC_METHOD_NOT_ALLOWED); + return; + } + String newInfo = newPath; + if (contextPath != null) + newInfo = newInfo.substring(contextPath.length()); + + String newResource = URIUtil.addEncodedPaths(_baseURI, newInfo); + File newFile = new File(new URI(newResource)); + + file.renameTo(newFile); + + response.setStatus(HttpServletResponse.SC_NO_CONTENT); + response.flushBuffer(); + } + + public void handleOptions(FilterChain chain, HttpServletRequest request, HttpServletResponse response) throws IOException, ServletException + { + chain.doFilter(request, new HttpServletResponseWrapper(response) + { + @Override + public void setHeader(String name, String value) + { + if ("Allow".equalsIgnoreCase(name)) + { + Set options = new HashSet(); + options.addAll(Arrays.asList(StringUtil.csvSplit(value))); + options.addAll(_operations); + value = null; + for (String o : options) + { + value = value == null ? o : (value + ", " + o); + } + } + + super.setHeader(name, value); + } + }); + } + + /* + * Check modification date headers. + */ + protected boolean passConditionalHeaders(HttpServletRequest request, HttpServletResponse response, File file) throws IOException + { + long date = 0; + + if ((date = request.getDateHeader("if-unmodified-since")) > 0) + { + if (file.lastModified() / 1000 > date / 1000) + { + response.sendError(HttpServletResponse.SC_PRECONDITION_FAILED); + return false; + } + } + + if ((date = request.getDateHeader("if-modified-since")) > 0) + { + if (file.lastModified() / 1000 <= date / 1000) + { + response.reset(); + response.setStatus(HttpServletResponse.SC_NOT_MODIFIED); + response.flushBuffer(); + return false; + } + } + return true; + } +} diff --git a/test-proxy/src/main/java/servlets/QoSFilter.java b/test-proxy/src/main/java/servlets/QoSFilter.java new file mode 100644 index 0000000..79fad50 --- /dev/null +++ b/test-proxy/src/main/java/servlets/QoSFilter.java @@ -0,0 +1,380 @@ +// +// ======================================================================== +// Copyright (c) 1995-2020 Mort Bay Consulting Pty Ltd and others. +// +// This program and the accompanying materials are made available under +// the terms of the Eclipse Public License 2.0 which is available at +// https://www.eclipse.org/legal/epl-2.0 +// +// This Source Code may also be made available under the following +// Secondary Licenses when the conditions for such availability set +// forth in the Eclipse Public License, v. 2.0 are satisfied: +// the Apache License v2.0 which is available at +// https://www.apache.org/licenses/LICENSE-2.0 +// +// SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 +// ======================================================================== +// + +package servlets; + +import org.eclipse.jetty.server.handler.ContextHandler; +import org.eclipse.jetty.util.annotation.ManagedAttribute; +import org.eclipse.jetty.util.annotation.ManagedObject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.servlet.*; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import javax.servlet.http.HttpSession; +import java.io.IOException; +import java.util.Queue; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.Semaphore; +import java.util.concurrent.TimeUnit; + +/** + * Quality of Service Filter. + *

+ * This filter limits the number of active requests to the number set by the "maxRequests" init parameter (default 10). + * If more requests are received, they are suspended and placed on priority queues. Priorities are determined by + * the {@link #getPriority(ServletRequest)} method and are a value between 0 and the value given by the "maxPriority" + * init parameter (default 10), with higher values having higher priority. + *

+ * This filter is ideal to prevent wasting threads waiting for slow/limited + * resources such as a JDBC connection pool. It avoids the situation where all of a + * containers thread pool may be consumed blocking on such a slow resource. + * By limiting the number of active threads, a smaller thread pool may be used as + * the threads are not wasted waiting. Thus more memory may be available for use by + * the active threads. + *

+ * Furthermore, this filter uses a priority when resuming waiting requests. So that if + * a container is under load, and there are many requests waiting for resources, + * the {@link #getPriority(ServletRequest)} method is used, so that more important + * requests are serviced first. For example, this filter could be deployed with a + * maxRequest limit slightly smaller than the containers thread pool and a high priority + * allocated to admin users. Thus regardless of load, admin users would always be + * able to access the web application. + *

+ * The maxRequest limit is policed by a {@link Semaphore} and the filter will wait a short while attempting to acquire + * the semaphore. This wait is controlled by the "waitMs" init parameter and allows the expense of a suspend to be + * avoided if the semaphore is shortly available. If the semaphore cannot be obtained, the request will be suspended + * for the default suspend period of the container or the valued set as the "suspendMs" init parameter. + *

+ * If the "managedAttr" init parameter is set to true, then this servlet is set as a {@link ServletContext} attribute with the + * filter name as the attribute name. This allows context external mechanism (eg JMX via {@link ContextHandler#MANAGED_ATTRIBUTES}) to + * manage the configuration of the filter. + */ +@ManagedObject("Quality of Service Filter") +public class QoSFilter implements Filter +{ + private static final Logger LOG = LoggerFactory.getLogger(QoSFilter.class); + + static final int __DEFAULT_MAX_PRIORITY = 10; + static final int __DEFAULT_PASSES = 10; + static final int __DEFAULT_WAIT_MS = 50; + static final long __DEFAULT_TIMEOUT_MS = -1; + + static final String MANAGED_ATTR_INIT_PARAM = "managedAttr"; + static final String MAX_REQUESTS_INIT_PARAM = "maxRequests"; + static final String MAX_PRIORITY_INIT_PARAM = "maxPriority"; + static final String MAX_WAIT_INIT_PARAM = "waitMs"; + static final String SUSPEND_INIT_PARAM = "suspendMs"; + + private final String _suspended = "QoSFilter@" + Integer.toHexString(hashCode()) + ".SUSPENDED"; + private final String _resumed = "QoSFilter@" + Integer.toHexString(hashCode()) + ".RESUMED"; + private long _waitMs; + private long _suspendMs; + private int _maxRequests; + private Semaphore _passes; + private Queue[] _queues; + private AsyncListener[] _listeners; + + @Override + public void init(FilterConfig filterConfig) + { + int maxPriority = __DEFAULT_MAX_PRIORITY; + if (filterConfig.getInitParameter(MAX_PRIORITY_INIT_PARAM) != null) + maxPriority = Integer.parseInt(filterConfig.getInitParameter(MAX_PRIORITY_INIT_PARAM)); + _queues = new Queue[maxPriority + 1]; + _listeners = new AsyncListener[_queues.length]; + for (int p = 0; p < _queues.length; ++p) + { + _queues[p] = new ConcurrentLinkedQueue<>(); + _listeners[p] = new QoSAsyncListener(p); + } + + int maxRequests = __DEFAULT_PASSES; + if (filterConfig.getInitParameter(MAX_REQUESTS_INIT_PARAM) != null) + maxRequests = Integer.parseInt(filterConfig.getInitParameter(MAX_REQUESTS_INIT_PARAM)); + _passes = new Semaphore(maxRequests, true); + _maxRequests = maxRequests; + + long wait = __DEFAULT_WAIT_MS; + if (filterConfig.getInitParameter(MAX_WAIT_INIT_PARAM) != null) + wait = Integer.parseInt(filterConfig.getInitParameter(MAX_WAIT_INIT_PARAM)); + _waitMs = wait; + + long suspend = __DEFAULT_TIMEOUT_MS; + if (filterConfig.getInitParameter(SUSPEND_INIT_PARAM) != null) + suspend = Integer.parseInt(filterConfig.getInitParameter(SUSPEND_INIT_PARAM)); + _suspendMs = suspend; + + ServletContext context = filterConfig.getServletContext(); + if (context != null && Boolean.parseBoolean(filterConfig.getInitParameter(MANAGED_ATTR_INIT_PARAM))) + context.setAttribute(filterConfig.getFilterName(), this); + } + + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException + { + boolean accepted = false; + try + { + Boolean suspended = (Boolean)request.getAttribute(_suspended); + if (suspended == null) + { + accepted = _passes.tryAcquire(getWaitMs(), TimeUnit.MILLISECONDS); + if (accepted) + { + request.setAttribute(_suspended, Boolean.FALSE); + if (LOG.isDebugEnabled()) + LOG.debug("Accepted {}", request); + } + else + { + request.setAttribute(_suspended, Boolean.TRUE); + int priority = getPriority(request); + AsyncContext asyncContext = request.startAsync(); + long suspendMs = getSuspendMs(); + if (suspendMs > 0) + asyncContext.setTimeout(suspendMs); + asyncContext.addListener(_listeners[priority]); + _queues[priority].add(asyncContext); + if (LOG.isDebugEnabled()) + LOG.debug("Suspended {}", request); + return; + } + } + else + { + if (suspended) + { + request.setAttribute(_suspended, Boolean.FALSE); + Boolean resumed = (Boolean)request.getAttribute(_resumed); + if (Boolean.TRUE.equals(resumed)) + { + _passes.acquire(); + accepted = true; + if (LOG.isDebugEnabled()) + LOG.debug("Resumed {}", request); + } + else + { + // Timeout! try 1 more time. + accepted = _passes.tryAcquire(getWaitMs(), TimeUnit.MILLISECONDS); + if (LOG.isDebugEnabled()) + LOG.debug("Timeout {}", request); + } + } + else + { + // Pass through resume of previously accepted request. + _passes.acquire(); + accepted = true; + if (LOG.isDebugEnabled()) + LOG.debug("Passthrough {}", request); + } + } + + if (accepted) + { + chain.doFilter(request, response); + } + else + { + if (LOG.isDebugEnabled()) + LOG.debug("Rejected {}", request); + ((HttpServletResponse)response).sendError(HttpServletResponse.SC_SERVICE_UNAVAILABLE); + } + } + catch (InterruptedException e) + { + ((HttpServletResponse)response).sendError(HttpServletResponse.SC_SERVICE_UNAVAILABLE); + } + finally + { + if (accepted) + { + _passes.release(); + + for (int p = _queues.length - 1; p >= 0; --p) + { + AsyncContext asyncContext = _queues[p].poll(); + if (asyncContext != null) + { + ServletRequest candidate = asyncContext.getRequest(); + Boolean suspended = (Boolean)candidate.getAttribute(_suspended); + if (Boolean.TRUE.equals(suspended)) + { + try + { + candidate.setAttribute(_resumed, Boolean.TRUE); + asyncContext.dispatch(); + break; + } + catch (IllegalStateException x) + { + LOG.warn("Unable to resume suspended dispatch", x); + continue; + } + } + } + } + } + } + } + + /** + * Computes the request priority. + *

+ * The default implementation assigns the following priorities: + *

    + *
  • 2 - for an authenticated request + *
  • 1 - for a request with valid / non new session + *
  • 0 - for all other requests. + *
+ * This method may be overridden to provide application specific priorities. + * + * @param request the incoming request + * @return the computed request priority + */ + protected int getPriority(ServletRequest request) + { + HttpServletRequest baseRequest = (HttpServletRequest)request; + if (baseRequest.getUserPrincipal() != null) + { + return 2; + } + else + { + HttpSession session = baseRequest.getSession(false); + if (session != null && !session.isNew()) + return 1; + else + return 0; + } + } + + @Override + public void destroy() + { + } + + /** + * Get the (short) amount of time (in milliseconds) that the filter would wait + * for the semaphore to become available before suspending a request. + * + * @return wait time (in milliseconds) + */ + @ManagedAttribute("(short) amount of time filter will wait before suspending request (in ms)") + public long getWaitMs() + { + return _waitMs; + } + + /** + * Set the (short) amount of time (in milliseconds) that the filter would wait + * for the semaphore to become available before suspending a request. + * + * @param value wait time (in milliseconds) + */ + public void setWaitMs(long value) + { + _waitMs = value; + } + + /** + * Get the amount of time (in milliseconds) that the filter would suspend + * a request for while waiting for the semaphore to become available. + * + * @return suspend time (in milliseconds) + */ + @ManagedAttribute("amount of time filter will suspend a request for while waiting for the semaphore to become available (in ms)") + public long getSuspendMs() + { + return _suspendMs; + } + + /** + * Set the amount of time (in milliseconds) that the filter would suspend + * a request for while waiting for the semaphore to become available. + * + * @param value suspend time (in milliseconds) + */ + public void setSuspendMs(long value) + { + _suspendMs = value; + } + + /** + * Get the maximum number of requests allowed to be processed + * at the same time. + * + * @return maximum number of requests + */ + @ManagedAttribute("maximum number of requests to allow processing of at the same time") + public int getMaxRequests() + { + return _maxRequests; + } + + /** + * Set the maximum number of requests allowed to be processed + * at the same time. + * + * @param value the number of requests + */ + public void setMaxRequests(int value) + { + _passes = new Semaphore((value - getMaxRequests() + _passes.availablePermits()), true); + _maxRequests = value; + } + + private class QoSAsyncListener implements AsyncListener + { + private final int priority; + + public QoSAsyncListener(int priority) + { + this.priority = priority; + } + + @Override + public void onStartAsync(AsyncEvent event) throws IOException + { + } + + @Override + public void onComplete(AsyncEvent event) throws IOException + { + } + + @Override + public void onTimeout(AsyncEvent event) throws IOException + { + // Remove before it's redispatched, so it won't be + // redispatched again at the end of the filtering. + AsyncContext asyncContext = event.getAsyncContext(); + _queues[priority].remove(asyncContext); + ((HttpServletResponse)event.getSuppliedResponse()).sendError(HttpServletResponse.SC_SERVICE_UNAVAILABLE); + asyncContext.complete(); + } + + @Override + public void onError(AsyncEvent event) throws IOException + { + } + } +} diff --git a/test-proxy/src/main/java/servlets/WelcomeFilter.java b/test-proxy/src/main/java/servlets/WelcomeFilter.java new file mode 100644 index 0000000..03f325b --- /dev/null +++ b/test-proxy/src/main/java/servlets/WelcomeFilter.java @@ -0,0 +1,69 @@ +// +// ======================================================================== +// Copyright (c) 1995-2020 Mort Bay Consulting Pty Ltd and others. +// +// This program and the accompanying materials are made available under +// the terms of the Eclipse Public License 2.0 which is available at +// https://www.eclipse.org/legal/epl-2.0 +// +// This Source Code may also be made available under the following +// Secondary Licenses when the conditions for such availability set +// forth in the Eclipse Public License, v. 2.0 are satisfied: +// the Apache License v2.0 which is available at +// https://www.apache.org/licenses/LICENSE-2.0 +// +// SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 +// ======================================================================== +// + +package servlets; + +import javax.servlet.*; +import javax.servlet.http.HttpServletRequest; +import java.io.IOException; + +/** + * Welcome Filter + * This filter can be used to server an index file for a directory + * when no index file actually exists (thus the web.xml mechanism does + * not work). + * + * This filter will dispatch requests to a directory (URLs ending with /) + * to the welcome URL determined by the "welcome" init parameter. So if + * the filter "welcome" init parameter is set to "index.do" then a request + * to "/some/directory/" will be dispatched to "/some/directory/index.do" and + * will be handled by any servlets mapped to that URL. + * + * Requests to "/some/directory" will be redirected to "/some/directory/". + */ +public class WelcomeFilter implements Filter +{ + private String welcome; + + @Override + public void init(FilterConfig filterConfig) + { + welcome = filterConfig.getInitParameter("welcome"); + if (welcome == null) + welcome = "index.html"; + } + + @Override + public void doFilter(ServletRequest request, + ServletResponse response, + FilterChain chain) + throws IOException, ServletException + { + String path = ((HttpServletRequest)request).getServletPath(); + if (welcome != null && path.endsWith("/")) + request.getRequestDispatcher(path + welcome).forward(request, response); + else + chain.doFilter(request, response); + } + + @Override + public void destroy() + { + } +} + diff --git a/test-proxy/src/main/java/servlets/package-info.java b/test-proxy/src/main/java/servlets/package-info.java new file mode 100644 index 0000000..e9151f2 --- /dev/null +++ b/test-proxy/src/main/java/servlets/package-info.java @@ -0,0 +1,23 @@ +// +// ======================================================================== +// Copyright (c) 1995-2020 Mort Bay Consulting Pty Ltd and others. +// +// This program and the accompanying materials are made available under +// the terms of the Eclipse Public License 2.0 which is available at +// https://www.eclipse.org/legal/epl-2.0 +// +// This Source Code may also be made available under the following +// Secondary Licenses when the conditions for such availability set +// forth in the Eclipse Public License, v. 2.0 are satisfied: +// the Apache License v2.0 which is available at +// https://www.apache.org/licenses/LICENSE-2.0 +// +// SPDX-License-Identifier: EPL-2.0 OR Apache-2.0 +// ======================================================================== +// + +/** + * Jetty Servlets : Generally Useful Servlets, Handlers and Filters + */ +package servlets; + diff --git a/test-proxy/src/main/webapp/WEB-INF/web.xml b/test-proxy/src/main/webapp/WEB-INF/web.xml new file mode 100644 index 0000000..9f88c1f --- /dev/null +++ b/test-proxy/src/main/webapp/WEB-INF/web.xml @@ -0,0 +1,7 @@ + + + + Archetype Created Web Application + diff --git a/test-proxy/src/main/webapp/index.jsp b/test-proxy/src/main/webapp/index.jsp new file mode 100644 index 0000000..c38169b --- /dev/null +++ b/test-proxy/src/main/webapp/index.jsp @@ -0,0 +1,5 @@ + + +

Hello World!

+ +