1 Commits

Author SHA1 Message Date
b2b52c05c3 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) <noreply@anthropic.com>
2026-03-29 18:42:39 -07:00
2 changed files with 222 additions and 0 deletions

221
cmd/mcns/cert.go Normal file
View File

@@ -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
}

View File

@@ -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)