m/cli/metroctl: factor out some helper functions

We will need these to create metroctl-compatible configs from
//metropolis/cluster/launch.

Change-Id: I2705afefb62b7e1b35c87d9753c4ca9c7f534c26
Reviewed-on: https://review.monogon.dev/c/monogon/+/1324
Tested-by: Jenkins CI
Reviewed-by: Mateusz Zalega <mateusz@monogon.tech>
diff --git a/metropolis/cli/metroctl/core/config.go b/metropolis/cli/metroctl/core/config.go
new file mode 100644
index 0000000..d62bf2e
--- /dev/null
+++ b/metropolis/cli/metroctl/core/config.go
@@ -0,0 +1,185 @@
+package core
+
+import (
+	"crypto/ed25519"
+	"crypto/rand"
+	"crypto/x509"
+	"encoding/pem"
+	"errors"
+	"fmt"
+	"os"
+	"path/filepath"
+
+	clientauthentication "k8s.io/client-go/pkg/apis/clientauthentication/v1"
+	"k8s.io/client-go/tools/clientcmd"
+	clientapi "k8s.io/client-go/tools/clientcmd/api"
+)
+
+const (
+	// OwnerKeyFileName is the filename of the owner key in a metroctl config
+	// directory.
+	OwnerKeyFileName = "owner-key.pem"
+	// OwnerCertificateFileName is the filename of the owner certificate in a
+	// metroctl config directory.
+	OwnerCertificateFileName = "owner.pem"
+)
+
+// NoCredentialsError indicates that the requested datum (eg. owner key or owner
+// certificate) is not present in the requested directory.
+var NoCredentialsError = errors.New("owner certificate or key does not exist")
+
+// A PEM block type for a Metropolis initial owner private key
+const ownerKeyType = "METROPOLIS INITIAL OWNER PRIVATE KEY"
+
+// GetOrMakeOwnerKey returns the owner key for a given metroctl configuration
+// directory path, generating and saving it first if it doesn't exist.
+func GetOrMakeOwnerKey(path string) (ed25519.PrivateKey, error) {
+	existing, err := GetOwnerKey(path)
+	switch err {
+	case nil:
+		return existing, nil
+	case NoCredentialsError:
+	default:
+		return nil, err
+	}
+
+	_, priv, err := ed25519.GenerateKey(rand.Reader)
+	if err != nil {
+		return nil, fmt.Errorf("when generating key: %w", err)
+	}
+	if err := WriteOwnerKey(path, priv); err != nil {
+		return nil, err
+	}
+	return priv, nil
+}
+
+// WriteOwnerKey saves a given raw ED25519 private key as the owner key at a
+// given metroctl configuration directory path.
+func WriteOwnerKey(path string, priv ed25519.PrivateKey) error {
+	pemPriv := pem.EncodeToMemory(&pem.Block{Type: ownerKeyType, Bytes: priv})
+	if err := os.WriteFile(filepath.Join(path, OwnerKeyFileName), pemPriv, 0600); err != nil {
+		return fmt.Errorf("when saving key: %w", err)
+	}
+	return nil
+}
+
+// GetOwnerKey loads and returns a raw ED25519 private key from the saved owner
+// key in a given metroctl configuration directory path. If the owner key doesn't
+// exist, NoCredentialsError will be returned.
+func GetOwnerKey(path string) (ed25519.PrivateKey, error) {
+	ownerPrivateKeyPEM, err := os.ReadFile(filepath.Join(path, OwnerKeyFileName))
+	if os.IsNotExist(err) {
+		return nil, NoCredentialsError
+	} else if err != nil {
+		return nil, fmt.Errorf("failed to load owner private key: %w", err)
+	}
+	block, _ := pem.Decode(ownerPrivateKeyPEM)
+	if block == nil {
+		return nil, errors.New("owner-key.pem contains invalid PEM armoring")
+	}
+	if block.Type != ownerKeyType {
+		return nil, fmt.Errorf("owner-key.pem contains a PEM block that's not a %v", ownerKeyType)
+	}
+	if len(block.Bytes) != ed25519.PrivateKeySize {
+		return nil, errors.New("owner-key.pem contains a non-Ed25519 key")
+	}
+	return block.Bytes, nil
+}
+
+// WriteOwnerCertificate saves a given DER-encoded X509 certificate as the owner
+// key for a given metroctl configuration directory path.
+func WriteOwnerCertificate(path string, cert []byte) error {
+	ownerCertPEM := pem.Block{
+		Type:  "CERTIFICATE",
+		Bytes: cert,
+	}
+	if err := os.WriteFile(filepath.Join(path, OwnerCertificateFileName), pem.EncodeToMemory(&ownerCertPEM), 0644); err != nil {
+		return err
+	}
+	return nil
+}
+
+// GetOwnerCredentials loads and returns a raw ED25519 private key alongside a
+// DER-encoded X509 certificate from the saved owner key and certificate in a
+// given metroctl configuration directory path. If either the key or certificate
+// doesn't exist, NoCredentialsError will be returned.
+func GetOwnerCredentials(path string) (cert *x509.Certificate, key ed25519.PrivateKey, err error) {
+	key, err = GetOwnerKey(path)
+	if err != nil {
+		return nil, nil, err
+	}
+
+	ownerCertPEM, err := os.ReadFile(filepath.Join(path, OwnerCertificateFileName))
+	if os.IsNotExist(err) {
+		return nil, nil, NoCredentialsError
+	} else if err != nil {
+		return nil, nil, fmt.Errorf("failed to load owner certificate: %w", err)
+	}
+	block, _ := pem.Decode(ownerCertPEM)
+	if block == nil {
+		return nil, nil, errors.New("owner.pem contains invalid PEM armoring")
+	}
+	if block.Type != "CERTIFICATE" {
+		return nil, nil, fmt.Errorf("owner.pem contains a PEM block that's not a CERTIFICATE")
+	}
+	cert, err = x509.ParseCertificate(block.Bytes)
+	if err != nil {
+		return nil, nil, fmt.Errorf("owner.pem contains an invalid X.509 certificate: %w", err)
+	}
+	return
+}
+
+// InstallK8SWrapper configures the current user's kubectl to connect to a
+// Kubernetes cluster as defined by server (Metropolis wrapped APIServer
+// endpoint), proxyURL (optional proxy URL) and metroctlPath (binary managing
+// credentials for this cluster, and used to implement the client-side part of
+// the Metropolis-wrapped APIServer protocol). The configuration will be saved to
+// the 'configName' context in kubectl.
+func InstallK8SWrapper(metroctlPath, configName, server, proxyURL string) error {
+	ca := clientcmd.NewDefaultPathOptions()
+	config, err := ca.GetStartingConfig()
+	if err != nil {
+		return fmt.Errorf("getting initial config failed: %w", err)
+	}
+
+	config.AuthInfos[configName] = &clientapi.AuthInfo{
+		Exec: &clientapi.ExecConfig{
+			APIVersion: clientauthentication.SchemeGroupVersion.String(),
+			Command:    metroctlPath,
+			Args:       []string{"k8scredplugin"},
+			InstallHint: `Authenticating to Metropolis clusters requires metroctl to be present.
+Running metroctl takeownership creates this entry and either points to metroctl as a command in
+PATH if metroctl is in PATH at that time or to the absolute path to metroctl at that time.
+If you moved metroctl afterwards or want to switch to PATH resolution, edit $HOME/.kube/config and
+change users.metropolis.exec.command to the required path (or just metroctl if using PATH resolution).`,
+			InteractiveMode: clientapi.NeverExecInteractiveMode,
+		},
+	}
+
+	config.Clusters[configName] = &clientapi.Cluster{
+		// MVP: This is insecure, but making this work would be wasted effort
+		// as all of it will be replaced by the identity system.
+		// TODO(issues/144): adjust cluster endpoints once have functioning roles
+		// implemented.
+		InsecureSkipTLSVerify: true,
+		Server:                server,
+		ProxyURL:              proxyURL,
+	}
+
+	config.Contexts[configName] = &clientapi.Context{
+		AuthInfo:  configName,
+		Cluster:   configName,
+		Namespace: "default",
+	}
+
+	// Only set us as the current context if no other exists. Changing that
+	// unprompted would be kind of rude.
+	if config.CurrentContext == "" {
+		config.CurrentContext = configName
+	}
+
+	if err := clientcmd.ModifyConfig(ca, *config, true); err != nil {
+		return fmt.Errorf("modifying config failed: %w", err)
+	}
+	return nil
+}