From 7d9537d175f1730554146a2699116a5187c776cd Mon Sep 17 00:00:00 2001 From: Linzhi Zheng Date: Wed, 21 Oct 2026 12:12:27 +0000 Subject: [PATCH] feat(gateway): support HTTPS with manual and ACME certificates --- deploy/docker-compose.yml | 3 + services/README.md | 130 +++++++++ services/gateway/cmd/main.go | 190 +++++++++++- services/gateway/cmd/main_test.go | 396 ++++++++++++++++++++++++++ services/go.mod | 32 ++- services/go.sum | 66 ++++- services/shared/config/config.go | 193 +++++++++++-- services/shared/config/config_test.go | 178 ++++++++++++ 8 files changed, 1164 insertions(+), 24 deletions(-) diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 74b8a18e5..908d9efe3 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -78,9 +78,11 @@ services: volumes: - ./docker/config/default.json:/config/default.json:ro - agentenv-auth:/run/secrets:ro + - gateway-tls:/var/lib/agentenv/tls environment: AENV_API_KEY: GATEWAY_HTTP_LISTEN_ADDR: :8080 + GATEWAY_HTTPS_LISTEN_ADDR: :8080 GATEWAY_SCHEDULER_ADDR: scheduler:9090 GATEWAY_SANDBOX_PROXY_DOMAINS: ${SANDBOX_PROXY_DOMAINS:-} ports: @@ -107,5 +109,6 @@ services: AENV_NODE_ID: node-b volumes: + gateway-tls: agentenv-auth: agentenv-snapshot-store: diff --git a/services/README.md b/services/README.md index fef3c1942..a0dfb7511 100644 --- a/services/README.md +++ b/services/README.md @@ -16,6 +16,8 @@ Go implementation of a distributed Gateway and pluggable Scheduler for AgentENV. - Scheduler can run as a primary read/write service or as query-only replicas that serve only `LookupNode` from Redis. - Scheduler observes node health and sandbox roster from heartbeats, and drops expired sandbox-to-node bindings on heartbeat, node unregistration, or lookup. - HTTP and WebSocket forwarding. +- Optional gateway HTTPS with manual certificates or CertMagic automatic issuance + and renewal. Local HTTP remains the default. See [Gateway HTTPS](#gateway-https). ## Header compatibility @@ -180,6 +182,134 @@ LOG_FORMAT=console make run-scheduler LOG_FORMAT=json make run-gateway ``` +## Gateway HTTPS + +Gateway exposes either HTTP or HTTPS through a single listener. Omitting +`gateway.tls` keeps HTTP enabled. Enabling TLS starts only the HTTPS listener; +it does not retain an HTTP listener or redirect HTTP requests. TLS configuration +does not change gateway communication with the internal scheduler or runtime nodes. + +### Enable HTTPS + +Add a `tls` object under `gateway` in the existing JSON configuration. Merge one +of the following examples into your configuration, keeping other gateway settings. + +**Manual certificates** + +Use existing PEM certificate and private key files: + +```json +{ + "gateway": { + "https_listen_addr": ":8080", + "tls": { + "mode": "manual", + "cert_file": "/run/gateway-tls/tls.crt", + "key_file": "/run/gateway-tls/tls.key" + } + } +} +``` + +In Docker, mount certificate and private key files at the configured paths. +The certificate must cover the hostname or IP clients use. Clients +must trust the issuing CA. + +**ACME with TLS-ALPN-01** + +Automatically obtain and renew a certificate without DNS provider credentials: + +```json +{ + "gateway": { + "https_listen_addr": ":8080", + "tls": { + "mode": "acme", + "domains": ["api.example.com"], + "storage_dir": "/var/lib/agentenv/tls", + "challenge": "tls-alpn-01" + } + } +} +``` + +Replace `api.example.com` with a hostname resolving to your gateway, or a public +IP you control. Wildcards are not supported with this challenge. Public TCP port +443 must reach this HTTPS listener; for Docker, publish `443:8080` in addition to +or instead of `8000:8080`. Persist `storage_dir` and make it writable by gateway. + +**ACME with DNS-01** + +Use Cloudflare DNS validation to obtain certificates for the gateway hostname and +a wildcard sandbox hostname: + +```json +{ + "gateway": { + "http_listen_addr": ":8080", + "https_listen_addr": ":8080", + "tls": { + "mode": "acme", + "domains": ["api.example.com", "*.sandbox.example.com"], + "storage_dir": "/var/lib/agentenv/tls", + "challenge": "dns-01", + "dns_provider": "cloudflare", + "dns_config": {"api_token": ""} + } + } +} +``` + +Replace `api.example.com` with your gateway hostname. Remove +`*.sandbox.example.com` if you do not need wildcard sandbox access. + +### Configuration fields + +Listener addresses belong to `gateway`; all other fields below belong to +`gateway.tls`. + +| Field | Purpose | Requirements, accepted values, and defaults | +| --- | --- | --- | +| `gateway.http_listen_addr` | HTTP listener address in `host:port` format. For example, `:8080` listens on all interfaces on port 8080. | Optional; defaults to `:8080`. Used only when TLS is `off`. Overridden by `GATEWAY_HTTP_LISTEN_ADDR`. | +| `gateway.https_listen_addr` | HTTPS listener address. In a container, this is the container address, not the published host port. | Optional; defaults to `:8080`. Used in `manual` and `acme` modes. Overridden by `GATEWAY_HTTPS_LISTEN_ADDR`. | +| `mode` | Enables TLS and selects the certificate source. | Accepts `off` (default; HTTP only), `manual` (HTTPS with existing certificate and key files), or `acme` (HTTPS with CertMagic generating private keys, requesting certificates from a CA, and renewing them automatically). | +| `cert_file` | Path to a PEM server certificate chain file. | Required in `manual` mode; no default. Must be readable by the gateway process. In containers, use the mounted path. Unused in `acme` mode. | +| `key_file` | Path to the PEM private key matching the server certificate. | Required in `manual` mode; no default. Must be readable by the gateway process. Unused in `acme` mode. | +| `domains` | Names for which to obtain and maintain certificates, such as `["api.example.com"]`. Each entry is a DNS name, a wildcard such as `*.sandbox.example.com`, or a public IP address, without a scheme, port, or path. | Required and nonempty in `acme` mode. Supports multiple DNS names and currently at most one public IP. Wildcards require `dns-01`; IPs require `tls-alpn-01`, so they cannot be combined in one configuration. Private, loopback, and special-purpose IPs are rejected for ACME. Not needed in `manual` mode: the supplied certificate determines the covered names and IPs. | +| `storage_dir` | Directory for CertMagic certificates, private keys, and ACME account data. | Required in `acme` mode; no default. Must be writable and persisted across container recreation. Compose mounts a persistent volume at `/var/lib/agentenv/tls`, but you must still set this field to that path. Unused in `manual` mode. | +| `email` | Contact email supplied to the ACME CA. | Optional in `acme` mode; no email is supplied by default. Not required to enable HTTPS. | +| `ca` | ACME directory URL of the certificate issuer, not a local CA certificate file path. | Optional in `acme` mode; defaults to Let's Encrypt production. A custom value must be an HTTPS URL, and the service must support the requested identifiers and challenge. A test CA directory URL may be used, but its certificates are generally not trusted by clients by default. | +| `challenge` | Method used by the CA to verify control of a domain or IP. | Optional in `acme` mode; defaults to `tls-alpn-01`. Accepts only `tls-alpn-01` or `dns-01`. The former requires public TCP port 443 to reach the gateway HTTPS listener. The latter validates DNS TXT records, supports wildcards and domain deployments without public port 443, and cannot validate IPs. `http-01` is not enabled. | +| `dns_provider` | DNS service provider used for validation. | Required when `challenge=dns-01`. Supports `cloudflare`, `alidns`, `tencentcloud`, and `route53`. Must be omitted or empty for `tls-alpn-01`. | +| `dns_config` | Provider-native JSON object containing credentials and optional settings. | Required when `challenge=dns-01`; see examples below. Use `{}` for Route 53 with AWS SDK credentials supplied outside JSON. Must be omitted for `tls-alpn-01`. Unknown fields and incorrect types are rejected at startup. The whole object is omitted from JSON configuration dumps. | + +In `manual` mode, certificate and key files are loaded at startup. There is no +automatic renewal or file reload; restart gateway after replacing them. The +certificate itself determines its validity period and covered names or IPs. + +At minimum, `acme` mode requires `mode`, `domains`, and `storage_dir`. Using +`dns-01` additionally requires `challenge`, `dns_provider`, and `dns_config`. + +DNS now supports four providers: + +| `dns_provider` | `dns_config` | Credentials and optional settings | +| --- | --- | --- | +| `cloudflare` | `{"api_token":""}` | Requires `api_token`; optionally accepts a separate `zone_token`. | +| `alidns` | `{"access_key_id":"","access_key_secret":""}` | Requires both keys; optionally accepts `security_token` for temporary credentials and `region_id`. | +| `tencentcloud` | `{"SecretId":"","SecretKey":""}` | Requires both keys; optionally accepts `SessionToken` and `Region`. These names follow the provider's Go fields; `secret_id` and `secret_key` are not accepted. | +| `route53` | `{"access_key_id":"","secret_access_key":"","region":"us-east-1"}` | Supply both static keys or omit both to use the AWS SDK credential chain (environment, shared profiles, or instance roles). Optional fields include `session_token`, `profile`, and `region`. | + +`domains` controls certificate issuance; `gateway.sandbox_proxy_domains` controls +sandbox request routing. Neither setting populates or replaces the other. For +example, wildcard sandbox access requires `*.sandbox.example.com` in `domains` +and `sandbox.example.com` in `sandbox_proxy_domains`. + +Clients may use a nonstandard HTTPS port, such as `https://api.example.com:8000`. +However, the CA still connects to public port 443 for `tls-alpn-01` issuance and +renewal; exposing only port 8000 does not satisfy this challenge. Domain deployments +can use `dns-01` instead, while public IP deployments still require forwarding +public port 443 to the HTTPS listener. + ## Deploy with Docker Compose From **repository root**, start gateway + scheduler + two backend nodes: diff --git a/services/gateway/cmd/main.go b/services/gateway/cmd/main.go index e7bbef260..8e6fa0645 100644 --- a/services/gateway/cmd/main.go +++ b/services/gateway/cmd/main.go @@ -1,15 +1,20 @@ package main import ( + "bytes" "context" + "crypto/tls" + "encoding/json" "errors" "flag" "fmt" "io" "log" + "net" "net/http" "os" "os/signal" + "strconv" "strings" "syscall" "time" @@ -19,6 +24,11 @@ import ( "agentenv/services/shared/config" "agentenv/services/shared/logging" + "github.com/caddyserver/certmagic" + "github.com/libdns/alidns" + "github.com/libdns/cloudflare" + "github.com/libdns/route53" + "github.com/libdns/tencentcloud" "github.com/prometheus/client_golang/prometheus/promhttp" "go.uber.org/zap" "google.golang.org/grpc" @@ -152,16 +162,35 @@ func main() { logger.Fatal("init gateway server failed", zap.Error(err)) } + tlsConfig, startCertificates, closeTLS, err := prepareTLS(cfg.Gateway, logger) + if err != nil { + logger.Fatal("init gateway TLS failed", zap.Error(err)) + } + defer closeTLS() + certificateCtx, cancelCertificates := context.WithCancel(context.Background()) + defer cancelCertificates() + listenAddr, scheme := cfg.Gateway.HTTPListenAddr, "http" + if tlsConfig != nil { + listenAddr, scheme = cfg.Gateway.HTTPSListenAddr, "https" + } + listener, err := net.Listen("tcp", listenAddr) + if err != nil { + logger.Fatal("bind gateway listener failed", zap.Error(err)) + } + defer listener.Close() logger.Info("gateway listening", - zap.String("addr", cfg.Gateway.HTTPListenAddr), + zap.String("addr", listenAddr), + zap.String("scheme", scheme), + zap.String("tls_mode", cfg.Gateway.TLS.Mode), zap.String("metrics_addr", cfg.Gateway.MetricsListenAddr), zap.String("scheduler", cfg.Gateway.SchedulerAddr), zap.String("query_only_scheduler", cfg.Gateway.QueryOnlySchedulerAddr), zap.Strings("sandbox_proxy_domains", s.SandboxProxyDomains()), ) httpServer := &http.Server{ - Addr: cfg.Gateway.HTTPListenAddr, - Handler: s.Handler(), + Addr: listenAddr, + Handler: s.Handler(), + TLSConfig: tlsConfig, } metricsServer := &http.Server{ Addr: cfg.Gateway.MetricsListenAddr, @@ -169,10 +198,19 @@ func main() { } go func() { - if err := httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { - logger.Fatal("gateway serve failed", zap.Error(err)) + var serveErr error + if tlsConfig == nil { + serveErr = httpServer.Serve(listener) + } else { + serveErr = httpServer.ServeTLS(listener, "", "") + } + if serveErr != nil && !errors.Is(serveErr, http.ErrServerClosed) { + logger.Fatal("gateway serve failed", zap.Error(serveErr)) } }() + if err := startCertificates(certificateCtx); err != nil { + logger.Fatal("start certificate management failed", zap.Error(err)) + } go func() { logger.Info("gateway metrics server listening", zap.String("addr", cfg.Gateway.MetricsListenAddr)) if err := metricsServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { @@ -196,3 +234,145 @@ func main() { logger.Warn("gateway metrics graceful shutdown failed", zap.Error(err)) } } + +func prepareTLS(cfg config.GatewayConfig, logger *zap.Logger) ( + tlsConfig *tls.Config, start func(context.Context) error, closeTLS func(), err error, +) { + start = func(context.Context) error { return nil } + closeTLS = func() {} + switch cfg.TLS.Mode { + case "", "off": + return nil, start, closeTLS, nil + case "manual": + cert, loadErr := tls.LoadX509KeyPair(cfg.TLS.CertFile, cfg.TLS.KeyFile) + if loadErr != nil { + return nil, start, closeTLS, fmt.Errorf("load gateway TLS certificate: %w", loadErr) + } + return &tls.Config{ + MinVersion: tls.VersionTLS12, + Certificates: []tls.Certificate{cert}, + }, start, closeTLS, nil + case "acme": + default: + return nil, start, closeTLS, errors.New("unsupported gateway TLS mode") + } + + host, port, err := net.SplitHostPort(cfg.HTTPSListenAddr) + if err != nil { + return nil, start, closeTLS, err + } + portNumber, err := strconv.Atoi(port) + if err != nil { + return nil, start, closeTLS, err + } + var solver *certmagic.DNS01Solver + if cfg.TLS.Challenge == "dns-01" { + provider, providerErr := newDNSProvider(cfg.TLS.DNSProvider, cfg.TLS.DNSConfig) + if providerErr != nil { + return nil, start, closeTLS, providerErr + } + solver = &certmagic.DNS01Solver{DNSManager: certmagic.DNSManager{ + DNSProvider: provider, + }} + } + if err := os.MkdirAll(cfg.TLS.StorageDir, 0o700); err != nil { + return nil, start, closeTLS, fmt.Errorf("create TLS storage: %w", err) + } + var magic *certmagic.Config + cache := certmagic.NewCache(certmagic.CacheOptions{ + GetConfigForCert: func(certmagic.Certificate) (*certmagic.Config, error) { + return magic, nil + }, + Logger: logger, + }) + defaultName := "" + for _, name := range cfg.TLS.Domains { + if ip := net.ParseIP(name); ip != nil { + defaultName = ip.String() + break + } + } + magic = certmagic.New(cache, certmagic.Config{ + // IP clients omit SNI; behind Docker/NAT the local address is not the public IP. + DefaultServerName: defaultName, + Storage: &certmagic.FileStorage{Path: cfg.TLS.StorageDir}, + Logger: logger, + }) + issuer := certmagic.ACMEIssuer{ + CA: cfg.TLS.CA, + Profile: certificateProfile(cfg.TLS.CA, cfg.TLS.Domains), + Email: cfg.TLS.Email, + Agreed: true, + DisableHTTPChallenge: true, + DisableTLSALPNChallenge: solver != nil, + ListenHost: host, + AltTLSALPNPort: portNumber, + Logger: logger, + } + if solver != nil { + issuer.DNS01Solver = solver + } + magic.Issuers = []certmagic.Issuer{certmagic.NewACMEIssuer(magic, issuer)} + tlsConfig = magic.TLSConfig() + tlsConfig.NextProtos = append([]string{"h2", "http/1.1"}, tlsConfig.NextProtos...) + start = func(ctx context.Context) error { + return magic.ManageAsync(ctx, cfg.TLS.Domains) + } + return tlsConfig, start, cache.Stop, nil +} + +// Let's Encrypt requires the shortlived profile for IP certificates. Apply it +// to this issuer's whole identifier set; custom CAs retain their own defaults. +func certificateProfile(ca string, identifiers []string) string { + if ca != "" && ca != certmagic.LetsEncryptProductionCA && ca != certmagic.LetsEncryptStagingCA { + return "" + } + for _, identifier := range identifiers { + if net.ParseIP(identifier) != nil { + return "shortlived" + } + } + return "" +} + +func newDNSProvider(name string, raw json.RawMessage) (certmagic.DNSProvider, error) { + var provider certmagic.DNSProvider + switch name { + case "cloudflare": + provider = &cloudflare.Provider{} + case "alidns": + provider = &alidns.Provider{} + case "tencentcloud": + provider = &tencentcloud.Provider{} + case "route53": + provider = &route53.Provider{} + default: + return nil, errors.New("unsupported gateway.tls.dns_provider") + } + var object map[string]json.RawMessage + if err := json.Unmarshal(raw, &object); err != nil || object == nil { + return nil, errors.New("gateway.tls.dns_config must be a JSON object") + } + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(provider); err != nil { + // Decoder errors may contain user-supplied keys or values; do not log them. + return nil, errors.New("invalid gateway.tls.dns_config: check provider field names and types") + } + missing := false + switch p := provider.(type) { + case *cloudflare.Provider: + missing = strings.TrimSpace(p.APIToken) == "" + case *alidns.Provider: + missing = strings.TrimSpace(p.AccessKeyID) == "" || strings.TrimSpace(p.AccessKeySecret) == "" + case *tencentcloud.Provider: + missing = strings.TrimSpace(p.SecretId) == "" || strings.TrimSpace(p.SecretKey) == "" + case *route53.Provider: + // Empty credentials allow the AWS SDK credential chain (roles, env, profiles). + missing = (strings.TrimSpace(p.AccessKeyId) == "") != (strings.TrimSpace(p.SecretAccessKey) == "") + } + if missing { + return nil, errors.New("gateway.tls.dns_config is missing required provider credentials") + } + return provider, nil +} diff --git a/services/gateway/cmd/main_test.go b/services/gateway/cmd/main_test.go index f384460e5..b10b467fb 100644 --- a/services/gateway/cmd/main_test.go +++ b/services/gateway/cmd/main_test.go @@ -1,11 +1,37 @@ package main import ( + "bufio" + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/sha1" + "crypto/tls" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/pem" + "fmt" + "io" + "math/big" + "net" + "net/http" + "net/http/httptest" "os" "path/filepath" "strings" "syscall" "testing" + "time" + + schedulerv1 "agentenv/services/api/proto" + gateway "agentenv/services/gateway/internal" + "agentenv/services/shared/config" + + "github.com/caddyserver/certmagic" + "go.uber.org/zap" + "google.golang.org/grpc" ) const testAPIKey = "e2b_0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" @@ -109,3 +135,373 @@ func TestLoadAPIKeyAllowsSymlinkedSecret(t *testing.T) { t.Fatalf("loadAPIKeyFrom() = %q, want %q", got, testAPIKey) } } + +func localCertificate(t *testing.T) (string, string, *x509.CertPool) { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + template := &x509.Certificate{ + SerialNumber: big.NewInt(1), NotBefore: time.Now().Add(-time.Minute), + NotAfter: time.Now().Add(time.Hour), IPAddresses: []net.IP{net.ParseIP("127.0.0.1")}, + KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + } + der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) + if err != nil { + t.Fatal(err) + } + keyDER, err := x509.MarshalECPrivateKey(key) + if err != nil { + t.Fatal(err) + } + dir := t.TempDir() + certPath, keyPath := filepath.Join(dir, "cert.pem"), filepath.Join(dir, "key.pem") + certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) + for path, data := range map[string][]byte{ + certPath: certPEM, + keyPath: pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}), + } { + if err := os.WriteFile(path, data, 0o600); err != nil { + t.Fatal(err) + } + } + roots := x509.NewCertPool() + if !roots.AppendCertsFromPEM(certPEM) { + t.Fatal("cannot trust test certificate") + } + return certPath, keyPath, roots +} + +type tlsTestScheduler struct { + schedulerv1.SchedulerClient + endpoint string +} + +func (s tlsTestScheduler) LookupNode(context.Context, *schedulerv1.LookupNodeRequest, ...grpc.CallOption) (*schedulerv1.LookupNodeResponse, error) { + return &schedulerv1.LookupNodeResponse{Node: &schedulerv1.Node{Endpoint: s.endpoint}}, nil +} + +func TestManualTLSGatewayToPlainHTTPNode(t *testing.T) { + cert, key, roots := localCertificate(t) + cfg := config.GatewayConfig{TLS: config.GatewayTLSConfig{Mode: "manual", CertFile: cert, KeyFile: key}} + tlsConfig, start, cleanup, err := prepareTLS(cfg, zap.NewNop()) + if err != nil { + t.Fatal(err) + } + defer cleanup() + if err := start(context.Background()); err != nil { + t.Fatal(err) + } + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.TLS != nil { + t.Error("node received TLS instead of HTTP") + } + if got := r.Header.Get("X-Forwarded-Proto"); got != "https" { + t.Errorf("forwarded proto = %q", got) + } + if r.URL.RequestURI() != "/proxy/files/%2F?test=1" { + t.Errorf("encoded path was changed: %s", r.URL.RequestURI()) + } + if r.Header.Get("x-agentenv-sandbox-id") != "sandbox" || r.Header.Get("x-agentenv-target-port") != "8080" { + t.Error("host-based sandbox routing was lost") + } + w.Write([]byte("node-response")) + })) + defer backend.Close() + gw, err := gateway.NewServer(zap.NewNop(), tlsTestScheduler{endpoint: backend.URL}, gateway.ServerOptions{ + APIKey: testAPIKey, RequestTimeout: time.Second, SandboxProxyDomains: []string{"sandbox.example.com"}, + }) + if err != nil { + t.Fatal(err) + } + front := httptest.NewUnstartedServer(gw.Handler()) + front.TLS = tlsConfig + front.StartTLS() + defer front.Close() + transport := &http.Transport{TLSClientConfig: &tls.Config{RootCAs: roots}} + defer transport.CloseIdleConnections() + client := &http.Client{Transport: transport, Timeout: 5 * time.Second} + req, _ := http.NewRequest("GET", front.URL+"/files/%2F?test=1", nil) + req.Host = "8080-sandbox.sandbox.example.com" + req.Header.Set("X-API-Key", testAPIKey) + resp, err := client.Do(req) + if err != nil { + t.Fatal(err) + } + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + if resp.StatusCode != http.StatusOK || string(body) != "node-response" { + t.Fatalf("gateway response: %d %s", resp.StatusCode, body) + } + req.Header.Del("X-API-Key") + // Use the API host to exercise gateway API-key authentication. + req.Host = "api.example.com" + resp, err = client.Do(req) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("unauthenticated request returned %d", resp.StatusCode) + } + // HTTPS-only endpoints must reject plaintext; no silent HTTP fallback. + resp, err = http.Get("http://" + front.Listener.Addr().String() + "/health") + if err == nil { + resp.Body.Close() + if resp.StatusCode < 400 { + t.Fatalf("plaintext request unexpectedly succeeded: %d", resp.StatusCode) + } + } +} + +func TestPrepareTLSModes(t *testing.T) { + tlsConfig, _, closeTLS, err := prepareTLS(config.GatewayConfig{}, zap.NewNop()) + if err != nil || tlsConfig != nil { + t.Fatalf("default should remain HTTP: %v", err) + } + closeTLS() + _, _, _, err = prepareTLS(config.GatewayConfig{TLS: config.GatewayTLSConfig{ + Mode: "manual", CertFile: "missing-cert", KeyFile: "missing-key", + }}, zap.NewNop()) + if err == nil { + t.Fatal("missing certificates accepted") + } + _, _, _, err = prepareTLS(config.GatewayConfig{ + HTTPSListenAddr: ":8443", + TLS: config.GatewayTLSConfig{Mode: "acme", Challenge: "dns-01"}, + }, zap.NewNop()) + if err == nil { + t.Fatal("missing DNS credential accepted") + } + // Construction alone must not contact a CA; issuance starts explicitly. + tlsConfig, _, closeTLS, err = prepareTLS(config.GatewayConfig{ + HTTPSListenAddr: ":8443", + TLS: config.GatewayTLSConfig{Mode: "acme", StorageDir: t.TempDir()}, + }, zap.NewNop()) + if err != nil { + t.Fatal(err) + } + defer closeTLS() + found := false + for _, protocol := range tlsConfig.NextProtos { + found = found || protocol == "acme-tls/1" + } + if !found || tlsConfig.GetCertificate == nil || tlsConfig.MinVersion < tls.VersionTLS12 { + t.Fatal("ACME listener lacks TLS-ALPN or certificate callback") + } +} + +func TestTLSGatewayStreamingAndWebSocket(t *testing.T) { + cert, key, roots := localCertificate(t) + tlsConfig, _, cleanup, err := prepareTLS(config.GatewayConfig{ + TLS: config.GatewayTLSConfig{Mode: "manual", CertFile: cert, KeyFile: key}, + }, zap.NewNop()) + if err != nil { + t.Fatal(err) + } + defer cleanup() + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/proxy/events": + w.Header().Set("Content-Type", "text/event-stream") + fmt.Fprint(w, "data: first\n\n") + w.(http.Flusher).Flush() + // The client must receive the first event before this response ends. + <-r.Context().Done() + case "/proxy/ws": + conn, rw, err := w.(http.Hijacker).Hijack() + if err != nil { + t.Error(err) + return + } + defer conn.Close() + conn.SetDeadline(time.Now().Add(5 * time.Second)) + hash := sha1.Sum([]byte(r.Header.Get("Sec-WebSocket-Key") + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11")) + fmt.Fprintf(rw, "HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: websocket\r\nSec-WebSocket-Accept: %s\r\n\r\n", + base64.StdEncoding.EncodeToString(hash[:])) + rw.Flush() + frame := make([]byte, 8) + if _, err := io.ReadFull(rw, frame); err != nil { + t.Error(err) + return + } + if frame[0] != 0x81 || frame[1] != 0x82 || frame[6]^frame[2] != 'h' || frame[7]^frame[3] != 'i' { + t.Error("WebSocket frame changed in transit") + return + } + rw.Write([]byte{0x81, 2, 'o', 'k'}) + rw.Flush() + default: + http.NotFound(w, r) + } + })) + defer backend.Close() + gw, err := gateway.NewServer(zap.NewNop(), tlsTestScheduler{endpoint: backend.URL}, gateway.ServerOptions{ + APIKey: testAPIKey, RequestTimeout: time.Second, SandboxProxyDomains: []string{"sandbox.example.com"}, + }) + if err != nil { + t.Fatal(err) + } + front := httptest.NewUnstartedServer(gw.Handler()) + front.TLS = tlsConfig + front.StartTLS() + defer front.Close() + t.Run("SSE", func(t *testing.T) { + transport := &http.Transport{TLSClientConfig: &tls.Config{RootCAs: roots}} + defer transport.CloseIdleConnections() + client := &http.Client{Transport: transport, Timeout: 5 * time.Second} + req, _ := http.NewRequest("GET", front.URL+"/events", nil) + req.Host = "8080-sandbox.sandbox.example.com" + req.Header.Set("Accept", "text/event-stream") + resp, err := client.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + line, err := bufio.NewReader(resp.Body).ReadString('\n') + if err != nil || line != "data: first\n" { + t.Fatalf("event did not arrive before response completion: %q, %v", line, err) + } + }) + t.Run("WSS", func(t *testing.T) { + conn, err := tls.DialWithDialer(&net.Dialer{Timeout: 5 * time.Second}, "tcp", + front.Listener.Addr().String(), &tls.Config{RootCAs: roots}) + if err != nil { + t.Fatal(err) + } + defer conn.Close() + conn.SetDeadline(time.Now().Add(5 * time.Second)) + fmt.Fprint(conn, "GET /ws HTTP/1.1\r\nHost: 8080-sandbox.sandbox.example.com\r\nConnection: Upgrade\r\nUpgrade: websocket\r\nSec-WebSocket-Version: 13\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\r\n") + reader := bufio.NewReader(conn) + status, err := reader.ReadString('\n') + if err != nil || !strings.Contains(status, "101") { + t.Fatalf("WebSocket upgrade failed: %q %v", status, err) + } + for { + line, err := reader.ReadString('\n') + if err != nil { + t.Fatal(err) + } + if line == "\r\n" { + break + } + } + conn.Write([]byte{0x81, 0x82, 1, 2, 3, 4, 'h' ^ 1, 'i' ^ 2}) + frame := make([]byte, 4) + if _, err := io.ReadFull(reader, frame); err != nil { + t.Fatal(err) + } + if string(frame) != "\x81\x02ok" { + t.Fatalf("bad WebSocket reply: %x", frame) + } + }) +} + +func TestIPCertificateProfile(t *testing.T) { + for _, tc := range []struct { + ca string + names []string + want string + }{ + {"", []string{"8.8.8.8"}, "shortlived"}, + {certmagic.LetsEncryptProductionCA, []string{"2606:4700:4700::1111"}, "shortlived"}, + {certmagic.LetsEncryptStagingCA, []string{"api.example.com", "8.8.8.8"}, "shortlived"}, + {"", []string{"api.example.com"}, ""}, + {"https://ca.example.com/directory", []string{"8.8.8.8"}, ""}, + } { + if got := certificateProfile(tc.ca, tc.names); got != tc.want { + t.Errorf("profile(%q, %v) = %q, want %q", tc.ca, tc.names, got, tc.want) + } + } +} + +func TestACMEIPLookupWithoutSNI(t *testing.T) { + cfg := config.GatewayConfig{HTTPSListenAddr: ":8443", TLS: config.GatewayTLSConfig{ + Mode: "acme", Domains: []string{"8.8.8.8"}, StorageDir: t.TempDir(), + }} + tlsConfig, _, cleanup, err := prepareTLS(cfg, zap.NewNop()) + if err != nil { + t.Fatal(err) + } + defer cleanup() + // No issuance is started. The missing-certificate error must name the public + // identifier, not the socket's private/local address (or an empty SNI). + conn, peer := net.Pipe() + defer conn.Close() + defer peer.Close() + _, err = tlsConfig.GetCertificate(&tls.ClientHelloInfo{Conn: conn}) + if err == nil || !strings.Contains(err.Error(), "8.8.8.8") { + t.Fatalf("unexpected no-SNI certificate lookup: %v", err) + } +} + +func TestPrepareDNSUsesConfiguredToken(t *testing.T) { + // Prepare only: this must not contact Cloudflare or a CA. + _, _, cleanup, err := prepareTLS(config.GatewayConfig{ + HTTPSListenAddr: ":8080", + TLS: config.GatewayTLSConfig{Mode: "acme", Challenge: "dns-01", DNSProvider: "cloudflare", DNSConfig: []byte(`{"api_token":"test-config-token"}`), StorageDir: t.TempDir()}, + }, zap.NewNop()) + if err != nil { + t.Fatal("failed to prepare DNS-01 using configured credential") + } + defer cleanup() +} + +func TestDNSProviders(t *testing.T) { + for _, tc := range []struct{ name, raw, kind string }{ + {"cloudflare", `{"api_token":"test-token","zone_token":"test-zone"}`, "*cloudflare.Provider"}, + {"alidns", `{"access_key_id":"test-id","access_key_secret":"test-secret","security_token":"test-session"}`, "*alidns.Provider"}, + {"tencentcloud", `{"SecretId":"test-id","SecretKey":"test-secret","SessionToken":"test-session"}`, "*tencentcloud.Provider"}, + {"route53", `{"access_key_id":"test-id","secret_access_key":"test-secret","region":"us-east-1","max_retries":3}`, "*route53.Provider"}, + {"route53", `{}`, "*route53.Provider"}, + } { + t.Run(tc.name+tc.raw, func(t *testing.T) { + provider, err := newDNSProvider(tc.name, []byte(tc.raw)) + if err != nil { + t.Fatal(err) + } + if fmt.Sprintf("%T", provider) != tc.kind { + t.Fatal("wrong provider type") + } + // Verify provider-native fields survive decoding, including non-string options. + encoded, err := json.Marshal(provider) + if err != nil { + t.Fatal(err) + } + var want, got map[string]any + json.Unmarshal([]byte(tc.raw), &want) + json.Unmarshal(encoded, &got) + for key, value := range want { + if got[key] != value { + t.Fatalf("provider field %s not forwarded", key) + } + } + _, _, cleanup, err := prepareTLS(config.GatewayConfig{HTTPSListenAddr: ":8080", TLS: config.GatewayTLSConfig{Mode: "acme", Challenge: "dns-01", DNSProvider: tc.name, DNSConfig: []byte(tc.raw), StorageDir: t.TempDir()}}, zap.NewNop()) + if err != nil { + t.Fatal(err) + } + cleanup() + }) + } +} + +func TestDNSProviderRejectsInvalidConfig(t *testing.T) { + for _, tc := range []struct{ name, raw string }{ + {"unknown", `{}`}, {"cloudflare", `null`}, {"cloudflare", `[]`}, + {"cloudflare", `{}`}, {"cloudflare", `{"api_token":123}`}, + {"cloudflare", `{"api_token":"test","sensitive-unknown-key":"secret"}`}, + {"cloudflare", `{"api_token":"test"} {}`}, + {"alidns", `{"access_key_id":"test"}`}, + {"tencentcloud", `{"secret_id":"test","secret_key":"secret"}`}, + {"route53", `{"access_key_id":"test"}`}, + } { + _, err := newDNSProvider(tc.name, []byte(tc.raw)) + if err == nil { + t.Fatalf("accepted invalid config for %s", tc.name) + } + if strings.Contains(err.Error(), "sensitive-unknown-key") || strings.Contains(err.Error(), "secret") { + t.Fatal("error exposed credentials") + } + } +} diff --git a/services/go.mod b/services/go.mod index 9f0bf6f6e..c16261e73 100644 --- a/services/go.mod +++ b/services/go.mod @@ -3,7 +3,12 @@ module agentenv/services go 1.25.0 require ( + github.com/caddyserver/certmagic v0.24.0 github.com/hashicorp/golang-lru/v2 v2.0.7 + github.com/libdns/alidns v1.0.7 + github.com/libdns/cloudflare v0.2.2 + github.com/libdns/route53 v1.6.2 + github.com/libdns/tencentcloud v1.4.3 github.com/prometheus/client_golang v1.20.5 github.com/redis/go-redis/v9 v9.7.3 go.uber.org/zap v1.27.0 @@ -16,7 +21,22 @@ require ( ) require ( + github.com/aws/aws-sdk-go-v2 v1.39.1 // indirect + github.com/aws/aws-sdk-go-v2/config v1.31.10 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.18.14 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.8 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.8 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.8 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.8 // indirect + github.com/aws/aws-sdk-go-v2/service/route53 v1.58.3 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.29.4 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.0 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.38.5 // indirect + github.com/aws/smithy-go v1.23.0 // indirect github.com/beorn7/perks v1.0.1 // indirect + github.com/caddyserver/zerossl v0.1.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect @@ -34,20 +54,30 @@ require ( github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/compress v1.18.7 // indirect + github.com/klauspost/cpuid/v2 v2.2.10 // indirect + github.com/libdns/libdns v1.1.1 // indirect github.com/mailru/easyjson v0.7.7 // indirect + github.com/mholt/acmez/v3 v3.1.2 // indirect + github.com/miekg/dns v1.1.63 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/prometheus/client_model v0.6.1 // indirect github.com/prometheus/common v0.55.0 // indirect github.com/prometheus/procfs v0.15.1 // indirect - go.uber.org/multierr v1.10.0 // indirect + github.com/zeebo/blake3 v0.2.4 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.uber.org/zap/exp v0.3.0 // indirect + golang.org/x/crypto v0.55.0 // indirect + golang.org/x/mod v0.38.0 // indirect golang.org/x/net v0.58.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect + golang.org/x/sync v0.22.0 // indirect golang.org/x/sys v0.47.0 // indirect golang.org/x/term v0.45.0 // indirect golang.org/x/text v0.41.0 // indirect golang.org/x/time v0.3.0 // indirect + golang.org/x/tools v0.48.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect diff --git a/services/go.sum b/services/go.sum index 65c7c0af1..8a0ff827b 100644 --- a/services/go.sum +++ b/services/go.sum @@ -1,9 +1,41 @@ +github.com/aws/aws-sdk-go-v2 v1.39.1 h1:fWZhGAwVRK/fAN2tmt7ilH4PPAE11rDj7HytrmbZ2FE= +github.com/aws/aws-sdk-go-v2 v1.39.1/go.mod h1:sDioUELIUO9Znk23YVmIk86/9DOpkbyyVb1i/gUNFXY= +github.com/aws/aws-sdk-go-v2/config v1.31.10 h1:7LllDZAegXU3yk41mwM6KcPu0wmjKGQB1bg99bNdQm4= +github.com/aws/aws-sdk-go-v2/config v1.31.10/go.mod h1:Ge6gzXPjqu4v0oHvgAwvGzYcK921GU0hQM25WF/Kl+8= +github.com/aws/aws-sdk-go-v2/credentials v1.18.14 h1:TxkI7QI+sFkTItN/6cJuMZEIVMFXeu2dI1ZffkXngKI= +github.com/aws/aws-sdk-go-v2/credentials v1.18.14/go.mod h1:12x4Uw/vijC11XkctTjy92TNCQ+UnNJkT7fzX0Yd93E= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.8 h1:gLD09eaJUdiszm7vd1btiQUYE0Hj+0I2b8AS+75z9AY= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.8/go.mod h1:4RW3oMPt1POR74qVOC4SbubxAwdP4pCT0nSw3jycOU4= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.8 h1:6bgAZgRyT4RoFWhxS+aoGMFyE0cD1bSzFnEEi4bFPGI= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.8/go.mod h1:KcGkXFVU8U28qS4KvLEcPxytPZPBcRawaH2Pf/0jptE= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.8 h1:HhJYoES3zOz34yWEpGENqJvRVPqpmJyR3+AFg9ybhdY= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.8/go.mod h1:JnA+hPWeYAVbDssp83tv+ysAG8lTfLVXvSsyKg/7xNA= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 h1:bIqFDwgGXXN1Kpp99pDOdKMTTb5d2KyU5X/BZxjOkRo= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3/go.mod h1:H5O/EsxDWyU+LP/V8i5sm8cxoZgc2fdNR9bxlOFrQTo= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1 h1:oegbebPEMA/1Jny7kvwejowCaHz1FWZAQ94WXFNCyTM= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.1/go.mod h1:kemo5Myr9ac0U9JfSjMo9yHLtw+pECEHsFtJ9tqCEI8= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.8 h1:M6JI2aGFEzYxsF6CXIuRBnkge9Wf9a2xU39rNeXgu10= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.8/go.mod h1:Fw+MyTwlwjFsSTE31mH211Np+CUslml8mzc0AFEG09s= +github.com/aws/aws-sdk-go-v2/service/route53 v1.58.3 h1:jQzRC+0eI/l5mFXVoPTyyolrqyZtKIYaKHSuKJoIJKs= +github.com/aws/aws-sdk-go-v2/service/route53 v1.58.3/go.mod h1:1GNaojT/gG4Ru9tT39ton6kRZ3FvptJ/QRKBoqUOVX4= +github.com/aws/aws-sdk-go-v2/service/sso v1.29.4 h1:FTdEN9dtWPB0EOURNtDPmwGp6GGvMqRJCAihkSl/1No= +github.com/aws/aws-sdk-go-v2/service/sso v1.29.4/go.mod h1:mYubxV9Ff42fZH4kexj43gFPhgc/LyC7KqvUKt1watc= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.0 h1:I7ghctfGXrscr7r1Ga/mDqSJKm7Fkpl5Mwq79Z+rZqU= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.0/go.mod h1:Zo9id81XP6jbayIFWNuDpA6lMBWhsVy+3ou2jLa4JnA= +github.com/aws/aws-sdk-go-v2/service/sts v1.38.5 h1:+LVB0xBqEgjQoqr9bGZbRzvg212B0f17JdflleJRNR4= +github.com/aws/aws-sdk-go-v2/service/sts v1.38.5/go.mod h1:xoaxeqnnUaZjPjaICgIy5B+MHCSb/ZSOn4MvkFNOUA0= +github.com/aws/smithy-go v1.23.0 h1:8n6I3gXzWJB2DxBDnfxgBaSX6oe0d/t10qGz7OKqMCE= +github.com/aws/smithy-go v1.23.0/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/caddyserver/certmagic v0.24.0 h1:EfXTWpxHAUKgDfOj6MHImJN8Jm4AMFfMT6ITuKhrDF0= +github.com/caddyserver/certmagic v0.24.0/go.mod h1:xPT7dC1DuHHnS2yuEQCEyks+b89sUkMENh8dJF+InLE= +github.com/caddyserver/zerossl v0.1.3 h1:onS+pxp3M8HnHpN5MMbOMyNjmTheJyWRaZYwn+YTAyA= +github.com/caddyserver/zerossl v0.1.3/go.mod h1:CxA0acn7oEGO6//4rtrRjYgEoa4MFw/XofZnrYwGqG4= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= @@ -53,6 +85,8 @@ github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.18.7 h1:aUyZsS4kH3QTKurYhAOwAHxllVPnOthb3vPfnF1Ehjw= github.com/klauspost/compress v1.18.7/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= +github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= @@ -62,8 +96,22 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/libdns/alidns v1.0.7 h1:0BhTUFlWIUyb5U7wujGqsyoeZkleUR/hMCtdZDS8Oc8= +github.com/libdns/alidns v1.0.7/go.mod h1:RECwyQ88e9VqQVtSrvX76o1ux3gQUKGzMgxICi+u7Ec= +github.com/libdns/cloudflare v0.2.2 h1:XWHv+C1dDcApqazlh08Q6pjytYLgR2a+Y3xrXFu0vsI= +github.com/libdns/cloudflare v0.2.2/go.mod h1:w9uTmRCDlAoafAsTPnn2nJ0XHK/eaUMh86DUk8BWi60= +github.com/libdns/libdns v1.1.1 h1:wPrHrXILoSHKWJKGd0EiAVmiJbFShguILTg9leS/P/U= +github.com/libdns/libdns v1.1.1/go.mod h1:4Bj9+5CQiNMVGf87wjX4CY3HQJypUHRuLvlsfsZqLWQ= +github.com/libdns/route53 v1.6.2 h1:unPlpgC2InQ/xrql5NOwCmFS9vZrRx8lH1WUo8/rjk8= +github.com/libdns/route53 v1.6.2/go.mod h1:7QGcw/2J0VxcVwHsPYpuo1I6IJLHy77bbOvi1BVK3eE= +github.com/libdns/tencentcloud v1.4.3 h1:xJHYLL1TdPeOtUr6Bu6dHTd1TU6/VFm7BFc2EAzAlvc= +github.com/libdns/tencentcloud v1.4.3/go.mod h1:Be9gY3tDa12DuAPU79RV9NZIcjY6qg5s7zKPsP26yAM= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mholt/acmez/v3 v3.1.2 h1:auob8J/0FhmdClQicvJvuDavgd5ezwLBfKuYmynhYzc= +github.com/mholt/acmez/v3 v3.1.2/go.mod h1:L1wOU06KKvq7tswuMDwKdcHeKpFFgkppZy/y0DFxagQ= +github.com/miekg/dns v1.1.63 h1:8M5aAw6OMZfFXTT7K5V0Eu5YiiL8l7nUAkyN6C9YwaY= +github.com/miekg/dns v1.1.63/go.mod h1:6NGHfjhpmr5lt3XPLuyfDJi5AXbNIPM9PY6H6sF1Nfs= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -102,6 +150,12 @@ github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsT github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/zeebo/assert v1.1.0 h1:hU1L1vLTHsnO8x8c9KAR5GmM5QscxHg5RNU5z5qbUWY= +github.com/zeebo/assert v1.1.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= +github.com/zeebo/blake3 v0.2.4 h1:KYQPkhpRtcqh0ssGYcKLG1JYvddkEA8QwCM/yBqhaZI= +github.com/zeebo/blake3 v0.2.4/go.mod h1:7eeQ6d2iXWRGF6npfaxl2CU+xy2Fjo2gxeyZGCRUjcE= +github.com/zeebo/pcg v1.0.1 h1:lyqfGeWiv4ahac6ttHs+I5hwtH/+1mrhlCtVNQM2kHo= +github.com/zeebo/pcg v1.0.1/go.mod h1:09F0S9iiKrwn9rlI5yjLkmrug154/YRW6KnnXVDM/l4= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= @@ -116,15 +170,21 @@ go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/ go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= -go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= -go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.uber.org/zap/exp v0.3.0 h1:6JYzdifzYkGmTdRR59oYH+Ng7k49H9qVpWwNSsGJj3U= +go.uber.org/zap/exp v0.3.0/go.mod h1:5I384qq7XGxYyByIhHm6jg5CHkGY0nsTfbDLgDDlgJQ= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= +golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= +golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= @@ -136,6 +196,8 @@ golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7 golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= diff --git a/services/shared/config/config.go b/services/shared/config/config.go index 7977084e2..0e33a3af2 100644 --- a/services/shared/config/config.go +++ b/services/shared/config/config.go @@ -5,6 +5,9 @@ import ( "encoding/json" "errors" "fmt" + "net" + "net/netip" + "net/url" "os" "strconv" "strings" @@ -155,14 +158,38 @@ func parseSchedulerDuration(raw json.RawMessage, field string) (time.Duration, e return 0, fmt.Errorf("%s must be a duration string like \"30s\"", field) } +type GatewayTLSConfig struct { + Mode string `json:"mode"` + CertFile string `json:"cert_file"` + KeyFile string `json:"key_file"` + Domains []string `json:"domains"` + StorageDir string `json:"storage_dir"` + Email string `json:"email"` + CA string `json:"ca"` + Challenge string `json:"challenge"` + DNSProvider string `json:"dns_provider"` + // Sensitive: never include this field in logs. + DNSConfig json.RawMessage `json:"dns_config,omitempty"` +} + +func (t GatewayTLSConfig) MarshalJSON() ([]byte, error) { + type plain GatewayTLSConfig + return json.Marshal(struct { + plain + DNSConfig *json.RawMessage `json:"dns_config,omitempty"` + }{plain: plain(t)}) +} + type GatewayConfig struct { - HTTPListenAddr string `json:"http_listen_addr"` - MetricsListenAddr string `json:"metrics_listen_addr"` - SchedulerAddr string `json:"scheduler_addr"` - QueryOnlySchedulerAddr string `json:"query_only_scheduler_addr"` - RequestTimeout time.Duration `json:"request_timeout"` - ForwardResponseSize int64 `json:"forward_response_size"` - SandboxProxyDomains []string `json:"sandbox_proxy_domains"` + HTTPSListenAddr string `json:"https_listen_addr"` + TLS GatewayTLSConfig `json:"tls"` + HTTPListenAddr string `json:"http_listen_addr"` + MetricsListenAddr string `json:"metrics_listen_addr"` + SchedulerAddr string `json:"scheduler_addr"` + QueryOnlySchedulerAddr string `json:"query_only_scheduler_addr"` + RequestTimeout time.Duration `json:"request_timeout"` + ForwardResponseSize int64 `json:"forward_response_size"` + SandboxProxyDomains []string `json:"sandbox_proxy_domains"` // DebugMode enables debug-only behaviors in the gateway such as exposing // the backend node id on proxied responses. It is off by default. DebugMode bool `json:"debug_mode"` @@ -170,14 +197,16 @@ type GatewayConfig struct { func (g *GatewayConfig) UnmarshalJSON(data []byte) error { type wire struct { - HTTPListenAddr *string `json:"http_listen_addr"` - MetricsListenAddr *string `json:"metrics_listen_addr"` - SchedulerAddr *string `json:"scheduler_addr"` - QueryOnlySchedulerAddr *string `json:"query_only_scheduler_addr"` - RequestTimeout json.RawMessage `json:"request_timeout"` - ForwardResponseSize *int64 `json:"forward_response_size"` - SandboxProxyDomains *[]string `json:"sandbox_proxy_domains"` - DebugMode *bool `json:"debug_mode"` + HTTPSListenAddr *string `json:"https_listen_addr"` + TLS *GatewayTLSConfig `json:"tls"` + HTTPListenAddr *string `json:"http_listen_addr"` + MetricsListenAddr *string `json:"metrics_listen_addr"` + SchedulerAddr *string `json:"scheduler_addr"` + QueryOnlySchedulerAddr *string `json:"query_only_scheduler_addr"` + RequestTimeout json.RawMessage `json:"request_timeout"` + ForwardResponseSize *int64 `json:"forward_response_size"` + SandboxProxyDomains *[]string `json:"sandbox_proxy_domains"` + DebugMode *bool `json:"debug_mode"` } parsed := wire{} @@ -185,6 +214,12 @@ func (g *GatewayConfig) UnmarshalJSON(data []byte) error { return err } + if parsed.HTTPSListenAddr != nil { + g.HTTPSListenAddr = *parsed.HTTPSListenAddr + } + if parsed.TLS != nil { + g.TLS = *parsed.TLS + } if parsed.HTTPListenAddr != nil { g.HTTPListenAddr = *parsed.HTTPListenAddr } @@ -298,6 +333,8 @@ func defaultConfig(service string) Config { }, }, Gateway: GatewayConfig{ + HTTPSListenAddr: ":8080", + TLS: GatewayTLSConfig{Mode: "off"}, HTTPListenAddr: ":8080", MetricsListenAddr: ":9102", SchedulerAddr: "127.0.0.1:9090", @@ -320,6 +357,7 @@ func overrideWithEnv(cfg *Config) error { set("SCHEDULER_METRICS_LISTEN_ADDR", &cfg.Scheduler.MetricsListenAddr) set("SCHEDULER_STRATEGY", &cfg.Scheduler.Strategy) set("SCHEDULER_REDIS_ADDR", &cfg.Scheduler.RedisAddr) + set("GATEWAY_HTTPS_LISTEN_ADDR", &cfg.Gateway.HTTPSListenAddr) set("GATEWAY_HTTP_LISTEN_ADDR", &cfg.Gateway.HTTPListenAddr) set("GATEWAY_METRICS_LISTEN_ADDR", &cfg.Gateway.MetricsListenAddr) set("GATEWAY_SCHEDULER_ADDR", &cfg.Gateway.SchedulerAddr) @@ -385,6 +423,13 @@ func splitCommaSeparated(raw string) []string { } func (c *Config) applyDefaults() { + if c.Gateway.TLS.Mode == "" { + c.Gateway.TLS.Mode = "off" + } + if c.Gateway.TLS.Challenge == "" { + c.Gateway.TLS.Challenge = "tls-alpn-01" + } + if strings.TrimSpace(c.Scheduler.MetricsListenAddr) == "" { c.Scheduler.MetricsListenAddr = ":9101" } @@ -475,7 +520,10 @@ func (c Config) validate(schedulerQueryOnly bool) error { } } if c.Service == "gateway" { - if c.Gateway.HTTPListenAddr == "" { + if err := c.Gateway.validateTLS(); err != nil { + return err + } + if (c.Gateway.TLS.Mode == "" || c.Gateway.TLS.Mode == "off") && c.Gateway.HTTPListenAddr == "" { return errors.New("gateway.http_listen_addr is required") } if c.Gateway.MetricsListenAddr == "" { @@ -487,3 +535,116 @@ func (c Config) validate(schedulerQueryOnly bool) error { } return nil } + +func (g GatewayConfig) validateTLS() error { + t := g.TLS + switch t.Mode { + case "", "off": + return nil + case "manual", "acme": + default: + return errors.New("gateway.tls.mode must be off, manual, or acme") + } + _, port, err := net.SplitHostPort(g.HTTPSListenAddr) + n, portErr := strconv.Atoi(port) + if err != nil || portErr != nil || n < 1 || n > 65535 { + return errors.New("gateway.https_listen_addr must contain a valid TCP port") + } + if t.Mode == "manual" { + if t.CertFile == "" || t.KeyFile == "" { + return errors.New("gateway.tls manual mode requires cert_file and key_file") + } + return nil + } + if len(t.Domains) == 0 || strings.TrimSpace(t.StorageDir) == "" { + return errors.New("gateway.tls acme mode requires domains and persistent storage_dir") + } + if t.CA != "" { + u, err := url.Parse(t.CA) + if err != nil || u.Scheme != "https" || u.Host == "" || u.User != nil { + return errors.New("gateway.tls.ca must be an HTTPS ACME directory URL") + } + } + switch t.Challenge { + case "", "tls-alpn-01": + if t.DNSProvider != "" || len(t.DNSConfig) != 0 { + return errors.New("gateway.tls.dns_provider and dns_config require challenge=dns-01") + } + case "dns-01": + switch t.DNSProvider { + case "cloudflare", "alidns", "tencentcloud", "route53": + default: + return errors.New("gateway.tls.dns_provider must be cloudflare, alidns, tencentcloud, or route53") + } + var object map[string]json.RawMessage + if err := json.Unmarshal(t.DNSConfig, &object); err != nil || object == nil { + return errors.New("gateway.tls.dns_config must be a JSON object for dns-01") + } + default: + return errors.New("gateway.tls.challenge must be tls-alpn-01 or dns-01") + } + ipCount := 0 + for _, name := range t.Domains { + if ip, err := netip.ParseAddr(name); err == nil { + ipCount++ + if ipCount > 1 { + return errors.New("gateway.tls.domains supports one IP per listener (IP clients do not send SNI); DNS names may be combined with that IP") + } + if !publicCertificateIP(ip) { + return fmt.Errorf("gateway.tls.domains contains non-public or special-purpose IP %q; use manual mode for local certificates", name) + } + if t.Challenge == "dns-01" { + return errors.New("gateway.tls IP certificates require tls-alpn-01; DNS-01 cannot validate IP addresses") + } + continue + } + domain := strings.TrimPrefix(name, "*.") + if !validCertificateDomain(domain) { + return fmt.Errorf("gateway.tls.domains contains invalid DNS name %q (use manual mode for local IP certificates)", name) + } + if strings.HasPrefix(name, "*.") && t.Challenge != "dns-01" { + return errors.New("gateway.tls wildcard domains require challenge=dns-01") + } + } + return nil +} + +func validCertificateDomain(name string) bool { + if len(name) > 253 || !strings.Contains(name, ".") || net.ParseIP(name) != nil { + return false + } + for _, label := range strings.Split(name, ".") { + if len(label) == 0 || len(label) > 63 || label[0] == '-' || label[len(label)-1] == '-' { + return false + } + for _, c := range label { + if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '-') { + return false + } + } + } + return true +} + +// Conservative public-IP policy: exclude special-purpose ranges, including +// documentation, benchmarking and transition addresses. +// Reference: IANA IPv4/IPv6 Special-Purpose Address Registries (2025-10-09). +func publicCertificateIP(ip netip.Addr) bool { + if ip.Zone() != "" || ip.Is4In6() || !ip.IsGlobalUnicast() || ip.IsPrivate() { + return false + } + blocked := []string{ + "0.0.0.0/8", "100.64.0.0/10", "192.0.0.0/24", "192.0.2.0/24", + "192.88.99.0/24", "198.18.0.0/15", "198.51.100.0/24", "203.0.113.0/24", "240.0.0.0/4", + "2001::/23", "2001:db8::/32", "2002::/16", "3fff::/20", + } + if ip.Is6() && !netip.MustParsePrefix("2000::/3").Contains(ip) { + return false + } + for _, prefix := range blocked { + if netip.MustParsePrefix(prefix).Contains(ip) { + return false + } + } + return true +} diff --git a/services/shared/config/config_test.go b/services/shared/config/config_test.go index dbe47f0f2..bf7cc5892 100644 --- a/services/shared/config/config_test.go +++ b/services/shared/config/config_test.go @@ -1,9 +1,11 @@ package config import ( + "encoding/json" "fmt" "os" "path/filepath" + "strings" "testing" "time" ) @@ -603,3 +605,179 @@ func TestLoadRejectsIncompleteKubernetesSchedulerDiscoveryConfig(t *testing.T) { t.Fatal("expected load to fail for incomplete kubernetes discovery config") } } + +func TestTLSConfigLoadAndEnvironmentOverrides(t *testing.T) { + path := filepath.Join(t.TempDir(), "gateway.json") + if err := os.WriteFile(path, []byte(`{"gateway":{ + "https_listen_addr":":9443", + "tls":{"mode":"manual","cert_file":"cert.pem","key_file":"key.pem"} + }}`), 0o600); err != nil { + t.Fatal(err) + } + cfg, err := Load(path, "gateway") + if err != nil { + t.Fatal(err) + } + if cfg.Gateway.TLS.Mode != "manual" || cfg.Gateway.TLS.CertFile != "cert.pem" || cfg.Gateway.HTTPSListenAddr != ":9443" { + t.Fatalf("TLS configuration was not decoded: %+v", cfg.Gateway) + } + // Removed TLS environment overrides must not supersede the JSON mode. + t.Setenv("GATEWAY_TLS_MODE", "acme") + t.Setenv("GATEWAY_TLS_AGREE_TO_TERMS", "not-a-boolean") + t.Setenv("GATEWAY_HTTPS_LISTEN_ADDR", ":8080") + cfg, err = Load(path, "gateway") + if err != nil { + t.Fatal(err) + } + if cfg.Gateway.TLS.Mode != "manual" || cfg.Gateway.TLS.CertFile != "cert.pem" || cfg.Gateway.HTTPSListenAddr != ":8080" { + t.Fatalf("environment overrides were not applied: %+v", cfg.Gateway) + } +} + +func TestTLSConfigValidation(t *testing.T) { + valid := func() Config { + cfg := defaultConfig("gateway") + cfg.Gateway.TLS = GatewayTLSConfig{ + Mode: "acme", Domains: []string{"api.example.com"}, + StorageDir: "/data/tls", Challenge: "tls-alpn-01", + } + return cfg + } + for _, tc := range []struct { + name string + edit func(*Config) + }{ + {"unknown mode", func(c *Config) { c.Gateway.TLS.Mode = "auto" }}, + {"missing manual certificate", func(c *Config) { c.Gateway.TLS.Mode = "manual" }}, + {"missing domains", func(c *Config) { c.Gateway.TLS.Domains = nil }}, + {"missing storage", func(c *Config) { c.Gateway.TLS.StorageDir = "" }}, + {"bad listener", func(c *Config) { c.Gateway.HTTPSListenAddr = ":bad" }}, + {"bad port", func(c *Config) { c.Gateway.HTTPSListenAddr = ":65536" }}, + {"unsupported challenge", func(c *Config) { c.Gateway.TLS.Challenge = "http-01" }}, + {"missing DNS provider", func(c *Config) { c.Gateway.TLS.Challenge = "dns-01" }}, + {"wildcard without DNS", func(c *Config) { c.Gateway.TLS.Domains = []string{"*.example.com"} }}, + {"IP address", func(c *Config) { c.Gateway.TLS.Domains = []string{"127.0.0.1"} }}, + {"URL instead of domain", func(c *Config) { c.Gateway.TLS.Domains = []string{"https://example.com"} }}, + {"plaintext CA", func(c *Config) { c.Gateway.TLS.CA = "http://ca.example.com" }}, + } { + t.Run(tc.name, func(t *testing.T) { + cfg := valid() + tc.edit(&cfg) + if err := cfg.Validate(); err == nil { + t.Fatal("invalid TLS configuration accepted") + } + }) + } + cfg := valid() + cfg.Gateway.HTTPListenAddr = "" + if err := cfg.Validate(); err != nil { + t.Fatal(err) + } + cfg = defaultConfig("gateway") + if err := cfg.Validate(); err != nil || cfg.Gateway.TLS.Mode != "off" { + t.Fatalf("default HTTP compatibility lost: %v", err) + } +} + +func TestACMEIPAddressValidation(t *testing.T) { + for _, tc := range []struct { + address string + valid bool + }{ + {"8.8.8.8", true}, {"2606:4700:4700::1111", true}, + {"127.0.0.2", false}, {"10.1.2.3", false}, {"172.16.0.1", false}, + {"172.31.255.255", false}, {"192.168.1.10", false}, {"169.254.1.1", false}, + {"100.64.0.1", false}, {"100.127.255.255", false}, {"0.1.2.3", false}, + {"192.0.2.1", false}, {"198.51.100.1", false}, {"203.0.113.1", false}, + {"198.19.1.1", false}, {"224.0.0.1", false}, {"240.1.1.1", false}, + {"255.255.255.255", false}, {"::", false}, {"::1", false}, + {"fc00::1", false}, {"fe80::1", false}, {"ff02::1", false}, + {"2001:db8::1", false}, {"2002::1", false}, {"3fff::1", false}, + {"64:ff9b::808:808", false}, {"::ffff:8.8.8.8", false}, + {"::ffff:192.168.1.10", false}, {"fe80::1%eth0", false}, + {"8.8.8.8:8000", false}, {"[2606:4700:4700::1111]", false}, + {"*.8.8.8.8", false}, + } { + t.Run(tc.address, func(t *testing.T) { + cfg := defaultConfig("gateway") + cfg.Gateway.TLS = GatewayTLSConfig{Mode: "acme", Domains: []string{tc.address}, StorageDir: "/data/tls", Challenge: "tls-alpn-01"} + err := cfg.Validate() + if (err == nil) != tc.valid { + t.Fatalf("Validate(%q) = %v, want valid=%v", tc.address, err, tc.valid) + } + if tc.valid { + cfg.Gateway.TLS.Challenge = "dns-01" + cfg.Gateway.TLS.DNSProvider = "cloudflare" + cfg.Gateway.TLS.DNSConfig = []byte(`{"api_token":"test-token"}`) + if cfg.Validate() == nil { + t.Fatal("DNS-01 accepted an IP identifier") + } + } + cfg.Gateway.TLS = GatewayTLSConfig{Mode: "manual", CertFile: "cert.pem", KeyFile: "key.pem", Domains: []string{tc.address}} + if err := cfg.Validate(); err != nil { + t.Fatalf("manual certificates should not use public-issuance checks: %v", err) + } + }) + } +} + +func TestACMERejectsAmbiguousMultipleIPs(t *testing.T) { + cfg := defaultConfig("gateway") + cfg.Gateway.TLS = GatewayTLSConfig{Mode: "acme", Domains: []string{"8.8.8.8", "1.1.1.1"}, StorageDir: "/data/tls"} + if cfg.Validate() == nil { + t.Fatal("multiple IP identities on a single listener accepted") + } + cfg.Gateway.TLS.Domains = []string{"api.example.com", "8.8.8.8"} + if err := cfg.Validate(); err != nil { + t.Fatal(err) + } +} + +func TestDNSConfigJSONAndRedaction(t *testing.T) { + path := filepath.Join(t.TempDir(), "gateway.json") + data := []byte(`{"gateway":{"tls":{"mode":"acme","domains":["*.example.com"],"storage_dir":"/data/tls","challenge":"dns-01","dns_provider":"cloudflare","dns_config":{"api_token":"test-secret"}}}}`) + if err := os.WriteFile(path, data, 0600); err != nil { + t.Fatal(err) + } + cfg, err := Load(path, "gateway") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(cfg.Gateway.TLS.DNSConfig), "test-secret") { + t.Fatal("DNS config not loaded") + } + for _, value := range []any{cfg, cfg.Gateway, cfg.Gateway.TLS} { + encoded, err := json.Marshal(value) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(encoded), "test-secret") || strings.Contains(string(encoded), "dns_config") { + t.Fatal("DNS credentials were serialized") + } + } +} + +func TestDNSConfigValidation(t *testing.T) { + for _, tc := range []struct { + name, provider, raw, challenge string + valid bool + }{ + {"cloudflare", "cloudflare", `{"api_token":"test"}`, "dns-01", true}, + {"alidns", "alidns", `{}`, "dns-01", true}, + {"tencentcloud", "tencentcloud", `{}`, "dns-01", true}, + {"aws role", "route53", `{}`, "dns-01", true}, + {"unknown", "unknown", `{}`, "dns-01", false}, + {"missing config", "cloudflare", ``, "dns-01", false}, + {"null", "cloudflare", `null`, "dns-01", false}, + {"array", "cloudflare", `[]`, "dns-01", false}, + {"alpn config", "", `{}`, "tls-alpn-01", false}, + } { + t.Run(tc.name, func(t *testing.T) { + cfg := defaultConfig("gateway") + cfg.Gateway.TLS = GatewayTLSConfig{Mode: "acme", Domains: []string{"example.com"}, StorageDir: "/data/tls", Challenge: tc.challenge, DNSProvider: tc.provider, DNSConfig: []byte(tc.raw)} + if err := cfg.Validate(); (err == nil) != tc.valid { + t.Fatalf("unexpected validation result: %v", err) + } + }) + } +}