From fba330b86e7fd5d9e202dc61a80df001660ba80d Mon Sep 17 00:00:00 2001 From: joshsiv-mulga Date: Wed, 7 Oct 2026 11:31:59 +1000 Subject: [PATCH] refactor: move EKS konnectivity cert helper to the EKS agent tree cmd/eks-konnectivity-cert keeps flag parsing, the log prefix and the exit; agents/eks/konnectivitycert owns Config and Run. Output, error text, file modes and exit codes are unchanged. --- cmd/eks-konnectivity-cert/main.go | 238 ++--------------- spinifex/agents/eks/konnectivitycert/cert.go | 243 ++++++++++++++++++ .../agents/eks/konnectivitycert/cert_test.go | 2 +- .../agents/eks/konnectivitycert/run_test.go | 78 ++++++ 4 files changed, 337 insertions(+), 224 deletions(-) create mode 100644 spinifex/agents/eks/konnectivitycert/cert.go rename cmd/eks-konnectivity-cert/main_test.go => spinifex/agents/eks/konnectivitycert/cert_test.go (99%) create mode 100644 spinifex/agents/eks/konnectivitycert/run_test.go diff --git a/cmd/eks-konnectivity-cert/main.go b/cmd/eks-konnectivity-cert/main.go index 4cb62b328..58ec68ce2 100644 --- a/cmd/eks-konnectivity-cert/main.go +++ b/cmd/eks-konnectivity-cert/main.go @@ -1,16 +1,6 @@ -// Command eks-konnectivity-cert mints, then reuses, an ECDSA-P256 serving -// certificate for the on-node konnectivity-server, signed by the K3s cluster CA. -// -// The konnectivity-agent (DaemonSet on workers) verifies the server it dials -// using the in-pod ServiceAccount CA (/var/run/secrets/.../ca.crt), which in -// K3s is the same server-ca that signs the apiserver. Signing the konnectivity -// serving cert with that CA is therefore what lets the agent validate the -// reverse-tunnel endpoint with no separate CA distribution. -// -// The eks-node AMI carries no openssl, so this is the Go equivalent: load the -// K3s server CA cert+key, issue a leaf (ServerAuth) cert carrying the cluster's -// konnectivity SANs (the NLB private-endpoint IP, public endpoint IP, NLB DNS), -// and cache it under -dir so the OpenRC service is byte-stable across restarts. +// Command eks-konnectivity-cert mints, then reuses, a K3s-CA-signed serving +// certificate for the on-node konnectivity-server; the implementation is in +// agents/eks/konnectivitycert. // // Usage: // @@ -23,225 +13,27 @@ package main import ( - "crypto/ecdsa" - "crypto/elliptic" - "crypto/rand" - "crypto/x509" - "crypto/x509/pkix" - "encoding/pem" + "context" "flag" - "fmt" "log/slog" - "math/big" - "net" "os" - "path/filepath" - "strings" - "time" _ "github.com/mulgadc/bluebottle/pkg/fipsboot" + + "github.com/mulgadc/spinifex/spinifex/agents/eks/konnectivitycert" ) func main() { - dir := flag.String("dir", "", "directory to cache tls.crt/tls.key in") - caCert := flag.String("ca-cert", "", "PEM path of the signing (K3s server) CA cert") - caKey := flag.String("ca-key", "", "PEM path of the signing CA private key") - cn := flag.String("cn", "konnectivity-server", "certificate common name") - sansCSV := flag.String("sans", "", "comma-separated SANs (IP or DNS)") + cfg := konnectivitycert.Config{Out: os.Stdout} + flag.StringVar(&cfg.Dir, "dir", "", "directory to cache tls.crt/tls.key in") + flag.StringVar(&cfg.CACert, "ca-cert", "", "PEM path of the signing (K3s server) CA cert") + flag.StringVar(&cfg.CAKey, "ca-key", "", "PEM path of the signing CA private key") + flag.StringVar(&cfg.CN, "cn", "konnectivity-server", "certificate common name") + flag.StringVar(&cfg.SANs, "sans", "", "comma-separated SANs (IP or DNS)") flag.Parse() - if *dir == "" || *caCert == "" || *caKey == "" || *sansCSV == "" { - fatal("eks-konnectivity-cert: -dir, -ca-cert, -ca-key and -sans are required") + if err := konnectivitycert.Run(context.Background(), cfg); err != nil { + slog.Error("eks-konnectivity-cert: " + err.Error()) + os.Exit(1) } - dns, ips := splitSANs(*sansCSV) - if len(dns) == 0 && len(ips) == 0 { - fatal("eks-konnectivity-cert: -sans lists no usable names") - } - - certPath, keyPath, err := ensureCert(*dir, *caCert, *caKey, *cn, dns, ips) - if err != nil { - fatal("eks-konnectivity-cert: " + err.Error()) - } - fmt.Printf("%s\t%s\n", certPath, keyPath) -} - -// ensureCert returns the cached cert/key paths under dir, minting and persisting -// a fresh CA-signed pair when none is present, readable, and still SAN-current. -func ensureCert(dir, caCertPath, caKeyPath, cn string, dns []string, ips []net.IP) (string, string, error) { - certPath := filepath.Join(dir, "tls.crt") - keyPath := filepath.Join(dir, "tls.key") - - if certCoversSANs(certPath, keyPath, dns, ips) { - return certPath, keyPath, nil - } - - caCert, caKey, err := loadCA(caCertPath, caKeyPath) - if err != nil { - return "", "", err - } - certPEM, keyPEM, err := issueLeaf(caCert, caKey, cn, dns, ips) - if err != nil { - return "", "", err - } - if err = os.MkdirAll(dir, 0o700); err != nil { - return "", "", fmt.Errorf("mkdir %s: %w", dir, err) - } - if err = os.WriteFile(certPath, certPEM, 0o644); err != nil { - return "", "", fmt.Errorf("write %s: %w", certPath, err) - } - if err = os.WriteFile(keyPath, keyPEM, 0o600); err != nil { - return "", "", fmt.Errorf("write %s: %w", keyPath, err) - } - return certPath, keyPath, nil -} - -// certCoversSANs reports whether the cached cert exists, parses, has a key, and -// already carries every requested SAN — so a cert minted before the endpoint -// set changed is re-issued rather than served stale. -func certCoversSANs(certPath, keyPath string, dns []string, ips []net.IP) bool { - c, err := os.ReadFile(certPath) - if err != nil { - return false - } - if k, kerr := os.ReadFile(keyPath); kerr != nil || len(k) == 0 { - return false - } - der := decodeFirst(c) - if der == nil { - return false - } - crt, err := x509.ParseCertificate(der) - if err != nil { - return false - } - have := make(map[string]struct{}, len(crt.DNSNames)+len(crt.IPAddresses)) - for _, n := range crt.DNSNames { - have[n] = struct{}{} - } - for _, ip := range crt.IPAddresses { - have[ip.String()] = struct{}{} - } - for _, n := range dns { - if _, ok := have[n]; !ok { - return false - } - } - for _, ip := range ips { - if _, ok := have[ip.String()]; !ok { - return false - } - } - return true -} - -// loadCA parses the PEM CA cert + EC/PKCS8 private key used to sign the leaf. -func loadCA(certPath, keyPath string) (*x509.Certificate, *ecdsa.PrivateKey, error) { - certPEM, err := os.ReadFile(certPath) - if err != nil { - return nil, nil, fmt.Errorf("read ca cert %s: %w", certPath, err) - } - der := decodeFirst(certPEM) - if der == nil { - return nil, nil, fmt.Errorf("ca cert %s: no PEM block", certPath) - } - caCert, err := x509.ParseCertificate(der) - if err != nil { - return nil, nil, fmt.Errorf("parse ca cert: %w", err) - } - - keyPEM, err := os.ReadFile(keyPath) - if err != nil { - return nil, nil, fmt.Errorf("read ca key %s: %w", keyPath, err) - } - keyDER := decodeFirst(keyPEM) - if keyDER == nil { - return nil, nil, fmt.Errorf("ca key %s: no PEM block", keyPath) - } - caKey, err := parseECKey(keyDER) - if err != nil { - return nil, nil, err - } - return caCert, caKey, nil -} - -// parseECKey accepts the EC SEC1 or PKCS8 encodings K3s may write for the CA key. -func parseECKey(der []byte) (*ecdsa.PrivateKey, error) { - if k, err := x509.ParseECPrivateKey(der); err == nil { - return k, nil - } - k8, err := x509.ParsePKCS8PrivateKey(der) - if err != nil { - return nil, fmt.Errorf("parse ca key (sec1 and pkcs8 failed): %w", err) - } - ec, ok := k8.(*ecdsa.PrivateKey) - if !ok { - return nil, fmt.Errorf("ca key is %T, want ecdsa", k8) - } - return ec, nil -} - -// issueLeaf mints an ECDSA-P256 ServerAuth leaf for cn + SANs, signed by caCert/caKey. -func issueLeaf(caCert *x509.Certificate, caKey *ecdsa.PrivateKey, cn string, dns []string, ips []net.IP) (certPEM, keyPEM []byte, err error) { - priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - if err != nil { - return nil, nil, fmt.Errorf("generate key: %w", err) - } - serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) - if err != nil { - return nil, nil, fmt.Errorf("serial: %w", err) - } - now := time.Now().UTC() - tmpl := x509.Certificate{ - SerialNumber: serial, - Subject: pkix.Name{CommonName: cn}, - NotBefore: now.Add(-time.Hour), - NotAfter: now.AddDate(10, 0, 0), - KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, - ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, - BasicConstraintsValid: true, - DNSNames: dns, - IPAddresses: ips, - } - der, err := x509.CreateCertificate(rand.Reader, &tmpl, caCert, &priv.PublicKey, caKey) - if err != nil { - return nil, nil, fmt.Errorf("create cert: %w", err) - } - keyDER, err := x509.MarshalECPrivateKey(priv) - if err != nil { - return nil, nil, fmt.Errorf("marshal key: %w", err) - } - certPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) - keyPEM = pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}) - return certPEM, keyPEM, nil -} - -// splitSANs partitions the CSV into DNS names and IPs (an entry that parses as an -// IP goes to IPAddresses, else DNSNames), matching how SAN matching is typed. -func splitSANs(s string) (dns []string, ips []net.IP) { - for p := range strings.SplitSeq(s, ",") { - p = strings.TrimSpace(p) - if p == "" { - continue - } - if ip := net.ParseIP(p); ip != nil { - ips = append(ips, ip) - continue - } - dns = append(dns, p) - } - return dns, ips -} - -// decodeFirst returns the DER bytes of the first PEM block, or nil. -func decodeFirst(pemBytes []byte) []byte { - blk, _ := pem.Decode(pemBytes) - if blk == nil { - return nil - } - return blk.Bytes -} - -func fatal(msg string) { - slog.Error(msg) - os.Exit(1) } diff --git a/spinifex/agents/eks/konnectivitycert/cert.go b/spinifex/agents/eks/konnectivitycert/cert.go new file mode 100644 index 000000000..8b7ace8fa --- /dev/null +++ b/spinifex/agents/eks/konnectivitycert/cert.go @@ -0,0 +1,243 @@ +// Package konnectivitycert implements eks-konnectivity-cert, which mints, then +// reuses, an ECDSA-P256 serving certificate for the on-node konnectivity-server, +// signed by the K3s cluster CA. +// +// The konnectivity-agent (DaemonSet on workers) verifies the server it dials +// using the in-pod ServiceAccount CA (/var/run/secrets/.../ca.crt), which in +// K3s is the same server-ca that signs the apiserver. Signing the konnectivity +// serving cert with that CA is therefore what lets the agent validate the +// reverse-tunnel endpoint with no separate CA distribution. +// +// The eks-node AMI carries no openssl, so this is the Go equivalent: load the +// K3s server CA cert+key, issue a leaf (ServerAuth) cert carrying the cluster's +// konnectivity SANs (the NLB private-endpoint IP, public endpoint IP, NLB DNS), +// and cache it under -dir so the OpenRC service is byte-stable across restarts. +// +// Writes /tls.crt and /tls.key; prints the two paths (tab-separated). +package konnectivitycert + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "errors" + "fmt" + "io" + "math/big" + "net" + "os" + "path/filepath" + "strings" + "time" +) + +// Config names the signing CA, the leaf's identity and where it is cached. +type Config struct { + Dir string + CACert string + CAKey string + CN string + // SANs is the comma-separated SAN list (IP or DNS). + SANs string + // Out receives the tab-separated cert and key paths. + Out io.Writer +} + +// Run mints or reuses the CA-signed certificate cached under cfg.Dir and writes +// its cert and key paths to cfg.Out. +func Run(_ context.Context, cfg Config) error { + if cfg.Dir == "" || cfg.CACert == "" || cfg.CAKey == "" || cfg.SANs == "" { + return errors.New("-dir, -ca-cert, -ca-key and -sans are required") + } + dns, ips := splitSANs(cfg.SANs) + if len(dns) == 0 && len(ips) == 0 { + return errors.New("-sans lists no usable names") + } + + certPath, keyPath, err := ensureCert(cfg.Dir, cfg.CACert, cfg.CAKey, cfg.CN, dns, ips) + if err != nil { + return err + } + fmt.Fprintf(cfg.Out, "%s\t%s\n", certPath, keyPath) + return nil +} + +// ensureCert returns the cached cert/key paths under dir, minting and persisting +// a fresh CA-signed pair when none is present, readable, and still SAN-current. +func ensureCert(dir, caCertPath, caKeyPath, cn string, dns []string, ips []net.IP) (string, string, error) { + certPath := filepath.Join(dir, "tls.crt") + keyPath := filepath.Join(dir, "tls.key") + + if certCoversSANs(certPath, keyPath, dns, ips) { + return certPath, keyPath, nil + } + + caCert, caKey, err := loadCA(caCertPath, caKeyPath) + if err != nil { + return "", "", err + } + certPEM, keyPEM, err := issueLeaf(caCert, caKey, cn, dns, ips) + if err != nil { + return "", "", err + } + if err = os.MkdirAll(dir, 0o700); err != nil { + return "", "", fmt.Errorf("mkdir %s: %w", dir, err) + } + if err = os.WriteFile(certPath, certPEM, 0o644); err != nil { //nolint:gosec // G306: public certificate; the private key is written 0600 below. + return "", "", fmt.Errorf("write %s: %w", certPath, err) + } + if err = os.WriteFile(keyPath, keyPEM, 0o600); err != nil { + return "", "", fmt.Errorf("write %s: %w", keyPath, err) + } + return certPath, keyPath, nil +} + +// certCoversSANs reports whether the cached cert exists, parses, has a key, and +// already carries every requested SAN — so a cert minted before the endpoint +// set changed is re-issued rather than served stale. +func certCoversSANs(certPath, keyPath string, dns []string, ips []net.IP) bool { + c, err := os.ReadFile(certPath) + if err != nil { + return false + } + if k, kerr := os.ReadFile(keyPath); kerr != nil || len(k) == 0 { + return false + } + der := decodeFirst(c) + if der == nil { + return false + } + crt, err := x509.ParseCertificate(der) + if err != nil { + return false + } + have := make(map[string]struct{}, len(crt.DNSNames)+len(crt.IPAddresses)) + for _, n := range crt.DNSNames { + have[n] = struct{}{} + } + for _, ip := range crt.IPAddresses { + have[ip.String()] = struct{}{} + } + for _, n := range dns { + if _, ok := have[n]; !ok { + return false + } + } + for _, ip := range ips { + if _, ok := have[ip.String()]; !ok { + return false + } + } + return true +} + +// loadCA parses the PEM CA cert + EC/PKCS8 private key used to sign the leaf. +func loadCA(certPath, keyPath string) (*x509.Certificate, *ecdsa.PrivateKey, error) { + certPEM, err := os.ReadFile(certPath) + if err != nil { + return nil, nil, fmt.Errorf("read ca cert %s: %w", certPath, err) + } + der := decodeFirst(certPEM) + if der == nil { + return nil, nil, fmt.Errorf("ca cert %s: no PEM block", certPath) + } + caCert, err := x509.ParseCertificate(der) + if err != nil { + return nil, nil, fmt.Errorf("parse ca cert: %w", err) + } + + keyPEM, err := os.ReadFile(keyPath) + if err != nil { + return nil, nil, fmt.Errorf("read ca key %s: %w", keyPath, err) + } + keyDER := decodeFirst(keyPEM) + if keyDER == nil { + return nil, nil, fmt.Errorf("ca key %s: no PEM block", keyPath) + } + caKey, err := parseECKey(keyDER) + if err != nil { + return nil, nil, err + } + return caCert, caKey, nil +} + +// parseECKey accepts the EC SEC1 or PKCS8 encodings K3s may write for the CA key. +func parseECKey(der []byte) (*ecdsa.PrivateKey, error) { + if k, err := x509.ParseECPrivateKey(der); err == nil { + return k, nil + } + k8, err := x509.ParsePKCS8PrivateKey(der) + if err != nil { + return nil, fmt.Errorf("parse ca key (sec1 and pkcs8 failed): %w", err) + } + ec, ok := k8.(*ecdsa.PrivateKey) + if !ok { + return nil, fmt.Errorf("ca key is %T, want ecdsa", k8) + } + return ec, nil +} + +// issueLeaf mints an ECDSA-P256 ServerAuth leaf for cn + SANs, signed by caCert/caKey. +func issueLeaf(caCert *x509.Certificate, caKey *ecdsa.PrivateKey, cn string, dns []string, ips []net.IP) (certPEM, keyPEM []byte, err error) { + priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return nil, nil, fmt.Errorf("generate key: %w", err) + } + serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) + if err != nil { + return nil, nil, fmt.Errorf("serial: %w", err) + } + now := time.Now().UTC() + tmpl := x509.Certificate{ + SerialNumber: serial, + Subject: pkix.Name{CommonName: cn}, + NotBefore: now.Add(-time.Hour), + NotAfter: now.AddDate(10, 0, 0), + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + BasicConstraintsValid: true, + DNSNames: dns, + IPAddresses: ips, + } + der, err := x509.CreateCertificate(rand.Reader, &tmpl, caCert, &priv.PublicKey, caKey) + if err != nil { + return nil, nil, fmt.Errorf("create cert: %w", err) + } + keyDER, err := x509.MarshalECPrivateKey(priv) + if err != nil { + return nil, nil, fmt.Errorf("marshal key: %w", err) + } + certPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) + keyPEM = pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}) + return certPEM, keyPEM, nil +} + +// splitSANs partitions the CSV into DNS names and IPs (an entry that parses as an +// IP goes to IPAddresses, else DNSNames), matching how SAN matching is typed. +func splitSANs(s string) (dns []string, ips []net.IP) { + for p := range strings.SplitSeq(s, ",") { + p = strings.TrimSpace(p) + if p == "" { + continue + } + if ip := net.ParseIP(p); ip != nil { + ips = append(ips, ip) + continue + } + dns = append(dns, p) + } + return dns, ips +} + +// decodeFirst returns the DER bytes of the first PEM block, or nil. +func decodeFirst(pemBytes []byte) []byte { + blk, _ := pem.Decode(pemBytes) + if blk == nil { + return nil + } + return blk.Bytes +} diff --git a/cmd/eks-konnectivity-cert/main_test.go b/spinifex/agents/eks/konnectivitycert/cert_test.go similarity index 99% rename from cmd/eks-konnectivity-cert/main_test.go rename to spinifex/agents/eks/konnectivitycert/cert_test.go index 27a185130..8e32ff44f 100644 --- a/cmd/eks-konnectivity-cert/main_test.go +++ b/spinifex/agents/eks/konnectivitycert/cert_test.go @@ -1,4 +1,4 @@ -package main +package konnectivitycert import ( "crypto/ecdsa" diff --git a/spinifex/agents/eks/konnectivitycert/run_test.go b/spinifex/agents/eks/konnectivitycert/run_test.go new file mode 100644 index 000000000..d9f1dcc45 --- /dev/null +++ b/spinifex/agents/eks/konnectivitycert/run_test.go @@ -0,0 +1,78 @@ +package konnectivitycert + +import ( + "bytes" + "context" + "path/filepath" + "strings" + "testing" +) + +func TestRunRejectsIncompleteConfig(t *testing.T) { + dir := t.TempDir() + full := Config{Dir: dir, CACert: "ca.crt", CAKey: "ca.key", CN: "konnectivity-server", SANs: "10.0.0.1"} + cases := []struct { + name string + mutate func(*Config) + want string + }{ + {"no dir", func(c *Config) { c.Dir = "" }, "-dir, -ca-cert, -ca-key and -sans are required"}, + {"no ca cert", func(c *Config) { c.CACert = "" }, "-dir, -ca-cert, -ca-key and -sans are required"}, + {"no ca key", func(c *Config) { c.CAKey = "" }, "-dir, -ca-cert, -ca-key and -sans are required"}, + {"no sans", func(c *Config) { c.SANs = "" }, "-dir, -ca-cert, -ca-key and -sans are required"}, + {"blank sans list", func(c *Config) { c.SANs = " , ," }, "-sans lists no usable names"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + cfg := full + tc.mutate(&cfg) + var out bytes.Buffer + cfg.Out = &out + err := Run(context.Background(), cfg) + if err == nil || err.Error() != tc.want { + t.Fatalf("Run error = %v, want %q", err, tc.want) + } + if out.Len() != 0 { + t.Errorf("Run wrote %q on failure", out.String()) + } + }) + } +} + +func TestRunReportsUnreadableCA(t *testing.T) { + dir := t.TempDir() + missing := filepath.Join(dir, "absent.crt") + var out bytes.Buffer + err := Run(context.Background(), Config{ + Dir: filepath.Join(dir, "leaf"), CACert: missing, CAKey: missing, SANs: "10.0.0.1", Out: &out, + }) + if err == nil || !strings.HasPrefix(err.Error(), "read ca cert "+missing+": ") { + t.Fatalf("Run error = %v, want read ca cert failure", err) + } + if out.Len() != 0 { + t.Errorf("Run wrote %q on failure", out.String()) + } +} + +func TestRunPrintsCachedCertAndKeyPaths(t *testing.T) { + caCert, caKey := writeTestCA(t, t.TempDir()) + dir := filepath.Join(t.TempDir(), "leaf") + var out bytes.Buffer + err := Run(context.Background(), Config{ + Dir: dir, CACert: caCert, CAKey: caKey, CN: "konnectivity-server", SANs: "10.32.100.4,eks.example", Out: &out, + }) + if err != nil { + t.Fatalf("Run: %v", err) + } + want := filepath.Join(dir, "tls.crt") + "\t" + filepath.Join(dir, "tls.key") + "\n" + if out.String() != want { + t.Fatalf("output = %q, want %q", out.String(), want) + } + leaf := leafFrom(t, filepath.Join(dir, "tls.crt")) + if err := leaf.VerifyHostname("10.32.100.4"); err != nil { + t.Errorf("IP SAN from -sans missing: %v", err) + } + if err := leaf.VerifyHostname("eks.example"); err != nil { + t.Errorf("DNS SAN from -sans missing: %v", err) + } +}