~ knotless Search/
↑↓ move↵ open⇧↵ all results
0
0
main
250 lines · 7.8 KB Raw History
package vaultmain

import (
	"context"
	"crypto/tls"
	"errors"
	"fmt"
	"log/slog"
	"net/http"
	"os"
	"reflect"
	"strings"
	"time"

	"k8s.io/client-go/rest"

	"github.com/johnandersen777/socialweb-computer-kcp/api/v1alpha1"
	"github.com/johnandersen777/socialweb-computer-kcp/internal/controllers/runtime/kube"
	"github.com/johnandersen777/socialweb-computer-kcp/internal/kcpserver"
	"github.com/johnandersen777/socialweb-computer-kcp/internal/kit/opt"
	"github.com/johnandersen777/socialweb-computer-kcp/internal/openbaoctl"
	"github.com/johnandersen777/socialweb-computer-kcp/internal/vault/openbao"
	"github.com/johnandersen777/socialweb-computer-kcp/internal/vaultlayout"
)

const (
	pkiRootCommonName = "socialweb.local"
	pkiRootTTL        = 8760 * time.Hour
)

type RootAccess struct {
	Server     string
	Address    string
	CAFile     string
	ClientCert string
	ClientKey  string
	CertMount  string
	CertRole   string
}

func RootAccessOf(o v1alpha1.OpenBaoServer) (RootAccess, error) {
	if o.Status.Phase != v1alpha1.OpenBaoServerPhaseReady || o.Status.Address == "" {
		return RootAccess{}, fmt.Errorf("the OpenBaoServer %s is %s (%s), not Ready", o.Name, o.Status.Phase, o.Status.Message)
	}
	a := RootAccess{Server: o.Name, Address: o.Status.Address, CertMount: openbaoctl.CertAuthMount}
	if o.Status.Refs != nil {
		a.CAFile = openbaoctl.FileRef(o.Status.Refs.CA)
	}
	for _, c := range o.Status.Clients {
		if c.Name != vaultlayout.RootClient {
			continue
		}
		a.ClientCert, a.ClientKey, a.CertRole = openbaoctl.FileRef(c.ClientCert), openbaoctl.FileRef(c.ClientKey), c.CertAuthRole
		if c.CertAuthMount != "" {
			a.CertMount = c.CertAuthMount
		}
	}
	if a.CAFile == "" || a.ClientCert == "" || a.ClientKey == "" || a.CertRole == "" {
		return RootAccess{}, fmt.Errorf("the OpenBaoServer %s reports no CA or no client %s", o.Name, vaultlayout.RootClient)
	}
	return a, nil
}

func ReadRootAccess(ctx context.Context, kubeconfig, workspace, server string) (RootAccess, error) {
	restCfg, err := kcpserver.FrontRestConfig(kubeconfig)
	if err != nil {
		return RootAccess{}, err
	}
	cfg := rest.CopyConfig(restCfg)
	cfg.Host = strings.TrimSuffix(cfg.Host, "/") + "/clusters/" + workspace
	kc, err := kube.New(cfg)
	if err != nil {
		return RootAccess{}, err
	}
	o, err := kube.Get[v1alpha1.OpenBaoServer](ctx, kc.Resource(openbaoctl.GVR), server)
	if err != nil {
		return RootAccess{}, fmt.Errorf("the root vault's OpenBaoServer %s in %s: %w", server, workspace, err)
	}
	return RootAccessOf(*o)
}

func (a RootAccess) Client(ctx context.Context) (*openbao.Client, error) {
	raw, err := os.ReadFile(a.CAFile)
	if err != nil {
		return nil, fmt.Errorf("the root vault's TLS CA: %w", err)
	}
	pool, err := openbao.CertPoolFromPEM(raw)
	if err != nil {
		return nil, err
	}
	pair, err := tls.LoadX509KeyPair(a.ClientCert, a.ClientKey)
	if err != nil {
		return nil, fmt.Errorf("the client certificate of %s: %w", vaultlayout.RootClient, err)
	}
	base := openbao.New(a.Address, "", openbao.WithCACertPool(pool), openbao.WithClientCertificate(pair))
	client, err := base.LoginCert(ctx, a.CertMount, a.CertRole)
	if err != nil {
		return nil, fmt.Errorf("logging in to the root vault at %s as %s: %w", a.Address, a.CertRole, err)
	}
	return client, nil
}

func pkiRootPresent(ctx context.Context, c *openbao.Client, mount string) (bool, error) {
	_, err := c.CAPEM(ctx, mount)
	if err == nil {
		return true, nil
	}
	var re *openbao.ResponseError
	if errors.As(err, &re) {
		lower := strings.ToLower(re.Body)
		if re.Status == http.StatusNoContent || re.Status == http.StatusNotFound || strings.Contains(lower, "no ca") ||
			strings.Contains(lower, "not found") || strings.Contains(lower, "no certificate") {
			return false, nil
		}
	}
	if strings.Contains(err.Error(), "holds no certificate") {
		return false, nil
	}
	return false, err
}

func ApplyRoot(ctx context.Context, a RootAccess, cfg Config, log *slog.Logger) error {
	c, err := a.Client(ctx)
	if err != nil {
		return err
	}
	if err := ensurePKIRoot(ctx, c, log); err != nil {
		return err
	}
	return applySecrets(ctx, c, cfg, log)
}

func ensurePKIRoot(ctx context.Context, c *openbao.Client, log *slog.Logger) error {
	for _, m := range vaultlayout.Default().Mounts {
		if m.Vault != vaultlayout.Root || m.Kind != vaultlayout.MountPKI {
			continue
		}
		present, err := pkiRootPresent(ctx, c, m.Path)
		if err != nil {
			return err
		}
		if present {
			log.Info("pki root already generated", "mount", m.Path)
			continue
		}
		ttl := m.MaxLeaseTTL
		ttl = opt.Positive(ttl, pkiRootTTL)
		cert, err := c.GeneratePKIRoot(ctx, m.Path, pkiRootCommonName, ttl.String())
		if err != nil {
			return fmt.Errorf("generating the root CA under %s: %w", m.Path, err)
		}
		log.Info("generated the pki root", "mount", m.Path, "common_name", pkiRootCommonName, "serial", cert.Serial)
	}
	return nil
}

type sourcedSecret struct {
	Path     string
	Data     map[string]any
	Reason   string
	Required bool
}

func planRootSecrets(ctx context.Context, c *openbao.Client, cfg Config) []sourcedSecret {
	var out []sourcedSecret
	for _, s := range vaultlayout.Default().Secrets {
		if s.Vault != vaultlayout.Root {
			continue
		}
		switch s.Class {
		case vaultlayout.ClassKCPAdminKubeconfig:
			raw, err := os.ReadFile(cfg.KCPKubeconfig)
			if err != nil {
				out = append(out, sourcedSecret{Path: s.Path, Required: true, Reason: err.Error()})
				continue
			}
			out = append(out, sourcedSecret{Path: s.Path, Data: map[string]any{"kubeconfig": string(raw)}})
		case vaultlayout.ClassKCPAdminTokenStore:
			data, err := kcpAdminTokenStoreData(cfg)
			if err != nil {
				out = append(out, sourcedSecret{Path: s.Path, Required: true, Reason: err.Error()})
				continue
			}
			out = append(out, sourcedSecret{Path: s.Path, Data: data})
		case vaultlayout.ClassKCPClientCA:
			pem, err := c.CAPEM(ctx, vaultlayout.MountPKIRoot)
			if err != nil {
				out = append(out, sourcedSecret{Path: s.Path, Required: true, Reason: err.Error()})
				continue
			}
			out = append(out, sourcedSecret{Path: s.Path, Data: map[string]any{"ca.crt": strings.TrimSpace(pem)}})
		case vaultlayout.ClassContainerTLS:
			if cfg.TLSCert == "" || cfg.TLSKey == "" {
				out = append(out, sourcedSecret{Path: s.Path, Reason: "the TLS front names no certificate"})
				continue
			}
			cert, cerr := os.ReadFile(cfg.TLSCert)
			key, kerr := os.ReadFile(cfg.TLSKey)
			if err := errors.Join(cerr, kerr); err != nil {
				out = append(out, sourcedSecret{Path: s.Path, Reason: err.Error()})
				continue
			}
			out = append(out, sourcedSecret{Path: s.Path, Data: map[string]any{"tls.crt": string(cert), "tls.key": string(key)}})
		default:
			out = append(out, sourcedSecret{Path: s.Path, Required: true, Reason: fmt.Sprintf("class %s has no producer", s.Class)})
		}
	}
	return out
}

func applySecrets(ctx context.Context, c *openbao.Client, cfg Config, log *slog.Logger) error {
	for _, s := range planRootSecrets(ctx, c, cfg) {
		if s.Data == nil {
			if s.Required {
				return fmt.Errorf("the root secret at %s has no source: %s", s.Path, s.Reason)
			}
			log.Warn("root secret not written", "path", s.Path, "reason", s.Reason)
			continue
		}
		changed, err := putIfChanged(ctx, c, s.Path, s.Data)
		if err != nil {
			return fmt.Errorf("writing %s: %w", s.Path, err)
		}
		if changed {
			log.Info("wrote root secret", "path", s.Path, "keys", keysOf(s.Data))
		} else {
			log.Info("root secret already current", "path", s.Path)
		}
	}
	return nil
}

func putIfChanged(ctx context.Context, c *openbao.Client, path string, data map[string]any) (bool, error) {
	existing, err := c.GetKV(ctx, path)
	if err == nil && reflect.DeepEqual(existing, data) {
		return false, nil
	}
	if err != nil && !errors.Is(err, openbao.ErrNotFound) {
		return false, err
	}
	return true, c.PutKV(ctx, path, data)
}

func keysOf(data map[string]any) []string {
	out := make([]string, 0, len(data))
	for k := range data {
		out = append(out, k)
	}
	return out
}