/*
 * 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.hadoop.hdds.server.http;

import static org.apache.hadoop.hdds.HddsUtils.createDir;
import static org.apache.hadoop.hdds.HddsUtils.getHostNameFromConfigKeys;
import static org.apache.hadoop.hdds.HddsUtils.getPortNumberFromConfigKeys;
import static org.apache.hadoop.hdds.server.ServerUtils.getOzoneMetaDirPath;
import static org.apache.hadoop.hdds.server.http.HttpConfig.getHttpPolicy;
import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_ADMINISTRATORS;
import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_ADMINISTRATORS_GROUPS;
import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_CLIENT_HTTPS_NEED_AUTH_DEFAULT;
import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_CLIENT_HTTPS_NEED_AUTH_KEY;
import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_HTTP_SECURITY_ENABLED_DEFAULT;
import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_HTTP_SECURITY_ENABLED_KEY;
import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_SECURITY_ENABLED_DEFAULT;
import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_SECURITY_ENABLED_KEY;
import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_SERVER_HTTPS_KEYPASSWORD_KEY;
import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_SERVER_HTTPS_KEYSTORE_PASSWORD_KEY;
import static org.apache.hadoop.ozone.OzoneConfigKeys.OZONE_SERVER_HTTPS_TRUSTSTORE_PASSWORD_KEY;

import com.google.common.annotations.VisibleForTesting;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.URI;
import java.util.Map;
import java.util.Optional;
import java.util.OptionalInt;
import javax.servlet.http.HttpServlet;
import org.apache.commons.lang3.StringUtils;
import org.apache.hadoop.conf.Configuration;
import org.apache.hadoop.hdds.HddsConfigKeys;
import org.apache.hadoop.hdds.conf.ConfigurationSource;
import org.apache.hadoop.hdds.conf.HddsConfServlet;
import org.apache.hadoop.hdds.conf.HddsPrometheusConfig;
import org.apache.hadoop.hdds.conf.MutableConfigurationSource;
import org.apache.hadoop.hdds.utils.LegacyHadoopConfigurationSource;
import org.apache.hadoop.metrics2.lib.DefaultMetricsSystem;
import org.apache.hadoop.net.NetUtils;
import org.apache.hadoop.ozone.OzoneConfigKeys;
import org.apache.hadoop.ozone.OzoneSecurityUtil;
import org.apache.hadoop.security.UserGroupInformation;
import org.apache.hadoop.security.authorize.AccessControlList;
import org.apache.hadoop.security.ssl.SSLFactory;
import org.eclipse.jetty.webapp.WebAppContext;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
 * Base class for HTTP server of the Ozone related components.
 */
public abstract class BaseHttpServer implements AutoCloseable {

  private static final Logger LOG =
      LoggerFactory.getLogger(BaseHttpServer.class);
  static final String PROMETHEUS_SINK = "PROMETHEUS_SINK";
  private static final String JETTY_BASETMPDIR =
      "org.eclipse.jetty.webapp.basetempdir";
  public static final String SERVER_DIR = "/webserver";

  private HttpServer2 httpServer;
  private final MutableConfigurationSource conf;

  private InetSocketAddress httpAddress;
  private InetSocketAddress httpsAddress;

  private HttpConfig.Policy policy;

  private String name;
  private PrometheusMetricsSink prometheusMetricsSink;

  private boolean prometheusSupport;

  public BaseHttpServer(MutableConfigurationSource conf, String name)
      throws IOException {
    this.name = name;
    this.conf = conf;
    policy = getHttpPolicy(conf);
    if (isEnabled()) {
      this.httpAddress = getHttpBindAddress();
      this.httpsAddress = getHttpsBindAddress();

      // Avoid registering o.a.h.http.PrometheusServlet in HttpServer2.
      // TODO: Replace "hadoop.prometheus.endpoint.enabled" with
      // CommonConfigurationKeysPublic.HADOOP_PROMETHEUS_ENABLED when possible.
      conf.set("hadoop.prometheus.endpoint.enabled", "false");

      HttpServer2.Builder builder = newHttpServer2BuilderForOzone(
          conf, httpAddress, httpsAddress, name);

      LOG.info("Hadoop Security Enabled: {} " +
              "Ozone Security Enabled: {} " +
              "Ozone HTTP Security Enabled: {} ",
          UserGroupInformation.isSecurityEnabled(),
          conf.getBoolean(OZONE_SECURITY_ENABLED_KEY,
              OZONE_SECURITY_ENABLED_DEFAULT),
          conf.getBoolean(OZONE_HTTP_SECURITY_ENABLED_KEY,
              OZONE_HTTP_SECURITY_ENABLED_DEFAULT));

      if (isSecurityEnabled()) {
        String httpAuthType = conf.get(getHttpAuthType(), "simple");
        LOG.info("HttpAuthType: {} = {}", getHttpAuthType(), httpAuthType);
        // Ozone config prefix must be set to avoid AuthenticationFilter
        // fall back to default one form hadoop.http.authentication.
        builder.authFilterConfigurationPrefix(getHttpAuthConfigPrefix());
        if (httpAuthType.equals("kerberos")) {
          builder.setSecurityEnabled(true);
          builder.setUsernameConfKey(getSpnegoPrincipal());
          builder.setKeytabConfKey(getKeytabFile());
        }
      }

      final boolean xFrameEnabled = conf.getBoolean(
          HddsConfigKeys.HDDS_XFRAME_OPTION_ENABLED,
          HddsConfigKeys.HDDS_XFRAME_OPTION_ENABLED_DEFAULT);

      final String xFrameOptionValue = conf.getTrimmed(
          HddsConfigKeys.HDDS_XFRAME_OPTION_VALUE,
          HddsConfigKeys.HDDS_XFRAME_OPTION_VALUE_DEFAULT);

      builder.configureXFrame(xFrameEnabled).setXFrameOption(xFrameOptionValue);

      boolean addDefaultApps = shouldAddDefaultApps();
      if (!addDefaultApps) {
        builder.withoutDefaultApps();
      }

      httpServer = builder.build();

      // TODO move these to HttpServer2.addDefaultApps
      if (addDefaultApps) {
        httpServer.addServlet("conf", "/conf", HddsConfServlet.class);
        httpServer.addServlet("logstream", "/logstream", LogStreamServlet.class);
      }

      prometheusSupport = addDefaultApps &&
          conf.getBoolean(HddsConfigKeys.HDDS_PROMETHEUS_ENABLED, true);

      boolean profilerSupport = addDefaultApps &&
                                    conf.getBoolean(HddsConfigKeys.HDDS_PROFILER_ENABLED, false);

      if (prometheusSupport) {
        prometheusMetricsSink = new PrometheusMetricsSink(name);
        httpServer.getWebAppContext().getServletContext()
            .setAttribute(PROMETHEUS_SINK, prometheusMetricsSink);
        HddsPrometheusConfig prometheusConfig =
            conf.getObject(HddsPrometheusConfig.class);
        String token = prometheusConfig.getPrometheusEndpointToken();
        if (StringUtils.isNotEmpty(token)) {
          httpServer.getWebAppContext().getServletContext()
              .setAttribute(PrometheusServlet.SECURITY_TOKEN, token);
          // Adding as internal servlet since we want to have token based
          // auth and hence SPNEGO should be disabled if security is enabled.
          httpServer.addInternalServlet("prometheus", "/prom",
              PrometheusServlet.class);
        } else {
          // If token is not configured, keeping as regular servlet and not
          // internal servlet since we do not want to expose /prom endpoint
          // without authentication in a secure cluster.
          httpServer.addServlet("prometheus", "/prom",
              PrometheusServlet.class);
        }
      }

      if (profilerSupport) {
        LOG.warn(
            "/prof java profiling servlet is activated. Not safe for "
                + "production!");
        httpServer.addServlet("profile", "/prof", ProfileServlet.class);
      }

      String baseDir = conf.get(OzoneConfigKeys.OZONE_HTTP_BASEDIR);

      if (StringUtils.isEmpty(baseDir)) {
        baseDir = getOzoneMetaDirPath(conf) + SERVER_DIR;
      }
      createDir(baseDir);
      httpServer.getWebAppContext().setAttribute(JETTY_BASETMPDIR, baseDir);
      LOG.info("HTTP server of {} uses base directory {}", name, baseDir);
    }
  }

  @VisibleForTesting
  public String getJettyBaseTmpDir() {
    return httpServer.getWebAppContext().getAttribute(JETTY_BASETMPDIR)
        .toString();
  }

  /**
   * Return a HttpServer.Builder that the OzoneManager/SCM/Datanode/S3Gateway/
   * Recon to initialize their HTTP / HTTPS server.
   */
  public static HttpServer2.Builder newHttpServer2BuilderForOzone(
      MutableConfigurationSource conf, final InetSocketAddress httpAddr,
      final InetSocketAddress httpsAddr, String name) throws IOException {
    HttpConfig.Policy policy = getHttpPolicy(conf);

    String userString = conf.get(OZONE_ADMINISTRATORS, "");
    String groupString = conf.get(OZONE_ADMINISTRATORS_GROUPS, "");

    HttpServer2.Builder builder = new HttpServer2.Builder().setName(name)
        .setConf(conf)
        .setACL(new AccessControlList(userString, groupString));

    // initialize the webserver for uploading/downloading files.
    if (policy.isHttpEnabled()) {
      if (httpAddr.getPort() == 0) {
        builder.setFindPort(true);
      }

      URI uri = URI.create("http://" + NetUtils.getHostPortString(httpAddr));
      builder.addEndpoint(uri);
      LOG.info("Starting Web-server for {} at: {}", name, uri);
    }

    if (policy.isHttpsEnabled() && httpsAddr != null) {
      ConfigurationSource sslConf = loadSslConfiguration(conf);
      loadSslConfToHttpServerBuilder(builder, sslConf);

      if (httpsAddr.getPort() == 0) {
        builder.setFindPort(true);
      }

      URI uri = URI.create("https://" + NetUtils.getHostPortString(httpsAddr));
      builder.addEndpoint(uri);
      LOG.info("Starting Web-server for {} at: {}", name, uri);
    }
    return builder;
  }

  /**
   * Add a servlet to BaseHttpServer.
   *
   * @param servletName The name of the servlet
   * @param pathSpec    The path spec for the servlet
   * @param clazz       The servlet class
   */
  protected void addServlet(String servletName, String pathSpec,
      Class<? extends HttpServlet> clazz) {
    httpServer.addServlet(servletName, pathSpec, clazz);
  }

  protected void addInternalServlet(String servletName, String pathSpec,
      Class<? extends HttpServlet> clazz) {
    httpServer.addInternalServlet(servletName, pathSpec, clazz);
  }

  /**
   * Add a filter to BaseHttpServer.
   *
   * @param filterName The name of the filter
   * @param classname  The filter class
   * @param parameters The filter parameters
   */
  protected void addFilter(String filterName, String classname,
      Map<String, String> parameters) {
    httpServer.addFilter(filterName, classname, parameters);
  }

  /**
   * Returns the WebAppContext associated with this HttpServer.
   *
   * @return WebAppContext
   */
  protected WebAppContext getWebAppContext() {
    return httpServer.getWebAppContext();
  }

  protected InetSocketAddress getBindAddress(String bindHostKey,
      String addressKey, String bindHostDefault, int bindPortdefault) {
    final Optional<String> bindHost =
        getHostNameFromConfigKeys(conf, bindHostKey);

    final OptionalInt addressPort =
        getPortNumberFromConfigKeys(conf, addressKey);

    final Optional<String> addressHost =
        getHostNameFromConfigKeys(conf, addressKey);

    String hostName = bindHost.orElse(addressHost.orElse(bindHostDefault));

    return NetUtils.createSocketAddr(
        hostName + ":" + addressPort.orElse(bindPortdefault));
  }

  /**
   * Retrieve the socket address that should be used by clients to connect
   * to the  HTTPS web interface.
   *
   * @return Target InetSocketAddress for the Ozone HTTPS endpoint.
   */
  public InetSocketAddress getHttpsBindAddress() {
    return getBindAddress(getHttpsBindHostKey(), getHttpsAddressKey(),
        getBindHostDefault(), getHttpsBindPortDefault());
  }

  /**
   * Retrieve the socket address that should be used by clients to connect
   * to the  HTTP web interface.
   * <p>
   * * @return Target InetSocketAddress for the Ozone HTTP endpoint.
   */
  public InetSocketAddress getHttpBindAddress() {
    return getBindAddress(getHttpBindHostKey(), getHttpAddressKey(),
        getBindHostDefault(), getHttpBindPortDefault());

  }

  public void start() throws IOException {
    if (httpServer != null && isEnabled()) {
      httpServer.start();
      if (prometheusSupport) {
        DefaultMetricsSystem.instance()
            .register("prometheus", "Hadoop metrics prometheus exporter",
                prometheusMetricsSink);
      }
      updateConnectorAddress();
    }

  }

  private boolean isEnabled() {
    return conf.getBoolean(getEnabledKey(), true);
  }

  public void stop() throws Exception {
    if (httpServer != null) {
      httpServer.stop();
    }
  }

  @Override
  public void close() throws Exception {
    stop();
  }

  /**
   * Update the configured listen address based on the real port
   * <p>
   * (eg. replace :0 with real port)
   */
  public void updateConnectorAddress() {
    int connIdx = 0;
    if (policy.isHttpEnabled()) {
      httpAddress = httpServer.getConnectorAddress(connIdx++);
      String realAddress = NetUtils.getHostPortString(NetUtils.getConnectAddress(httpAddress));
      conf.set(getHttpAddressKey(), realAddress);
      LOG.info("HTTP server of {} listening at http://{}", name, realAddress);
    }

    if (policy.isHttpsEnabled()) {
      httpsAddress = httpServer.getConnectorAddress(connIdx);
      String realAddress = NetUtils.getHostPortString(NetUtils.getConnectAddress(httpsAddress));
      conf.set(getHttpsAddressKey(), realAddress);
      LOG.info("HTTPS server of {} listening at https://{}", name, realAddress);
    }
  }

  public static void loadSslConfToHttpServerBuilder(HttpServer2.Builder builder, ConfigurationSource sslConf) {
    builder
        .needsClientAuth(
            sslConf.getBoolean(OZONE_CLIENT_HTTPS_NEED_AUTH_KEY,
                OZONE_CLIENT_HTTPS_NEED_AUTH_DEFAULT))
        .keyPassword(getPassword(sslConf, OZONE_SERVER_HTTPS_KEYPASSWORD_KEY))
        .keyStore(
            sslConf.get(SSLFactory.SSL_SERVER_KEYSTORE_LOCATION),
            getPassword(sslConf, OZONE_SERVER_HTTPS_KEYSTORE_PASSWORD_KEY),
            sslConf.get(
                HddsConfigKeys.HDDS_HTTP_SERVER_KEYSTORE_TYPE,
                HddsConfigKeys.HDDS_HTTP_SERVER_KEYSTORE_TYPE_DEFAULT)
        )
        .trustStore(
            sslConf.get(SSLFactory.SSL_SERVER_TRUSTSTORE_LOCATION),
            getPassword(sslConf, OZONE_SERVER_HTTPS_TRUSTSTORE_PASSWORD_KEY),
            sslConf.get(
                HddsConfigKeys.HDDS_HTTP_SERVER_TRUSTSTORE_TYPE,
                HddsConfigKeys.HDDS_HTTP_SERVER_TRUSTSTORE_TYPE_DEFAULT)
        )
        .includeCiphers(sslConf.get(SSLFactory.SSL_SERVER_INCLUDE_CIPHER_LIST))
        .excludeCiphers(sslConf.get(SSLFactory.SSL_SERVER_EXCLUDE_CIPHER_LIST));
  }

  /**
   * Leverages the Configuration.getPassword method to attempt to get
   * passwords from the CredentialProvider API before falling back to
   * clear text in config - if falling back is allowed.
   *
   * @param conf  Configuration instance
   * @param alias name of the credential to retrieve
   * @return String credential value or null
   */
  static String getPassword(ConfigurationSource conf, String alias) {
    String password = null;
    try {
      char[] passchars = conf.getPassword(alias);
      if (passchars != null) {
        password = new String(passchars);
      }
    } catch (IOException ioe) {
      LOG.warn("Setting password to null since IOException is caught"
          + " when getting password", ioe);

      password = null;
    }
    return password;
  }

  /**
   * Load HTTPS-related configuration.
   */
  public static ConfigurationSource loadSslConfiguration(
      ConfigurationSource conf) {
    Configuration sslConf =
        new Configuration(false);

    sslConf.addResource(conf.get(
        OzoneConfigKeys.OZONE_SERVER_HTTPS_KEYSTORE_RESOURCE_KEY,
        OzoneConfigKeys.OZONE_SERVER_HTTPS_KEYSTORE_RESOURCE_DEFAULT));

    final String[] reqSslProps = {
        OzoneConfigKeys.OZONE_SERVER_HTTPS_TRUSTSTORE_LOCATION_KEY,
        OzoneConfigKeys.OZONE_SERVER_HTTPS_KEYSTORE_LOCATION_KEY,
        OzoneConfigKeys.OZONE_SERVER_HTTPS_KEYSTORE_PASSWORD_KEY,
        OzoneConfigKeys.OZONE_SERVER_HTTPS_KEYPASSWORD_KEY
    };

    // Check if the required properties are included
    for (String sslProp : reqSslProps) {
      if (sslConf.get(sslProp) == null) {
        LOG.warn("SSL config {} is missing. If {} is specified, make sure it "
                + "is a relative path", sslProp,
                OzoneConfigKeys.OZONE_SERVER_HTTPS_KEYSTORE_RESOURCE_KEY);
      }
    }

    boolean requireClientAuth = conf.getBoolean(
        OZONE_CLIENT_HTTPS_NEED_AUTH_KEY, OZONE_CLIENT_HTTPS_NEED_AUTH_DEFAULT);
    sslConf.setBoolean(OZONE_CLIENT_HTTPS_NEED_AUTH_KEY, requireClientAuth);
    return new LegacyHadoopConfigurationSource(sslConf);
  }

  public InetSocketAddress getHttpAddress() {
    return httpAddress;
  }

  public InetSocketAddress getHttpsAddress() {
    return httpsAddress;
  }

  public boolean isSecurityEnabled() {
    return UserGroupInformation.isSecurityEnabled() &&
        OzoneSecurityUtil.isHttpSecurityEnabled(conf);
  }

  protected abstract String getHttpAddressKey();

  protected abstract String getHttpsAddressKey();

  protected abstract String getHttpBindHostKey();

  protected abstract String getHttpsBindHostKey();

  protected abstract String getBindHostDefault();

  protected abstract int getHttpBindPortDefault();

  protected abstract int getHttpsBindPortDefault();

  protected abstract String getKeytabFile();

  protected abstract String getSpnegoPrincipal();

  protected abstract String getEnabledKey();

  protected abstract String getHttpAuthType();

  protected abstract String getHttpAuthConfigPrefix();

  /** Override to disable the default servlets. */
  protected boolean shouldAddDefaultApps() {
    return true;
  }

}
