[] _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!
+
+