blob: e42ab960f66e37f5b29ee6087e6dc7f2c1f121c3 [file] [log] [blame]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.knox.gateway.ha.dispatch;
import org.apache.commons.codec.digest.DigestUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.http.HttpResponse;
import org.apache.http.client.methods.HttpRequestBase;
import org.apache.http.client.methods.HttpUriRequest;
import org.apache.http.client.utils.URIBuilder;
import org.apache.knox.gateway.config.Configure;
import org.apache.knox.gateway.config.GatewayConfig;
import org.apache.knox.gateway.dispatch.ConfigurableDispatch;
import org.apache.knox.gateway.filter.AbstractGatewayFilter;
import org.apache.knox.gateway.ha.dispatch.i18n.HaDispatchMessages;
import org.apache.knox.gateway.ha.provider.HaProvider;
import org.apache.knox.gateway.ha.provider.HaServiceConfig;
import org.apache.knox.gateway.ha.provider.impl.HaServiceConfigConstants;
import org.apache.knox.gateway.i18n.messages.MessagesFactory;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletRequestWrapper;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import java.util.stream.Collectors;
/**
* A configurable HA dispatch class that has a very basic failover mechanism and
* configurable options of ConfigurableDispatch class.
*/
public class ConfigurableHADispatch extends ConfigurableDispatch {
protected static final String FAILOVER_COUNTER_ATTRIBUTE = "dispatch.ha.failover.counter";
protected static final HaDispatchMessages LOG = MessagesFactory.get(HaDispatchMessages.class);
protected int maxFailoverAttempts = HaServiceConfigConstants.DEFAULT_MAX_FAILOVER_ATTEMPTS;
protected int failoverSleep = HaServiceConfigConstants.DEFAULT_FAILOVER_SLEEP;
protected HaProvider haProvider;
private static final Map<String, String> urlToHashLookup = new HashMap<>();
private static final Map<String, String> hashToUrlLookup = new HashMap<>();
protected static final List<String> nonIdempotentRequests = Arrays.asList("POST", "PATCH", "CONNECT");
private boolean loadBalancingEnabled = HaServiceConfigConstants.DEFAULT_LOAD_BALANCING_ENABLED;
private boolean stickySessionsEnabled = HaServiceConfigConstants.DEFAULT_STICKY_SESSIONS_ENABLED;
private boolean noFallbackEnabled = HaServiceConfigConstants.DEFAULT_NO_FALLBACK_ENABLED;
protected boolean failoverNonIdempotentRequestEnabled = HaServiceConfigConstants.DEFAULT_FAILOVER_NON_IDEMPOTENT;
private String stickySessionCookieName = HaServiceConfigConstants.DEFAULT_STICKY_SESSION_COOKIE_NAME;
private List<String> disableLoadBalancingForUserAgents = Arrays.asList(HaServiceConfigConstants.DEFAULT_DISABLE_LB_USER_AGENTS);
/**
* This activeURL is used to track urls when LB is turned off for some clients
* The problem we have with selectively turning off LB is that other clients
* that use LB can change the state from under the current session where LB is
* turned off.
* e.g.
* ODBC Connection established where LB is off. JDBC connection is established
* next where LB is enabled. This changes the active URL under the existing ODBC
* connection which will be an issue.
* This variable keeps track of non-LB'ed url and updated upon failover.
*/
private AtomicReference<String> activeURL = new AtomicReference();
@Override
public void init() {
super.init();
LOG.initializingForResourceRole(getServiceRole());
if ( haProvider != null ) {
HaServiceConfig serviceConfig = haProvider.getHaDescriptor().getServiceConfig(getServiceRole());
maxFailoverAttempts = serviceConfig.getMaxFailoverAttempts();
failoverSleep = serviceConfig.getFailoverSleep();
loadBalancingEnabled = serviceConfig.isLoadBalancingEnabled();
failoverNonIdempotentRequestEnabled = serviceConfig.isFailoverNonIdempotentRequestEnabled();
/* enforce dependency */
stickySessionsEnabled = loadBalancingEnabled && serviceConfig.isStickySessionEnabled();
noFallbackEnabled = stickySessionsEnabled && serviceConfig.isNoFallbackEnabled();
if(stickySessionsEnabled) {
stickySessionCookieName = serviceConfig.getStickySessionCookieName();
}
if(StringUtils.isNotBlank(serviceConfig.getStickySessionDisabledUserAgents())) {
disableLoadBalancingForUserAgents = Arrays.asList(serviceConfig.getStickySessionDisabledUserAgents()
.trim()
.split("\\s*,\\s*"));
}
setupUrlHashLookup();
}
/* setup the active URL for non-LB case */
activeURL.set(haProvider.getActiveURL(getServiceRole()));
// Suffix the cookie name by the service to make it unique
// The cookie path is NOT unique since Knox is stripping the service name.
stickySessionCookieName = stickySessionCookieName + '-' + getServiceRole();
}
private void setupUrlHashLookup() {
for (String url : haProvider.getURLs(getServiceRole())) {
String urlHash = hash(url);
urlToHashLookup.put(url, urlHash);
hashToUrlLookup.put(urlHash, url);
}
}
public HaProvider getHaProvider() {
return haProvider;
}
@Configure
public void setHaProvider(HaProvider haProvider) {
this.haProvider = haProvider;
}
@Override
protected void executeRequestWrapper(HttpUriRequest outboundRequest,
HttpServletRequest inboundRequest, HttpServletResponse outboundResponse)
throws IOException {
final String userAgentFromBrowser = StringUtils.isBlank(inboundRequest.getHeader("User-Agent")) ? "" : inboundRequest.getHeader("User-Agent");
/* disable loadblancing override */
boolean userAgentDisabled = false;
/* disable loadbalancing in case a configured user agent is detected to disable LB */
if(disableLoadBalancingForUserAgents.stream().anyMatch(c -> userAgentFromBrowser.contains(c)) ) {
userAgentDisabled = true;
LOG.disableHALoadbalancinguserAgent(userAgentFromBrowser, disableLoadBalancingForUserAgents.toString());
}
/* if disable LB is set don't bother setting backend from cookie */
Optional<URI> backendURI = Optional.empty();
if(!userAgentDisabled) {
backendURI = setBackendfromHaCookie(outboundRequest, inboundRequest);
if(backendURI.isPresent()) {
((HttpRequestBase) outboundRequest).setURI(backendURI.get());
}
}
/**
* case where loadbalancing is enabled
* and we have a HTTP request configured not to use LB
* use the activeURL
*/
if(loadBalancingEnabled && userAgentDisabled) {
try {
((HttpRequestBase) outboundRequest).setURI(updateHostURL(outboundRequest.getURI(), activeURL.get()));
} catch (final URISyntaxException e) {
LOG.errorSettingActiveUrl();
}
}
executeRequest(outboundRequest, inboundRequest, outboundResponse);
/**
* 1. Load balance when loadbalancing is enabled and there are no overrides (disableLB)
* 2. Loadbalance only when sticky session is enabled but cookie not detected
* i.e. when loadbalancing is enabled every request that does not have BACKEND cookie
* needs to be loadbalanced. If a request has BACKEND coookie and Loadbalance=on then
* there should be no loadbalancing.
*/
if (loadBalancingEnabled && !userAgentDisabled) {
/* check sticky session enabled */
if(stickySessionsEnabled) {
/* loadbalance only when sticky session enabled and no backend url cookie */
if(!backendURI.isPresent()) {
haProvider.makeNextActiveURLAvailable(getServiceRole());
} else{
/* sticky session enabled and backend url cookie is valid no need to loadbalance */
/* do nothing */
}
} else {
haProvider.makeNextActiveURLAvailable(getServiceRole());
}
}
}
@Override
protected void outboundResponseWrapper(final HttpUriRequest outboundRequest, final HttpServletRequest inboundRequest, final HttpServletResponse outboundResponse) {
setKnoxHaCookie(outboundRequest, inboundRequest, outboundResponse);
}
@Override
protected void executeRequest(HttpUriRequest outboundRequest, HttpServletRequest inboundRequest, HttpServletResponse outboundResponse) throws
IOException {
HttpResponse inboundResponse = null;
try {
inboundResponse = executeOutboundRequest(outboundRequest);
writeOutboundResponse(outboundRequest, inboundRequest, outboundResponse, inboundResponse);
} catch ( IOException e ) {
/* if non-idempotent requests are not allowed to failover */
if(!failoverNonIdempotentRequestEnabled && nonIdempotentRequests.stream().anyMatch(outboundRequest.getMethod()::equalsIgnoreCase)) {
LOG.cannotFailoverNonIdempotentRequest(outboundRequest.getMethod(), e.toString());
throw e;
} else {
LOG.errorConnectingToServer(outboundRequest.getURI().toString(), e);
failoverRequest(outboundRequest, inboundRequest, outboundResponse, inboundResponse, e);
}
}
}
private Optional<URI> setBackendfromHaCookie(HttpUriRequest outboundRequest, HttpServletRequest inboundRequest) {
if (loadBalancingEnabled && stickySessionsEnabled && inboundRequest.getCookies() != null) {
for (Cookie cookie : inboundRequest.getCookies()) {
if (stickySessionCookieName.equals(cookie.getName())) {
String backendURLHash = cookie.getValue();
String backendURL = hashToUrlLookup.get(backendURLHash);
// Make sure that the url provided is actually a valid backend url
if (haProvider.getURLs(getServiceRole()).contains(backendURL)) {
try {
return Optional.of(updateHostURL(outboundRequest.getURI(), backendURL));
} catch (URISyntaxException ignore) {
// The cookie was invalid so we just don't set it. Knox will pick a backend automatically
}
}
}
}
}
return Optional.empty();
}
private void setKnoxHaCookie(final HttpUriRequest outboundRequest, final HttpServletRequest inboundRequest,
final HttpServletResponse outboundResponse) {
if (stickySessionsEnabled) {
List<Cookie> serviceHaCookies = Collections.emptyList();
if(inboundRequest.getCookies() != null) {
serviceHaCookies = Arrays
.stream(inboundRequest.getCookies())
.filter(cookie -> stickySessionCookieName.equals(cookie.getName()))
.collect(Collectors.toList());
}
/* if the inbound request has a valid hash then no need to set a different hash */
if (serviceHaCookies != null && !serviceHaCookies.isEmpty()
&& hashToUrlLookup.containsKey(serviceHaCookies.get(0).getValue())) {
return;
} else {
/**
* Due to concurrency issues haProvider.getActiveURL() will not return the accurate list
* This will cause issues where original request goes to host-1 and cookie is set for host-2 - because
* haProvider.getActiveURL() returned host-2. To prevent this from happening we need to make sure
* we set cookie for the endpoint that was served and not rely on haProvider.getActiveURL().
* let LBing logic take care of rotating urls.
**/
final List<String> urls = haProvider.getURLs(getServiceRole())
.stream()
.filter(u -> u.contains(outboundRequest.getURI().getHost()))
.collect(Collectors.toList());
final String cookieValue = urlToHashLookup.get(urls.get(0));
Cookie stickySessionCookie = new Cookie(stickySessionCookieName, cookieValue);
stickySessionCookie.setPath(inboundRequest.getContextPath());
stickySessionCookie.setMaxAge(-1);
stickySessionCookie.setHttpOnly(true);
GatewayConfig config = (GatewayConfig) inboundRequest
.getServletContext()
.getAttribute(GatewayConfig.GATEWAY_CONFIG_ATTRIBUTE);
if (config != null) {
stickySessionCookie.setSecure(config.isSSLEnabled());
}
outboundResponse.addCookie(stickySessionCookie);
}
}
}
protected void failoverRequest(HttpUriRequest outboundRequest, HttpServletRequest inboundRequest, HttpServletResponse outboundResponse, HttpResponse inboundResponse, Exception exception) throws IOException {
// Check whether the session cookie is present
Optional<Cookie> sessionCookie = Optional.empty();
if (inboundRequest.getCookies() != null) {
sessionCookie =
Arrays.stream(inboundRequest.getCookies())
.filter(cookie -> stickySessionCookieName.equals(cookie.getName()))
.findFirst();
}
// Check for a case where no fallback is configured
if(stickySessionsEnabled && noFallbackEnabled && sessionCookie.isPresent()) {
LOG.noFallbackError();
outboundResponse.sendError(HttpServletResponse.SC_BAD_GATEWAY, "Service connection error, HA failover disabled");
return;
}
haProvider.markFailedURL(getServiceRole(), outboundRequest.getURI().toString());
AtomicInteger counter = (AtomicInteger) inboundRequest.getAttribute(FAILOVER_COUNTER_ATTRIBUTE);
if ( counter == null ) {
counter = new AtomicInteger(0);
}
inboundRequest.setAttribute(FAILOVER_COUNTER_ATTRIBUTE, counter);
if ( counter.incrementAndGet() <= maxFailoverAttempts ) {
setupUrlHashLookup(); // refresh the url hash after failing a url
//null out target url so that rewriters run again
inboundRequest.setAttribute(AbstractGatewayFilter.TARGET_REQUEST_URL_ATTRIBUTE_NAME, null);
// Make sure to remove the cookie ha cookie from the request
inboundRequest = new StickySessionCookieRemovedRequest(stickySessionCookieName, inboundRequest);
URI uri = getDispatchUrl(inboundRequest);
((HttpRequestBase) outboundRequest).setURI(uri);
if ( failoverSleep > 0 ) {
try {
Thread.sleep(failoverSleep);
} catch ( InterruptedException e ) {
LOG.failoverSleepFailed(getServiceRole(), e);
Thread.currentThread().interrupt();
}
}
LOG.failingOverRequest(outboundRequest.getURI().toString());
/* in case of failover update the activeURL variable */
activeURL.set(outboundRequest.getURI().toString());
executeRequest(outboundRequest, inboundRequest, outboundResponse);
} else {
LOG.maxFailoverAttemptsReached(maxFailoverAttempts, getServiceRole());
if ( inboundResponse != null ) {
writeOutboundResponse(outboundRequest, inboundRequest, outboundResponse, inboundResponse);
} else {
throw new IOException(exception);
}
}
}
private String hash(String url) {
return DigestUtils.sha256Hex(url);
}
/**
* Strips out the cookies by the cookie name provided
*/
private static class StickySessionCookieRemovedRequest extends HttpServletRequestWrapper {
private final Cookie[] cookies;
StickySessionCookieRemovedRequest(String cookieName, HttpServletRequest request) {
super(request);
this.cookies = filterCookies(cookieName, request.getCookies());
}
private Cookie[] filterCookies(String cookieName, Cookie[] cookies) {
if (super.getCookies() == null) {
return null;
}
List<Cookie> cookiesInternal = new ArrayList<>();
for (Cookie cookie : cookies) {
if (!cookieName.equals(cookie.getName())) {
cookiesInternal.add(cookie);
}
}
return cookiesInternal.toArray(new Cookie[0]);
}
@Override
public Cookie[] getCookies() {
return cookies;
}
}
/**
* A helper function that updates the schema, host and port
* of the URI with the provided string URL and returnes a new
* URI object
* @param source
* @param host
* @return
*/
private URI updateHostURL(final URI source, final String host) throws URISyntaxException {
final URI newUri = new URI(host);
final URIBuilder uriBuilder = new URIBuilder(source);
uriBuilder.setScheme(newUri.getScheme());
uriBuilder.setHost(newUri.getHost());
uriBuilder.setPort(newUri.getPort());
return uriBuilder.build();
}
}