diff --git a/.env.example b/.env.example index 514bfcd4..ad1ea6c3 100644 --- a/.env.example +++ b/.env.example @@ -1,75 +1,165 @@ -# Public routing, discovery, and relay identity persistence +# Portal relay configuration. +# +# Copy to .env and edit. Every key here is read either by the relay binary or by +# Docker Compose; nothing else reads this file. +# +# Check the configuration before starting anything. The report gives the +# effective value and source of every key, names any key that nothing reads, and +# says which features are off and what is missing: +# +# docker compose run --rm -T portal config +# +# No --env-file: Compose has already built the container's environment from this +# file plus its own defaults, so the report describes what `docker compose up` +# will actually run. Passing --env-file instead reads the file against the relay +# binary's defaults, which differ -- MIN_PORT is 0 there and 40000 under Compose +# -- and would report features as blocked that the deployment enables. +# +# -T because `docker compose run` asks for a TTY otherwise, and older versions +# fail with "the input device is not a TTY". +# +# API_PORT and SNI_PORT are deliberately absent. The bundled topology fixes them +# at 4017 and 443 because the relay reaches its own API listener through its SNI +# router; overriding them breaks that wiring rather than moving it. + + +# ───────────────────────────────────────────────────────────────────────────── +# 1. Required +# ───────────────────────────────────────────────────────────────────────────── + +# Public HTTPS origin browsers and tunnel clients use. Must be publicly +# resolvable when DISCOVERY=true; localhost and other local-only names are +# rejected by public discovery. PORTAL_URL=https://localhost -# Optional directory inside the Portal container containing a custom SPA index.html. -# Leave empty to use the official frontend embedded in the Portal binary. -PORTAL_FRONTEND_DIR= -DISCOVERY=true + +# Bearer token for the admin and policy APIs. Leaving this empty leaves those +# APIs unauthenticated. Generate one with: openssl rand -hex 32 +ADMIN_TOKEN= + + +# ───────────────────────────────────────────────────────────────────────────── +# 2. Storage and ports +# ───────────────────────────────────────────────────────────────────────────── + +# Directory for relay identity, policy state, and certificate material. +# Also the in-container mount point for ./.portal-certs. IDENTITY_PATH=/portal-certs -# Public HTTPS/SNI +# Public and listen UDP port for the relay overlay. Required when DISCOVERY=true. +# Compose publishes this port. WIREGUARD_PORT=51820 -# Set when enabling public UDP or raw TCP lease ports. + +# Inclusive lease port range shared by the UDP and raw TCP transports. +# 0 disables both. Enabling a transport without a range does nothing; the relay +# reports that at startup. Publish the same range in docker-compose.yml when set. MIN_PORT=0 MAX_PORT=0 + + +# ───────────────────────────────────────────────────────────────────────────── +# 3. Feature toggles - the defaults suit a single private relay +# ───────────────────────────────────────────────────────────────────────────── + +# Serve relay discovery endpoints and poll discovery peers. Requires a publicly +# reachable PORTAL_URL and an open WIREGUARD_PORT/udp. +DISCOVERY=true + +# Comma-separated relay API URLs to seed discovery from. +BOOTSTRAPS= + +# Enable the UDP and raw TCP lease transports. Both need MIN_PORT/MAX_PORT. UDP_ENABLED=false TCP_ENABLED=false -# Supported managed values: embedded (default), cloudflare, gcloud, hetzner, njalla, route53, vultr. -# Reused for ACME DNS-01, managed A records, the relay ECH record, -# and opt-in tunnel ECH records. -ACME_DNS_PROVIDER=embedded +# Initial landing-page state. Admin changes are persisted in policy.json. +LANDING_PAGE_ENABLED=false -# Embedded authoritative DNS server, the default DNS provider. -# Requires a one-time NS delegation of the base domain at the parent zone -# (NS portal.example.com -> ns.portal.example.com + glue A to the relay IP) -# and open 53/tcp + 53/udp. -# ENS gasless automation (zone DNSSEC) is not supported with embedded yet. -EMBEDDED_DNS_PORT=53 +# Directory inside the container holding a custom SPA index.html. Leave empty to +# serve the frontend embedded in the binary. Uncomment the matching mount in +# docker-compose.yml when setting this. +PORTAL_FRONTEND_DIR= -# Cloudflare API token (required when ACME_DNS_PROVIDER=cloudflare) -CLOUDFLARE_TOKEN= +# Trust X-Forwarded-* and X-Real-IP. Portal owns its public port in the bundled +# topology, so client addresses already come from the socket; enable this only +# when a proxy you control genuinely sits in front. TRUSTED_PROXY_CIDRS empty +# means the default private and loopback ranges. +TRUST_PROXY_HEADERS=false +TRUSTED_PROXY_CIDRS= -# Google Cloud DNS settings. (required when ACME_DNS_PROVIDER=gcloud) -GCP_PROJECT_ID= -GCP_MANAGED_ZONE= -GOOGLE_APPLICATION_CREDENTIALS= +# Relay-owned Sui x402 facilitator under /api/x402. +# X402_ENABLED=true without X402_PAY_TO cannot receive payments. +X402_ENABLED=false +X402_TESTNET=false +X402_PAY_TO= -# Hetzner DNS settings (required when ACME_DNS_PROVIDER=hetzner) -HETZNER_API_TOKEN= +# pprof diagnostics. Keep the address on loopback unless the port is otherwise +# protected. PPROF_PORT only matters when you also uncomment the pprof port +# mapping in docker-compose.yml, which exposes it to the host. +PPROF_ENABLED=false +PPROF_ADDR=127.0.0.1:6060 +# PPROF_PORT=6060 -# Route53 settings (required when ACME_DNS_PROVIDER=route53) -AWS_ACCESS_KEY_ID= -AWS_SECRET_ACCESS_KEY= -AWS_SESSION_TOKEN= -AWS_REGION= -AWS_DEFAULT_REGION= -AWS_HOSTED_ZONE_ID= -# Required only when ACME_DNS_PROVIDER=route53 and ENS_GASLESS_ENABLED=true and no ACTIVE KSK already exists. -AWS_DNSSEC_KMS_KEY_ARN= -# Vultr DNS settings (required when ACME_DNS_PROVIDER=vultr) -VULTR_API_KEY= +# ───────────────────────────────────────────────────────────────────────────── +# 4. DNS provider - pick one, fill only that block +# +# Used for ACME DNS-01, managed A records, ECH HTTPS records, and optional ENS +# DNS automation. Unset means embedded, not "off": to manage certificates +# yourself instead, place fullchain.pem and privatekey.pem under IDENTITY_PATH +# and they are used when present. +# +# Only one provider's credentials are ever read. The unused blocks below stay +# commented out on purpose. +# ───────────────────────────────────────────────────────────────────────────── -# Njalla DNS settings (required when ACME_DNS_PROVIDER=njalla) -NJALLA_TOKEN= +# embedded (default) | cloudflare | gcloud | hetzner | njalla | route53 | vultr +ACME_DNS_PROVIDER=embedded -# ENS gasless DNS import automation. When enabled, Portal uses ACME_DNS_PROVIDER -# for DNSSEC and ENS TXT automation, even when certificate files are managed manually. -# Not supported with ACME_DNS_PROVIDER=embedded yet. -ENS_GASLESS_ENABLED=false +# -> ACME_DNS_PROVIDER=embedded +# An authoritative DNS server inside the relay, so no provider API +# credentials are needed. Requires a one-time NS delegation of the base +# domain at the parent zone (NS portal.example.com -> ns.portal.example.com +# with a glue A record to the relay IP) and open 53/tcp + 53/udp. A +# container running without root needs CAP_NET_BIND_SERVICE to bind 53. +# ENS gasless automation is not supported with embedded yet. +EMBEDDED_DNS_PORT=53 -# Admin/auth configuration. Use a long random value for production relays. -ADMIN_TOKEN= +# -> ACME_DNS_PROVIDER=cloudflare +# The token needs Zone:Read as well as DNS:Edit. DNS:Edit alone cannot +# locate the zone, and issuance fails with "no cloudflare zone found". +# CLOUDFLARE_TOKEN= -# Optional embedded Sui x402 facilitator exposed under /api/x402. -X402_ENABLED=false -X402_TESTNET=false -X402_PAY_TO= +# -> ACME_DNS_PROVIDER=gcloud +# Project is auto-detected from ADC or GCE metadata when omitted. +# GOOGLE_APPLICATION_CREDENTIALS is read by the Google Cloud SDK itself, +# not by a relay flag: mount the service account file and point this at +# the in-container path. +# GCP_PROJECT_ID= +# GCP_MANAGED_ZONE= +# GOOGLE_APPLICATION_CREDENTIALS= -# Enable only when an explicitly configured upstream proxy supplies forwarded client IP headers. -# Optionally restrict which proxy source ranges may supply those headers; leave empty for default private/loopback proxy ranges. -TRUST_PROXY_HEADERS=false -TRUSTED_PROXY_CIDRS= +# -> ACME_DNS_PROVIDER=hetzner +# HETZNER_API_TOKEN= -# Initial landing-page state. Admin changes are persisted in policy.json. -LANDING_PAGE_ENABLED=false +# -> ACME_DNS_PROVIDER=route53 +# Omit the static keys to use the default AWS credential chain. +# AWS_DNSSEC_KMS_KEY_ARN is needed only with ENS_GASLESS_ENABLED=true when +# no ACTIVE KSK exists yet. +# AWS_ACCESS_KEY_ID= +# AWS_SECRET_ACCESS_KEY= +# AWS_SESSION_TOKEN= +# AWS_REGION= +# AWS_HOSTED_ZONE_ID= +# AWS_DNSSEC_KMS_KEY_ARN= + +# -> ACME_DNS_PROVIDER=vultr +# VULTR_API_KEY= + +# -> ACME_DNS_PROVIDER=njalla +# NJALLA_TOKEN= + +# ENS gasless DNS import automation. Needs a DNS provider that can manage +# DNSSEC, so it does not work with the embedded server yet; pick one of the +# managed providers above. Not needed for normal relay operation; leave false +# unless you specifically want it. +ENS_GASLESS_ENABLED=false diff --git a/Makefile b/Makefile index e2fc9896..d214ce4a 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: help install fmt vet lint lint-auto test tidy all run build build-frontend build-docs build-tunnel build-server build-server-bin clean load-test +.PHONY: help install fmt vet lint lint-auto test tidy all run build build-frontend build-docs build-tunnel build-server build-server-bin clean load-test check-env-example env-reference .DEFAULT_GOAL := help @@ -16,6 +16,8 @@ help: @echo " make fmt - Apply gofmt/goimports" @echo " make lint-auto - Run autofix lint/format pipeline" @echo " make test - Run Go and frontend tests" + @echo " make check-env-example - Fail if .env.example is missing a configuration key" + @echo " make env-reference - Print every configuration key, generated from the flags" @echo " make build - Build Go tunnel and relay server artifacts" @echo " make build-frontend - Build React frontend (Tailwind CSS 4)" @echo " make build-docs - Build documentation site (SvelteKit)" @@ -52,6 +54,45 @@ tidy: go mod tidy go mod verify +# The keys themselves are owned by the flag definitions in +# cmd/relay-server/main.go and by the catalog of keys other components read. +# .env.example and the configuration reference are documentation of that set. +# Adding a flag without documenting it is how configuration drifts away from the +# code, so fail loudly here rather than let an operator find the gap in +# production. Keys the bundled topology pins are excluded on purpose; see +# cmd/relay-server/envcatalog.go. +CONFIG_DOC := docs/src/routes/configuration/+page.md + +check-env-example: + @go run ./cmd/relay-server config --format names > /tmp/portal-env-names.txt + @status=0; \ + missing=""; \ + while read -r name; do \ + grep -qE "^#? *$$name=" .env.example || missing="$$missing $$name"; \ + done < /tmp/portal-env-names.txt; \ + if [ -n "$$missing" ]; then \ + echo "[env] .env.example does not document:"; \ + for name in $$missing; do echo " - $$name"; done; \ + status=1; \ + fi; \ + missing=""; \ + while read -r name; do \ + grep -qF "\`$$name\`" $(CONFIG_DOC) || missing="$$missing $$name"; \ + done < /tmp/portal-env-names.txt; \ + if [ -n "$$missing" ]; then \ + echo "[env] $(CONFIG_DOC) does not document:"; \ + for name in $$missing; do echo " - $$name"; done; \ + status=1; \ + fi; \ + if [ "$$status" -ne 0 ]; then \ + echo "[env] run 'make env-reference' to see each key with its usage text"; \ + exit 1; \ + fi; \ + echo "[env] .env.example and $(CONFIG_DOC) document every configuration key" + +env-reference: + @go run ./cmd/relay-server config --format env + all: fmt vet lint test build run: diff --git a/cmd/relay-server/config.go b/cmd/relay-server/config.go new file mode 100644 index 00000000..3954efbf --- /dev/null +++ b/cmd/relay-server/config.go @@ -0,0 +1,859 @@ +package main + +import ( + "bufio" + "errors" + "flag" + "fmt" + "io" + "net/url" + "os" + "path/filepath" + "slices" + "sort" + "strings" + + "github.com/rs/zerolog/log" + + "github.com/gosuda/portal-tunnel/v2/portal/acme" + portalx402 "github.com/gosuda/portal-tunnel/v2/portal/x402" + "github.com/gosuda/portal-tunnel/v2/utils" +) + +// A feature is one capability the operator turned on or off, reported at +// startup and by the config subcommand. Logging the raw settings is not enough: +// "you switched this off" and "you switched this on but it cannot run" both +// show up as a false flag, and only the second one is a misconfiguration. +type featureState string + +const ( + stateEnabled featureState = "enabled" + stateDisabled featureState = "disabled" + stateBlocked featureState = "blocked" + stateUnprotected featureState = "UNPROTECTED" +) + +type feature struct { + Name string + // State is what the feature is actually doing, not what was requested. + State featureState + // By is the setting that produced the state, e.g. "DISCOVERY=true". + By string + // Detail adds context for a working feature. + Detail string + // Missing says what has to be supplied, for blocked and unprotected states. + Missing string +} + +func (f feature) needsAttention() bool { + return f.State == stateBlocked || f.State == stateUnprotected +} + +func evaluateFeatures(cfg relayServerConfig) []feature { + return []feature{ + discoveryFeature(cfg), + acmeFeature(cfg), + ensGaslessFeature(cfg), + leaseTransportFeature("udp-transport", "UDP_ENABLED", cfg.UDPEnabled, cfg), + leaseTransportFeature("tcp-transport", "TCP_ENABLED", cfg.TCPEnabled, cfg), + adminAPIFeature(cfg), + frontendFeature(cfg), + landingPageFeature(cfg), + proxyHeaderFeature(cfg), + x402Feature(cfg), + pprofFeature(cfg), + } +} + +func frontendFeature(cfg relayServerConfig) feature { + f := feature{Name: "frontend"} + dir := strings.TrimSpace(cfg.FrontendDir) + if dir == "" { + f.State, f.By = stateEnabled, "PORTAL_FRONTEND_DIR=" + f.Detail = "serving the SPA embedded in the binary" + return f + } + index := filepath.Join(dir, "index.html") + if _, err := os.Stat(index); err != nil { + f.State, f.By = stateBlocked, "PORTAL_FRONTEND_DIR="+dir + f.Missing = fmt.Sprintf("%s is not readable (%v); mount the directory or clear the variable to use the embedded SPA", index, err) + return f + } + f.State, f.By = stateEnabled, "PORTAL_FRONTEND_DIR="+dir + f.Detail = "serving a custom SPA instead of the embedded one" + return f +} + +func landingPageFeature(cfg relayServerConfig) feature { + f := feature{Name: "landing-page"} + if !cfg.LandingPageEnabled { + f.State, f.By = stateDisabled, "LANDING_PAGE_ENABLED=false" + f.Detail = "the dashboard opens directly on the relay view" + return f + } + f.State, f.By = stateEnabled, "LANDING_PAGE_ENABLED=true" + return f +} + +func discoveryFeature(cfg relayServerConfig) feature { + f := feature{Name: "discovery"} + if !cfg.DiscoveryEnabled { + f.State, f.By = stateDisabled, "DISCOVERY=false" + return f + } + // The same normalization portal.NewServer applies, not a second opinion + // about it. Checking only the parsed hostname would report PORTAL_URL=http://… + // as enabled and then have the server reject it seconds later, which is + // exactly the divergence this report exists to remove. + if _, err := utils.NormalizeRelayURL(cfg.PortalURL); err != nil { + f.State, f.By = stateBlocked, "DISCOVERY=true" + f.Missing = fmt.Sprintf("PORTAL_URL is not usable as a relay URL: %v", err) + return f + } + host := portalURLHost(cfg.PortalURL) + if host == "" || utils.IsLocalRelayHost(host) { + f.State, f.By = stateBlocked, "DISCOVERY=true" + f.Missing = fmt.Sprintf( + "PORTAL_URL host %q is local-only and public discovery rejects it; set PORTAL_URL to a publicly resolvable HTTPS origin", + host) + return f + } + bootstraps, err := utils.NormalizeRelayURLs(utils.SplitCSV(cfg.Bootstraps)...) + if err != nil { + f.State, f.By = stateBlocked, "DISCOVERY=true" + f.Missing = fmt.Sprintf("BOOTSTRAPS is not usable: %v", err) + return f + } + f.State, f.By = stateEnabled, "DISCOVERY=true" + f.Detail = fmt.Sprintf("host=%s bootstraps=%d wireguard_port=%d", + host, len(bootstraps), cfg.WireGuardPort) + return f +} + +func acmeFeature(cfg relayServerConfig) feature { + f := feature{Name: "acme"} + // Unset is not off. The relay falls back to the embedded authoritative DNS + // server, so reporting "disabled" here would describe a relay that is in + // fact serving its own zone and answering ACME challenges from it. + provider := strings.ToLower(strings.TrimSpace(cfg.ACMEDNSProvider)) + if provider == "" { + provider = acme.TypeEmbedded + } + if provider == acme.TypeEmbedded { + f.State, f.By = stateEnabled, "ACME_DNS_PROVIDER="+provider + f.Detail = fmt.Sprintf( + "the relay serves its own zone on port %d; delegate the base domain with an NS record and open 53/tcp+udp. "+ + "Manual fullchain.pem and privatekey.pem under IDENTITY_PATH are used instead when present", + cfg.EmbeddedDNSPort) + return f + } + + // acme.NewManager returns before it builds a DNS provider when the base + // domain is local-only, so managed issuance cannot run however well the + // provider is configured. Reporting it as enabled here would describe an + // automation that never starts. + if host := portalURLHost(cfg.PortalURL); host == "" || utils.IsLocalRelayHost(host) { + f.State, f.By = stateBlocked, "ACME_DNS_PROVIDER="+provider + f.Missing = fmt.Sprintf( + "PORTAL_URL host %q is local-only; managed issuance is skipped for local hosts and a development certificate is used instead", + host) + return f + } + + required, supported := dnsProviderCredential[provider] + if !supported { + f.State, f.By = stateBlocked, "ACME_DNS_PROVIDER="+provider + f.Missing = "unsupported provider; use cloudflare, gcloud, hetzner, njalla, route53 or vultr" + return f + } + + var empty []string + for _, name := range required { + if strings.TrimSpace(providerCredential(cfg, name)) == "" { + empty = append(empty, name) + } + } + if len(empty) > 0 { + f.State, f.By = stateBlocked, "ACME_DNS_PROVIDER="+provider + f.Missing = strings.Join(empty, ", ") + " is empty" + return f + } + + f.State, f.By = stateEnabled, "ACME_DNS_PROVIDER="+provider + f.Detail = "managed issuance and renewal under IDENTITY_PATH" + if len(required) == 0 { + f.Detail += "; credentials come from the ambient provider chain" + } + return f +} + +func ensGaslessFeature(cfg relayServerConfig) feature { + f := feature{Name: "ens-gasless"} + if !cfg.ENSGaslessEnabled { + f.State, f.By = stateDisabled, "ENS_GASLESS_ENABLED=false" + return f + } + // The embedded server cannot manage zone DNSSEC yet, and it is what an + // unset ACME_DNS_PROVIDER selects, so this is the combination an operator + // reaches by turning ENS on and changing nothing else. + provider := strings.ToLower(strings.TrimSpace(cfg.ACMEDNSProvider)) + if provider == "" { + provider = acme.TypeEmbedded + } + if provider == acme.TypeEmbedded { + f.State, f.By = stateBlocked, "ENS_GASLESS_ENABLED=true" + f.Missing = "ACME_DNS_PROVIDER=" + provider + + " cannot manage zone DNSSEC yet; ENS gasless automation needs one of the managed providers" + return f + } + // ENS gasless drives the same provider ACME does, so it cannot work when + // that provider cannot. Repeating only the "is it set" half of the check + // here would report this as enabled while acme is blocked, which is exactly + // the mismatch this report exists to surface. + if acmeState := acmeFeature(cfg); acmeState.State == stateBlocked { + f.State, f.By = stateBlocked, "ENS_GASLESS_ENABLED=true" + f.Missing = "the DNS provider it shares with ACME is blocked: " + acmeState.Missing + return f + } + f.State, f.By = stateEnabled, "ENS_GASLESS_ENABLED=true" + f.Detail = "DNSSEC and ENS TXT automation through " + provider + return f +} + +func leaseTransportFeature(name, envName string, enabled bool, cfg relayServerConfig) feature { + f := feature{Name: name} + if !enabled { + f.State, f.By = stateDisabled, envName+"=false" + return f + } + if cfg.MinPort <= 0 || cfg.MaxPort <= 0 || cfg.MaxPort < cfg.MinPort { + f.State, f.By = stateBlocked, envName+"=true" + f.Missing = fmt.Sprintf( + "MIN_PORT=%d MAX_PORT=%d is not a usable range; set both and publish the range in docker-compose.yml", + cfg.MinPort, cfg.MaxPort) + return f + } + f.State, f.By = stateEnabled, envName+"=true" + f.Detail = fmt.Sprintf("ports=%d-%d", cfg.MinPort, cfg.MaxPort) + return f +} + +func adminAPIFeature(cfg relayServerConfig) feature { + f := feature{Name: "admin-api"} + if strings.TrimSpace(cfg.AdminToken) == "" { + f.State = stateUnprotected + f.Missing = "ADMIN_TOKEN is empty; the admin and policy APIs accept unauthenticated requests. Generate one with: openssl rand -hex 32" + return f + } + f.State, f.By = stateEnabled, "ADMIN_TOKEN set" + f.Detail = "bearer token required for /api/admin and /api/policy" + return f +} + +func proxyHeaderFeature(cfg relayServerConfig) feature { + f := feature{Name: "proxy-headers"} + if !cfg.TrustProxyHeaders { + f.State, f.By = stateDisabled, "TRUST_PROXY_HEADERS=false" + f.Detail = "client addresses come from the socket, which is correct when Portal owns the public port itself" + return f + } + f.State, f.By = stateEnabled, "TRUST_PROXY_HEADERS=true" + if cidrs := strings.TrimSpace(cfg.TrustedProxyCIDRs); cidrs != "" { + f.Detail = "trusted=" + cidrs + } else { + f.Detail = "trusted=default private and loopback ranges (TRUSTED_PROXY_CIDRS empty)" + } + return f +} + +func x402Feature(cfg relayServerConfig) feature { + f := feature{Name: "x402"} + if !cfg.X402Enabled { + f.State, f.By = stateDisabled, "X402_ENABLED=false" + return f + } + if strings.TrimSpace(cfg.X402PayTo) == "" { + f.State, f.By = stateBlocked, "X402_ENABLED=true" + f.Missing = "X402_PAY_TO is empty; the facilitator has no payment recipient" + return f + } + f.State, f.By = stateEnabled, "X402_ENABLED=true" + f.Detail = "network=" + portalx402.Network(cfg.X402Testnet) + return f +} + +func pprofFeature(cfg relayServerConfig) feature { + f := feature{Name: "pprof"} + if !cfg.PProfEnabled { + f.State, f.By = stateDisabled, "PPROF_ENABLED=false" + return f + } + f.State, f.By = stateEnabled, "PPROF_ENABLED=true" + f.Detail = "addr=" + cfg.PProfAddr + return f +} + +func portalURLHost(raw string) string { + parsed, err := url.Parse(strings.TrimSpace(raw)) + if err != nil { + return "" + } + return utils.NormalizeHostname(parsed.Hostname()) +} + +func providerCredential(cfg relayServerConfig, name string) string { + switch name { + case "CLOUDFLARE_TOKEN": + return cfg.CloudflareToken + case "HETZNER_API_TOKEN": + return cfg.HetznerAPIToken + case "NJALLA_TOKEN": + return cfg.NjallaToken + case "VULTR_API_KEY": + return cfg.VultrAPIKey + default: + return "" + } +} + +// logFeatureReport emits the same report the config subcommand renders, so the +// two can never describe the deployment differently. +func logFeatureReport(features []feature) { + for _, f := range features { + event := log.Info() + if f.needsAttention() { + event = log.Warn() + } + event = event.Str("feature", f.Name).Str("state", string(f.State)) + if f.By != "" { + event = event.Str("by", f.By) + } + if f.Detail != "" { + event = event.Str("detail", f.Detail) + } + if f.Missing != "" { + event = event.Str("missing", f.Missing) + } + event.Msg("feature") + } +} + +// envFileEntry is one assignment read from an env file, kept in file order so +// the report follows the operator's own layout. +type envFileEntry struct { + Name string + Value string +} + +func loadEnvFile(path string) ([]envFileEntry, error) { + file, err := os.Open(path) + if err != nil { + return nil, err + } + defer func() { _ = file.Close() }() + + var entries []envFileEntry + scanner := bufio.NewScanner(file) + lineNo := 0 + for scanner.Scan() { + lineNo++ + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + line = strings.TrimPrefix(line, "export ") + // A line that is neither blank, a comment, nor an assignment is a + // mistake, and skipping it would reproduce the silent misconfiguration + // this command exists to expose: `DISCOVERY true` would simply vanish + // and the feature would report its default with nothing to explain why. + name, value, found := strings.Cut(line, "=") + if !found { + return nil, fmt.Errorf("%s:%d: not an assignment: %q", path, lineNo, line) + } + name = strings.TrimSpace(name) + if name == "" { + return nil, fmt.Errorf("%s:%d: assignment has no name: %q", path, lineNo, line) + } + // Compose does not expand values read from an env file, so neither do we. + value = strings.TrimSpace(value) + if len(value) >= 2 && (value[0] == '"' || value[0] == '\'') && value[len(value)-1] == value[0] { + value = value[1 : len(value)-1] + } + entries = append(entries, envFileEntry{Name: name, Value: value}) + } + if err := scanner.Err(); err != nil { + return nil, err + } + return entries, nil +} + +// knownEnvNames indexes every name the relay reads, including flag aliases. +func knownEnvNames() map[string]utils.EnvVar { + index := make(map[string]utils.EnvVar) + for _, entry := range utils.EnvVars() { + index[entry.Name] = entry + for _, alias := range entry.Aliases { + index[alias] = entry + } + } + return index +} + +func secretEnvName(name string) bool { + upper := strings.ToUpper(name) + for _, marker := range []string{"TOKEN", "SECRET", "KEY", "PASSWORD", "CREDENTIALS"} { + if strings.Contains(upper, marker) { + return true + } + } + return false +} + +// valueMarker distinguishes a key that something supplied from one running on +// its default, so a long list can be skimmed for what the operator actually set. +func valueMarker(entry utils.EnvVar) string { + if entry.SetBy == "" { + return "--" + } + return "OK" +} + +// valueSource names where the effective value came from. When an alias supplied +// it, the alias is named: "I set AWS_REGION, why is the value different?" is +// answered by seeing that AWS_DEFAULT_REGION was consulted first. +func valueSource(entry utils.EnvVar, supplied map[string]bool, envFile string) string { + if entry.SetBy == "" { + return fmt.Sprintf("default (%s)", defaultDisplay(entry.Default)) + } + + origin := "process environment" + if supplied[entry.SetBy] { + origin = envFile + } + if entry.SetBy != entry.Name { + return fmt.Sprintf("%s, via the alias %s", origin, entry.SetBy) + } + return origin +} + +func displayValue(name, value string) string { + if strings.TrimSpace(value) == "" { + return "" + } + if secretEnvName(name) { + return "" + } + return value +} + +func writeConfigReport(w io.Writer, cfg relayServerConfig, entries []envFileEntry, source string) { + relay := knownEnvNames() + + fmt.Fprintf(w, "Portal relay configuration (%s)\n", source) + // Say what was inspected, because the two modes answer different questions + // and only one of them describes a Compose deployment. Reading a file in + // isolation applies relay defaults to every key the file omits, while + // Compose supplies its own first: a file with only PORTAL_URL reports + // MIN_PORT=0 here, and `docker compose up` would run it with 40000. + if len(entries) > 0 { + fmt.Fprint(w, "Keys absent from this file take relay defaults. A Compose deployment\n"+ + "supplies its own first; for that environment run the command inside the\n"+ + "container instead: docker compose run --rm -T portal config\n") + } + fmt.Fprintln(w) + + supplied := make(map[string]bool, len(entries)) + for _, entry := range entries { + supplied[entry.Name] = true + } + + // Every relay key is listed with the value the flag actually resolved to, + // not the text of whichever line happened to appear in the file. A key that + // an alias or a higher-priority name overrode would otherwise read as though + // it were in effect, which is the confusion this report exists to remove. + fmt.Fprintln(w, "Keys") + for _, entry := range utils.EnvVars() { + fmt.Fprintf(w, " %-4s %-30s %-24s relay --%s\n", + valueMarker(entry), entry.Name, displayValue(entry.Name, entry.Value), entry.Flag) + fmt.Fprintf(w, " source: %s\n", valueSource(entry, supplied, source)) + writeWrapped(w, entry.Usage) + if pinned, ok := pinnedByTopology[entry.Name]; ok && entry.Value != pinned.Value { + fmt.Fprintf(w, " WARNING: pinned to %s by the bundled topology; %s\n", + pinned.Value, pinned.Reason) + } + if note := alsoConsumedBy[entry.Name]; note != "" { + fmt.Fprintf(w, " also: %s\n", note) + } + } + + // Keys owned by another component are only shown when actually supplied: + // the relay cannot resolve them, so there is no effective value to report. + var unknown []envFileEntry + for _, entry := range entries { + if _, isRelay := relay[entry.Name]; isRelay { + continue + } + external, isExternal := externalEnvVars[entry.Name] + if !isExternal { + unknown = append(unknown, entry) + continue + } + fmt.Fprintf(w, " OK %-30s %-24s %s\n", + entry.Name, displayValue(entry.Name, entry.Value), external.Owner) + writeWrapped(w, external.Usage) + } + + if len(unknown) > 0 { + fmt.Fprintf(w, "\nUNKNOWN %d key(s) are not read by any component and are silently ignored:\n", len(unknown)) + for _, entry := range unknown { + if suggestion := nearestEnvName(entry.Name, relay); suggestion != "" { + fmt.Fprintf(w, " %-30s did you mean %s?\n", entry.Name, suggestion) + continue + } + fmt.Fprintf(w, " %-30s no equivalent key exists\n", entry.Name) + } + } + fmt.Fprintln(w) + + fmt.Fprintln(w, "Features") + for _, f := range evaluateFeatures(cfg) { + marker := " " + if f.needsAttention() { + marker = "!" + } + fmt.Fprintf(w, " %s %-16s %-12s %s\n", marker, f.Name, f.State, f.By) + if f.Detail != "" { + writeWrapped(w, f.Detail) + } + if f.Missing != "" { + writeWrapped(w, "missing: "+f.Missing) + } + } + + if issues := utils.EnvIssues(); len(issues) > 0 { + fmt.Fprintln(w, "\nInvalid values") + for _, issue := range issues { + fmt.Fprintf(w, " %s=%s %s\n", issue.Name, displayValue(issue.Name, issue.Value), issue.Problem) + } + } +} + +// writeWrapped prints an indented, soft-wrapped continuation line. +func writeWrapped(w io.Writer, text string) { + text = strings.TrimSpace(text) + if text == "" { + return + } + const width = 72 + const indent = " " + line := indent + for _, word := range strings.Fields(text) { + if len(line)+len(word)+1 > width && strings.TrimSpace(line) != "" { + fmt.Fprintln(w, line) + line = indent + } + if strings.TrimSpace(line) == "" { + line += word + continue + } + line += " " + word + } + if strings.TrimSpace(line) != "" { + fmt.Fprintln(w, line) + } +} + +// nearestEnvName suggests the closest known key for a typo. Deployment drift +// usually looks like ADMIN_WALLETS for ADMIN_TOKEN: close enough to look right, +// far enough that nothing reads it. +func nearestEnvName(name string, relay map[string]utils.EnvVar) string { + candidates := make([]string, 0, len(relay)+len(externalEnvVars)) + for candidate := range relay { + candidates = append(candidates, candidate) + } + for candidate := range externalEnvVars { + candidates = append(candidates, candidate) + } + sort.Strings(candidates) + + best := "" + bestDistance := len(name)/2 + 2 + for _, candidate := range candidates { + if distance := editDistance(name, candidate); distance < bestDistance { + best, bestDistance = candidate, distance + } + } + return best +} + +func editDistance(a, b string) int { + previous := make([]int, len(b)+1) + current := make([]int, len(b)+1) + for j := range previous { + previous[j] = j + } + for i := 1; i <= len(a); i++ { + current[0] = i + for j := 1; j <= len(b); j++ { + cost := 1 + if a[i-1] == b[j-1] { + cost = 0 + } + current[j] = min(previous[j]+1, current[j-1]+1, previous[j-1]+cost) + } + previous, current = current, previous + } + return previous[len(b)] +} + +// writeEnvReference emits every key the deployment understands, grouped by +// owner. It is generated from the flag definitions and the catalog, so it +// cannot drift from the code the way a hand-written list does. +func writeEnvReference(w io.Writer) { + fmt.Fprintln(w, "# Generated by `relay-server config --format env`. Do not edit by hand.") + fmt.Fprintln(w, "# Every key the bundled deployment understands, grouped by the component") + fmt.Fprintln(w, "# that reads it. See .env.example for a commented starting point.") + + fmt.Fprintln(w, "\n# ── relay ──") + for _, entry := range utils.EnvVars() { + fmt.Fprintf(w, "\n# %s [relay --%s] default: %s\n", entry.Name, entry.Flag, defaultDisplay(entry.Default)) + if len(entry.Aliases) > 0 { + fmt.Fprintf(w, "# also accepted: %s\n", strings.Join(entry.Aliases, ", ")) + } + writeCommentWrapped(w, entry.Usage) + fmt.Fprintf(w, "%s=%s\n", entry.Name, entry.Default) + } + + owners := make([]string, 0, len(externalEnvVars)) + byOwner := map[string][]string{} + for name, external := range externalEnvVars { + if _, seen := byOwner[external.Owner]; !seen { + owners = append(owners, external.Owner) + } + byOwner[external.Owner] = append(byOwner[external.Owner], name) + } + sort.Strings(owners) + for _, owner := range owners { + names := byOwner[owner] + sort.Strings(names) + fmt.Fprintf(w, "\n# ── %s ──\n", owner) + for _, name := range names { + fmt.Fprintf(w, "\n# %s [%s]\n", name, owner) + writeCommentWrapped(w, externalEnvVars[name].Usage) + fmt.Fprintf(w, "# %s=\n", name) + } + } +} + +// writeEnvNames lists the keys an operator is expected to set, one per line, so +// `make check-env-example` can assert .env.example still documents all of them. +// A flag added without a matching .env.example entry is exactly how the +// documented configuration drifts away from the code. +// +// Keys the bundled topology pins in the image are excluded: they are recognised +// everywhere else, but documenting them would invite an override that breaks +// the wiring between nginx and the services behind it. +func writeEnvNames(w io.Writer) { + names := make([]string, 0, len(externalEnvVars)) + for _, entry := range utils.EnvVars() { + if _, pinned := pinnedByTopology[entry.Name]; pinned { + continue + } + names = append(names, entry.Name) + } + for name, external := range externalEnvVars { + if external.Pinned { + continue + } + names = append(names, name) + } + sort.Strings(names) + for _, name := range slices.Compact(names) { + fmt.Fprintln(w, name) + } +} + +func defaultDisplay(value string) string { + if value == "" { + return "(empty)" + } + return value +} + +func writeCommentWrapped(w io.Writer, text string) { + text = strings.TrimSpace(text) + if text == "" { + return + } + const width = 74 + line := "# " + for _, word := range strings.Fields(text) { + if len(line)+len(word)+1 > width && strings.TrimSpace(line) != "#" { + fmt.Fprintln(w, line) + line = "# " + } + if line == "# " { + line += word + continue + } + line += " " + word + } + if strings.TrimSpace(line) != "#" { + fmt.Fprintln(w, line) + } +} + +// applyEnvFileInIsolation makes the file the whole environment for the pass +// that follows, and returns a function restoring what was there before. +// +// Setting only the file's own keys is not enough. A relay variable absent from +// the file would stay inherited from the shell, and a higher-priority alias in +// the shell would beat a value the file does supply — process AWS_REGION over +// file AWS_DEFAULT_REGION, for instance. Either way the report would describe a +// configuration different from the one Compose is going to deploy, which is the +// opposite of what checking a file is for. +func applyEnvFileInIsolation(entries []envFileEntry) (func(), error) { + // A first pass populates the registry, which is how the set of names the + // deployment understands is known at all. + if _, err := resolveRelayServerConfig(nil); err != nil { + return nil, err + } + + names := make([]string, 0, len(externalEnvVars)) + for _, entry := range utils.EnvVars() { + names = append(names, entry.Name) + names = append(names, entry.Aliases...) + } + for name := range externalEnvVars { + names = append(names, name) + } + for _, entry := range entries { + names = append(names, entry.Name) + } + + type saved struct { + value string + set bool + } + previous := make(map[string]saved, len(names)) + restore := func() { + for name, prior := range previous { + if prior.set { + _ = os.Setenv(name, prior.value) + continue + } + _ = os.Unsetenv(name) + } + } + + for _, name := range names { + if _, recorded := previous[name]; recorded { + continue + } + value, set := os.LookupEnv(name) + previous[name] = saved{value: value, set: set} + if err := os.Unsetenv(name); err != nil { + restore() + return nil, fmt.Errorf("isolate %s: %w", name, err) + } + } + + for _, entry := range entries { + if err := os.Setenv(entry.Name, entry.Value); err != nil { + restore() + return nil, fmt.Errorf("apply %s: %w", entry.Name, err) + } + } + return restore, nil +} + +func runConfigCommand(args []string) error { + var ( + envFilePath string + format string + ) + fs := utils.NewFlagSet("relay-server config", printConfigUsage) + utils.StringFlag(fs, &envFilePath, "env-file", "", + "read this file in place of the process environment, against relay defaults. "+ + "Compose supplies its own defaults on top of a file, so to see what a Compose "+ + "deployment will actually run, omit this flag and let Compose build the environment: "+ + "docker compose run --rm -T portal config") + utils.StringFlag(fs, &format, "format", "text", "output format: text, env or names") + + if err := utils.ParseFlagSet(fs, args, printConfigUsage); err != nil { + if errors.Is(err, flag.ErrHelp) { + return nil + } + return err + } + if err := utils.RequireNoArgs(fs.Args(), "relay-server config"); err != nil { + printConfigUsage(os.Stderr) + return err + } + + var entries []envFileEntry + source := "process environment" + if strings.TrimSpace(envFilePath) != "" { + loaded, err := loadEnvFile(envFilePath) + if err != nil { + return fmt.Errorf("read env file: %w", err) + } + restore, err := applyEnvFileInIsolation(loaded) + if err != nil { + return err + } + defer restore() + entries = loaded + source = envFilePath + } + + cfg, err := resolveRelayServerConfig(nil) + if err != nil { + return err + } + + switch strings.TrimSpace(format) { + case "", "text": + writeConfigReport(os.Stdout, cfg, entries, source) + return nil + case "env": + writeEnvReference(os.Stdout) + return nil + case "names": + writeEnvNames(os.Stdout) + return nil + default: + printConfigUsage(os.Stderr) + return fmt.Errorf("unknown format %q", format) + } +} + +func printConfigUsage(w io.Writer) { + utils.WriteCommandUsage(w, + []string{ + "relay-server config [--env-file PATH] [--format text|env]", + }, + []string{ + "docker compose run --rm -T portal config # what Compose will run", + "relay-server config # this process environment", + "relay-server config --env-file .env # one file, against relay defaults", + "relay-server config --format env > env.reference", + }, + ) +} + +// envIssueError turns recorded parse failures into a startup error. A value +// that cannot be parsed is always a mistake, and falling back silently is what +// let deployments run for months with settings nothing read. +func envIssueError() error { + issues := utils.EnvIssues() + if len(issues) == 0 { + return nil + } + messages := make([]string, 0, len(issues)) + for _, issue := range issues { + messages = append(messages, fmt.Sprintf("%s=%q: %s", issue.Name, issue.Value, issue.Problem)) + } + slices.Sort(messages) + return errors.New("invalid environment values: " + strings.Join(messages, "; ")) +} diff --git a/cmd/relay-server/config_test.go b/cmd/relay-server/config_test.go new file mode 100644 index 00000000..922d0144 --- /dev/null +++ b/cmd/relay-server/config_test.go @@ -0,0 +1,290 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// writeEnvFile writes lines to a temporary env file and returns its path. +func writeEnvFile(t *testing.T, lines ...string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "test.env") + body := strings.Join(lines, "\n") + "\n" + if err := os.WriteFile(path, []byte(body), 0o600); err != nil { + t.Fatalf("write env file: %v", err) + } + return path +} + +// resolveWithEnvFile runs the same isolation the config subcommand performs and +// returns the resulting configuration. +func resolveWithEnvFile(t *testing.T, path string) relayServerConfig { + t.Helper() + entries, err := loadEnvFile(path) + if err != nil { + t.Fatalf("load env file: %v", err) + } + restore, err := applyEnvFileInIsolation(entries) + if err != nil { + t.Fatalf("isolate env file: %v", err) + } + defer restore() + + cfg, err := resolveRelayServerConfig(nil) + if err != nil { + t.Fatalf("resolve config: %v", err) + } + return cfg +} + +func featureByName(t *testing.T, cfg relayServerConfig, name string) feature { + t.Helper() + for _, f := range evaluateFeatures(cfg) { + if f.Name == name { + return f + } + } + t.Fatalf("feature %q not reported", name) + return feature{} +} + +// A variable absent from the env file must not leak in from the surrounding +// shell: Compose passes only the file, so a report that saw the shell would be +// describing a different deployment. +func TestEnvFileIsolationIgnoresInheritedValue(t *testing.T) { + t.Setenv("DISCOVERY", "true") + + cfg := resolveWithEnvFile(t, writeEnvFile(t, "PORTAL_URL=https://relay.example.com")) + + if cfg.DiscoveryEnabled { + t.Fatal("DISCOVERY was inherited from the process environment; the file did not set it") + } +} + +// A higher-priority alias in the shell must not beat a value the file supplies +// through a lower-priority name. +func TestEnvFileIsolationBeatsHigherPriorityAlias(t *testing.T) { + t.Setenv("AWS_REGION", "us-east-1") + + cfg := resolveWithEnvFile(t, writeEnvFile(t, "AWS_DEFAULT_REGION=ap-northeast-2")) + + if cfg.AWSRegion != "ap-northeast-2" { + t.Fatalf("AWS region = %q, want the file value ap-northeast-2", cfg.AWSRegion) + } +} + +func TestEnvFileIsolationRestoresEnvironment(t *testing.T) { + t.Setenv("DISCOVERY", "true") + if err := os.Unsetenv("BOOTSTRAPS"); err != nil { + t.Fatalf("unset BOOTSTRAPS: %v", err) + } + + entries, err := loadEnvFile(writeEnvFile(t, "BOOTSTRAPS=https://seed.example.com")) + if err != nil { + t.Fatalf("load env file: %v", err) + } + restore, err := applyEnvFileInIsolation(entries) + if err != nil { + t.Fatalf("isolate env file: %v", err) + } + restore() + + if got := os.Getenv("DISCOVERY"); got != "true" { + t.Fatalf("DISCOVERY = %q after restore, want true", got) + } + if _, set := os.LookupEnv("BOOTSTRAPS"); set { + t.Fatal("BOOTSTRAPS is set after restore; it was unset before") + } +} + +// acme.NewManager returns before building a DNS provider for a local-only base +// domain, so a configured provider still yields no managed issuance. +func TestACMEFeatureBlockedForLocalHost(t *testing.T) { + cfg := relayServerConfig{ + PortalURL: "https://localhost", + ACMEDNSProvider: "cloudflare", + CloudflareToken: "token", + } + + f := featureByName(t, cfg, "acme") + if f.State != stateBlocked { + t.Fatalf("acme state = %q, want %q", f.State, stateBlocked) + } + if !strings.Contains(f.Missing, "local-only") { + t.Fatalf("acme missing = %q, want it to name the local-only host", f.Missing) + } +} + +// ens-gasless drives the same provider, so it must inherit the blocked state +// rather than repeating half of the check. +func TestENSGaslessFollowsBlockedACME(t *testing.T) { + cfg := relayServerConfig{ + PortalURL: "https://localhost", + ACMEDNSProvider: "cloudflare", + CloudflareToken: "token", + ENSGaslessEnabled: true, + } + + f := featureByName(t, cfg, "ens-gasless") + if f.State != stateBlocked { + t.Fatalf("ens-gasless state = %q, want %q", f.State, stateBlocked) + } + if !strings.Contains(f.Missing, "local-only") { + t.Fatalf("ens-gasless missing = %q, want the ACME reason propagated", f.Missing) + } +} + +func TestACMEFeatureEnabledForPublicHost(t *testing.T) { + cfg := relayServerConfig{ + PortalURL: "https://relay.example.com", + ACMEDNSProvider: "cloudflare", + CloudflareToken: "token", + } + + f := featureByName(t, cfg, "acme") + if f.State != stateEnabled { + t.Fatalf("acme state = %q, want %q (missing: %s)", f.State, stateEnabled, f.Missing) + } +} + +func TestACMEFeatureBlockedWithoutCredential(t *testing.T) { + cfg := relayServerConfig{ + PortalURL: "https://relay.example.com", + ACMEDNSProvider: "cloudflare", + } + + f := featureByName(t, cfg, "acme") + if f.State != stateBlocked { + t.Fatalf("acme state = %q, want %q", f.State, stateBlocked) + } + if !strings.Contains(f.Missing, "CLOUDFLARE_TOKEN") { + t.Fatalf("acme missing = %q, want it to name the credential", f.Missing) + } +} + +// A line that is neither blank, a comment, nor an assignment is a typo, and +// dropping it would recreate the silent misconfiguration this command exists to +// expose: the feature would report its default with nothing to explain why. +func TestLoadEnvFileRejectsMalformedLines(t *testing.T) { + for name, line := range map[string]string{ + "missing separator": "DISCOVERY true", + "empty name": "=true", + } { + t.Run(name, func(t *testing.T) { + path := writeEnvFile(t, "PORTAL_URL=https://relay.example.com", line) + + _, err := loadEnvFile(path) + if err == nil { + t.Fatalf("%q was accepted", line) + } + if !strings.Contains(err.Error(), ":2:") { + t.Fatalf("error does not point at the line: %v", err) + } + }) + } +} + +func TestLoadEnvFileKeepsCommentsAndBlanks(t *testing.T) { + path := writeEnvFile(t, "# a comment", "", " ", "export PORTAL_URL=https://relay.example.com") + + entries, err := loadEnvFile(path) + if err != nil { + t.Fatalf("load env file: %v", err) + } + if len(entries) != 1 || entries[0].Name != "PORTAL_URL" { + t.Fatalf("entries = %v, want only PORTAL_URL", entries) + } +} + +// The report must not claim a feature works when the server will reject the +// same value moments later. portal.NewServer normalizes PORTAL_URL through +// utils.NormalizeRelayURL, which requires https. +func TestDiscoveryBlockedForNonHTTPSPortalURL(t *testing.T) { + path := writeEnvFile(t, "DISCOVERY=true", "PORTAL_URL=http://relay.example.com") + cfg := resolveWithEnvFile(t, path) + + f := discoveryFeature(cfg) + if f.State != stateBlocked { + t.Fatalf("discovery state = %q, want blocked for a non-https PORTAL_URL", f.State) + } + if !strings.Contains(f.Missing, "https") { + t.Fatalf("missing = %q, want it to name the https requirement", f.Missing) + } +} + +func TestDiscoveryBlockedForUnusableBootstraps(t *testing.T) { + path := writeEnvFile(t, + "DISCOVERY=true", + "PORTAL_URL=https://relay.example.com", + "BOOTSTRAPS=http://peer.example.com") + cfg := resolveWithEnvFile(t, path) + + f := discoveryFeature(cfg) + if f.State != stateBlocked { + t.Fatalf("discovery state = %q, want blocked for an unusable BOOTSTRAPS", f.State) + } +} + +func TestDiscoveryEnabledCountsNormalizedBootstraps(t *testing.T) { + path := writeEnvFile(t, + "DISCOVERY=true", + "PORTAL_URL=https://relay.example.com", + "BOOTSTRAPS=https://a.example.com,https://b.example.com") + cfg := resolveWithEnvFile(t, path) + + f := discoveryFeature(cfg) + if f.State != stateEnabled { + t.Fatalf("discovery state = %q, want enabled", f.State) + } + if !strings.Contains(f.Detail, "bootstraps=2") { + t.Fatalf("detail = %q, want bootstraps=2", f.Detail) + } +} + +// An unset ACME_DNS_PROVIDER selects the embedded authoritative server, not +// "no automation". Reporting it as disabled would describe a relay that is in +// fact serving its own zone. +func TestACMEFeatureReportsEmbeddedWhenUnset(t *testing.T) { + path := writeEnvFile(t, "PORTAL_URL=https://relay.example.com") + cfg := resolveWithEnvFile(t, path) + + f := acmeFeature(cfg) + if f.State != stateEnabled { + t.Fatalf("acme state = %q, want enabled for the embedded default", f.State) + } + if !strings.Contains(f.By, "embedded") { + t.Fatalf("by = %q, want it to name the embedded provider", f.By) + } +} + +// The embedded server cannot manage zone DNSSEC yet, and it is what an operator +// gets by enabling ENS and changing nothing else. +func TestENSGaslessBlockedOnEmbeddedProvider(t *testing.T) { + path := writeEnvFile(t, + "PORTAL_URL=https://relay.example.com", + "ENS_GASLESS_ENABLED=true") + cfg := resolveWithEnvFile(t, path) + + f := ensGaslessFeature(cfg) + if f.State != stateBlocked { + t.Fatalf("ens-gasless state = %q, want blocked on the embedded provider", f.State) + } + if !strings.Contains(f.Missing, "DNSSEC") { + t.Fatalf("missing = %q, want it to name the DNSSEC limitation", f.Missing) + } +} + +func TestENSGaslessEnabledOnManagedProvider(t *testing.T) { + path := writeEnvFile(t, + "PORTAL_URL=https://relay.example.com", + "ACME_DNS_PROVIDER=cloudflare", + "CLOUDFLARE_TOKEN=token", + "ENS_GASLESS_ENABLED=true") + cfg := resolveWithEnvFile(t, path) + + if f := ensGaslessFeature(cfg); f.State != stateEnabled { + t.Fatalf("ens-gasless state = %q (%s), want enabled", f.State, f.Missing) + } +} diff --git a/cmd/relay-server/envcatalog.go b/cmd/relay-server/envcatalog.go new file mode 100644 index 00000000..c0006db8 --- /dev/null +++ b/cmd/relay-server/envcatalog.go @@ -0,0 +1,84 @@ +package main + +import "github.com/gosuda/portal-tunnel/v2/portal/acme" + +// The deployment .env is shared by the relay and by Docker Compose itself. +// Checking a key against the relay's own flags alone would report the +// Compose-level ones as unknown, so the keys owned elsewhere are catalogued +// here. +// +// This table is the only place that knowledge lives. Relay-owned keys are not +// listed: they come from the flag definitions in main.go through +// utils.EnvVars(). + +const ( + ownerCompose = "compose" + ownerGoogleSDK = "Google Cloud SDK" + ownerImage = "container image" +) + +// externalEnvVar is a deployment key this binary does not read. +// +// Pinned keys are recognised so that setting one is never reported as unknown, +// but they are left out of .env.example on purpose: the bundled topology fixes +// them, and listing them invites an override that breaks the wiring. Pinned is +// what keeps that decision from silently reverting the next time someone runs +// the drift check. +type externalEnvVar struct { + Owner string + Usage string + Pinned bool +} + +var externalEnvVars = map[string]externalEnvVar{ + "PPROF_PORT": {Owner: ownerCompose, + Usage: "host port published for the pprof listener, when that mapping is uncommented in docker-compose.yml. Consumed when Compose parses the file, so it is not a container variable."}, + + "GOOGLE_APPLICATION_CREDENTIALS": {Owner: ownerGoogleSDK, + Usage: "service account file path. Read by the Google Cloud SDK directly rather than by a relay flag, so it is passed through untouched."}, + + "TZ": {Owner: ownerImage, Pinned: true, + Usage: "container time zone. Set to UTC by the image."}, +} + +// alsoConsumedBy notes extra consumers of keys the relay does read, so the +// report can say that changing one moves more than the relay. +var alsoConsumedBy = map[string]string{ + "IDENTITY_PATH": "compose mounts ./.portal-certs at this path", + "WIREGUARD_PORT": "compose publishes this UDP port", + "MIN_PORT": "compose publishes this port range when the mapping is uncommented", + "MAX_PORT": "compose publishes this port range when the mapping is uncommented", + "PORTAL_FRONTEND_DIR": "compose has a matching read-only mount to uncomment when replacing the SPA", +} + +// pinnedByTopology are keys the bundled Compose stack fixes because the relay +// reaches itself at those ports through its own SNI router. Overriding one +// through .env breaks that wiring, so the report calls it out. +var pinnedByTopology = map[string]struct { + Value string + Reason string +}{ + "API_PORT": {"4017", "the SNI router forwards root-host traffic to the internal API listener on this port"}, + "SNI_PORT": {"443", "this is the public port tunnel clients are told to reach"}, +} + +// dnsProviderCredential maps each supported ACME_DNS_PROVIDER value to the +// credential it requires. Providers whose credentials come from an ambient +// chain (an instance role, application default credentials) map to an empty +// list because there is nothing to require. +// +// The keys are acme's own exported constants rather than repeated strings, so +// a provider added there cannot silently go unreported here: acme.NewDNSProvider +// decides what is supported, and this map only adds the credential each one +// needs, which is knowledge the report owns. +var dnsProviderCredential = map[string][]string{ + // The embedded server is the default and needs no credentials: it answers + // from the relay itself rather than through a provider API. + acme.TypeEmbedded: nil, + acme.TypeCloudflare: {"CLOUDFLARE_TOKEN"}, + acme.TypeHetzner: {"HETZNER_API_TOKEN"}, + acme.TypeNjalla: {"NJALLA_TOKEN"}, + acme.TypeVultr: {"VULTR_API_KEY"}, + acme.TypeRoute53: nil, + acme.TypeGCloud: nil, +} diff --git a/cmd/relay-server/main.go b/cmd/relay-server/main.go index f61ba382..7894cd8b 100644 --- a/cmd/relay-server/main.go +++ b/cmd/relay-server/main.go @@ -24,9 +24,10 @@ import ( func main() { log.Logger = log.Output(zerolog.NewConsoleWriter()) if err := utils.RunCommands(os.Args[1:], os.Stdout, os.Stderr, printRootUsage, map[string]utils.CommandFunc{ - "": runServeCommand, - "serve": runServeCommand, - "help": runHelpCommand, + "": runServeCommand, + "serve": runServeCommand, + "config": runConfigCommand, + "help": runHelpCommand, }); err != nil { log.Error().Err(err).Msg("execute root command") os.Exit(1) @@ -73,7 +74,15 @@ type relayServerConfig struct { NjallaToken string } -func runServeCommand(args []string) error { +// resolveRelayServerConfig registers every flag and resolves it against the +// process environment. The config subcommand reuses it so that inspecting a +// deployment and running it read the same definitions. +func resolveRelayServerConfig(args []string) (relayServerConfig, error) { + // Registration records into a process-global registry, so start from empty: + // the config subcommand loads an env file and resolves again, and issues + // from an earlier pass must not fail the current one. + utils.ResetEnvRegistry() + cfg := relayServerConfig{} fs := utils.NewFlagSet("relay-server", printRootUsage) @@ -105,6 +114,7 @@ func runServeCommand(args []string) error { utils.StringFlagEnv(fs, &cfg.ACMEDNSProvider, "acme-dns-provider", "", "DNS provider for managed DNS-01/A-record sync, ECH HTTPS records, and ENS gasless DNSSEC/TXT automation (embedded|cloudflare|gcloud|hetzner|njalla|route53|vultr); defaults to embedded when unset", "ACME_DNS_PROVIDER") utils.BoolFlagEnv(fs, &cfg.ENSGaslessEnabled, "ens-gasless-enabled", false, "enable ENS gasless DNS import automation for the managed DNS zone and lease hostnames", "ENS_GASLESS_ENABLED") utils.IntFlagEnv(fs, &cfg.EmbeddedDNSPort, "embedded-dns-port", 53, utils.ParsePortNumber, "listen port for the embedded authoritative DNS server (the default DNS provider); requires a one-time NS delegation of the base domain and open 53/tcp+udp", "EMBEDDED_DNS_PORT") + utils.StringFlagEnv(fs, &cfg.CloudflareToken, "cloudflare-token", "", "Cloudflare DNS API token for DNS automation (required when acme-dns-provider=cloudflare)", "CLOUDFLARE_TOKEN") utils.StringFlagEnv(fs, &cfg.GCPProjectID, "gcp-project-id", "", "Google Cloud project id for Cloud DNS automation; auto-detected from ADC or GCE metadata when omitted", "GCP_PROJECT_ID", "GOOGLE_CLOUD_PROJECT", "GCLOUD_PROJECT", "GCE_PROJECT") utils.StringFlagEnv(fs, &cfg.GCPManagedZone, "gcp-managed-zone", "", "explicit Google Cloud DNS managed zone name or numeric ID override", "GCP_MANAGED_ZONE", "GCP_ZONE", "GCE_ZONE_ID") utils.StringFlagEnv(fs, &cfg.HetznerAPIToken, "hetzner-api-token", "", "Hetzner Cloud API token for DNS automation (required when acme-dns-provider=hetzner)", "HETZNER_API_TOKEN", "HCLOUD_TOKEN") @@ -118,44 +128,41 @@ func runServeCommand(args []string) error { utils.StringFlagEnv(fs, &cfg.NjallaToken, "njalla-token", "", "Njalla API token for DNS automation (required when acme-dns-provider=njalla)", "NJALLA_TOKEN") if err := utils.ParseFlagSet(fs, args, printRootUsage); err != nil { + return relayServerConfig{}, err + } + if err := utils.RequireNoArgs(fs.Args(), "relay-server"); err != nil { + printRootUsage(os.Stderr) + return relayServerConfig{}, err + } + cfg.IdentityPath = identity.ResolveRelayStateDir(cfg.IdentityPath) + return cfg, nil +} + +func runServeCommand(args []string) error { + cfg, err := resolveRelayServerConfig(args) + if err != nil { if errors.Is(err, flag.ErrHelp) { return nil } return err } - if err := utils.RequireNoArgs(fs.Args(), "relay-server"); err != nil { - printRootUsage(os.Stderr) + // A value that could not be parsed is always a mistake. Starting anyway is + // how a deployment ends up running with a setting nobody reads. + if err := envIssueError(); err != nil { return err } - cfg.IdentityPath = identity.ResolveRelayStateDir(cfg.IdentityPath) log.Info(). Str("release_version", types.ReleaseVersion). Str("portal_url", cfg.PortalURL). - Str("frontend_dir", cfg.FrontendDir). Str("identity_path", cfg.IdentityPath). - Str("bootstraps", cfg.Bootstraps). - Bool("discovery_enabled", cfg.DiscoveryEnabled). - Int("wireguard_port", cfg.WireGuardPort). Int("api_port", cfg.APIPort). Int("sni_port", cfg.SNIPort). - Bool("trust_proxy_headers", cfg.TrustProxyHeaders). - Str("trusted_proxy_cidrs", cfg.TrustedProxyCIDRs). - Bool("udp_enabled", cfg.UDPEnabled). - Bool("tcp_enabled", cfg.TCPEnabled). - Bool("landing_page_enabled", cfg.LandingPageEnabled). - Int("min_port", cfg.MinPort). - Int("max_port", cfg.MaxPort). - Bool("admin_token_configured", strings.TrimSpace(cfg.AdminToken) != ""). - Bool("pprof_enabled", cfg.PProfEnabled). - Str("pprof_addr", cfg.PProfAddr). - Bool("x402_facilitator_enabled", cfg.X402Enabled). - Bool("x402_testnet", cfg.X402Testnet). - Bool("x402_pay_to_configured", strings.TrimSpace(cfg.X402PayTo) != ""). - Str("acme_dns_provider", cfg.ACMEDNSProvider). - Int("embedded_dns_port", cfg.EmbeddedDNSPort). - Bool("ens_gasless_enabled", cfg.ENSGaslessEnabled). - Msg("configured relay server") + Msg("starting relay server") + + // Report each capability with the setting that produced it, so a feature + // that was switched off is distinguishable from one that cannot run. + logFeatureReport(evaluateFeatures(cfg)) ctx, stop := utils.SignalContext() defer stop() diff --git a/docs/src/routes/configuration/+page.md b/docs/src/routes/configuration/+page.md index 1516b4db..bb91d0a4 100644 --- a/docs/src/routes/configuration/+page.md +++ b/docs/src/routes/configuration/+page.md @@ -7,10 +7,44 @@ description: Complete reference for all Portal environment variables, CLI flags, Complete reference for all Portal environment variables, CLI flags, and configuration files. +## Checking a Real Deployment + +This page describes what each variable means. To see what a specific deployment +is actually doing, ask the binary rather than reading a table: + +```bash +docker compose run --rm -T portal config +``` + +It prints every key with its effective value and where that value came from, +names any key nothing reads, and reports which features are off and what is +missing. + +Run it **inside the container, without `--env-file`**. Compose has already +combined `.env` with the defaults declared in `docker-compose.yml`, so the +report then describes the environment `docker compose up` will actually +provide. `--env-file` reads a file on its own, against the relay binary's +defaults — `MIN_PORT` is `0` there and `40000` under Compose — so a file that +sets only `PORTAL_URL` and `UDP_ENABLED=true` is reported as +`udp-transport blocked` although the deployment would enable it. Use it to +inspect a file in isolation, not to predict a deployment: + +```bash +relay-server config # this process environment +relay-server config --env-file .env # one file, against relay defaults +``` + +`relay-server config --format env` regenerates the full list from the flag +definitions, and `make check-env-example` fails when this page or +`.env.example` stops mentioning a key. + ## Relay Server Environment Variables The relay server (`relay-server`) reads configuration from environment variables. Each variable corresponds to a CLI flag of the same shape (e.g. `PORTAL_URL` → `--portal-url`). CLI flags take precedence over environment variables when both are set. +A value that cannot be parsed is a startup error rather than a silent fallback: +`DISCOVERY=yes` fails immediately instead of resolving to `false`. + ### Core | Variable | Default | Type | Description | @@ -77,6 +111,7 @@ Serves the relay base domain from an authoritative DNS server embedded in the re |----------|---------|------|-------------| | `PPROF_ENABLED` | `false` | bool | Enable the relay pprof diagnostics HTTP server | | `PPROF_ADDR` | `127.0.0.1:6060` | string | pprof listen address when enabled; keep it on loopback unless the port is protected | +| `PPROF_PORT` | `6060` | int | Host port published for the pprof listener, read by Docker Compose rather than the relay. Only takes effect when the matching port mapping in `docker-compose.yml` is uncommented, which exposes it to the host | ### Admin diff --git a/utils/cmd.go b/utils/cmd.go index 36c8da0e..aec5a6aa 100644 --- a/utils/cmd.go +++ b/utils/cmd.go @@ -9,6 +9,7 @@ import ( "net" "os" "os/signal" + "slices" "strconv" "strings" "syscall" @@ -19,22 +20,112 @@ type CommandFunc func([]string) error type IntEnvParser func(string, int) int type boolFlagValue interface{ IsBoolFlag() bool } +// EnvVar records one environment variable that backs a flag, together with the +// flag's own documentation. Flag definitions already carry the name, default, +// and usage text, so registering them here lets `relay-server config`, the +// startup feature report, and the generated .env.example all read from the flag +// definitions instead of a second hand-maintained list. +type EnvVar struct { + Name string + Aliases []string + Flag string + Usage string + Default string + // Value is what the flag actually resolved to. Reporting the raw text of an + // env file instead would show a value that a higher-priority name overrode, + // or an alias that was never consulted, as though it were in effect. + Value string + // SetBy is the environment variable that supplied the value, or empty when + // the default was used. It answers "I set that, why did nothing change?". + SetBy string +} + +// EnvIssue is a value that was present but unusable. Without recording these, +// the resolve helpers below silently fall back and a typo is indistinguishable +// from an intentional default. +type EnvIssue struct { + Name string + Value string + Problem string +} + +// The process environment is process-global, so the registry is too. Flag +// registration happens once per process before any concurrent work starts. +var ( + envVars []EnvVar + envVarIndex = map[string]int{} + envIssues []EnvIssue +) + +// EnvVars returns every environment variable backing a registered flag, in +// registration order. +func EnvVars() []EnvVar { + return slices.Clone(envVars) +} + +// EnvIssues returns values that were set but could not be used. +func EnvIssues() []EnvIssue { + return slices.Clone(envIssues) +} + +// ResetEnvRegistry clears both the registry and the recorded issues so a +// resolution pass reports only its own environment. Registering a flag twice +// already replaces its entry, but issues would otherwise accumulate across +// passes and a stale one could fail a configuration that no longer has it. +func ResetEnvRegistry() { + envVars = nil + envVarIndex = map[string]int{} + envIssues = nil +} + +func registerEnvVar(flagName, usage, defaultValue, value, setBy string, envNames []string) { + names := make([]string, 0, len(envNames)) + for _, envName := range envNames { + if envName = strings.TrimSpace(envName); envName != "" { + names = append(names, envName) + } + } + if len(names) == 0 { + return + } + + entry := EnvVar{ + Name: names[0], + Aliases: names[1:], + Flag: flagName, + Usage: usage, + Default: defaultValue, + Value: value, + SetBy: setBy, + } + // Registering the same flag twice (a re-parsed command, a test) replaces the + // entry rather than duplicating it. + if i, ok := envVarIndex[entry.Name]; ok { + envVars[i] = entry + return + } + envVarIndex[entry.Name] = len(envVars) + envVars = append(envVars, entry) +} + +func recordEnvIssue(name, value, problem string) { + envIssues = append(envIssues, EnvIssue{Name: name, Value: value, Problem: problem}) +} + func trimmedEnv(name string) string { return strings.TrimSpace(os.Getenv(name)) } -func resolveStringEnv(fallback string, envNames ...string) string { - value := fallback +func resolveStringEnv(fallback string, envNames ...string) (string, string) { for _, envName := range envNames { if envValue := trimmedEnv(envName); envValue != "" { - value = envValue - break + return envValue, envName } } - return value + return fallback, "" } -func resolveBoolEnv(fallback bool, envNames ...string) bool { +func resolveBoolEnv(fallback bool, envNames ...string) (bool, string) { for _, envName := range envNames { raw := trimmedEnv(envName) if raw == "" { @@ -42,14 +133,15 @@ func resolveBoolEnv(fallback bool, envNames ...string) bool { } parsed, err := strconv.ParseBool(raw) if err != nil { - return fallback + recordEnvIssue(envName, raw, "not a boolean; use true or false") + return fallback, "" } - return parsed + return parsed, envName } - return fallback + return fallback, "" } -func resolveIntEnv(fallback int, parse IntEnvParser, envNames ...string) int { +func resolveIntEnv(fallback int, parse IntEnvParser, envNames ...string) (int, string) { if parse == nil { parse = func(raw string, fallback int) int { v, err := strconv.Atoi(strings.TrimSpace(raw)) @@ -64,9 +156,21 @@ func resolveIntEnv(fallback int, parse IntEnvParser, envNames ...string) int { if raw == "" { continue } - return parse(raw, fallback) + // Parse first so a non-numeric value is reported as such instead of + // being flattened into the parser's fallback. + number, err := strconv.Atoi(raw) + if err != nil { + recordEnvIssue(envName, raw, "not an integer") + return fallback, "" + } + value := parse(raw, fallback) + if value != number && value == fallback { + recordEnvIssue(envName, raw, "out of the accepted range") + return fallback, "" + } + return value, envName } - return fallback + return fallback, "" } func ParsePortNumber(raw string, fallback int) int { @@ -118,7 +222,9 @@ func StringFlag(fs *flag.FlagSet, target *string, name, fallback, usage string) } func StringFlagEnv(fs *flag.FlagSet, target *string, name, fallback, usage string, envNames ...string) { - ensureFlagSet(fs).StringVar(target, name, resolveStringEnv(fallback, envNames...), flagUsage(usage, envNames...)) + value, setBy := resolveStringEnv(fallback, envNames...) + registerEnvVar(name, usage, fallback, value, setBy, envNames) + ensureFlagSet(fs).StringVar(target, name, value, flagUsage(usage, envNames...)) } func BoolFlag(fs *flag.FlagSet, target *bool, name string, fallback bool, usage string) { @@ -126,11 +232,15 @@ func BoolFlag(fs *flag.FlagSet, target *bool, name string, fallback bool, usage } func BoolFlagEnv(fs *flag.FlagSet, target *bool, name string, fallback bool, usage string, envNames ...string) { - ensureFlagSet(fs).BoolVar(target, name, resolveBoolEnv(fallback, envNames...), flagUsage(usage, envNames...)) + value, setBy := resolveBoolEnv(fallback, envNames...) + registerEnvVar(name, usage, strconv.FormatBool(fallback), strconv.FormatBool(value), setBy, envNames) + ensureFlagSet(fs).BoolVar(target, name, value, flagUsage(usage, envNames...)) } func IntFlagEnv(fs *flag.FlagSet, target *int, name string, fallback int, parse IntEnvParser, usage string, envNames ...string) { - ensureFlagSet(fs).IntVar(target, name, resolveIntEnv(fallback, parse, envNames...), flagUsage(usage, envNames...)) + value, setBy := resolveIntEnv(fallback, parse, envNames...) + registerEnvVar(name, usage, strconv.Itoa(fallback), strconv.Itoa(value), setBy, envNames) + ensureFlagSet(fs).IntVar(target, name, value, flagUsage(usage, envNames...)) } func RepeatedStringFlag(fs *flag.FlagSet, target *[]string, name, usage string) {