12 Commits

Author SHA1 Message Date
43789dd6be Fix route-based port mapping: use hostPort as container port
allocateRoutePorts() was using the route's port field (the mc-proxy
listener port, e.g. 443) as the container internal port in the podman
port mapping. For L7 routes, apps don't listen on the mc-proxy port —
they read $PORT (set to the assigned host port) and listen on that.

The mapping host:53204 → container:443 fails because nothing listens
on 443 inside the container. Fix: use hostPort as both the host and
container port, so $PORT = host port = container port.

Broke mcdoc in production (manually fixed, now permanently fixed).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 16:50:48 -07:00
2dd0ea93fc Fix ImageExists to use skopeo instead of podman manifest inspect
podman manifest inspect only works for multi-arch manifest lists,
returning exit code 125 for regular single-arch images. Switch to
skopeo inspect which works for both.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 16:49:48 -07:00
169b3a0d4a Fix EnsureRecord to check all existing records before updating
When multiple A records exist for a service (e.g., LAN and Tailscale
IPs), check all of them for the correct value before attempting an
update. Previously only checked the first record, which could trigger
a 409 conflict if another record already had the target value.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 15:17:19 -07:00
2bda7fc138 Fix DNS record JSON parsing for MCNS response format
MCNS returns records wrapped in {"records": [...]} envelope with
uppercase field names (ID, Name, Type, Value), not bare arrays
with lowercase fields.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 15:12:43 -07:00
76247978c2 Fix protoToComponent to include routes in synced components
Routes from the proto ComponentSpec were dropped during sync, causing
the deploy flow to see empty regRoutes and skip cert provisioning,
route registration, and DNS registration.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 14:39:26 -07:00
ca3bc736f6 Bump version to v0.5.0 for Phase D release
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 14:33:54 -07:00
9d9ad6588e Phase D: Automated DNS registration via MCNS
Add DNSRegistrar that creates/updates/deletes A records in MCNS
during deploy and stop. When a service has routes, the agent ensures
an A record exists in the configured zone pointing to the node's
address. On stop, the record is removed.

- Add MCNSConfig to agent config (server_url, ca_cert, token_path,
  zone, node_addr) with defaults and env overrides
- Add DNSRegistrar (internal/agent/dns.go): REST client for MCNS
  record CRUD, nil-receiver safe
- Wire into deploy flow (EnsureRecord after route registration)
- Wire into stop flow (RemoveRecord before container stop)
- 7 new tests, make all passes with 0 issues

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 14:33:41 -07:00
e4d131021e Bump version to v0.4.0 for Phase C release
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 13:47:02 -07:00
8d6c060483 Update mc-proxy dependency to v1.2.0, drop replace directive
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 13:39:41 -07:00
c7e1232f98 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) <noreply@anthropic.com>
2026-03-27 13:31:11 -07:00
572d2fb196 Regenerate proto files for mc/ module path
Raw descriptor bytes in .pb.go files were corrupted by the sed-based
module path rename (string length changed, breaking protobuf binary
encoding). Regenerated with protoc to fix.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 02:54:40 -07:00
c6a84a1b80 Bump flake.nix version to match latest tag
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 02:16:45 -07:00
23 changed files with 1638 additions and 37 deletions

View File

@@ -5,6 +5,24 @@ run:
tests: true tests: true
linters: 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 default: none
enable: enable:
- errcheck - errcheck
@@ -69,12 +87,3 @@ formatters:
issues: issues:
max-issues-per-linter: 0 max-issues-per-linter: 0
max-same-issues: 0 max-same-issues: 0
exclusions:
paths:
- vendor
rules:
- path: "_test\\.go"
linters:
- gosec
text: "G101"

View File

@@ -10,7 +10,7 @@
let let
system = "x86_64-linux"; system = "x86_64-linux";
pkgs = nixpkgs.legacyPackages.${system}; pkgs = nixpkgs.legacyPackages.${system};
version = "0.1.0"; version = "0.5.0";
in in
{ {
packages.${system} = { packages.${system} = {

View File

@@ -2294,7 +2294,7 @@ const file_proto_mcp_v1_mcp_proto_rawDesc = "" +
"\bPushFile\x12\x17.mcp.v1.PushFileRequest\x1a\x18.mcp.v1.PushFileResponse\x12=\n" + "\bPushFile\x12\x17.mcp.v1.PushFileRequest\x1a\x18.mcp.v1.PushFileResponse\x12=\n" +
"\bPullFile\x12\x17.mcp.v1.PullFileRequest\x1a\x18.mcp.v1.PullFileResponse\x12C\n" + "\bPullFile\x12\x17.mcp.v1.PullFileRequest\x1a\x18.mcp.v1.PullFileResponse\x12C\n" +
"\n" + "\n" +
"NodeStatus\x12\x19.mcp.v1.NodeStatusRequest\x1a\x1a.mcp.v1.NodeStatusResponseB,Z*git.wntrmute.dev/mc/mcp/gen/mcp/v1;mcpv1b\x06proto3" "NodeStatus\x12\x19.mcp.v1.NodeStatusRequest\x1a\x1a.mcp.v1.NodeStatusResponseB*Z(git.wntrmute.dev/mc/mcp/gen/mcp/v1;mcpv1b\x06proto3"
var ( var (
file_proto_mcp_v1_mcp_proto_rawDescOnce sync.Once file_proto_mcp_v1_mcp_proto_rawDescOnce sync.Once

2
go.mod
View File

@@ -3,7 +3,7 @@ module git.wntrmute.dev/mc/mcp
go 1.25.7 go 1.25.7
require ( require (
git.wntrmute.dev/mc/mc-proxy v1.1.0 git.wntrmute.dev/mc/mc-proxy v1.2.0
github.com/pelletier/go-toml/v2 v2.3.0 github.com/pelletier/go-toml/v2 v2.3.0
github.com/spf13/cobra v1.10.2 github.com/spf13/cobra v1.10.2
golang.org/x/sys v0.42.0 golang.org/x/sys v0.42.0

4
go.sum
View File

@@ -1,5 +1,5 @@
git.wntrmute.dev/mc/mc-proxy v1.1.0 h1:r8LnBuiS0OqLSuHMuRikQlIhmeNtlJV9IrIvVVTIGuw= git.wntrmute.dev/mc/mc-proxy v1.2.0 h1:TVfwdZzYqMs/ksZ0a6aSR7hKGDDMG8X0Od5RIxlbXKQ=
git.wntrmute.dev/mc/mc-proxy v1.1.0/go.mod h1:6w8smZ/DNJVBb4n5std/faye0ROLEXfk3iJY1XNc1JU= git.wntrmute.dev/mc/mc-proxy v1.2.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 h1:41hep7/PNZJfN0SN/nM+rQpyF1GSZcvNNjyVG81DI7U=
git.wntrmute.dev/mc/mcdsl v1.2.0/go.mod h1:lXYrAt74ZUix6rx9oVN8d2zH1YJoyp4uxPVKQ+SSxuM= git.wntrmute.dev/mc/mcdsl v1.2.0/go.mod h1:lXYrAt74ZUix6rx9oVN8d2zH1YJoyp4uxPVKQ+SSxuM=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=

View File

@@ -33,6 +33,8 @@ type Agent struct {
Logger *slog.Logger Logger *slog.Logger
PortAlloc *PortAllocator PortAlloc *PortAllocator
Proxy *ProxyRouter Proxy *ProxyRouter
Certs *CertProvisioner
DNS *DNSRegistrar
} }
// Run starts the agent: opens the database, sets up the gRPC server with // Run starts the agent: opens the database, sets up the gRPC server with
@@ -57,6 +59,16 @@ func Run(cfg *config.AgentConfig) error {
return fmt.Errorf("connect to mc-proxy: %w", err) 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)
}
dns, err := NewDNSRegistrar(cfg.MCNS, logger)
if err != nil {
return fmt.Errorf("create DNS registrar: %w", err)
}
a := &Agent{ a := &Agent{
Config: cfg, Config: cfg,
DB: db, DB: db,
@@ -65,6 +77,8 @@ func Run(cfg *config.AgentConfig) error {
Logger: logger, Logger: logger,
PortAlloc: NewPortAllocator(), PortAlloc: NewPortAllocator(),
Proxy: proxy, Proxy: proxy,
Certs: certs,
DNS: dns,
} }
tlsCert, err := tls.LoadX509KeyPair(cfg.Server.TLSCert, cfg.Server.TLSKey) tlsCert, err := tls.LoadX509KeyPair(cfg.Server.TLSCert, cfg.Server.TLSKey)

244
internal/agent/certs.go Normal file
View File

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

View File

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

View File

@@ -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. // Register routes with mc-proxy after the container is running.
if len(regRoutes) > 0 && a.Proxy != nil { if len(regRoutes) > 0 && a.Proxy != nil {
hostPorts, err := registry.GetRouteHostPorts(a.DB, serviceName, compName) hostPorts, err := registry.GetRouteHostPorts(a.DB, serviceName, compName)
@@ -156,6 +164,13 @@ func (a *Agent) deployComponent(ctx context.Context, serviceName string, cs *mcp
} }
} }
// Register DNS record for the service.
if a.DNS != nil && len(regRoutes) > 0 {
if err := a.DNS.EnsureRecord(ctx, serviceName); err != nil {
a.Logger.Warn("failed to register DNS record", "service", serviceName, "err", err)
}
}
if err := registry.UpdateComponentState(a.DB, serviceName, compName, "running", "running"); err != nil { if err := registry.UpdateComponentState(a.DB, serviceName, compName, "running", "running"); err != nil {
a.Logger.Warn("failed to update component state", "service", serviceName, "component", compName, "err", err) a.Logger.Warn("failed to update component state", "service", serviceName, "component", compName, "err", err)
} }
@@ -183,7 +198,10 @@ func (a *Agent) allocateRoutePorts(service, component string, routes []registry.
return nil, nil, fmt.Errorf("store host port for route %q: %w", r.Name, err) return nil, nil, fmt.Errorf("store host port for route %q: %w", r.Name, err)
} }
ports = append(ports, fmt.Sprintf("127.0.0.1:%d:%d", hostPort, r.Port)) // The container port must match hostPort (which is also set as $PORT),
// so the app's listen address matches the podman port mapping.
// r.Port is the mc-proxy listener port, NOT the container port.
ports = append(ports, fmt.Sprintf("127.0.0.1:%d:%d", hostPort, hostPort))
if len(routes) == 1 { if len(routes) == 1 {
env = append(env, fmt.Sprintf("PORT=%d", hostPort)) env = append(env, fmt.Sprintf("PORT=%d", hostPort))
@@ -209,6 +227,37 @@ func ensureService(db *sql.DB, name string, active bool) error {
return registry.UpdateServiceActive(db, name, active) 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 // ensureComponent creates the component if it does not exist, or updates its
// spec if it does. // spec if it does.
func ensureComponent(db *sql.DB, c *registry.Component) error { func ensureComponent(db *sql.DB, c *registry.Component) error {

View File

@@ -0,0 +1,159 @@
package agent
import (
"database/sql"
"fmt"
"log/slog"
"os"
"path/filepath"
"testing"
"git.wntrmute.dev/mc/mcp/internal/registry"
)
func openTestDB(t *testing.T) *sql.DB {
t.Helper()
db, err := registry.Open(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatalf("open db: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
return db
}
func testAgent(t *testing.T) *Agent {
t.Helper()
return &Agent{
DB: openTestDB(t),
PortAlloc: NewPortAllocator(),
Logger: slog.New(slog.NewTextHandler(os.Stderr, nil)),
}
}
// seedComponent creates the service and component in the registry so that
// allocateRoutePorts can store host ports for it.
func seedComponent(t *testing.T, db *sql.DB, service, component string, routes []registry.Route) {
t.Helper()
if err := registry.CreateService(db, service, true); err != nil {
t.Fatalf("create service: %v", err)
}
if err := registry.CreateComponent(db, &registry.Component{
Name: component,
Service: service,
Image: "img:latest",
DesiredState: "running",
ObservedState: "unknown",
Routes: routes,
}); err != nil {
t.Fatalf("create component: %v", err)
}
}
func TestAllocateRoutePorts_SingleRoute(t *testing.T) {
a := testAgent(t)
routes := []registry.Route{
{Name: "default", Port: 443, Mode: "l7"},
}
seedComponent(t, a.DB, "mcdoc", "mcdoc", routes)
ports, env, err := a.allocateRoutePorts("mcdoc", "mcdoc", routes)
if err != nil {
t.Fatalf("allocateRoutePorts: %v", err)
}
if len(ports) != 1 {
t.Fatalf("expected 1 port mapping, got %d", len(ports))
}
if len(env) != 1 {
t.Fatalf("expected 1 env var, got %d", len(env))
}
// Parse the port mapping: should be "127.0.0.1:<hostPort>:<hostPort>"
// NOT "127.0.0.1:<hostPort>:443"
var hostPort, containerPort int
n, _ := fmt.Sscanf(ports[0], "127.0.0.1:%d:%d", &hostPort, &containerPort)
if n != 2 {
t.Fatalf("failed to parse port mapping %q", ports[0])
}
if hostPort != containerPort {
t.Errorf("host port (%d) != container port (%d); container port must match host port for $PORT consistency", hostPort, containerPort)
}
// Env var should be PORT=<hostPort>
var envPort int
n, _ = fmt.Sscanf(env[0], "PORT=%d", &envPort)
if n != 1 {
t.Fatalf("failed to parse env var %q", env[0])
}
if envPort != hostPort {
t.Errorf("PORT env (%d) != host port (%d)", envPort, hostPort)
}
}
func TestAllocateRoutePorts_MultiRoute(t *testing.T) {
a := testAgent(t)
routes := []registry.Route{
{Name: "rest", Port: 8443, Mode: "l4"},
{Name: "grpc", Port: 9443, Mode: "l4"},
}
seedComponent(t, a.DB, "metacrypt", "api", routes)
ports, env, err := a.allocateRoutePorts("metacrypt", "api", routes)
if err != nil {
t.Fatalf("allocateRoutePorts: %v", err)
}
if len(ports) != 2 {
t.Fatalf("expected 2 port mappings, got %d", len(ports))
}
if len(env) != 2 {
t.Fatalf("expected 2 env vars, got %d", len(env))
}
// Each port mapping should have host port == container port.
for i, p := range ports {
var hp, cp int
n, _ := fmt.Sscanf(p, "127.0.0.1:%d:%d", &hp, &cp)
if n != 2 {
t.Fatalf("port[%d]: failed to parse %q", i, p)
}
if hp != cp {
t.Errorf("port[%d]: host port (%d) != container port (%d)", i, hp, cp)
}
}
// Env vars should be PORT_REST and PORT_GRPC (not bare PORT).
if env[0][:10] != "PORT_REST=" {
t.Errorf("env[0] = %q, want PORT_REST=...", env[0])
}
if env[1][:10] != "PORT_GRPC=" {
t.Errorf("env[1] = %q, want PORT_GRPC=...", env[1])
}
}
func TestAllocateRoutePorts_L7PortNotUsedAsContainerPort(t *testing.T) {
a := testAgent(t)
routes := []registry.Route{
{Name: "default", Port: 443, Mode: "l7"},
}
seedComponent(t, a.DB, "svc", "web", routes)
ports, _, err := a.allocateRoutePorts("svc", "web", routes)
if err != nil {
t.Fatalf("allocateRoutePorts: %v", err)
}
// The container port must NOT be 443 (the mc-proxy listener port).
// It must be the host port (which is in range 10000-60000).
var hostPort, containerPort int
n, _ := fmt.Sscanf(ports[0], "127.0.0.1:%d:%d", &hostPort, &containerPort)
if n != 2 {
t.Fatalf("failed to parse port mapping %q", ports[0])
}
if containerPort == 443 {
t.Errorf("container port is 443 (mc-proxy listener); should be %d (host port)", hostPort)
}
if containerPort < portRangeMin || containerPort >= portRangeMax {
t.Errorf("container port %d outside allocation range [%d, %d)", containerPort, portRangeMin, portRangeMax)
}
}

265
internal/agent/dns.go Normal file
View File

@@ -0,0 +1,265 @@
package agent
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"strings"
"git.wntrmute.dev/mc/mcp/internal/auth"
"git.wntrmute.dev/mc/mcp/internal/config"
)
// DNSRegistrar creates and removes A records in MCNS during deploy
// and stop. It is nil-safe: all methods are no-ops when the receiver
// is nil.
type DNSRegistrar struct {
serverURL string
token string
zone string
nodeAddr string
httpClient *http.Client
logger *slog.Logger
}
// dnsRecord is the JSON representation of an MCNS record.
type dnsRecord struct {
ID int `json:"ID"`
Name string `json:"Name"`
Type string `json:"Type"`
Value string `json:"Value"`
TTL int `json:"TTL"`
}
// NewDNSRegistrar creates a DNSRegistrar. Returns (nil, nil) if
// cfg.ServerURL is empty (DNS registration disabled).
func NewDNSRegistrar(cfg config.MCNSConfig, logger *slog.Logger) (*DNSRegistrar, error) {
if cfg.ServerURL == "" {
logger.Info("mcns not configured, DNS registration disabled")
return nil, nil
}
token, err := auth.LoadToken(cfg.TokenPath)
if err != nil {
return nil, fmt.Errorf("load mcns token: %w", err)
}
httpClient, err := newTLSClient(cfg.CACert)
if err != nil {
return nil, fmt.Errorf("create mcns HTTP client: %w", err)
}
logger.Info("mcns DNS registrar enabled", "server", cfg.ServerURL, "zone", cfg.Zone, "node_addr", cfg.NodeAddr)
return &DNSRegistrar{
serverURL: strings.TrimRight(cfg.ServerURL, "/"),
token: token,
zone: cfg.Zone,
nodeAddr: cfg.NodeAddr,
httpClient: httpClient,
logger: logger,
}, nil
}
// EnsureRecord ensures an A record exists for the service in the
// configured zone, pointing to the node's address.
func (d *DNSRegistrar) EnsureRecord(ctx context.Context, serviceName string) error {
if d == nil {
return nil
}
existing, err := d.listRecords(ctx, serviceName)
if err != nil {
return fmt.Errorf("list DNS records: %w", err)
}
// Check if any existing record already has the correct value.
for _, r := range existing {
if r.Value == d.nodeAddr {
d.logger.Debug("DNS record exists, skipping",
"service", serviceName,
"record", r.Name+"."+d.zone,
"value", r.Value,
)
return nil
}
}
// No record with the correct value — update the first one if it exists.
if len(existing) > 0 {
d.logger.Info("updating DNS record",
"service", serviceName,
"old_value", existing[0].Value,
"new_value", d.nodeAddr,
)
return d.updateRecord(ctx, existing[0].ID, serviceName)
}
// No existing record — create one.
d.logger.Info("creating DNS record",
"service", serviceName,
"record", serviceName+"."+d.zone,
"value", d.nodeAddr,
)
return d.createRecord(ctx, serviceName)
}
// RemoveRecord removes A records for the service from the configured zone.
func (d *DNSRegistrar) RemoveRecord(ctx context.Context, serviceName string) error {
if d == nil {
return nil
}
existing, err := d.listRecords(ctx, serviceName)
if err != nil {
return fmt.Errorf("list DNS records: %w", err)
}
if len(existing) == 0 {
d.logger.Debug("no DNS record to remove", "service", serviceName)
return nil
}
for _, r := range existing {
d.logger.Info("removing DNS record",
"service", serviceName,
"record", r.Name+"."+d.zone,
"id", r.ID,
)
if err := d.deleteRecord(ctx, r.ID); err != nil {
return err
}
}
return nil
}
// listRecords returns A records matching the service name in the zone.
func (d *DNSRegistrar) listRecords(ctx context.Context, serviceName string) ([]dnsRecord, error) {
url := fmt.Sprintf("%s/v1/zones/%s/records?name=%s&type=A", d.serverURL, d.zone, serviceName)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("create list request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+d.token)
resp, err := d.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("list records: %w", err)
}
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read list response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("list records: mcns returned %d: %s", resp.StatusCode, string(body))
}
var envelope struct {
Records []dnsRecord `json:"records"`
}
if err := json.Unmarshal(body, &envelope); err != nil {
return nil, fmt.Errorf("parse list response: %w", err)
}
return envelope.Records, nil
}
// createRecord creates an A record in the zone.
func (d *DNSRegistrar) createRecord(ctx context.Context, serviceName string) error {
reqBody := map[string]interface{}{
"name": serviceName,
"type": "A",
"value": d.nodeAddr,
"ttl": 300,
}
body, err := json.Marshal(reqBody)
if err != nil {
return fmt.Errorf("marshal create request: %w", err)
}
url := fmt.Sprintf("%s/v1/zones/%s/records", d.serverURL, d.zone)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return fmt.Errorf("create record request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+d.token)
resp, err := d.httpClient.Do(req)
if err != nil {
return fmt.Errorf("create record: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(resp.Body)
return fmt.Errorf("create record: mcns returned %d: %s", resp.StatusCode, string(respBody))
}
return nil
}
// updateRecord updates an existing record's value.
func (d *DNSRegistrar) updateRecord(ctx context.Context, recordID int, serviceName string) error {
reqBody := map[string]interface{}{
"name": serviceName,
"type": "A",
"value": d.nodeAddr,
"ttl": 300,
}
body, err := json.Marshal(reqBody)
if err != nil {
return fmt.Errorf("marshal update request: %w", err)
}
url := fmt.Sprintf("%s/v1/zones/%s/records/%d", d.serverURL, d.zone, recordID)
req, err := http.NewRequestWithContext(ctx, http.MethodPut, url, bytes.NewReader(body))
if err != nil {
return fmt.Errorf("create update request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+d.token)
resp, err := d.httpClient.Do(req)
if err != nil {
return fmt.Errorf("update record: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(resp.Body)
return fmt.Errorf("update record: mcns returned %d: %s", resp.StatusCode, string(respBody))
}
return nil
}
// deleteRecord deletes a record by ID.
func (d *DNSRegistrar) deleteRecord(ctx context.Context, recordID int) error {
url := fmt.Sprintf("%s/v1/zones/%s/records/%d", d.serverURL, d.zone, recordID)
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, url, nil)
if err != nil {
return fmt.Errorf("create delete request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+d.token)
resp, err := d.httpClient.Do(req)
if err != nil {
return fmt.Errorf("delete record: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(resp.Body)
return fmt.Errorf("delete record: mcns returned %d: %s", resp.StatusCode, string(respBody))
}
return nil
}

214
internal/agent/dns_test.go Normal file
View File

@@ -0,0 +1,214 @@
package agent
import (
"context"
"encoding/json"
"log/slog"
"net/http"
"net/http/httptest"
"testing"
"git.wntrmute.dev/mc/mcp/internal/config"
)
func TestNilDNSRegistrarIsNoop(t *testing.T) {
var d *DNSRegistrar
if err := d.EnsureRecord(context.Background(), "svc"); err != nil {
t.Fatalf("EnsureRecord on nil: %v", err)
}
if err := d.RemoveRecord(context.Background(), "svc"); err != nil {
t.Fatalf("RemoveRecord on nil: %v", err)
}
}
func TestNewDNSRegistrarDisabledWhenUnconfigured(t *testing.T) {
d, err := NewDNSRegistrar(config.MCNSConfig{}, slog.Default())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if d != nil {
t.Fatal("expected nil registrar for empty config")
}
}
func TestEnsureRecordCreatesWhenMissing(t *testing.T) {
var gotMethod, gotPath, gotAuth string
var gotBody map[string]interface{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
// List returns empty — no existing records.
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"records":[]}`))
return
}
gotMethod = r.Method
gotPath = r.URL.Path
gotAuth = r.Header.Get("Authorization")
_ = json.NewDecoder(r.Body).Decode(&gotBody)
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{"id":1}`))
}))
defer srv.Close()
d := &DNSRegistrar{
serverURL: srv.URL,
token: "test-token",
zone: "svc.mcp.metacircular.net",
nodeAddr: "192.168.88.181",
httpClient: srv.Client(),
logger: slog.Default(),
}
if err := d.EnsureRecord(context.Background(), "myservice"); err != nil {
t.Fatalf("EnsureRecord: %v", err)
}
if gotMethod != http.MethodPost {
t.Fatalf("method: got %q, want POST", gotMethod)
}
if gotPath != "/v1/zones/svc.mcp.metacircular.net/records" {
t.Fatalf("path: got %q", gotPath)
}
if gotAuth != "Bearer test-token" {
t.Fatalf("auth: got %q", gotAuth)
}
if gotBody["name"] != "myservice" {
t.Fatalf("name: got %v", gotBody["name"])
}
if gotBody["type"] != "A" {
t.Fatalf("type: got %v", gotBody["type"])
}
if gotBody["value"] != "192.168.88.181" {
t.Fatalf("value: got %v", gotBody["value"])
}
}
func TestEnsureRecordSkipsWhenExists(t *testing.T) {
createCalled := false
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
// Return an existing record with the correct value.
resp := map[string][]dnsRecord{"records": {{ID: 1, Name: "myservice", Type: "A", Value: "192.168.88.181", TTL: 300}}}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(resp)
return
}
createCalled = true
w.WriteHeader(http.StatusCreated)
}))
defer srv.Close()
d := &DNSRegistrar{
serverURL: srv.URL,
token: "test-token",
zone: "svc.mcp.metacircular.net",
nodeAddr: "192.168.88.181",
httpClient: srv.Client(),
logger: slog.Default(),
}
if err := d.EnsureRecord(context.Background(), "myservice"); err != nil {
t.Fatalf("EnsureRecord: %v", err)
}
if createCalled {
t.Fatal("should not create when record already exists with correct value")
}
}
func TestEnsureRecordUpdatesWrongValue(t *testing.T) {
var gotMethod string
var gotPath string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
// Return a record with a stale value.
resp := map[string][]dnsRecord{"records": {{ID: 42, Name: "myservice", Type: "A", Value: "10.0.0.1", TTL: 300}}}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(resp)
return
}
gotMethod = r.Method
gotPath = r.URL.Path
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
d := &DNSRegistrar{
serverURL: srv.URL,
token: "test-token",
zone: "svc.mcp.metacircular.net",
nodeAddr: "192.168.88.181",
httpClient: srv.Client(),
logger: slog.Default(),
}
if err := d.EnsureRecord(context.Background(), "myservice"); err != nil {
t.Fatalf("EnsureRecord: %v", err)
}
if gotMethod != http.MethodPut {
t.Fatalf("method: got %q, want PUT", gotMethod)
}
if gotPath != "/v1/zones/svc.mcp.metacircular.net/records/42" {
t.Fatalf("path: got %q", gotPath)
}
}
func TestRemoveRecordDeletes(t *testing.T) {
var gotMethod, gotPath string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
resp := map[string][]dnsRecord{"records": {{ID: 7, Name: "myservice", Type: "A", Value: "192.168.88.181", TTL: 300}}}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(resp)
return
}
gotMethod = r.Method
gotPath = r.URL.Path
w.WriteHeader(http.StatusNoContent)
}))
defer srv.Close()
d := &DNSRegistrar{
serverURL: srv.URL,
token: "test-token",
zone: "svc.mcp.metacircular.net",
nodeAddr: "192.168.88.181",
httpClient: srv.Client(),
logger: slog.Default(),
}
if err := d.RemoveRecord(context.Background(), "myservice"); err != nil {
t.Fatalf("RemoveRecord: %v", err)
}
if gotMethod != http.MethodDelete {
t.Fatalf("method: got %q, want DELETE", gotMethod)
}
if gotPath != "/v1/zones/svc.mcp.metacircular.net/records/7" {
t.Fatalf("path: got %q", gotPath)
}
}
func TestRemoveRecordNoopWhenMissing(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// List returns empty.
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"records":[]}`))
}))
defer srv.Close()
d := &DNSRegistrar{
serverURL: srv.URL,
token: "test-token",
zone: "svc.mcp.metacircular.net",
nodeAddr: "192.168.88.181",
httpClient: srv.Client(),
logger: slog.Default(),
}
if err := d.RemoveRecord(context.Background(), "myservice"); err != nil {
t.Fatalf("RemoveRecord: %v", err)
}
}

View File

@@ -37,6 +37,13 @@ func (a *Agent) StopService(ctx context.Context, req *mcpv1.StopServiceRequest)
} }
} }
// Remove DNS record when stopping the service.
if len(c.Routes) > 0 && a.DNS != nil {
if err := a.DNS.RemoveRecord(ctx, req.GetName()); err != nil {
a.Logger.Warn("failed to remove DNS record", "service", req.GetName(), "err", err)
}
}
if err := a.Runtime.Stop(ctx, containerName); err != nil { if err := a.Runtime.Stop(ctx, containerName); err != nil {
a.Logger.Info("stop container (ignored)", "container", containerName, "error", err) a.Logger.Info("stop container (ignored)", "container", containerName, "error", err)
} }

View File

@@ -33,8 +33,8 @@ func (pa *PortAllocator) Allocate() (int, error) {
pa.mu.Lock() pa.mu.Lock()
defer pa.mu.Unlock() defer pa.mu.Unlock()
for i := range maxRetries { for range maxRetries {
port := portRangeMin + rand.IntN(portRangeMax-portRangeMin) port := portRangeMin + rand.IntN(portRangeMax-portRangeMin) //nolint:gosec // port selection, not security
if pa.allocated[port] { if pa.allocated[port] {
continue continue
} }
@@ -45,7 +45,6 @@ func (pa *PortAllocator) Allocate() (int, error) {
pa.allocated[port] = true pa.allocated[port] = true
return port, nil return port, nil
_ = i
} }
return 0, fmt.Errorf("failed to allocate port after %d attempts", maxRetries) return 0, fmt.Errorf("failed to allocate port after %d attempts", maxRetries)

View File

@@ -157,6 +157,24 @@ func (a *Agent) reconcileUntracked(ctx context.Context, known map[string]bool) e
// protoToComponent converts a proto ComponentSpec to a registry Component. // protoToComponent converts a proto ComponentSpec to a registry Component.
func protoToComponent(service string, cs *mcpv1.ComponentSpec, desiredState string) *registry.Component { func protoToComponent(service string, cs *mcpv1.ComponentSpec, desiredState string) *registry.Component {
var routes []registry.Route
for _, r := range cs.GetRoutes() {
mode := r.GetMode()
if mode == "" {
mode = "l4"
}
name := r.GetName()
if name == "" {
name = "default"
}
routes = append(routes, registry.Route{
Name: name,
Port: int(r.GetPort()),
Mode: mode,
Hostname: r.GetHostname(),
})
}
return &registry.Component{ return &registry.Component{
Name: cs.GetName(), Name: cs.GetName(),
Service: service, Service: service,
@@ -167,6 +185,7 @@ func protoToComponent(service string, cs *mcpv1.ComponentSpec, desiredState stri
Ports: cs.GetPorts(), Ports: cs.GetPorts(),
Volumes: cs.GetVolumes(), Volumes: cs.GetVolumes(),
Cmd: cs.GetCmd(), Cmd: cs.GetCmd(),
Routes: routes,
DesiredState: desiredState, DesiredState: desiredState,
Version: runtime.ExtractVersion(cs.GetImage()), Version: runtime.ExtractVersion(cs.GetImage()),
} }

View File

@@ -10,13 +10,55 @@ import (
// AgentConfig is the configuration for the mcp-agent daemon. // AgentConfig is the configuration for the mcp-agent daemon.
type AgentConfig struct { type AgentConfig struct {
Server ServerConfig `toml:"server"` Server ServerConfig `toml:"server"`
Database DatabaseConfig `toml:"database"` Database DatabaseConfig `toml:"database"`
MCIAS MCIASConfig `toml:"mcias"` MCIAS MCIASConfig `toml:"mcias"`
Agent AgentSettings `toml:"agent"` Agent AgentSettings `toml:"agent"`
MCProxy MCProxyConfig `toml:"mcproxy"` MCProxy MCProxyConfig `toml:"mcproxy"`
Monitor MonitorConfig `toml:"monitor"` Metacrypt MetacryptConfig `toml:"metacrypt"`
Log LogConfig `toml:"log"` MCNS MCNSConfig `toml:"mcns"`
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"`
}
// MCNSConfig holds the MCNS DNS integration settings for automated
// DNS record registration. If ServerURL is empty, DNS registration
// is disabled.
type MCNSConfig struct {
// ServerURL is the MCNS API base URL (e.g. "https://localhost:28443").
ServerURL string `toml:"server_url"`
// CACert is the path to the CA certificate for verifying MCNS's TLS.
CACert string `toml:"ca_cert"`
// TokenPath is the path to the MCIAS service token file.
TokenPath string `toml:"token_path"`
// Zone is the DNS zone for service records. Defaults to "svc.mcp.metacircular.net".
Zone string `toml:"zone"`
// NodeAddr is the IP address to register as the A record value.
NodeAddr string `toml:"node_addr"`
} }
// MCProxyConfig holds the mc-proxy connection settings. // MCProxyConfig holds the mc-proxy connection settings.
@@ -150,6 +192,15 @@ func applyAgentDefaults(cfg *AgentConfig) {
if cfg.MCProxy.CertDir == "" { if cfg.MCProxy.CertDir == "" {
cfg.MCProxy.CertDir = "/srv/mc-proxy/certs" cfg.MCProxy.CertDir = "/srv/mc-proxy/certs"
} }
if cfg.Metacrypt.Mount == "" {
cfg.Metacrypt.Mount = "pki"
}
if cfg.Metacrypt.Issuer == "" {
cfg.Metacrypt.Issuer = "infra"
}
if cfg.MCNS.Zone == "" {
cfg.MCNS.Zone = "svc.mcp.metacircular.net"
}
} }
func applyAgentEnvOverrides(cfg *AgentConfig) { func applyAgentEnvOverrides(cfg *AgentConfig) {
@@ -180,6 +231,21 @@ func applyAgentEnvOverrides(cfg *AgentConfig) {
if v := os.Getenv("MCP_AGENT_MCPROXY_CERT_DIR"); v != "" { if v := os.Getenv("MCP_AGENT_MCPROXY_CERT_DIR"); v != "" {
cfg.MCProxy.CertDir = 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
}
if v := os.Getenv("MCP_AGENT_MCNS_SERVER_URL"); v != "" {
cfg.MCNS.ServerURL = v
}
if v := os.Getenv("MCP_AGENT_MCNS_TOKEN_PATH"); v != "" {
cfg.MCNS.TokenPath = v
}
if v := os.Getenv("MCP_AGENT_MCNS_NODE_ADDR"); v != "" {
cfg.MCNS.NodeAddr = v
}
} }
func validateAgentConfig(cfg *AgentConfig) error { func validateAgentConfig(cfg *AgentConfig) error {

View File

@@ -163,6 +163,19 @@ func TestLoadAgentConfig(t *testing.T) {
if cfg.Log.Level != "debug" { if cfg.Log.Level != "debug" {
t.Fatalf("log.level: got %q", cfg.Log.Level) 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)
}
// MCNS defaults when section is omitted.
if cfg.MCNS.Zone != "svc.mcp.metacircular.net" {
t.Fatalf("mcns.zone default: got %q, want svc.mcp.metacircular.net", cfg.MCNS.Zone)
}
} }
func TestCLIConfigValidation(t *testing.T) { func TestCLIConfigValidation(t *testing.T) {
@@ -439,6 +452,155 @@ 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 TestAgentConfigMCNS(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"
[mcns]
server_url = "https://localhost:28443"
ca_cert = "/srv/mcp/certs/metacircular-ca.pem"
token_path = "/srv/mcp/metacrypt-token"
zone = "custom.zone"
node_addr = "10.0.0.1"
`
path := writeTempConfig(t, cfgStr)
cfg, err := LoadAgentConfig(path)
if err != nil {
t.Fatalf("load: %v", err)
}
if cfg.MCNS.ServerURL != "https://localhost:28443" {
t.Fatalf("mcns.server_url: got %q", cfg.MCNS.ServerURL)
}
if cfg.MCNS.CACert != "/srv/mcp/certs/metacircular-ca.pem" {
t.Fatalf("mcns.ca_cert: got %q", cfg.MCNS.CACert)
}
if cfg.MCNS.Zone != "custom.zone" {
t.Fatalf("mcns.zone: got %q", cfg.MCNS.Zone)
}
if cfg.MCNS.NodeAddr != "10.0.0.1" {
t.Fatalf("mcns.node_addr: got %q", cfg.MCNS.NodeAddr)
}
}
func TestAgentConfigMCNSEnvOverrides(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_MCNS_SERVER_URL", "https://override:28443")
t.Setenv("MCP_AGENT_MCNS_TOKEN_PATH", "/override/token")
t.Setenv("MCP_AGENT_MCNS_NODE_ADDR", "10.0.0.99")
path := writeTempConfig(t, minimal)
cfg, err := LoadAgentConfig(path)
if err != nil {
t.Fatalf("load: %v", err)
}
if cfg.MCNS.ServerURL != "https://override:28443" {
t.Fatalf("mcns.server_url: got %q", cfg.MCNS.ServerURL)
}
if cfg.MCNS.TokenPath != "/override/token" {
t.Fatalf("mcns.token_path: got %q", cfg.MCNS.TokenPath)
}
if cfg.MCNS.NodeAddr != "10.0.0.99" {
t.Fatalf("mcns.node_addr: got %q", cfg.MCNS.NodeAddr)
}
}
func TestDurationParsing(t *testing.T) { func TestDurationParsing(t *testing.T) {
tests := []struct { tests := []struct {
input string input string

View File

@@ -199,15 +199,16 @@ func (p *Podman) Push(ctx context.Context, image string) error {
} }
// ImageExists checks whether an image tag exists in a remote registry. // ImageExists checks whether an image tag exists in a remote registry.
// Uses skopeo inspect which works for both regular images and multi-arch
// manifests, unlike podman manifest inspect which only handles manifests.
func (p *Podman) ImageExists(ctx context.Context, image string) (bool, error) { func (p *Podman) ImageExists(ctx context.Context, image string) (bool, error) {
cmd := exec.CommandContext(ctx, p.command(), "manifest", "inspect", "docker://"+image) //nolint:gosec // args built programmatically cmd := exec.CommandContext(ctx, "skopeo", "inspect", "--tls-verify=false", "docker://"+image) //nolint:gosec // args built programmatically
if err := cmd.Run(); err != nil { if err := cmd.Run(); err != nil {
// Exit code 1 means the manifest was not found.
var exitErr *exec.ExitError var exitErr *exec.ExitError
if ok := errors.As(err, &exitErr); ok && exitErr.ExitCode() == 1 { if ok := errors.As(err, &exitErr); ok && exitErr.ExitCode() != 0 {
return false, nil return false, nil
} }
return false, fmt.Errorf("podman manifest inspect %q: %w", image, err) return false, fmt.Errorf("skopeo inspect %q: %w", image, err)
} }
return true, nil return true, nil
} }

View File

@@ -90,18 +90,19 @@ func TestBuildRunArgs(t *testing.T) {
}) })
t.Run("full spec with env", func(t *testing.T) { t.Run("full spec with env", func(t *testing.T) {
// Route-allocated ports: host port = container port (matches $PORT).
spec := ContainerSpec{ spec := ContainerSpec{
Name: "svc-api", Name: "svc-api",
Image: "img:latest", Image: "img:latest",
Network: "net", Network: "net",
Ports: []string{"127.0.0.1:12345:8443"}, Ports: []string{"127.0.0.1:12345:12345"},
Volumes: []string{"/srv:/srv"}, Volumes: []string{"/srv:/srv"},
Env: []string{"PORT=12345"}, Env: []string{"PORT=12345"},
} }
requireEqualArgs(t, p.BuildRunArgs(spec), []string{ requireEqualArgs(t, p.BuildRunArgs(spec), []string{
"run", "-d", "--name", "svc-api", "run", "-d", "--name", "svc-api",
"--network", "net", "--network", "net",
"-p", "127.0.0.1:12345:8443", "-p", "127.0.0.1:12345:12345",
"-v", "/srv:/srv", "-v", "/srv:/srv",
"-e", "PORT=12345", "-e", "PORT=12345",
"img:latest", "img:latest",

View File

@@ -25,8 +25,8 @@ type ServiceDef struct {
// BuildDef describes how to build container images for a service. // BuildDef describes how to build container images for a service.
type BuildDef struct { type BuildDef struct {
Images map[string]string `toml:"images"` Images map[string]string `toml:"images"`
UsesMCDSL bool `toml:"uses_mcdsl,omitempty"` UsesMCDSL bool `toml:"uses_mcdsl,omitempty"`
} }
// RouteDef describes a route for a component, used for automatic port // 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 { for _, r := range c.Routes {
cs.Routes = append(cs.Routes, &mcpv1.RouteSpec{ cs.Routes = append(cs.Routes, &mcpv1.RouteSpec{
Name: r.Name, Name: r.Name,
Port: int32(r.Port), Port: int32(r.Port), //nolint:gosec // port range validated
Mode: r.Mode, Mode: r.Mode,
Hostname: r.Hostname, Hostname: r.Hostname,
}) })

View File

@@ -38,7 +38,7 @@ service McpAgentService {
message RouteSpec { message RouteSpec {
string name = 1; // route name (used for $PORT_<NAME>) string name = 1; // route name (used for $PORT_<NAME>)
int32 port = 2; // external port on mc-proxy int32 port = 2; // mc-proxy listener port (e.g. 443, 8443, 9443); NOT the container internal port
string mode = 3; // "l4" or "l7" string mode = 3; // "l4" or "l7"
string hostname = 4; // optional public hostname override string hostname = 4; // optional public hostname override
} }

View File

@@ -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" + "\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" + "\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" + "\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 ( var (
file_proto_mc_proxy_v1_admin_proto_rawDescOnce sync.Once file_proto_mc_proxy_v1_admin_proto_rawDescOnce sync.Once

2
vendor/modules.txt vendored
View File

@@ -1,4 +1,4 @@
# git.wntrmute.dev/mc/mc-proxy v1.1.0 # git.wntrmute.dev/mc/mc-proxy v1.2.0
## explicit; go 1.25.7 ## explicit; go 1.25.7
git.wntrmute.dev/mc/mc-proxy/client/mcproxy git.wntrmute.dev/mc/mc-proxy/client/mcproxy
git.wntrmute.dev/mc/mc-proxy/gen/mc_proxy/v1 git.wntrmute.dev/mc/mc-proxy/gen/mc_proxy/v1