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>
This commit is contained in:
221
cmd/mcns/cert.go
Normal file
221
cmd/mcns/cert.go
Normal 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
|
||||||
|
}
|
||||||
@@ -38,6 +38,7 @@ func main() {
|
|||||||
root.AddCommand(serverCmd())
|
root.AddCommand(serverCmd())
|
||||||
root.AddCommand(statusCmd())
|
root.AddCommand(statusCmd())
|
||||||
root.AddCommand(snapshotCmd())
|
root.AddCommand(snapshotCmd())
|
||||||
|
root.AddCommand(certCmd())
|
||||||
|
|
||||||
if err := root.Execute(); err != nil {
|
if err := root.Execute(); err != nil {
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
|
|||||||
Reference in New Issue
Block a user