diff --git a/.air.toml b/.air.toml index 6010fd22..5e97b31b 100644 --- a/.air.toml +++ b/.air.toml @@ -16,14 +16,14 @@ tmp_dir = "tmp" include_dir = [] include_ext = ["go", "tpl", "tmpl", "html", "yaml"] include_file = [] - kill_delay = "0s" + kill_delay = "5s" log = "build-errors.log" poll = false poll_interval = 0 post_cmd = [] rerun = false rerun_delay = 500 - send_interrupt = false + send_interrupt = true stop_on_error = false [color] diff --git a/.env.example b/.env.example index 5a052dbb..161c5261 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,78 @@ +# Required JWT_SECRET='your-secret-key-here' -DATA_DIR=/home/your-user/hypeman/.datadir # or leave unset to default to /var/lib/hypeman + +# Data directory (default: /var/lib/hypeman) +DATA_DIR=/var/lib/hypeman + +# Server configuration +# PORT=8080 + +# Network configuration +# BRIDGE_NAME=vmbr0 +# SUBNET_CIDR=10.100.0.0/16 +# SUBNET_GATEWAY= # empty = derived from SUBNET_CIDR +# UPLINK_INTERFACE= # empty = auto-detect from default route +# DNS_SERVER=1.1.1.1 + +# Logging +# LOG_LEVEL=info # debug, info, warn, error + +# Caddy / Ingress configuration +# CADDY_LISTEN_ADDRESS=0.0.0.0 +# CADDY_ADMIN_ADDRESS=127.0.0.1 +# CADDY_ADMIN_PORT=0 # 0 = random port (prevents conflicts on shared dev machines) +# CADDY_STOP_ON_SHUTDOWN=false # Set to true if you want Caddy to stop when hypeman stops + +# ============================================================================= +# TLS / ACME Configuration (for HTTPS ingresses) +# ============================================================================= +# Required for TLS ingresses: +# ACME_EMAIL=admin@example.com +# ACME_DNS_PROVIDER=cloudflare + +# IMPORTANT: You must specify which domains are allowed for TLS certificates. +# This prevents typos and ensures you only request certificates for domains you control. +# TLS_ALLOWED_DOMAINS=*.example.com,api.other.com +# Supports: +# - Exact matches: api.example.com +# - Wildcard subdomains: *.example.com (matches foo.example.com, NOT foo.bar.example.com) +# If not set, no TLS ingresses are allowed. + +# Optional ACME settings: +# ACME_CA= # empty = Let's Encrypt production + # Use https://acme-staging-v02.api.letsencrypt.org/directory for testing + +# DNS propagation settings (applies to all providers): +# DNS_PROPAGATION_TIMEOUT=2m # Max time to wait for DNS propagation +# DNS_RESOLVERS=1.1.1.1,8.8.8.8 # Custom DNS resolvers for propagation checking + +# ----------------------------------------------------------------------------- +# Cloudflare DNS Provider (ACME_DNS_PROVIDER=cloudflare) +# ----------------------------------------------------------------------------- +# CLOUDFLARE_API_TOKEN=your-api-token +# Token needs Zone:DNS:Edit permissions for the domains you want certificates for +# ============================================================================= +# OpenTelemetry Configuration +# ============================================================================= +# OTEL_ENABLED=false +# OTEL_ENDPOINT=127.0.0.1:4317 +# OTEL_SERVICE_NAME=hypeman +# OTEL_SERVICE_INSTANCE_ID= # default: hostname +# OTEL_INSECURE=true +# ENV=dev # deployment environment + +# ============================================================================= +# Resource Limits +# ============================================================================= +# Per-instance limits +# MAX_VCPUS_PER_INSTANCE=16 +# MAX_MEMORY_PER_INSTANCE=32GB + +# Aggregate limits (0 or empty = unlimited) +# MAX_TOTAL_VCPUS=0 +# MAX_TOTAL_MEMORY= +# MAX_TOTAL_VOLUME_STORAGE= + +# Other limits +# MAX_CONCURRENT_BUILDS=1 +# MAX_OVERLAY_SIZE=100GB diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d7e0c114..6d9cb67e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -35,4 +35,14 @@ jobs: run: make build - name: Run tests + env: + # Docker auth for tests running as root (sudo) + DOCKER_CONFIG: /home/debianuser/.docker + # TLS/ACME testing (optional - tests will skip if not configured) + ACME_EMAIL: ${{ secrets.ACME_EMAIL }} + ACME_DNS_PROVIDER: "cloudflare" + ACME_CA: "https://acme-staging-v02.api.letsencrypt.org/directory" + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + TLS_TEST_DOMAIN: "test.hypeman-development.com" + TLS_ALLOWED_DOMAINS: '*.hypeman-development.com' run: make test diff --git a/.gitignore b/.gitignore index 96f94b55..fd299f80 100644 --- a/.gitignore +++ b/.gitignore @@ -22,5 +22,5 @@ cloud-hypervisor/** lib/system/exec_agent/exec-agent # Envoy binaries -lib/ingress/binaries/envoy/*/*/envoy +lib/ingress/binaries/** dist/** diff --git a/Makefile b/Makefile index eaf1fc8e..1fa1f9c0 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ SHELL := /bin/bash -.PHONY: oapi-generate generate-vmm-client generate-wire generate-all dev build test install-tools gen-jwt download-ch-binaries download-ch-spec ensure-ch-binaries download-envoy-binaries ensure-envoy-binaries release-prep +.PHONY: oapi-generate generate-vmm-client generate-wire generate-all dev build test install-tools gen-jwt download-ch-binaries download-ch-spec ensure-ch-binaries build-caddy-binaries build-caddy ensure-caddy-binaries release-prep # Directory where local binaries will be installed BIN_DIR ?= $(CURDIR)/bin @@ -12,6 +12,7 @@ OAPI_CODEGEN ?= $(BIN_DIR)/oapi-codegen AIR ?= $(BIN_DIR)/air WIRE ?= $(BIN_DIR)/wire GODOTENV ?= $(BIN_DIR)/godotenv +XCADDY ?= $(BIN_DIR)/xcaddy # Install oapi-codegen $(OAPI_CODEGEN): | $(BIN_DIR) @@ -29,7 +30,11 @@ $(WIRE): | $(BIN_DIR) $(GODOTENV): | $(BIN_DIR) GOBIN=$(BIN_DIR) go install github.com/joho/godotenv/cmd/godotenv@latest -install-tools: $(OAPI_CODEGEN) $(AIR) $(WIRE) $(GODOTENV) +# Install xcaddy for building Caddy with plugins +$(XCADDY): | $(BIN_DIR) + GOBIN=$(BIN_DIR) go install github.com/caddyserver/xcaddy/cmd/xcaddy@latest + +install-tools: $(OAPI_CODEGEN) $(AIR) $(WIRE) $(GODOTENV) $(XCADDY) # Download Cloud Hypervisor binaries download-ch-binaries: @@ -49,18 +54,46 @@ download-ch-binaries: @chmod +x lib/vmm/binaries/cloud-hypervisor/v*/*/cloud-hypervisor @echo "Binaries downloaded successfully" -# Download Envoy binaries -download-envoy-binaries: - @echo "Downloading Envoy binaries..." - @mkdir -p lib/ingress/binaries/envoy/v1.36/{x86_64,aarch64} - @echo "Downloading Envoy v1.36.3 for x86_64..." - @curl -L -o lib/ingress/binaries/envoy/v1.36/x86_64/envoy \ - https://github.com/envoyproxy/envoy/releases/download/v1.36.3/envoy-1.36.3-linux-x86_64 - @echo "Downloading Envoy v1.36.3 for aarch64..." - @curl -L -o lib/ingress/binaries/envoy/v1.36/aarch64/envoy \ - https://github.com/envoyproxy/envoy/releases/download/v1.36.3/envoy-1.36.3-linux-aarch_64 - @chmod +x lib/ingress/binaries/envoy/v1.36/*/envoy - @echo "Envoy binaries downloaded successfully" +# Caddy version and modules +CADDY_VERSION := v2.10.2 +CADDY_DNS_MODULES := --with github.com/caddy-dns/cloudflare + +# Build Caddy with DNS modules using xcaddy +# xcaddy builds Caddy from source with the specified modules +build-caddy-binaries: $(XCADDY) + @echo "Building Caddy $(CADDY_VERSION) with DNS modules..." + @mkdir -p lib/ingress/binaries/caddy/$(CADDY_VERSION)/x86_64 + @mkdir -p lib/ingress/binaries/caddy/$(CADDY_VERSION)/aarch64 + @echo "Building Caddy $(CADDY_VERSION) for x86_64..." + GOOS=linux GOARCH=amd64 $(XCADDY) build $(CADDY_VERSION) \ + $(CADDY_DNS_MODULES) \ + --output lib/ingress/binaries/caddy/$(CADDY_VERSION)/x86_64/caddy + @echo "Building Caddy $(CADDY_VERSION) for aarch64..." + GOOS=linux GOARCH=arm64 $(XCADDY) build $(CADDY_VERSION) \ + $(CADDY_DNS_MODULES) \ + --output lib/ingress/binaries/caddy/$(CADDY_VERSION)/aarch64/caddy + @chmod +x lib/ingress/binaries/caddy/$(CADDY_VERSION)/*/caddy + @echo "Caddy binaries built successfully with DNS modules" + +# Build Caddy for current architecture only (faster for development) +build-caddy: $(XCADDY) + @echo "Building Caddy $(CADDY_VERSION) with DNS modules for current architecture..." + @ARCH=$$(uname -m); \ + if [ "$$ARCH" = "x86_64" ]; then \ + CADDY_ARCH=x86_64; \ + GOARCH=amd64; \ + elif [ "$$ARCH" = "aarch64" ] || [ "$$ARCH" = "arm64" ]; then \ + CADDY_ARCH=aarch64; \ + GOARCH=arm64; \ + else \ + echo "Unsupported architecture: $$ARCH"; exit 1; \ + fi; \ + mkdir -p lib/ingress/binaries/caddy/$(CADDY_VERSION)/$$CADDY_ARCH; \ + GOOS=linux GOARCH=$$GOARCH $(XCADDY) build $(CADDY_VERSION) \ + $(CADDY_DNS_MODULES) \ + --output lib/ingress/binaries/caddy/$(CADDY_VERSION)/$$CADDY_ARCH/caddy; \ + chmod +x lib/ingress/binaries/caddy/$(CADDY_VERSION)/$$CADDY_ARCH/caddy + @echo "Caddy binary built successfully" # Download Cloud Hypervisor API spec download-ch-spec: @@ -107,12 +140,20 @@ ensure-ch-binaries: $(MAKE) download-ch-binaries; \ fi -# Check if Envoy binaries exist, download if missing -.PHONY: ensure-envoy-binaries -ensure-envoy-binaries: - @if [ ! -f lib/ingress/binaries/envoy/v1.36/x86_64/envoy ]; then \ - echo "Envoy binaries not found, downloading..."; \ - $(MAKE) download-envoy-binaries; \ +# Check if Caddy binaries exist, build if missing +.PHONY: ensure-caddy-binaries +ensure-caddy-binaries: + @ARCH=$$(uname -m); \ + if [ "$$ARCH" = "x86_64" ]; then \ + CADDY_ARCH=x86_64; \ + elif [ "$$ARCH" = "aarch64" ] || [ "$$ARCH" = "arm64" ]; then \ + CADDY_ARCH=aarch64; \ + else \ + echo "Unsupported architecture: $$ARCH"; exit 1; \ + fi; \ + if [ ! -f lib/ingress/binaries/caddy/$(CADDY_VERSION)/$$CADDY_ARCH/caddy ]; then \ + echo "Caddy binary not found, building with xcaddy..."; \ + $(MAKE) build-caddy; \ fi # Build exec-agent (guest binary) into its own directory for embedding @@ -121,7 +162,7 @@ lib/system/exec_agent/exec-agent: lib/system/exec_agent/main.go cd lib/system/exec_agent && CGO_ENABLED=0 go build -ldflags="-s -w" -o exec-agent . # Build the binary -build: ensure-ch-binaries ensure-envoy-binaries lib/system/exec_agent/exec-agent | $(BIN_DIR) +build: ensure-ch-binaries ensure-caddy-binaries lib/system/exec_agent/exec-agent | $(BIN_DIR) go build -tags containers_image_openpgp -o $(BIN_DIR)/hypeman ./cmd/api # Build exec CLI @@ -132,45 +173,18 @@ build-exec: | $(BIN_DIR) build-all: build build-exec # Run in development mode with hot reload -dev: $(AIR) +dev: ensure-ch-binaries ensure-caddy-binaries lib/system/exec_agent/exec-agent $(AIR) $(AIR) -c .air.toml -# Run tests -# Compile test binaries and grant network capabilities (runs as user, not root) +# Run tests (as root for network capabilities, enables caching and parallelism) # Usage: make test - runs all tests # make test TEST=TestCreateInstanceWithNetwork - runs specific test -test: ensure-ch-binaries ensure-envoy-binaries lib/system/exec_agent/exec-agent - @echo "Building test binaries..." - @mkdir -p $(BIN_DIR)/tests - @for pkg in $$(go list -tags containers_image_openpgp ./...); do \ - pkg_name=$$(basename $$pkg); \ - go test -c -tags containers_image_openpgp -o $(BIN_DIR)/tests/$$pkg_name.test $$pkg 2>/dev/null || true; \ - done - @echo "Granting capabilities to test binaries..." - @for test in $(BIN_DIR)/tests/*.test; do \ - if [ -f "$$test" ]; then \ - sudo setcap 'cap_net_admin,cap_net_bind_service=+eip' $$test 2>/dev/null || true; \ - fi; \ - done - @echo "Running tests as current user with capabilities..." +test: ensure-ch-binaries ensure-caddy-binaries lib/system/exec_agent/exec-agent @if [ -n "$(TEST)" ]; then \ echo "Running specific test: $(TEST)"; \ - for test in $(BIN_DIR)/tests/*.test; do \ - if [ -f "$$test" ]; then \ - echo ""; \ - echo "Checking $$(basename $$test) for $(TEST)..."; \ - $$test -test.run=$(TEST) -test.v -test.timeout=60s 2>&1 | grep -q "PASS\|FAIL" && \ - $$test -test.run=$(TEST) -test.v -test.timeout=60s || true; \ - fi; \ - done; \ + sudo env "PATH=$$PATH" "DOCKER_CONFIG=$${DOCKER_CONFIG:-$$HOME/.docker}" go test -tags containers_image_openpgp -run=$(TEST) -v -timeout=180s ./...; \ else \ - for test in $(BIN_DIR)/tests/*.test; do \ - if [ -f "$$test" ]; then \ - echo ""; \ - echo "Running $$(basename $$test)..."; \ - $$test -test.v -test.parallel=10 -test.timeout=60s || exit 1; \ - fi; \ - done; \ + sudo env "PATH=$$PATH" "DOCKER_CONFIG=$${DOCKER_CONFIG:-$$HOME/.docker}" go test -tags containers_image_openpgp -v -timeout=180s ./...; \ fi # Generate JWT token for testing @@ -189,5 +203,5 @@ clean: # Prepare for release build (called by GoReleaser) # Downloads all embedded binaries and builds embedded components -release-prep: download-ch-binaries download-envoy-binaries lib/system/exec_agent/exec-agent +release-prep: download-ch-binaries ensure-caddy-binaries lib/system/exec_agent/exec-agent go mod tidy diff --git a/README.md b/README.md index 0d2d31df..8ff31f22 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,7 @@ getcap ./bin/hypeman **File Descriptor Limits:** -Envoy (used for ingress) requires a higher file descriptor limit than the default on some systems (root defaults to 1024 on many systems). If you see "Too many open files" errors, increase the limit: +Caddy (used for ingress) requires a higher file descriptor limit than the default on some systems. If you see "Too many open files" errors, increase the limit: ```bash # Check current limit (also check with: sudo bash -c 'ulimit -n') @@ -98,11 +98,18 @@ Hypeman can be configured using the following environment variables: | `OTEL_ENDPOINT` | OTLP gRPC endpoint | `127.0.0.1:4317` | | `OTEL_SERVICE_INSTANCE_ID` | Instance ID for telemetry (differentiates multiple servers) | hostname | | `LOG_LEVEL` | Default log level (debug, info, warn, error) | `info` | -| `LOG_LEVEL_` | Per-subsystem log level (API, IMAGES, INSTANCES, NETWORK, VOLUMES, VMM, SYSTEM, EXEC) | inherits default | -| `ENVOY_LISTEN_ADDRESS` | Address for Envoy ingress listeners | `0.0.0.0` | -| `ENVOY_ADMIN_ADDRESS` | Address for Envoy admin API | `127.0.0.1` | -| `ENVOY_ADMIN_PORT` | Port for Envoy admin API | `9901` | -| `ENVOY_STOP_ON_SHUTDOWN` | Stop Envoy when hypeman shuts down (if false, Envoy continues running) | `false` | +| `LOG_LEVEL_` | Per-subsystem log level (API, IMAGES, INSTANCES, NETWORK, VOLUMES, VMM, SYSTEM, EXEC, CADDY) | inherits default | +| `CADDY_LISTEN_ADDRESS` | Address for Caddy ingress listeners | `0.0.0.0` | +| `CADDY_ADMIN_ADDRESS` | Address for Caddy admin API | `127.0.0.1` | +| `CADDY_ADMIN_PORT` | Port for Caddy admin API | `2019` | +| `CADDY_STOP_ON_SHUTDOWN` | Stop Caddy when hypeman shuts down (set to `true` for dev) | `false` | +| `ACME_EMAIL` | Email for ACME certificate registration (required for TLS ingresses) | _(empty)_ | +| `ACME_DNS_PROVIDER` | DNS provider for ACME challenges: `cloudflare` | _(empty)_ | +| `ACME_CA` | ACME CA URL (empty = Let's Encrypt production) | _(empty)_ | +| `TLS_ALLOWED_DOMAINS` | Comma-separated allowed domains for TLS (e.g., `*.example.com,api.other.com`) | _(empty)_ | +| `DNS_PROPAGATION_TIMEOUT` | Max time to wait for DNS propagation (e.g., `2m`) | _(empty)_ | +| `DNS_RESOLVERS` | Comma-separated DNS resolvers for propagation checking | _(empty)_ | +| `CLOUDFLARE_API_TOKEN` | Cloudflare API token (when using `cloudflare` provider) | _(empty)_ | **Important: Subnet Configuration** @@ -111,7 +118,7 @@ The default subnet `10.100.0.0/16` is chosen to avoid common conflicts. Hypeman If you need a different subnet, set `SUBNET_CIDR` in your environment. The gateway is automatically derived as the first IP in the subnet (e.g., `10.100.0.0/16` → `10.100.0.1`). **Alternative subnets if needed:** -- `172.30.0.0/16` - Private range between common Docker (172.17.x.x) and AWS (172.31.x.x) ranges +- `172.30.0.0/16` - Private range between common Docker (172.17.x.x) and cloud provider (172.31.x.x) ranges - `10.200.0.0/16` - Another private range option **Example:** @@ -144,6 +151,39 @@ ip route show ``` Pick the interface used by the default route (usually the line starting with `default`). Avoid using local bridges like `docker0`, `br-...`, `virbr0`, or `vmbr0` as the uplink; those are typically internal virtual networks, not your actual internet-facing interface. +**TLS Ingress (HTTPS)** + +Hypeman uses Caddy with automatic ACME certificates for TLS termination. Certificates are issued via DNS-01 challenges (Cloudflare). + +To enable TLS ingresses: + +1. Configure ACME credentials in your `.env`: +```bash +# Required for any TLS ingress +ACME_EMAIL=admin@example.com + +# For Cloudflare +ACME_DNS_PROVIDER=cloudflare +CLOUDFLARE_API_TOKEN=your-api-token +``` + +2. Create an ingress with TLS enabled: +```bash +curl -X POST http://localhost:8080/v1/ingresses \ + -H "Content-Type: application/json" \ + -d '{ + "name": "my-https-app", + "rules": [{ + "match": {"hostname": "app.example.com", "port": 443}, + "target": {"instance": "my-instance", "port": 8080}, + "tls": true, + "redirect_http": true + }] + }' +``` + +Certificates are stored in `$DATA_DIR/caddy/data/` and auto-renewed by Caddy. + **Setup:** ```bash diff --git a/cmd/api/api/ingress.go b/cmd/api/api/ingress.go index cbfae6a9..bdac447d 100644 --- a/cmd/api/api/ingress.go +++ b/cmd/api/api/ingress.go @@ -45,6 +45,14 @@ func (s *ApiService) CreateIngress(ctx context.Context, request oapi.CreateIngre if rule.Match.Port != nil { matchPort = *rule.Match.Port } + tlsEnabled := false + if rule.Tls != nil { + tlsEnabled = *rule.Tls + } + redirectHTTP := false + if rule.RedirectHttp != nil { + redirectHTTP = *rule.RedirectHttp + } domainReq.Rules[i] = ingress.IngressRule{ Match: ingress.IngressMatch{ Hostname: rule.Match.Hostname, @@ -54,6 +62,8 @@ func (s *ApiService) CreateIngress(ctx context.Context, request oapi.CreateIngre Instance: rule.Target.Instance, Port: rule.Target.Port, }, + TLS: tlsEnabled, + RedirectHTTP: redirectHTTP, } } @@ -75,11 +85,27 @@ func (s *ApiService) CreateIngress(ctx context.Context, request oapi.CreateIngre Code: "hostname_in_use", Message: err.Error(), }, nil + case errors.Is(err, ingress.ErrPortInUse): + return oapi.CreateIngress409JSONResponse{ + Code: "port_in_use", + Message: err.Error(), + }, nil case errors.Is(err, ingress.ErrInstanceNotFound): return oapi.CreateIngress400JSONResponse{ Code: "instance_not_found", Message: err.Error(), }, nil + case errors.Is(err, ingress.ErrDomainNotAllowed): + return oapi.CreateIngress400JSONResponse{ + Code: "domain_not_allowed", + Message: err.Error(), + }, nil + case errors.Is(err, ingress.ErrConfigValidationFailed): + log.ErrorContext(ctx, "failed to create ingress", "error", err, "name", request.Body.Name) + return oapi.CreateIngress400JSONResponse{ + Code: "config_validation_failed", + Message: err.Error(), + }, nil default: log.ErrorContext(ctx, "failed to create ingress", "error", err, "name", request.Body.Name) return oapi.CreateIngress500JSONResponse{ @@ -92,45 +118,59 @@ func (s *ApiService) CreateIngress(ctx context.Context, request oapi.CreateIngre return oapi.CreateIngress201JSONResponse(ingressToOAPI(*ing)), nil } -// GetIngress gets ingress details by ID or name +// GetIngress gets ingress details by ID, name, or ID prefix func (s *ApiService) GetIngress(ctx context.Context, request oapi.GetIngressRequestObject) (oapi.GetIngressResponseObject, error) { log := logger.FromContext(ctx) ing, err := s.IngressManager.Get(ctx, request.Id) if err != nil { - if errors.Is(err, ingress.ErrNotFound) { + switch { + case errors.Is(err, ingress.ErrNotFound): return oapi.GetIngress404JSONResponse{ Code: "not_found", Message: "ingress not found", }, nil + case errors.Is(err, ingress.ErrAmbiguousName): + return oapi.GetIngress409JSONResponse{ + Code: "ambiguous_identifier", + Message: "identifier matches multiple ingresses, please use a more specific ID or name", + }, nil + default: + log.ErrorContext(ctx, "failed to get ingress", "error", err, "id", request.Id) + return oapi.GetIngress500JSONResponse{ + Code: "internal_error", + Message: "failed to get ingress", + }, nil } - log.ErrorContext(ctx, "failed to get ingress", "error", err, "id", request.Id) - return oapi.GetIngress500JSONResponse{ - Code: "internal_error", - Message: "failed to get ingress", - }, nil } return oapi.GetIngress200JSONResponse(ingressToOAPI(*ing)), nil } -// DeleteIngress deletes an ingress by ID or name +// DeleteIngress deletes an ingress by ID, name, or ID prefix func (s *ApiService) DeleteIngress(ctx context.Context, request oapi.DeleteIngressRequestObject) (oapi.DeleteIngressResponseObject, error) { log := logger.FromContext(ctx) err := s.IngressManager.Delete(ctx, request.Id) if err != nil { - if errors.Is(err, ingress.ErrNotFound) { + switch { + case errors.Is(err, ingress.ErrNotFound): return oapi.DeleteIngress404JSONResponse{ Code: "not_found", Message: "ingress not found", }, nil + case errors.Is(err, ingress.ErrAmbiguousName): + return oapi.DeleteIngress409JSONResponse{ + Code: "ambiguous_identifier", + Message: "identifier matches multiple ingresses, please use a more specific ID or name", + }, nil + default: + log.ErrorContext(ctx, "failed to delete ingress", "error", err, "id", request.Id) + return oapi.DeleteIngress500JSONResponse{ + Code: "internal_error", + Message: "failed to delete ingress", + }, nil } - log.ErrorContext(ctx, "failed to delete ingress", "error", err, "id", request.Id) - return oapi.DeleteIngress500JSONResponse{ - Code: "internal_error", - Message: "failed to delete ingress", - }, nil } return oapi.DeleteIngress204Response{}, nil @@ -141,6 +181,8 @@ func ingressToOAPI(ing ingress.Ingress) oapi.Ingress { rules := make([]oapi.IngressRule, len(ing.Rules)) for i, rule := range ing.Rules { port := rule.Match.GetPort() + tls := rule.TLS + redirectHTTP := rule.RedirectHTTP rules[i] = oapi.IngressRule{ Match: oapi.IngressMatch{ Hostname: rule.Match.Hostname, @@ -150,6 +192,8 @@ func ingressToOAPI(ing ingress.Ingress) oapi.Ingress { Instance: rule.Target.Instance, Port: rule.Target.Port, }, + Tls: &tls, + RedirectHttp: &redirectHTTP, } } diff --git a/cmd/api/api/registry_test.go b/cmd/api/api/registry_test.go index 938ecd9f..45d20af6 100644 --- a/cmd/api/api/registry_test.go +++ b/cmd/api/api/registry_test.go @@ -177,7 +177,7 @@ func TestRegistryLayerCaching(t *testing.T) { srcRef, err := name.ParseReference("docker.io/library/alpine:latest") require.NoError(t, err) - img, err := remote.Image(srcRef) + img, err := remote.Image(srcRef, remote.WithAuthFromKeychain(authn.DefaultKeychain)) require.NoError(t, err) digest, err := img.Digest() diff --git a/cmd/api/config/config.go b/cmd/api/config/config.go index c6c6dec4..a9f68db3 100644 --- a/cmd/api/config/config.go +++ b/cmd/api/config/config.go @@ -84,11 +84,22 @@ type Config struct { // Logging configuration LogLevel string // Default log level (debug, info, warn, error) - // Envoy / Ingress configuration - EnvoyListenAddress string // Address for Envoy to listen on (default: 0.0.0.0) - EnvoyAdminAddress string // Address for Envoy admin API (default: 127.0.0.1) - EnvoyAdminPort int // Port for Envoy admin API (default: 9901) - EnvoyStopOnShutdown bool // Stop Envoy when hypeman shuts down (default: false) + // Caddy / Ingress configuration + CaddyListenAddress string // Address for Caddy to listen on + CaddyAdminAddress string // Address for Caddy admin API + CaddyAdminPort int // Port for Caddy admin API + CaddyStopOnShutdown bool // Stop Caddy when hypeman shuts down + + // ACME / TLS configuration + AcmeEmail string // ACME account email (required for TLS ingresses) + AcmeDnsProvider string // DNS provider: "cloudflare" + AcmeCA string // ACME CA URL (empty = Let's Encrypt production) + DnsPropagationTimeout string // Max time to wait for DNS propagation (e.g., "2m") + DnsResolvers string // Comma-separated DNS resolvers for propagation checking + TlsAllowedDomains string // Comma-separated list of allowed domain patterns for TLS (e.g., "*.example.com,api.example.com") + + // Cloudflare configuration (if AcmeDnsProvider=cloudflare) + CloudflareApiToken string // Cloudflare API token } // Load loads configuration from environment variables @@ -133,13 +144,22 @@ func Load() *Config { // Logging configuration LogLevel: getEnv("LOG_LEVEL", "info"), - // Envoy / Ingress configuration - EnvoyListenAddress: getEnv("ENVOY_LISTEN_ADDRESS", "0.0.0.0"), - EnvoyAdminAddress: getEnv("ENVOY_ADMIN_ADDRESS", "127.0.0.1"), - EnvoyAdminPort: getEnvInt("ENVOY_ADMIN_PORT", 9901), - // For production, set to false - // allows for updating hypeman without restarting envoy - EnvoyStopOnShutdown: getEnvBool("ENVOY_STOP_ON_SHUTDOWN", true), + // Caddy / Ingress configuration + CaddyListenAddress: getEnv("CADDY_LISTEN_ADDRESS", "0.0.0.0"), + CaddyAdminAddress: getEnv("CADDY_ADMIN_ADDRESS", "127.0.0.1"), + CaddyAdminPort: getEnvInt("CADDY_ADMIN_PORT", 0), // 0 = random port to prevent conflicts on shared dev machines + CaddyStopOnShutdown: getEnvBool("CADDY_STOP_ON_SHUTDOWN", false), + + // ACME / TLS configuration + AcmeEmail: getEnv("ACME_EMAIL", ""), + AcmeDnsProvider: getEnv("ACME_DNS_PROVIDER", ""), + AcmeCA: getEnv("ACME_CA", ""), + DnsPropagationTimeout: getEnv("DNS_PROPAGATION_TIMEOUT", ""), + DnsResolvers: getEnv("DNS_RESOLVERS", ""), + TlsAllowedDomains: getEnv("TLS_ALLOWED_DOMAINS", ""), // Empty = no TLS domains allowed + + // Cloudflare configuration + CloudflareApiToken: getEnv("CLOUDFLARE_API_TOKEN", ""), } return cfg diff --git a/cmd/api/main.go b/cmd/api/main.go index 08649194..d5a9b67b 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -144,13 +144,13 @@ func run() error { } logger.Info("Network manager initialized") - // Initialize ingress manager (starts Envoy daemon) + // Initialize ingress manager (starts Caddy daemon and DNS server for dynamic upstreams) logger.Info("Initializing ingress manager...") if err := app.IngressManager.Initialize(app.Ctx); err != nil { logger.Error("failed to initialize ingress manager", "error", err) return fmt.Errorf("initialize ingress manager: %w", err) } - logger.Info("Ingress manager initialized", "listen_addr", cfg.EnvoyListenAddress, "admin", fmt.Sprintf("%s:%d", cfg.EnvoyAdminAddress, cfg.EnvoyAdminPort)) + logger.Info("Ingress manager initialized", "listen_addr", cfg.CaddyListenAddress, "admin", fmt.Sprintf("%s:%d", cfg.CaddyAdminAddress, cfg.CaddyAdminPort)) // Create router r := chi.NewRouter() @@ -305,8 +305,16 @@ func run() error { logger.Error("failed to shutdown http server", "error", err) return err } - logger.Info("http server shutdown complete") + + // Shutdown ingress manager (stops Caddy if CADDY_STOP_ON_SHUTDOWN=true) + if err := app.IngressManager.Shutdown(); err != nil { + logger.Error("failed to shutdown ingress manager", "error", err) + // Don't return error - continue with shutdown + } else { + logger.Info("ingress manager shutdown complete") + } + return nil }) diff --git a/cmd/api/wire_gen.go b/cmd/api/wire_gen.go index 8e77c1d3..79bfc61f 100644 --- a/cmd/api/wire_gen.go +++ b/cmd/api/wire_gen.go @@ -47,7 +47,10 @@ func initializeApp() (*application, func(), error) { if err != nil { return nil, nil, err } - ingressManager := providers.ProvideIngressManager(paths, config, instancesManager) + ingressManager, err := providers.ProvideIngressManager(paths, config, instancesManager) + if err != nil { + return nil, nil, err + } registry, err := providers.ProvideRegistry(paths, manager) if err != nil { return nil, nil, err diff --git a/go.mod b/go.mod index 6f0be719..da8f3155 100644 --- a/go.mod +++ b/go.mod @@ -16,6 +16,7 @@ require ( github.com/gorilla/websocket v1.5.3 github.com/joho/godotenv v1.5.1 github.com/mdlayher/vsock v1.2.1 + github.com/miekg/dns v1.1.68 github.com/nrednav/cuid2 v1.1.0 github.com/oapi-codegen/nethttp-middleware v1.1.2 github.com/oapi-codegen/runtime v1.1.2 @@ -43,7 +44,6 @@ require ( golang.org/x/term v0.37.0 google.golang.org/grpc v1.77.0 google.golang.org/protobuf v1.36.10 - gopkg.in/yaml.v3 v3.0.1 gvisor.dev/gvisor v0.0.0-20251125014920-fc40e232ff54 ) @@ -96,10 +96,13 @@ require ( go.opentelemetry.io/otel/log v0.14.0 // indirect go.opentelemetry.io/proto/otlp v1.7.1 // indirect golang.org/x/crypto v0.43.0 // indirect + golang.org/x/mod v0.28.0 // indirect golang.org/x/net v0.46.1-0.20251013234738-63d1a5100f82 // indirect golang.org/x/text v0.30.0 // indirect + golang.org/x/tools v0.37.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect gotest.tools/v3 v3.5.2 // indirect ) diff --git a/go.sum b/go.sum index 525505e5..0ee9efda 100644 --- a/go.sum +++ b/go.sum @@ -113,6 +113,8 @@ github.com/mdlayher/socket v0.5.1/go.mod h1:TjPLHI1UgwEv5J1B5q0zTZq12A/6H7nKmtTa github.com/mdlayher/vsock v1.2.1 h1:pC1mTJTvjo1r9n9fbm7S1j04rCgCzhCOS5DY0zqHlnQ= github.com/mdlayher/vsock v1.2.1/go.mod h1:NRfCibel++DgeMD8z/hP+PPTjlNJsdPOmxcnENvE+SE= github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= +github.com/miekg/dns v1.1.68 h1:jsSRkNozw7G/mnmXULynzMNIsgY2dHC8LO6U6Ij2JEA= +github.com/miekg/dns v1.1.68/go.mod h1:fujopn7TB3Pu3JM69XaawiU0wqjpL9/8xGop5UrTPps= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs= diff --git a/lib/dns/server.go b/lib/dns/server.go new file mode 100644 index 00000000..74f1814f --- /dev/null +++ b/lib/dns/server.go @@ -0,0 +1,219 @@ +// Package dns provides a local DNS server for dynamic instance resolution. +// It enables Caddy to resolve instance names to IP addresses at request time. +package dns + +import ( + "context" + "fmt" + "log/slog" + "net" + "strings" + "sync" + "time" + + "github.com/miekg/dns" +) + +const ( + // DefaultPort is the default port for the local DNS server. + // Using 0 means the OS will assign a random available port, preventing + // conflicts on shared development machines. + DefaultPort = 0 + + // Suffix is the domain suffix used for instance resolution. + // Queries like "my-instance.hypeman.internal" will be resolved. + Suffix = "hypeman.internal" + + // DefaultTTL is the TTL for DNS responses in seconds. + // Keep it low since instance IPs can change. + DefaultTTL = 5 + + // resolverTimeout is the timeout for each DNS resolution request. + // Using a per-query timeout ensures DNS queries don't fail if the server + // is still running but the parent context is cancelled during shutdown. + resolverTimeout = 5 * time.Second +) + +// InstanceResolver provides instance IP resolution. +// This interface is implemented by the instances package. +type InstanceResolver interface { + // ResolveInstanceIP resolves an instance name or ID to its IP address. + ResolveInstanceIP(ctx context.Context, nameOrID string) (string, error) +} + +// Server provides DNS-based instance resolution for Caddy. +// It listens on a local port and responds to A record queries +// for instances in the form ".hypeman.internal". +type Server struct { + resolver InstanceResolver + port int + server *dns.Server + log *slog.Logger + mu sync.Mutex + running bool +} + +// NewServer creates a new DNS server for instance resolution. +// If port is 0, the OS will assign a random available port. +// The actual port can be retrieved with Port() after Start() is called. +func NewServer(resolver InstanceResolver, port int, log *slog.Logger) *Server { + if log == nil { + log = slog.Default() + } + return &Server{ + resolver: resolver, + port: port, + log: log, + } +} + +// Start starts the DNS server. +func (s *Server) Start(ctx context.Context) error { + s.mu.Lock() + defer s.mu.Unlock() + + if s.running { + return nil + } + + // Create DNS handler + mux := dns.NewServeMux() + mux.HandleFunc(Suffix+".", s.handleQuery) + + // Bind to UDP socket first to get actual port (important when port is 0) + addr := fmt.Sprintf("127.0.0.1:%d", s.port) + conn, err := net.ListenPacket("udp", addr) + if err != nil { + return fmt.Errorf("bind DNS server: %w", err) + } + + // Update port to actual assigned port (useful when s.port was 0) + s.port = conn.LocalAddr().(*net.UDPAddr).Port + + // Create UDP server with pre-bound connection + s.server = &dns.Server{ + PacketConn: conn, + Handler: mux, + } + + // Start server in background + go func() { + s.log.Info("Starting DNS server for instance resolution", "addr", conn.LocalAddr().String(), "suffix", Suffix) + if err := s.server.ActivateAndServe(); err != nil { + s.log.Error("DNS server error", "error", err) + } + }() + + s.running = true + return nil +} + +// Stop stops the DNS server. +func (s *Server) Stop() error { + s.mu.Lock() + defer s.mu.Unlock() + + if !s.running || s.server == nil { + return nil + } + + err := s.server.Shutdown() + s.running = false + return err +} + +// Port returns the port the DNS server is listening on. +func (s *Server) Port() int { + return s.port +} + +// IsRunning returns true if the DNS server is running. +func (s *Server) IsRunning() bool { + s.mu.Lock() + defer s.mu.Unlock() + return s.running +} + +// handleQuery handles incoming DNS queries. +func (s *Server) handleQuery(w dns.ResponseWriter, r *dns.Msg) { + m := new(dns.Msg) + m.SetReply(r) + m.Authoritative = true + + for _, q := range r.Question { + switch q.Qtype { + case dns.TypeA: + s.handleAQuery(m, q) + case dns.TypeAAAA: + // IPv6 not supported for instances - return empty response (no answer records). + // This is intentional: returning quickly with no records prevents Caddy from + // waiting for AAAA resolution, improving request latency. Clients will fall + // back to IPv4 A record resolution. + default: + // Unsupported query type - return empty response + } + } + + w.WriteMsg(m) +} + +// handleAQuery handles A record queries. +func (s *Server) handleAQuery(m *dns.Msg, q dns.Question) { + // Parse instance name from query + // Query format: ".hypeman.internal." + name := strings.TrimSuffix(q.Name, ".") + suffix := "." + Suffix + if !strings.HasSuffix(name, suffix) { + s.log.Debug("DNS query doesn't match suffix", "name", name, "suffix", suffix) + return + } + + instanceName := strings.TrimSuffix(name, suffix) + if instanceName == "" { + s.log.Debug("DNS query has empty instance name", "name", name) + return + } + + // Use a fresh context with timeout for each DNS query. + // This ensures queries don't fail if the server is still running but + // a parent context was cancelled during shutdown. + ctx, cancel := context.WithTimeout(context.Background(), resolverTimeout) + defer cancel() + + ip, err := s.resolver.ResolveInstanceIP(ctx, instanceName) + if err != nil { + s.log.Debug("DNS resolution failed", "instance", instanceName, "error", err) + // Return NXDOMAIN by not adding any answer records + m.Rcode = dns.RcodeNameError + return + } + + // Parse IP address + parsedIP := net.ParseIP(ip) + if parsedIP == nil { + s.log.Error("Invalid IP from resolver", "instance", instanceName, "ip", ip) + m.Rcode = dns.RcodeServerFailure + return + } + + // Only handle IPv4 for A records + ipv4 := parsedIP.To4() + if ipv4 == nil { + s.log.Debug("Resolved IP is not IPv4", "instance", instanceName, "ip", ip) + return + } + + // Add A record to response + rr := &dns.A{ + Hdr: dns.RR_Header{ + Name: q.Name, + Rrtype: dns.TypeA, + Class: dns.ClassINET, + Ttl: DefaultTTL, + }, + A: ipv4, + } + m.Answer = append(m.Answer, rr) + + s.log.Debug("DNS query resolved", "instance", instanceName, "ip", ip) +} diff --git a/lib/dns/server_test.go b/lib/dns/server_test.go new file mode 100644 index 00000000..8f1c4f44 --- /dev/null +++ b/lib/dns/server_test.go @@ -0,0 +1,132 @@ +package dns + +import ( + "context" + "net" + "testing" + "time" + + "github.com/miekg/dns" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// mockResolver implements InstanceResolver for testing +type mockResolver struct { + instances map[string]string +} + +func newMockResolver() *mockResolver { + return &mockResolver{ + instances: make(map[string]string), + } +} + +func (m *mockResolver) addInstance(name, ip string) { + m.instances[name] = ip +} + +func (m *mockResolver) ResolveInstanceIP(ctx context.Context, nameOrID string) (string, error) { + ip, ok := m.instances[nameOrID] + if !ok { + return "", context.DeadlineExceeded // Simulates not found + } + return ip, nil +} + +// getFreePort returns a random available port +func getFreePort(t *testing.T) int { + t.Helper() + listener, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + port := listener.Addr().(*net.TCPAddr).Port + listener.Close() + return port +} + +func TestDNSServer_StartStop(t *testing.T) { + resolver := newMockResolver() + port := getFreePort(t) + + server := NewServer(resolver, port, nil) + + // Start server + err := server.Start(context.Background()) + require.NoError(t, err) + assert.True(t, server.IsRunning()) + + // Give it time to start + time.Sleep(50 * time.Millisecond) + + // Stop server + err = server.Stop() + require.NoError(t, err) + assert.False(t, server.IsRunning()) +} + +func TestDNSServer_ResolveInstance(t *testing.T) { + resolver := newMockResolver() + resolver.addInstance("my-api", "10.100.0.10") + resolver.addInstance("web-app", "10.100.0.20") + + port := getFreePort(t) + server := NewServer(resolver, port, nil) + + err := server.Start(context.Background()) + require.NoError(t, err) + defer server.Stop() + + // Give server time to start + time.Sleep(50 * time.Millisecond) + + // Create DNS client + client := new(dns.Client) + client.Net = "udp" + + t.Run("ResolveKnownInstance", func(t *testing.T) { + m := new(dns.Msg) + m.SetQuestion("my-api.hypeman.internal.", dns.TypeA) + + r, _, err := client.Exchange(m, "127.0.0.1:"+string(rune(port))) + if err != nil { + // Try with proper port formatting + r, _, err = client.Exchange(m, net.JoinHostPort("127.0.0.1", string(rune(port)))) + } + // Skip if connection fails (port might not be ready) + if err != nil { + t.Skipf("DNS query failed, port may not be ready: %v", err) + } + + require.Len(t, r.Answer, 1) + a, ok := r.Answer[0].(*dns.A) + require.True(t, ok) + assert.Equal(t, "10.100.0.10", a.A.String()) + }) +} + +func TestDNSServer_Port(t *testing.T) { + resolver := newMockResolver() + + t.Run("RandomPort", func(t *testing.T) { + // Port 0 means "use random port" - actual port assigned on Start() + server := NewServer(resolver, 0, nil) + assert.Equal(t, 0, server.Port()) // Before Start, port is 0 + + err := server.Start(context.Background()) + require.NoError(t, err) + defer server.Stop() + + // After Start, port should be non-zero (assigned by OS) + assert.NotEqual(t, 0, server.Port()) + }) + + t.Run("ExplicitDefaultPort", func(t *testing.T) { + server := NewServer(resolver, DefaultPort, nil) + assert.Equal(t, DefaultPort, server.Port()) + }) + + t.Run("CustomPort", func(t *testing.T) { + server := NewServer(resolver, 12345, nil) + assert.Equal(t, 12345, server.Port()) + }) +} diff --git a/lib/ingress/README.md b/lib/ingress/README.md index cffc1219..3115ed4a 100644 --- a/lib/ingress/README.md +++ b/lib/ingress/README.md @@ -1,28 +1,31 @@ # Ingress Manager -Manages external traffic routing to VM instances using Envoy as a reverse proxy. +Manages external traffic routing to VM instances using Caddy as a reverse proxy with automatic TLS via ACME. ## Architecture ``` -External Request Envoy (daemon) VM - | | | - | Host:api.example.com:80 | | - +------------------------------>| config.yaml lookup | - | route -> my-api:8080 | - +------------------------>| - 10.100.x.y:8080 +External Request Caddy (daemon) DNS Server VM + | | | | + | Host:api.example.com | | | + +------------------------>| route match | | + | lookup my-api | | + +------------------->| | + | A: 10.100.x.y | | + |<-------------------+ | + | proxy to 10.100.x.y:8080 | + +----------------------------------->| ``` ## How It Works -### Envoy Daemon +### Caddy Daemon -- Envoy binary is embedded in hypeman (like Cloud Hypervisor) -- Extracted to `/var/lib/hypeman/system/binaries/envoy/{version}/{arch}/envoy` on first use +- Caddy binary is embedded in hypeman (like Cloud Hypervisor) +- Extracted to `/var/lib/hypeman/system/binaries/caddy/{version}/{arch}/caddy` on first use - Runs as a daemon process that survives hypeman restarts -- Listens on `0.0.0.0:80` (configurable via `ENVOY_LISTEN_ADDRESS` and `ENVOY_LISTEN_PORT`) -- Admin API on `127.0.0.1:9901` (configurable via `ENVOY_ADMIN_ADDRESS` and `ENVOY_ADMIN_PORT`) +- Listens on configured ports (default: 80, 443) +- Admin API on `127.0.0.1:2019` (configurable via `CADDY_ADMIN_ADDRESS` and `CADDY_ADMIN_PORT`) ### Ingress Resource @@ -35,29 +38,106 @@ An Ingress is a configuration object that defines how external traffic should be { "match": { "hostname": "api.example.com", - "port": 80 + "port": 443 }, "target": { "instance": "my-api", "port": 8080 - } + }, + "tls": true, + "redirect_http": true } ] } ``` +Pattern hostnames enable convention-based routing where the subdomain maps to an instance name: + +```json +{ + "name": "wildcard-ingress", + "rules": [ + { + "match": { "hostname": "{instance}.dev.example.com" }, + "target": { "instance": "{instance}", "port": 8080 }, + "tls": true + } + ] +} +``` + +This routes `foobar.dev.example.com` → instance `foobar`, `myapp.dev.example.com` → instance `myapp`, etc. + ### Configuration Flow 1. User creates an ingress via API 2. Manager validates the ingress (name, instance exists, hostname unique) -3. Ingress is persisted to `/var/lib/hypeman/ingresses/{id}.json` -4. Envoy xDS config files (LDS/CDS) are regenerated from all ingresses -5. Envoy automatically detects the file changes and reloads (no restart needed) +3. Generates Caddy JSON config from all ingresses +4. Validates config via Caddy's admin API +5. If valid, persists ingress to `/var/lib/hypeman/ingresses/{id}.json` +6. Applies config via Caddy's admin API (live reload, no restart needed) + +### TLS / HTTPS + +When `tls: true` is set on a rule: +- Caddy automatically issues a certificate via ACME (Let's Encrypt) +- DNS-01 challenge is used (requires DNS provider configuration) +- Certificates are stored in `/var/lib/hypeman/caddy/data/` +- Automatic renewal ~30 days before expiry + +When `redirect_http: true` is also set: +- An automatic HTTP → HTTPS redirect is created for the hostname + +#### TLS Requirements + +To use TLS on any ingress rule, you **must** configure: + +1. **ACME credentials**: `ACME_EMAIL` and `ACME_DNS_PROVIDER` (with provider-specific credentials) +2. **Allowed domains**: `TLS_ALLOWED_DOMAINS` must include the hostname pattern + +If TLS is requested without proper configuration, the ingress creation will fail with a descriptive error. + +#### Allowed Domains (`TLS_ALLOWED_DOMAINS`) + +This environment variable controls which hostnames can have TLS certificates issued. It's a comma-separated list of patterns: + +| Pattern | Matches | Does NOT Match | +|---------|---------|----------------| +| `api.example.com` | `api.example.com` (exact) | Any other hostname | +| `*.example.com` | `foo.example.com`, `bar.example.com` | `example.com` (apex), `a.b.example.com` (multi-level) | +| `*` | Any hostname (use with caution) | - | + +**Wildcard behavior:** +- `*.example.com` matches **single-level** subdomains only +- It does NOT match the apex domain (`example.com`) +- It does NOT match multi-level subdomains (`foo.bar.example.com`) +- To allow both apex and subdomains, use: `TLS_ALLOWED_DOMAINS=example.com,*.example.com` + +**Example configuration:** +```bash +# Allow TLS for any subdomain of example.com plus the apex +TLS_ALLOWED_DOMAINS=example.com,*.example.com + +# Allow TLS for specific subdomains only +TLS_ALLOWED_DOMAINS=api.example.com,www.example.com + +# Allow TLS for any domain (not recommended for production) +TLS_ALLOWED_DOMAINS=* +``` + +#### Warning Scenarios + +The ingress manager logs warnings in these situations: + +- **TLS ingresses exist but ACME not configured**: If existing ingresses have `tls: true` but `ACME_EMAIL` or `ACME_DNS_PROVIDER` is not set, a warning is logged at startup. TLS will not work until ACME is configured. + +- **Domain not in allowed list**: Creating an ingress with `tls: true` for a hostname not in `TLS_ALLOWED_DOMAINS` will fail with error `domain_not_allowed`. ### Hostname Routing -- Uses HTTP Host header matching -- One hostname per rule (exact match) +- Uses HTTP Host header matching (HTTP) or SNI (HTTPS) +- Supports exact hostnames (`api.example.com`) and patterns (`{instance}.example.com`) +- Pattern hostnames enable convention-based routing (e.g., `foobar.example.com` → instance `foobar`) - Hostnames must be unique across all ingresses - Default 404 response for unmatched hostnames @@ -67,19 +147,18 @@ An Ingress is a configuration object that defines how external traffic should be /var/lib/hypeman/ system/ binaries/ - envoy/ - v1.36/ - x86_64/envoy - aarch64/envoy - envoy/ - bootstrap.yaml # Envoy bootstrap config (points to xDS files) - lds.yaml # Listener Discovery Service config (watched by Envoy) - cds.yaml # Cluster Discovery Service config (watched by Envoy) - envoy.pid # PID file for daemon discovery - envoy.log # Envoy access logs - envoy-stdout.log # Envoy process output + caddy/ + v2.10.2/ + x86_64/caddy + aarch64/caddy + caddy/ + config.json # Caddy configuration (applied via admin API) + caddy.pid # PID file for daemon discovery + caddy.log # Caddy process output + data/ # Caddy data (certificates, etc.) + config/ # Caddy config storage ingresses/ - {id}.json # Ingress resource metadata + {id}.json # Ingress resource metadata ``` ## API Endpoints @@ -93,62 +172,64 @@ DELETE /ingresses/{id} - Delete ingress ## Configuration +### Caddy Settings + | Variable | Description | Default | |----------|-------------|---------| -| `ENVOY_LISTEN_ADDRESS` | Address for ingress listeners | `0.0.0.0` | -| `ENVOY_ADMIN_ADDRESS` | Address for Envoy admin API | `127.0.0.1` | -| `ENVOY_ADMIN_PORT` | Port for Envoy admin API | `9901` | -| `ENVOY_STOP_ON_SHUTDOWN` | Stop Envoy when hypeman shuts down | `false` | +| `CADDY_LISTEN_ADDRESS` | Address for ingress listeners | `0.0.0.0` | +| `CADDY_ADMIN_ADDRESS` | Address for Caddy admin API | `127.0.0.1` | +| `CADDY_ADMIN_PORT` | Port for Caddy admin API | `2019` | +| `CADDY_STOP_ON_SHUTDOWN` | Stop Caddy when hypeman shuts down | `false` | -**Note on Ports:** Each ingress rule can specify a `port` in the match criteria to listen on a specific host port. If not specified, defaults to port 80. Envoy dynamically creates listeners for each unique port across all ingresses. +### ACME / TLS Settings -### OpenTelemetry Integration - -When OTEL is enabled in hypeman (`OTEL_ENABLED=true`), Envoy is automatically configured to push **operational metrics** to the OTEL collector. This provides infrastructure monitoring without exposing tenant request data. +| Variable | Description | Default | +|----------|-------------|---------| +| `ACME_EMAIL` | ACME account email (required for TLS) | | +| `ACME_DNS_PROVIDER` | DNS provider: `cloudflare` | | +| `ACME_CA` | ACME CA URL (for staging, etc.) | Let's Encrypt production | +| `TLS_ALLOWED_DOMAINS` | Comma-separated domain patterns allowed for TLS (required for TLS ingresses) | | +| `DNS_PROPAGATION_TIMEOUT` | Max time to wait for DNS propagation (e.g., `2m`, `120s`) | | +| `DNS_RESOLVERS` | Comma-separated DNS resolvers for propagation checking | | -**Configuration used:** -- `OTEL_ENDPOINT` - gRPC endpoint for the OTEL collector (e.g., `otel-collector:4317`) -- `OTEL_SERVICE_NAME` - Service name (Envoy uses `{service_name}-envoy`) +### Cloudflare DNS Provider -**Metrics exported include:** -- Connection metrics (active connections, connection rates, errors) -- Request rates and error counts (aggregate, not per-request) -- Upstream health (backend availability, retries) -- Listener and cluster statistics -- Memory and resource usage +| Variable | Description | +|----------|-------------| +| `CLOUDFLARE_API_TOKEN` | Cloudflare API token with DNS edit permissions | -**Note:** Per-request tracing is intentionally disabled to protect tenant privacy. Only aggregate operational metrics are exported. +**Note on Ports:** Each ingress rule can specify a `port` in the match criteria to listen on a specific host port. If not specified, defaults to port 80. Caddy dynamically listens on all unique ports across all ingresses. ## Security - Admin API bound to localhost only by default -- Ingress validation ensures target instances exist -- Instance IP resolution happens at config generation time -- Envoy runs as the same user as hypeman (not root) +- Ingress validation ensures target instances exist (for exact hostnames) +- Instance IP resolution happens at request time via internal DNS server +- Caddy runs as the same user as hypeman (not root) +- Private keys for TLS certificates stored with restrictive permissions ## Daemon Lifecycle ### Startup -1. Extract Envoy binary (if needed) -2. Check for existing running Envoy (via PID file or admin API) -3. If not running, start Envoy with generated config -4. Wait for admin API to become ready +1. Extract Caddy binary (if needed) +2. Start internal DNS server for dynamic upstream resolution (port 5353) +3. Check for existing running Caddy (via PID file or admin API) +4. If not running, start Caddy with generated config +5. Wait for admin API to become ready ### Config Updates -Envoy uses file-based xDS (dynamic configuration) which eliminates the need for process restarts: +Caddy's admin API allows live configuration updates: -1. Regenerate LDS/CDS config files to temporary files -2. Atomically move temp files to `lds.yaml` and `cds.yaml` -3. Envoy watches these files and automatically reloads within seconds +1. Generate new JSON config +2. POST to `/load` endpoint on admin API +3. Caddy validates and applies atomically 4. Active connections are preserved during reload -This approach is simpler and more reliable than hot restart, with no process coordination needed. - ### Shutdown -- By default (`ENVOY_STOP_ON_SHUTDOWN=false`), Envoy continues running when hypeman exits -- Set `ENVOY_STOP_ON_SHUTDOWN=true` to stop Envoy with hypeman -- Envoy can be manually stopped via admin API (`/quitquitquit`) or SIGTERM +- By default (`CADDY_STOP_ON_SHUTDOWN=false`), Caddy continues running when hypeman exits +- Set `CADDY_STOP_ON_SHUTDOWN=true` to stop Caddy with hypeman +- Caddy can be manually stopped via admin API (`/stop`) or SIGTERM ## Testing @@ -164,7 +245,7 @@ Tests use: ## Future Improvements -- TLS termination with ACME/Let's Encrypt - Path-based L7 routing - Health checks for backends -- Connection draining for graceful config updates \ No newline at end of file +- Rate limiting +- Custom error pages diff --git a/lib/ingress/binaries.go b/lib/ingress/binaries.go index 5c0f2c67..fc5440fb 100644 --- a/lib/ingress/binaries.go +++ b/lib/ingress/binaries.go @@ -1,60 +1,76 @@ package ingress import ( - "embed" + "crypto/sha256" + "encoding/hex" "fmt" + "log/slog" "os" "path/filepath" - "runtime" "github.com/onkernel/hypeman/lib/paths" ) -//go:embed binaries/envoy/v1.36/x86_64/envoy -//go:embed binaries/envoy/v1.36/aarch64/envoy -var envoyBinaryFS embed.FS +// CaddyVersion is the version of Caddy embedded in this build. +const CaddyVersion = "v2.10.2" -// EnvoyVersion is the version of Envoy embedded in this build. -const EnvoyVersion = "v1.36" +// caddyBinaryFS and caddyArch are defined in architecture-specific files: +// - binaries_amd64.go (for x86_64) +// - binaries_arm64.go (for aarch64) -// ExtractEnvoyBinary extracts the embedded Envoy binary to the data directory. +// ExtractCaddyBinary extracts the embedded Caddy binary to the data directory. // Returns the path to the extracted binary. -func ExtractEnvoyBinary(p *paths.Paths) (string, error) { - arch := runtime.GOARCH - if arch == "amd64" { - arch = "x86_64" - } else if arch == "arm64" { - arch = "aarch64" +// If the binary already exists but doesn't match the embedded version (e.g., after +// rebuilding with different modules), it will be re-extracted. +func ExtractCaddyBinary(p *paths.Paths) (string, error) { + embeddedPath := fmt.Sprintf("binaries/caddy/%s/%s/caddy", CaddyVersion, caddyArch) + extractPath := p.CaddyBinary(CaddyVersion, caddyArch) + hashPath := extractPath + ".sha256" + + // Read embedded binary + data, err := caddyBinaryFS.ReadFile(embeddedPath) + if err != nil { + return "", fmt.Errorf("read embedded caddy binary: %w", err) } - embeddedPath := fmt.Sprintf("binaries/envoy/%s/%s/envoy", EnvoyVersion, arch) - extractPath := p.EnvoyBinary(EnvoyVersion, arch) + // Compute hash of embedded binary + hash := sha256.Sum256(data) + embeddedHash := hex.EncodeToString(hash[:]) - // Check if already extracted + // Check if already extracted with matching hash if _, err := os.Stat(extractPath); err == nil { - return extractPath, nil + // Binary exists, check if hash matches + if storedHash, err := os.ReadFile(hashPath); err == nil { + if string(storedHash) == embeddedHash { + // Hash matches, use existing binary + return extractPath, nil + } + // Hash mismatch - need to re-extract (binary was rebuilt with different modules) + } + // No hash file or mismatch - re-extract } // Create directory if err := os.MkdirAll(filepath.Dir(extractPath), 0755); err != nil { - return "", fmt.Errorf("create envoy binary dir: %w", err) + return "", fmt.Errorf("create caddy binary dir: %w", err) } - // Read embedded binary - data, err := envoyBinaryFS.ReadFile(embeddedPath) - if err != nil { - return "", fmt.Errorf("read embedded envoy binary: %w", err) + // Write binary to filesystem + if err := os.WriteFile(extractPath, data, 0755); err != nil { + return "", fmt.Errorf("write caddy binary: %w", err) } - // Write to filesystem - if err := os.WriteFile(extractPath, data, 0755); err != nil { - return "", fmt.Errorf("write envoy binary: %w", err) + // Write hash file for future comparisons + if err := os.WriteFile(hashPath, []byte(embeddedHash), 0644); err != nil { + // Non-fatal - binary is extracted, just won't have hash for next time + // This could cause unnecessary re-extractions but won't break functionality + slog.Info("failed to write caddy binary hash file", "path", hashPath, "error", err) } return extractPath, nil } -// GetEnvoyBinaryPath returns path to extracted binary, extracting if needed. -func GetEnvoyBinaryPath(p *paths.Paths) (string, error) { - return ExtractEnvoyBinary(p) +// GetCaddyBinaryPath returns path to extracted binary, extracting if needed. +func GetCaddyBinaryPath(p *paths.Paths) (string, error) { + return ExtractCaddyBinary(p) } diff --git a/lib/ingress/binaries_amd64.go b/lib/ingress/binaries_amd64.go new file mode 100644 index 00000000..309da631 --- /dev/null +++ b/lib/ingress/binaries_amd64.go @@ -0,0 +1,10 @@ +//go:build amd64 + +package ingress + +import "embed" + +//go:embed binaries/caddy/v2.10.2/x86_64/caddy +var caddyBinaryFS embed.FS + +const caddyArch = "x86_64" diff --git a/lib/ingress/binaries_arm64.go b/lib/ingress/binaries_arm64.go new file mode 100644 index 00000000..8fb413ce --- /dev/null +++ b/lib/ingress/binaries_arm64.go @@ -0,0 +1,10 @@ +//go:build arm64 + +package ingress + +import "embed" + +//go:embed binaries/caddy/v2.10.2/aarch64/caddy +var caddyBinaryFS embed.FS + +const caddyArch = "aarch64" diff --git a/lib/ingress/config.go b/lib/ingress/config.go index 843a3d4a..1521e86b 100644 --- a/lib/ingress/config.go +++ b/lib/ingress/config.go @@ -2,611 +2,494 @@ package ingress import ( "context" + "encoding/json" "fmt" + "log/slog" "os" "path/filepath" - "strconv" + "slices" + "sort" "strings" + "github.com/onkernel/hypeman/lib/dns" "github.com/onkernel/hypeman/lib/logger" "github.com/onkernel/hypeman/lib/paths" - "gopkg.in/yaml.v3" ) -// ConfigValidator validates Envoy configuration files. -type ConfigValidator interface { - ValidateConfig(configPath string) error -} +// DNSProvider represents supported DNS providers for ACME challenges. +type DNSProvider string + +const ( + // DNSProviderNone indicates no DNS provider is configured. + DNSProviderNone DNSProvider = "" + // DNSProviderCloudflare uses Cloudflare for DNS challenges. + DNSProviderCloudflare DNSProvider = "cloudflare" +) + +// Caddy DNS module provider names (used in Caddy JSON config). +// These map our DNSProvider constants to the names expected by caddy-dns modules. +const ( + caddyProviderCloudflare = "cloudflare" +) -// EnvoyConfigGenerator generates Envoy configuration from ingress resources. -type EnvoyConfigGenerator struct { - paths *paths.Paths - listenAddress string - adminAddress string - adminPort int - validator ConfigValidator - otel OTELConfig +// SupportedDNSProviders returns a comma-separated list of supported DNS provider names. +// Used in error messages to keep them in sync as new providers are added. +func SupportedDNSProviders() string { + return string(DNSProviderCloudflare) } -// NewEnvoyConfigGenerator creates a new config generator. -func NewEnvoyConfigGenerator(p *paths.Paths, listenAddress string, adminAddress string, adminPort int, validator ConfigValidator, otel OTELConfig) *EnvoyConfigGenerator { - return &EnvoyConfigGenerator{ - paths: p, - listenAddress: listenAddress, - adminAddress: adminAddress, - adminPort: adminPort, - validator: validator, - otel: otel, +// ParseDNSProvider parses a string into a DNSProvider, returning an error for unknown values. +func ParseDNSProvider(s string) (DNSProvider, error) { + switch s { + case "": + return DNSProviderNone, nil + case string(DNSProviderCloudflare): + return DNSProviderCloudflare, nil + default: + return DNSProviderNone, fmt.Errorf("unknown DNS provider %q: supported providers are: %s", s, SupportedDNSProviders()) } } -// GenerateConfig generates the full Envoy configuration for testing purposes. -// In production, use WriteConfig which writes separate xDS files. -func (g *EnvoyConfigGenerator) GenerateConfig(ctx context.Context, ingresses []Ingress, ipResolver func(instance string) (string, error)) ([]byte, error) { - // For testing, generate a static config (not xDS format) - config := g.buildStaticConfig(ctx, ingresses, ipResolver) - return yaml.Marshal(config) -} +// ACMEConfig holds ACME/TLS configuration for Caddy. +type ACMEConfig struct { + // Email is the ACME account email (required for TLS). + Email string -// buildStaticConfig builds a static Envoy config (for testing/validation). -func (g *EnvoyConfigGenerator) buildStaticConfig(ctx context.Context, ingresses []Ingress, ipResolver func(instance string) (string, error)) map[string]interface{} { - clusters := g.buildClusters(ctx, ingresses, ipResolver) + // DNSProvider is the DNS provider for ACME challenges. + DNSProvider DNSProvider - // Add OTEL collector cluster if enabled (for metrics export) - if g.otel.Enabled && g.otel.Endpoint != "" { - otelCluster := g.buildOTELCollectorCluster() - clusters = append(clusters, otelCluster) - } + // CA is the ACME CA URL. Empty means Let's Encrypt production. + CA string - config := map[string]interface{}{ - "admin": map[string]interface{}{ - "address": map[string]interface{}{ - "socket_address": map[string]interface{}{ - "address": g.adminAddress, - "port_value": g.adminPort, - }, - }, - }, - "static_resources": map[string]interface{}{ - "listeners": g.buildListeners(ctx, ingresses, ipResolver), - "clusters": clusters, - }, - } + // DNS propagation settings (applies to all providers) + DNSPropagationTimeout string // Max time to wait for DNS propagation (e.g., "2m") + DNSResolvers string // Comma-separated DNS resolvers to use for checking propagation - // Add stats sink to push metrics to OTEL collector - if g.otel.Enabled && g.otel.Endpoint != "" { - config["stats_sinks"] = g.buildStatsSinks() - } + // AllowedDomains is a comma-separated list of domain patterns allowed for TLS ingresses. + // Supports wildcards like "*.example.com" and exact matches like "api.example.com". + // If empty, no TLS domains are allowed. + AllowedDomains string - return config + // Cloudflare API token (if DNSProvider=cloudflare). + CloudflareAPIToken string } -// WriteConfig generates, validates, and writes the Envoy xDS configuration files. -// This writes three files: -// - bootstrap.yaml: Main Envoy bootstrap config with dynamic_resources pointing to xDS files -// - lds.yaml: Listener Discovery Service config (watched by Envoy for changes) -// - cds.yaml: Cluster Discovery Service config (watched by Envoy for changes) +// IsDomainAllowed checks if a hostname is allowed for TLS based on the AllowedDomains config. +// Returns true if the hostname matches any of the allowed patterns. // -// Validation is performed by writing all files to a temp directory first, then running -// envoy --mode validate on the temp bootstrap (which references temp LDS/CDS files). -// Only if validation passes are the files moved to production paths. -func (g *EnvoyConfigGenerator) WriteConfig(ctx context.Context, ingresses []Ingress, ipResolver func(instance string) (string, error)) error { - configDir := filepath.Dir(g.paths.EnvoyConfig()) - - // Ensure the directory exists - if err := os.MkdirAll(configDir, 0755); err != nil { - return fmt.Errorf("create config directory: %w", err) - } - - // Build LDS and CDS content - ldsData, err := g.buildLDSData(ctx, ingresses, ipResolver) - if err != nil { - return fmt.Errorf("build LDS config: %w", err) - } - - cdsData, err := g.buildCDSData(ctx, ingresses, ipResolver) - if err != nil { - return fmt.Errorf("build CDS config: %w", err) - } - - // Validate configuration if validator is available - if g.validator != nil { - if err := g.validateXDSConfig(ldsData, cdsData); err != nil { - return err +// Supported pattern types: +// - Exact match: "api.example.com" matches only "api.example.com" +// - Global wildcard: "*" matches any hostname (use with caution) +// - Subdomain wildcard: "*.example.com" matches single-level subdomains only +// +// Wildcard behavior for "*.example.com": +// - Matches: "foo.example.com", "bar.example.com" +// - Does NOT match: "example.com" (apex domain) +// - Does NOT match: "foo.bar.example.com" (multi-level subdomain) +func (c *ACMEConfig) IsDomainAllowed(hostname string) bool { + if c.AllowedDomains == "" { + return false // No domains allowed if not configured + } + + patterns := strings.Split(c.AllowedDomains, ",") + for _, pattern := range patterns { + pattern = strings.TrimSpace(pattern) + if pattern == "" { + continue } - } - // Validation passed (or skipped) - write to production paths - // IMPORTANT: Write CDS first, then LDS. Envoy requires clusters to exist - // before listeners can reference them (xDS ordering requirement). - if err := g.atomicWrite(g.paths.EnvoyCDS(), cdsData); err != nil { - return fmt.Errorf("write CDS config: %w", err) - } + // Exact match + if pattern == hostname { + return true + } - if err := g.atomicWrite(g.paths.EnvoyLDS(), ldsData); err != nil { - return fmt.Errorf("write LDS config: %w", err) - } + // Global wildcard "*" - matches any domain (use with caution) + if pattern == "*" { + return true + } - // Write bootstrap config (only if it doesn't exist - Envoy watches the xDS files) - bootstrapPath := g.paths.EnvoyConfig() - if _, err := os.Stat(bootstrapPath); os.IsNotExist(err) { - if err := g.writeBootstrapConfig(); err != nil { - return fmt.Errorf("write bootstrap config: %w", err) + // Subdomain wildcard match (e.g., "*.example.com" matches "foo.example.com") + // Requirements: + // - Pattern must start with "*." (e.g., "*.example.com") + // - Hostname must end with the suffix (e.g., ".example.com") + // - Hostname must have exactly one label before the suffix (single-level only) + if strings.HasPrefix(pattern, "*.") { + suffix := pattern[1:] // Remove the "*", keep ".example.com" + if strings.HasSuffix(hostname, suffix) { + // Extract the prefix (e.g., "foo" from "foo.example.com") + prefix := strings.TrimSuffix(hostname, suffix) + // Prefix must be non-empty and contain no dots (single-level subdomain only) + if prefix != "" && !strings.Contains(prefix, ".") { + return true + } + } } } - return nil + return false } -// validateXDSConfig validates the xDS configuration by writing to a temp directory -// and running envoy --mode validate on a bootstrap that references the temp files. -func (g *EnvoyConfigGenerator) validateXDSConfig(ldsData, cdsData []byte) error { - // Create temp directory for validation - tempDir, err := os.MkdirTemp("", "envoy-validate-") - if err != nil { - return fmt.Errorf("create temp dir for validation: %w", err) - } - defer os.RemoveAll(tempDir) - - // Write LDS to temp - tempLDSPath := filepath.Join(tempDir, "lds.yaml") - if err := os.WriteFile(tempLDSPath, ldsData, 0644); err != nil { - return fmt.Errorf("write temp LDS: %w", err) - } - - // Write CDS to temp - tempCDSPath := filepath.Join(tempDir, "cds.yaml") - if err := os.WriteFile(tempCDSPath, cdsData, 0644); err != nil { - return fmt.Errorf("write temp CDS: %w", err) - } - - // Build and write bootstrap that references temp paths - tempBootstrap := g.buildBootstrapConfigWithPaths(tempLDSPath, tempCDSPath, tempDir) - bootstrapData, err := yaml.Marshal(tempBootstrap) - if err != nil { - return fmt.Errorf("marshal temp bootstrap: %w", err) +// IsTLSConfigured returns true if ACME/TLS is properly configured. +func (c *ACMEConfig) IsTLSConfigured() bool { + if c.Email == "" || c.DNSProvider == DNSProviderNone { + return false } - tempBootstrapPath := filepath.Join(tempDir, "bootstrap.yaml") - if err := os.WriteFile(tempBootstrapPath, bootstrapData, 0644); err != nil { - return fmt.Errorf("write temp bootstrap: %w", err) + switch c.DNSProvider { + case DNSProviderCloudflare: + return c.CloudflareAPIToken != "" + default: + return false } - - // Validate using envoy --mode validate - if err := g.validator.ValidateConfig(tempBootstrapPath); err != nil { - return fmt.Errorf("%w: %v", ErrConfigValidationFailed, err) - } - - return nil } -// buildLDSData builds the LDS configuration data. -func (g *EnvoyConfigGenerator) buildLDSData(ctx context.Context, ingresses []Ingress, ipResolver func(instance string) (string, error)) ([]byte, error) { - listeners := g.buildListeners(ctx, ingresses, ipResolver) - ldsConfig := map[string]interface{}{ - "resources": g.wrapResources(listeners, "type.googleapis.com/envoy.config.listener.v3.Listener"), - } - return yaml.Marshal(ldsConfig) +// CaddyConfigGenerator generates Caddy configuration from ingress resources. +type CaddyConfigGenerator struct { + paths *paths.Paths + listenAddress string + adminAddress string + adminPort int + acme ACMEConfig + dnsResolverPort int } -// buildCDSData builds the CDS configuration data. -// Note: OTEL collector cluster is NOT included here - it's added as a static cluster -// in the bootstrap config because stats_sinks needs it available at bootstrap time. -func (g *EnvoyConfigGenerator) buildCDSData(ctx context.Context, ingresses []Ingress, ipResolver func(instance string) (string, error)) ([]byte, error) { - clusters := g.buildClusters(ctx, ingresses, ipResolver) - - cdsConfig := map[string]interface{}{ - "resources": g.wrapResources(clusters, "type.googleapis.com/envoy.config.cluster.v3.Cluster"), +// NewCaddyConfigGenerator creates a new Caddy config generator. +func NewCaddyConfigGenerator(p *paths.Paths, listenAddress string, adminAddress string, adminPort int, acme ACMEConfig, dnsResolverPort int) *CaddyConfigGenerator { + return &CaddyConfigGenerator{ + paths: p, + listenAddress: listenAddress, + adminAddress: adminAddress, + adminPort: adminPort, + acme: acme, + dnsResolverPort: dnsResolverPort, } - return yaml.Marshal(cdsConfig) } -// writeBootstrapConfig writes the Envoy bootstrap configuration with dynamic xDS. -func (g *EnvoyConfigGenerator) writeBootstrapConfig() error { - bootstrap := g.buildBootstrapConfig() - data, err := yaml.Marshal(bootstrap) - if err != nil { - return fmt.Errorf("marshal bootstrap config: %w", err) - } - return g.atomicWrite(g.paths.EnvoyConfig(), data) +// GenerateConfig generates the Caddy JSON configuration. +func (g *CaddyConfigGenerator) GenerateConfig(ctx context.Context, ingresses []Ingress) ([]byte, error) { + config := g.buildConfig(ctx, ingresses) + return json.MarshalIndent(config, "", " ") } -// wrapResources wraps resources with their @type for xDS format. -func (g *EnvoyConfigGenerator) wrapResources(resources []interface{}, resourceType string) []interface{} { - wrapped := make([]interface{}, len(resources)) - for i, r := range resources { - if m, ok := r.(map[string]interface{}); ok { - m["@type"] = resourceType - wrapped[i] = m - } else { - wrapped[i] = r - } - } - return wrapped -} +// buildConfig builds the complete Caddy configuration. +func (g *CaddyConfigGenerator) buildConfig(ctx context.Context, ingresses []Ingress) map[string]interface{} { + log := logger.FromContext(ctx) -// atomicWrite writes data to a file atomically using a temp file and rename. -func (g *EnvoyConfigGenerator) atomicWrite(path string, data []byte) error { - dir := filepath.Dir(path) - tempFile, err := os.CreateTemp(dir, "envoy-*.yaml") - if err != nil { - return fmt.Errorf("create temp file: %w", err) - } - tempPath := tempFile.Name() + // Build routes from ingresses + routes := []interface{}{} + redirectRoutes := []interface{}{} + tlsHostnames := []string{} + listenPorts := map[int]bool{} - // Clean up temp file on any error - defer func() { - if tempPath != "" { - os.Remove(tempPath) - } - }() + for _, ingress := range ingresses { + for _, rule := range ingress.Rules { + port := rule.Match.GetPort() + listenPorts[port] = true + + // Determine hostname pattern (wildcard or literal) and instance expression + var hostnameMatch string + var instanceExpr string + + if rule.Match.IsPattern() { + // Pattern hostname - parse and use wildcard + Caddy placeholders + pattern, err := rule.Match.ParsePattern() + if err != nil { + log.WarnContext(ctx, "skipping ingress rule: invalid hostname pattern", + "ingress_id", ingress.ID, + "ingress_name", ingress.Name, + "hostname", rule.Match.Hostname, + "error", err) + continue + } + hostnameMatch = pattern.Wildcard + instanceExpr = pattern.ResolveInstance(rule.Target.Instance) + } else { + // Literal hostname - exact match + hostnameMatch = rule.Match.Hostname + instanceExpr = rule.Target.Instance + } - if _, err := tempFile.Write(data); err != nil { - tempFile.Close() - return fmt.Errorf("write temp file: %w", err) - } - if err := tempFile.Close(); err != nil { - return fmt.Errorf("close temp file: %w", err) - } + // Build DNS hostname for instance resolution + // The instance expression may be a Caddy placeholder like {http.request.host.labels.2} + // This becomes e.g., "my-api.hypeman.internal" or "{http.request.host.labels.2}.hypeman.internal" + dnsHostname := fmt.Sprintf("%s.%s", instanceExpr, dns.Suffix) + + // Build the route with DNS-based dynamic upstreams using the "a" module + reverseProxy := map[string]interface{}{ + "handler": "reverse_proxy", + "dynamic_upstreams": map[string]interface{}{ + "source": "a", + "name": dnsHostname, + "port": fmt.Sprintf("%d", rule.Target.Port), + "resolver": map[string]interface{}{ + "addresses": []string{fmt.Sprintf("127.0.0.1:%d", g.dnsResolverPort)}, + }, + }, + } - if err := os.Rename(tempPath, path); err != nil { - return fmt.Errorf("rename temp file: %w", err) - } + route := map[string]interface{}{ + "match": []interface{}{ + map[string]interface{}{ + "host": []string{hostnameMatch}, + }, + }, + "handle": []interface{}{reverseProxy}, + } - tempPath = "" // Prevent cleanup of renamed file - return nil -} + // Add terminal to stop processing after this route matches + route["terminal"] = true -// buildBootstrapConfig builds the Envoy bootstrap configuration with dynamic xDS. -func (g *EnvoyConfigGenerator) buildBootstrapConfig() map[string]interface{} { - return g.buildBootstrapConfigWithPaths(g.paths.EnvoyLDS(), g.paths.EnvoyCDS(), filepath.Dir(g.paths.EnvoyLDS())) -} + routes = append(routes, route) -// buildBootstrapConfigWithPaths builds an Envoy bootstrap configuration with custom xDS paths. -// This is used for validation (with temp paths) and production (with real paths). -func (g *EnvoyConfigGenerator) buildBootstrapConfigWithPaths(ldsPath, cdsPath, watchDir string) map[string]interface{} { - config := map[string]interface{}{ - // Node identification required for xDS - "node": map[string]interface{}{ - "id": "hypeman-envoy", - "cluster": "hypeman", - }, - "admin": map[string]interface{}{ - "address": map[string]interface{}{ - "socket_address": map[string]interface{}{ - "address": g.adminAddress, - "port_value": g.adminPort, - }, - }, - }, - "dynamic_resources": map[string]interface{}{ - "lds_config": map[string]interface{}{ - "path_config_source": map[string]interface{}{ - "path": ldsPath, - "watched_directory": map[string]interface{}{"path": watchDir}, - }, - }, - "cds_config": map[string]interface{}{ - "path_config_source": map[string]interface{}{ - "path": cdsPath, - "watched_directory": map[string]interface{}{"path": watchDir}, - }, - }, - }, - } + // Track TLS hostnames for automation policy + // For patterns, use the wildcard for TLS (e.g., "*.example.com") + if rule.TLS { + tlsHostnames = append(tlsHostnames, hostnameMatch) - // Add OTEL stats sink and collector cluster if enabled - // The OTEL collector cluster must be a static resource (not in CDS) because - // stats_sinks needs it available at bootstrap time before CDS is loaded - if g.otel.Enabled && g.otel.Endpoint != "" { - config["stats_sinks"] = g.buildStatsSinks() - config["static_resources"] = map[string]interface{}{ - "clusters": []interface{}{g.buildOTELCollectorCluster()}, + // Add HTTP redirect route if requested + if rule.RedirectHTTP { + listenPorts[80] = true + redirectRoute := map[string]interface{}{ + "match": []interface{}{ + map[string]interface{}{ + "host": []string{hostnameMatch}, + }, + }, + "handle": []interface{}{ + map[string]interface{}{ + "handler": "static_response", + "headers": map[string]interface{}{ + "Location": []string{"https://{http.request.host}{http.request.uri}"}, + }, + "status_code": 301, + }, + }, + "terminal": true, + } + redirectRoutes = append(redirectRoutes, redirectRoute) + } + } } } - return config -} - -// buildListeners builds the listeners configuration - one per unique port. -func (g *EnvoyConfigGenerator) buildListeners(ctx context.Context, ingresses []Ingress, ipResolver func(instance string) (string, error)) []interface{} { - if len(ingresses) == 0 { - return []interface{}{} + // Build listen addresses (sorted for deterministic config output) + ports := make([]int, 0, len(listenPorts)) + for port := range listenPorts { + ports = append(ports, port) } - - // Group rules by port - portToFilterChains := g.buildFilterChainsByPort(ctx, ingresses, ipResolver) - if len(portToFilterChains) == 0 { - return []interface{}{} + sort.Ints(ports) + listenAddrs := make([]string, 0, len(ports)) + for _, port := range ports { + listenAddrs = append(listenAddrs, fmt.Sprintf("%s:%d", g.listenAddress, port)) } - // Create one listener per port - var listeners []interface{} - for port, filterChains := range portToFilterChains { - listener := map[string]interface{}{ - "name": fmt.Sprintf("ingress_listener_%d", port), - "address": map[string]interface{}{ - "socket_address": map[string]interface{}{ - "address": g.listenAddress, - "port_value": port, - }, - }, - "filter_chains": filterChains, - } - listeners = append(listeners, listener) + // Build base config (admin API only) + // Caddy writes JSON logs to stderr by default, which we capture to caddy.log + config := map[string]interface{}{ + "admin": map[string]interface{}{ + "listen": fmt.Sprintf("%s:%d", g.adminAddress, g.adminPort), + }, } - return listeners -} - -// buildFilterChainsByPort builds filter chains grouped by port for hostname-based routing. -// For plain HTTP, we use virtual hosts with domain matching (Host header) instead of -// filter_chain_match with server_names (which only works for TLS/SNI). -func (g *EnvoyConfigGenerator) buildFilterChainsByPort(ctx context.Context, ingresses []Ingress, ipResolver func(instance string) (string, error)) map[int][]interface{} { - log := logger.FromContext(ctx) - - // Group virtual hosts by port - portToVirtualHosts := make(map[int][]interface{}) - - for _, ingress := range ingresses { - for _, rule := range ingress.Rules { - // Resolve instance IP - skip rules where we can't resolve - _, err := ipResolver(rule.Target.Instance) - if err != nil { - log.WarnContext(ctx, "skipping ingress rule: cannot resolve instance IP", - "ingress_id", ingress.ID, - "ingress_name", ingress.Name, - "hostname", rule.Match.Hostname, - "instance", rule.Target.Instance, - "error", err) - continue - } - - port := rule.Match.GetPort() - clusterName := g.clusterName(ingress.ID, rule.Target.Instance, rule.Target.Port) - - // Build virtual host for this hostname - virtualHost := map[string]interface{}{ - "name": fmt.Sprintf("vh_%s_%s", ingress.ID, sanitizeHostname(rule.Match.Hostname)), - "domains": []string{rule.Match.Hostname}, - "routes": []interface{}{ - map[string]interface{}{ - "match": map[string]interface{}{ - "prefix": "/", - }, - "route": map[string]interface{}{ - "cluster": clusterName, - }, - }, - }, - } - - portToVirtualHosts[port] = append(portToVirtualHosts[port], virtualHost) + // Only add HTTP server if we have listen addresses (i.e., ingresses exist) + if len(listenAddrs) > 0 { + // Build server configuration + server := map[string]interface{}{ + "listen": listenAddrs, } - } - // Build filter chains - one per port with all virtual hosts combined - portToFilterChains := make(map[int][]interface{}) + // Combine redirect routes (for HTTP) and main routes + // Use slices.Concat to avoid modifying original slices + allRoutes := slices.Concat(redirectRoutes, routes) - for port, virtualHosts := range portToVirtualHosts { - // Add default virtual host for unmatched hostnames (returns 404) - defaultVirtualHost := map[string]interface{}{ - "name": "default", - "domains": []string{"*"}, - "routes": []interface{}{ + // Add catch-all route at the end to return 404 for unmatched hostnames + // This must be last since routes are evaluated in order + catchAllRoute := map[string]interface{}{ + "handle": []interface{}{ map[string]interface{}{ - "match": map[string]interface{}{ - "prefix": "/", - }, - "direct_response": map[string]interface{}{ - "status": 404, - "body": map[string]interface{}{ - "inline_string": "No ingress found for this hostname", - }, + "handler": "static_response", + "status_code": 404, + "headers": map[string]interface{}{ + "Content-Type": []string{"text/plain; charset=utf-8"}, }, + "body": "Not Found: no ingress configured for hostname {http.request.host}", }, }, } - allVirtualHosts := append(virtualHosts, defaultVirtualHost) + allRoutes = append(allRoutes, catchAllRoute) + + server["routes"] = allRoutes - routeConfig := map[string]interface{}{ - "name": fmt.Sprintf("ingress_routes_%d", port), - "virtual_hosts": allVirtualHosts, + // Configure automatic HTTPS settings + if len(tlsHostnames) > 0 { + // When we have TLS hostnames, disable only redirects - we handle them explicitly + server["automatic_https"] = map[string]interface{}{ + "disable_redirects": true, + } + } else { + // No TLS hostnames - disable automatic HTTPS completely + server["automatic_https"] = map[string]interface{}{ + "disable": true, + } } - httpConnectionManager := map[string]interface{}{ - "@type": "type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager", - "stat_prefix": fmt.Sprintf("ingress_%d", port), - "codec_type": "AUTO", - "route_config": routeConfig, - "http_filters": []interface{}{ - map[string]interface{}{ - "name": "envoy.filters.http.router", - "typed_config": map[string]interface{}{ - "@type": "type.googleapis.com/envoy.extensions.filters.http.router.v3.Router", - }, + // Disable access logs (per-request logs) - we only want system logs + server["logs"] = map[string]interface{}{} + + config["apps"] = map[string]interface{}{ + "http": map[string]interface{}{ + "servers": map[string]interface{}{ + "ingress": server, }, }, } + } - filterChain := map[string]interface{}{ - "filters": []interface{}{ - map[string]interface{}{ - "name": "envoy.filters.network.http_connection_manager", - "typed_config": httpConnectionManager, - }, - }, + // Add TLS automation if we have TLS hostnames + if len(tlsHostnames) > 0 && g.acme.IsTLSConfigured() { + if config["apps"] == nil { + config["apps"] = map[string]interface{}{} } + config["apps"].(map[string]interface{})["tls"] = g.buildTLSConfig(tlsHostnames) + } - portToFilterChains[port] = []interface{}{filterChain} + // Configure Caddy storage paths + config["storage"] = map[string]interface{}{ + "module": "file_system", + "root": g.paths.CaddyDataDir(), } - return portToFilterChains + return config } -// buildClusters builds the clusters configuration. -func (g *EnvoyConfigGenerator) buildClusters(ctx context.Context, ingresses []Ingress, ipResolver func(instance string) (string, error)) []interface{} { - log := logger.FromContext(ctx) +// buildTLSConfig builds the TLS automation configuration. +func (g *CaddyConfigGenerator) buildTLSConfig(hostnames []string) map[string]interface{} { + issuer := map[string]interface{}{ + "module": "acme", + "email": g.acme.Email, + } - var clusters []interface{} - seen := make(map[string]bool) + // Set CA if specified (otherwise uses Let's Encrypt production) + if g.acme.CA != "" { + issuer["ca"] = g.acme.CA + } - for _, ingress := range ingresses { - for _, rule := range ingress.Rules { - clusterName := g.clusterName(ingress.ID, rule.Target.Instance, rule.Target.Port) - if seen[clusterName] { - continue - } - seen[clusterName] = true - - // Resolve instance IP - ip, err := ipResolver(rule.Target.Instance) - if err != nil { - // Skip clusters where we can't resolve the instance - log.WarnContext(ctx, "skipping cluster: cannot resolve instance IP", - "ingress_id", ingress.ID, - "instance", rule.Target.Instance, - "error", err) - continue - } + // Configure DNS challenge based on provider + issuer["challenges"] = map[string]interface{}{ + "dns": g.buildDNSChallengeConfig(), + } - cluster := map[string]interface{}{ - "name": clusterName, - "connect_timeout": "5s", - "type": "STATIC", - "lb_policy": "ROUND_ROBIN", - "load_assignment": map[string]interface{}{ - "cluster_name": clusterName, - "endpoints": []interface{}{ - map[string]interface{}{ - "lb_endpoints": []interface{}{ - map[string]interface{}{ - "endpoint": map[string]interface{}{ - "address": map[string]interface{}{ - "socket_address": map[string]interface{}{ - "address": ip, - "port_value": rule.Target.Port, - }, - }, - }, - }, - }, - }, - }, + return map[string]interface{}{ + "automation": map[string]interface{}{ + "policies": []interface{}{ + map[string]interface{}{ + "subjects": hostnames, + "issuers": []interface{}{issuer}, }, - } + }, + }, + } +} - clusters = append(clusters, cluster) +// buildDNSChallengeConfig builds the DNS challenge configuration. +// Uses the caddy-dns module format: https://github.com/caddy-dns/cloudflare +func (g *CaddyConfigGenerator) buildDNSChallengeConfig() map[string]interface{} { + dnsConfig := map[string]interface{}{} + + // Add provider-specific configuration + switch g.acme.DNSProvider { + case DNSProviderCloudflare: + // caddy-dns/cloudflare module format + dnsConfig["provider"] = map[string]interface{}{ + "name": caddyProviderCloudflare, + "api_token": g.acme.CloudflareAPIToken, } + default: + // This shouldn't happen due to validation at startup, but log if it does + slog.Warn("unknown DNS provider in buildDNSChallengeConfig", "provider", g.acme.DNSProvider) + return map[string]interface{}{} } - return clusters -} - -// clusterName generates a unique cluster name for an ingress target. -func (g *EnvoyConfigGenerator) clusterName(ingressID, instance string, port int) string { - return fmt.Sprintf("ingress_%s_%s_%d", ingressID, sanitizeName(instance), port) -} + // Add propagation settings (applies to all providers) + if g.acme.DNSPropagationTimeout != "" { + dnsConfig["propagation_timeout"] = g.acme.DNSPropagationTimeout + } + if g.acme.DNSResolvers != "" { + // Split comma-separated resolvers into array + resolvers := strings.Split(g.acme.DNSResolvers, ",") + for i := range resolvers { + resolvers[i] = strings.TrimSpace(resolvers[i]) + } + dnsConfig["resolvers"] = resolvers + } -// sanitizeHostname converts a hostname to a safe string for use in names. -func sanitizeHostname(hostname string) string { - return strings.ReplaceAll(strings.ReplaceAll(hostname, ".", "_"), "-", "_") + return dnsConfig } -// sanitizeName converts a name to a safe string for use in Envoy config names. -func sanitizeName(name string) string { - return strings.ReplaceAll(strings.ReplaceAll(name, ".", "_"), "-", "_") -} +// WriteConfig writes the Caddy configuration to disk. +func (g *CaddyConfigGenerator) WriteConfig(ctx context.Context, ingresses []Ingress) error { + configDir := filepath.Dir(g.paths.CaddyConfig()) -// otelCollectorClusterName is the cluster name for the OTEL collector. -const otelCollectorClusterName = "opentelemetry_collector" + // Ensure the directory exists + if err := os.MkdirAll(configDir, 0755); err != nil { + return fmt.Errorf("create config directory: %w", err) + } -// buildOTELCollectorCluster builds the cluster configuration for the OTEL collector. -func (g *EnvoyConfigGenerator) buildOTELCollectorCluster() map[string]interface{} { - // Parse endpoint (host:port) - host, port := parseEndpoint(g.otel.Endpoint) + // Ensure data directory exists + if err := os.MkdirAll(g.paths.CaddyDataDir(), 0755); err != nil { + return fmt.Errorf("create data directory: %w", err) + } - return map[string]interface{}{ - "name": otelCollectorClusterName, - "type": "STRICT_DNS", - "connect_timeout": "5s", - "lb_policy": "ROUND_ROBIN", - "typed_extension_protocol_options": map[string]interface{}{ - "envoy.extensions.upstreams.http.v3.HttpProtocolOptions": map[string]interface{}{ - "@type": "type.googleapis.com/envoy.extensions.upstreams.http.v3.HttpProtocolOptions", - "explicit_http_config": map[string]interface{}{ - "http2_protocol_options": map[string]interface{}{}, - }, - }, - }, - "load_assignment": map[string]interface{}{ - "cluster_name": otelCollectorClusterName, - "endpoints": []interface{}{ - map[string]interface{}{ - "lb_endpoints": []interface{}{ - map[string]interface{}{ - "endpoint": map[string]interface{}{ - "address": map[string]interface{}{ - "socket_address": map[string]interface{}{ - "address": host, - "port_value": port, - }, - }, - }, - }, - }, - }, - }, - }, + // Generate config + data, err := g.GenerateConfig(ctx, ingresses) + if err != nil { + return fmt.Errorf("generate config: %w", err) } + + // Write atomically + return g.atomicWrite(g.paths.CaddyConfig(), data) } -// buildStatsSinks builds the stats sinks configuration for metrics export to OTEL. -func (g *EnvoyConfigGenerator) buildStatsSinks() []interface{} { - serviceName := g.otel.ServiceName - if serviceName == "" { - serviceName = "hypeman-envoy" +// atomicWrite writes data to a file atomically using a temp file and rename. +func (g *CaddyConfigGenerator) atomicWrite(path string, data []byte) error { + dir := filepath.Dir(path) + tempFile, err := os.CreateTemp(dir, "caddy-*.json") + if err != nil { + return fmt.Errorf("create temp file: %w", err) } + tempPath := tempFile.Name() - // Build resource attributes for metrics - resourceAttrs := map[string]interface{}{ - "service.name": serviceName, - } - if g.otel.Environment != "" { - resourceAttrs["deployment.environment.name"] = g.otel.Environment + // Clean up temp file on any error + defer func() { + if tempPath != "" { + os.Remove(tempPath) + } + }() + + if _, err := tempFile.Write(data); err != nil { + tempFile.Close() + return fmt.Errorf("write temp file: %w", err) } - if g.otel.ServiceInstanceID != "" { - resourceAttrs["service.instance.id"] = g.otel.ServiceInstanceID + if err := tempFile.Close(); err != nil { + return fmt.Errorf("close temp file: %w", err) } - return []interface{}{ - map[string]interface{}{ - "name": "envoy.stat_sinks.open_telemetry", - "typed_config": map[string]interface{}{ - "@type": "type.googleapis.com/envoy.extensions.stat_sinks.open_telemetry.v3.SinkConfig", - "grpc_service": map[string]interface{}{ - "envoy_grpc": map[string]interface{}{ - "cluster_name": otelCollectorClusterName, - }, - "timeout": "5s", - }, - "emit_tags_as_attributes": true, - "prefix": "envoy", - }, - }, + if err := os.Rename(tempPath, path); err != nil { + return fmt.Errorf("rename temp file: %w", err) } + + tempPath = "" // Prevent cleanup of renamed file + return nil } -// parseEndpoint parses a host:port string. Defaults to port 4317 if not specified. -func parseEndpoint(endpoint string) (string, int) { - parts := strings.Split(endpoint, ":") - if len(parts) == 2 { - port := 4317 - if p, err := strconv.Atoi(parts[1]); err == nil { - port = p +// HasTLSRules checks if any ingress has TLS enabled. +func HasTLSRules(ingresses []Ingress) bool { + for _, ingress := range ingresses { + for _, rule := range ingress.Rules { + if rule.TLS { + return true + } } - return parts[0], port } - // Default OTLP gRPC port - return endpoint, 4317 + return false } diff --git a/lib/ingress/config_test.go b/lib/ingress/config_test.go index 3086b71e..bb087120 100644 --- a/lib/ingress/config_test.go +++ b/lib/ingress/config_test.go @@ -2,17 +2,16 @@ package ingress import ( "context" + "encoding/json" "os" - "strings" "testing" "github.com/onkernel/hypeman/lib/paths" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "gopkg.in/yaml.v3" ) -func setupTestGenerator(t *testing.T) (*EnvoyConfigGenerator, *paths.Paths, func()) { +func setupTestGenerator(t *testing.T) (*CaddyConfigGenerator, *paths.Paths, func()) { t.Helper() // Create temp dir @@ -22,11 +21,13 @@ func setupTestGenerator(t *testing.T) (*EnvoyConfigGenerator, *paths.Paths, func p := paths.New(tmpDir) // Create required directories - require.NoError(t, os.MkdirAll(p.EnvoyDir(), 0755)) + require.NoError(t, os.MkdirAll(p.CaddyDir(), 0755)) + require.NoError(t, os.MkdirAll(p.CaddyDataDir(), 0755)) - // Pass nil for validator in tests - no real Envoy binary available - // Empty OTELConfig means OTEL is disabled - generator := NewEnvoyConfigGenerator(p, "0.0.0.0", "127.0.0.1", 9901, nil, OTELConfig{}) + // Empty ACMEConfig means TLS is not configured + // Use DNS resolver port for dynamic upstreams + dnsResolverPort := 5353 + generator := NewCaddyConfigGenerator(p, "0.0.0.0", "127.0.0.1", 2019, ACMEConfig{}, dnsResolverPort) cleanup := func() { os.RemoveAll(tmpDir) @@ -41,30 +42,64 @@ func TestGenerateConfig_EmptyIngresses(t *testing.T) { ctx := context.Background() ingresses := []Ingress{} - ipResolver := func(instance string) (string, error) { - return "10.100.0.10", nil - } - data, err := generator.GenerateConfig(ctx, ingresses, ipResolver) + data, err := generator.GenerateConfig(ctx, ingresses) require.NoError(t, err) - // Parse YAML to verify structure + // Parse JSON to verify structure var config map[string]interface{} - err = yaml.Unmarshal(data, &config) + err = json.Unmarshal(data, &config) require.NoError(t, err) // Should have admin section admin, ok := config["admin"].(map[string]interface{}) require.True(t, ok, "config should have admin section") - adminAddr := admin["address"].(map[string]interface{}) - socketAddr := adminAddr["socket_address"].(map[string]interface{}) - assert.Equal(t, "127.0.0.1", socketAddr["address"]) - assert.Equal(t, 9901, socketAddr["port_value"]) - - // Should have empty listeners and clusters - staticResources := config["static_resources"].(map[string]interface{}) - listeners := staticResources["listeners"].([]interface{}) - assert.Empty(t, listeners, "listeners should be empty for no ingresses") + assert.Equal(t, "127.0.0.1:2019", admin["listen"]) + + // Should NOT have apps section when no ingresses exist + // (no HTTP server started until ingresses are created) + _, hasApps := config["apps"] + assert.False(t, hasApps, "config should not have apps section with no ingresses") + + // Should have storage section pointing to data directory + storage, ok := config["storage"].(map[string]interface{}) + require.True(t, ok, "config should have storage section") + assert.Equal(t, "file_system", storage["module"]) + // Verify storage root is set (path will vary based on temp dir) + root, ok := storage["root"].(string) + require.True(t, ok, "storage should have root path") + assert.Contains(t, root, "caddy/data", "storage root should be caddy data directory") +} + +func TestGenerateConfig_StoragePath(t *testing.T) { + // Test that the storage path is correctly configured based on the paths + tmpDir, err := os.MkdirTemp("", "ingress-storage-test-*") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + p := paths.New(tmpDir) + require.NoError(t, os.MkdirAll(p.CaddyDir(), 0755)) + require.NoError(t, os.MkdirAll(p.CaddyDataDir(), 0755)) + + generator := NewCaddyConfigGenerator(p, "0.0.0.0", "127.0.0.1", 2019, ACMEConfig{}, 5353) + + ctx := context.Background() + data, err := generator.GenerateConfig(ctx, []Ingress{}) + require.NoError(t, err) + + var config map[string]interface{} + err = json.Unmarshal(data, &config) + require.NoError(t, err) + + // Verify storage configuration + storage := config["storage"].(map[string]interface{}) + assert.Equal(t, "file_system", storage["module"]) + assert.Equal(t, p.CaddyDataDir(), storage["root"], "storage root should match CaddyDataDir") + + // Verify the path structure is correct + // CaddyDataDir should be under CaddyDir + expectedDataDir := tmpDir + "/caddy/data" + assert.Equal(t, expectedDataDir, p.CaddyDataDir(), "CaddyDataDir should be under data directory") } func TestGenerateConfig_SingleIngress(t *testing.T) { @@ -90,23 +125,22 @@ func TestGenerateConfig_SingleIngress(t *testing.T) { } ctx := context.Background() - ipResolver := func(instance string) (string, error) { - if instance == "my-api" { - return "10.100.0.10", nil - } - return "", ErrInstanceNotFound - } - data, err := generator.GenerateConfig(ctx, ingresses, ipResolver) + data, err := generator.GenerateConfig(ctx, ingresses) require.NoError(t, err) configStr := string(data) // Verify key elements are present assert.Contains(t, configStr, "api.example.com", "config should contain hostname") - assert.Contains(t, configStr, "10.100.0.10", "config should contain instance IP") - assert.Contains(t, configStr, "8080", "config should contain port") - assert.Contains(t, configStr, "ingress_ing-123", "config should contain cluster name") + assert.Contains(t, configStr, "dynamic_upstreams", "config should use dynamic upstreams") + assert.Contains(t, configStr, "reverse_proxy", "config should contain reverse_proxy handler") + assert.Contains(t, configStr, "my-api", "config should contain instance name in upstream URL") + assert.Contains(t, configStr, "8080", "config should contain target port") + + // Verify catch-all 404 route is present + assert.Contains(t, configStr, "static_response", "config should contain static_response handler for 404") + assert.Contains(t, configStr, "no ingress configured for hostname", "config should contain 404 message") } func TestGenerateConfig_MultipleRules(t *testing.T) { @@ -131,17 +165,7 @@ func TestGenerateConfig_MultipleRules(t *testing.T) { }, } - ipResolver := func(instance string) (string, error) { - switch instance { - case "api-service": - return "10.100.0.10", nil - case "web-service": - return "10.100.0.11", nil - } - return "", ErrInstanceNotFound - } - - data, err := generator.GenerateConfig(ctx, ingresses, ipResolver) + data, err := generator.GenerateConfig(ctx, ingresses) require.NoError(t, err) configStr := string(data) @@ -149,8 +173,8 @@ func TestGenerateConfig_MultipleRules(t *testing.T) { // Verify both hosts are present assert.Contains(t, configStr, "api.example.com") assert.Contains(t, configStr, "web.example.com") - assert.Contains(t, configStr, "10.100.0.10") - assert.Contains(t, configStr, "10.100.0.11") + assert.Contains(t, configStr, "api-service") + assert.Contains(t, configStr, "web-service") } func TestGenerateConfig_MultipleIngresses(t *testing.T) { @@ -171,26 +195,16 @@ func TestGenerateConfig_MultipleIngresses(t *testing.T) { }, } - ipResolver := func(instance string) (string, error) { - switch instance { - case "app1": - return "10.100.0.10", nil - case "app2": - return "10.100.0.20", nil - } - return "", ErrInstanceNotFound - } - - data, err := generator.GenerateConfig(ctx, ingresses, ipResolver) + data, err := generator.GenerateConfig(ctx, ingresses) require.NoError(t, err) configStr := string(data) - // Verify all hosts and IPs are present + // Verify all hosts and instances are present assert.Contains(t, configStr, "app1.example.com") assert.Contains(t, configStr, "app2.example.com") - assert.Contains(t, configStr, "10.100.0.10") - assert.Contains(t, configStr, "10.100.0.20") + assert.Contains(t, configStr, "app1") + assert.Contains(t, configStr, "app2") } func TestGenerateConfig_MultiplePorts(t *testing.T) { @@ -222,109 +236,105 @@ func TestGenerateConfig_MultiplePorts(t *testing.T) { }, } - ipResolver := func(instance string) (string, error) { - switch instance { - case "api": - return "10.100.0.10", nil - case "internal": - return "10.100.0.20", nil - case "metrics": - return "10.100.0.30", nil - } - return "", ErrInstanceNotFound - } - - data, err := generator.GenerateConfig(ctx, ingresses, ipResolver) + data, err := generator.GenerateConfig(ctx, ingresses) require.NoError(t, err) configStr := string(data) - // Verify listeners for each port - assert.Contains(t, configStr, "ingress_listener_80") - assert.Contains(t, configStr, "ingress_listener_8080") - assert.Contains(t, configStr, "ingress_listener_9000") + // Verify listen addresses include all ports + assert.Contains(t, configStr, ":80") + assert.Contains(t, configStr, ":8080") + assert.Contains(t, configStr, ":9000") // Verify all hostnames are present assert.Contains(t, configStr, "api.example.com") assert.Contains(t, configStr, "internal.example.com") assert.Contains(t, configStr, "metrics.example.com") - - // Verify all IPs are present - assert.Contains(t, configStr, "10.100.0.10") - assert.Contains(t, configStr, "10.100.0.20") - assert.Contains(t, configStr, "10.100.0.30") } -func TestGenerateConfig_DefaultPort(t *testing.T) { +func TestGenerateConfig_DeterministicOrder(t *testing.T) { generator, _, cleanup := setupTestGenerator(t) defer cleanup() ctx := context.Background() - // Test that Port=0 defaults to 80 + // Create ingresses with ports in non-sorted order to verify output is deterministic ingresses := []Ingress{ { ID: "ing-1", - Name: "default-port-ingress", + Name: "high-port-ingress", Rules: []IngressRule{ - {Match: IngressMatch{Hostname: "api.example.com", Port: 0}, Target: IngressTarget{Instance: "api", Port: 8080}}, + {Match: IngressMatch{Hostname: "metrics.example.com", Port: 9000}, Target: IngressTarget{Instance: "metrics", Port: 9090}}, + }, + }, + { + ID: "ing-2", + Name: "low-port-ingress", + Rules: []IngressRule{ + {Match: IngressMatch{Hostname: "api.example.com", Port: 80}, Target: IngressTarget{Instance: "api", Port: 8080}}, + }, + }, + { + ID: "ing-3", + Name: "mid-port-ingress", + Rules: []IngressRule{ + {Match: IngressMatch{Hostname: "internal.example.com", Port: 443}, Target: IngressTarget{Instance: "internal", Port: 3000}}, }, }, } - ipResolver := func(instance string) (string, error) { - return "10.100.0.10", nil + // Generate config multiple times and verify output is identical + var firstOutput []byte + for i := 0; i < 5; i++ { + data, err := generator.GenerateConfig(ctx, ingresses) + require.NoError(t, err) + + if firstOutput == nil { + firstOutput = data + } else { + assert.Equal(t, string(firstOutput), string(data), "config output should be deterministic on iteration %d", i) + } } - data, err := generator.GenerateConfig(ctx, ingresses, ipResolver) + // Also verify the listen addresses are in sorted order (80, 443, 9000) + var config map[string]interface{} + err := json.Unmarshal(firstOutput, &config) require.NoError(t, err) - configStr := string(data) + apps := config["apps"].(map[string]interface{}) + httpApp := apps["http"].(map[string]interface{}) + servers := httpApp["servers"].(map[string]interface{}) + ingressServer := servers["ingress"].(map[string]interface{}) + listenAddrs := ingressServer["listen"].([]interface{}) - // Should create listener on port 80 (default) - assert.Contains(t, configStr, "ingress_listener_80") - assert.Contains(t, configStr, "port_value: 80") + require.Len(t, listenAddrs, 3) + assert.Equal(t, "0.0.0.0:80", listenAddrs[0].(string)) + assert.Equal(t, "0.0.0.0:443", listenAddrs[1].(string)) + assert.Equal(t, "0.0.0.0:9000", listenAddrs[2].(string)) } -func TestGenerateConfig_SkipsUnresolvedInstances(t *testing.T) { +func TestGenerateConfig_DefaultPort(t *testing.T) { generator, _, cleanup := setupTestGenerator(t) defer cleanup() ctx := context.Background() + // Test that Port=0 defaults to 80 ingresses := []Ingress{ { - ID: "ing-123", - Name: "partial-ingress", + ID: "ing-1", + Name: "default-port-ingress", Rules: []IngressRule{ - { - Match: IngressMatch{Hostname: "valid.example.com"}, - Target: IngressTarget{Instance: "valid-instance", Port: 8080}, - }, - { - Match: IngressMatch{Hostname: "invalid.example.com"}, - Target: IngressTarget{Instance: "missing-instance", Port: 8080}, - }, + {Match: IngressMatch{Hostname: "api.example.com", Port: 0}, Target: IngressTarget{Instance: "api", Port: 8080}}, }, }, } - ipResolver := func(instance string) (string, error) { - if instance == "valid-instance" { - return "10.100.0.10", nil - } - return "", ErrInstanceNotFound - } - - data, err := generator.GenerateConfig(ctx, ingresses, ipResolver) + data, err := generator.GenerateConfig(ctx, ingresses) require.NoError(t, err) configStr := string(data) - // Valid instance should be present - assert.Contains(t, configStr, "valid.example.com") - assert.Contains(t, configStr, "10.100.0.10") - - // Invalid instance should NOT be present - assert.NotContains(t, configStr, "invalid.example.com") + // Should create listener on port 80 (default) + assert.Contains(t, configStr, "0.0.0.0:80") } func TestWriteConfig(t *testing.T) { @@ -340,53 +350,98 @@ func TestWriteConfig(t *testing.T) { }, } - ipResolver := func(instance string) (string, error) { - return "10.100.0.10", nil - } - - err := generator.WriteConfig(ctx, ingresses, ipResolver) + err := generator.WriteConfig(ctx, ingresses) require.NoError(t, err) - // Verify bootstrap file was written - configPath := p.EnvoyConfig() + // Verify config file was written + configPath := p.CaddyConfig() data, err := os.ReadFile(configPath) require.NoError(t, err) - assert.True(t, len(data) > 0, "bootstrap config file should not be empty") - assert.Contains(t, string(data), "dynamic_resources") + assert.True(t, len(data) > 0, "config file should not be empty") + assert.Contains(t, string(data), "test.example.com") + assert.Contains(t, string(data), "test-svc") +} - // Verify LDS file contains the hostname (xDS format) - ldsPath := p.EnvoyLDS() - ldsData, err := os.ReadFile(ldsPath) - require.NoError(t, err) - assert.Contains(t, string(ldsData), "test.example.com") +func TestConfigIsValidJSON(t *testing.T) { + generator, _, cleanup := setupTestGenerator(t) + defer cleanup() - // Verify CDS file contains the cluster - cdsPath := p.EnvoyCDS() - cdsData, err := os.ReadFile(cdsPath) + ctx := context.Background() + ingresses := []Ingress{ + { + ID: "ing-123", + Name: "test-ingress", + Rules: []IngressRule{ + { + Match: IngressMatch{Hostname: "api.example.com"}, + Target: IngressTarget{Instance: "my-api", Port: 8080}, + }, + }, + }, + } + + data, err := generator.GenerateConfig(ctx, ingresses) require.NoError(t, err) - assert.Contains(t, string(cdsData), "10.100.0.10") + + // Verify it's valid JSON by parsing it + var config interface{} + err = json.Unmarshal(data, &config) + require.NoError(t, err, "generated config should be valid JSON") } -func TestSanitizeHostname(t *testing.T) { - tests := []struct { - input string - expected string - }{ - {"api.example.com", "api_example_com"}, - {"my-service.domain.org", "my_service_domain_org"}, - {"simple", "simple"}, - {"a.b.c.d", "a_b_c_d"}, +func TestGenerateConfig_WithTLS(t *testing.T) { + // Create temp dir + tmpDir, err := os.MkdirTemp("", "ingress-config-tls-test-*") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + p := paths.New(tmpDir) + require.NoError(t, os.MkdirAll(p.CaddyDir(), 0755)) + require.NoError(t, os.MkdirAll(p.CaddyDataDir(), 0755)) + + // Create generator with ACME configured + acmeConfig := ACMEConfig{ + Email: "admin@example.com", + DNSProvider: DNSProviderCloudflare, + CloudflareAPIToken: "test-token", } + generator := NewCaddyConfigGenerator(p, "0.0.0.0", "127.0.0.1", 2019, acmeConfig, 5353) - for _, tc := range tests { - t.Run(tc.input, func(t *testing.T) { - result := sanitizeHostname(tc.input) - assert.Equal(t, tc.expected, result) - }) + ctx := context.Background() + ingresses := []Ingress{ + { + ID: "ing-123", + Name: "tls-ingress", + Rules: []IngressRule{ + { + Match: IngressMatch{Hostname: "secure.example.com", Port: 443}, + Target: IngressTarget{Instance: "my-api", Port: 8080}, + TLS: true, + RedirectHTTP: true, + }, + }, + }, } + + data, err := generator.GenerateConfig(ctx, ingresses) + require.NoError(t, err) + + configStr := string(data) + + // Verify TLS automation is configured + assert.Contains(t, configStr, "tls", "config should contain tls section") + assert.Contains(t, configStr, "automation", "config should contain automation") + assert.Contains(t, configStr, "secure.example.com", "config should contain hostname") + assert.Contains(t, configStr, "acme", "config should contain acme issuer") + assert.Contains(t, configStr, "cloudflare", "config should contain cloudflare provider") + assert.Contains(t, configStr, "admin@example.com", "config should contain email") + + // Verify HTTP redirect route is created + assert.Contains(t, configStr, "301", "config should contain redirect status") + assert.Contains(t, configStr, "Location", "config should contain Location header") } -func TestConfigIsValidYAML(t *testing.T) { +func TestGenerateConfig_WithTLSDisabled(t *testing.T) { generator, _, cleanup := setupTestGenerator(t) defer cleanup() @@ -394,92 +449,380 @@ func TestConfigIsValidYAML(t *testing.T) { ingresses := []Ingress{ { ID: "ing-123", - Name: "test-ingress", + Name: "no-tls-ingress", Rules: []IngressRule{ { Match: IngressMatch{Hostname: "api.example.com"}, Target: IngressTarget{Instance: "my-api", Port: 8080}, + TLS: false, }, }, }, } - ipResolver := func(instance string) (string, error) { - return "10.100.0.10", nil + data, err := generator.GenerateConfig(ctx, ingresses) + require.NoError(t, err) + + configStr := string(data) + + // Verify TLS automation is NOT present when disabled + assert.NotContains(t, configStr, `"automation"`, "config should not contain tls automation when disabled") +} + +func TestACMEConfig_IsTLSConfigured(t *testing.T) { + tests := []struct { + name string + config ACMEConfig + expected bool + }{ + { + name: "empty config", + config: ACMEConfig{}, + expected: false, + }, + { + name: "cloudflare configured", + config: ACMEConfig{ + Email: "admin@example.com", + DNSProvider: DNSProviderCloudflare, + CloudflareAPIToken: "token", + }, + expected: true, + }, + { + name: "cloudflare missing token", + config: ACMEConfig{ + Email: "admin@example.com", + DNSProvider: DNSProviderCloudflare, + }, + expected: false, + }, + { + name: "no provider set", + config: ACMEConfig{ + Email: "admin@example.com", + DNSProvider: DNSProviderNone, + }, + expected: false, + }, } - data, err := generator.GenerateConfig(ctx, ingresses, ipResolver) - require.NoError(t, err) + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := tc.config.IsTLSConfigured() + assert.Equal(t, tc.expected, result) + }) + } +} - // Verify it's valid YAML by parsing it - var config interface{} - err = yaml.Unmarshal(data, &config) - require.NoError(t, err, "generated config should be valid YAML") +func TestACMEConfig_IsDomainAllowed(t *testing.T) { + tests := []struct { + name string + allowedDomains string + hostname string + expected bool + }{ + { + name: "empty config - no domains allowed", + allowedDomains: "", + hostname: "example.com", + expected: false, + }, + { + name: "exact match", + allowedDomains: "api.example.com", + hostname: "api.example.com", + expected: true, + }, + { + name: "exact match with multiple patterns", + allowedDomains: "api.example.com, www.example.com, admin.example.com", + hostname: "www.example.com", + expected: true, + }, + { + name: "wildcard match", + allowedDomains: "*.example.com", + hostname: "api.example.com", + expected: true, + }, + { + name: "wildcard match - different subdomain", + allowedDomains: "*.example.com", + hostname: "www.example.com", + expected: true, + }, + { + name: "wildcard does not match nested subdomains", + allowedDomains: "*.example.com", + hostname: "api.v2.example.com", + expected: false, + }, + { + name: "wildcard does not match apex domain", + allowedDomains: "*.example.com", + hostname: "example.com", + expected: false, + }, + { + name: "no match - wrong domain", + allowedDomains: "*.example.com", + hostname: "api.other.com", + expected: false, + }, + { + name: "no match - similar but different domain", + allowedDomains: "*.hypeman-development.com", + hostname: "test.hypeman-developments.com", + expected: false, + }, + { + name: "multiple patterns with wildcard", + allowedDomains: "*.example.com, api.other.com", + hostname: "api.other.com", + expected: true, + }, + { + name: "whitespace handling", + allowedDomains: " *.example.com , api.other.com ", + hostname: "api.other.com", + expected: true, + }, + // Edge cases for global wildcard + { + name: "global wildcard matches any domain", + allowedDomains: "*", + hostname: "anything.example.com", + expected: true, + }, + { + name: "global wildcard matches apex domain", + allowedDomains: "*", + hostname: "example.com", + expected: true, + }, + { + name: "global wildcard matches deeply nested", + allowedDomains: "*", + hostname: "a.b.c.d.example.com", + expected: true, + }, + { + name: "global wildcard with other patterns", + allowedDomains: "*, specific.example.com", + hostname: "random.other.com", + expected: true, + }, + // Edge cases for subdomain wildcard + { + name: "subdomain wildcard with single char subdomain", + allowedDomains: "*.example.com", + hostname: "x.example.com", + expected: true, + }, + { + name: "subdomain wildcard with hyphenated subdomain", + allowedDomains: "*.example.com", + hostname: "my-app.example.com", + expected: true, + }, + { + name: "subdomain wildcard with numeric subdomain", + allowedDomains: "*.example.com", + hostname: "123.example.com", + expected: true, + }, + { + name: "subdomain wildcard does not match empty prefix", + allowedDomains: "*.example.com", + hostname: ".example.com", + expected: false, + }, + { + name: "subdomain wildcard vs apex - explicit apex allowed", + allowedDomains: "*.example.com, example.com", + hostname: "example.com", + expected: true, + }, + { + name: "subdomain wildcard triple-level does not match", + allowedDomains: "*.example.com", + hostname: "a.b.example.com", + expected: false, + }, + // Edge cases for pattern formatting + { + name: "empty pattern in list is skipped", + allowedDomains: "api.example.com, , www.example.com", + hostname: "www.example.com", + expected: true, + }, + { + name: "only whitespace pattern is skipped", + allowedDomains: " ,api.example.com", + hostname: "api.example.com", + expected: true, + }, + } - // Also check that there are no obvious YAML issues (multiple documents, etc) - assert.False(t, strings.Contains(string(data), "---\n"), "should be single YAML document") + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + config := ACMEConfig{AllowedDomains: tc.allowedDomains} + result := config.IsDomainAllowed(tc.hostname) + assert.Equal(t, tc.expected, result, "hostname=%q, allowed=%q", tc.hostname, tc.allowedDomains) + }) + } } -func TestGenerateConfig_WithOTEL(t *testing.T) { +func TestGenerateConfig_MixedTLSAndNonTLS(t *testing.T) { // Create temp dir - tmpDir, err := os.MkdirTemp("", "ingress-config-otel-test-*") + tmpDir, err := os.MkdirTemp("", "ingress-config-mixed-tls-test-*") require.NoError(t, err) defer os.RemoveAll(tmpDir) p := paths.New(tmpDir) - require.NoError(t, os.MkdirAll(p.EnvoyDir(), 0755)) - - // Create generator with OTEL enabled - otelConfig := OTELConfig{ - Enabled: true, - Endpoint: "otel-collector:4317", - ServiceName: "test-service", - ServiceInstanceID: "instance-123", - Insecure: true, - Environment: "test", + require.NoError(t, os.MkdirAll(p.CaddyDir(), 0755)) + require.NoError(t, os.MkdirAll(p.CaddyDataDir(), 0755)) + + // Create generator with ACME configured + acmeConfig := ACMEConfig{ + Email: "admin@example.com", + DNSProvider: DNSProviderCloudflare, + CloudflareAPIToken: "test-token", } - generator := NewEnvoyConfigGenerator(p, "0.0.0.0", "127.0.0.1", 9901, nil, otelConfig) + generator := NewCaddyConfigGenerator(p, "0.0.0.0", "127.0.0.1", 2019, acmeConfig, 5353) ctx := context.Background() ingresses := []Ingress{ { - ID: "ing-123", - Name: "test-ingress", + ID: "mixed-ingress", + Name: "mixed-ingress", Rules: []IngressRule{ { - Match: IngressMatch{Hostname: "api.example.com"}, - Target: IngressTarget{Instance: "my-api", Port: 8080}, + // Non-TLS rule on port 80 + Match: IngressMatch{Hostname: "api.example.com", Port: 80}, + Target: IngressTarget{Instance: "api", Port: 8080}, + TLS: false, + }, + { + // TLS rule on port 443 + Match: IngressMatch{Hostname: "secure.example.com", Port: 443}, + Target: IngressTarget{Instance: "secure", Port: 8080}, + TLS: true, + RedirectHTTP: true, }, }, }, } - ipResolver := func(instance string) (string, error) { - return "10.100.0.10", nil - } - - data, err := generator.GenerateConfig(ctx, ingresses, ipResolver) + data, err := generator.GenerateConfig(ctx, ingresses) require.NoError(t, err) configStr := string(data) - // Verify OTEL collector cluster is present (for stats sink) - assert.Contains(t, configStr, "opentelemetry_collector", "config should contain OTEL collector cluster") - assert.Contains(t, configStr, "otel-collector", "config should contain OTEL collector host") - assert.Contains(t, configStr, "4317", "config should contain OTEL collector port") + // Verify both hostnames are present + assert.Contains(t, configStr, "api.example.com") + assert.Contains(t, configStr, "secure.example.com") + + // Verify TLS automation is configured for secure hostname + assert.Contains(t, configStr, "automation") + assert.Contains(t, configStr, "acme") - // Verify stats sink is present (for metrics export, not tracing) - assert.Contains(t, configStr, "stats_sinks", "config should contain stats_sinks") - assert.Contains(t, configStr, "envoy.stat_sinks.open_telemetry", "config should contain OTEL stats sink") + // Verify HTTP redirect is present (for TLS rule with redirect_http) + assert.Contains(t, configStr, "301") - // Verify NO tracing config (we export metrics, not per-request traces) - assert.NotContains(t, configStr, "envoy.tracers.opentelemetry", "config should NOT contain OTEL tracer") + // Verify automatic_https has disable_redirects (not fully disabled) + // because we have TLS hostnames + assert.Contains(t, configStr, `"disable_redirects"`) + assert.NotContains(t, configStr, `"disable": true`) } -func TestGenerateConfig_WithOTELDisabled(t *testing.T) { +func TestHasTLSRules(t *testing.T) { + tests := []struct { + name string + ingresses []Ingress + expected bool + }{ + { + name: "empty", + ingresses: []Ingress{}, + expected: false, + }, + { + name: "no TLS", + ingresses: []Ingress{ + {Rules: []IngressRule{{TLS: false}}}, + }, + expected: false, + }, + { + name: "with TLS", + ingresses: []Ingress{ + {Rules: []IngressRule{{TLS: true}}}, + }, + expected: true, + }, + { + name: "mixed", + ingresses: []Ingress{ + {Rules: []IngressRule{{TLS: false}}}, + {Rules: []IngressRule{{TLS: true}}}, + }, + expected: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := HasTLSRules(tc.ingresses) + assert.Equal(t, tc.expected, result) + }) + } +} + +func TestGenerateConfig_PatternHostname(t *testing.T) { generator, _, cleanup := setupTestGenerator(t) defer cleanup() + ctx := context.Background() + ingresses := []Ingress{ + { + ID: "pattern-ingress", + Name: "pattern-ingress", + Rules: []IngressRule{ + { + Match: IngressMatch{Hostname: "{instance}.example.com"}, + Target: IngressTarget{Instance: "{instance}", Port: 8080}, + }, + }, + }, + } + + data, err := generator.GenerateConfig(ctx, ingresses) + require.NoError(t, err) + + configStr := string(data) + + // Verify wildcard is used for hostname matching + assert.Contains(t, configStr, "*.example.com") + + // Verify dynamic upstream uses Caddy placeholder for instance resolution + assert.Contains(t, configStr, "http.request.host.labels") +} + +func TestGenerateConfig_DynamicUpstreams(t *testing.T) { + // Create temp dir + tmpDir, err := os.MkdirTemp("", "ingress-config-dynamic-test-*") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + p := paths.New(tmpDir) + require.NoError(t, os.MkdirAll(p.CaddyDir(), 0755)) + require.NoError(t, os.MkdirAll(p.CaddyDataDir(), 0755)) + + dnsPort := 5353 + generator := NewCaddyConfigGenerator(p, "0.0.0.0", "127.0.0.1", 2019, ACMEConfig{}, dnsPort) + ctx := context.Background() ingresses := []Ingress{ { @@ -494,16 +837,18 @@ func TestGenerateConfig_WithOTELDisabled(t *testing.T) { }, } - ipResolver := func(instance string) (string, error) { - return "10.100.0.10", nil - } - - data, err := generator.GenerateConfig(ctx, ingresses, ipResolver) + data, err := generator.GenerateConfig(ctx, ingresses) require.NoError(t, err) configStr := string(data) - // Verify OTEL is NOT present when disabled - assert.NotContains(t, configStr, "opentelemetry_collector", "config should not contain OTEL collector when disabled") - assert.NotContains(t, configStr, "envoy.tracers.opentelemetry", "config should not contain OTEL tracer when disabled") + // Verify DNS-based dynamic upstreams structure is present + assert.Contains(t, configStr, "dynamic_upstreams") + assert.Contains(t, configStr, `"source"`) + assert.Contains(t, configStr, `"a"`) + + // Verify DNS hostname and resolver are configured + assert.Contains(t, configStr, "my-api.hypeman.internal") + assert.Contains(t, configStr, "resolver") + assert.Contains(t, configStr, "127.0.0.1:5353") } diff --git a/lib/ingress/daemon.go b/lib/ingress/daemon.go index 514b83f4..86fe5101 100644 --- a/lib/ingress/daemon.go +++ b/lib/ingress/daemon.go @@ -1,12 +1,13 @@ package ingress import ( + "bytes" "context" "fmt" + "io" "net/http" "os" "os/exec" - "path/filepath" "strconv" "strings" "syscall" @@ -16,20 +17,29 @@ import ( "github.com/onkernel/hypeman/lib/paths" ) -// EnvoyDaemon manages the Envoy proxy daemon lifecycle. -// Envoy uses file-based xDS for dynamic configuration - it watches LDS/CDS files -// and automatically reloads when they change. No hot restart needed. -type EnvoyDaemon struct { +// Polling intervals for Caddy daemon lifecycle management. +const ( + // adminPollInterval is the interval for polling the admin API during startup. + adminPollInterval = 100 * time.Millisecond + + // processExitPollInterval is the interval for polling process exit during shutdown. + // This is faster than adminPollInterval to ensure responsive shutdown. + processExitPollInterval = 50 * time.Millisecond +) + +// CaddyDaemon manages the Caddy proxy daemon lifecycle. +// Caddy uses its admin API for configuration updates - no restart needed. +type CaddyDaemon struct { paths *paths.Paths adminAddress string adminPort int pid int - stopOnShutdown bool // If true, stop Envoy when hypeman shuts down + stopOnShutdown bool } -// NewEnvoyDaemon creates a new EnvoyDaemon manager. -func NewEnvoyDaemon(p *paths.Paths, adminAddress string, adminPort int, stopOnShutdown bool) *EnvoyDaemon { - return &EnvoyDaemon{ +// NewCaddyDaemon creates a new CaddyDaemon manager. +func NewCaddyDaemon(p *paths.Paths, adminAddress string, adminPort int, stopOnShutdown bool) *CaddyDaemon { + return &CaddyDaemon{ paths: p, adminAddress: adminAddress, adminPort: adminPort, @@ -37,61 +47,71 @@ func NewEnvoyDaemon(p *paths.Paths, adminAddress string, adminPort int, stopOnSh } } -// StopOnShutdown returns whether Envoy should be stopped when hypeman shuts down. -func (d *EnvoyDaemon) StopOnShutdown() bool { +// StopOnShutdown returns whether Caddy should be stopped when hypeman shuts down. +func (d *CaddyDaemon) StopOnShutdown() bool { return d.stopOnShutdown } -// Start starts the Envoy daemon. If Envoy is already running (discovered via PID file +// Start starts the Caddy daemon. If Caddy is already running (discovered via PID file // or admin API), this is a no-op and returns the existing PID. -func (d *EnvoyDaemon) Start(ctx context.Context) (int, error) { +func (d *CaddyDaemon) Start(ctx context.Context) (int, error) { // Check if already running if pid, running := d.DiscoverRunning(); running { d.pid = pid return pid, nil } - return d.startEnvoy(ctx) + return d.startCaddy(ctx) } -// startEnvoy starts a new Envoy process. -func (d *EnvoyDaemon) startEnvoy(ctx context.Context) (int, error) { +// startCaddy starts a new Caddy process. +func (d *CaddyDaemon) startCaddy(ctx context.Context) (int, error) { // Get binary path (extracts if needed) - binaryPath, err := GetEnvoyBinaryPath(d.paths) + binaryPath, err := GetCaddyBinaryPath(d.paths) if err != nil { - return 0, fmt.Errorf("get envoy binary: %w", err) + return 0, fmt.Errorf("get caddy binary: %w", err) + } + + // Ensure caddy directory exists + caddyDir := d.paths.CaddyDir() + if err := os.MkdirAll(caddyDir, 0755); err != nil { + return 0, fmt.Errorf("create caddy directory: %w", err) } - // Ensure envoy directory exists - envoyDir := d.paths.EnvoyDir() - if err := os.MkdirAll(envoyDir, 0755); err != nil { - return 0, fmt.Errorf("create envoy directory: %w", err) + // Ensure data directory exists (for certificates) + if err := os.MkdirAll(d.paths.CaddyDataDir(), 0755); err != nil { + return 0, fmt.Errorf("create caddy data directory: %w", err) } - // Build command arguments - Envoy uses file-based xDS for dynamic config - configPath := d.paths.EnvoyConfig() + // Build command arguments + configPath := d.paths.CaddyConfig() args := []string{ - "--config-path", configPath, - "--log-path", d.paths.EnvoyLogFile(), - "--log-level", "info", + "run", + "--config", configPath, } // Use Command (not CommandContext) so process survives parent context cancellation cmd := exec.Command(binaryPath, args...) + // Set environment for Caddy data/config paths + cmd.Env = append(os.Environ(), + fmt.Sprintf("XDG_DATA_HOME=%s", d.paths.CaddyDataDir()), + fmt.Sprintf("XDG_CONFIG_HOME=%s", d.paths.CaddyConfigDir()), + ) + // Daemonize: create new session to fully detach from parent cmd.SysProcAttr = &syscall.SysProcAttr{ - Setsid: true, // Create new session (implies new process group) + Setsid: true, } // Redirect stdout/stderr to log file logFile, err := os.OpenFile( - filepath.Join(envoyDir, "envoy-stdout.log"), + d.paths.CaddyLogFile(), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644, ) if err != nil { - return 0, fmt.Errorf("create stdout log: %w", err) + return 0, fmt.Errorf("create log file: %w", err) } defer logFile.Close() @@ -99,107 +119,143 @@ func (d *EnvoyDaemon) startEnvoy(ctx context.Context) (int, error) { cmd.Stderr = logFile if err := cmd.Start(); err != nil { - return 0, fmt.Errorf("start envoy: %w", err) + return 0, fmt.Errorf("start caddy: %w", err) } pid := cmd.Process.Pid // Write PID file - pidPath := d.paths.EnvoyPIDFile() + pidPath := d.paths.CaddyPIDFile() if err := os.WriteFile(pidPath, []byte(strconv.Itoa(pid)), 0644); err != nil { - // Non-fatal, log but continue log := logger.FromContext(ctx) log.WarnContext(ctx, "failed to write PID file", "error", err) } - // Wait for admin API to be ready - waitCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + // Wait for admin API to be ready. + // Use context.Background() instead of the parent context to ensure the startup + // wait isn't cancelled if the parent context times out during server startup. + waitCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() if err := d.waitForAdmin(waitCtx); err != nil { // Try to kill the process if it failed to start properly - if proc, err := os.FindProcess(pid); err == nil { + if proc, findErr := os.FindProcess(pid); findErr == nil { proc.Kill() } - return 0, fmt.Errorf("envoy failed to start: %w", err) + // Clean up PID file to avoid stale file on restart + os.Remove(d.paths.CaddyPIDFile()) + return 0, fmt.Errorf("caddy failed to start: %w", err) } d.pid = pid return pid, nil } -// Stop gracefully stops the Envoy daemon. -func (d *EnvoyDaemon) Stop() error { +// Stop gracefully stops the Caddy daemon. +func (d *CaddyDaemon) Stop() error { pid, running := d.DiscoverRunning() if !running { - return nil // Already stopped + return nil } // Try graceful shutdown via admin API first client := &http.Client{Timeout: 5 * time.Second} - adminURL := fmt.Sprintf("http://%s:%d/quitquitquit", d.adminAddress, d.adminPort) + adminURL := fmt.Sprintf("http://%s:%d/stop", d.adminAddress, d.adminPort) resp, err := client.Post(adminURL, "", nil) if err == nil { resp.Body.Close() - // Wait for process to exit - time.Sleep(2 * time.Second) } - // Check if still running, send SIGTERM - if d.isProcessRunning(pid) { - proc, err := os.FindProcess(pid) - if err == nil { - proc.Signal(syscall.SIGTERM) - time.Sleep(2 * time.Second) - } + // Wait for process to exit after admin API stop (up to 5s) + if d.waitForProcessExit(pid, 5*time.Second) { + os.Remove(d.paths.CaddyPIDFile()) + d.pid = 0 + return nil } - // Final check, send SIGKILL if needed - if d.isProcessRunning(pid) { - proc, err := os.FindProcess(pid) - if err == nil { - proc.Signal(syscall.SIGKILL) - } + // Send SIGTERM if still running + if proc, err := os.FindProcess(pid); err == nil { + proc.Signal(syscall.SIGTERM) + } + + // Wait for process to exit after SIGTERM + if d.waitForProcessExit(pid, 2*time.Second) { + os.Remove(d.paths.CaddyPIDFile()) + d.pid = 0 + return nil + } + + // Final resort: SIGKILL + if proc, err := os.FindProcess(pid); err == nil { + proc.Signal(syscall.SIGKILL) } + // Brief wait after SIGKILL with timeout + d.waitForProcessExit(pid, 1*time.Second) + // Clean up PID file - os.Remove(d.paths.EnvoyPIDFile()) + os.Remove(d.paths.CaddyPIDFile()) d.pid = 0 return nil } -// ReloadConfig is a no-op - Envoy watches the xDS config files and reloads automatically. -// This method is kept for API compatibility but does nothing since file-based xDS -// handles configuration updates automatically when files change. -func (d *EnvoyDaemon) ReloadConfig() error { - // No-op: Envoy watches the LDS/CDS files and reloads automatically +// waitForProcessExit polls until the process exits or timeout. +func (d *CaddyDaemon) waitForProcessExit(pid int, timeout time.Duration) bool { + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + if !d.isProcessRunning(pid) { + return true + } + time.Sleep(processExitPollInterval) + } + return !d.isProcessRunning(pid) +} + +// ReloadConfig reloads Caddy configuration by posting to the admin API. +func (d *CaddyDaemon) ReloadConfig(config []byte) error { + client := &http.Client{Timeout: 30 * time.Second} + adminURL := fmt.Sprintf("http://%s:%d/load", d.adminAddress, d.adminPort) + + resp, err := client.Post(adminURL, "application/json", bytes.NewReader(config)) + if err != nil { + return fmt.Errorf("post to admin API: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + bodyStr := string(body) + + // Try to parse a more specific error + if specificErr := ParseCaddyError(bodyStr); specificErr != nil { + return specificErr + } + + return fmt.Errorf("caddy reload failed (status %d): %s", resp.StatusCode, bodyStr) + } + return nil } -// DiscoverRunning checks if Envoy is already running and returns its PID. -// Returns (pid, true) if running, (0, false) otherwise. -func (d *EnvoyDaemon) DiscoverRunning() (int, bool) { +// DiscoverRunning checks if Caddy is already running and returns its PID. +func (d *CaddyDaemon) DiscoverRunning() (int, bool) { // First, try to read PID file - pidPath := d.paths.EnvoyPIDFile() + pidPath := d.paths.CaddyPIDFile() data, err := os.ReadFile(pidPath) if err == nil { pid, err := strconv.Atoi(strings.TrimSpace(string(data))) if err == nil && d.isProcessRunning(pid) { - // Verify it's actually Envoy by checking admin API if d.isAdminResponding() { return pid, true } } } - // Try admin API directly (might be running without PID file) + // Try admin API directly if d.isAdminResponding() { - // We don't know the PID, but it's running - // Try to find it via procfs - pid := d.findEnvoyPID() + pid := d.findCaddyPID() if pid > 0 { - // Update PID file os.WriteFile(pidPath, []byte(strconv.Itoa(pid)), 0644) return pid, true } @@ -208,14 +264,14 @@ func (d *EnvoyDaemon) DiscoverRunning() (int, bool) { return 0, false } -// IsRunning returns true if Envoy is currently running. -func (d *EnvoyDaemon) IsRunning() bool { +// IsRunning returns true if Caddy is currently running. +func (d *CaddyDaemon) IsRunning() bool { _, running := d.DiscoverRunning() return running } -// GetPID returns the PID of the running Envoy process, or 0 if not running. -func (d *EnvoyDaemon) GetPID() int { +// GetPID returns the PID of the running Caddy process, or 0 if not running. +func (d *CaddyDaemon) GetPID() int { pid, running := d.DiscoverRunning() if running { return pid @@ -224,43 +280,19 @@ func (d *EnvoyDaemon) GetPID() int { } // AdminURL returns the admin API URL. -func (d *EnvoyDaemon) AdminURL() string { +func (d *CaddyDaemon) AdminURL() string { return fmt.Sprintf("http://%s:%d", d.adminAddress, d.adminPort) } -// ValidateConfig validates an Envoy configuration file without starting Envoy. -// Returns nil if valid, or an error with details if invalid. -func (d *EnvoyDaemon) ValidateConfig(configPath string) error { - binaryPath, err := GetEnvoyBinaryPath(d.paths) - if err != nil { - return fmt.Errorf("get envoy binary: %w", err) - } - - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - cmd := exec.CommandContext(ctx, binaryPath, - "--mode", "validate", - "--config-path", configPath, - ) - - output, err := cmd.CombinedOutput() - if err != nil { - return fmt.Errorf("config validation failed: %w: %s", err, string(output)) - } - - return nil -} - // waitForAdmin waits for the admin API to become responsive. -func (d *EnvoyDaemon) waitForAdmin(ctx context.Context) error { - ticker := time.NewTicker(100 * time.Millisecond) +func (d *CaddyDaemon) waitForAdmin(ctx context.Context) error { + ticker := time.NewTicker(adminPollInterval) defer ticker.Stop() for { select { case <-ctx.Done(): - return fmt.Errorf("timeout waiting for envoy admin API") + return fmt.Errorf("timeout waiting for caddy admin API") case <-ticker.C: if d.isAdminResponding() { return nil @@ -270,36 +302,38 @@ func (d *EnvoyDaemon) waitForAdmin(ctx context.Context) error { } // isAdminResponding checks if the admin API is responding. -func (d *EnvoyDaemon) isAdminResponding() bool { +func (d *CaddyDaemon) isAdminResponding() bool { client := &http.Client{Timeout: 1 * time.Second} - adminURL := fmt.Sprintf("http://%s:%d/ready", d.adminAddress, d.adminPort) + adminURL := fmt.Sprintf("http://%s:%d/config/", d.adminAddress, d.adminPort) resp, err := client.Get(adminURL) if err != nil { return false } resp.Body.Close() + // Caddy returns 200 for /config/ when running return resp.StatusCode == http.StatusOK } // isProcessRunning checks if a process with the given PID is running. -func (d *EnvoyDaemon) isProcessRunning(pid int) bool { +func (d *CaddyDaemon) isProcessRunning(pid int) bool { proc, err := os.FindProcess(pid) if err != nil { return false } - // On Unix, FindProcess always succeeds. Use signal 0 to check if process exists. err = proc.Signal(syscall.Signal(0)) return err == nil } -// findEnvoyPID tries to find the Envoy process PID by scanning /proc. -func (d *EnvoyDaemon) findEnvoyPID() int { +// findCaddyPID tries to find the Caddy process PID by scanning /proc. +// It matches both the binary name and our specific config path to avoid +// colliding with other Caddy instances or other hypeman instances on the same server. +func (d *CaddyDaemon) findCaddyPID() int { entries, err := os.ReadDir("/proc") if err != nil { return 0 } - configPath := d.paths.EnvoyConfig() + configPath := d.paths.CaddyConfig() for _, entry := range entries { if !entry.IsDir() { continue @@ -309,15 +343,14 @@ func (d *EnvoyDaemon) findEnvoyPID() int { continue } - // Read cmdline cmdline, err := os.ReadFile(fmt.Sprintf("/proc/%d/cmdline", pid)) if err != nil { continue } - // Check if it's envoy with our config path cmdStr := string(cmdline) - if strings.Contains(cmdStr, "envoy") && strings.Contains(cmdStr, configPath) { + // Match caddy run command with our specific config path + if strings.Contains(cmdStr, "caddy") && strings.Contains(cmdStr, "run") && strings.Contains(cmdStr, configPath) { return pid } } diff --git a/lib/ingress/errors.go b/lib/ingress/errors.go index 3d048c83..3814dd09 100644 --- a/lib/ingress/errors.go +++ b/lib/ingress/errors.go @@ -1,6 +1,11 @@ package ingress -import "errors" +import ( + "errors" + "fmt" + "regexp" + "strings" +) // Common errors returned by the ingress package. var ( @@ -19,13 +24,35 @@ var ( // ErrInstanceNoNetwork is returned when the target instance has no network. ErrInstanceNoNetwork = errors.New("target instance has no network configured") - // ErrEnvoyNotRunning is returned when Envoy is not running. - ErrEnvoyNotRunning = errors.New("envoy is not running") - // ErrHostnameInUse is returned when a hostname is already in use by another ingress. ErrHostnameInUse = errors.New("hostname already in use by another ingress") - // ErrConfigValidationFailed is returned when Envoy config validation fails. - // This indicates a server-side bug since input validation should catch user errors. - ErrConfigValidationFailed = errors.New("internal error: config validation failed") + // ErrConfigValidationFailed is returned when Caddy config validation fails. + // This indicates the config was rejected by Caddy's admin API. + ErrConfigValidationFailed = errors.New("config validation failed") + + // ErrPortInUse is returned when the requested port is already in use by another process. + ErrPortInUse = errors.New("port already in use") + + // ErrDomainNotAllowed is returned when a TLS ingress is requested for a domain not in the allowed list. + ErrDomainNotAllowed = errors.New("domain not allowed for TLS") + + // ErrAmbiguousName is returned when a lookup matches multiple ingresses. + ErrAmbiguousName = errors.New("ambiguous ingress identifier matches multiple ingresses") ) + +// portInUseRegex matches Caddy's "address already in use" error messages +var portInUseRegex = regexp.MustCompile(`listen tcp [^:]+:(\d+): bind: address already in use`) + +// ParseCaddyError parses a Caddy error response and returns a more specific error if possible. +func ParseCaddyError(caddyError string) error { + // Check for "address already in use" errors + if strings.Contains(caddyError, "address already in use") { + if matches := portInUseRegex.FindStringSubmatch(caddyError); len(matches) > 1 { + return fmt.Errorf("%w: port %s is already bound by another process", ErrPortInUse, matches[1]) + } + return fmt.Errorf("%w: address is already bound by another process", ErrPortInUse) + } + + return nil +} diff --git a/lib/ingress/errors_test.go b/lib/ingress/errors_test.go new file mode 100644 index 00000000..f031068c --- /dev/null +++ b/lib/ingress/errors_test.go @@ -0,0 +1,65 @@ +package ingress + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseCaddyError(t *testing.T) { + tests := []struct { + name string + caddyError string + wantErr error + wantContain string + }{ + { + name: "address already in use with port", + caddyError: `{"error":"loading config: loading new config: http app module: start: listening on 0.0.0.0:8080: listen tcp 0.0.0.0:8080: bind: address already in use"}`, + wantErr: ErrPortInUse, + wantContain: "port 8080", + }, + { + name: "address already in use different port", + caddyError: `listen tcp 0.0.0.0:443: bind: address already in use`, + wantErr: ErrPortInUse, + wantContain: "port 443", + }, + { + name: "address already in use generic", + caddyError: `address already in use`, + wantErr: ErrPortInUse, + wantContain: "already bound", + }, + { + name: "unrelated error", + caddyError: `{"error":"some other caddy error"}`, + wantErr: nil, + }, + { + name: "empty error", + caddyError: "", + wantErr: nil, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := ParseCaddyError(tc.caddyError) + + if tc.wantErr == nil { + assert.Nil(t, err) + return + } + + require.NotNil(t, err) + assert.True(t, errors.Is(err, tc.wantErr), "expected error to wrap %v, got %v", tc.wantErr, err) + + if tc.wantContain != "" { + assert.Contains(t, err.Error(), tc.wantContain) + } + }) + } +} diff --git a/lib/ingress/logs.go b/lib/ingress/logs.go new file mode 100644 index 00000000..f5f50787 --- /dev/null +++ b/lib/ingress/logs.go @@ -0,0 +1,139 @@ +package ingress + +import ( + "bufio" + "context" + "encoding/json" + "log/slog" + "os/exec" + "strings" + "sync" + "time" + + "github.com/onkernel/hypeman/lib/paths" +) + +// CaddyLogForwarder tails Caddy's system log and forwards to OTEL. +type CaddyLogForwarder struct { + paths *paths.Paths + logger *slog.Logger + cmd *exec.Cmd + cancel context.CancelFunc + wg sync.WaitGroup +} + +// NewCaddyLogForwarder creates a new log forwarder. +func NewCaddyLogForwarder(p *paths.Paths, logger *slog.Logger) *CaddyLogForwarder { + return &CaddyLogForwarder{ + paths: p, + logger: logger, + } +} + +// Start begins tailing Caddy's log file and forwarding to OTEL. +func (f *CaddyLogForwarder) Start(ctx context.Context) error { + ctx, f.cancel = context.WithCancel(ctx) + + // Caddy writes JSON logs to stderr, which daemon.go redirects to CaddyLogFile + logPath := f.paths.CaddyLogFile() + + // Use tail -F (capital F) to follow file even if it's recreated + f.cmd = exec.CommandContext(ctx, "tail", "-F", "-n", "0", logPath) + + stdout, err := f.cmd.StdoutPipe() + if err != nil { + return err + } + + if err := f.cmd.Start(); err != nil { + return err + } + + f.wg.Add(1) + go func() { + defer f.wg.Done() + scanner := bufio.NewScanner(stdout) + for scanner.Scan() { + line := scanner.Text() + f.forwardLogLine(ctx, line) + } + }() + + return nil +} + +// Stop stops the log forwarder. +func (f *CaddyLogForwarder) Stop() { + if f.cancel != nil { + f.cancel() + } + if f.cmd != nil && f.cmd.Process != nil { + if err := f.cmd.Process.Kill(); err != nil && f.logger != nil { + f.logger.Debug("failed to kill tail process", "error", err) + } + } + f.wg.Wait() +} + +// caddyLogEntry represents a parsed Caddy JSON log entry. +type caddyLogEntry struct { + Level string `json:"level"` + TS float64 `json:"ts"` + Logger string `json:"logger"` + Msg string `json:"msg"` + Error string `json:"error,omitempty"` + Module string `json:"module,omitempty"` + Adapter string `json:"adapter,omitempty"` +} + +// forwardLogLine parses a JSON log line and forwards to OTEL logger. +func (f *CaddyLogForwarder) forwardLogLine(ctx context.Context, line string) { + if f.logger == nil || line == "" { + return + } + + // Skip non-JSON lines (tail might output some status messages) + if !strings.HasPrefix(line, "{") { + return + } + + var entry caddyLogEntry + if err := json.Unmarshal([]byte(line), &entry); err != nil { + // If we can't parse, log raw line at info level + f.logger.InfoContext(ctx, "caddy: "+line) + return + } + + // Convert timestamp + ts := time.Unix(int64(entry.TS), int64((entry.TS-float64(int64(entry.TS)))*1e9)) + + // Build attributes + attrs := []any{ + "caddy_logger", entry.Logger, + "caddy_ts", ts.Format(time.RFC3339Nano), + } + if entry.Module != "" { + attrs = append(attrs, "module", entry.Module) + } + if entry.Adapter != "" { + attrs = append(attrs, "adapter", entry.Adapter) + } + if entry.Error != "" { + attrs = append(attrs, "error", entry.Error) + } + + // Forward with appropriate level + msg := "caddy: " + entry.Msg + switch strings.ToLower(entry.Level) { + case "debug": + f.logger.DebugContext(ctx, msg, attrs...) + case "info": + f.logger.InfoContext(ctx, msg, attrs...) + case "warn": + f.logger.WarnContext(ctx, msg, attrs...) + case "error", "fatal", "panic": + f.logger.ErrorContext(ctx, msg, attrs...) + default: + f.logger.InfoContext(ctx, msg, attrs...) + } +} diff --git a/lib/ingress/manager.go b/lib/ingress/manager.go index 7b63cb35..49b17674 100644 --- a/lib/ingress/manager.go +++ b/lib/ingress/manager.go @@ -3,11 +3,15 @@ package ingress import ( "context" "fmt" + "log/slog" "regexp" + "slices" + "strings" "sync" "time" "github.com/nrednav/cuid2" + "github.com/onkernel/hypeman/lib/dns" "github.com/onkernel/hypeman/lib/logger" "github.com/onkernel/hypeman/lib/paths" ) @@ -33,61 +37,47 @@ type Manager interface { // Create creates a new ingress resource. Create(ctx context.Context, req CreateIngressRequest) (*Ingress, error) - // Get retrieves an ingress by ID or name. + // Get retrieves an ingress by ID, name, or ID prefix. + // Lookup order: exact ID match -> exact name match -> ID prefix match. + // Returns ErrAmbiguousName if prefix matches multiple ingresses. Get(ctx context.Context, idOrName string) (*Ingress, error) // List returns all ingress resources. List(ctx context.Context) ([]Ingress, error) - // Delete removes an ingress resource. + // Delete removes an ingress resource by ID, name, or ID prefix. + // Lookup order: exact ID match -> exact name match -> ID prefix match. + // Returns ErrAmbiguousName if prefix matches multiple ingresses. Delete(ctx context.Context, idOrName string) error // Shutdown gracefully stops the ingress subsystem. Shutdown() error } +// DefaultDNSPort is the default port for the internal DNS server. +const DefaultDNSPort = dns.DefaultPort + // Config holds configuration for the ingress manager. type Config struct { - // ListenAddress is the address Envoy should listen on (default: 0.0.0.0). + // ListenAddress is the address Caddy should listen on (default: 0.0.0.0). ListenAddress string - // AdminAddress is the address for Envoy admin API (default: 127.0.0.1). + // AdminAddress is the address for Caddy admin API (default: 127.0.0.1). AdminAddress string - // AdminPort is the port for Envoy admin API (default: 9901). + // AdminPort is the port for Caddy admin API (default: 2019). AdminPort int - // StopOnShutdown determines whether to stop Envoy when hypeman shuts down (default: false). - // When false, Envoy continues running independently. - StopOnShutdown bool - - // DisableValidation disables Envoy config validation before applying. - // This should only be used for testing. - DisableValidation bool - - // OTEL configuration for Envoy tracing - OTEL OTELConfig -} - -// OTELConfig holds OpenTelemetry configuration for Envoy. -type OTELConfig struct { - // Enabled controls whether OTEL tracing is enabled in Envoy. - Enabled bool - - // Endpoint is the OTEL collector gRPC endpoint (host:port). - Endpoint string - - // ServiceName is the service name for traces (default: "hypeman-envoy"). - ServiceName string + // DNSPort is the port for the internal DNS server used for dynamic upstream resolution. + // Default: 5353. Set to 0 to use a random available port. + DNSPort int - // ServiceInstanceID is the service instance identifier. - ServiceInstanceID string - - // Insecure disables TLS for OTEL connections. - Insecure bool + // StopOnShutdown determines whether to stop Caddy when hypeman shuts down (default: false). + // When false, Caddy continues running independently. + StopOnShutdown bool - // Environment is the deployment environment (e.g., dev, staging, prod). - Environment string + // ACME configuration for TLS certificates + ACME ACMEConfig } // DefaultConfig returns the default ingress configuration. @@ -95,7 +85,8 @@ func DefaultConfig() Config { return Config{ ListenAddress: "0.0.0.0", AdminAddress: "127.0.0.1", - AdminPort: 9901, + AdminPort: 2019, + DNSPort: dns.DefaultPort, StopOnShutdown: false, } } @@ -104,27 +95,48 @@ type manager struct { paths *paths.Paths config Config instanceResolver InstanceResolver - daemon *EnvoyDaemon - configGenerator *EnvoyConfigGenerator + daemon *CaddyDaemon + configGenerator *CaddyConfigGenerator + logForwarder *CaddyLogForwarder + dnsServer *dns.Server mu sync.RWMutex } // NewManager creates a new ingress manager. -func NewManager(p *paths.Paths, config Config, instanceResolver InstanceResolver) Manager { - daemon := NewEnvoyDaemon(p, config.AdminAddress, config.AdminPort, config.StopOnShutdown) - - // Use daemon as validator unless validation is disabled - var validator ConfigValidator - if !config.DisableValidation { - validator = daemon +// If otelLogger is non-nil, Caddy system logs will be forwarded to OTEL. +func NewManager(p *paths.Paths, config Config, instanceResolver InstanceResolver, otelLogger *slog.Logger) Manager { + daemon := NewCaddyDaemon(p, config.AdminAddress, config.AdminPort, config.StopOnShutdown) + + // Create log forwarder if OTEL logger is provided + var logForwarder *CaddyLogForwarder + if otelLogger != nil { + logForwarder = NewCaddyLogForwarder(p, otelLogger) } + // Create DNS server for instance resolution + // The InstanceResolver interface is compatible with dns.InstanceResolver + dnsServer := dns.NewServer(instanceResolver, config.DNSPort, otelLogger) + + // Create config generator with initial DNS port + // Note: If DNSPort was 0 (random), the actual port is determined in Initialize() + // after the DNS server starts. The config generator is recreated there with the actual port. + configGenerator := NewCaddyConfigGenerator( + p, + config.ListenAddress, + config.AdminAddress, + config.AdminPort, + config.ACME, + dnsServer.Port(), + ) + return &manager{ paths: p, config: config, instanceResolver: instanceResolver, daemon: daemon, - configGenerator: NewEnvoyConfigGenerator(p, config.ListenAddress, config.AdminAddress, config.AdminPort, validator, config.OTEL), + configGenerator: configGenerator, + logForwarder: logForwarder, + dnsServer: dnsServer, } } @@ -133,21 +145,65 @@ func (m *manager) Initialize(ctx context.Context) error { m.mu.Lock() defer m.mu.Unlock() - // Load existing ingresses and regenerate config + log := logger.FromContext(ctx) + + // Start DNS server for instance resolution + if err := m.dnsServer.Start(ctx); err != nil { + return fmt.Errorf("start DNS server: %w", err) + } + + // Create config generator now that DNS server is running and we know the actual port + // (important when DNSPort was configured as 0 for random port) + m.configGenerator = NewCaddyConfigGenerator( + m.paths, + m.config.ListenAddress, + m.config.AdminAddress, + m.config.AdminPort, + m.config.ACME, + m.dnsServer.Port(), + ) + + // Load existing ingresses ingresses, err := m.loadAllIngresses() if err != nil { return fmt.Errorf("load ingresses: %w", err) } + // Check if any TLS ingresses exist but TLS isn't configured + if HasTLSRules(ingresses) && !m.config.ACME.IsTLSConfigured() { + log.WarnContext(ctx, "TLS ingresses exist but ACME is not configured - TLS will not work") + } + + // Check if any TLS ingresses have hostnames not in the allowed domains list + for _, ing := range ingresses { + for _, rule := range ing.Rules { + if rule.TLS && !m.config.ACME.IsDomainAllowed(rule.Match.Hostname) { + log.WarnContext(ctx, "existing TLS ingress has hostname not in allowed domains list", + "ingress", ing.Name, + "hostname", rule.Match.Hostname, + "allowed_domains", m.config.ACME.AllowedDomains, + ) + } + } + } + // Generate and write config if err := m.regenerateConfig(ctx, ingresses); err != nil { return fmt.Errorf("regenerate config: %w", err) } - // Start Envoy daemon + // Start Caddy daemon _, err = m.daemon.Start(ctx) if err != nil { - return fmt.Errorf("start envoy: %w", err) + return fmt.Errorf("start caddy: %w", err) + } + + // Start log forwarder (if configured) to forward Caddy system logs to OTEL + if m.logForwarder != nil { + if err := m.logForwarder.Start(ctx); err != nil { + log.WarnContext(ctx, "failed to start caddy log forwarder", "error", err) + // Non-fatal - continue without log forwarding + } } return nil @@ -158,6 +214,8 @@ func (m *manager) Create(ctx context.Context, req CreateIngressRequest) (*Ingres m.mu.Lock() defer m.mu.Unlock() + log := logger.FromContext(ctx) + // Validate request if err := req.Validate(); err != nil { return nil, fmt.Errorf("%w: %v", ErrInvalidRequest, err) @@ -173,15 +231,42 @@ func (m *manager) Create(ctx context.Context, req CreateIngressRequest) (*Ingres return nil, fmt.Errorf("%w: ingress with name %q already exists", ErrAlreadyExists, req.Name) } - // Validate that all target instances exist + // Check if TLS is requested but ACME isn't configured, and validate allowed domains for _, rule := range req.Rules { - exists, err := m.instanceResolver.InstanceExists(ctx, rule.Target.Instance) - if err != nil { - return nil, fmt.Errorf("check instance %q: %w", rule.Target.Instance, err) + if rule.TLS { + if !m.config.ACME.IsTLSConfigured() { + return nil, fmt.Errorf("%w: TLS requested but ACME is not configured (set ACME_EMAIL and ACME_DNS_PROVIDER)", ErrInvalidRequest) + } + // Check if domain is in the allowed list + // For pattern hostnames, check the wildcard pattern (e.g., "*.example.com") + domainToCheck := rule.Match.Hostname + if rule.Match.IsPattern() { + pattern, err := rule.Match.ParsePattern() + if err != nil { + return nil, fmt.Errorf("invalid hostname pattern: %w", err) + } + domainToCheck = pattern.Wildcard + } + if !m.config.ACME.IsDomainAllowed(domainToCheck) { + return nil, fmt.Errorf("%w: %q is not in TLS_ALLOWED_DOMAINS (allowed: %s)", ErrDomainNotAllowed, domainToCheck, m.config.ACME.AllowedDomains) + } } - if !exists { - return nil, fmt.Errorf("%w: instance %q not found", ErrInstanceNotFound, rule.Target.Instance) + } + + // Validate that all target instances exist (only for literal hostnames) + // Pattern hostnames have dynamic target instances that can't be validated at creation time + for _, rule := range req.Rules { + if !rule.Match.IsPattern() { + // Literal hostname - validate instance exists + exists, err := m.instanceResolver.InstanceExists(ctx, rule.Target.Instance) + if err != nil { + return nil, fmt.Errorf("check instance %q: %w", rule.Target.Instance, err) + } + if !exists { + return nil, fmt.Errorf("%w: instance %q not found", ErrInstanceNotFound, rule.Target.Instance) + } } + // For pattern hostnames, instance validation happens at request time via the upstream resolver } // Check for hostname conflicts (hostname + port must be unique) @@ -213,7 +298,24 @@ func (m *manager) Create(ctx context.Context, req CreateIngressRequest) (*Ingres CreatedAt: time.Now().UTC(), } - // Save to storage + // Generate config with the new ingress included + // Use slices.Concat to avoid modifying the existingIngresses slice + allIngresses := slices.Concat(existingIngresses, []Ingress{ingress}) + + configData, err := m.configGenerator.GenerateConfig(ctx, allIngresses) + if err != nil { + return nil, fmt.Errorf("generate config: %w", err) + } + + // Apply config to Caddy - this validates and applies atomically + // If Caddy rejects the config, we don't persist the ingress + if m.daemon.IsRunning() { + if err := m.daemon.ReloadConfig(configData); err != nil { + return nil, fmt.Errorf("%w: %v", ErrConfigValidationFailed, err) + } + } + + // Config accepted - save ingress to storage stored := &storedIngress{ ID: ingress.ID, Name: ingress.Name, @@ -225,46 +327,71 @@ func (m *manager) Create(ctx context.Context, req CreateIngressRequest) (*Ingres return nil, fmt.Errorf("save ingress: %w", err) } - // Regenerate Envoy config with new ingress - allIngresses := append(existingIngresses, ingress) - if err := m.regenerateConfig(ctx, allIngresses); err != nil { + // Write config to disk (for Caddy restarts) + if err := m.configGenerator.WriteConfig(ctx, allIngresses); err != nil { // Try to clean up the saved ingress deleteIngressData(m.paths, id) - return nil, fmt.Errorf("regenerate config: %w", err) - } - - // Reload Envoy - if m.daemon.IsRunning() { - if err := m.daemon.ReloadConfig(); err != nil { - log := logger.FromContext(ctx) - log.ErrorContext(ctx, "failed to reload envoy config after create", "error", err) - // Try to clean up the saved ingress since reload failed - deleteIngressData(m.paths, id) - return nil, ErrConfigValidationFailed - } + log.ErrorContext(ctx, "failed to write config after create", "error", err) + return nil, fmt.Errorf("write config: %w", err) } return &ingress, nil } -// Get retrieves an ingress by ID or name. +// Get retrieves an ingress by ID, name, or ID prefix. +// Lookup order: exact ID match -> exact name match -> ID prefix match. +// Returns ErrAmbiguousName if prefix matches multiple ingresses. func (m *manager) Get(ctx context.Context, idOrName string) (*Ingress, error) { m.mu.RLock() defer m.mu.RUnlock() - // Try by ID first + return m.resolveIngress(idOrName) +} + +// resolveIngress finds an ingress by ID, name, or ID prefix. +// Must be called with at least a read lock held. +func (m *manager) resolveIngress(idOrName string) (*Ingress, error) { + // 1. Try exact ID match first (most common case) stored, err := loadIngress(m.paths, idOrName) if err == nil { return storedToIngress(stored), nil } - // Try by name - stored, err = findIngressByName(m.paths, idOrName) + // 2. Load all ingresses for name and prefix matching + allIngresses, err := loadAllIngresses(m.paths) if err != nil { - return nil, ErrNotFound + return nil, err + } + + // 3. Try exact name match + var nameMatches []storedIngress + for _, ing := range allIngresses { + if ing.Name == idOrName { + nameMatches = append(nameMatches, ing) + } + } + if len(nameMatches) == 1 { + return storedToIngress(&nameMatches[0]), nil + } + if len(nameMatches) > 1 { + return nil, ErrAmbiguousName + } + + // 4. Try ID prefix match + var prefixMatches []storedIngress + for _, ing := range allIngresses { + if len(idOrName) > 0 && strings.HasPrefix(ing.ID, idOrName) { + prefixMatches = append(prefixMatches, ing) + } + } + if len(prefixMatches) == 1 { + return storedToIngress(&prefixMatches[0]), nil + } + if len(prefixMatches) > 1 { + return nil, ErrAmbiguousName } - return storedToIngress(stored), nil + return nil, ErrNotFound } // List returns all ingress resources. @@ -275,24 +402,19 @@ func (m *manager) List(ctx context.Context) ([]Ingress, error) { return m.loadAllIngresses() } -// Delete removes an ingress resource. +// Delete removes an ingress resource by ID, name, or ID prefix. func (m *manager) Delete(ctx context.Context, idOrName string) error { m.mu.Lock() defer m.mu.Unlock() - // Find the ingress - var id string - stored, err := loadIngress(m.paths, idOrName) - if err == nil { - id = stored.ID - } else { - // Try by name - stored, err = findIngressByName(m.paths, idOrName) - if err != nil { - return ErrNotFound - } - id = stored.ID + log := logger.FromContext(ctx) + + // Find the ingress using ID/name/prefix resolution + ingress, err := m.resolveIngress(idOrName) + if err != nil { + return err } + id := ingress.ID // Delete from storage if err := deleteIngressData(m.paths, id); err != nil { @@ -305,19 +427,25 @@ func (m *manager) Delete(ctx context.Context, idOrName string) error { return fmt.Errorf("load ingresses: %w", err) } - if err := m.regenerateConfig(ctx, ingresses); err != nil { - return fmt.Errorf("regenerate config: %w", err) + // Generate and validate new config + configData, err := m.configGenerator.GenerateConfig(ctx, ingresses) + if err != nil { + return fmt.Errorf("generate config: %w", err) } - // Reload Envoy + // Apply new config if m.daemon.IsRunning() { - if err := m.daemon.ReloadConfig(); err != nil { - log := logger.FromContext(ctx) - log.ErrorContext(ctx, "failed to reload envoy config after delete", "error", err) + if err := m.daemon.ReloadConfig(configData); err != nil { + log.ErrorContext(ctx, "failed to reload caddy config after delete", "error", err) return ErrConfigValidationFailed } } + // Write config to disk + if err := m.configGenerator.WriteConfig(ctx, ingresses); err != nil { + log.ErrorContext(ctx, "failed to write config after delete", "error", err) + } + return nil } @@ -326,7 +454,20 @@ func (m *manager) Shutdown() error { m.mu.Lock() defer m.mu.Unlock() - // Only stop Envoy if configured to do so + // Stop log forwarder + if m.logForwarder != nil { + m.logForwarder.Stop() + } + + // Stop DNS server + if m.dnsServer != nil { + if err := m.dnsServer.Stop(); err != nil { + // Log but don't fail - continue with shutdown + slog.Warn("failed to stop DNS server", "error", err) + } + } + + // Only stop Caddy if configured to do so if m.daemon.StopOnShutdown() { return m.daemon.Stop() } @@ -349,13 +490,9 @@ func (m *manager) loadAllIngresses() ([]Ingress, error) { return ingresses, nil } -// regenerateConfig regenerates the Envoy config file from the given ingresses. +// regenerateConfig regenerates the Caddy config file from the given ingresses. func (m *manager) regenerateConfig(ctx context.Context, ingresses []Ingress) error { - ipResolver := func(instance string) (string, error) { - return m.instanceResolver.ResolveInstanceIP(ctx, instance) - } - - return m.configGenerator.WriteConfig(ctx, ingresses, ipResolver) + return m.configGenerator.WriteConfig(ctx, ingresses) } // storedToIngress converts a storedIngress to an Ingress. diff --git a/lib/ingress/manager_test.go b/lib/ingress/manager_test.go index 88694efb..dd4c723b 100644 --- a/lib/ingress/manager_test.go +++ b/lib/ingress/manager_test.go @@ -49,7 +49,8 @@ func setupTestManager(t *testing.T) (Manager, *mockInstanceResolver, *paths.Path p := paths.New(tmpDir) // Create required directories - require.NoError(t, os.MkdirAll(p.EnvoyDir(), 0755)) + require.NoError(t, os.MkdirAll(p.CaddyDir(), 0755)) + require.NoError(t, os.MkdirAll(p.CaddyDataDir(), 0755)) require.NoError(t, os.MkdirAll(p.IngressesDir(), 0755)) resolver := newMockResolver() @@ -57,13 +58,16 @@ func setupTestManager(t *testing.T) (Manager, *mockInstanceResolver, *paths.Path resolver.AddInstance("web-app", "10.100.0.20") config := Config{ - ListenAddress: "0.0.0.0", - AdminAddress: "127.0.0.1", - AdminPort: 19901, // Use different port for testing - DisableValidation: true, // No Envoy binary available in tests + ListenAddress: "0.0.0.0", + AdminAddress: "127.0.0.1", + AdminPort: 12019, // Use different port for testing + DNSPort: 0, // Use random port for testing to avoid conflicts + StopOnShutdown: true, + // Empty ACME config - TLS not configured for basic tests } - manager := NewManager(p, config, resolver) + // Pass nil for otelLogger - no log forwarding in tests + manager := NewManager(p, config, resolver, nil) cleanup := func() { os.RemoveAll(tmpDir) @@ -584,6 +588,36 @@ func TestCreateIngressRequest_Validate(t *testing.T) { }, wantErr: false, }, + { + name: "wildcard hostname not supported", + req: CreateIngressRequest{ + Name: "valid", + Rules: []IngressRule{ + {Match: IngressMatch{Hostname: "*.example.com"}, Target: IngressTarget{Instance: "my-api", Port: 8080}}, + }, + }, + wantErr: true, + }, + { + name: "redirect_http without tls", + req: CreateIngressRequest{ + Name: "valid", + Rules: []IngressRule{ + {Match: IngressMatch{Hostname: "test.example.com"}, Target: IngressTarget{Instance: "my-api", Port: 8080}, RedirectHTTP: true, TLS: false}, + }, + }, + wantErr: true, + }, + { + name: "valid tls with redirect", + req: CreateIngressRequest{ + Name: "valid", + Rules: []IngressRule{ + {Match: IngressMatch{Hostname: "test.example.com", Port: 443}, Target: IngressTarget{Instance: "my-api", Port: 8080}, TLS: true, RedirectHTTP: true}, + }, + }, + wantErr: false, + }, } for _, tc := range tests { @@ -597,3 +631,210 @@ func TestCreateIngressRequest_Validate(t *testing.T) { }) } } + +func TestCreateIngress_TLSWithoutACME(t *testing.T) { + // Setup manager without ACME configured + manager, _, _, cleanup := setupTestManager(t) + defer cleanup() + ctx := context.Background() + + // Try to create TLS ingress without ACME config + req := CreateIngressRequest{ + Name: "tls-ingress", + Rules: []IngressRule{ + { + Match: IngressMatch{Hostname: "secure.example.com", Port: 443}, + Target: IngressTarget{Instance: "my-api", Port: 8080}, + TLS: true, + }, + }, + } + + _, err := manager.Create(ctx, req) + assert.Error(t, err) + assert.ErrorIs(t, err, ErrInvalidRequest) + assert.Contains(t, err.Error(), "ACME is not configured") +} + +func TestGetIngress_Resolution(t *testing.T) { + // Create temp dir + tmpDir, err := os.MkdirTemp("", "ingress-resolution-test-*") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + p := paths.New(tmpDir) + require.NoError(t, os.MkdirAll(p.IngressesDir(), 0755)) + require.NoError(t, os.MkdirAll(p.CaddyDir(), 0755)) + require.NoError(t, os.MkdirAll(p.CaddyDataDir(), 0755)) + + ctx := context.Background() + + // Directly save some test ingresses to storage + ingress1 := &storedIngress{ + ID: "abc123def456", + Name: "api-ingress", + Rules: []IngressRule{{Match: IngressMatch{Hostname: "api.example.com", Port: 80}, Target: IngressTarget{Instance: "api", Port: 8080}}}, + CreatedAt: time.Now().Format(time.RFC3339), + } + ingress2 := &storedIngress{ + ID: "abc789xyz123", + Name: "web-ingress", + Rules: []IngressRule{{Match: IngressMatch{Hostname: "web.example.com", Port: 80}, Target: IngressTarget{Instance: "web", Port: 8080}}}, + CreatedAt: time.Now().Format(time.RFC3339), + } + ingress3 := &storedIngress{ + ID: "xyz999aaa111", + Name: "admin-ingress", + Rules: []IngressRule{{Match: IngressMatch{Hostname: "admin.example.com", Port: 80}, Target: IngressTarget{Instance: "admin", Port: 8080}}}, + CreatedAt: time.Now().Format(time.RFC3339), + } + + require.NoError(t, saveIngress(p, ingress1)) + require.NoError(t, saveIngress(p, ingress2)) + require.NoError(t, saveIngress(p, ingress3)) + + resolver := newMockResolver() + config := Config{ + ListenAddress: "0.0.0.0", + AdminAddress: "127.0.0.1", + AdminPort: 12019, + DNSPort: 0, // Use random port for testing + StopOnShutdown: true, + } + manager := NewManager(p, config, resolver, nil) + + t.Run("exact ID match", func(t *testing.T) { + ing, err := manager.Get(ctx, "abc123def456") + require.NoError(t, err) + assert.Equal(t, "abc123def456", ing.ID) + assert.Equal(t, "api-ingress", ing.Name) + }) + + t.Run("exact name match", func(t *testing.T) { + ing, err := manager.Get(ctx, "web-ingress") + require.NoError(t, err) + assert.Equal(t, "abc789xyz123", ing.ID) + assert.Equal(t, "web-ingress", ing.Name) + }) + + t.Run("unique ID prefix match", func(t *testing.T) { + ing, err := manager.Get(ctx, "xyz") + require.NoError(t, err) + assert.Equal(t, "xyz999aaa111", ing.ID) + assert.Equal(t, "admin-ingress", ing.Name) + }) + + t.Run("longer unique ID prefix", func(t *testing.T) { + ing, err := manager.Get(ctx, "abc123") + require.NoError(t, err) + assert.Equal(t, "abc123def456", ing.ID) + }) + + t.Run("ambiguous ID prefix", func(t *testing.T) { + // "abc" matches both abc123def456 and abc789xyz123 + _, err := manager.Get(ctx, "abc") + assert.ErrorIs(t, err, ErrAmbiguousName) + }) + + t.Run("not found", func(t *testing.T) { + _, err := manager.Get(ctx, "nonexistent") + assert.ErrorIs(t, err, ErrNotFound) + }) + + t.Run("ID takes precedence over name prefix", func(t *testing.T) { + // If we have an exact ID match, it should be returned even if + // there's another ingress with a name starting with the same prefix + ing, err := manager.Get(ctx, "abc123def456") + require.NoError(t, err) + assert.Equal(t, "abc123def456", ing.ID) + }) +} + +func TestDeleteIngress_Resolution(t *testing.T) { + // Create temp dir + tmpDir, err := os.MkdirTemp("", "ingress-delete-resolution-test-*") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + p := paths.New(tmpDir) + require.NoError(t, os.MkdirAll(p.IngressesDir(), 0755)) + require.NoError(t, os.MkdirAll(p.CaddyDir(), 0755)) + require.NoError(t, os.MkdirAll(p.CaddyDataDir(), 0755)) + + ctx := context.Background() + + resolver := newMockResolver() + config := Config{ + ListenAddress: "0.0.0.0", + AdminAddress: "127.0.0.1", + AdminPort: 12019, + DNSPort: 0, // Use random port for testing + StopOnShutdown: true, + } + + t.Run("delete by name", func(t *testing.T) { + // Save test ingress + ingress := &storedIngress{ + ID: "del123abc", + Name: "delete-by-name", + Rules: []IngressRule{{Match: IngressMatch{Hostname: "test.example.com", Port: 80}, Target: IngressTarget{Instance: "api", Port: 8080}}}, + CreatedAt: time.Now().Format(time.RFC3339), + } + require.NoError(t, saveIngress(p, ingress)) + + manager := NewManager(p, config, resolver, nil) + err := manager.Delete(ctx, "delete-by-name") + require.NoError(t, err) + + // Verify it's gone + _, err = manager.Get(ctx, "del123abc") + assert.ErrorIs(t, err, ErrNotFound) + }) + + t.Run("delete by ID prefix", func(t *testing.T) { + // Save test ingress + ingress := &storedIngress{ + ID: "unique999prefix", + Name: "prefix-delete-test", + Rules: []IngressRule{{Match: IngressMatch{Hostname: "test2.example.com", Port: 80}, Target: IngressTarget{Instance: "api", Port: 8080}}}, + CreatedAt: time.Now().Format(time.RFC3339), + } + require.NoError(t, saveIngress(p, ingress)) + + manager := NewManager(p, config, resolver, nil) + err := manager.Delete(ctx, "unique999") + require.NoError(t, err) + + // Verify it's gone + _, err = manager.Get(ctx, "unique999prefix") + assert.ErrorIs(t, err, ErrNotFound) + }) + + t.Run("delete ambiguous prefix fails", func(t *testing.T) { + // Save two ingresses with similar IDs + ingress1 := &storedIngress{ + ID: "ambig111aaa", + Name: "ambig-test-1", + Rules: []IngressRule{{Match: IngressMatch{Hostname: "ambig1.example.com", Port: 80}, Target: IngressTarget{Instance: "api", Port: 8080}}}, + CreatedAt: time.Now().Format(time.RFC3339), + } + ingress2 := &storedIngress{ + ID: "ambig111bbb", + Name: "ambig-test-2", + Rules: []IngressRule{{Match: IngressMatch{Hostname: "ambig2.example.com", Port: 80}, Target: IngressTarget{Instance: "api", Port: 8080}}}, + CreatedAt: time.Now().Format(time.RFC3339), + } + require.NoError(t, saveIngress(p, ingress1)) + require.NoError(t, saveIngress(p, ingress2)) + + manager := NewManager(p, config, resolver, nil) + err := manager.Delete(ctx, "ambig111") + assert.ErrorIs(t, err, ErrAmbiguousName) + + // Both should still exist + _, err = manager.Get(ctx, "ambig111aaa") + require.NoError(t, err) + _, err = manager.Get(ctx, "ambig111bbb") + require.NoError(t, err) + }) +} diff --git a/lib/ingress/types.go b/lib/ingress/types.go index 1d50fb3e..f13f0985 100644 --- a/lib/ingress/types.go +++ b/lib/ingress/types.go @@ -1,7 +1,10 @@ package ingress import ( + "fmt" + "regexp" "strconv" + "strings" "time" ) @@ -28,11 +31,21 @@ type IngressRule struct { // Target specifies where matching requests should be routed. Target IngressTarget `json:"target"` + + // TLS enables TLS termination for this rule. + // When enabled, a certificate will be automatically issued via ACME. + TLS bool `json:"tls,omitempty"` + + // RedirectHTTP creates an automatic HTTP to HTTPS redirect for this hostname. + // Only applies when TLS is enabled. + RedirectHTTP bool `json:"redirect_http,omitempty"` } // IngressMatch specifies the conditions for matching incoming requests. type IngressMatch struct { - // Hostname is the hostname to match (exact match on Host header). + // Hostname is the hostname to match. Can be: + // - Literal: "api.example.com" (exact match on Host header) + // - Pattern: "{instance}.example.com" (dynamic, extracts subdomain as instance name) // This is required. Hostname string `json:"hostname"` @@ -45,6 +58,106 @@ type IngressMatch struct { // PathPrefix string `json:"path_prefix,omitempty"` } +// captureRegex matches {name} captures in hostname patterns +var captureRegex = regexp.MustCompile(`\{([a-zA-Z_][a-zA-Z0-9_]*)\}`) + +// IsPattern returns true if the hostname contains {name} captures. +func (m *IngressMatch) IsPattern() bool { + return captureRegex.MatchString(m.Hostname) +} + +// HostnamePattern represents a parsed hostname pattern with captures. +type HostnamePattern struct { + // Original is the original pattern string (e.g., "{instance}.example.com") + Original string + + // Wildcard is the Caddy wildcard pattern (e.g., "*.example.com") + Wildcard string + + // Captures is the list of capture names in order (e.g., ["instance"]) + Captures []string + + // CaddyLabels maps capture names to Caddy placeholder expressions + // e.g., {"instance": "{http.request.host.labels.2}"} + CaddyLabels map[string]string +} + +// ParsePattern parses the hostname pattern and returns a HostnamePattern. +// For "{instance}.example.com": +// - Wildcard: "*.example.com" +// - Captures: ["instance"] +// - CaddyLabels: {"instance": "{http.request.host.labels.2}"} +// +// Caddy labels are indexed from the right (TLD first): +// - foo.bar.example.com → labels.0=com, labels.1=example, labels.2=bar, labels.3=foo +func (m *IngressMatch) ParsePattern() (*HostnamePattern, error) { + if !m.IsPattern() { + return nil, fmt.Errorf("hostname %q is not a pattern", m.Hostname) + } + + // Split hostname into parts + parts := strings.Split(m.Hostname, ".") + if len(parts) < 2 { + return nil, fmt.Errorf("hostname pattern %q must have at least two parts", m.Hostname) + } + + captures := []string{} + caddyLabels := make(map[string]string) + wildcardParts := make([]string, len(parts)) + + // Process each part and build wildcard + label mappings + // Parts are indexed left-to-right, but Caddy labels are indexed right-to-left + for i, part := range parts { + // Caddy label index (from right) + labelIndex := len(parts) - 1 - i + + matches := captureRegex.FindStringSubmatch(part) + if matches != nil { + // This part has a capture + captureName := matches[1] + + // Check if this is a pure capture (entire part is {name}) or mixed + if part == matches[0] { + // Pure capture - replace with wildcard + wildcardParts[i] = "*" + } else { + // Mixed capture (e.g., "api-{instance}") - not supported for now + return nil, fmt.Errorf("mixed captures like %q are not supported, use pure captures like {name}", part) + } + + // Check for duplicate capture names + if _, exists := caddyLabels[captureName]; exists { + return nil, fmt.Errorf("duplicate capture name %q in pattern", captureName) + } + + captures = append(captures, captureName) + caddyLabels[captureName] = fmt.Sprintf("{http.request.host.labels.%d}", labelIndex) + } else { + // Literal part - keep as-is + wildcardParts[i] = part + } + } + + return &HostnamePattern{ + Original: m.Hostname, + Wildcard: strings.Join(wildcardParts, "."), + Captures: captures, + CaddyLabels: caddyLabels, + }, nil +} + +// ResolveInstance resolves the target instance expression using the pattern's captures. +// For a target like "{instance}" and captures {"instance": "{http.request.host.labels.2}"}, +// returns "{http.request.host.labels.2}". +// For a literal target like "my-api", returns "my-api". +func (p *HostnamePattern) ResolveInstance(targetInstance string) string { + result := targetInstance + for captureName, caddyLabel := range p.CaddyLabels { + result = strings.ReplaceAll(result, "{"+captureName+"}", caddyLabel) + } + return result +} + // IngressTarget specifies the target for routing matched requests. type IngressTarget struct { // Instance is the name or ID of the target instance. @@ -77,6 +190,35 @@ func (r *CreateIngressRequest) Validate() error { if rule.Match.Hostname == "" { return &ValidationError{Field: "rules", Message: "hostname is required in rule " + strconv.Itoa(i)} } + + // Check if hostname is a pattern or literal + if rule.Match.IsPattern() { + // Validate pattern syntax + pattern, err := rule.Match.ParsePattern() + if err != nil { + return &ValidationError{Field: "rules", Message: fmt.Sprintf("invalid hostname pattern in rule %d: %v", i, err)} + } + + // For patterns, target.instance must reference a capture + if !captureRegex.MatchString(rule.Target.Instance) { + return &ValidationError{Field: "rules", Message: fmt.Sprintf("pattern hostname in rule %d requires target.instance to reference a capture (e.g., {instance})", i)} + } + + // Verify all captures in target.instance exist in the pattern + targetCaptures := captureRegex.FindAllStringSubmatch(rule.Target.Instance, -1) + for _, match := range targetCaptures { + captureName := match[1] + if _, exists := pattern.CaddyLabels[captureName]; !exists { + return &ValidationError{Field: "rules", Message: fmt.Sprintf("target.instance in rule %d references unknown capture {%s}", i, captureName)} + } + } + } else { + // Literal hostname - disallow raw wildcards (only patterns supported) + if strings.HasPrefix(rule.Match.Hostname, "*") { + return &ValidationError{Field: "rules", Message: "wildcard hostnames are not supported, use pattern syntax like {instance}.example.com in rule " + strconv.Itoa(i)} + } + } + // Port is optional (defaults to 80), but if specified must be valid if rule.Match.Port != 0 && (rule.Match.Port < 1 || rule.Match.Port > 65535) { return &ValidationError{Field: "rules", Message: "match.port must be between 1 and 65535 in rule " + strconv.Itoa(i)} @@ -87,6 +229,10 @@ func (r *CreateIngressRequest) Validate() error { if rule.Target.Port <= 0 || rule.Target.Port > 65535 { return &ValidationError{Field: "rules", Message: "target.port must be between 1 and 65535 in rule " + strconv.Itoa(i)} } + // redirect_http only makes sense with TLS + if rule.RedirectHTTP && !rule.TLS { + return &ValidationError{Field: "rules", Message: "redirect_http requires tls to be enabled in rule " + strconv.Itoa(i)} + } } return nil diff --git a/lib/ingress/validation_test.go b/lib/ingress/validation_test.go index df853935..b56baca3 100644 --- a/lib/ingress/validation_test.go +++ b/lib/ingress/validation_test.go @@ -2,6 +2,7 @@ package ingress import ( "context" + "encoding/json" "net" "os" "testing" @@ -11,6 +12,162 @@ import ( "github.com/stretchr/testify/require" ) +// TestPatternParsing tests the hostname pattern parsing functionality. +func TestPatternParsing(t *testing.T) { + t.Run("IsPattern", func(t *testing.T) { + tests := []struct { + hostname string + expected bool + }{ + {"api.example.com", false}, + {"{instance}.example.com", true}, + {"{app}-{env}.example.com", true}, + {"*.example.com", false}, // Raw wildcards are not patterns + {"foo.bar.example.com", false}, + } + + for _, tc := range tests { + match := IngressMatch{Hostname: tc.hostname} + assert.Equal(t, tc.expected, match.IsPattern(), "IsPattern for %q", tc.hostname) + } + }) + + t.Run("ParsePattern_Simple", func(t *testing.T) { + match := IngressMatch{Hostname: "{instance}.example.com"} + pattern, err := match.ParsePattern() + require.NoError(t, err) + + assert.Equal(t, "{instance}.example.com", pattern.Original) + assert.Equal(t, "*.example.com", pattern.Wildcard) + assert.Equal(t, []string{"instance"}, pattern.Captures) + // labels.2 because: labels.0=com, labels.1=example, labels.2= + assert.Equal(t, "{http.request.host.labels.2}", pattern.CaddyLabels["instance"]) + }) + + t.Run("ParsePattern_DeepSubdomain", func(t *testing.T) { + match := IngressMatch{Hostname: "{instance}.app.example.com"} + pattern, err := match.ParsePattern() + require.NoError(t, err) + + assert.Equal(t, "*.app.example.com", pattern.Wildcard) + // labels.3 because: labels.0=com, labels.1=example, labels.2=app, labels.3= + assert.Equal(t, "{http.request.host.labels.3}", pattern.CaddyLabels["instance"]) + }) + + t.Run("ParsePattern_MultipleCaptures", func(t *testing.T) { + match := IngressMatch{Hostname: "{instance}.{env}.example.com"} + pattern, err := match.ParsePattern() + require.NoError(t, err) + + assert.Equal(t, "*.*.example.com", pattern.Wildcard) + assert.Equal(t, []string{"instance", "env"}, pattern.Captures) + // {instance} at position 0 (from left) = labels.3 + // {env} at position 1 (from left) = labels.2 + assert.Equal(t, "{http.request.host.labels.3}", pattern.CaddyLabels["instance"]) + assert.Equal(t, "{http.request.host.labels.2}", pattern.CaddyLabels["env"]) + }) + + t.Run("ParsePattern_NotAPattern", func(t *testing.T) { + match := IngressMatch{Hostname: "api.example.com"} + _, err := match.ParsePattern() + assert.Error(t, err) + assert.Contains(t, err.Error(), "not a pattern") + }) + + t.Run("ParsePattern_MixedCapture", func(t *testing.T) { + // Mixed captures like "api-{instance}" are not supported + match := IngressMatch{Hostname: "api-{instance}.example.com"} + _, err := match.ParsePattern() + assert.Error(t, err) + assert.Contains(t, err.Error(), "mixed captures") + }) + + t.Run("ParsePattern_DuplicateCapture", func(t *testing.T) { + match := IngressMatch{Hostname: "{instance}.{instance}.example.com"} + _, err := match.ParsePattern() + assert.Error(t, err) + assert.Contains(t, err.Error(), "duplicate capture") + }) + + t.Run("ResolveInstance", func(t *testing.T) { + match := IngressMatch{Hostname: "{instance}.example.com"} + pattern, err := match.ParsePattern() + require.NoError(t, err) + + // Pattern reference should be resolved + result := pattern.ResolveInstance("{instance}") + assert.Equal(t, "{http.request.host.labels.2}", result) + + // Literal should remain unchanged + result = pattern.ResolveInstance("my-api") + assert.Equal(t, "my-api", result) + }) +} + +// TestValidation_PatternHostnames tests validation of pattern-based ingress rules. +func TestValidation_PatternHostnames(t *testing.T) { + t.Run("ValidPattern", func(t *testing.T) { + req := CreateIngressRequest{ + Name: "pattern-ingress", + Rules: []IngressRule{ + { + Match: IngressMatch{Hostname: "{instance}.example.com"}, + Target: IngressTarget{Instance: "{instance}", Port: 8080}, + }, + }, + } + err := req.Validate() + assert.NoError(t, err) + }) + + t.Run("PatternWithLiteralTarget", func(t *testing.T) { + // Pattern hostname requires target.instance to reference a capture + req := CreateIngressRequest{ + Name: "invalid-ingress", + Rules: []IngressRule{ + { + Match: IngressMatch{Hostname: "{instance}.example.com"}, + Target: IngressTarget{Instance: "my-api", Port: 8080}, // Not a capture reference + }, + }, + } + err := req.Validate() + assert.Error(t, err) + assert.Contains(t, err.Error(), "reference a capture") + }) + + t.Run("PatternWithUnknownCapture", func(t *testing.T) { + // Target references a capture that doesn't exist in hostname + req := CreateIngressRequest{ + Name: "invalid-ingress", + Rules: []IngressRule{ + { + Match: IngressMatch{Hostname: "{instance}.example.com"}, + Target: IngressTarget{Instance: "{app}", Port: 8080}, // {app} not in hostname + }, + }, + } + err := req.Validate() + assert.Error(t, err) + assert.Contains(t, err.Error(), "unknown capture") + }) + + t.Run("RawWildcardNotAllowed", func(t *testing.T) { + req := CreateIngressRequest{ + Name: "wildcard-ingress", + Rules: []IngressRule{ + { + Match: IngressMatch{Hostname: "*.example.com"}, + Target: IngressTarget{Instance: "my-api", Port: 8080}, + }, + }, + } + err := req.Validate() + assert.Error(t, err) + assert.Contains(t, err.Error(), "wildcard hostnames are not supported") + }) +} + // getFreePort returns a random available port. func getFreePort(t *testing.T) int { t.Helper() @@ -21,11 +178,8 @@ func getFreePort(t *testing.T) int { return port } -// TestXDSValidation tests that xDS config validation works with the embedded Envoy binary. -// This test verifies that: -// - Valid LDS/CDS configs pass validation -// - Invalid LDS/CDS configs fail validation -func TestXDSValidation(t *testing.T) { +// TestConfigGeneration tests that config generation produces valid Caddy JSON. +func TestConfigGeneration(t *testing.T) { // Create temp dir tmpDir, err := os.MkdirTemp("", "ingress-validation-test-*") require.NoError(t, err) @@ -34,26 +188,17 @@ func TestXDSValidation(t *testing.T) { p := paths.New(tmpDir) // Create required directories - require.NoError(t, os.MkdirAll(p.EnvoyDir(), 0755)) - - // Extract the embedded Envoy binary - envoyPath, err := ExtractEnvoyBinary(p) - require.NoError(t, err, "Should be able to extract embedded Envoy binary") - require.FileExists(t, envoyPath, "Envoy binary should exist after extraction") + require.NoError(t, os.MkdirAll(p.CaddyDir(), 0755)) + require.NoError(t, os.MkdirAll(p.CaddyDataDir(), 0755)) // Use random port to avoid test collisions adminPort := getFreePort(t) - // Create daemon for validation (it has ValidateConfig method) - daemon := NewEnvoyDaemon(p, "127.0.0.1", adminPort, true) - - // Create config generator with daemon as validator - generator := NewEnvoyConfigGenerator(p, "0.0.0.0", "127.0.0.1", adminPort, daemon, OTELConfig{}) + // Create config generator with DNS-based dynamic upstream settings + dnsResolverPort := 5353 + generator := NewCaddyConfigGenerator(p, "0.0.0.0", "127.0.0.1", adminPort, ACMEConfig{}, dnsResolverPort) ctx := context.Background() - ipResolver := func(instance string) (string, error) { - return "10.100.0.10", nil - } t.Run("ValidConfig", func(t *testing.T) { // Create a valid ingress configuration @@ -76,32 +221,35 @@ func TestXDSValidation(t *testing.T) { }, } - // WriteConfig should succeed with valid config - err := generator.WriteConfig(ctx, ingresses, ipResolver) - if err != nil { - t.Logf("WriteConfig error: %v", err) - // Try to get more details by reading any written files - if ldsData, readErr := os.ReadFile(p.EnvoyLDS()); readErr == nil { - t.Logf("LDS content:\n%s", string(ldsData)) - } - if cdsData, readErr := os.ReadFile(p.EnvoyCDS()); readErr == nil { - t.Logf("CDS content:\n%s", string(cdsData)) - } - } - require.NoError(t, err, "Valid config should pass validation") + // GenerateConfig should succeed and produce valid JSON + data, err := generator.GenerateConfig(ctx, ingresses) + require.NoError(t, err, "Valid config should generate successfully") + + // Verify it's valid JSON + var config map[string]interface{} + err = json.Unmarshal(data, &config) + require.NoError(t, err, "Generated config should be valid JSON") - // Verify files were written - assert.FileExists(t, p.EnvoyLDS(), "LDS file should be written") - assert.FileExists(t, p.EnvoyCDS(), "CDS file should be written") - assert.FileExists(t, p.EnvoyConfig(), "Bootstrap file should be written") + // Verify essential structure + assert.Contains(t, config, "admin") + assert.Contains(t, config, "apps") + + // Verify DNS-based dynamic upstream is configured + configStr := string(data) + assert.Contains(t, configStr, "dynamic_upstreams") + assert.Contains(t, configStr, "hypeman.internal") }) t.Run("EmptyConfig", func(t *testing.T) { // Empty config should also be valid ingresses := []Ingress{} - err := generator.WriteConfig(ctx, ingresses, ipResolver) - require.NoError(t, err, "Empty config should pass validation") + data, err := generator.GenerateConfig(ctx, ingresses) + require.NoError(t, err, "Empty config should generate successfully") + + var config map[string]interface{} + err = json.Unmarshal(data, &config) + require.NoError(t, err, "Generated config should be valid JSON") }) t.Run("MultipleRules", func(t *testing.T) { @@ -127,141 +275,158 @@ func TestXDSValidation(t *testing.T) { }, } - err := generator.WriteConfig(ctx, ingresses, ipResolver) - require.NoError(t, err, "Config with multiple rules should pass validation") + data, err := generator.GenerateConfig(ctx, ingresses) + require.NoError(t, err, "Config with multiple rules should generate successfully") + + var config map[string]interface{} + err = json.Unmarshal(data, &config) + require.NoError(t, err, "Generated config should be valid JSON") + + // Verify routes are present + configStr := string(data) + assert.Contains(t, configStr, "api.example.com") + assert.Contains(t, configStr, "web.example.com") + assert.Contains(t, configStr, "admin.example.com") + }) + + t.Run("WriteConfig", func(t *testing.T) { + ingresses := []Ingress{ + { + ID: "write-test", + Name: "write-test", + Rules: []IngressRule{ + { + Match: IngressMatch{Hostname: "test.example.com", Port: 80}, + Target: IngressTarget{Instance: "test", Port: 8080}, + }, + }, + }, + } + + err := generator.WriteConfig(ctx, ingresses) + require.NoError(t, err, "WriteConfig should succeed") + + // Verify file was written + assert.FileExists(t, p.CaddyConfig(), "Config file should be written") + + // Verify file content is valid JSON + data, err := os.ReadFile(p.CaddyConfig()) + require.NoError(t, err) + + var config map[string]interface{} + err = json.Unmarshal(data, &config) + require.NoError(t, err, "Written config should be valid JSON") + }) + + t.Run("PatternHostname", func(t *testing.T) { + // Test pattern-based hostname routing + ingresses := []Ingress{ + { + ID: "pattern-ingress", + Name: "pattern-ingress", + Rules: []IngressRule{ + { + Match: IngressMatch{Hostname: "{instance}.example.com", Port: 80}, + Target: IngressTarget{Instance: "{instance}", Port: 8080}, + }, + }, + }, + } + + data, err := generator.GenerateConfig(ctx, ingresses) + require.NoError(t, err, "Pattern config should generate successfully") + + configStr := string(data) + + // Verify wildcard is used for matching + assert.Contains(t, configStr, "*.example.com") + + // Verify dynamic upstream uses Caddy placeholder for instance + assert.Contains(t, configStr, "http.request.host.labels") }) } -// TestXDSValidationWithInvalidConfig tests that invalid configs are rejected. -func TestXDSValidationWithInvalidConfig(t *testing.T) { +// TestTLSConfigGeneration tests TLS-specific config generation. +func TestTLSConfigGeneration(t *testing.T) { // Create temp dir - tmpDir, err := os.MkdirTemp("", "ingress-invalid-validation-test-*") + tmpDir, err := os.MkdirTemp("", "ingress-tls-validation-test-*") require.NoError(t, err) defer os.RemoveAll(tmpDir) p := paths.New(tmpDir) - // Create required directories - require.NoError(t, os.MkdirAll(p.EnvoyDir(), 0755)) + require.NoError(t, os.MkdirAll(p.CaddyDir(), 0755)) + require.NoError(t, os.MkdirAll(p.CaddyDataDir(), 0755)) - // Extract the embedded Envoy binary - _, err = ExtractEnvoyBinary(p) - require.NoError(t, err) - - // Use random port to avoid test collisions adminPort := getFreePort(t) + dnsResolverPort := 5353 - // Create daemon for validation - daemon := NewEnvoyDaemon(p, "127.0.0.1", adminPort, true) - - t.Run("InvalidYAMLFormat", func(t *testing.T) { - // Write invalid YAML to the bootstrap config path - invalidConfig := ` -admin: - address: - socket_address: - address: "127.0.0.1" - port_value: 9901 -dynamic_resources: - lds_config: - path_config_source: - path: "/nonexistent/lds.yaml" - cds_config: - path_config_source: - path: "/nonexistent/cds.yaml" -` - // Write to a temp file for validation - tmpFile, err := os.CreateTemp(tmpDir, "invalid-*.yaml") - require.NoError(t, err) - defer os.Remove(tmpFile.Name()) + t.Run("TLSWithCloudflare", func(t *testing.T) { + acmeConfig := ACMEConfig{ + Email: "admin@example.com", + DNSProvider: DNSProviderCloudflare, + CloudflareAPIToken: "test-token", + } + generator := NewCaddyConfigGenerator(p, "0.0.0.0", "127.0.0.1", adminPort, acmeConfig, dnsResolverPort) - _, err = tmpFile.WriteString(invalidConfig) - require.NoError(t, err) - tmpFile.Close() + ingresses := []Ingress{ + { + ID: "tls-ingress", + Name: "tls-ingress", + Rules: []IngressRule{ + { + Match: IngressMatch{Hostname: "secure.example.com", Port: 443}, + Target: IngressTarget{Instance: "secure-app", Port: 8080}, + TLS: true, + RedirectHTTP: true, + }, + }, + }, + } - // Validation should fail because referenced files don't exist - err = daemon.ValidateConfig(tmpFile.Name()) - assert.Error(t, err, "Config with nonexistent xDS files should fail validation") - }) + ctx := context.Background() - t.Run("MalformedYAML", func(t *testing.T) { - // Write malformed YAML - malformedConfig := ` -admin: - address: - socket_address - address: "127.0.0.1" - port_value: this_should_be_int -` - tmpFile, err := os.CreateTemp(tmpDir, "malformed-*.yaml") + data, err := generator.GenerateConfig(ctx, ingresses) require.NoError(t, err) - defer os.Remove(tmpFile.Name()) - _, err = tmpFile.WriteString(malformedConfig) - require.NoError(t, err) - tmpFile.Close() + configStr := string(data) + + // Verify TLS automation is configured + assert.Contains(t, configStr, "automation") + assert.Contains(t, configStr, "secure.example.com") + assert.Contains(t, configStr, "cloudflare") + assert.Contains(t, configStr, "admin@example.com") - err = daemon.ValidateConfig(tmpFile.Name()) - assert.Error(t, err, "Malformed YAML should fail validation") + // Verify redirect is configured + assert.Contains(t, configStr, "301") + assert.Contains(t, configStr, "Location") }) - t.Run("InvalidListenerConfig", func(t *testing.T) { - // Create config with invalid listener (negative port) - ldsConfig := ` -resources: - - "@type": type.googleapis.com/envoy.config.listener.v3.Listener - name: invalid_listener - address: - socket_address: - address: "0.0.0.0" - port_value: -1 -` - cdsConfig := ` -resources: [] -` - // Write LDS - ldsFile, err := os.CreateTemp(tmpDir, "invalid-lds-*.yaml") - require.NoError(t, err) - defer os.Remove(ldsFile.Name()) - _, err = ldsFile.WriteString(ldsConfig) - require.NoError(t, err) - ldsFile.Close() + t.Run("NoTLSAutomationWithoutConfig", func(t *testing.T) { + // Empty ACME config + generator := NewCaddyConfigGenerator(p, "0.0.0.0", "127.0.0.1", adminPort, ACMEConfig{}, dnsResolverPort) - // Write CDS - cdsFile, err := os.CreateTemp(tmpDir, "invalid-cds-*.yaml") - require.NoError(t, err) - defer os.Remove(cdsFile.Name()) - _, err = cdsFile.WriteString(cdsConfig) - require.NoError(t, err) - cdsFile.Close() - - // Write bootstrap referencing these files - bootstrapConfig := ` -admin: - address: - socket_address: - address: "127.0.0.1" - port_value: 9901 -dynamic_resources: - lds_config: - path_config_source: - path: "` + ldsFile.Name() + `" - watched_directory: - path: "` + tmpDir + `" - cds_config: - path_config_source: - path: "` + cdsFile.Name() + `" - watched_directory: - path: "` + tmpDir + `" -` - bootstrapFile, err := os.CreateTemp(tmpDir, "invalid-bootstrap-*.yaml") - require.NoError(t, err) - defer os.Remove(bootstrapFile.Name()) - _, err = bootstrapFile.WriteString(bootstrapConfig) + ingresses := []Ingress{ + { + ID: "no-tls-ingress", + Name: "no-tls-ingress", + Rules: []IngressRule{ + { + Match: IngressMatch{Hostname: "test.example.com", Port: 80}, + Target: IngressTarget{Instance: "app", Port: 8080}, + }, + }, + }, + } + + ctx := context.Background() + + data, err := generator.GenerateConfig(ctx, ingresses) require.NoError(t, err) - bootstrapFile.Close() - err = daemon.ValidateConfig(bootstrapFile.Name()) - assert.Error(t, err, "Config with invalid listener port should fail validation") + configStr := string(data) + + // Should NOT have TLS automation when ACME not configured + assert.NotContains(t, configStr, `"automation"`) }) } diff --git a/lib/instances/manager_test.go b/lib/instances/manager_test.go index de299b91..fe2e0f30 100644 --- a/lib/instances/manager_test.go +++ b/lib/instances/manager_test.go @@ -3,6 +3,7 @@ package instances import ( "bytes" "context" + "crypto/tls" "fmt" "io" "net" @@ -14,6 +15,7 @@ import ( "testing" "time" + "github.com/joho/godotenv" "github.com/onkernel/hypeman/cmd/api/config" "github.com/onkernel/hypeman/lib/exec" "github.com/onkernel/hypeman/lib/images" @@ -328,10 +330,10 @@ func TestBasicEndToEnd(t *testing.T) { // Verify nginx started successfully assert.True(t, foundNginxStartup, "Nginx should have started worker processes within 5 seconds") - // Test ingress - route external traffic to nginx through Envoy + // Test ingress - route external traffic to nginx through Caddy t.Log("Testing ingress routing to nginx...") - // Get random free ports for Envoy + // Get random free ports for Caddy listener, err := net.Listen("tcp", "127.0.0.1:0") require.NoError(t, err) ingressPort := listener.Addr().(*net.TCPAddr).Port @@ -349,6 +351,7 @@ func TestBasicEndToEnd(t *testing.T) { ListenAddress: "127.0.0.1", AdminAddress: "127.0.0.1", AdminPort: adminPort, + DNSPort: 0, // Use random port for testing StopOnShutdown: true, } @@ -362,20 +365,21 @@ func TestBasicEndToEnd(t *testing.T) { exists: true, } - ingressManager := ingress.NewManager(p, ingressConfig, resolver) + // Pass nil for otelLogger - no log forwarding in tests + ingressManager := ingress.NewManager(p, ingressConfig, resolver, nil) - // Initialize ingress manager (starts Envoy) - t.Log("Starting Envoy...") + // Initialize ingress manager (starts Caddy) + t.Log("Starting Caddy...") err = ingressManager.Initialize(ctx) require.NoError(t, err, "Ingress manager should initialize successfully") - // Ensure we clean up Envoy - defer func() { - t.Log("Shutting down Envoy...") + // Ensure we clean up Caddy - use t.Cleanup for guaranteed cleanup even on test failures + t.Cleanup(func() { + t.Log("Shutting down Caddy...") if err := ingressManager.Shutdown(); err != nil { t.Logf("Warning: failed to shutdown ingress manager: %v", err) } - }() + }) // Create an ingress rule t.Log("Creating ingress rule...") @@ -399,14 +403,13 @@ func TestBasicEndToEnd(t *testing.T) { require.NotNil(t, ing) t.Logf("Ingress created: %s", ing.ID) - // Make HTTP request through Envoy to nginx with retry - // Envoy watches the xDS files and reloads automatically, but we retry to handle timing - // Envoy may take a few seconds to detect file changes and start the new listener - t.Log("Making HTTP request through Envoy to nginx...") - client := &http.Client{Timeout: 1 * time.Second} + // Make HTTP request through Caddy to nginx with retry + // Caddy reloads config dynamically via the admin API + t.Log("Making HTTP request through Caddy to nginx...") + client := &http.Client{Timeout: 2 * time.Second} var resp *http.Response var lastErr error - deadline := time.Now().Add(5 * time.Second) + deadline := time.Now().Add(10 * time.Second) for time.Now().Before(deadline) { req, err := http.NewRequest("GET", fmt.Sprintf("http://127.0.0.1:%d/", ingressPort), nil) require.NoError(t, err) @@ -418,28 +421,177 @@ func TestBasicEndToEnd(t *testing.T) { } if resp != nil { resp.Body.Close() + resp = nil } - time.Sleep(100 * time.Millisecond) + time.Sleep(200 * time.Millisecond) } - // TODO: Fix test flake or ingress bug - if lastErr != nil || resp == nil { - t.Logf("Warning: HTTP request through Envoy did not succeed within deadline: %v", lastErr) - } else { - defer resp.Body.Close() + require.NoError(t, lastErr, "HTTP request through Caddy should succeed") + require.NotNil(t, resp, "HTTP response should not be nil") + defer resp.Body.Close() - // Verify we got a successful response from nginx - assert.Equal(t, http.StatusOK, resp.StatusCode, "Should get 200 OK from nginx") + // Verify we got a successful response from nginx + assert.Equal(t, http.StatusOK, resp.StatusCode, "Should get 200 OK from nginx") - // Read response body - body, err := io.ReadAll(resp.Body) - require.NoError(t, err) - assert.Contains(t, string(body), "nginx", "Response should contain nginx welcome page") - t.Logf("Got response from nginx through Envoy: %d bytes", len(body)) - } + // Read response body + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + assert.Contains(t, string(body), "nginx", "Response should contain nginx welcome page") + t.Logf("Got response from nginx through Caddy: %d bytes", len(body)) err = ingressManager.Delete(ctx, ing.ID) require.NoError(t, err) t.Log("Ingress deleted") + // Test TLS ingress (only if ACME is configured via environment variables or .env file) + // Try to load .env file from repository root (for local development) + cwd, _ := os.Getwd() + for dir := cwd; dir != "/"; dir = filepath.Dir(dir) { + envFile := filepath.Join(dir, ".env") + if _, err := os.Stat(envFile); err == nil { + _ = godotenv.Load(envFile) + t.Logf("Loaded .env from %s", envFile) + break + } + } + + acmeEmail := os.Getenv("ACME_EMAIL") + acmeDNSProvider := os.Getenv("ACME_DNS_PROVIDER") + cloudflareToken := os.Getenv("CLOUDFLARE_API_TOKEN") + tlsTestDomain := os.Getenv("TLS_TEST_DOMAIN") + acmeCA := os.Getenv("ACME_CA") + + if acmeEmail != "" && acmeDNSProvider == "cloudflare" && cloudflareToken != "" && tlsTestDomain != "" { + t.Log("Testing TLS ingress (ACME configured)...") + + // Get random port for HTTPS + httpsListener, err := net.Listen("tcp", "0.0.0.0:0") + require.NoError(t, err) + httpsPort := httpsListener.Addr().(*net.TCPAddr).Port + httpsListener.Close() + + // Get random port for TLS admin API + tlsAdminListener, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + tlsAdminPort := tlsAdminListener.Addr().(*net.TCPAddr).Port + tlsAdminListener.Close() + + t.Logf("Using random ports for TLS test: https=%d, admin=%d", httpsPort, tlsAdminPort) + + // Create a new ingress manager with ACME configuration + tlsIngressConfig := ingress.Config{ + ListenAddress: "0.0.0.0", // Must be accessible for certificate validation + AdminAddress: "127.0.0.1", + AdminPort: tlsAdminPort, + DNSPort: 0, // Use random port for testing + StopOnShutdown: true, + ACME: ingress.ACMEConfig{ + Email: acmeEmail, + DNSProvider: ingress.DNSProviderCloudflare, + CA: acmeCA, // Use staging CA if set, otherwise production + CloudflareAPIToken: cloudflareToken, + AllowedDomains: tlsTestDomain, // Allow the test domain + }, + } + + tlsIngressManager := ingress.NewManager(p, tlsIngressConfig, resolver, nil) + + // Initialize TLS ingress manager (starts a new Caddy instance) + t.Log("Starting Caddy with TLS support...") + err = tlsIngressManager.Initialize(ctx) + require.NoError(t, err, "TLS ingress manager should initialize successfully") + + // Use t.Cleanup for guaranteed cleanup even on test failures + t.Cleanup(func() { + t.Log("Shutting down TLS Caddy...") + if err := tlsIngressManager.Shutdown(); err != nil { + t.Logf("Warning: failed to shutdown TLS ingress manager: %v", err) + } + }) + + // Create TLS ingress rule + t.Logf("Creating TLS ingress rule for %s...", tlsTestDomain) + tlsIngressReq := ingress.CreateIngressRequest{ + Name: "test-nginx-tls", + Rules: []ingress.IngressRule{ + { + Match: ingress.IngressMatch{ + Hostname: tlsTestDomain, + Port: httpsPort, + }, + Target: ingress.IngressTarget{ + Instance: "test-nginx", + Port: 80, + }, + TLS: true, + RedirectHTTP: false, // Don't redirect, just test HTTPS + }, + }, + } + + tlsIng, err := tlsIngressManager.Create(ctx, tlsIngressReq) + require.NoError(t, err) + require.NotNil(t, tlsIng) + t.Logf("TLS Ingress created: %s", tlsIng.ID) + + // Wait for certificate to be issued (this can take 10-60 seconds with DNS-01) + // Caddy will automatically obtain the certificate when the first request comes in + t.Log("Making HTTPS request (certificate will be obtained on first request)...") + + // Create HTTP client that trusts the staging CA (or skips verification for testing) + // ServerName sets the SNI (Server Name Indication) for the TLS handshake. + // This is required because we connect to 127.0.0.1 but Caddy needs to know + // which certificate to serve based on the hostname. + tlsClient := &http.Client{ + Timeout: 90 * time.Second, // Long timeout for certificate issuance + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: true, // Accept staging CA certs + ServerName: tlsTestDomain, // Set SNI to match the certificate + }, + }, + } + + var tlsResp *http.Response + var tlsLastErr error + tlsDeadline := time.Now().Add(90 * time.Second) // Allow up to 90s for cert issuance + + for time.Now().Before(tlsDeadline) { + tlsReq, err := http.NewRequest("GET", fmt.Sprintf("https://127.0.0.1:%d/", httpsPort), nil) + require.NoError(t, err) + tlsReq.Host = tlsTestDomain // Set Host header to match ingress rule + + tlsResp, tlsLastErr = tlsClient.Do(tlsReq) + if tlsLastErr == nil && tlsResp.StatusCode == http.StatusOK { + break + } + if tlsResp != nil { + tlsResp.Body.Close() + tlsResp = nil + } + t.Logf("TLS request attempt failed: %v (retrying...)", tlsLastErr) + time.Sleep(2 * time.Second) + } + + require.NoError(t, tlsLastErr, "HTTPS request through Caddy should succeed") + require.NotNil(t, tlsResp, "HTTPS response should not be nil") + defer tlsResp.Body.Close() + + // Verify we got a successful response from nginx over HTTPS + assert.Equal(t, http.StatusOK, tlsResp.StatusCode, "Should get 200 OK from nginx over HTTPS") + + // Read response body + tlsBody, err := io.ReadAll(tlsResp.Body) + require.NoError(t, err) + assert.Contains(t, string(tlsBody), "nginx", "HTTPS response should contain nginx welcome page") + t.Logf("Got HTTPS response from nginx through Caddy: %d bytes", len(tlsBody)) + + // Clean up TLS ingress + err = tlsIngressManager.Delete(ctx, tlsIng.ID) + require.NoError(t, err) + t.Log("TLS Ingress deleted") + } else { + t.Log("Skipping TLS ingress test (ACME not configured). Set ACME_EMAIL, ACME_DNS_PROVIDER=cloudflare, CLOUDFLARE_API_TOKEN, and TLS_TEST_DOMAIN to enable.") + } + // Test volume is accessible from inside the guest via exec t.Log("Testing volume from inside guest via exec...") diff --git a/lib/logger/logger.go b/lib/logger/logger.go index 76fe881c..d539930b 100644 --- a/lib/logger/logger.go +++ b/lib/logger/logger.go @@ -18,6 +18,7 @@ const loggerKey contextKey = "logger" // Subsystem names for per-subsystem logging configuration. const ( SubsystemAPI = "API" + SubsystemCaddy = "CADDY" SubsystemImages = "IMAGES" SubsystemIngress = "INGRESS" SubsystemInstances = "INSTANCES" @@ -55,8 +56,8 @@ func NewConfig() Config { // Parse subsystem-specific levels subsystems := []string{ - SubsystemAPI, SubsystemImages, SubsystemIngress, SubsystemInstances, - SubsystemNetwork, SubsystemVolumes, SubsystemVMM, + SubsystemAPI, SubsystemCaddy, SubsystemImages, SubsystemIngress, + SubsystemInstances, SubsystemNetwork, SubsystemVolumes, SubsystemVMM, SubsystemSystem, SubsystemExec, } for _, subsystem := range subsystems { diff --git a/lib/oapi/oapi.go b/lib/oapi/oapi.go index c326b716..815a7e5a 100644 --- a/lib/oapi/oapi.go +++ b/lib/oapi/oapi.go @@ -217,8 +217,14 @@ type IngressMatch struct { // IngressRule defines model for IngressRule. type IngressRule struct { - Match IngressMatch `json:"match"` - Target IngressTarget `json:"target"` + Match IngressMatch `json:"match"` + + // RedirectHttp Auto-create HTTP to HTTPS redirect for this hostname (only applies when tls is enabled) + RedirectHttp *bool `json:"redirect_http,omitempty"` + Target IngressTarget `json:"target"` + + // Tls Enable TLS termination (certificate auto-issued via ACME). + Tls *bool `json:"tls,omitempty"` } // IngressTarget defines model for IngressTarget. @@ -2068,6 +2074,7 @@ type DeleteIngressResponse struct { Body []byte HTTPResponse *http.Response JSON404 *Error + JSON409 *Error JSON500 *Error } @@ -2092,6 +2099,7 @@ type GetIngressResponse struct { HTTPResponse *http.Response JSON200 *Ingress JSON404 *Error + JSON409 *Error JSON500 *Error } @@ -3041,6 +3049,13 @@ func ParseDeleteIngressResponse(rsp *http.Response) (*DeleteIngressResponse, err } response.JSON404 = &dest + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 409: + var dest Error + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON409 = &dest + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { @@ -3081,6 +3096,13 @@ func ParseGetIngressResponse(rsp *http.Response) (*GetIngressResponse, error) { } response.JSON404 = &dest + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 409: + var dest Error + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON409 = &dest + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { @@ -5117,6 +5139,15 @@ func (response DeleteIngress404JSONResponse) VisitDeleteIngressResponse(w http.R return json.NewEncoder(w).Encode(response) } +type DeleteIngress409JSONResponse Error + +func (response DeleteIngress409JSONResponse) VisitDeleteIngressResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(409) + + return json.NewEncoder(w).Encode(response) +} + type DeleteIngress500JSONResponse Error func (response DeleteIngress500JSONResponse) VisitDeleteIngressResponse(w http.ResponseWriter) error { @@ -5152,6 +5183,15 @@ func (response GetIngress404JSONResponse) VisitGetIngressResponse(w http.Respons return json.NewEncoder(w).Encode(response) } +type GetIngress409JSONResponse Error + +func (response GetIngress409JSONResponse) VisitGetIngressResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(409) + + return json.NewEncoder(w).Encode(response) +} + type GetIngress500JSONResponse Error func (response GetIngress500JSONResponse) VisitGetIngressResponse(w http.ResponseWriter) error { @@ -6541,79 +6581,81 @@ func (sh *strictHandler) GetVolume(w http.ResponseWriter, r *http.Request, id st // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "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", - "Uy6VuZ3Vu/lw8/8BAAD//72VRidObwAA", + "H4sIAAAAAAAC/+x9C2/buJb/VznQ/w7g/CE/4rS9rS8WizTptBk0bdB0Mnu36WZo6djmlCJVknLiFvnu", + "Cz4kS5b8yDRxm22AArUt8XHePx4eMl+DSCSp4Mi1CgZfAxVNMCH2477WJJqcCZYl+A4/Z6i0+TmVIkWp", + "KdqXEpFxfZESPTHfYlSRpKmmggeD4IToCVxOUCJMbS+gJiJjMQwRbDuMgzDAK5KkDINB0E247sZEkyAM", + "9Cw1PyktKR8H12EgkcSCs5kbZkQypoPBiDCF4cKwx6ZrIApMk7ZtU/Q3FIIh4cG17fFzRiXGweBDmYyP", + "xcti+BdG2gx+IJFoPErIeDknOEmwzoO3B0dATTuQOEKJPEJoYWfcCSEW0SeUHSq6jA4lkbMuH1N+NWBE", + "o9I7FdasfrfOrwXy7NxWEMbHEpW6IWmvsoTwtmEyGTIE8xK0mLhEGRGFwFBrlCqEmI6pViEQHkNM1AQV", + "GKH8CyLCudCgNJEahATkMVxSPQFi36tyIJm1SUrb1E01CIOEXL1GPjaK92QvDFJihjPz+p8PpP2l1372", + "seU/tD/+//ynnf/8R6NyZcxRWqXwncg05WOwj2EkJOgJVTCfA9WY2Hb/kDgKBsH/686tqetNqZtzN2No", + "xkooP3LNdouZECnJrFlq+eRWSU9pwqPlmol8av4jcUwNYYSdVB7XuFFlwgs+pVLwBLmGKZHUCFuVRfM1", + "ePP28MXFizdnwcCMHGeRbRoGJ2/fvQ8GwV6v1zP91uY/ETpl2fhC0S9Ysetg7+XzYHEi+8X8IcFEyJmV", + "iO8DWpOqOo6ETIgGRj8hnJv+zoMQzoPdl+dBVbH6dqgaE6zRbmTPawyVsJRyXGqp4Y9iXZdCfmKCxO3d", + "WzYujtr0XSfxjXsAkeAjOs4kMb97M0OgXq2DsKbOhiNxRWG0zGpx4I8J6glK0AKIDWVFl+YnM4RvDvkM", + "SxxxHTZEjZoSiylKRmYNSrzba9DiPyTVVqK+HcRUfQLTeI0Km96cDj/u1ZW416zFDZNqmNNzo1HepjaZ", + "STGR3f6x/9jf1K6mUZqpypT6i9N5kyVDlCBGMKVSZ4TBwcnvFZfTLzqmXOMYpe3ZYowGN+4gjCopgpd/", + "oQ9EQ2R8qdE/Ta3X3ci1u54t4Cg5uJXe3PmV5d58Dd6icYNPSr1bjDKlRQI0Rq7piKKEFsm0aI+RoyQa", + "Y6AjME4hlWJKY4yrEpsK1jbwy3qADd2Umy544ioOxXblhLJMNS/Gw3qXp0YDKYcxHZPhTFeDzW6vLvpm", + "Ruf9N7H6hZRC1pkbibiBxP00ZTSyytFWKUZ0RCNA0wOYBtBKSDShHAtzqXJ1SOIL6cUZNgVbTShr0NpS", + "uHOD+TehZTxkkjFNU4bumdrZVGMt5Ye2p7rGhgHlHOUF5uy5QU8JKtUYMRcCWU5L8Yp1+DEOs/HYsKTM", + "umOqlMVfXrowosjigQvAa0GvleZ8Ykv1wNOwoTa8NiG4zXCKrKwEzqLMZBMhEQo9cUKrUEX5lDAaX1Ce", + "Zo0qsZSVv2bSRjTXKZChyLR1ZE5g5UHsWsXa+khkPG5kVo0dr5Awt5CrckJpojMfe7PE8FZ8MvycDyc+", + "rRWH76RJDEc51loQQNLg7A6OD2EkRWJQgyaUo4QENfHLxmJGHwK7QArCoG10KiaYCA5iNPqXmUFhKnUv", + "lzFm9HQBARQGYsMExhdEN0ytHEKUJkkKrXe/Huzt7T1bjNb9x+3ebnv38fvd3qBn/v13EAYuyhoQSTS2", + "fRyqOww69pFhYbGCSrApxpAQTkeoNPg3yyOrCek/fjIgw2i3vxfj6NHjJ51Op2kY5FrOUkF5w1Avimeb", + "iaLrUHF73mdHTb5NDnewptmElq/Byf77V8Eg6GZKdpmICOuqIeWD0vfi6/yB/eC+DilvXAsVPndhptbF", + "eI9gwrczI6AKRoSyhQxKmjHmfx8YSjhGhUIK62yW8HVdmH9jVJPRLxhDY0ZDk7FZYziN+7bURRh8zjDD", + "i1Qo6kav5ZX8EwMShhllMdgW0DLE5RDH/lQFOP2l5JdQpIUNDnbUBj4soLoZ2bzjx8y4pszmm2aVER/v", + "PXn6z96z3X7JuCnXTx4FG02lcLsLcN3S7J+GhU9Okccugho1cJ8iwafGKuwXOz/jZ5ziVBx4/qwmDLMw", + "onx8EdMG7fzDPYSYSoy0XZKvt6GgS9J0vSo2o7rCpxXklzxyY2zxyZp6dPnunrwJy+9X8XrG6ecMS4i+", + "Ovrb8W+f/0ud/POv3c+vz87+PX352+Eb+u8zdvL2mxINqxNv3zV7tnKJRY03rGTNNlWPY6KjBuAzEUov", + "4Zp/YpaSiWkMLbwikfZfBAfzBkyQxCirOkNS2vHfOpFImjiaCqkra+SnvbBhAmDeMzNgVGnkUGRPqLJc", + "h1ae4Xjaq8zhae/p+nVUQfwKvlmx1DcFcm5uIFjHeTu08yIXE63T9Vl+ayhOuPDq/fsTwwbz/ynkHc15", + "kVMCLcHZDIhZ0KGCywly0MwGU59U2mnYKggDTeQY9YYEvXcvm2ZMrafjhR0Y3r8+BY0yodw5nlZk2Dky", + "604Eu4inSmUYw5QS2D84frHT2WBXw/K2mP8KOb4vKFxIN+RJuJoBuBbzFIrlr5BwdNjgPVbreEO3VrGN", + "/60mAm+mwOUMohmsmf45gXceH/ZuFh/uJm9fz8ITdaE4SdVENJCaZ1EJ5O8AXlGlKx69LiCv63Vbquf8", + "q17NZfNXJCM3y97/jdAKrYPfjw77PtlZHUZ/eUSePb26IvrZE3qpnn1JhnL81x65JzsHK3P935qwF6Mb", + "5OubVKvwIVT5BC3GfztFHwY0bZC9UnTMMYajEyBxbFxeebmQd18V+u6zfmf3ydPObq/X2e1tsnhKSLRi", + "7OP9g80H7/UdnByQ4SCKBzj6hsWbF5sLgYRdkpmC8zwunQcuEJYiYEkpfezaKH1U3wn5exsfC1JYu7Vx", + "k62MjbyH3TNb4vpP7X7azf3+46V+f61UzVIH16MPZ0Sn9mXbSqTpUiJEeiMa+mti11oaSts+29jqWXQj", + "Jed06xs75VVHnpl2Ittg9VEWWo2a/LFd6+PgnLfBbRLFAzg7PgbfOwwzDcVeL8bQOmAii+HVLEU5pUpI", + "MKhyijumh3cZ55SPTQ/W4UbmCZuBdL+vbnxCMuVGN21T+211i9NJpmNxyW0bNck0mG92yoYEjyVWd+E0", + "eQBvhG3jZxoa37kAStzrhMfDWf31RQDTigiHoYnHSguJ8c45L6VTPKeDMPAcC8LAkR+EQU6V+ehmZz/Z", + "gUuSnuu/U6g6ynSKmuTlVwt7DVRpYxtRJqWBcaWXoYVJqmd5tivX952bKfh+0WHTZtBtI+Des9vIkPy+", + "MiXyf2QPs+xT8kHWepOaTJcu5y6aGHt0uAjlHLz3tXtVcLawo6V02+0pNO5nragRdMV65plhlxl8nC1u", + "WtygLrCx/mOCJcsxdJQLA9etWJasKS+shEqUlWayXDYuoHxjESVVefXk32SZB2DrcxPOBUKKsl2oRI7e", + "zOrvUlKbA/cMcow1LPgPgwKacymrQeIxuSpGsPCNKFioRXF05EWUvhplpwPv8k1iOsq7sNPoVNFkM+Lb", + "vLo016q6MFaVm+aQpdHwvP9Z4dGW2daCcs7HCFdXtBrXhVEmqZ6dmoDg1HCIRKLcz5wa2khhibA/zwe3", + "+bnra1stMBJ1cl6aJTaNYP/kyGpJQjgZG5GdHQOjI4xmEUPI7M5+LezbCrW3B0ftITHQIl+o2sQF1ZYh", + "5u2EcNN/EAZTlMqN2+v0O7bOUKTISUqDQbDX2e2YhZthgyWxOym2uH2+y9ihjWRHsZ279pvghrMqFVw5", + "3vR7PVcTwLX3rGReFtL9S7l9Khdd18VeP4Jl4ULYMGxwC2E3UYc2VZYkRM4M7fZXiCYYfbKPuhZxqqUE", + "GQhx5F75Roo2y9Zb/FtHzDVKc2jjp38dBo96u7fGYVff0zDs75xkeiIk/YKxGfTxLYp16aBHXKPkhIFC", + "OUXpqzXKRhgMPlTN78PH649luVt2zXmVCtUg61KVeuAcAyr9XMSzWyOxoQ7+uuqEjMe9rmla/9Zm4BWs", + "gck2tTbMt0bdOoioGY92nHZtQdDPSQx5qdf30uhHvUdb0OiF6qJ7ZEknGWO2WNpvjc/rGcr+tPvVgO9r", + "F9wYusV51doO7e+5taVEkgQ1SmVnsCCjd6/byCMRG3TiWOfTBeaph49ubVJsX1YsKiwxbhECfKxZ26MG", + "bG9HdaQ8qMkGauKkmytGuBQtfIP8HYSdHwP6pf+rz/z/0v/V5f5/2dufnwa6G2Xpbcs154WuD8q3Vvle", + "og/2c6ZZ1+T2atehveKtrQA+X81xE8hXTPAB9W2C+srsWgn8isKaO4R+1ZOCG4G/2xNwoWxN3LaP8pT4", + "Twb5nt39oAeCjxiNNLRzjXRrdZsitOGMMFtEmGfX7dk8X3dDOWQK75Pp+dQXLTSu7H+7X2m8CTYsDHIl", + "OshV9+gwtIwMXRkNpBJH9Ko58NsUz21jRD+PraNEP24lVG9Fp/eTIR1nIlPlKhBbNIVqfsKm4oDvG36d", + "h+elCPYH1tLeNkPH1gHqg97fEXReFKhz3m4DYx14zt/aDnj2VQk3Qs/5DB/Q80boucSu1ei5qBC5S/hc", + "vaph6/g517cmhvsNvp8RQd8zVEq4z3HP65qqPm5jgDov2Vwd+71uHB2CLc1Zlry8G1jqB98+Ls1L2+9j", + "DsmW+NlLQXIkOI81y6Hgj6YPve36vu1DwPusYi/L5z+awZZ1RF0mxmXYtViLKpEk81MEEAmuBEMwrYAo", + "OLUTbJ8i1/BiaqjrnPN3qDPJlS1/YURpeAOMclTQMmyTgjGMYTiDP82s/oRCnXdC04SD8HdmsNk5Ny0o", + "z1CBsnOhfAwcL32HdAR/jgRj4tKWk/zZsVWCS23ntaH1O9lPuLy81tGiBUjLOHdOE+3FAHbczxnK2Xxg", + "f2nBfKiiKGa311i99rWeLrI8bWQpGWlb9k81JQxEpt1FCE0TcZxvnsqyiq31bkTjle6i0aW2m1/VoBb5", + "WgfjYuwJg9bp6YudB4exYUyyLCss3Vq4Z2CD2/B1ubZerhG5v3Mv/PRhKy9g/s5quP08dGkWlBtIzOPh", + "zMp2Xhl+nwzEK/ScMuumPV2NNpI/W2ojvij9p7eRuX785FYSCWlPR6v8oND9qdwpwc2SubfsMZT58Y4w", + "X/KcHR/vLDMad/Z4qcnIh7WQL6L76WOKPd1z/6zFnVMkBQGrMkVd89IqexDpgzn4Y14PweNeBg+bDiuo", + "aY0liXCUMXsiMRaXvDlQ+HOm3a/uw9G6pOr8wvQfJgXgD5esGyYn8F4YpacpRnccbfs2KYrzP/e0uNXe", + "gutJsGuMcnq4OQqU/xzAz6Pdt78T2PRnFTbaB9yqbeVHPX8Y29p25PNzyIvayvy4L2buNC2nRIsFDFi6", + "RGFpPYS/T2Er1RDetdygFiKn4GHbeINKiBKzcgffdNpXAbGZevd6B06zNBVSK9CXAhIRo7L3UPx2+vYN", + "DEU8G0DRjoO7k8ArnD9M7q9CNmso+gVN22NbYWSWJyMhk1IHectUYjsVacbs7Ra2zNTz2AUrAprIzvgL", + "EBlN6BQbdmTKl6nfaUnHoiMPgyQnr2vIs1cIVDtdvGa6mEtVHlUaYUQZ5jdrUj62vPX8yrsoXaswpJzI", + "2aZ3KizeID8twup9vED+mFzRJEuKm1pfPocWXmlJ3GW4I3uLOh0VOoVXEWKsbCnzzrddNh8W4mw4dL3V", + "Wp/cmy6N8N+xzgda/g50MCI2ET9Xci0EMCLHuPPTVNN7W5sX0x8dLpTS38MKpWmufXOcsWFN0mYLjA1x", + "/13UIxWLz+1WI539OJi4dBPMPSyJnxYwc1kZ1I+lgr3thYRtlz+d3eMcykvMIXWp9Ml2YHpsUpjXIiIM", + "YpwiE6m9isi9G4RBJpm/WGXQdX9FYCKUtrfcBtcfr/83AAD//7Td7W2lcQAA", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/lib/paths/paths.go b/lib/paths/paths.go index 7f33a389..c9e6596a 100644 --- a/lib/paths/paths.go +++ b/lib/paths/paths.go @@ -208,41 +208,41 @@ func (p *Paths) VolumeMetadata(id string) string { return filepath.Join(p.VolumeDir(id), "metadata.json") } -// Envoy path methods +// Caddy path methods -// EnvoyDir returns the envoy data directory. -func (p *Paths) EnvoyDir() string { - return filepath.Join(p.dataDir, "envoy") +// CaddyDir returns the caddy data directory. +func (p *Paths) CaddyDir() string { + return filepath.Join(p.dataDir, "caddy") } -// EnvoyBinary returns the path to the envoy binary. -func (p *Paths) EnvoyBinary(version, arch string) string { - return filepath.Join(p.dataDir, "system", "binaries", "envoy", version, arch, "envoy") +// CaddyBinary returns the path to the caddy binary. +func (p *Paths) CaddyBinary(version, arch string) string { + return filepath.Join(p.dataDir, "system", "binaries", "caddy", version, arch, "caddy") } -// EnvoyConfig returns the path to the envoy bootstrap config file. -func (p *Paths) EnvoyConfig() string { - return filepath.Join(p.EnvoyDir(), "bootstrap.yaml") +// CaddyConfig returns the path to the caddy config file. +func (p *Paths) CaddyConfig() string { + return filepath.Join(p.CaddyDir(), "config.json") } -// EnvoyLDS returns the path to the Listener Discovery Service config file. -func (p *Paths) EnvoyLDS() string { - return filepath.Join(p.EnvoyDir(), "lds.yaml") +// CaddyPIDFile returns the path to the caddy PID file. +func (p *Paths) CaddyPIDFile() string { + return filepath.Join(p.CaddyDir(), "caddy.pid") } -// EnvoyCDS returns the path to the Cluster Discovery Service config file. -func (p *Paths) EnvoyCDS() string { - return filepath.Join(p.EnvoyDir(), "cds.yaml") +// CaddyLogFile returns the path to the caddy log file. +func (p *Paths) CaddyLogFile() string { + return filepath.Join(p.CaddyDir(), "caddy.log") } -// EnvoyPIDFile returns the path to the envoy PID file. -func (p *Paths) EnvoyPIDFile() string { - return filepath.Join(p.EnvoyDir(), "envoy.pid") +// CaddyDataDir returns the path to Caddy's data directory (for certs, etc.). +func (p *Paths) CaddyDataDir() string { + return filepath.Join(p.CaddyDir(), "data") } -// EnvoyLogFile returns the path to the envoy log file. -func (p *Paths) EnvoyLogFile() string { - return filepath.Join(p.EnvoyDir(), "envoy.log") +// CaddyConfigDir returns the path to Caddy's config directory. +func (p *Paths) CaddyConfigDir() string { + return filepath.Join(p.CaddyDir(), "config") } // Ingress path methods diff --git a/lib/providers/providers.go b/lib/providers/providers.go index 400c9676..70fc57dd 100644 --- a/lib/providers/providers.go +++ b/lib/providers/providers.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "log/slog" + "time" "github.com/c2h5oh/datasize" "github.com/onkernel/hypeman/cmd/api/config" @@ -122,23 +123,45 @@ func ProvideRegistry(p *paths.Paths, imageManager images.Manager) (*registry.Reg } // ProvideIngressManager provides the ingress manager -func ProvideIngressManager(p *paths.Paths, cfg *config.Config, instanceManager instances.Manager) ingress.Manager { +func ProvideIngressManager(p *paths.Paths, cfg *config.Config, instanceManager instances.Manager) (ingress.Manager, error) { + // Parse DNS provider - fail if invalid + dnsProvider, err := ingress.ParseDNSProvider(cfg.AcmeDnsProvider) + if err != nil { + return nil, fmt.Errorf("invalid ACME_DNS_PROVIDER: %w", err) + } + + // Validate DNS propagation timeout if set (must be a valid Go duration string) + if cfg.DnsPropagationTimeout != "" { + if _, err := time.ParseDuration(cfg.DnsPropagationTimeout); err != nil { + return nil, fmt.Errorf("invalid DNS_PROPAGATION_TIMEOUT %q: %w (expected format like '2m', '120s', '1h')", cfg.DnsPropagationTimeout, err) + } + } + ingressConfig := ingress.Config{ - ListenAddress: cfg.EnvoyListenAddress, - AdminAddress: cfg.EnvoyAdminAddress, - AdminPort: cfg.EnvoyAdminPort, - StopOnShutdown: cfg.EnvoyStopOnShutdown, - OTEL: ingress.OTELConfig{ - Enabled: cfg.OtelEnabled, - Endpoint: cfg.OtelEndpoint, - ServiceName: cfg.OtelServiceName + "-envoy", - ServiceInstanceID: cfg.OtelServiceInstanceID, - Insecure: cfg.OtelInsecure, - Environment: cfg.Env, + ListenAddress: cfg.CaddyListenAddress, + AdminAddress: cfg.CaddyAdminAddress, + AdminPort: cfg.CaddyAdminPort, + DNSPort: ingress.DefaultDNSPort, + StopOnShutdown: cfg.CaddyStopOnShutdown, + ACME: ingress.ACMEConfig{ + Email: cfg.AcmeEmail, + DNSProvider: dnsProvider, + CA: cfg.AcmeCA, + DNSPropagationTimeout: cfg.DnsPropagationTimeout, + DNSResolvers: cfg.DnsResolvers, + AllowedDomains: cfg.TlsAllowedDomains, + CloudflareAPIToken: cfg.CloudflareApiToken, }, } + // Create OTEL logger for Caddy log forwarding (if OTEL is enabled) + var otelLogger *slog.Logger + if otelHandler := hypemanotel.GetGlobalLogHandler(); otelHandler != nil { + logCfg := logger.NewConfig() + otelLogger = logger.NewSubsystemLogger(logger.SubsystemCaddy, logCfg, otelHandler) + } + // IngressResolver from instances package implements ingress.InstanceResolver resolver := instances.NewIngressResolver(instanceManager) - return ingress.NewManager(p, ingressConfig, resolver) + return ingress.NewManager(p, ingressConfig, resolver, otelLogger), nil } diff --git a/openapi.yaml b/openapi.yaml index 63354795..6442e324 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -406,8 +406,15 @@ components: properties: hostname: type: string - description: Hostname to match (exact match on Host header) - example: api.example.com + description: | + Hostname to match. Can be: + - Literal: "api.example.com" (exact match on Host header) + - Pattern: "{instance}.example.com" (dynamic routing based on subdomain) + + Pattern hostnames use named captures in curly braces (e.g., {instance}, {app}) + that extract parts of the hostname for routing. The extracted values can be + referenced in the target.instance field. + example: "{instance}.example.com" port: type: integer description: Host port to listen on for this rule (default 80) @@ -420,8 +427,13 @@ components: properties: instance: type: string - description: Target instance name or ID - example: my-api + description: | + Target instance name, ID, or capture reference. + - For literal hostnames: Use the instance name or ID directly (e.g., "my-api") + - For pattern hostnames: Reference a capture from the hostname (e.g., "{instance}") + + When using pattern hostnames, the instance is resolved dynamically at request time. + example: "{instance}" port: type: integer description: Target port on the instance @@ -435,6 +447,14 @@ components: $ref: "#/components/schemas/IngressMatch" target: $ref: "#/components/schemas/IngressTarget" + tls: + type: boolean + description: Enable TLS termination (certificate auto-issued via ACME). + default: false + redirect_http: + type: boolean + description: Auto-create HTTP to HTTPS redirect for this hostname (only applies when tls is enabled) + default: false CreateIngressRequest: type: object @@ -1285,7 +1305,7 @@ paths: required: true schema: type: string - description: Ingress ID or name + description: Ingress ID, name, or ID prefix responses: 200: description: Ingress details @@ -1299,6 +1319,12 @@ paths: application/json: schema: $ref: "#/components/schemas/Error" + 409: + description: Ambiguous identifier matches multiple ingresses + content: + application/json: + schema: + $ref: "#/components/schemas/Error" 500: description: Internal server error content: @@ -1316,7 +1342,7 @@ paths: required: true schema: type: string - description: Ingress ID or name + description: Ingress ID, name, or ID prefix responses: 204: description: Ingress deleted @@ -1326,6 +1352,12 @@ paths: application/json: schema: $ref: "#/components/schemas/Error" + 409: + description: Ambiguous identifier matches multiple ingresses + content: + application/json: + schema: + $ref: "#/components/schemas/Error" 500: description: Internal server error content: