ProxyMappings.java

/*
 * Copyright 2017 Red Hat, Inc. and/or its affiliates
 * and other contributors as indicated by the @author tags.
 *
 * Licensed 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.keycloak.connections.httpclient;

import org.apache.http.HttpHost;
import org.apache.http.auth.UsernamePasswordCredentials;
import org.jboss.logging.Logger;

import java.net.URI;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;
import java.util.regex.Pattern;
import java.util.stream.Collectors;

import static org.keycloak.utils.StringUtil.isBlank;

/**
 * {@link ProxyMappings} describes an ordered mapping for hostname regex patterns to a {@link HttpHost} proxy.
 * <p>
 * Mappings can be created via {@link #valueOf(String...)} or {@link #valueOf(List)}.
 * For a description of the mapping format see {@link ProxyMapping#valueOf(String)}
 *
 * @author <a href="mailto:thomas.darimont@gmail.com">Thomas Darimont</a>
 */
public class ProxyMappings {

  private static final Logger logger = Logger.getLogger(ProxyMappings.class);

  private static final ProxyMappings EMPTY_MAPPING = valueOf(Collections.emptyList());

  private static final String NO_PROXY_DELIMITER = ",";

  private final List<ProxyMapping> entries;

  private static final Map<String, ProxyMapping> hostnameToProxyCache = new ConcurrentHashMap<>();

  /**
   * Creates a {@link ProxyMappings} from the provided {@link ProxyMapping Entries}.
   *
   * @param entries
   */
  public ProxyMappings(List<ProxyMapping> entries) {
    this.entries = Collections.unmodifiableList(entries);
  }

  /**
   * Creates a new  {@link ProxyMappings} from the provided {@code List} of proxy mapping strings.
   * <p>
   *
   * @param proxyMappings
   */
  public static ProxyMappings valueOf(List<String> proxyMappings) {

    if (proxyMappings == null || proxyMappings.isEmpty()) {
      return EMPTY_MAPPING;
    }

    List<ProxyMapping> entries = proxyMappings.stream() //
      .map(ProxyMapping::valueOf) //
      .collect(Collectors.toList());

    return new ProxyMappings(entries);
  }

  /**
   * Creates a new  {@link ProxyMappings} from the provided {@code String[]} of proxy mapping strings.
   *
   * @param proxyMappings
   * @return
   * @see #valueOf(List)
   * @see ProxyMapping#valueOf(String...)
   */
  public static ProxyMappings valueOf(String... proxyMappings) {

    if (proxyMappings == null || proxyMappings.length == 0) {
      return EMPTY_MAPPING;
    }

    return valueOf(Arrays.asList(proxyMappings));
  }

  /**
   * Creates a new {@link ProxyMappings} from provided parameters representing the established {@code HTTP(S)_PROXY}
   * and {@code NO_PROXY} environment variables.
   *
   * @param httpProxy a proxy used for all hosts except the ones specified in {@code noProxy}
   * @param noProxy a list of hosts (separated by comma) that should not use proxy;
   *                all suffixes are matched too (e.g. redhat.com will also match access.redhat.com)
   * @return
   * @see <a href="https://about.gitlab.com/blog/2021/01/27/we-need-to-talk-no-proxy/">https://about.gitlab.com/blog/2021/01/27/we-need-to-talk-no-proxy/</a>
   */
  public static ProxyMappings withFixedProxyMapping(String httpProxy, String noProxy) {
    List<ProxyMapping> proxyMappings = new ArrayList<>();

    if (!isBlank(httpProxy)) {
      // noProxy must be first as it's more specific than .*
      if (!isBlank(noProxy)) {
        for (String host : noProxy.split(NO_PROXY_DELIMITER)) {
          // do not support regex in no_proxy
          proxyMappings.add(new ProxyMapping(Pattern.compile("(?:.+\\.)?" + Pattern.quote(host)), null, null));
        }
      }

      proxyMappings.add(ProxyMapping.valueOf(".*" + ProxyMapping.DELIMITER + httpProxy));
    }

    return proxyMappings.isEmpty() ? EMPTY_MAPPING : new ProxyMappings(proxyMappings);
  }


  public boolean isEmpty() {
    return this.entries.isEmpty();
  }

  /**
   * @param hostname
   * @return the {@link ProxyMapping} associated with the first matching hostname {@link Pattern}
   * or the {@link ProxyMapping} including {@literal null} as {@link HttpHost} if none matches.
   */
  public ProxyMapping getProxyFor(String hostname) {

    Objects.requireNonNull(hostname, "hostname");
    if (hostnameToProxyCache.containsKey(hostname)) {
      return hostnameToProxyCache.get(hostname);
    }
    ProxyMapping proxyMapping = entries.stream() //
            .filter(e -> e.matches(hostname)) //
            .findFirst() //
            .orElse(null);
    if (proxyMapping == null) {
      proxyMapping = new ProxyMapping(null, null, null);
    }
    hostnameToProxyCache.put(hostname, proxyMapping);
    return proxyMapping;
  }

  public static void clearCache() {
    hostnameToProxyCache.clear();
  }

  /**
   * {@link ProxyMapping} describes a Proxy Mapping with a Hostname {@link Pattern}
   * that is mapped to a proxy {@link HttpHost}.
   */
  public static class ProxyMapping {

    public static final String NO_PROXY = "NO_PROXY";
    private static final String DELIMITER = ";";

    private final Pattern hostnamePattern;

    private final HttpHost proxyHost;

    private final UsernamePasswordCredentials proxyCredentials;

    public ProxyMapping(Pattern hostnamePattern, HttpHost proxyHost, UsernamePasswordCredentials proxyCredentials) {
      this.hostnamePattern = hostnamePattern;
      this.proxyHost = proxyHost;
      this.proxyCredentials = proxyCredentials;
    }

    public Pattern getHostnamePattern() {
      return hostnamePattern;
    }

    public HttpHost getProxyHost() {
      return proxyHost;
    }

    public UsernamePasswordCredentials getProxyCredentials() {
      return proxyCredentials;
    }

    public boolean matches(String hostname) {
      return getHostnamePattern().matcher(hostname).matches();
    }

    /**
     * Parses a mapping string into an {@link ProxyMapping}.
     * <p>
     * A proxy mapping string must have the following format: {@code hostnameRegex;www-proxy-uri}
     * with semicolon as a delimiter.</p>
     * <p>
     * If no proxy should be used for a host pattern then use {@code NO_PROXY} as www-proxy-uri.
     * </p>
     * <p>Examples:
     * <pre>
     * {@code
     *
     * .*\.(google\.com|googleapis\.com);http://www-proxy.acme.corp.com:8080
     * .*\.acme\.corp\.com;NO_PROXY
     * .*;http://fallback:8080
     * }
     * </pre>
     * </p>
     *
     * @param mapping
     * @return
     */
    public static ProxyMapping valueOf(String mapping) {

      String[] mappingTokens = mapping.split(DELIMITER);

      String hostPatternRegex = mappingTokens[0];
      String proxyUriString = mappingTokens[1];

      Pattern hostPattern = Pattern.compile(hostPatternRegex);
      if (NO_PROXY.equals(proxyUriString)) {
        return new ProxyMapping(hostPattern, null, null);
      }

      URI uri = URI.create(proxyUriString);
      String userInfo = uri.getUserInfo();
      UsernamePasswordCredentials proxyCredentials = null;
      if (userInfo != null) {
        if (userInfo.indexOf(":") > 0) {
          String[] credencials = userInfo.split(":", 2);
          if (credencials != null && credencials.length == 2) {
            proxyCredentials = new UsernamePasswordCredentials(credencials[0], credencials[1]);
          }
        } else {
          logger.warn("Invalid proxy credentials: " + userInfo);
        }
      }
      return new ProxyMapping(hostPattern, new HttpHost(uri.getHost(), uri.getPort(), uri.getScheme()), proxyCredentials);
    }

    @Override
    public String toString() {
      return "ProxyMapping{" +
        "hostnamePattern=" + hostnamePattern +
        ", proxyHost=" + proxyHost +
        '}';
    }
  }
}