Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b2b52c05c3 |
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(statusCmd())
|
||||
root.AddCommand(snapshotCmd())
|
||||
root.AddCommand(certCmd())
|
||||
|
||||
if err := root.Execute(); err != nil {
|
||||
os.Exit(1)
|
||||
|
||||
Reference in New Issue
Block a user