From c7e1232f98cb8d7c390f4336da5a6b5afce14c44 Mon Sep 17 00:00:00 2001 From: Kyle Isom Date: Fri, 27 Mar 2026 13:31:11 -0700 Subject: [PATCH] Phase C: Automated TLS cert provisioning for L7 routes Add CertProvisioner that requests TLS certificates from Metacrypt's CA API during deploy. When a service has L7 routes, the agent checks for an existing cert, re-issues if missing or within 30 days of expiry, and writes chain+key to mc-proxy's cert directory before registering routes. - Add MetacryptConfig to agent config (server_url, ca_cert, mount, issuer, token_path) with defaults and env overrides - Add CertProvisioner (internal/agent/certs.go): REST client for Metacrypt IssueCert, atomic file writes, cert expiry checking - Wire into Agent struct and deploy flow (before route registration) - Add hasL7Routes/l7Hostnames helpers in deploy.go - Fix pre-existing lint issues: unreachable code in portalloc.go, gofmt in servicedef.go, gosec suppressions, golangci v2 config - Update vendored mc-proxy to fix protobuf init panic - 10 new tests, make all passes with 0 issues Co-Authored-By: Claude Opus 4.6 (1M context) --- .golangci.yaml | 27 +- go.mod | 2 + go.sum | 2 - internal/agent/agent.go | 7 + internal/agent/certs.go | 244 +++++++++++ internal/agent/certs_test.go | 392 ++++++++++++++++++ internal/agent/deploy.go | 39 ++ internal/agent/portalloc.go | 5 +- internal/config/agent.go | 47 ++- internal/config/config_test.go | 82 ++++ internal/servicedef/servicedef.go | 6 +- .../mc/mc-proxy/gen/mc_proxy/v1/admin.pb.go | 2 +- vendor/modules.txt | 3 +- 13 files changed, 832 insertions(+), 26 deletions(-) create mode 100644 internal/agent/certs.go create mode 100644 internal/agent/certs_test.go diff --git a/.golangci.yaml b/.golangci.yaml index ff150bc..fbf4dfc 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -5,6 +5,24 @@ run: tests: true linters: + exclusions: + paths: + - vendor + rules: + # In test files, suppress gosec rules that are false positives: + # G101: hardcoded test credentials + # G304: file paths from variables (t.TempDir paths) + # G306: WriteFile with 0644 (cert files need to be readable) + # G404: weak RNG (not security-relevant in tests) + - path: "_test\\.go" + linters: + - gosec + text: "G101|G304|G306|G404" + # Nil context is acceptable in tests for nil-receiver safety checks. + - path: "_test\\.go" + linters: + - staticcheck + text: "SA1012" default: none enable: - errcheck @@ -69,12 +87,3 @@ formatters: issues: max-issues-per-linter: 0 max-same-issues: 0 - - exclusions: - paths: - - vendor - rules: - - path: "_test\\.go" - linters: - - gosec - text: "G101" diff --git a/go.mod b/go.mod index f213dc0..6bacb74 100644 --- a/go.mod +++ b/go.mod @@ -27,3 +27,5 @@ require ( modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect ) + +replace git.wntrmute.dev/mc/mc-proxy => /home/kyle/src/metacircular/mc-proxy diff --git a/go.sum b/go.sum index 0109583..8716ff7 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,3 @@ -git.wntrmute.dev/mc/mc-proxy v1.1.0 h1:r8LnBuiS0OqLSuHMuRikQlIhmeNtlJV9IrIvVVTIGuw= -git.wntrmute.dev/mc/mc-proxy v1.1.0/go.mod h1:6w8smZ/DNJVBb4n5std/faye0ROLEXfk3iJY1XNc1JU= git.wntrmute.dev/mc/mcdsl v1.2.0 h1:41hep7/PNZJfN0SN/nM+rQpyF1GSZcvNNjyVG81DI7U= git.wntrmute.dev/mc/mcdsl v1.2.0/go.mod h1:lXYrAt74ZUix6rx9oVN8d2zH1YJoyp4uxPVKQ+SSxuM= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 1f981e2..2632c31 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -33,6 +33,7 @@ type Agent struct { Logger *slog.Logger PortAlloc *PortAllocator Proxy *ProxyRouter + Certs *CertProvisioner } // Run starts the agent: opens the database, sets up the gRPC server with @@ -57,6 +58,11 @@ func Run(cfg *config.AgentConfig) error { return fmt.Errorf("connect to mc-proxy: %w", err) } + certs, err := NewCertProvisioner(cfg.Metacrypt, cfg.MCProxy.CertDir, logger) + if err != nil { + return fmt.Errorf("create cert provisioner: %w", err) + } + a := &Agent{ Config: cfg, DB: db, @@ -65,6 +71,7 @@ func Run(cfg *config.AgentConfig) error { Logger: logger, PortAlloc: NewPortAllocator(), Proxy: proxy, + Certs: certs, } tlsCert, err := tls.LoadX509KeyPair(cfg.Server.TLSCert, cfg.Server.TLSKey) diff --git a/internal/agent/certs.go b/internal/agent/certs.go new file mode 100644 index 0000000..629bd1c --- /dev/null +++ b/internal/agent/certs.go @@ -0,0 +1,244 @@ +package agent + +import ( + "bytes" + "context" + "crypto/tls" + "crypto/x509" + "encoding/json" + "encoding/pem" + "fmt" + "io" + "log/slog" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "git.wntrmute.dev/mc/mcp/internal/auth" + "git.wntrmute.dev/mc/mcp/internal/config" +) + +// renewWindow is how far before expiry a cert is considered stale and +// should be re-issued. +const renewWindow = 30 * 24 * time.Hour // 30 days + +// CertProvisioner requests TLS certificates from Metacrypt's CA API +// and writes them to the mc-proxy cert directory. It is nil-safe: all +// methods are no-ops when the receiver is nil. +type CertProvisioner struct { + serverURL string + token string + mount string + issuer string + certDir string + httpClient *http.Client + logger *slog.Logger +} + +// NewCertProvisioner creates a CertProvisioner. Returns (nil, nil) if +// cfg.ServerURL is empty (cert provisioning disabled). +func NewCertProvisioner(cfg config.MetacryptConfig, certDir string, logger *slog.Logger) (*CertProvisioner, error) { + if cfg.ServerURL == "" { + logger.Info("metacrypt not configured, cert provisioning disabled") + return nil, nil + } + + token, err := auth.LoadToken(cfg.TokenPath) + if err != nil { + return nil, fmt.Errorf("load metacrypt token: %w", err) + } + + httpClient, err := newTLSClient(cfg.CACert) + if err != nil { + return nil, fmt.Errorf("create metacrypt HTTP client: %w", err) + } + + logger.Info("metacrypt cert provisioner enabled", "server", cfg.ServerURL, "mount", cfg.Mount, "issuer", cfg.Issuer) + return &CertProvisioner{ + serverURL: strings.TrimRight(cfg.ServerURL, "/"), + token: token, + mount: cfg.Mount, + issuer: cfg.Issuer, + certDir: certDir, + httpClient: httpClient, + logger: logger, + }, nil +} + +// EnsureCert checks whether a valid TLS certificate exists for the +// service. If the cert is missing or near expiry, it requests a new +// one from Metacrypt. +func (p *CertProvisioner) EnsureCert(ctx context.Context, serviceName string, hostnames []string) error { + if p == nil || len(hostnames) == 0 { + return nil + } + + certPath := filepath.Join(p.certDir, serviceName+".pem") + + if remaining, ok := certTimeRemaining(certPath); ok { + if remaining > renewWindow { + p.logger.Debug("cert valid, skipping provisioning", + "service", serviceName, + "expires_in", remaining.Round(time.Hour), + ) + return nil + } + p.logger.Info("cert near expiry, re-issuing", + "service", serviceName, + "expires_in", remaining.Round(time.Hour), + ) + } + + return p.issueCert(ctx, serviceName, hostnames[0], hostnames) +} + +// issueCert calls Metacrypt's CA API to issue a certificate and writes +// the chain and key to the cert directory. +func (p *CertProvisioner) issueCert(ctx context.Context, serviceName, commonName string, dnsNames []string) error { + p.logger.Info("provisioning TLS cert", + "service", serviceName, + "cn", commonName, + "sans", dnsNames, + ) + + reqBody := map[string]interface{}{ + "mount": p.mount, + "operation": "issue", + "data": map[string]interface{}{ + "issuer": p.issuer, + "common_name": commonName, + "dns_names": dnsNames, + "profile": "server", + "ttl": "2160h", + }, + } + + body, err := json.Marshal(reqBody) + if err != nil { + return fmt.Errorf("marshal issue request: %w", err) + } + + url := p.serverURL + "/v1/engine/request" + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("create issue request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+p.token) + + resp, err := p.httpClient.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 issue response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("issue cert: 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 issue response: %w", err) + } + + if result.ChainPEM == "" || result.KeyPEM == "" { + return fmt.Errorf("issue cert: response missing chain_pem or key_pem") + } + + // Write cert and key atomically (temp file + rename). + certPath := filepath.Join(p.certDir, serviceName+".pem") + keyPath := filepath.Join(p.certDir, serviceName+".key") + + if err := atomicWrite(certPath, []byte(result.ChainPEM), 0644); err != nil { + return fmt.Errorf("write cert: %w", err) + } + if err := atomicWrite(keyPath, []byte(result.KeyPEM), 0600); err != nil { + return fmt.Errorf("write key: %w", err) + } + + p.logger.Info("cert provisioned", + "service", serviceName, + "serial", result.Serial, + "expires_at", result.ExpiresAt, + ) + return 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 // path from trusted config + 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 +} + +// atomicWrite writes data to a temporary file then renames it to path, +// ensuring readers never see a partial file. +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 +} + +// newTLSClient creates an HTTP client with TLS 1.3 minimum. If +// caCertPath is non-empty, the CA certificate is loaded into the +// root CA pool. +func newTLSClient(caCertPath string) (*http.Client, error) { + tlsConfig := &tls.Config{ + MinVersion: tls.VersionTLS13, + } + + if caCertPath != "" { + caCert, err := os.ReadFile(caCertPath) //nolint:gosec // path from trusted config + if err != nil { + return nil, fmt.Errorf("read CA cert %q: %w", caCertPath, err) + } + pool := x509.NewCertPool() + if !pool.AppendCertsFromPEM(caCert) { + return nil, fmt.Errorf("parse CA cert %q: no valid certificates found", caCertPath) + } + tlsConfig.RootCAs = pool + } + + return &http.Client{ + Timeout: 30 * time.Second, + Transport: &http.Transport{ + TLSClientConfig: tlsConfig, + }, + }, nil +} diff --git a/internal/agent/certs_test.go b/internal/agent/certs_test.go new file mode 100644 index 0000000..4a6e81c --- /dev/null +++ b/internal/agent/certs_test.go @@ -0,0 +1,392 @@ +package agent + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/json" + "encoding/pem" + "log/slog" + "math/big" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + "time" + + "git.wntrmute.dev/mc/mcp/internal/config" + "git.wntrmute.dev/mc/mcp/internal/registry" +) + +func TestNilCertProvisionerIsNoop(t *testing.T) { + var p *CertProvisioner + if err := p.EnsureCert(context.Background(), "svc", []string{"svc.example.com"}); err != nil { + t.Fatalf("EnsureCert on nil: %v", err) + } +} + +func TestNewCertProvisionerDisabledWhenUnconfigured(t *testing.T) { + p, err := NewCertProvisioner(config.MetacryptConfig{}, "/tmp", slog.Default()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if p != nil { + t.Fatal("expected nil provisioner for empty config") + } +} + +func TestEnsureCertSkipsValidCert(t *testing.T) { + certDir := t.TempDir() + certPath := filepath.Join(certDir, "svc.pem") + keyPath := filepath.Join(certDir, "svc.key") + + // Generate a cert that expires in 90 days. + writeSelfSignedCert(t, certPath, keyPath, "svc.example.com", 90*24*time.Hour) + + // Create a provisioner that would fail if it tried to issue. + p := &CertProvisioner{ + serverURL: "https://will-fail-if-called:9999", + certDir: certDir, + logger: slog.Default(), + } + + if err := p.EnsureCert(context.Background(), "svc", []string{"svc.example.com"}); err != nil { + t.Fatalf("EnsureCert: %v", err) + } +} + +func TestEnsureCertReissuesExpiring(t *testing.T) { + certDir := t.TempDir() + certPath := filepath.Join(certDir, "svc.pem") + keyPath := filepath.Join(certDir, "svc.key") + + // Generate a cert that expires in 10 days (within 30-day renewal window). + writeSelfSignedCert(t, certPath, keyPath, "svc.example.com", 10*24*time.Hour) + + // Mock Metacrypt API. + newCert, newKey := generateCertPEM(t, "svc.example.com", 90*24*time.Hour) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + resp := map[string]string{ + "chain_pem": newCert, + "key_pem": newKey, + "serial": "abc123", + "expires_at": time.Now().Add(90 * 24 * time.Hour).Format(time.RFC3339), + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) + })) + defer srv.Close() + + p := &CertProvisioner{ + serverURL: srv.URL, + token: "test-token", + mount: "pki", + issuer: "infra", + certDir: certDir, + httpClient: srv.Client(), + logger: slog.Default(), + } + + if err := p.EnsureCert(context.Background(), "svc", []string{"svc.example.com"}); err != nil { + t.Fatalf("EnsureCert: %v", err) + } + + // Verify new cert was written. + got, err := os.ReadFile(certPath) + if err != nil { + t.Fatalf("read cert: %v", err) + } + if string(got) != newCert { + t.Fatal("cert file was not updated with new cert") + } +} + +func TestIssueCertWritesFiles(t *testing.T) { + certDir := t.TempDir() + + // Mock Metacrypt API. + certPEM, keyPEM := generateCertPEM(t, "svc.example.com", 90*24*time.Hour) + var gotAuth string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuth = r.Header.Get("Authorization") + + var req map[string]interface{} + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + + // Verify request structure. + if req["mount"] != "pki" || req["operation"] != "issue" { + t.Errorf("unexpected request: %v", req) + } + + resp := map[string]string{ + "chain_pem": certPEM, + "key_pem": keyPEM, + "serial": "deadbeef", + "expires_at": time.Now().Add(90 * 24 * time.Hour).Format(time.RFC3339), + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) + })) + defer srv.Close() + + p := &CertProvisioner{ + serverURL: srv.URL, + token: "my-service-token", + mount: "pki", + issuer: "infra", + certDir: certDir, + httpClient: srv.Client(), + logger: slog.Default(), + } + + if err := p.EnsureCert(context.Background(), "svc", []string{"svc.example.com"}); err != nil { + t.Fatalf("EnsureCert: %v", err) + } + + // Verify auth header. + if gotAuth != "Bearer my-service-token" { + t.Fatalf("auth header: got %q, want %q", gotAuth, "Bearer my-service-token") + } + + // Verify cert file. + certData, err := os.ReadFile(filepath.Join(certDir, "svc.pem")) + if err != nil { + t.Fatalf("read cert: %v", err) + } + if string(certData) != certPEM { + t.Fatal("cert content mismatch") + } + + // Verify key file. + keyData, err := os.ReadFile(filepath.Join(certDir, "svc.key")) + if err != nil { + t.Fatalf("read key: %v", err) + } + if string(keyData) != keyPEM { + t.Fatal("key content mismatch") + } + + // Verify key file permissions. + info, err := os.Stat(filepath.Join(certDir, "svc.key")) + if err != nil { + t.Fatalf("stat key: %v", err) + } + if perm := info.Mode().Perm(); perm != 0600 { + t.Fatalf("key permissions: got %o, want 0600", perm) + } +} + +func TestIssueCertAPIError(t *testing.T) { + certDir := t.TempDir() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, `{"error":"sealed"}`, http.StatusServiceUnavailable) + })) + defer srv.Close() + + p := &CertProvisioner{ + serverURL: srv.URL, + token: "test-token", + mount: "pki", + issuer: "infra", + certDir: certDir, + httpClient: srv.Client(), + logger: slog.Default(), + } + + err := p.EnsureCert(context.Background(), "svc", []string{"svc.example.com"}) + if err == nil { + t.Fatal("expected error for sealed metacrypt") + } +} + +func TestCertTimeRemaining(t *testing.T) { + t.Run("missing file", func(t *testing.T) { + if _, ok := certTimeRemaining("/nonexistent/cert.pem"); ok { + t.Fatal("expected false for missing file") + } + }) + + t.Run("valid cert", func(t *testing.T) { + certDir := t.TempDir() + path := filepath.Join(certDir, "test.pem") + writeSelfSignedCert(t, path, filepath.Join(certDir, "test.key"), "test.example.com", 90*24*time.Hour) + + remaining, ok := certTimeRemaining(path) + if !ok { + t.Fatal("expected true for valid cert") + } + // Should be close to 90 days. + if remaining < 89*24*time.Hour || remaining > 91*24*time.Hour { + t.Fatalf("remaining: got %v, want ~90 days", remaining) + } + }) + + t.Run("expired cert", func(t *testing.T) { + certDir := t.TempDir() + path := filepath.Join(certDir, "expired.pem") + // Write a cert that's already expired (valid from -2h to -1h). + writeExpiredCert(t, path, filepath.Join(certDir, "expired.key"), "expired.example.com") + + remaining, ok := certTimeRemaining(path) + if !ok { + t.Fatal("expected true for expired cert") + } + if remaining > 0 { + t.Fatalf("remaining: got %v, want <= 0", remaining) + } + }) +} + +func TestHasL7Routes(t *testing.T) { + tests := []struct { + name string + routes []registry.Route + want bool + }{ + {"nil", nil, false}, + {"empty", []registry.Route{}, false}, + {"l4 only", []registry.Route{{Mode: "l4"}}, false}, + {"l7 only", []registry.Route{{Mode: "l7"}}, true}, + {"mixed", []registry.Route{{Mode: "l4"}, {Mode: "l7"}}, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := hasL7Routes(tt.routes); got != tt.want { + t.Fatalf("hasL7Routes = %v, want %v", got, tt.want) + } + }) + } +} + +func TestL7Hostnames(t *testing.T) { + routes := []registry.Route{ + {Mode: "l7", Hostname: ""}, + {Mode: "l4", Hostname: "ignored.example.com"}, + {Mode: "l7", Hostname: "custom.example.com"}, + {Mode: "l7", Hostname: ""}, // duplicate default + } + + got := l7Hostnames("myservice", routes) + want := []string{"myservice.svc.mcp.metacircular.net", "custom.example.com"} + + if len(got) != len(want) { + t.Fatalf("got %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("got[%d] = %q, want %q", i, got[i], want[i]) + } + } +} + +func TestAtomicWrite(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "test.txt") + + if err := atomicWrite(path, []byte("hello"), 0644); err != nil { + t.Fatalf("atomicWrite: %v", err) + } + + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read: %v", err) + } + if string(data) != "hello" { + t.Fatalf("got %q, want %q", string(data), "hello") + } + + // Verify no .tmp file left behind. + if _, err := os.Stat(path + ".tmp"); !os.IsNotExist(err) { + t.Fatal("temp file should not exist after atomic write") + } +} + +// --- test helpers --- + +// writeSelfSignedCert generates a self-signed cert/key and writes them to disk. +func writeSelfSignedCert(t *testing.T, certPath, keyPath, hostname string, validity time.Duration) { + t.Helper() + certPEM, keyPEM := generateCertPEM(t, hostname, validity) + if err := os.WriteFile(certPath, []byte(certPEM), 0644); err != nil { + t.Fatalf("write cert: %v", err) + } + if err := os.WriteFile(keyPath, []byte(keyPEM), 0600); err != nil { + t.Fatalf("write key: %v", err) + } +} + +// writeExpiredCert generates a cert that is already expired. +func writeExpiredCert(t *testing.T, certPath, keyPath, hostname string) { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatalf("generate key: %v", err) + } + + tmpl := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: hostname}, + DNSNames: []string{hostname}, + NotBefore: time.Now().Add(-2 * time.Hour), + NotAfter: time.Now().Add(-1 * time.Hour), + } + + der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key) + if err != nil { + t.Fatalf("create cert: %v", err) + } + + certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) + keyDER, err := x509.MarshalECPrivateKey(key) + if err != nil { + t.Fatalf("marshal key: %v", err) + } + keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}) + + if err := os.WriteFile(certPath, certPEM, 0644); err != nil { + t.Fatalf("write cert: %v", err) + } + if err := os.WriteFile(keyPath, keyPEM, 0600); err != nil { + t.Fatalf("write key: %v", err) + } +} + +// generateCertPEM generates a self-signed cert and returns PEM strings. +func generateCertPEM(t *testing.T, hostname string, validity time.Duration) (certPEM, keyPEM string) { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatalf("generate key: %v", err) + } + + tmpl := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: hostname}, + DNSNames: []string{hostname}, + NotBefore: time.Now().Add(-1 * time.Hour), + NotAfter: time.Now().Add(validity), + } + + der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key) + if err != nil { + t.Fatalf("create cert: %v", err) + } + + certBlock := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) + keyDER, err := x509.MarshalECPrivateKey(key) + if err != nil { + t.Fatalf("marshal key: %v", err) + } + keyBlock := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}) + + return string(certBlock), string(keyBlock) +} diff --git a/internal/agent/deploy.go b/internal/agent/deploy.go index 935a1e9..468e415 100644 --- a/internal/agent/deploy.go +++ b/internal/agent/deploy.go @@ -146,6 +146,14 @@ func (a *Agent) deployComponent(ctx context.Context, serviceName string, cs *mcp } } + // Provision TLS certs for L7 routes before registering with mc-proxy. + if a.Certs != nil && hasL7Routes(regRoutes) { + hostnames := l7Hostnames(serviceName, regRoutes) + if err := a.Certs.EnsureCert(ctx, serviceName, hostnames); err != nil { + a.Logger.Warn("failed to provision TLS cert", "service", serviceName, "err", err) + } + } + // Register routes with mc-proxy after the container is running. if len(regRoutes) > 0 && a.Proxy != nil { hostPorts, err := registry.GetRouteHostPorts(a.DB, serviceName, compName) @@ -209,6 +217,37 @@ func ensureService(db *sql.DB, name string, active bool) error { return registry.UpdateServiceActive(db, name, active) } +// hasL7Routes reports whether any route uses L7 (TLS-terminating) mode. +func hasL7Routes(routes []registry.Route) bool { + for _, r := range routes { + if r.Mode == "l7" { + return true + } + } + return false +} + +// l7Hostnames returns the unique hostnames from L7 routes, applying +// the default hostname convention when a route has no explicit hostname. +func l7Hostnames(serviceName string, routes []registry.Route) []string { + seen := make(map[string]bool) + var hostnames []string + for _, r := range routes { + if r.Mode != "l7" { + continue + } + h := r.Hostname + if h == "" { + h = serviceName + ".svc.mcp.metacircular.net" + } + if !seen[h] { + seen[h] = true + hostnames = append(hostnames, h) + } + } + return hostnames +} + // ensureComponent creates the component if it does not exist, or updates its // spec if it does. func ensureComponent(db *sql.DB, c *registry.Component) error { diff --git a/internal/agent/portalloc.go b/internal/agent/portalloc.go index 1cb5855..baecc5b 100644 --- a/internal/agent/portalloc.go +++ b/internal/agent/portalloc.go @@ -33,8 +33,8 @@ func (pa *PortAllocator) Allocate() (int, error) { pa.mu.Lock() defer pa.mu.Unlock() - for i := range maxRetries { - port := portRangeMin + rand.IntN(portRangeMax-portRangeMin) + for range maxRetries { + port := portRangeMin + rand.IntN(portRangeMax-portRangeMin) //nolint:gosec // port selection, not security if pa.allocated[port] { continue } @@ -45,7 +45,6 @@ func (pa *PortAllocator) Allocate() (int, error) { pa.allocated[port] = true return port, nil - _ = i } return 0, fmt.Errorf("failed to allocate port after %d attempts", maxRetries) diff --git a/internal/config/agent.go b/internal/config/agent.go index 47f6723..e5b105c 100644 --- a/internal/config/agent.go +++ b/internal/config/agent.go @@ -10,13 +10,34 @@ import ( // AgentConfig is the configuration for the mcp-agent daemon. type AgentConfig struct { - Server ServerConfig `toml:"server"` - Database DatabaseConfig `toml:"database"` - MCIAS MCIASConfig `toml:"mcias"` - Agent AgentSettings `toml:"agent"` - MCProxy MCProxyConfig `toml:"mcproxy"` - Monitor MonitorConfig `toml:"monitor"` - Log LogConfig `toml:"log"` + Server ServerConfig `toml:"server"` + Database DatabaseConfig `toml:"database"` + MCIAS MCIASConfig `toml:"mcias"` + Agent AgentSettings `toml:"agent"` + MCProxy MCProxyConfig `toml:"mcproxy"` + Metacrypt MetacryptConfig `toml:"metacrypt"` + Monitor MonitorConfig `toml:"monitor"` + Log LogConfig `toml:"log"` +} + +// MetacryptConfig holds the Metacrypt CA integration settings for +// automated TLS cert provisioning. If ServerURL is empty, cert +// provisioning is disabled. +type MetacryptConfig struct { + // ServerURL is the Metacrypt API base URL (e.g. "https://metacrypt:8443"). + ServerURL string `toml:"server_url"` + + // CACert is the path to the CA certificate for verifying Metacrypt's TLS. + CACert string `toml:"ca_cert"` + + // Mount is the CA engine mount name. Defaults to "pki". + Mount string `toml:"mount"` + + // Issuer is the intermediate CA issuer name. Defaults to "infra". + Issuer string `toml:"issuer"` + + // TokenPath is the path to the MCIAS service token file. + TokenPath string `toml:"token_path"` } // MCProxyConfig holds the mc-proxy connection settings. @@ -150,6 +171,12 @@ func applyAgentDefaults(cfg *AgentConfig) { if cfg.MCProxy.CertDir == "" { cfg.MCProxy.CertDir = "/srv/mc-proxy/certs" } + if cfg.Metacrypt.Mount == "" { + cfg.Metacrypt.Mount = "pki" + } + if cfg.Metacrypt.Issuer == "" { + cfg.Metacrypt.Issuer = "infra" + } } func applyAgentEnvOverrides(cfg *AgentConfig) { @@ -180,6 +207,12 @@ func applyAgentEnvOverrides(cfg *AgentConfig) { if v := os.Getenv("MCP_AGENT_MCPROXY_CERT_DIR"); v != "" { cfg.MCProxy.CertDir = v } + if v := os.Getenv("MCP_AGENT_METACRYPT_SERVER_URL"); v != "" { + cfg.Metacrypt.ServerURL = v + } + if v := os.Getenv("MCP_AGENT_METACRYPT_TOKEN_PATH"); v != "" { + cfg.Metacrypt.TokenPath = v + } } func validateAgentConfig(cfg *AgentConfig) error { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 7fe2704..daa7398 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -163,6 +163,14 @@ func TestLoadAgentConfig(t *testing.T) { if cfg.Log.Level != "debug" { t.Fatalf("log.level: got %q", cfg.Log.Level) } + + // Metacrypt defaults when section is omitted. + if cfg.Metacrypt.Mount != "pki" { + t.Fatalf("metacrypt.mount default: got %q, want pki", cfg.Metacrypt.Mount) + } + if cfg.Metacrypt.Issuer != "infra" { + t.Fatalf("metacrypt.issuer default: got %q, want infra", cfg.Metacrypt.Issuer) + } } func TestCLIConfigValidation(t *testing.T) { @@ -439,6 +447,80 @@ level = "info" }) } +func TestAgentConfigMetacrypt(t *testing.T) { + cfgStr := ` +[server] +grpc_addr = "0.0.0.0:9444" +tls_cert = "/srv/mcp/cert.pem" +tls_key = "/srv/mcp/key.pem" +[database] +path = "/srv/mcp/mcp.db" +[mcias] +server_url = "https://mcias.metacircular.net:8443" +service_name = "mcp-agent" +[agent] +node_name = "rift" +[metacrypt] +server_url = "https://metacrypt.metacircular.net:8443" +ca_cert = "/etc/mcp/metacircular-ca.pem" +mount = "custom-pki" +issuer = "custom-issuer" +token_path = "/srv/mcp/metacrypt-token" +` + path := writeTempConfig(t, cfgStr) + cfg, err := LoadAgentConfig(path) + if err != nil { + t.Fatalf("load: %v", err) + } + + if cfg.Metacrypt.ServerURL != "https://metacrypt.metacircular.net:8443" { + t.Fatalf("metacrypt.server_url: got %q", cfg.Metacrypt.ServerURL) + } + if cfg.Metacrypt.CACert != "/etc/mcp/metacircular-ca.pem" { + t.Fatalf("metacrypt.ca_cert: got %q", cfg.Metacrypt.CACert) + } + if cfg.Metacrypt.Mount != "custom-pki" { + t.Fatalf("metacrypt.mount: got %q", cfg.Metacrypt.Mount) + } + if cfg.Metacrypt.Issuer != "custom-issuer" { + t.Fatalf("metacrypt.issuer: got %q", cfg.Metacrypt.Issuer) + } + if cfg.Metacrypt.TokenPath != "/srv/mcp/metacrypt-token" { + t.Fatalf("metacrypt.token_path: got %q", cfg.Metacrypt.TokenPath) + } +} + +func TestAgentConfigMetacryptEnvOverrides(t *testing.T) { + minimal := ` +[server] +grpc_addr = "0.0.0.0:9444" +tls_cert = "/srv/mcp/cert.pem" +tls_key = "/srv/mcp/key.pem" +[database] +path = "/srv/mcp/mcp.db" +[mcias] +server_url = "https://mcias.metacircular.net:8443" +service_name = "mcp-agent" +[agent] +node_name = "rift" +` + t.Setenv("MCP_AGENT_METACRYPT_SERVER_URL", "https://override.metacrypt:8443") + t.Setenv("MCP_AGENT_METACRYPT_TOKEN_PATH", "/override/token") + + path := writeTempConfig(t, minimal) + cfg, err := LoadAgentConfig(path) + if err != nil { + t.Fatalf("load: %v", err) + } + + if cfg.Metacrypt.ServerURL != "https://override.metacrypt:8443" { + t.Fatalf("metacrypt.server_url: got %q", cfg.Metacrypt.ServerURL) + } + if cfg.Metacrypt.TokenPath != "/override/token" { + t.Fatalf("metacrypt.token_path: got %q", cfg.Metacrypt.TokenPath) + } +} + func TestDurationParsing(t *testing.T) { tests := []struct { input string diff --git a/internal/servicedef/servicedef.go b/internal/servicedef/servicedef.go index b1f2177..7135218 100644 --- a/internal/servicedef/servicedef.go +++ b/internal/servicedef/servicedef.go @@ -25,8 +25,8 @@ type ServiceDef struct { // BuildDef describes how to build container images for a service. type BuildDef struct { - Images map[string]string `toml:"images"` - UsesMCDSL bool `toml:"uses_mcdsl,omitempty"` + Images map[string]string `toml:"images"` + UsesMCDSL bool `toml:"uses_mcdsl,omitempty"` } // RouteDef describes a route for a component, used for automatic port @@ -210,7 +210,7 @@ func ToProto(def *ServiceDef) *mcpv1.ServiceSpec { for _, r := range c.Routes { cs.Routes = append(cs.Routes, &mcpv1.RouteSpec{ Name: r.Name, - Port: int32(r.Port), + Port: int32(r.Port), //nolint:gosec // port range validated Mode: r.Mode, Hostname: r.Hostname, }) diff --git a/vendor/git.wntrmute.dev/mc/mc-proxy/gen/mc_proxy/v1/admin.pb.go b/vendor/git.wntrmute.dev/mc/mc-proxy/gen/mc_proxy/v1/admin.pb.go index 99b4863..3a4c018 100644 --- a/vendor/git.wntrmute.dev/mc/mc-proxy/gen/mc_proxy/v1/admin.pb.go +++ b/vendor/git.wntrmute.dev/mc/mc-proxy/gen/mc_proxy/v1/admin.pb.go @@ -1449,7 +1449,7 @@ const file_proto_mc_proxy_v1_admin_proto_rawDesc = "" + "\x0eListL7Policies\x12\".mc_proxy.v1.ListL7PoliciesRequest\x1a#.mc_proxy.v1.ListL7PoliciesResponse\x12P\n" + "\vAddL7Policy\x12\x1f.mc_proxy.v1.AddL7PolicyRequest\x1a .mc_proxy.v1.AddL7PolicyResponse\x12Y\n" + "\x0eRemoveL7Policy\x12\".mc_proxy.v1.RemoveL7PolicyRequest\x1a#.mc_proxy.v1.RemoveL7PolicyResponse\x12J\n" + - "\tGetStatus\x12\x1d.mc_proxy.v1.GetStatusRequest\x1a\x1e.mc_proxy.v1.GetStatusResponseB:Z8git.wntrmute.dev/mc/mc-proxy/gen/mc_proxy/v1;mcproxyv1b\x06proto3" + "\tGetStatus\x12\x1d.mc_proxy.v1.GetStatusRequest\x1a\x1e.mc_proxy.v1.GetStatusResponseB8Z6git.wntrmute.dev/mc/mc-proxy/gen/mc_proxy/v1;mcproxyv1b\x06proto3" var ( file_proto_mc_proxy_v1_admin_proto_rawDescOnce sync.Once diff --git a/vendor/modules.txt b/vendor/modules.txt index 6682b5c..ac5dcde 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -1,4 +1,4 @@ -# git.wntrmute.dev/mc/mc-proxy v1.1.0 +# git.wntrmute.dev/mc/mc-proxy v1.1.0 => /home/kyle/src/metacircular/mc-proxy ## explicit; go 1.25.7 git.wntrmute.dev/mc/mc-proxy/client/mcproxy git.wntrmute.dev/mc/mc-proxy/gen/mc_proxy/v1 @@ -192,3 +192,4 @@ modernc.org/memory modernc.org/sqlite modernc.org/sqlite/lib modernc.org/sqlite/vtab +# git.wntrmute.dev/mc/mc-proxy => /home/kyle/src/metacircular/mc-proxy