// Copyright (C) The Arvados Authors. All rights reserved.
//
// SPDX-License-Identifier: AGPL-3.0

package boot

import (
	"context"
	"crypto/rsa"
	"crypto/tls"
	"crypto/x509"
	"encoding/pem"
	"errors"
	"fmt"
	"io/ioutil"
	"net"
	"net/http"
	"net/url"
	"os"
	"path/filepath"
	"strings"
	"time"

	"golang.org/x/crypto/acme"
	"golang.org/x/crypto/acme/autocert"
)

const stagingDirectoryURL = "https://acme-staging-v02.api.letsencrypt.org/directory"

var errInvalidHost = errors.New("unrecognized target host in incoming TLS request")

type createCertificates struct{}

func (createCertificates) String() string {
	return "certificates"
}

func (createCertificates) Run(ctx context.Context, fail func(error), super *Supervisor) error {
	if super.cluster.TLS.ACME.Server != "" {
		return bootAutoCert(ctx, fail, super)
	} else if super.cluster.TLS.Key == "" && super.cluster.TLS.Certificate == "" {
		return createSelfSignedCert(ctx, fail, super)
	} else {
		return nil
	}
}

// bootAutoCert uses Let's Encrypt to get certificates for all the
// domains appearing in ExternalURLs, writes them to files where Nginx
// can load them, and updates super.cluster.TLS fields (Key and
// Certificiate) to point to those files.
//
// It also runs a background task to keep the files up to date.
//
// After bootAutoCert returns, other service components will get the
// certificates they need by reading these files or by using a
// read-only autocert cache.
//
// Currently this only works when port 80 of every ExternalURL domain
// is routed to this host, i.e., on a single-node cluster. Wildcard
// domains [for WebDAV] are not supported.
func bootAutoCert(ctx context.Context, fail func(error), super *Supervisor) error {
	hosts := map[string]bool{}
	for _, svc := range super.cluster.Services.Map() {
		u := url.URL(svc.ExternalURL)
		if u.Scheme == "https" || u.Scheme == "wss" {
			hosts[strings.ToLower(u.Hostname())] = true
		}
	}
	mgr := &autocert.Manager{
		Cache:  autocert.DirCache(super.tempdir + "/autocert"),
		Prompt: autocert.AcceptTOS,
		HostPolicy: func(ctx context.Context, host string) error {
			if hosts[strings.ToLower(host)] {
				return nil
			} else {
				return errInvalidHost
			}
		},
	}
	if srv := super.cluster.TLS.ACME.Server; srv == "LE" {
		// Leaving mgr.Client == nil means use Let's Encrypt
		// production environment
	} else if srv == "LE-staging" {
		mgr.Client = &acme.Client{DirectoryURL: stagingDirectoryURL}
	} else if strings.HasPrefix(srv, "https://") {
		mgr.Client = &acme.Client{DirectoryURL: srv}
	} else {
		return fmt.Errorf("autocert setup: invalid directory URL in TLS.ACME.Server: %q", srv)
	}
	go func() {
		err := http.ListenAndServe(":80", mgr.HTTPHandler(nil))
		fail(fmt.Errorf("autocert http-01 challenge handler stopped: %w", err))
	}()
	u := url.URL(super.cluster.Services.Controller.ExternalURL)
	extHost := u.Hostname()
	update := func() error {
		for h := range hosts {
			cert, err := mgr.GetCertificate(&tls.ClientHelloInfo{ServerName: h})
			if err != nil {
				return err
			}
			if h == extHost {
				err = writeCert(super.tempdir, "server.key", "server.crt", cert)
				if err != nil {
					return err
				}
			}
		}
		return nil
	}
	err := update()
	if err != nil {
		return err
	}
	go func() {
		for range time.NewTicker(time.Hour).C {
			err := update()
			if err != nil {
				super.logger.WithError(err).Error("error getting certificate from autocert")
			}
		}
	}()
	super.cluster.TLS.Key = "file://" + super.tempdir + "/server.key"
	super.cluster.TLS.Certificate = "file://" + super.tempdir + "/server.crt"
	return nil
}

// Save cert chain and key in a format Nginx can read.
func writeCert(outdir, keyfile, certfile string, cert *tls.Certificate) error {
	keytmp, err := os.CreateTemp(outdir, keyfile+".tmp.*")
	if err != nil {
		return err
	}
	defer keytmp.Close()
	defer os.Remove(keytmp.Name())

	certtmp, err := os.CreateTemp(outdir, certfile+".tmp.*")
	if err != nil {
		return err
	}
	defer certtmp.Close()
	defer os.Remove(certtmp.Name())

	switch privkey := cert.PrivateKey.(type) {
	case *rsa.PrivateKey:
		err = pem.Encode(keytmp, &pem.Block{
			Type:  "RSA PRIVATE KEY",
			Bytes: x509.MarshalPKCS1PrivateKey(privkey),
		})
		if err != nil {
			return err
		}
	default:
		buf, err := x509.MarshalPKCS8PrivateKey(privkey)
		if err != nil {
			return err
		}
		err = pem.Encode(keytmp, &pem.Block{
			Type:  "PRIVATE KEY",
			Bytes: buf,
		})
		if err != nil {
			return err
		}
	}
	err = keytmp.Close()
	if err != nil {
		return err
	}

	for _, cert := range cert.Certificate {
		err = pem.Encode(certtmp, &pem.Block{
			Type:  "CERTIFICATE",
			Bytes: cert,
		})
		if err != nil {
			return err
		}
	}
	err = certtmp.Close()
	if err != nil {
		return err
	}

	err = os.Rename(keytmp.Name(), filepath.Join(outdir, keyfile))
	if err != nil {
		return err
	}
	err = os.Rename(certtmp.Name(), filepath.Join(outdir, certfile))
	if err != nil {
		return err
	}
	return nil
}

// Create a root CA key and use it to make a new server
// certificate+key pair.
//
// In future we'll make one root CA key per host instead of one per
// cluster, so it only needs to be imported to a browser once for
// ongoing dev/test usage.
func createSelfSignedCert(ctx context.Context, fail func(error), super *Supervisor) error {
	san := "DNS:localhost,DNS:localhost.localdomain"
	if net.ParseIP(super.ListenHost) != nil {
		san += fmt.Sprintf(",IP:%s", super.ListenHost)
	} else {
		san += fmt.Sprintf(",DNS:%s", super.ListenHost)
	}
	hostname, err := os.Hostname()
	if err != nil {
		return fmt.Errorf("hostname: %w", err)
	}
	if hostname != super.ListenHost {
		san += ",DNS:" + hostname
	}

	// Generate root key
	err = super.RunProgram(ctx, super.tempdir, runOptions{}, "openssl", "genrsa", "-out", "rootCA.key", "4096")
	if err != nil {
		return err
	}
	// Generate a self-signed root certificate
	err = super.RunProgram(ctx, super.tempdir, runOptions{}, "openssl", "req", "-x509", "-new", "-nodes", "-key", "rootCA.key", "-sha256", "-days", "3650", "-out", "rootCA.crt", "-subj", "/C=US/ST=MA/O=Example Org/CN=localhost")
	if err != nil {
		return err
	}
	// Generate server key
	err = super.RunProgram(ctx, super.tempdir, runOptions{}, "openssl", "genrsa", "-out", "server.key", "2048")
	if err != nil {
		return err
	}
	// Build config file for signing request
	defaultconf, err := ioutil.ReadFile("/etc/ssl/openssl.cnf")
	if err != nil {
		return err
	}
	conf := append(defaultconf, []byte(fmt.Sprintf("\n[SAN]\nsubjectAltName=%s\n", san))...)
	err = ioutil.WriteFile(filepath.Join(super.tempdir, "server.cfg"), conf, 0644)
	if err != nil {
		return err
	}
	// Generate signing request
	err = super.RunProgram(ctx, super.tempdir, runOptions{}, "openssl", "req", "-new", "-sha256", "-key", "server.key", "-subj", "/C=US/ST=MA/O=Example Org/CN=localhost", "-reqexts", "SAN", "-config", "server.cfg", "-out", "server.csr")
	if err != nil {
		return err
	}
	// Sign certificate
	err = super.RunProgram(ctx, super.tempdir, runOptions{}, "openssl", "x509", "-req", "-in", "server.csr", "-CA", "rootCA.crt", "-CAkey", "rootCA.key", "-CAcreateserial", "-out", "server.crt", "-extfile", "server.cfg", "-extensions", "SAN", "-days", "3650", "-sha256")
	if err != nil {
		return err
	}
	super.cluster.TLS.Key = "file://" + super.tempdir + "/server.key"
	super.cluster.TLS.Certificate = "file://" + super.tempdir + "/server.crt"
	return nil
}
