From b2b52c05c3c59ed09177efb44480f034517c0d8f Mon Sep 17 00:00:00 2001 From: Kyle Isom Date: Sun, 29 Mar 2026 18:42:39 -0700 Subject: [PATCH] Add cert command for Metacrypt TLS provisioning Checks the configured TLS certificate: provisions a new one if missing, renews if expiring within 7 days, otherwise reports remaining validity. Calls the Metacrypt CA API directly. Co-Authored-By: Claude Opus 4.6 (1M context) --- cmd/mcns/cert.go | 221 +++++++++++++++++++++++++++++++++++++++++++++++ cmd/mcns/main.go | 1 + 2 files changed, 222 insertions(+) create mode 100644 cmd/mcns/cert.go diff --git a/cmd/mcns/cert.go b/cmd/mcns/cert.go new file mode 100644 index 0000000..598a42f --- /dev/null +++ b/cmd/mcns/cert.go @@ -0,0 +1,221 @@ +package main + +import ( + "bytes" + "crypto/tls" + "crypto/x509" + "encoding/json" + "encoding/pem" + "fmt" + "io" + "net/http" + "os" + "strings" + "time" + + "github.com/spf13/cobra" + + "git.wntrmute.dev/mc/mcns/internal/config" +) + +func certCmd() *cobra.Command { + var ( + configPath string + serverURL string + caCert string + tokenPath string + mount string + issuer string + hostnames []string + ) + + cmd := &cobra.Command{ + Use: "cert", + Short: "Ensure a valid TLS certificate from Metacrypt", + Long: `Check the TLS certificate referenced in the config file. +If the certificate does not exist, provision a new one from Metacrypt. +If it exists but expires within 7 days, renew it. +Otherwise, report that the certificate is still valid.`, + RunE: func(_ *cobra.Command, _ []string) error { + cfg, err := config.Load(configPath) + if err != nil { + return fmt.Errorf("load config: %w", err) + } + + if serverURL == "" { + return fmt.Errorf("--server is required") + } + if tokenPath == "" { + return fmt.Errorf("--token is required") + } + if len(hostnames) == 0 { + return fmt.Errorf("--hostname is required") + } + + // Check existing certificate. + remaining, ok := certTimeRemaining(cfg.Server.TLSCert) + if ok && remaining > 7*24*time.Hour { + fmt.Printf("Certificate valid for %s, no action needed\n", remaining.Round(time.Hour)) + return nil + } + + if ok { + fmt.Printf("Certificate expires in %s, renewing\n", remaining.Round(time.Hour)) + } else { + fmt.Println("No valid certificate found, provisioning") + } + + token, err := os.ReadFile(tokenPath) //nolint:gosec // operator-supplied path + if err != nil { + return fmt.Errorf("read token: %w", err) + } + + httpClient, err := metacryptClient(caCert) + if err != nil { + return err + } + + certPEM, keyPEM, err := issueCert(httpClient, strings.TrimRight(serverURL, "/"), strings.TrimSpace(string(token)), mount, issuer, hostnames) + if err != nil { + return err + } + + if err := atomicWrite(cfg.Server.TLSCert, []byte(certPEM), 0644); err != nil { + return fmt.Errorf("write cert: %w", err) + } + if err := atomicWrite(cfg.Server.TLSKey, []byte(keyPEM), 0600); err != nil { + return fmt.Errorf("write key: %w", err) + } + + fmt.Printf("Certificate written to %s\n", cfg.Server.TLSCert) + fmt.Printf("Key written to %s\n", cfg.Server.TLSKey) + return nil + }, + } + + cmd.Flags().StringVarP(&configPath, "config", "c", "mcns.toml", "path to configuration file") + cmd.Flags().StringVar(&serverURL, "server", "", "Metacrypt server URL") + cmd.Flags().StringVar(&caCert, "ca-cert", "", "CA certificate for Metacrypt TLS") + cmd.Flags().StringVar(&tokenPath, "token", "", "path to MCIAS token file") + cmd.Flags().StringVar(&mount, "mount", "pki", "CA engine mount name") + cmd.Flags().StringVar(&issuer, "issuer", "infra", "CA issuer name") + cmd.Flags().StringSliceVar(&hostnames, "hostname", nil, "SAN hostnames (repeatable, first is CN)") + + return cmd +} + +func issueCert(client *http.Client, serverURL, token, mount, issuer string, hostnames []string) (chainPEM, keyPEM string, err error) { + reqBody := map[string]any{ + "mount": mount, + "operation": "issue", + "data": map[string]any{ + "issuer": issuer, + "common_name": hostnames[0], + "dns_names": hostnames, + "profile": "server", + "ttl": "2160h", + }, + } + + body, err := json.Marshal(reqBody) + if err != nil { + return "", "", fmt.Errorf("marshal request: %w", err) + } + + req, err := http.NewRequest(http.MethodPost, serverURL+"/v1/engine/request", bytes.NewReader(body)) + if err != nil { + return "", "", fmt.Errorf("create request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+token) + + resp, err := client.Do(req) + if err != nil { + return "", "", fmt.Errorf("issue cert: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return "", "", fmt.Errorf("read response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return "", "", fmt.Errorf("metacrypt returned %d: %s", resp.StatusCode, string(respBody)) + } + + var result struct { + ChainPEM string `json:"chain_pem"` + KeyPEM string `json:"key_pem"` + Serial string `json:"serial"` + ExpiresAt string `json:"expires_at"` + } + if err := json.Unmarshal(respBody, &result); err != nil { + return "", "", fmt.Errorf("parse response: %w", err) + } + + if result.ChainPEM == "" || result.KeyPEM == "" { + return "", "", fmt.Errorf("response missing chain_pem or key_pem") + } + + fmt.Printf("Issued certificate serial=%s expires=%s\n", result.Serial, result.ExpiresAt) + return result.ChainPEM, result.KeyPEM, nil +} + +func metacryptClient(caCertPath string) (*http.Client, error) { + tlsCfg := &tls.Config{MinVersion: tls.VersionTLS13} + + if caCertPath != "" { + pemData, err := os.ReadFile(caCertPath) //nolint:gosec // operator-supplied path + if err != nil { + return nil, fmt.Errorf("read CA cert: %w", err) + } + pool := x509.NewCertPool() + if !pool.AppendCertsFromPEM(pemData) { + return nil, fmt.Errorf("no valid certificates in %s", caCertPath) + } + tlsCfg.RootCAs = pool + } + + return &http.Client{ + Timeout: 30 * time.Second, + Transport: &http.Transport{TLSClientConfig: tlsCfg}, + }, nil +} + +// certTimeRemaining returns the time until the leaf certificate at +// path expires. Returns (0, false) if the cert cannot be read or parsed. +func certTimeRemaining(path string) (time.Duration, bool) { + data, err := os.ReadFile(path) //nolint:gosec // operator-supplied path + if err != nil { + return 0, false + } + + block, _ := pem.Decode(data) + if block == nil { + return 0, false + } + + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return 0, false + } + + remaining := time.Until(cert.NotAfter) + if remaining <= 0 { + return 0, true // expired + } + return remaining, true +} + +func atomicWrite(path string, data []byte, perm os.FileMode) error { + tmp := path + ".tmp" + if err := os.WriteFile(tmp, data, perm); err != nil { + return fmt.Errorf("write %s: %w", tmp, err) + } + if err := os.Rename(tmp, path); err != nil { + _ = os.Remove(tmp) + return fmt.Errorf("rename %s -> %s: %w", tmp, path, err) + } + return nil +} diff --git a/cmd/mcns/main.go b/cmd/mcns/main.go index 14d5125..1d3fd85 100644 --- a/cmd/mcns/main.go +++ b/cmd/mcns/main.go @@ -38,6 +38,7 @@ func main() { root.AddCommand(serverCmd()) root.AddCommand(statusCmd()) root.AddCommand(snapshotCmd()) + root.AddCommand(certCmd()) if err := root.Execute(); err != nil { os.Exit(1)