cluster/clustercfg: rewrite it in Go

This replaces the old clustercfg script with a brand spanking new
mostly-equivalent Go reimplementation. But it's not exactly the same,
here are the differences:

 1. No cluster deployment logic anymore - we expect everyone to use ops/
    machine at this point.
 2. All certs/keys are Ed25519 and do not expire by default - but
    support for short-lived certificates is there, and is actually more
    generic and reusable. Currently it's only used for admincreds.
 3. Speaking of admincreds: the new admincreds automatically figure out
    your username.
 4. admincreds also doesn't shell out to kubectl anymore, and doesn't
    override your default context. The generated creds can live
    peacefully alongside your normal prodaccess creds.
 5. gencerts (the new nodestrap without deployment support) now
    automatically generates certs for all nodes, based on local Nix
    modules in ops/.
 6. No secretstore support. This will be changed once we rebuild
    secretstore in Go. For now users are expected to manually run
    secretstore sync on cluster/secrets.

Change-Id: Ida935f44e04fd933df125905eee10121ac078495
Reviewed-on: https://gerrit.hackerspace.pl/c/hscloud/+/1498
Reviewed-by: q3k <q3k@hackerspace.pl>
diff --git a/cluster/clustercfg/certs/generator.go b/cluster/clustercfg/certs/generator.go
new file mode 100644
index 0000000..7cd2c81
--- /dev/null
+++ b/cluster/clustercfg/certs/generator.go
@@ -0,0 +1,297 @@
+package certs
+
+import (
+	"bytes"
+	"crypto"
+	"crypto/ed25519"
+	"crypto/rand"
+	"crypto/x509"
+	"encoding/pem"
+	"errors"
+	"fmt"
+	"log"
+	"math/big"
+	"net"
+	"os"
+	"path/filepath"
+	"time"
+)
+
+// Certificate is a higher-level descriptor of an intent to generate a
+// certificate and corresponding Ed25519 keypair on disk.
+type Certificate struct {
+	// uniquer name for this cert, used to calculate filesystem paths.
+	name string
+	// root directory where all certs are stored.
+	root string
+	// duration used to determine TimeAfter. If not set, the certificate will
+	// never expire.
+	duration time.Duration
+
+	kind certificateKind
+
+	// cn is the subject common name that's going to be produced in the X.509
+	// certificate.
+	cn string
+	// o is the subject organziation that's going to be produced in the X.509
+	// certificate.
+	o string
+	// san are the DNS alternate names that are going to be produced in the
+	// X.509 certificate.
+	san []string
+	// ips are the IP alternate names that are going to be produced in the
+	// X.509 certificate.
+	ips []net.IP
+
+	// issuer, if set, is the certificate that will sign this certificate. If
+	// not set, the certificate will be self-signed.
+	issuer *Certificate
+}
+
+// Paths returns local filesystem paths to the CA certificate, certificate and
+// key respectively. If the certificate is self signed, the CA path returned
+// will be empty. These files might or might not live on the file system - you
+// should first call Ensure to make sure they do.
+func (c *Certificate) Paths() (caPath, certPath, keyPath string) {
+	if c.issuer != nil {
+		caPath = c.issuer.path(fileKindCert)
+	}
+	certPath = c.path(fileKindCert)
+	keyPath = c.path(fileKindKey)
+	return
+}
+
+type certificateKind string
+
+const (
+	kindServer       certificateKind = "server"
+	kindClient       certificateKind = "client"
+	kindClientServer certificateKind = "client-server"
+	kindCA           certificateKind = "ca"
+	kindProdvider    certificateKind = "prodvider"
+)
+
+type fileKind string
+
+const (
+	fileKindKey          fileKind = "key"
+	fileKindKeyEncrypted fileKind = "key-encrypted"
+	fileKindCert         fileKind = "cert"
+)
+
+// path returns the path to the generated fileKind for this Certificate.
+func (c *Certificate) path(k fileKind) string {
+	switch k {
+	case fileKindKeyEncrypted:
+		return filepath.Join(c.root, "secrets", "cipher", c.name+".key")
+	case fileKindKey:
+		return filepath.Join(c.root, "secrets", "plain", c.name+".key")
+	case fileKindCert:
+		// clustercfg.py compat: CA certs end in .crt, non-CA certs end in .cert.
+		// We're keeping this accidental convention to avoid spurious nix rebuilds
+		// when migrating.
+		//
+		// Feel free to fix it if it annoys you.
+		extension := ".cert"
+		if c.kind == kindCA {
+			extension = ".crt"
+		}
+		return filepath.Join(c.root, "certs", c.name+extension)
+	default:
+		panic("unexpected file kind type " + k)
+	}
+}
+
+// ensureKey loads or generates-then-saves the private key for this
+// Certificate.
+func (c *Certificate) ensureKey() (crypto.Signer, error) {
+	path := c.path(fileKindKey)
+	_, err := os.Stat(path)
+	switch {
+	case err == nil:
+		return c.loadKey()
+	case errors.Is(err, os.ErrNotExist):
+		epath := c.path(fileKindKeyEncrypted)
+		if _, err = os.Stat(epath); err == nil {
+			return nil, fmt.Errorf("plaintext key at %q not found, but exists encrypted at %q - please decrypt using secretstore", path, epath)
+		}
+		return c.generateKey()
+	default:
+		return nil, fmt.Errorf("could not read key: %w", err)
+	}
+}
+
+func (c *Certificate) loadKey() (crypto.Signer, error) {
+	path := c.path(fileKindKey)
+	bytes, err := os.ReadFile(path)
+	if err != nil {
+		return nil, err
+	}
+	block, _ := pem.Decode(bytes)
+	if block == nil {
+		return nil, fmt.Errorf("no PEM block found")
+	}
+	if block.Type != "PRIVATE KEY" {
+		return nil, fmt.Errorf("unexpected PEM block: %q", block.Type)
+	}
+	key, err := x509.ParsePKCS8PrivateKey(block.Bytes)
+	if err != nil {
+		return nil, err
+	}
+	if k, ok := key.(ed25519.PrivateKey); ok {
+		return k, nil
+	}
+	return nil, fmt.Errorf("not an ED25519 key")
+}
+
+func (c *Certificate) generateKey() (crypto.Signer, error) {
+	_, priv, err := ed25519.GenerateKey(rand.Reader)
+	if err != nil {
+		return nil, err
+	}
+
+	pkcs8, err := x509.MarshalPKCS8PrivateKey(priv)
+	if err != nil {
+		return nil, err
+	}
+
+	block := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: pkcs8})
+	path := c.path(fileKindKey)
+	os.MkdirAll(filepath.Dir(path), 0700)
+	log.Printf("Saving %s key to %s ...", c.name, path)
+	if err := os.WriteFile(path, block, 0600); err != nil {
+		return nil, err
+	}
+
+	return priv, nil
+}
+
+// ensureCert loads or generates-then-saves the X.509 certificate for the
+// Certificate.
+func (c *Certificate) ensureCert() (*x509.Certificate, error) {
+	path := c.path(fileKindCert)
+	_, err := os.Stat(path)
+	switch {
+	case err == nil:
+		cert, err := c.loadCert()
+		switch err {
+		case nil:
+			return cert, nil
+		case errExpired:
+			return c.generateCert()
+		default:
+			return nil, err
+		}
+	case errors.Is(err, os.ErrNotExist):
+		return c.generateCert()
+	default:
+		return nil, fmt.Errorf("could not read cert: %w", err)
+	}
+}
+
+func (c *Certificate) generateCert() (*x509.Certificate, error) {
+	serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 127)
+	serialNumber, err := rand.Int(rand.Reader, serialNumberLimit)
+	if err != nil {
+		return nil, err
+	}
+
+	notAfter := unknownNotAfter
+	if c.duration != 0 {
+		notAfter = time.Now().Add(c.duration)
+	}
+	template := c.template()
+	template.SerialNumber = serialNumber
+	template.NotBefore = time.Now()
+	template.NotAfter = notAfter
+
+	parent := template
+	skey, err := c.ensureKey()
+	if err != nil {
+		return nil, fmt.Errorf("when ensuring key: %w", err)
+	}
+	pkey := skey.Public()
+	caskey := skey
+	if c.issuer != nil {
+		caskey, err = c.issuer.ensureKey()
+		if err != nil {
+			return nil, fmt.Errorf("when ensuring CA key: %w", err)
+		}
+		cacert, err := c.issuer.ensureCert()
+		if err != nil {
+			return nil, fmt.Errorf("when ensuring CA cert: %w", err)
+		}
+		parent = cacert
+	}
+
+	bytes, err := x509.CreateCertificate(rand.Reader, template, parent, pkey, caskey)
+	if err != nil {
+		return nil, fmt.Errorf("issuing certificate failed: %w", err)
+	}
+
+	block := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: bytes})
+	path := c.path(fileKindCert)
+	os.MkdirAll(filepath.Dir(path), 0700)
+	log.Printf("Saving %s cert to %s ...", c.name, path)
+	if err := os.WriteFile(path, block, 0600); err != nil {
+		return nil, err
+	}
+
+	return x509.ParseCertificate(bytes)
+}
+
+// errExpired is returned if the cert exists on disk but has (nearly) expired.
+var errExpired = errors.New("certificate expired")
+
+func (c *Certificate) loadCert() (*x509.Certificate, error) {
+	path := c.path(fileKindCert)
+	b, err := os.ReadFile(path)
+	if err != nil {
+		return nil, err
+	}
+
+	block, _ := pem.Decode(b)
+	if block == nil {
+		return nil, fmt.Errorf("no PEM block found")
+	}
+	if block.Type != "CERTIFICATE" {
+		return nil, fmt.Errorf("unexpected PEM block: %q", block.Type)
+	}
+	cert, err := x509.ParseCertificate(block.Bytes)
+	if err != nil {
+		return nil, err
+	}
+	if time.Now().Add(time.Hour).After(cert.NotAfter) {
+		return nil, errExpired
+	}
+	pkey, ok := cert.PublicKey.(ed25519.PublicKey)
+	if !ok {
+		return nil, fmt.Errorf("not a ED25519 cert")
+	}
+	skey, err := c.ensureKey()
+	if err != nil {
+		return nil, fmt.Errorf("when ensuring key: %w", err)
+	}
+	if !bytes.Equal(pkey, skey.Public().(ed25519.PublicKey)) {
+		return nil, fmt.Errorf("issued for different key")
+	}
+
+	template := c.template()
+	if err := compareCertData(template, cert); err != nil {
+		return nil, err
+	}
+	return cert, nil
+}
+
+// Ensure makes sure the given Certificate (and all of its' issuers) have
+// corresponding private keys and X.509 certificates on disk, generating things
+// as necessary.
+func (c *Certificate) Ensure() error {
+	cert, err := c.ensureCert()
+	if err != nil {
+		return fmt.Errorf("when ensuring cert %s: %w", c.name, err)
+	}
+	_ = cert
+
+	return nil
+}