#!/usr/bin/env node
//
// cli entrypoint for starting a Configurable Proxy
//
// Copyright (c) Jupyter Development Team.
// Distributed under the terms of the Modified BSD License.
//
"use strict";

var fs = require("fs"),
  pkg = require("../package.json"),
  cli = require("commander"),
  tls = require("tls"),
  winston = require("winston");

cli
  .version(pkg.version)
  .option("--ip <ip-address>", "Public-facing IP of the proxy")
  .option("--port <n> (defaults to 8000)", "Public-facing port of the proxy", parseInt)
  .option("--ssl-key <keyfile>", "SSL key to use, if any")
  .option("--ssl-cert <certfile>", "SSL certificate to use, if any")
  .option("--ssl-ca <ca-file>", "SSL certificate authority, if any")
  .option("--ssl-request-cert", "Request SSL certs to authenticate clients")
  .option(
    "--ssl-reject-unauthorized",
    "Reject unauthorized SSL connections (only meaningful if --ssl-request-cert is given)"
  )
  .option("--ssl-protocol <ssl-protocol>", "Set specific SSL protocol, e.g. TLSv1_2, SSLv3")
  .option("--ssl-ciphers <ciphers>", "`:`-separated ssl cipher list. Default excludes RC4")
  .option("--ssl-allow-rc4", "Allow RC4 cipher for SSL (disabled by default)")
  .option("--ssl-dhparam <dhparam-file>", "SSL Diffie-Helman Parameters pem file, if any")
  .option("--api-ip <ip>", "Inward-facing IP for API requests", "localhost")
  .option(
    "--api-port <n>",
    "Inward-facing port for API requests (defaults to --port=value+1)",
    parseInt
  )
  .option("--api-ssl-key <keyfile>", "SSL key to use, if any, for API requests")
  .option("--api-ssl-cert <certfile>", "SSL certificate to use, if any, for API requests")
  .option("--api-ssl-ca <ca-file>", "SSL certificate authority, if any, for API requests")
  .option("--api-ssl-request-cert", "Request SSL certs to authenticate clients for API requests")
  .option(
    "--api-ssl-reject-unauthorized",
    "Reject unauthorized SSL connections (only meaningful if --api-ssl-request-cert is given)"
  )
  .option("--client-ssl-key <keyfile>", "SSL key to use, if any, for proxy to client requests")
  .option(
    "--client-ssl-cert <certfile>",
    "SSL certificate to use, if any, for proxy to client requests"
  )
  .option(
    "--client-ssl-ca <ca-file>",
    "SSL certificate authority, if any, for proxy to client requests"
  )
  .option("--client-ssl-request-cert", "Request SSL certs to authenticate clients for API requests")
  .option(
    "--client-ssl-reject-unauthorized",
    "Reject unauthorized SSL connections (only meaningful if --client-ssl-request-cert is given)"
  )
  .option("--default-target <host>", "Default proxy target (proto://host[:port])")
  .option(
    "--error-target <host>",
    "Alternate server for handling proxy errors (proto://host[:port])"
  )
  .option("--error-path <path>", "Alternate server for handling proxy errors (proto://host[:port])")
  .option(
    "--redirect-port <redirect-port>",
    "Redirect HTTP requests on this port to the server on HTTPS"
  )
  .option("--redirect-to <port>", "Redirect HTTP requests from --redirect-port to this port")
  .option("--pid-file <pid-file>", "Write our PID to a file")
  // passthrough http-proxy options
  .option("--no-x-forward", "Don't add 'X-forward-' headers to proxied requests")
  .option("--no-prepend-path", "Avoid prepending target paths to proxied requests")
  .option("--no-include-prefix", "Don't include the routing prefix in proxied requests")
  .option("--auto-rewrite", "Rewrite the Location header host/port in redirect responses")
  .option("--change-origin", "Changes the origin of the host header to the target URL")
  .option(
    "--protocol-rewrite <proto>",
    "Rewrite the Location header protocol in redirect responses to the specified protocol"
  )
  .option(
    "--custom-header <header>",
    "Custom header to add to proxied requests. Use same option for multiple headers (--custom-header k1:v1 --custom-header k2:v2)",
    collectHeadersIntoObject,
    {}
  )
  .option("--insecure", "Disable SSL cert verification")
  .option("--host-routing", "Use host routing (host as first level of path)")
  .option("--metrics-ip <ip>", "IP for metrics server", "")
  .option("--metrics-port <n>", "Port of metrics server. Defaults to no metrics server")
  .option("--log-level <loglevel>", "Log level (debug, info, warn, error)", "info")
  .option(
    "--timeout <n>",
    "Timeout (in millis) when proxy drops connection for a request.",
    parseInt
  )
  .option(
    "--proxy-timeout <n>",
    "Timeout (in millis) when proxy receives no response from target.",
    parseInt
  )
  .option(
    "--storage-backend <storage-class>",
    "Define an external storage class. Defaults to in-MemoryStore."
  );

// collects multiple flags to an object
// --custom-header "k1:v1" --custom-header " k2 : v2 " --> {"k1":"v1","k2":"v2"}
function collectHeadersIntoObject(value, previous) {
  var headerParts = value.split(":").map((p) => p.trim());
  if (headerParts.length != 2) {
    log.error("A single colon was expected in custom header: " + value);
    process.exit(1);
  }
  previous[headerParts[0]] = headerParts[1];
}

cli.parse(process.argv);
var args = cli.opts();

var ConfigurableProxy = require("../lib/configproxy.js").ConfigurableProxy;

var log = require("../lib/log.js").defaultLogger({ level: args.logLevel.toLowerCase() });
var options = { log: log };

var sslCiphers;
if (args.sslCiphers) {
  sslCiphers = args.sslCiphers;
} else {
  var rc4 = "!RC4"; // disable RC4 by default
  if (args.sslAllowRc4) {
    // autoCamelCase is duMb
    rc4 = "RC4";
  }
  // ref: https://iojs.org/api/tls.html#tls_modifying_the_default_tls_cipher_suite
  sslCiphers = [
    "ECDHE-RSA-AES128-GCM-SHA256",
    "ECDHE-ECDSA-AES128-GCM-SHA256",
    "ECDHE-RSA-AES256-GCM-SHA384",
    "ECDHE-ECDSA-AES256-GCM-SHA384",
    "DHE-RSA-AES128-GCM-SHA256",
    "ECDHE-RSA-AES128-SHA256",
    "DHE-RSA-AES128-SHA256",
    "ECDHE-RSA-AES256-SHA384",
    "DHE-RSA-AES256-SHA384",
    "ECDHE-RSA-AES256-SHA256",
    "DHE-RSA-AES256-SHA256",
    "HIGH",
    rc4,
    "!aNULL",
    "!eNULL",
    "!EXPORT",
    "!DES",
    "!RC4",
    "!MD5",
    "!PSK",
    "!SRP",
    "!CAMELLIA",
  ].join(":");
}

function _loadSslCa(caFile) {
  // When multiple CAs need to be specified, they must be broken into
  // an array of certs unfortunately.
  var chain = fs.readFileSync(caFile, "utf8");
  var ca = [];
  var cert = [];
  chain.split("\n").forEach(function (line) {
    cert.push(line);
    if (line.match(/-END CERTIFICATE-/)) {
      ca.push(new Buffer(cert.join("\n")));
      cert = [];
    }
  });
  return ca;
}

// ssl options
if (args.sslKey || args.sslCert) {
  options.ssl = {};
  if (args.sslKey) {
    options.ssl.key = fs.readFileSync(args.sslKey);
    if (process.env.CONFIGPROXY_SSL_KEY_PASSPHRASE) {
      options.ssl.passphrase = process.env.CONFIGPROXY_SSL_KEY_PASSPHRASE;
    }
  }
  if (args.sslCert) {
    options.ssl.cert = fs.readFileSync(args.sslCert);
  }
  if (args.sslCa) {
    options.ssl.ca = fs.readFileSync(args.sslCa);
  }
  if (args.sslDhparam) {
    options.ssl.dhparam = fs.readFileSync(args.sslDhparam);
  }
  if (args.sslProtocol) {
    options.ssl.secureProtocol = args.sslProtocol + "_method";
  }
  options.ssl.ciphers = sslCiphers;
  options.ssl.honorCipherOrder = true;
  options.ssl.requestCert = args.sslRequestCert;
  options.ssl.rejectUnauthorized = args.sslRejectUnauthorized;
}

// ssl options for the API interface
if (args.apiSslKey || args.apiSslCert) {
  options.apiSsl = {};
  if (args.apiSslKey) {
    options.apiSsl.key = fs.readFileSync(args.apiSslKey);
    if (process.env.CONFIGPROXY_API_SSL_KEY_PASSPHRASE) {
      options.apiSsl.passphrase = process.env.CONFIGPROXY_API_SSL_KEY_PASSPHRASE;
    }
  }
  if (args.apiSslCert) {
    options.apiSsl.cert = fs.readFileSync(args.apiSslCert);
  }
  if (args.apiSslCa) {
    options.apiSsl.ca = _loadSslCa(args.apiSslCa);
  }
  if (args.sslDhparam) {
    options.apiSsl.dhparam = fs.readFileSync(args.sslDhparam);
  }
  if (args.sslProtocol) {
    options.apiSsl.secureProtocol = args.sslProtocol + "_method";
  }
  options.apiSsl.ciphers = sslCiphers;
  options.apiSsl.honorCipherOrder = true;
  options.apiSsl.requestCert = args.apiSslRequestCert;
  options.apiSsl.rejectUnauthorized = args.apiSslRejectUnauthorized;
}

if (args.clientSslKey || args.clientSslCert || args.clientSslCa) {
  options.clientSsl = {};
  if (args.clientSslKey) {
    options.clientSsl.key = fs.readFileSync(args.clientSslKey);
  }
  if (args.clientSslCert) {
    options.clientSsl.cert = fs.readFileSync(args.clientSslCert);
  }
  if (args.clientSslCa) {
    options.clientSsl.ca = _loadSslCa(args.clientSslCa);
  }
  if (args.sslDhparam) {
    options.clientSsl.dhparam = fs.readFileSync(args.sslDhparam);
  }
  if (args.sslProtocol) {
    options.clientSsl.secureProtocol = args.sslProtocol + "_method";
  }
  options.clientSsl.ciphers = sslCiphers;
  options.clientSsl.honorCipherOrder = true;
  options.clientSsl.requestCert = args.clientSslRequestCert;
  options.clientSsl.rejectUnauthorized = args.clientSslRejectUnauthorized;
}

// because camelCase is the js way!
options.defaultTarget = args.defaultTarget;
options.errorTarget = args.errorTarget;
options.errorPath = args.errorPath;
options.hostRouting = args.hostRouting;
options.authToken = process.env.CONFIGPROXY_AUTH_TOKEN;
options.redirectPort = args.redirectPort;
options.redirectTo = args.redirectTo;
options.headers = args.customHeader;
options.timeout = args.timeout;
options.proxyTimeout = args.proxyTimeout;

// metrics options
options.enableMetrics = !!args.metricsPort;

// certs need to be provided for https redirection
if (!options.ssl && options.redirectPort) {
  log.error("HTTPS redirection specified but certificates not provided.");
  process.exit(1);
}

if (options.errorTarget && options.errorPath) {
  log.error("Cannot specify both error-target and error-path. Pick one.");
  process.exit(1);
}

// passthrough for http-proxy options
if (args.insecure) options.secure = false;
options.xfwd = args.xForward;
options.prependPath = args.prependPath;
options.includePrefix = args.includePrefix;
if (args.autoRewrite) {
  options.autoRewrite = true;
  log.info("AutoRewrite of Location headers enabled.");
}
if (args.changeOrigin) {
  options.changeOrigin = true;
  log.info("Change Origin of host headers enabled.");
}

if (args.protocolRewrite) {
  options.protocolRewrite = args.protocolRewrite;
  log.info("ProtocolRewrite enabled. Rewriting to " + options.protocolRewrite);
}

if (!options.authToken) {
  log.warn("REST API is not authenticated.");
}

// external backend class
options.storageBackend = args.storageBackend;

var proxy = new ConfigurableProxy(options);

var listen = {};
listen.port = parseInt(args.port) || 8000;
if (args.ip === "*") {
  // handle ip=* alias for all interfaces
  log.warn(
    "Interpreting ip='*' as all-interfaces. Preferred usage is 0.0.0.0 for all IPv4 or '' for all-interfaces."
  );
  args.ip = "";
}
listen.ip = args.ip;
listen.apiIp = args.apiIp;
listen.apiPort = args.apiPort || listen.port + 1;
listen.metricsIp = args.metricsIp;
listen.metricsPort = args.metricsPort;

proxy.proxyServer.listen(listen.port, listen.ip);
proxy.apiServer.listen(listen.apiPort, listen.apiIp);
if (listen.metricsPort) {
  proxy.metricsServer.listen(listen.metricsPort, listen.metricsIp);
}

log.info(
  "Proxying %s://%s:%s to %s",
  options.ssl ? "https" : "http",
  listen.ip || "*",
  listen.port,
  options.defaultTarget || "(no default)"
);
log.info(
  "Proxy API at %s://%s:%s/api/routes",
  options.apiSsl ? "https" : "http",
  listen.apiIp || "*",
  listen.apiPort
);
if (listen.metricsPort) {
  log.info("Serve metrics at %s://%s:%s/metrics", "http", listen.metricsIp, listen.metricsPort);
}

if (args.pidFile) {
  log.info("Writing pid %s to %s", process.pid, args.pidFile);
  var fd = fs.openSync(args.pidFile, "w");
  fs.writeSync(fd, process.pid.toString());
  fs.closeSync(fd);
  process.on("exit", function () {
    log.debug("Removing %s", args.pidFile);
    fs.unlinkSync(args.pidFile);
  });
}

// Redirect HTTP to HTTPS on the proxy's port
if (options.redirectPort && listen.port !== 80) {
  var http = require("http");
  var redirectPort = options.redirectTo ? options.redirectTo : listen.port;
  var server = http
    .createServer(function (req, res) {
      if (typeof req.headers.host === "undefined") {
        res.statusCode = 400;
        res.write(
          "This server is HTTPS-only on port " +
            redirectPort +
            ", but an HTTP request was made and the host could not be determined from the request."
        );
        res.end();
        return;
      }
      var host = req.headers.host.split(":")[0];

      // Make sure that when we redirect, it's to the port the proxy is running on
      // or the port to which we have been instructed to forward.
      if (redirectPort !== 443) {
        host = host + ":" + redirectPort;
      }
      res.writeHead(301, { Location: "https://" + host + req.url });
      res.end();
    })
    .listen(options.redirectPort, () => {
      log.info(
        "Added HTTP to HTTPS redirection from " + server.address().port + " to " + redirectPort
      );
    });
}

// trigger normal exit on SIGINT
// without this, PID cleanup won't fire on SIGINT
process.on("SIGINT", function () {
  log.warn("Interrupted");
  process.exit(2);
});

// trigger normal exit on SIGTERM
// fired on `docker stop` and during Kubernetes pod container evictions
process.on("SIGTERM", function () {
  log.warn("Terminated");
  process.exit(2);
});

// log uncaught exceptions, don't exit now that setup is complete
process.on("uncaughtException", function (e) {
  log.error("Uncaught Exception: " + e.message);
  if (e.stack) {
    log.error(e.stack);
  }
});
