From d064924e69d5dc2b37837df09587e946d74ce7ab Mon Sep 17 00:00:00 2001 From: joshsiv-mulga Date: Wed, 7 Oct 2026 11:24:58 +1000 Subject: [PATCH] refactor: move EKS webhook cert helper to the EKS agent tree cmd/eks-webhook-cert keeps flag parsing, the log prefix and the exit; agents/eks/webhookcert owns Config and Run. Output, error text, file modes and exit codes are unchanged. --- cmd/eks-webhook-cert/main.go | 140 ++--------------- spinifex/agents/eks/webhookcert/cert.go | 147 ++++++++++++++++++ .../agents/eks/webhookcert/cert_test.go | 2 +- spinifex/agents/eks/webhookcert/run_test.go | 95 +++++++++++ 4 files changed, 256 insertions(+), 128 deletions(-) create mode 100644 spinifex/agents/eks/webhookcert/cert.go rename cmd/eks-webhook-cert/main_test.go => spinifex/agents/eks/webhookcert/cert_test.go (99%) create mode 100644 spinifex/agents/eks/webhookcert/run_test.go diff --git a/cmd/eks-webhook-cert/main.go b/cmd/eks-webhook-cert/main.go index 29cb21aa6..328a340e8 100644 --- a/cmd/eks-webhook-cert/main.go +++ b/cmd/eks-webhook-cert/main.go @@ -1,12 +1,6 @@ -// Command eks-webhook-cert mints, then reuses, a self-signed ECDSA-P256 serving -// certificate for an in-cluster admission webhook and prints its base64 PEM so -// the addon renderer can embed it as the webhook Secret + each caBundle. -// -// The eks-node AMI carries no openssl, so this is the Go equivalent of the -// upstream Helm chart's install-time genSelfSignedCert: one cert per cluster, -// cached on disk under -dir, stable across addon-sync ticks. The single cert is -// its own CA (IsCA + ServerAuth), so the same PEM serves as both the TLS leaf -// and the caBundle the apiserver trusts. +// Command eks-webhook-cert mints, then reuses, a self-signed serving certificate +// for an in-cluster admission webhook and prints its base64 PEM; the +// implementation is in agents/eks/webhookcert. // // Usage: // @@ -17,133 +11,25 @@ package main import ( - "crypto/ecdsa" - "crypto/elliptic" - "crypto/rand" - "crypto/x509" - "crypto/x509/pkix" - "encoding/base64" - "encoding/pem" + "context" "flag" - "fmt" "log/slog" - "math/big" "os" - "path/filepath" - "strings" - "time" _ "github.com/mulgadc/bluebottle/pkg/fipsboot" + + "github.com/mulgadc/spinifex/spinifex/agents/eks/webhookcert" ) func main() { - dir := flag.String("dir", "", "directory to cache tls.crt/tls.key in") - cn := flag.String("cn", "", "certificate common name") - dnsCSV := flag.String("dns", "", "comma-separated DNS SANs") + cfg := webhookcert.Config{Out: os.Stdout} + flag.StringVar(&cfg.Dir, "dir", "", "directory to cache tls.crt/tls.key in") + flag.StringVar(&cfg.CN, "cn", "", "certificate common name") + flag.StringVar(&cfg.DNS, "dns", "", "comma-separated DNS SANs") flag.Parse() - if *dir == "" || *cn == "" || *dnsCSV == "" { - fatal("eks-webhook-cert: -dir, -cn and -dns are required") + if err := webhookcert.Run(context.Background(), cfg); err != nil { + slog.Error("eks-webhook-cert: " + err.Error()) + os.Exit(1) } - dnsNames := splitCSV(*dnsCSV) - if len(dnsNames) == 0 { - fatal("eks-webhook-cert: -dns lists no names") - } - - certPEM, keyPEM, err := ensureCert(*dir, *cn, dnsNames) - if err != nil { - fatal("eks-webhook-cert: " + err.Error()) - } - b := base64.StdEncoding.EncodeToString - fmt.Printf("%s\t%s\t%s\n", b(certPEM), b(certPEM), b(keyPEM)) -} - -// ensureCert returns the cached cert/key under dir, minting and persisting a -// fresh self-signed pair (for cn + dnsNames) when none is present or readable. -func ensureCert(dir, cn string, dnsNames []string) (certPEM, keyPEM []byte, err error) { - certPath := filepath.Join(dir, "tls.crt") - keyPath := filepath.Join(dir, "tls.key") - - if c, cerr := os.ReadFile(certPath); cerr == nil { - if k, kerr := os.ReadFile(keyPath); kerr == nil { - if _, perr := x509.ParseCertificate(decodeFirst(c)); perr == nil && len(k) > 0 { - return c, k, nil - } - } - } - - certPEM, keyPEM, err = generateSelfSigned(cn, dnsNames) - if err != nil { - return nil, nil, err - } - if err = os.MkdirAll(dir, 0o700); err != nil { - return nil, nil, fmt.Errorf("mkdir %s: %w", dir, err) - } - if err = os.WriteFile(certPath, certPEM, 0o644); err != nil { - return nil, nil, fmt.Errorf("write %s: %w", certPath, err) - } - if err = os.WriteFile(keyPath, keyPEM, 0o600); err != nil { - return nil, nil, fmt.Errorf("write %s: %w", keyPath, err) - } - return certPEM, keyPEM, nil -} - -// generateSelfSigned mints an ECDSA-P256 self-signed cert valid as both a CA and -// a server cert, with the given common name and DNS SANs. -func generateSelfSigned(cn string, dnsNames []string) (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 | x509.KeyUsageCertSign, - ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, - BasicConstraintsValid: true, - IsCA: true, - DNSNames: dnsNames, - } - der, err := x509.CreateCertificate(rand.Reader, &tmpl, &tmpl, &priv.PublicKey, priv) - 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 -} - -// 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 splitCSV(s string) []string { - var out []string - for p := range strings.SplitSeq(s, ",") { - if p = strings.TrimSpace(p); p != "" { - out = append(out, p) - } - } - return out -} - -func fatal(msg string) { - slog.Error(msg) - os.Exit(1) } diff --git a/spinifex/agents/eks/webhookcert/cert.go b/spinifex/agents/eks/webhookcert/cert.go new file mode 100644 index 000000000..8b215937e --- /dev/null +++ b/spinifex/agents/eks/webhookcert/cert.go @@ -0,0 +1,147 @@ +// Package webhookcert implements eks-webhook-cert, which mints, then reuses, a +// self-signed ECDSA-P256 serving certificate for an in-cluster admission webhook +// and prints its base64 PEM so the addon renderer can embed it as the webhook +// Secret + each caBundle. +// +// The eks-node AMI carries no openssl, so this is the Go equivalent of the +// upstream Helm chart's install-time genSelfSignedCert: one cert per cluster, +// cached on disk under -dir, stable across addon-sync ticks. The single cert is +// its own CA (IsCA + ServerAuth), so the same PEM serves as both the TLS leaf +// and the caBundle the apiserver trusts. +// +// Output (one line): \t\t +package webhookcert + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/base64" + "encoding/pem" + "errors" + "fmt" + "io" + "math/big" + "os" + "path/filepath" + "strings" + "time" +) + +// Config names the webhook certificate and where it is cached. +type Config struct { + Dir string + CN string + // DNS is the comma-separated DNS SAN list. + DNS string + // Out receives the base64 PEM line. + Out io.Writer +} + +// Run mints or reuses the certificate cached under cfg.Dir and writes +// \t\t to cfg.Out. +func Run(_ context.Context, cfg Config) error { + if cfg.Dir == "" || cfg.CN == "" || cfg.DNS == "" { + return errors.New("-dir, -cn and -dns are required") + } + dnsNames := splitCSV(cfg.DNS) + if len(dnsNames) == 0 { + return errors.New("-dns lists no names") + } + + certPEM, keyPEM, err := ensureCert(cfg.Dir, cfg.CN, dnsNames) + if err != nil { + return err + } + b := base64.StdEncoding.EncodeToString + fmt.Fprintf(cfg.Out, "%s\t%s\t%s\n", b(certPEM), b(certPEM), b(keyPEM)) + return nil +} + +// ensureCert returns the cached cert/key under dir, minting and persisting a +// fresh self-signed pair (for cn + dnsNames) when none is present or readable. +func ensureCert(dir, cn string, dnsNames []string) (certPEM, keyPEM []byte, err error) { + certPath := filepath.Join(dir, "tls.crt") + keyPath := filepath.Join(dir, "tls.key") + + if c, cerr := os.ReadFile(certPath); cerr == nil { + if k, kerr := os.ReadFile(keyPath); kerr == nil { + if _, perr := x509.ParseCertificate(decodeFirst(c)); perr == nil && len(k) > 0 { + return c, k, nil + } + } + } + + certPEM, keyPEM, err = generateSelfSigned(cn, dnsNames) + if err != nil { + return nil, nil, err + } + if err = os.MkdirAll(dir, 0o700); err != nil { + return nil, nil, 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 nil, nil, fmt.Errorf("write %s: %w", certPath, err) + } + if err = os.WriteFile(keyPath, keyPEM, 0o600); err != nil { + return nil, nil, fmt.Errorf("write %s: %w", keyPath, err) + } + return certPEM, keyPEM, nil +} + +// generateSelfSigned mints an ECDSA-P256 self-signed cert valid as both a CA and +// a server cert, with the given common name and DNS SANs. +func generateSelfSigned(cn string, dnsNames []string) (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 | x509.KeyUsageCertSign, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + BasicConstraintsValid: true, + IsCA: true, + DNSNames: dnsNames, + } + der, err := x509.CreateCertificate(rand.Reader, &tmpl, &tmpl, &priv.PublicKey, priv) + 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 +} + +// 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 splitCSV(s string) []string { + var out []string + for p := range strings.SplitSeq(s, ",") { + if p = strings.TrimSpace(p); p != "" { + out = append(out, p) + } + } + return out +} diff --git a/cmd/eks-webhook-cert/main_test.go b/spinifex/agents/eks/webhookcert/cert_test.go similarity index 99% rename from cmd/eks-webhook-cert/main_test.go rename to spinifex/agents/eks/webhookcert/cert_test.go index e05d2001f..5875fddd4 100644 --- a/cmd/eks-webhook-cert/main_test.go +++ b/spinifex/agents/eks/webhookcert/cert_test.go @@ -1,4 +1,4 @@ -package main +package webhookcert import ( "crypto/x509" diff --git a/spinifex/agents/eks/webhookcert/run_test.go b/spinifex/agents/eks/webhookcert/run_test.go new file mode 100644 index 000000000..563c47eca --- /dev/null +++ b/spinifex/agents/eks/webhookcert/run_test.go @@ -0,0 +1,95 @@ +package webhookcert + +import ( + "bytes" + "context" + "encoding/base64" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestRunRejectsIncompleteConfig(t *testing.T) { + dir := t.TempDir() + cases := []struct { + name string + cfg Config + want string + }{ + {"no dir", Config{CN: testCN, DNS: testCN}, "-dir, -cn and -dns are required"}, + {"no cn", Config{Dir: dir, DNS: testCN}, "-dir, -cn and -dns are required"}, + {"no dns", Config{Dir: dir, CN: testCN}, "-dir, -cn and -dns are required"}, + {"blank dns list", Config{Dir: dir, CN: testCN, DNS: " , ,"}, "-dns lists no names"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var out bytes.Buffer + tc.cfg.Out = &out + err := Run(context.Background(), tc.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 TestRunReportsCacheDirFailure(t *testing.T) { + blocker := filepath.Join(t.TempDir(), "file") + if err := os.WriteFile(blocker, nil, 0o600); err != nil { + t.Fatal(err) + } + dir := filepath.Join(blocker, "certs") + var out bytes.Buffer + err := Run(context.Background(), Config{Dir: dir, CN: testCN, DNS: testCN, Out: &out}) + if err == nil || !strings.HasPrefix(err.Error(), "mkdir "+dir+": ") { + t.Fatalf("Run error = %v, want mkdir failure for %s", err, dir) + } + if out.Len() != 0 { + t.Errorf("Run wrote %q on failure", out.String()) + } +} + +func TestRunPrintsCachedPairAsCABundleLeafAndKey(t *testing.T) { + dir := t.TempDir() + var out bytes.Buffer + if err := Run(context.Background(), Config{Dir: dir, CN: testCN, DNS: testCN + "," + testDNS, Out: &out}); err != nil { + t.Fatalf("Run: %v", err) + } + line, ok := strings.CutSuffix(out.String(), "\n") + if !ok || strings.Contains(line, "\n") { + t.Fatalf("output %q is not exactly one line", out.String()) + } + fields := strings.Split(line, "\t") + if len(fields) != 3 { + t.Fatalf("got %d fields, want ca, crt, key", len(fields)) + } + if fields[0] != fields[1] { + t.Error("caBundle and serving cert differ; the apiserver would not trust the webhook") + } + for i, name := range map[int]string{1: "tls.crt", 2: "tls.key"} { + onDisk, err := os.ReadFile(filepath.Join(dir, name)) + if err != nil { + t.Fatal(err) + } + if fields[i] != base64.StdEncoding.EncodeToString(onDisk) { + t.Errorf("field %d does not encode the cached %s", i, name) + } + } + leaf := parseLeaf(t, onDiskCert(t, dir)) + if err := leaf.VerifyHostname(testDNS); err != nil { + t.Errorf("SAN from -dns missing: %v", err) + } +} + +func onDiskCert(t *testing.T, dir string) []byte { + t.Helper() + b, err := os.ReadFile(filepath.Join(dir, "tls.crt")) + if err != nil { + t.Fatal(err) + } + return b +}