Merge pull request #182 from bnema/feature/public-acme-tls
Add public ACME TLS support
65 files · +8644 -73
M .mockery.yaml +6 -0
| @@ -25,6 +25,11 @@ packages: | |||
| 25 | 25 | RateLimiter: | |
| 26 | 26 | BackupStorage: | |
| 27 | 27 | RouteChecker: | |
| 28 | + | HTTPChallengeSink: | |
| 29 | + | PublicCertificateIssuer: | |
| 30 | + | CertificateStore: | |
| 31 | + | SecretResolver: | |
| 32 | + | CloudflareZoneResolver: | |
| 28 | 33 | github.com/bnema/gordon/internal/boundaries/in: | |
| 29 | 34 | interfaces: | |
| 30 | 35 | ContainerService: | |
| @@ -38,6 +43,7 @@ packages: | |||
| 38 | 43 | SecretService: | |
| 39 | 44 | LogService: | |
| 40 | 45 | VolumeService: | |
| 46 | + | PublicTLSService: | |
| 41 | 47 | # Exception: pushImageOps is a CLI-local interface, not a boundary port. | |
| 42 | 48 | # Mocked here because it abstracts Docker SDK calls that require a running | |
| 43 | 49 | # daemon, making unit/integration tests impractical without a test double. | |
A docs/cli/tls.md +106 -0
| @@ -0,0 +1,106 @@ | |||
| 1 | + | # TLS Commands | |
| 2 | + | ||
| 3 | + | Inspect public TLS/ACME certificate status. | |
| 4 | + | ||
| 5 | + | Remote targeting uses client config or an active remote by default. | |
| 6 | + | Use `--remote` and `--token` to override. See [CLI Overview](./index.md). | |
| 7 | + | ||
| 8 | + | ## gordon tls | |
| 9 | + | ||
| 10 | + | ### Subcommands | |
| 11 | + | ||
| 12 | + | | Subcommand | Description | | |
| 13 | + | |------------|-------------| | |
| 14 | + | | `status` | Show public TLS certificate status | | |
| 15 | + | ||
| 16 | + | --- | |
| 17 | + | ||
| 18 | + | ## gordon tls status | |
| 19 | + | ||
| 20 | + | Display the current public TLS/ACME certificate status, including ACME mode, | |
| 21 | + | certificate details, route coverage, and any errors. | |
| 22 | + | ||
| 23 | + | ```bash | |
| 24 | + | gordon tls status | |
| 25 | + | gordon tls status --json | |
| 26 | + | gordon tls status --remote https://gordon.mydomain.com --token $TOKEN | |
| 27 | + | ``` | |
| 28 | + | ||
| 29 | + | ### Flags | |
| 30 | + | ||
| 31 | + | | Flag | Description | | |
| 32 | + | |------|-------------| | |
| 33 | + | | `--json` | Output as JSON | | |
| 34 | + | ||
| 35 | + | ### Human Output | |
| 36 | + | ||
| 37 | + | ```text | |
| 38 | + | Public TLS / ACME Status | |
| 39 | + | ||
| 40 | + | ACME: enabled | |
| 41 | + | Configured Mode: auto | |
| 42 | + | Effective Mode: http-01 | |
| 43 | + | Reason: configured | |
| 44 | + | Token Source: env | |
| 45 | + | ||
| 46 | + | Certificates | |
| 47 | + | ID: cert-abc123 | |
| 48 | + | Names: example.com, www.example.com | |
| 49 | + | Status: valid | |
| 50 | + | Not After: 2026-05-29 12:00:00 | |
| 51 | + | ||
| 52 | + | Route Coverage | |
| 53 | + | example.com covered=yes covered_by=cert-abc123 | |
| 54 | + | internal.local covered=no error=self-signed cert | |
| 55 | + | ||
| 56 | + | Errors | |
| 57 | + | route internal.local has no ACME cert | |
| 58 | + | ``` | |
| 59 | + | ||
| 60 | + | ### JSON Output | |
| 61 | + | ||
| 62 | + | ```json | |
| 63 | + | { | |
| 64 | + | "acme_enabled": true, | |
| 65 | + | "configured_mode": "auto", | |
| 66 | + | "effective_mode": "http-01", | |
| 67 | + | "selection_reason": "configured", | |
| 68 | + | "token_source": "env", | |
| 69 | + | "certificates": [ | |
| 70 | + | { | |
| 71 | + | "id": "cert-abc123", | |
| 72 | + | "names": ["example.com", "www.example.com"], | |
| 73 | + | "challenge": "http-01", | |
| 74 | + | "status": "valid", | |
| 75 | + | "not_after": "2026-05-29T12:00:00Z", | |
| 76 | + | "renewal_pending": false | |
| 77 | + | } | |
| 78 | + | ], | |
| 79 | + | "routes": [ | |
| 80 | + | { | |
| 81 | + | "domain": "example.com", | |
| 82 | + | "covered": true, | |
| 83 | + | "covered_by": "cert-abc123", | |
| 84 | + | "required_acme": true | |
| 85 | + | }, | |
| 86 | + | { | |
| 87 | + | "domain": "internal.local", | |
| 88 | + | "covered": false, | |
| 89 | + | "required_acme": false, | |
| 90 | + | "error": "self-signed cert" | |
| 91 | + | } | |
| 92 | + | ], | |
| 93 | + | "errors": ["route internal.local has no ACME cert"] | |
| 94 | + | } | |
| 95 | + | ``` | |
| 96 | + | ||
| 97 | + | ### Token Source | |
| 98 | + | ||
| 99 | + | The `token_source` field indicates where the ACME token was sourced from | |
| 100 | + | (e.g., `env`, `file`, `config`). The token value is never displayed. | |
| 101 | + | ||
| 102 | + | ## Related | |
| 103 | + | ||
| 104 | + | - [CLI Overview](./index.md) | |
| 105 | + | - [Status Command](./status.md) | |
| 106 | + | - [Routes Command](./routes.md) | |
M docs/concepts.md +1 -1
| @@ -79,7 +79,7 @@ When a request comes in for `app.example.com`, Gordon: | |||
| 79 | 79 | ||
| 80 | 80 | ### Route Domains | |
| 81 | 81 | ||
| 82 | - | Routes use plain hostnames. HTTPS is enabled by default for routes; see the [routes example](./config/routes.md#development-setup) with `dev-app.example.com`. Cloudflare is one deployment option, but Gordon can also terminate TLS with its built-in listener and certificates issued by Gordon's internal CA, or with static certificates. Gordon does not provide ACME or Let's Encrypt challenge automation. | |
| 82 | + | Routes use plain hostnames. HTTPS is enabled by default for routes; see the [routes example](./config/routes.md#development-setup) with `dev-app.example.com`. Cloudflare is one deployment option, but Gordon can also terminate TLS with its built-in listener and certificates issued by Gordon's internal CA, with static certificates, or with public ACME certificates when `[tls.acme]` is enabled. ACME supports HTTP-01 and Cloudflare DNS-01 challenge modes. | |
| 83 | 83 | ||
| 84 | 84 | ```toml | |
| 85 | 85 | [routes] | |
M docs/config/reference.md +11 -0
| @@ -33,6 +33,14 @@ token_secret = "" # Path in secrets backend to JWT si | |||
| 33 | 33 | token_expiry = "720h" # Token expiry duration (720h = 30 days) | |
| 34 | 34 | access_token_ttl = "15m" # Ephemeral access token lifetime (default: 15m) | |
| 35 | 35 | ||
| 36 | + | # ============================================================================= | |
| 37 | + | # PUBLIC TLS / ACME | |
| 38 | + | # ============================================================================= | |
| 39 | + | [tls.acme] | |
| 40 | + | enabled = false # Enable public ACME certificates (requires server.tls_port > 0) | |
| 41 | + | email = "" # ACME account email when enabled | |
| 42 | + | challenge = "auto" # "auto", "http-01", or "cloudflare-dns-01" | |
| 43 | + | ||
| 36 | 44 | # ============================================================================= | |
| 37 | 45 | # API (applies to both Registry and Admin endpoints) | |
| 38 | 46 | # ============================================================================= | |
| @@ -195,6 +203,9 @@ keep_last = 3 # Keep N newest tags per repository | |||
| 195 | 203 | | `server.registry_allowed_ips` | `[]` | IPs or CIDR ranges allowed to access the registry (empty = allow all) | | |
| 196 | 204 | | `server.proxy_allowed_ips` | `[]` | IPs or CIDR ranges allowed to reach the proxy (empty = allow all) | | |
| 197 | 205 | | `server.registry_listen_address` | `""` | Bind address for registry (empty = all interfaces) | | |
| 206 | + | | `tls.acme.enabled` | `false` | Enable public ACME certificates (requires `server.tls_port > 0`) | | |
| 207 | + | | `tls.acme.email` | `""` | ACME account email when enabled | | |
| 208 | + | | `tls.acme.challenge` | `"auto"` | ACME challenge mode: `auto`, `http-01`, or `cloudflare-dns-01` | | |
| 198 | 209 | | `auth.enabled` | `true` | Enable authentication; when `false`, run local-only mode (loopback-only `/v2/*`, `/admin/*` disabled) | | |
| 199 | 210 | | `auth.secrets_backend` | `"unsafe"` | Secrets storage | | |
| 200 | 211 | | `auth.token_expiry` | `"720h"` | 30 days | | |
M docs/config/server.md +22 -1
| @@ -84,12 +84,33 @@ tls_key_file = "/etc/gordon/tls/key.pem" | |||
| 84 | 84 | ||
| 85 | 85 | The static certificate is served for SNI-matching domains (including wildcards). All other domains get on-demand certs from the internal CA. | |
| 86 | 86 | ||
| 87 | + | #### Public ACME certificates | |
| 88 | + | ||
| 89 | + | Gordon can obtain public certificates through Let's Encrypt-compatible ACME when `[tls.acme]` is enabled. The HTTPS listener must also be enabled (`server.tls_port > 0`). | |
| 90 | + | ||
| 91 | + | ```toml | |
| 92 | + | [server] | |
| 93 | + | tls_port = 8443 | |
| 94 | + | ||
| 95 | + | [tls.acme] | |
| 96 | + | enabled = true | |
| 97 | + | email = "admin@example.com" | |
| 98 | + | challenge = "auto" # auto, http-01, or cloudflare-dns-01 | |
| 99 | + | ``` | |
| 100 | + | ||
| 101 | + | Challenge behavior: | |
| 102 | + | - `http-01` serves `/.well-known/acme-challenge/<token>` on the HTTP proxy port. | |
| 103 | + | - `cloudflare-dns-01` uses a Cloudflare API token from `pass`, `GORDON_CLOUDFLARE_API_TOKEN_FILE`, or `GORDON_CLOUDFLARE_API_TOKEN`. | |
| 104 | + | - `auto` selects Cloudflare DNS-01 when a token is available, otherwise falls back to HTTP-01. | |
| 105 | + | ||
| 106 | + | Static certificates have priority, then public ACME certificates, then Gordon's internal CA fallback. Gordon uses the `go-acme/lego` ACME client, including its DNS-provider support for Cloudflare DNS-01. | |
| 107 | + | ||
| 87 | 108 | #### Direct HTTP Onboarding | |
| 88 | 109 | ||
| 89 | 110 | When `tls_port` is non-zero, direct HTTP clients (those not arriving through a trusted proxy) are restricted to CA onboarding paths only: | |
| 90 | 111 | ||
| 91 | 112 | - `/`, `/ca`, `/ca.crt`, `/ca.mobileconfig` — serve the onboarding page and CA certificate downloads | |
| 92 | - | - `/.well-known/acme-challenge/*` — returns `404 Not Found` (reserved for future ACME support) | |
| 113 | + | - `/.well-known/acme-challenge/*` — serves ACME HTTP-01 challenges when public ACME HTTP-01 is enabled, otherwise returns `404 Not Found` | |
| 93 | 114 | - All other paths return `403 Forbidden` | |
| 94 | 115 | ||
| 95 | 116 | This lets new clients discover and trust the internal CA over plain HTTP without exposing the full application. Trusted proxy traffic (e.g. from Cloudflare) continues through the normal HTTP proxy path unaffected. | |
M go.mod +9 -4
| @@ -10,6 +10,7 @@ require ( | |||
| 10 | 10 | github.com/coreos/go-systemd/v22 v22.7.0 | |
| 11 | 11 | github.com/docker/docker v28.5.2+incompatible | |
| 12 | 12 | github.com/docker/go-connections v0.6.0 | |
| 13 | + | github.com/go-acme/lego/v4 v4.35.2 | |
| 13 | 14 | github.com/golang-jwt/jwt/v5 v5.3.1 | |
| 14 | 15 | github.com/google/go-containerregistry v0.21.5 | |
| 15 | 16 | github.com/google/uuid v1.6.0 | |
| @@ -46,10 +47,12 @@ require ( | |||
| 46 | 47 | github.com/docker/cli v29.4.0+incompatible // indirect | |
| 47 | 48 | github.com/docker/docker-credential-helpers v0.9.5 // indirect | |
| 48 | 49 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect | |
| 50 | + | github.com/go-jose/go-jose/v4 v4.1.4 // indirect | |
| 49 | 51 | github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect | |
| 50 | 52 | github.com/klauspost/compress v1.18.5 // indirect | |
| 51 | 53 | github.com/lucasb-eyer/go-colorful v1.4.0 // indirect | |
| 52 | 54 | github.com/mattn/go-localereader v0.0.1 // indirect | |
| 55 | + | github.com/miekg/dns v1.1.72 // indirect | |
| 53 | 56 | github.com/mitchellh/go-homedir v1.1.0 // indirect | |
| 54 | 57 | github.com/moby/sys/atomicwriter v0.1.0 // indirect | |
| 55 | 58 | github.com/morikuni/aec v1.1.0 // indirect | |
| @@ -63,7 +66,9 @@ require ( | |||
| 63 | 66 | go.opentelemetry.io/otel/log v0.19.0 // indirect | |
| 64 | 67 | go.opentelemetry.io/proto/otlp v1.10.0 // indirect | |
| 65 | 68 | go.yaml.in/yaml/v3 v3.0.4 // indirect | |
| 66 | - | golang.org/x/net v0.52.0 // indirect | |
| 69 | + | golang.org/x/crypto v0.50.0 // indirect | |
| 70 | + | golang.org/x/net v0.53.0 // indirect | |
| 71 | + | golang.org/x/tools v0.44.0 // indirect | |
| 67 | 72 | google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect | |
| 68 | 73 | google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect | |
| 69 | 74 | google.golang.org/grpc v1.80.0 // indirect | |
| @@ -72,8 +77,8 @@ require ( | |||
| 72 | 77 | ) | |
| 73 | 78 | ||
| 74 | 79 | require ( | |
| 75 | - | github.com/davecgh/go-spew v1.1.1 // indirect | |
| 76 | - | github.com/pmezard/go-difflib v1.0.0 // indirect | |
| 80 | + | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect | |
| 81 | + | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect | |
| 77 | 82 | github.com/stretchr/objx v0.5.3 // indirect | |
| 78 | 83 | go.uber.org/mock v0.6.0 // direct | |
| 79 | 84 | golang.org/x/time v0.15.0 | |
| @@ -111,7 +116,7 @@ require ( | |||
| 111 | 116 | go.opentelemetry.io/otel/metric v1.43.0 | |
| 112 | 117 | go.opentelemetry.io/otel/trace v1.43.0 | |
| 113 | 118 | golang.org/x/sys v0.43.0 | |
| 114 | - | golang.org/x/text v0.35.0 // indirect | |
| 119 | + | golang.org/x/text v0.36.0 // indirect | |
| 115 | 120 | gopkg.in/yaml.v3 v3.0.1 // indirect | |
| 116 | 121 | gotest.tools/v3 v3.5.2 // indirect | |
| 117 | 122 | ) | |
M go.sum +20 -10
| @@ -45,8 +45,8 @@ github.com/containerd/stargz-snapshotter/estargz v0.18.2/go.mod h1:XyVU5tcJ3PRpk | |||
| 45 | 45 | github.com/coreos/go-systemd/v22 v22.7.0 h1:LAEzFkke61DFROc7zNLX/WA2i5J8gYqe0rSj9KI28KA= | |
| 46 | 46 | github.com/coreos/go-systemd/v22 v22.7.0/go.mod h1:xNUYtjHu2EDXbsxz1i41wouACIwT7Ybq9o0BQhMwD0w= | |
| 47 | 47 | github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= | |
| 48 | - | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= | |
| 49 | - | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= | |
| 48 | + | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= | |
| 49 | + | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= | |
| 50 | 50 | github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= | |
| 51 | 51 | github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= | |
| 52 | 52 | github.com/docker/cli v29.4.0+incompatible h1:+IjXULMetlvWJiuSI0Nbor36lcJ5BTcVpUmB21KBoVM= | |
| @@ -67,6 +67,10 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk | |||
| 67 | 67 | github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= | |
| 68 | 68 | github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= | |
| 69 | 69 | github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= | |
| 70 | + | github.com/go-acme/lego/v4 v4.35.2 h1:uVQg+KC/yj9R2g7Q9W5wDqhvQvxV5SMu5eqFVoN5xZU= | |
| 71 | + | github.com/go-acme/lego/v4 v4.35.2/go.mod h1:pX2jN5n8OphMGY1IaMjYm5DAEzguBaKRt8AvJAgJXpc= | |
| 72 | + | github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= | |
| 73 | + | github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= | |
| 70 | 74 | github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= | |
| 71 | 75 | github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= | |
| 72 | 76 | github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= | |
| @@ -105,6 +109,8 @@ github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2J | |||
| 105 | 109 | github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= | |
| 106 | 110 | github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw= | |
| 107 | 111 | github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= | |
| 112 | + | github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI= | |
| 113 | + | github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs= | |
| 108 | 114 | github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= | |
| 109 | 115 | github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= | |
| 110 | 116 | github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= | |
| @@ -131,8 +137,8 @@ github.com/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf4 | |||
| 131 | 137 | github.com/pelletier/go-toml/v2 v2.3.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= | |
| 132 | 138 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= | |
| 133 | 139 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= | |
| 134 | - | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= | |
| 135 | - | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= | |
| 140 | + | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= | |
| 141 | + | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= | |
| 136 | 142 | github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= | |
| 137 | 143 | github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= | |
| 138 | 144 | github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= | |
| @@ -203,21 +209,25 @@ go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= | |||
| 203 | 209 | go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= | |
| 204 | 210 | go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= | |
| 205 | 211 | go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= | |
| 206 | - | golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= | |
| 207 | - | golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= | |
| 212 | + | golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= | |
| 213 | + | golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= | |
| 214 | + | golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM= | |
| 215 | + | golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80= | |
| 208 | 216 | golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= | |
| 209 | 217 | golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= | |
| 210 | - | golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= | |
| 211 | - | golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= | |
| 218 | + | golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= | |
| 219 | + | golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= | |
| 212 | 220 | golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= | |
| 213 | 221 | golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= | |
| 214 | 222 | golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | |
| 215 | 223 | golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= | |
| 216 | 224 | golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= | |
| 217 | - | golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= | |
| 218 | - | golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= | |
| 225 | + | golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= | |
| 226 | + | golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= | |
| 219 | 227 | golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= | |
| 220 | 228 | golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= | |
| 229 | + | golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= | |
| 230 | + | golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= | |
| 221 | 231 | gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= | |
| 222 | 232 | gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= | |
| 223 | 233 | google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA= | |
A internal/adapters/dto/tls.go +77 -0
| @@ -0,0 +1,77 @@ | |||
| 1 | + | package dto | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "time" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/gordon/internal/domain" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | // TLSStatusResponse represents the public TLS/ACME status for the admin API. | |
| 10 | + | type TLSStatusResponse struct { | |
| 11 | + | ACMEEnabled bool `json:"acme_enabled"` | |
| 12 | + | ConfiguredMode string `json:"configured_mode"` | |
| 13 | + | EffectiveMode string `json:"effective_mode"` | |
| 14 | + | SelectionReason string `json:"selection_reason"` | |
| 15 | + | TokenSource string `json:"token_source"` | |
| 16 | + | Certificates []TLSCertificateEntry `json:"certificates"` | |
| 17 | + | Routes []TLSRouteCoverage `json:"routes"` | |
| 18 | + | Errors []string `json:"errors,omitempty"` | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | // TLSCertificateEntry represents a managed certificate entry in the TLS status. | |
| 22 | + | type TLSCertificateEntry struct { | |
| 23 | + | ID string `json:"id"` | |
| 24 | + | Names []string `json:"names"` | |
| 25 | + | Challenge string `json:"challenge"` | |
| 26 | + | Status string `json:"status"` | |
| 27 | + | NotAfter time.Time `json:"not_after"` | |
| 28 | + | LastError string `json:"last_error,omitempty"` | |
| 29 | + | RenewalPending bool `json:"renewal_pending"` | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | // TLSRouteCoverage represents a route's TLS coverage status. | |
| 33 | + | type TLSRouteCoverage struct { | |
| 34 | + | Domain string `json:"domain"` | |
| 35 | + | Covered bool `json:"covered"` | |
| 36 | + | CoveredBy string `json:"covered_by,omitempty"` | |
| 37 | + | RequiredACME bool `json:"required_acme"` | |
| 38 | + | Error string `json:"error,omitempty"` | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | // TLSStatusFromDomain converts a public TLS domain status to its transport DTO. | |
| 42 | + | func TLSStatusFromDomain(s domain.PublicTLSStatus) TLSStatusResponse { | |
| 43 | + | certs := make([]TLSCertificateEntry, 0, len(s.Certificates)) | |
| 44 | + | for _, c := range s.Certificates { | |
| 45 | + | certs = append(certs, TLSCertificateEntry{ | |
| 46 | + | ID: c.ID, | |
| 47 | + | Names: c.Names, | |
| 48 | + | Challenge: string(c.Challenge), | |
| 49 | + | Status: string(c.Status), | |
| 50 | + | NotAfter: c.NotAfter, | |
| 51 | + | LastError: c.LastError, | |
| 52 | + | RenewalPending: c.RenewalPending, | |
| 53 | + | }) | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | routes := make([]TLSRouteCoverage, 0, len(s.Routes)) | |
| 57 | + | for _, r := range s.Routes { | |
| 58 | + | routes = append(routes, TLSRouteCoverage{ | |
| 59 | + | Domain: r.Domain, | |
| 60 | + | Covered: r.Covered, | |
| 61 | + | CoveredBy: r.CoveredBy, | |
| 62 | + | RequiredACME: r.RequiredACME, | |
| 63 | + | Error: r.Error, | |
| 64 | + | }) | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | return TLSStatusResponse{ | |
| 68 | + | ACMEEnabled: s.ACMEEnabled, | |
| 69 | + | ConfiguredMode: string(s.ConfiguredMode), | |
| 70 | + | EffectiveMode: string(s.EffectiveMode), | |
| 71 | + | SelectionReason: s.SelectionReason, | |
| 72 | + | TokenSource: string(s.TokenSource), | |
| 73 | + | Certificates: certs, | |
| 74 | + | Routes: routes, | |
| 75 | + | Errors: s.Errors, | |
| 76 | + | } | |
| 77 | + | } | |
M internal/adapters/in/cli/attachments_push_test.go +4 -0
| @@ -108,6 +108,10 @@ func (c *attachmentPushTestControlPlane) RemoveAutoRouteAllowedDomain(context.Co | |||
| 108 | 108 | panic("unexpected call") | |
| 109 | 109 | } | |
| 110 | 110 | ||
| 111 | + | func (c *attachmentPushTestControlPlane) GetTLSStatus(context.Context) (*dto.TLSStatusResponse, error) { | |
| 112 | + | return &dto.TLSStatusResponse{}, nil | |
| 113 | + | } | |
| 114 | + | ||
| 111 | 115 | func (c *attachmentPushTestControlPlane) GetStatus(ctx context.Context) (*remote.Status, error) { | |
| 112 | 116 | if c.getStatus != nil { | |
| 113 | 117 | return c.getStatus(ctx) | |
M internal/adapters/in/cli/controlplane.go +1 -0
| @@ -38,6 +38,7 @@ type ControlPlane interface { | |||
| 38 | 38 | RemoveAutoRouteAllowedDomain(ctx context.Context, pattern string) error | |
| 39 | 39 | ||
| 40 | 40 | GetStatus(ctx context.Context) (*remote.Status, error) | |
| 41 | + | GetTLSStatus(ctx context.Context) (*dto.TLSStatusResponse, error) | |
| 41 | 42 | Reload(ctx context.Context) error | |
| 42 | 43 | ListNetworks(ctx context.Context) ([]*domain.NetworkInfo, error) | |
| 43 | 44 | GetConfig(ctx context.Context) (*remote.Config, error) | |
M internal/adapters/in/cli/controlplane_local.go +15 -0
| @@ -25,6 +25,7 @@ type localControlPlane struct { | |||
| 25 | 25 | healthSvc in.HealthService | |
| 26 | 26 | logSvc in.LogService | |
| 27 | 27 | volumeSvc in.VolumeService | |
| 28 | + | publicTLSSvc in.PublicTLSService | |
| 28 | 29 | } | |
| 29 | 30 | ||
| 30 | 31 | func NewLocalControlPlane(kernel *app.Kernel) ControlPlane { | |
| @@ -50,6 +51,7 @@ func NewLocalControlPlane(kernel *app.Kernel) ControlPlane { | |||
| 50 | 51 | healthSvc: kernel.Health(), | |
| 51 | 52 | logSvc: kernel.Logs(), | |
| 52 | 53 | volumeSvc: kernel.Volumes(), | |
| 54 | + | publicTLSSvc: kernel.PublicTLS(), | |
| 53 | 55 | } | |
| 54 | 56 | } | |
| 55 | 57 | ||
| @@ -331,6 +333,19 @@ func (l *localControlPlane) RemoveAutoRouteAllowedDomain(ctx context.Context, pa | |||
| 331 | 333 | return l.configSvc.RemoveAutoRouteAllowedDomain(ctx, pattern) | |
| 332 | 334 | } | |
| 333 | 335 | ||
| 336 | + | func (l *localControlPlane) GetTLSStatus(ctx context.Context) (*dto.TLSStatusResponse, error) { | |
| 337 | + | if l.publicTLSSvc == nil { | |
| 338 | + | return &dto.TLSStatusResponse{ | |
| 339 | + | ACMEEnabled: false, | |
| 340 | + | SelectionReason: "public TLS service not configured", | |
| 341 | + | }, nil | |
| 342 | + | } | |
| 343 | + | ||
| 344 | + | status := l.publicTLSSvc.Status(ctx) | |
| 345 | + | result := dto.TLSStatusFromDomain(status) | |
| 346 | + | return &result, nil | |
| 347 | + | } | |
| 348 | + | ||
| 334 | 349 | func (l *localControlPlane) GetStatus(ctx context.Context) (*remote.Status, error) { | |
| 335 | 350 | if l.configSvc == nil { | |
| 336 | 351 | return nil, fmt.Errorf("local config service unavailable") | |
M internal/adapters/in/cli/controlplane_local_test.go +13 -0
| @@ -5,6 +5,7 @@ import ( | |||
| 5 | 5 | "testing" | |
| 6 | 6 | "time" | |
| 7 | 7 | ||
| 8 | + | "github.com/bnema/gordon/internal/adapters/dto" | |
| 8 | 9 | inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks" | |
| 9 | 10 | "github.com/bnema/gordon/internal/domain" | |
| 10 | 11 | "github.com/stretchr/testify/mock" | |
| @@ -95,6 +96,18 @@ func TestLocalControlPlane_RestartWithAttachments(t *testing.T) { | |||
| 95 | 96 | require.Equal(t, "app.local", result.Domain) | |
| 96 | 97 | } | |
| 97 | 98 | ||
| 99 | + | func TestLocalControlPlane_GetTLSStatusWithoutService(t *testing.T) { | |
| 100 | + | t.Parallel() | |
| 101 | + | ||
| 102 | + | cp := &localControlPlane{} | |
| 103 | + | status, err := cp.GetTLSStatus(context.Background()) | |
| 104 | + | require.NoError(t, err) | |
| 105 | + | require.Equal(t, &dto.TLSStatusResponse{ | |
| 106 | + | ACMEEnabled: false, | |
| 107 | + | SelectionReason: "public TLS service not configured", | |
| 108 | + | }, status) | |
| 109 | + | } | |
| 110 | + | ||
| 98 | 111 | func TestLocalControlPlane_GetContainerLogs(t *testing.T) { | |
| 99 | 112 | t.Parallel() | |
| 100 | 113 | ||
M internal/adapters/in/cli/controlplane_remote.go +4 -0
| @@ -104,6 +104,10 @@ func (r *remoteControlPlane) GetStatus(ctx context.Context) (*remote.Status, err | |||
| 104 | 104 | return r.client.GetStatus(ctx) | |
| 105 | 105 | } | |
| 106 | 106 | ||
| 107 | + | func (r *remoteControlPlane) GetTLSStatus(ctx context.Context) (*dto.TLSStatusResponse, error) { | |
| 108 | + | return r.client.GetTLSStatus(ctx) | |
| 109 | + | } | |
| 110 | + | ||
| 107 | 111 | func (r *remoteControlPlane) Reload(ctx context.Context) error { | |
| 108 | 112 | return r.client.Reload(ctx) | |
| 109 | 113 | } | |
M internal/adapters/in/cli/json_parity_test.go +1 -0
| @@ -25,6 +25,7 @@ func TestAllListCommands_AcceptJSONFlag(t *testing.T) { | |||
| 25 | 25 | {"routes show", newRoutesShowCmd}, | |
| 26 | 26 | {"networks list", newNetworksListCmd}, | |
| 27 | 27 | {"images tags", newImagesTagsCmd}, | |
| 28 | + | {"tls status", newTLSStatusCmd}, | |
| 28 | 29 | } | |
| 29 | 30 | ||
| 30 | 31 | for _, tc := range commands { | |
M internal/adapters/in/cli/push_test.go +4 -0
| @@ -114,6 +114,10 @@ func (c *resolveFromImageTestControlPlane) RemoveAutoRouteAllowedDomain(context. | |||
| 114 | 114 | panic("unexpected call") | |
| 115 | 115 | } | |
| 116 | 116 | ||
| 117 | + | func (c *resolveFromImageTestControlPlane) GetTLSStatus(context.Context) (*dto.TLSStatusResponse, error) { | |
| 118 | + | return &dto.TLSStatusResponse{}, nil | |
| 119 | + | } | |
| 120 | + | ||
| 117 | 121 | func (c *resolveFromImageTestControlPlane) GetStatus(context.Context) (*remote.Status, error) { | |
| 118 | 122 | panic("unexpected call") | |
| 119 | 123 | } | |
M internal/adapters/in/cli/remote/client.go +15 -0
| @@ -657,6 +657,21 @@ type Status struct { | |||
| 657 | 657 | ContainerStatus map[string]string `json:"container_status"` | |
| 658 | 658 | } | |
| 659 | 659 | ||
| 660 | + | // GetTLSStatus returns the public TLS/ACME status. | |
| 661 | + | func (c *Client) GetTLSStatus(ctx context.Context) (*dto.TLSStatusResponse, error) { | |
| 662 | + | resp, err := c.request(ctx, http.MethodGet, "/tls/status", nil) | |
| 663 | + | if err != nil { | |
| 664 | + | return nil, err | |
| 665 | + | } | |
| 666 | + | ||
| 667 | + | var status dto.TLSStatusResponse | |
| 668 | + | if err := parseResponse(resp, &status); err != nil { | |
| 669 | + | return nil, err | |
| 670 | + | } | |
| 671 | + | ||
| 672 | + | return &status, nil | |
| 673 | + | } | |
| 674 | + | ||
| 660 | 675 | // GetStatus returns the Gordon server status. | |
| 661 | 676 | func (c *Client) GetStatus(ctx context.Context) (*Status, error) { | |
| 662 | 677 | resp, err := c.request(ctx, http.MethodGet, "/status", nil) | |
M internal/adapters/in/cli/remote/client_test.go +17 -0
| @@ -116,6 +116,23 @@ func TestClientWithInsecureTLS_AllowsSelfSignedCertificate(t *testing.T) { | |||
| 116 | 116 | require.NoError(t, err) | |
| 117 | 117 | } | |
| 118 | 118 | ||
| 119 | + | func TestClientGetTLSStatus(t *testing.T) { | |
| 120 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 121 | + | require.Equal(t, "/admin/tls/status", r.URL.Path) | |
| 122 | + | require.Equal(t, http.MethodGet, r.Method) | |
| 123 | + | w.Header().Set("Content-Type", "application/json") | |
| 124 | + | _, _ = w.Write([]byte(`{"acme_enabled":true,"effective_mode":"http-01"}`)) | |
| 125 | + | })) | |
| 126 | + | defer srv.Close() | |
| 127 | + | ||
| 128 | + | client := NewClient(srv.URL) | |
| 129 | + | status, err := client.GetTLSStatus(context.Background()) | |
| 130 | + | require.NoError(t, err) | |
| 131 | + | require.NotNil(t, status) | |
| 132 | + | assert.True(t, status.ACMEEnabled) | |
| 133 | + | assert.Equal(t, "http-01", status.EffectiveMode) | |
| 134 | + | } | |
| 135 | + | ||
| 119 | 136 | func TestClientListImages(t *testing.T) { | |
| 120 | 137 | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 121 | 138 | require.Equal(t, "/admin/images", r.URL.Path) | |
M internal/adapters/in/cli/root.go +5 -1
| @@ -149,6 +149,10 @@ Commands are organized by where they run: | |||
| 149 | 149 | previewCmd.GroupID = groupManage | |
| 150 | 150 | rootCmd.AddCommand(previewCmd) | |
| 151 | 151 | ||
| 152 | + | tlsCmd := newTLSCmd() | |
| 153 | + | tlsCmd.GroupID = groupManage | |
| 154 | + | rootCmd.AddCommand(tlsCmd) | |
| 155 | + | ||
| 152 | 156 | // Client-only commands (no server needed) | |
| 153 | 157 | remotesCmd := newRemotesCmd() | |
| 154 | 158 | remotesCmd.GroupID = groupClient | |
| @@ -383,7 +387,7 @@ func runLogsLocal(logsConfigPath, logDomain string, follow bool, lines int) erro | |||
| 383 | 387 | } | |
| 384 | 388 | ||
| 385 | 389 | // showContainerLogsLocal shows container logs using local Docker access. | |
| 386 | - | func showContainerLogsLocal(logsConfigPath, logDomain string, follow bool, lines int) error { | |
| 390 | + | func showContainerLogsLocal(_ string, logDomain string, follow bool, lines int) error { | |
| 387 | 391 | // For local container logs, we need Docker access which requires | |
| 388 | 392 | // the runtime to be initialized. For now, suggest using remote mode | |
| 389 | 393 | // or direct docker logs command. | |
A internal/adapters/in/cli/tls.go +202 -0
| @@ -0,0 +1,202 @@ | |||
| 1 | + | package cli | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "fmt" | |
| 6 | + | "io" | |
| 7 | + | "strings" | |
| 8 | + | "time" | |
| 9 | + | ||
| 10 | + | "github.com/spf13/cobra" | |
| 11 | + | ||
| 12 | + | "github.com/bnema/gordon/internal/adapters/dto" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | // tlsResolveControlPlane allows tests to override control-plane resolution. | |
| 16 | + | var tlsResolveControlPlane = resolveControlPlane | |
| 17 | + | ||
| 18 | + | func newTLSCmd() *cobra.Command { | |
| 19 | + | cmd := &cobra.Command{ | |
| 20 | + | Use: "tls", | |
| 21 | + | Short: "Inspect TLS certificate status", | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | cmd.AddCommand(newTLSStatusCmd()) | |
| 25 | + | ||
| 26 | + | return cmd | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | func newTLSStatusCmd() *cobra.Command { | |
| 30 | + | var jsonOut bool | |
| 31 | + | ||
| 32 | + | cmd := &cobra.Command{ | |
| 33 | + | Use: "status", | |
| 34 | + | Short: "Show public TLS certificate status", | |
| 35 | + | RunE: func(cmd *cobra.Command, _ []string) error { | |
| 36 | + | handle, err := tlsResolveControlPlane(configPath) | |
| 37 | + | if err != nil { | |
| 38 | + | return err | |
| 39 | + | } | |
| 40 | + | defer handle.close() | |
| 41 | + | return runTLSStatusCmd(cmd.Context(), handle.plane, cmd.OutOrStdout(), jsonOut) | |
| 42 | + | }, | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON") | |
| 46 | + | ||
| 47 | + | return cmd | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | func runTLSStatusCmd(ctx context.Context, cp ControlPlane, out io.Writer, jsonOut bool) error { | |
| 51 | + | status, err := cp.GetTLSStatus(ctx) | |
| 52 | + | if err != nil { | |
| 53 | + | return fmt.Errorf("failed to get TLS status: %w", err) | |
| 54 | + | } | |
| 55 | + | if status == nil { | |
| 56 | + | return fmt.Errorf("failed to get TLS status: empty response") | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | if jsonOut { | |
| 60 | + | return writeJSON(out, status) | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | return renderTLSStatusText(out, status) | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | func renderTLSStatusText(out io.Writer, s *dto.TLSStatusResponse) error { | |
| 67 | + | if err := cliWriteLine(out, cliRenderTitle("Public TLS / ACME Status")); err != nil { | |
| 68 | + | return err | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | if err := renderTLSModeSection(out, s); err != nil { | |
| 72 | + | return err | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | if err := renderTLSCertificatesSection(out, s); err != nil { | |
| 76 | + | return err | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | if err := renderTLSRouteCoverageSection(out, s); err != nil { | |
| 80 | + | return err | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | return renderTLSErrorsSection(out, s) | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | func renderTLSModeSection(out io.Writer, s *dto.TLSStatusResponse) error { | |
| 87 | + | acmeStatus := "disabled" | |
| 88 | + | if s.ACMEEnabled { | |
| 89 | + | acmeStatus = "enabled" | |
| 90 | + | } | |
| 91 | + | if err := cliWriteLine(out, cliRenderMeta("ACME:", acmeStatus)); err != nil { | |
| 92 | + | return err | |
| 93 | + | } | |
| 94 | + | if err := cliWriteLine(out, cliRenderMeta("Configured Mode:", s.ConfiguredMode)); err != nil { | |
| 95 | + | return err | |
| 96 | + | } | |
| 97 | + | if err := cliWriteLine(out, cliRenderMeta("Effective Mode:", s.EffectiveMode)); err != nil { | |
| 98 | + | return err | |
| 99 | + | } | |
| 100 | + | if s.SelectionReason != "" { | |
| 101 | + | if err := cliWriteLine(out, cliRenderMeta("Reason:", s.SelectionReason)); err != nil { | |
| 102 | + | return err | |
| 103 | + | } | |
| 104 | + | } | |
| 105 | + | return cliWriteLine(out, cliRenderMeta("Token Source:", s.TokenSource)) | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | func renderTLSCertificatesSection(out io.Writer, s *dto.TLSStatusResponse) error { | |
| 109 | + | if len(s.Certificates) == 0 { | |
| 110 | + | return nil | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | if err := cliWriteLine(out, ""); err != nil { | |
| 114 | + | return err | |
| 115 | + | } | |
| 116 | + | if err := cliWriteLine(out, cliRenderTitle("Certificates")); err != nil { | |
| 117 | + | return err | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | for _, cert := range s.Certificates { | |
| 121 | + | if err := cliWritef(out, " %s\n", cliRenderMeta("ID:", cert.ID)); err != nil { | |
| 122 | + | return err | |
| 123 | + | } | |
| 124 | + | if err := cliWriteLine(out, cliRenderMeta(" Names:", strings.Join(cert.Names, ", "))); err != nil { | |
| 125 | + | return err | |
| 126 | + | } | |
| 127 | + | if err := cliWriteLine(out, cliRenderMeta(" Status:", cert.Status)); err != nil { | |
| 128 | + | return err | |
| 129 | + | } | |
| 130 | + | if !cert.NotAfter.IsZero() { | |
| 131 | + | if err := cliWriteLine(out, cliRenderMeta(" Not After:", cert.NotAfter.Format(time.DateTime))); err != nil { | |
| 132 | + | return err | |
| 133 | + | } | |
| 134 | + | } | |
| 135 | + | if cert.LastError != "" { | |
| 136 | + | if err := cliWriteLine(out, cliRenderMeta(" Last Error:", cert.LastError)); err != nil { | |
| 137 | + | return err | |
| 138 | + | } | |
| 139 | + | } | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | return nil | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | func renderTLSRouteCoverageSection(out io.Writer, s *dto.TLSStatusResponse) error { | |
| 146 | + | if len(s.Routes) == 0 { | |
| 147 | + | return nil | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | if err := cliWriteLine(out, ""); err != nil { | |
| 151 | + | return err | |
| 152 | + | } | |
| 153 | + | if err := cliWriteLine(out, cliRenderTitle("Route Coverage")); err != nil { | |
| 154 | + | return err | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | for _, route := range s.Routes { | |
| 158 | + | covered := "no" | |
| 159 | + | if route.Covered { | |
| 160 | + | covered = "yes" | |
| 161 | + | } | |
| 162 | + | if err := cliWritef(out, " %s covered=%s", route.Domain, covered); err != nil { | |
| 163 | + | return err | |
| 164 | + | } | |
| 165 | + | if route.CoveredBy != "" { | |
| 166 | + | if err := cliWritef(out, " covered_by=%s", route.CoveredBy); err != nil { | |
| 167 | + | return err | |
| 168 | + | } | |
| 169 | + | } | |
| 170 | + | if route.Error != "" { | |
| 171 | + | if err := cliWritef(out, " error=%s", route.Error); err != nil { | |
| 172 | + | return err | |
| 173 | + | } | |
| 174 | + | } | |
| 175 | + | if err := cliWriteLine(out, ""); err != nil { | |
| 176 | + | return err | |
| 177 | + | } | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | return nil | |
| 181 | + | } | |
| 182 | + | ||
| 183 | + | func renderTLSErrorsSection(out io.Writer, s *dto.TLSStatusResponse) error { | |
| 184 | + | if len(s.Errors) == 0 { | |
| 185 | + | return nil | |
| 186 | + | } | |
| 187 | + | ||
| 188 | + | if err := cliWriteLine(out, ""); err != nil { | |
| 189 | + | return err | |
| 190 | + | } | |
| 191 | + | if err := cliWriteLine(out, cliRenderTitle("Errors")); err != nil { | |
| 192 | + | return err | |
| 193 | + | } | |
| 194 | + | ||
| 195 | + | for _, e := range s.Errors { | |
| 196 | + | if err := cliWriteLine(out, " "+e); err != nil { | |
| 197 | + | return err | |
| 198 | + | } | |
| 199 | + | } | |
| 200 | + | ||
| 201 | + | return nil | |
| 202 | + | } | |
A internal/adapters/in/cli/tls_test.go +305 -0
| @@ -0,0 +1,305 @@ | |||
| 1 | + | package cli | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "context" | |
| 6 | + | "encoding/json" | |
| 7 | + | "testing" | |
| 8 | + | "time" | |
| 9 | + | ||
| 10 | + | "github.com/stretchr/testify/assert" | |
| 11 | + | "github.com/stretchr/testify/require" | |
| 12 | + | ||
| 13 | + | "github.com/bnema/gordon/internal/adapters/dto" | |
| 14 | + | "github.com/bnema/gordon/internal/adapters/in/cli/remote" | |
| 15 | + | "github.com/bnema/gordon/internal/domain" | |
| 16 | + | ) | |
| 17 | + | ||
| 18 | + | // tlsTestControlPlane implements ControlPlane for TLS tests. | |
| 19 | + | type tlsTestControlPlane struct { | |
| 20 | + | getTLSStatus func(context.Context) (*dto.TLSStatusResponse, error) | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | var _ ControlPlane = (*tlsTestControlPlane)(nil) | |
| 24 | + | ||
| 25 | + | func (c *tlsTestControlPlane) ListRoutesWithDetails(context.Context) ([]remote.RouteInfo, error) { | |
| 26 | + | panic("unexpected call") | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | func (c *tlsTestControlPlane) GetHealth(context.Context) (map[string]*remote.RouteHealth, error) { | |
| 30 | + | panic("unexpected call") | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | func (c *tlsTestControlPlane) GetRoute(context.Context, string) (*domain.Route, error) { | |
| 34 | + | panic("unexpected call") | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | func (c *tlsTestControlPlane) FindRoutesByImage(context.Context, string) ([]domain.Route, error) { | |
| 38 | + | panic("unexpected call") | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | func (c *tlsTestControlPlane) AddRoute(context.Context, domain.Route) error { | |
| 42 | + | panic("unexpected call") | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | func (c *tlsTestControlPlane) UpdateRoute(context.Context, domain.Route) error { | |
| 46 | + | panic("unexpected call") | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | func (c *tlsTestControlPlane) RemoveRoute(context.Context, string) error { | |
| 50 | + | panic("unexpected call") | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | func (c *tlsTestControlPlane) Bootstrap(context.Context, dto.BootstrapRequest) (*dto.BootstrapResponse, error) { | |
| 54 | + | panic("unexpected call") | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | func (c *tlsTestControlPlane) ListSecretsWithAttachments(context.Context, string) (*remote.SecretsListResult, error) { | |
| 58 | + | panic("unexpected call") | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | func (c *tlsTestControlPlane) SetSecrets(context.Context, string, map[string]string) error { | |
| 62 | + | panic("unexpected call") | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | func (c *tlsTestControlPlane) DeleteSecret(context.Context, string, string) error { | |
| 66 | + | panic("unexpected call") | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | func (c *tlsTestControlPlane) SetAttachmentSecrets(context.Context, string, string, map[string]string) error { | |
| 70 | + | panic("unexpected call") | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | func (c *tlsTestControlPlane) DeleteAttachmentSecret(context.Context, string, string, string) error { | |
| 74 | + | panic("unexpected call") | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | func (c *tlsTestControlPlane) GetAllAttachmentsConfig(context.Context) (map[string][]string, error) { | |
| 78 | + | panic("unexpected call") | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | func (c *tlsTestControlPlane) GetAttachmentsConfig(context.Context, string) ([]string, error) { | |
| 82 | + | panic("unexpected call") | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | func (c *tlsTestControlPlane) FindAttachmentTargetsByImage(context.Context, string) ([]string, error) { | |
| 86 | + | panic("unexpected call") | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | func (c *tlsTestControlPlane) AddAttachment(context.Context, string, string) error { | |
| 90 | + | panic("unexpected call") | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | func (c *tlsTestControlPlane) RemoveAttachment(context.Context, string, string) error { | |
| 94 | + | panic("unexpected call") | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | func (c *tlsTestControlPlane) GetAutoRouteAllowedDomains(context.Context) ([]string, error) { | |
| 98 | + | panic("unexpected call") | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | func (c *tlsTestControlPlane) AddAutoRouteAllowedDomain(context.Context, string) error { | |
| 102 | + | panic("unexpected call") | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | func (c *tlsTestControlPlane) RemoveAutoRouteAllowedDomain(context.Context, string) error { | |
| 106 | + | panic("unexpected call") | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | func (c *tlsTestControlPlane) GetTLSStatus(ctx context.Context) (*dto.TLSStatusResponse, error) { | |
| 110 | + | if c.getTLSStatus != nil { | |
| 111 | + | return c.getTLSStatus(ctx) | |
| 112 | + | } | |
| 113 | + | return nil, nil | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | func (c *tlsTestControlPlane) GetStatus(context.Context) (*remote.Status, error) { | |
| 117 | + | panic("unexpected call") | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | func (c *tlsTestControlPlane) Reload(context.Context) error { | |
| 121 | + | panic("unexpected call") | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | func (c *tlsTestControlPlane) ListNetworks(context.Context) ([]*domain.NetworkInfo, error) { | |
| 125 | + | panic("unexpected call") | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | func (c *tlsTestControlPlane) GetConfig(context.Context) (*remote.Config, error) { | |
| 129 | + | panic("unexpected call") | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | func (c *tlsTestControlPlane) DeployIntent(context.Context, string) error { | |
| 133 | + | panic("unexpected call") | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | func (c *tlsTestControlPlane) Deploy(context.Context, string) (*remote.DeployResult, error) { | |
| 137 | + | panic("unexpected call") | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | func (c *tlsTestControlPlane) Restart(context.Context, string, bool) (*remote.RestartResult, error) { | |
| 141 | + | panic("unexpected call") | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | func (c *tlsTestControlPlane) ListTags(context.Context, string) ([]string, error) { | |
| 145 | + | panic("unexpected call") | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | func (c *tlsTestControlPlane) ListBackups(context.Context, string) ([]dto.BackupJob, error) { | |
| 149 | + | panic("unexpected call") | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | func (c *tlsTestControlPlane) BackupStatus(context.Context) ([]dto.BackupJob, error) { | |
| 153 | + | panic("unexpected call") | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | func (c *tlsTestControlPlane) RunBackup(context.Context, string, string) (*dto.BackupRunResponse, error) { | |
| 157 | + | panic("unexpected call") | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | func (c *tlsTestControlPlane) DetectDatabases(context.Context, string) ([]dto.DatabaseInfo, error) { | |
| 161 | + | panic("unexpected call") | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | func (c *tlsTestControlPlane) GetProcessLogs(context.Context, int) ([]string, error) { | |
| 165 | + | panic("unexpected call") | |
| 166 | + | } | |
| 167 | + | ||
| 168 | + | func (c *tlsTestControlPlane) GetContainerLogs(context.Context, string, int) ([]string, error) { | |
| 169 | + | panic("unexpected call") | |
| 170 | + | } | |
| 171 | + | ||
| 172 | + | func (c *tlsTestControlPlane) StreamProcessLogs(context.Context, int) (<-chan string, error) { | |
| 173 | + | panic("unexpected call") | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | func (c *tlsTestControlPlane) StreamContainerLogs(context.Context, string, int) (<-chan string, error) { | |
| 177 | + | panic("unexpected call") | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | func (c *tlsTestControlPlane) ListVolumes(context.Context) ([]dto.Volume, error) { | |
| 181 | + | panic("unexpected call") | |
| 182 | + | } | |
| 183 | + | ||
| 184 | + | func (c *tlsTestControlPlane) PruneVolumes(context.Context, dto.VolumePruneRequest) (*dto.VolumePruneResponse, error) { | |
| 185 | + | panic("unexpected call") | |
| 186 | + | } | |
| 187 | + | ||
| 188 | + | func TestTLSStatus_HumanOutput(t *testing.T) { | |
| 189 | + | notAfter := time.Date(2026, 5, 29, 12, 0, 0, 0, time.UTC) | |
| 190 | + | ||
| 191 | + | cp := &tlsTestControlPlane{ | |
| 192 | + | getTLSStatus: func(_ context.Context) (*dto.TLSStatusResponse, error) { | |
| 193 | + | return &dto.TLSStatusResponse{ | |
| 194 | + | ACMEEnabled: true, | |
| 195 | + | ConfiguredMode: "acme", | |
| 196 | + | EffectiveMode: "acme-staging", | |
| 197 | + | SelectionReason: "configured", | |
| 198 | + | TokenSource: "env", | |
| 199 | + | Certificates: []dto.TLSCertificateEntry{ | |
| 200 | + | { | |
| 201 | + | ID: "cert-abc123", | |
| 202 | + | Names: []string{"example.com", "www.example.com"}, | |
| 203 | + | Status: "valid", | |
| 204 | + | NotAfter: notAfter, | |
| 205 | + | LastError: "", | |
| 206 | + | }, | |
| 207 | + | }, | |
| 208 | + | Routes: []dto.TLSRouteCoverage{ | |
| 209 | + | { | |
| 210 | + | Domain: "example.com", | |
| 211 | + | Covered: true, | |
| 212 | + | CoveredBy: "cert-abc123", | |
| 213 | + | RequiredACME: true, | |
| 214 | + | }, | |
| 215 | + | { | |
| 216 | + | Domain: "internal.local", | |
| 217 | + | Covered: false, | |
| 218 | + | RequiredACME: false, | |
| 219 | + | Error: "self-signed cert", | |
| 220 | + | }, | |
| 221 | + | }, | |
| 222 | + | Errors: []string{"route internal.local has no ACME cert"}, | |
| 223 | + | }, nil | |
| 224 | + | }, | |
| 225 | + | } | |
| 226 | + | ||
| 227 | + | var buf bytes.Buffer | |
| 228 | + | err := runTLSStatusCmd(context.Background(), cp, &buf, false) | |
| 229 | + | require.NoError(t, err) | |
| 230 | + | ||
| 231 | + | output := buf.String() | |
| 232 | + | t.Log("Human output:\n", output) | |
| 233 | + | ||
| 234 | + | // Check mode info is present | |
| 235 | + | assert.Contains(t, output, "ACME:") | |
| 236 | + | assert.Contains(t, output, "enabled") | |
| 237 | + | assert.Contains(t, output, "Configured Mode:") | |
| 238 | + | assert.Contains(t, output, "acme") | |
| 239 | + | assert.Contains(t, output, "Effective Mode:") | |
| 240 | + | assert.Contains(t, output, "acme-staging") | |
| 241 | + | ||
| 242 | + | // Check token source (not value) | |
| 243 | + | assert.Contains(t, output, "Token Source:") | |
| 244 | + | assert.Contains(t, output, "env") | |
| 245 | + | assert.NotContains(t, output, "token_secret_value") | |
| 246 | + | ||
| 247 | + | // Check certificate ID | |
| 248 | + | assert.Contains(t, output, "cert-abc123") | |
| 249 | + | assert.Contains(t, output, "example.com, www.example.com") | |
| 250 | + | assert.Contains(t, output, "valid") | |
| 251 | + | assert.Contains(t, output, notAfter.Format(time.DateTime)) | |
| 252 | + | ||
| 253 | + | // Check route coverage | |
| 254 | + | assert.Contains(t, output, "example.com") | |
| 255 | + | assert.Contains(t, output, "covered=yes") | |
| 256 | + | assert.Contains(t, output, "covered_by=cert-abc123") | |
| 257 | + | assert.Contains(t, output, "internal.local") | |
| 258 | + | assert.Contains(t, output, "covered=no") | |
| 259 | + | ||
| 260 | + | // Check errors section | |
| 261 | + | assert.Contains(t, output, "Errors") | |
| 262 | + | assert.Contains(t, output, "route internal.local has no ACME cert") | |
| 263 | + | } | |
| 264 | + | ||
| 265 | + | func TestTLSStatus_JSONOutput(t *testing.T) { | |
| 266 | + | notAfter := time.Date(2026, 5, 29, 12, 0, 0, 0, time.UTC) | |
| 267 | + | ||
| 268 | + | cp := &tlsTestControlPlane{ | |
| 269 | + | getTLSStatus: func(_ context.Context) (*dto.TLSStatusResponse, error) { | |
| 270 | + | return &dto.TLSStatusResponse{ | |
| 271 | + | ACMEEnabled: true, | |
| 272 | + | ConfiguredMode: "acme", | |
| 273 | + | EffectiveMode: "acme-staging", | |
| 274 | + | TokenSource: "env", | |
| 275 | + | Certificates: []dto.TLSCertificateEntry{ | |
| 276 | + | { | |
| 277 | + | ID: "cert-abc123", | |
| 278 | + | Names: []string{"example.com", "www.example.com"}, | |
| 279 | + | Status: "valid", | |
| 280 | + | NotAfter: notAfter, | |
| 281 | + | LastError: "", | |
| 282 | + | }, | |
| 283 | + | }, | |
| 284 | + | }, nil | |
| 285 | + | }, | |
| 286 | + | } | |
| 287 | + | ||
| 288 | + | var buf bytes.Buffer | |
| 289 | + | err := runTLSStatusCmd(context.Background(), cp, &buf, true) | |
| 290 | + | require.NoError(t, err) | |
| 291 | + | ||
| 292 | + | var result dto.TLSStatusResponse | |
| 293 | + | err = json.Unmarshal(buf.Bytes(), &result) | |
| 294 | + | require.NoError(t, err) | |
| 295 | + | ||
| 296 | + | assert.True(t, result.ACMEEnabled) | |
| 297 | + | assert.Equal(t, "acme", result.ConfiguredMode) | |
| 298 | + | assert.Equal(t, "acme-staging", result.EffectiveMode) | |
| 299 | + | assert.Equal(t, "env", result.TokenSource) | |
| 300 | + | require.Len(t, result.Certificates, 1) | |
| 301 | + | assert.Equal(t, "cert-abc123", result.Certificates[0].ID) | |
| 302 | + | assert.Equal(t, []string{"example.com", "www.example.com"}, result.Certificates[0].Names) | |
| 303 | + | assert.Equal(t, "valid", result.Certificates[0].Status) | |
| 304 | + | assert.Equal(t, notAfter.Format(time.RFC3339), result.Certificates[0].NotAfter.Format(time.RFC3339)) | |
| 305 | + | } | |
A internal/adapters/in/http/acme/handler.go +71 -0
| @@ -0,0 +1,71 @@ | |||
| 1 | + | // Package acme provides an HTTP handler for the ACME HTTP-01 challenge. | |
| 2 | + | // It serves key authorizations at /.well-known/acme-challenge/{token}. | |
| 3 | + | package acme | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "net/http" | |
| 8 | + | "strings" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/gordon/internal/domain" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | // Prefix is the path prefix for ACME HTTP-01 challenge requests. | |
| 14 | + | const Prefix = "/.well-known/acme-challenge/" | |
| 15 | + | ||
| 16 | + | // ChallengeService defines the interface for retrieving HTTP-01 challenge tokens. | |
| 17 | + | type ChallengeService interface { | |
| 18 | + | GetHTTP01Challenge(ctx context.Context, token string) (keyAuth string, ok bool) | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | // Handler serves ACME HTTP-01 challenge responses. | |
| 22 | + | type Handler struct { | |
| 23 | + | svc ChallengeService | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | // NewHandler creates a new Handler with the given ChallengeService. | |
| 27 | + | func NewHandler(svc ChallengeService) *Handler { | |
| 28 | + | return &Handler{svc: svc} | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | // ServeHTTP handles incoming HTTP requests for ACME HTTP-01 challenges. | |
| 32 | + | // Only GET and HEAD methods are allowed; all others return 405 Method Not Allowed. | |
| 33 | + | // The token must be a safe path component and must exist in the challenge store. | |
| 34 | + | func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { | |
| 35 | + | if r.Method != http.MethodGet && r.Method != http.MethodHead { | |
| 36 | + | w.Header().Set("Allow", "GET, HEAD") | |
| 37 | + | http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) | |
| 38 | + | return | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | if !strings.HasPrefix(r.URL.Path, Prefix) { | |
| 42 | + | http.NotFound(w, r) | |
| 43 | + | return | |
| 44 | + | } | |
| 45 | + | token := strings.TrimPrefix(r.URL.Path, Prefix) | |
| 46 | + | if !domain.IsValidHTTP01Token(token) { | |
| 47 | + | http.NotFound(w, r) | |
| 48 | + | return | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | if h.svc == nil { | |
| 52 | + | http.NotFound(w, r) | |
| 53 | + | return | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | keyAuth, ok := h.svc.GetHTTP01Challenge(r.Context(), token) | |
| 57 | + | if !ok { | |
| 58 | + | http.NotFound(w, r) | |
| 59 | + | return | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | w.Header().Set("Content-Type", "text/plain; charset=utf-8") | |
| 63 | + | w.Header().Set("Cache-Control", "no-store") | |
| 64 | + | w.WriteHeader(http.StatusOK) | |
| 65 | + | ||
| 66 | + | if r.Method == http.MethodHead { | |
| 67 | + | return | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | _, _ = w.Write([]byte(keyAuth)) | |
| 71 | + | } | |
A internal/adapters/in/http/acme/handler_test.go +123 -0
| @@ -0,0 +1,123 @@ | |||
| 1 | + | package acme | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "io" | |
| 6 | + | "net/http" | |
| 7 | + | "net/http/httptest" | |
| 8 | + | "testing" | |
| 9 | + | ||
| 10 | + | "github.com/stretchr/testify/assert" | |
| 11 | + | "github.com/stretchr/testify/require" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // challengeSvc is a simple map-backed implementation of ChallengeService for testing. | |
| 15 | + | type challengeSvc map[string]string | |
| 16 | + | ||
| 17 | + | func (s challengeSvc) GetHTTP01Challenge(_ context.Context, token string) (string, bool) { | |
| 18 | + | keyAuth, ok := s[token] | |
| 19 | + | return keyAuth, ok | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | func newTestServer(t *testing.T, svc ChallengeService) *httptest.Server { | |
| 23 | + | t.Helper() | |
| 24 | + | server := httptest.NewServer(NewHandler(svc)) | |
| 25 | + | t.Cleanup(server.Close) | |
| 26 | + | return server | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | func readBody(t *testing.T, resp *http.Response) string { | |
| 30 | + | t.Helper() | |
| 31 | + | defer resp.Body.Close() | |
| 32 | + | body, err := io.ReadAll(resp.Body) | |
| 33 | + | require.NoError(t, err) | |
| 34 | + | return string(body) | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | func TestHandlerServesGETChallenge(t *testing.T) { | |
| 38 | + | server := newTestServer(t, challengeSvc{"abc": "key-auth"}) | |
| 39 | + | ||
| 40 | + | resp, err := http.Get(server.URL + "/.well-known/acme-challenge/abc") | |
| 41 | + | require.NoError(t, err) | |
| 42 | + | ||
| 43 | + | assert.Equal(t, http.StatusOK, resp.StatusCode) | |
| 44 | + | assert.Equal(t, "text/plain; charset=utf-8", resp.Header.Get("Content-Type")) | |
| 45 | + | assert.Equal(t, "no-store", resp.Header.Get("Cache-Control")) | |
| 46 | + | assert.Equal(t, "key-auth", readBody(t, resp)) | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | func TestHandlerServesHEADChallenge(t *testing.T) { | |
| 50 | + | server := newTestServer(t, challengeSvc{"abc": "key-auth"}) | |
| 51 | + | ||
| 52 | + | resp, err := http.Head(server.URL + "/.well-known/acme-challenge/abc") | |
| 53 | + | require.NoError(t, err) | |
| 54 | + | defer resp.Body.Close() | |
| 55 | + | ||
| 56 | + | assert.Equal(t, http.StatusOK, resp.StatusCode) | |
| 57 | + | assert.Equal(t, "text/plain; charset=utf-8", resp.Header.Get("Content-Type")) | |
| 58 | + | assert.Equal(t, "no-store", resp.Header.Get("Cache-Control")) | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | func TestHandlerRejectsUnsafeToken(t *testing.T) { | |
| 62 | + | tests := []struct { | |
| 63 | + | name string | |
| 64 | + | path string | |
| 65 | + | }{ | |
| 66 | + | {name: "path traversal", path: "/.well-known/acme-challenge/../secret"}, | |
| 67 | + | {name: "slash in token", path: "/.well-known/acme-challenge/foo/bar"}, | |
| 68 | + | {name: "empty token", path: "/.well-known/acme-challenge/"}, | |
| 69 | + | {name: "backslash in token", path: "/.well-known/acme-challenge/foo%5Cbar"}, | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | for _, tt := range tests { | |
| 73 | + | t.Run(tt.name, func(t *testing.T) { | |
| 74 | + | server := newTestServer(t, challengeSvc{"abc": "key-auth"}) | |
| 75 | + | resp, err := http.Get(server.URL + tt.path) | |
| 76 | + | require.NoError(t, err) | |
| 77 | + | defer resp.Body.Close() | |
| 78 | + | ||
| 79 | + | assert.Equal(t, http.StatusNotFound, resp.StatusCode, "path: %s", tt.path) | |
| 80 | + | }) | |
| 81 | + | } | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | func TestHandlerRejectsPOST(t *testing.T) { | |
| 85 | + | server := newTestServer(t, challengeSvc{"abc": "key-auth"}) | |
| 86 | + | ||
| 87 | + | resp, err := http.Post(server.URL+"/.well-known/acme-challenge/abc", "text/plain", nil) | |
| 88 | + | require.NoError(t, err) | |
| 89 | + | defer resp.Body.Close() | |
| 90 | + | ||
| 91 | + | assert.Equal(t, http.StatusMethodNotAllowed, resp.StatusCode) | |
| 92 | + | assert.Equal(t, "GET, HEAD", resp.Header.Get("Allow")) | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | func TestHandlerRejectsMissingPrefix(t *testing.T) { | |
| 96 | + | server := newTestServer(t, challengeSvc{"abc": "key-auth"}) | |
| 97 | + | ||
| 98 | + | resp, err := http.Get(server.URL + "/some-other-path") | |
| 99 | + | require.NoError(t, err) | |
| 100 | + | defer resp.Body.Close() | |
| 101 | + | ||
| 102 | + | assert.Equal(t, http.StatusNotFound, resp.StatusCode) | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | func TestHandlerRejectsUnknownToken(t *testing.T) { | |
| 106 | + | server := newTestServer(t, challengeSvc{"abc": "key-auth"}) | |
| 107 | + | ||
| 108 | + | resp, err := http.Get(server.URL + "/.well-known/acme-challenge/unknown") | |
| 109 | + | require.NoError(t, err) | |
| 110 | + | defer resp.Body.Close() | |
| 111 | + | ||
| 112 | + | assert.Equal(t, http.StatusNotFound, resp.StatusCode) | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | func TestHandlerRejectsNilService(t *testing.T) { | |
| 116 | + | server := newTestServer(t, nil) | |
| 117 | + | ||
| 118 | + | resp, err := http.Get(server.URL + "/.well-known/acme-challenge/abc") | |
| 119 | + | require.NoError(t, err) | |
| 120 | + | defer resp.Body.Close() | |
| 121 | + | ||
| 122 | + | assert.Equal(t, http.StatusNotFound, resp.StatusCode) | |
| 123 | + | } | |
M internal/adapters/in/http/admin/handler.go +32 -4
| @@ -52,6 +52,7 @@ type Handler struct { | |||
| 52 | 52 | registrySvc registryDeployService | |
| 53 | 53 | previewSvc previewService | |
| 54 | 54 | reloadTrigger reloadTrigger | |
| 55 | + | publicTLSSvc in.PublicTLSService | |
| 55 | 56 | log zerowrap.Logger | |
| 56 | 57 | } | |
| 57 | 58 | ||
| @@ -149,6 +150,7 @@ type HandlerDeps struct { | |||
| 149 | 150 | ImageSvc in.ImageService | |
| 150 | 151 | VolumeSvc in.VolumeService | |
| 151 | 152 | ReloadTrigger reloadTrigger | |
| 153 | + | PublicTLSSvc in.PublicTLSService | |
| 152 | 154 | } | |
| 153 | 155 | ||
| 154 | 156 | // NewHandler creates a new admin HTTP handler. | |
| @@ -166,6 +168,7 @@ func NewHandler(deps HandlerDeps) *Handler { | |||
| 166 | 168 | registrySvc: deps.RegistrySvc, | |
| 167 | 169 | previewSvc: deps.PreviewSvc, | |
| 168 | 170 | reloadTrigger: deps.ReloadTrigger, | |
| 171 | + | publicTLSSvc: deps.PublicTLSSvc, | |
| 169 | 172 | log: deps.Log, | |
| 170 | 173 | } | |
| 171 | 174 | } | |
| @@ -216,6 +219,7 @@ func (h *Handler) matchRoute(path string) (routeHandler, bool) { | |||
| 216 | 219 | "/auth/verify": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleAuthVerify(w, r) }, | |
| 217 | 220 | "/volumes": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleListVolumes(w, r) }, | |
| 218 | 221 | "/volumes/prune": func(w http.ResponseWriter, r *http.Request, _ string) { h.handlePruneVolumes(w, r) }, | |
| 222 | + | "/tls/status": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleTLSStatus(w, r) }, | |
| 219 | 223 | } | |
| 220 | 224 | if handler, ok := exactRoutes[path]; ok { | |
| 221 | 225 | return handler, true | |
| @@ -541,8 +545,7 @@ func (h *Handler) handleRoutesGet(w http.ResponseWriter, r *http.Request, routeD | |||
| 541 | 545 | return | |
| 542 | 546 | } | |
| 543 | 547 | ||
| 544 | - | if strings.HasSuffix(routeDomain, "/attachments") { | |
| 545 | - | parentDomain := strings.TrimSuffix(routeDomain, "/attachments") | |
| 548 | + | if parentDomain, ok := strings.CutSuffix(routeDomain, "/attachments"); ok { | |
| 546 | 549 | if parentDomain == "" { | |
| 547 | 550 | h.sendError(w, http.StatusBadRequest, "domain required in path") | |
| 548 | 551 | return | |
| @@ -1321,8 +1324,7 @@ func (h *Handler) handleDeploy(w http.ResponseWriter, r *http.Request, path stri | |||
| 1321 | 1324 | container, err := h.containerSvc.Deploy(domain.WithInternalDeploy(ctx), *route) | |
| 1322 | 1325 | if err != nil { | |
| 1323 | 1326 | log.Error().Err(err).Str("domain", deployDomain).Msg("failed to deploy container") | |
| 1324 | - | var deployErr *domain.DeployFailureError | |
| 1325 | - | if errors.As(err, &deployErr) { | |
| 1327 | + | if deployErr, ok := errors.AsType[*domain.DeployFailureError](err); ok { | |
| 1326 | 1328 | response := dto.DeployErrorResponse{ | |
| 1327 | 1329 | Error: deployErr.Error(), | |
| 1328 | 1330 | Cause: deployErr.Cause, | |
| @@ -1906,3 +1908,29 @@ func (h *Handler) handleAuthVerify(w http.ResponseWriter, r *http.Request) { | |||
| 1906 | 1908 | ||
| 1907 | 1909 | h.sendJSON(w, http.StatusOK, response) | |
| 1908 | 1910 | } | |
| 1911 | + | ||
| 1912 | + | // handleTLSStatus handles GET /admin/tls/status. | |
| 1913 | + | func (h *Handler) handleTLSStatus(w http.ResponseWriter, r *http.Request) { | |
| 1914 | + | if r.Method != http.MethodGet { | |
| 1915 | + | h.sendError(w, http.StatusMethodNotAllowed, "method not allowed") | |
| 1916 | + | return | |
| 1917 | + | } | |
| 1918 | + | ||
| 1919 | + | ctx := r.Context() | |
| 1920 | + | ||
| 1921 | + | if !HasAccess(ctx, domain.AdminResourceStatus, domain.AdminActionRead) { | |
| 1922 | + | h.sendError(w, http.StatusForbidden, "insufficient permissions for status:read") | |
| 1923 | + | return | |
| 1924 | + | } | |
| 1925 | + | ||
| 1926 | + | if h.publicTLSSvc == nil { | |
| 1927 | + | h.sendJSON(w, http.StatusOK, dto.TLSStatusResponse{ | |
| 1928 | + | ACMEEnabled: false, | |
| 1929 | + | SelectionReason: "public TLS service not configured", | |
| 1930 | + | }) | |
| 1931 | + | return | |
| 1932 | + | } | |
| 1933 | + | ||
| 1934 | + | status := h.publicTLSSvc.Status(ctx) | |
| 1935 | + | h.sendJSON(w, http.StatusOK, dto.TLSStatusFromDomain(status)) | |
| 1936 | + | } | |
M internal/adapters/in/http/admin/handler_test.go +88 -0
| @@ -2585,3 +2585,91 @@ func TestHandler_Images_Authorization(t *testing.T) { | |||
| 2585 | 2585 | assert.Contains(t, rec.Body.String(), "insufficient permissions for config:write") | |
| 2586 | 2586 | }) | |
| 2587 | 2587 | } | |
| 2588 | + | ||
| 2589 | + | func TestHandler_TLSStatus_RequiresStatusReadScope(t *testing.T) { | |
| 2590 | + | handler := newTestHandler(t, func(d *HandlerDeps) { | |
| 2591 | + | d.PublicTLSSvc = inmocks.NewMockPublicTLSService(t) | |
| 2592 | + | }) | |
| 2593 | + | ||
| 2594 | + | server := newScopedTestServer(t, handler, "admin:config:write") | |
| 2595 | + | resp, err := http.Get(server.URL + "/admin/tls/status") | |
| 2596 | + | require.NoError(t, err) | |
| 2597 | + | defer resp.Body.Close() | |
| 2598 | + | ||
| 2599 | + | body, err := io.ReadAll(resp.Body) | |
| 2600 | + | require.NoError(t, err) | |
| 2601 | + | ||
| 2602 | + | assert.Equal(t, http.StatusForbidden, resp.StatusCode) | |
| 2603 | + | assert.Contains(t, string(body), "insufficient permissions for status:read") | |
| 2604 | + | } | |
| 2605 | + | ||
| 2606 | + | func TestHandler_TLSStatus_GETReturnsJSON(t *testing.T) { | |
| 2607 | + | now := time.Now() | |
| 2608 | + | tlsStatus := domain.PublicTLSStatus{ | |
| 2609 | + | ACMEEnabled: true, | |
| 2610 | + | ConfiguredMode: domain.ACMEChallengeAuto, | |
| 2611 | + | EffectiveMode: domain.ACMEChallengeHTTP01, | |
| 2612 | + | SelectionReason: "auto-selected", | |
| 2613 | + | TokenSource: domain.ACMETokenSourcePass, | |
| 2614 | + | Certificates: []domain.ManagedCertificate{ | |
| 2615 | + | { | |
| 2616 | + | ID: "cert-1", | |
| 2617 | + | Names: []string{"example.com"}, | |
| 2618 | + | Challenge: domain.ACMEChallengeHTTP01, | |
| 2619 | + | Status: domain.TLSCertificateStatusValid, | |
| 2620 | + | NotAfter: now.Add(60 * 24 * time.Hour), | |
| 2621 | + | }, | |
| 2622 | + | }, | |
| 2623 | + | Routes: []domain.TLSRouteCoverage{ | |
| 2624 | + | { | |
| 2625 | + | Domain: "example.com", | |
| 2626 | + | Covered: true, | |
| 2627 | + | CoveredBy: "cert-1", | |
| 2628 | + | RequiredACME: true, | |
| 2629 | + | }, | |
| 2630 | + | }, | |
| 2631 | + | } | |
| 2632 | + | ||
| 2633 | + | handler := newTestHandler(t, func(d *HandlerDeps) { | |
| 2634 | + | fakeSvc := inmocks.NewMockPublicTLSService(t) | |
| 2635 | + | fakeSvc.EXPECT().Status(mock.Anything).Return(tlsStatus) | |
| 2636 | + | d.PublicTLSSvc = fakeSvc | |
| 2637 | + | }) | |
| 2638 | + | ||
| 2639 | + | server := newScopedTestServer(t, handler, "admin:status:read") | |
| 2640 | + | resp, err := http.Get(server.URL + "/admin/tls/status") | |
| 2641 | + | require.NoError(t, err) | |
| 2642 | + | defer resp.Body.Close() | |
| 2643 | + | ||
| 2644 | + | assert.Equal(t, http.StatusOK, resp.StatusCode) | |
| 2645 | + | ||
| 2646 | + | var body dto.TLSStatusResponse | |
| 2647 | + | err = json.NewDecoder(resp.Body).Decode(&body) | |
| 2648 | + | require.NoError(t, err) | |
| 2649 | + | ||
| 2650 | + | assert.True(t, body.ACMEEnabled) | |
| 2651 | + | assert.Equal(t, "auto", body.ConfiguredMode) | |
| 2652 | + | assert.Equal(t, "http-01", body.EffectiveMode) | |
| 2653 | + | assert.Equal(t, "auto-selected", body.SelectionReason) | |
| 2654 | + | assert.Equal(t, "pass", body.TokenSource) | |
| 2655 | + | require.Len(t, body.Certificates, 1) | |
| 2656 | + | assert.Equal(t, "cert-1", body.Certificates[0].ID) | |
| 2657 | + | assert.Equal(t, "valid", body.Certificates[0].Status) | |
| 2658 | + | assert.Equal(t, "http-01", body.Certificates[0].Challenge) | |
| 2659 | + | require.Len(t, body.Routes, 1) | |
| 2660 | + | assert.Equal(t, "example.com", body.Routes[0].Domain) | |
| 2661 | + | assert.True(t, body.Routes[0].Covered) | |
| 2662 | + | } | |
| 2663 | + | ||
| 2664 | + | func TestHandler_TLSStatus_POSTReturns405(t *testing.T) { | |
| 2665 | + | handler := newTestHandler(t, func(d *HandlerDeps) { | |
| 2666 | + | d.PublicTLSSvc = inmocks.NewMockPublicTLSService(t) | |
| 2667 | + | }) | |
| 2668 | + | ||
| 2669 | + | server := newScopedTestServer(t, handler, "admin:status:read", "admin:status:write") | |
| 2670 | + | resp, err := http.Post(server.URL+"/admin/tls/status", "application/json", nil) | |
| 2671 | + | require.NoError(t, err) | |
| 2672 | + | defer resp.Body.Close() | |
| 2673 | + | ||
| 2674 | + | assert.Equal(t, http.StatusMethodNotAllowed, resp.StatusCode) | |
| 2675 | + | } | |
A internal/adapters/out/acmelego/cloudflare.go +261 -0
| @@ -0,0 +1,261 @@ | |||
| 1 | + | package acmelego | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "encoding/json" | |
| 6 | + | "errors" | |
| 7 | + | "fmt" | |
| 8 | + | "io" | |
| 9 | + | "net/http" | |
| 10 | + | "net/url" | |
| 11 | + | "strings" | |
| 12 | + | "sync" | |
| 13 | + | "time" | |
| 14 | + | ||
| 15 | + | "github.com/bnema/gordon/internal/boundaries/out" | |
| 16 | + | ) | |
| 17 | + | ||
| 18 | + | // zoneAPIResponse is a minimal Cloudflare API response for zone listing. | |
| 19 | + | type zoneAPIResponse struct { | |
| 20 | + | Success bool `json:"success"` | |
| 21 | + | Errors []zoneAPIError `json:"errors"` | |
| 22 | + | Result []zoneAPIResult `json:"result"` | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | type zoneAPIError struct { | |
| 26 | + | Code int `json:"code"` | |
| 27 | + | Message string `json:"message"` | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | type zoneAPIResult struct { | |
| 31 | + | ID string `json:"id"` | |
| 32 | + | Name string `json:"name"` | |
| 33 | + | Status string `json:"status"` | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | const defaultCloudflareBaseURL = "https://api.cloudflare.com/client/v4" | |
| 37 | + | ||
| 38 | + | // CloudflareZoneResolver resolves a hostname to its Cloudflare DNS zone by | |
| 39 | + | // searching from the full hostname down to the TLD. | |
| 40 | + | type CloudflareZoneResolver struct { | |
| 41 | + | token string | |
| 42 | + | baseURL string | |
| 43 | + | client *http.Client | |
| 44 | + | ||
| 45 | + | mu sync.RWMutex | |
| 46 | + | // cache uses a small fixed maximum size; eviction scans the map to keep the | |
| 47 | + | // resolver simple because defaultCacheMaxSize is intentionally low. | |
| 48 | + | cache map[string]zoneCacheEntry | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | type zoneCacheEntry struct { | |
| 52 | + | zone out.CloudflareZone | |
| 53 | + | err error | |
| 54 | + | createdAt time.Time | |
| 55 | + | ttl time.Duration | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | const ( | |
| 59 | + | defaultCacheTTL = 10 * time.Minute | |
| 60 | + | defaultErrorCacheTTL = time.Minute | |
| 61 | + | defaultCloudflareTimeout = 10 * time.Second | |
| 62 | + | defaultCacheMaxSize = 100 | |
| 63 | + | defaultMaxResponseSize = 1 << 20 // 1 MB | |
| 64 | + | ) | |
| 65 | + | ||
| 66 | + | // CloudflareZoneResolverOption configures a CloudflareZoneResolver. | |
| 67 | + | type CloudflareZoneResolverOption func(*CloudflareZoneResolver) | |
| 68 | + | ||
| 69 | + | // WithCloudflareBaseURL sets the base URL for the Cloudflare API. | |
| 70 | + | func WithCloudflareBaseURL(baseURL string) CloudflareZoneResolverOption { | |
| 71 | + | return func(r *CloudflareZoneResolver) { | |
| 72 | + | r.baseURL = strings.TrimRight(baseURL, "/") | |
| 73 | + | } | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | // WithCloudflareHTTPClient sets the HTTP client for the resolver. | |
| 77 | + | func WithCloudflareHTTPClient(client *http.Client) CloudflareZoneResolverOption { | |
| 78 | + | return func(r *CloudflareZoneResolver) { | |
| 79 | + | if client != nil { | |
| 80 | + | r.client = client | |
| 81 | + | } | |
| 82 | + | } | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | // NewCloudflareZoneResolver creates a new CloudflareZoneResolver. | |
| 86 | + | func NewCloudflareZoneResolver(token string, opts ...CloudflareZoneResolverOption) *CloudflareZoneResolver { | |
| 87 | + | r := &CloudflareZoneResolver{ | |
| 88 | + | token: token, | |
| 89 | + | baseURL: defaultCloudflareBaseURL, | |
| 90 | + | client: &http.Client{Timeout: defaultCloudflareTimeout}, | |
| 91 | + | cache: make(map[string]zoneCacheEntry), | |
| 92 | + | } | |
| 93 | + | for _, opt := range opts { | |
| 94 | + | opt(r) | |
| 95 | + | } | |
| 96 | + | return r | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | // compile-time interface check | |
| 100 | + | var _ out.CloudflareZoneResolver = (*CloudflareZoneResolver)(nil) | |
| 101 | + | ||
| 102 | + | // FindZone finds the most specific Cloudflare zone for the given domain. | |
| 103 | + | // It searches from the full hostname down to the TLD (e.g. for | |
| 104 | + | // api.prod.example.com it tries api.prod.example.com, prod.example.com, | |
| 105 | + | // example.com, com) and returns the first active zone found. | |
| 106 | + | func (r *CloudflareZoneResolver) FindZone(ctx context.Context, domainName string) (out.CloudflareZone, error) { | |
| 107 | + | domainName = strings.TrimSuffix(strings.TrimSpace(domainName), ".") | |
| 108 | + | domainName = strings.ToLower(domainName) | |
| 109 | + | if domainName == "" { | |
| 110 | + | return out.CloudflareZone{}, fmt.Errorf("cloudflare zone resolver: empty domain") | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | if cached, ok := r.cached(domainName); ok { | |
| 114 | + | return cached.zone, cached.err | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | parts := strings.Split(domainName, ".") | |
| 118 | + | ||
| 119 | + | // candidateCount is the total possible candidates (full domain down to TLD). | |
| 120 | + | // For "api.prod.example.com" -> 4 candidates; for "example.com" -> 2. | |
| 121 | + | candidateCount := len(parts) | |
| 122 | + | candidates := make([]string, 0, candidateCount) | |
| 123 | + | ||
| 124 | + | // Build candidates from full hostname down to the TLD. | |
| 125 | + | // For parts ["api","prod","example","com"]: | |
| 126 | + | // "api.prod.example.com", "prod.example.com", "example.com", "com" | |
| 127 | + | for i := range parts { | |
| 128 | + | candidate := strings.Join(parts[i:], ".") | |
| 129 | + | candidates = append(candidates, candidate) | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | var candidateErrs []error | |
| 133 | + | for _, candidate := range candidates { | |
| 134 | + | if cached, ok := r.cached(candidate); ok { | |
| 135 | + | if cached.err == nil { | |
| 136 | + | r.storeCache(domainName, cached.zone, nil) | |
| 137 | + | return cached.zone, nil | |
| 138 | + | } | |
| 139 | + | candidateErrs = append(candidateErrs, fmt.Errorf("%s: %w", candidate, cached.err)) | |
| 140 | + | continue | |
| 141 | + | } | |
| 142 | + | zone, err := r.findZoneByName(ctx, candidate) | |
| 143 | + | r.storeCache(candidate, zone, err) | |
| 144 | + | if err != nil { | |
| 145 | + | candidateErrs = append(candidateErrs, fmt.Errorf("%s: %w", candidate, err)) | |
| 146 | + | continue | |
| 147 | + | } | |
| 148 | + | r.storeCache(domainName, zone, nil) | |
| 149 | + | return zone, nil | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | err := fmt.Errorf("cloudflare zone resolver: no active zone found for %q: %w", domainName, errors.Join(candidateErrs...)) | |
| 153 | + | r.storeCache(domainName, out.CloudflareZone{}, err) | |
| 154 | + | return out.CloudflareZone{}, err | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | func (r *CloudflareZoneResolver) cached(name string) (zoneCacheEntry, bool) { | |
| 158 | + | r.mu.RLock() | |
| 159 | + | entry, ok := r.cache[name] | |
| 160 | + | if !ok { | |
| 161 | + | r.mu.RUnlock() | |
| 162 | + | return entry, false | |
| 163 | + | } | |
| 164 | + | if time.Since(entry.createdAt) > entry.ttl { | |
| 165 | + | r.mu.RUnlock() | |
| 166 | + | r.mu.Lock() | |
| 167 | + | if current, ok := r.cache[name]; ok && current.createdAt.Equal(entry.createdAt) { | |
| 168 | + | delete(r.cache, name) | |
| 169 | + | } | |
| 170 | + | r.mu.Unlock() | |
| 171 | + | return zoneCacheEntry{}, false | |
| 172 | + | } | |
| 173 | + | r.mu.RUnlock() | |
| 174 | + | return entry, true | |
| 175 | + | } | |
| 176 | + | ||
| 177 | + | func (r *CloudflareZoneResolver) storeCache(name string, zone out.CloudflareZone, err error) { | |
| 178 | + | r.mu.Lock() | |
| 179 | + | defer r.mu.Unlock() | |
| 180 | + | if len(r.cache) >= defaultCacheMaxSize { | |
| 181 | + | // Evict oldest entry with an O(n) scan. The cache is deliberately small | |
| 182 | + | // (defaultCacheMaxSize), so this stays simple without an LRU list. | |
| 183 | + | var oldestKey string | |
| 184 | + | var oldestTime time.Time | |
| 185 | + | for k, v := range r.cache { | |
| 186 | + | if oldestKey == "" || v.createdAt.Before(oldestTime) { | |
| 187 | + | oldestKey = k | |
| 188 | + | oldestTime = v.createdAt | |
| 189 | + | } | |
| 190 | + | } | |
| 191 | + | delete(r.cache, oldestKey) | |
| 192 | + | } | |
| 193 | + | ttl := defaultCacheTTL | |
| 194 | + | if err != nil { | |
| 195 | + | ttl = defaultErrorCacheTTL | |
| 196 | + | } | |
| 197 | + | r.cache[name] = zoneCacheEntry{zone: zone, err: err, createdAt: time.Now(), ttl: ttl} | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | // findZoneByName queries the Cloudflare API for an active zone with the exact given name. | |
| 201 | + | func (r *CloudflareZoneResolver) findZoneByName(ctx context.Context, name string) (out.CloudflareZone, error) { | |
| 202 | + | u, err := url.Parse(r.baseURL + "/zones") | |
| 203 | + | if err != nil { | |
| 204 | + | return out.CloudflareZone{}, fmt.Errorf("parse url: %w", err) | |
| 205 | + | } | |
| 206 | + | ||
| 207 | + | q := u.Query() | |
| 208 | + | q.Set("name", name) | |
| 209 | + | q.Set("status", "active") | |
| 210 | + | u.RawQuery = q.Encode() | |
| 211 | + | ||
| 212 | + | req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) | |
| 213 | + | if err != nil { | |
| 214 | + | return out.CloudflareZone{}, fmt.Errorf("create request: %w", err) | |
| 215 | + | } | |
| 216 | + | req.Header.Set("Authorization", "Bearer "+r.token) | |
| 217 | + | req.Header.Set("Accept", "application/json") | |
| 218 | + | ||
| 219 | + | resp, err := r.client.Do(req) | |
| 220 | + | if err != nil { | |
| 221 | + | return out.CloudflareZone{}, fmt.Errorf("request failed: %w", err) | |
| 222 | + | } | |
| 223 | + | defer resp.Body.Close() | |
| 224 | + | ||
| 225 | + | lr := io.LimitReader(resp.Body, defaultMaxResponseSize+1) | |
| 226 | + | body, err := io.ReadAll(lr) | |
| 227 | + | if err != nil { | |
| 228 | + | return out.CloudflareZone{}, fmt.Errorf("read response: %w", err) | |
| 229 | + | } | |
| 230 | + | if len(body) > defaultMaxResponseSize { | |
| 231 | + | return out.CloudflareZone{}, fmt.Errorf("cloudflare api: response body too large (%d bytes)", len(body)) | |
| 232 | + | } | |
| 233 | + | ||
| 234 | + | if resp.StatusCode != http.StatusOK { | |
| 235 | + | return out.CloudflareZone{}, fmt.Errorf("cloudflare api: status %d", resp.StatusCode) | |
| 236 | + | } | |
| 237 | + | ||
| 238 | + | var apiResp zoneAPIResponse | |
| 239 | + | if err := json.Unmarshal(body, &apiResp); err != nil { | |
| 240 | + | return out.CloudflareZone{}, fmt.Errorf("parse response: %w", err) | |
| 241 | + | } | |
| 242 | + | ||
| 243 | + | if !apiResp.Success { | |
| 244 | + | errMsg := "cloudflare api returned unsuccessfully" | |
| 245 | + | if len(apiResp.Errors) > 0 { | |
| 246 | + | errMsg = apiResp.Errors[0].Message | |
| 247 | + | } | |
| 248 | + | return out.CloudflareZone{}, fmt.Errorf("cloudflare api: %s", errMsg) | |
| 249 | + | } | |
| 250 | + | ||
| 251 | + | for _, z := range apiResp.Result { | |
| 252 | + | if z.Name == name && z.Status == "active" { | |
| 253 | + | return out.CloudflareZone{ | |
| 254 | + | ID: z.ID, | |
| 255 | + | Name: z.Name, | |
| 256 | + | }, nil | |
| 257 | + | } | |
| 258 | + | } | |
| 259 | + | ||
| 260 | + | return out.CloudflareZone{}, fmt.Errorf("no active zone found for %q", name) | |
| 261 | + | } | |
A internal/adapters/out/acmelego/cloudflare_test.go +261 -0
| @@ -0,0 +1,261 @@ | |||
| 1 | + | package acmelego | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "encoding/json" | |
| 6 | + | "errors" | |
| 7 | + | "net/http" | |
| 8 | + | "net/http/httptest" | |
| 9 | + | "testing" | |
| 10 | + | ||
| 11 | + | "github.com/stretchr/testify/assert" | |
| 12 | + | "github.com/stretchr/testify/require" | |
| 13 | + | ||
| 14 | + | "github.com/bnema/gordon/internal/boundaries/out" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | func TestCloudflareZoneResolverFindsParentZone(t *testing.T) { | |
| 18 | + | var requestCount int | |
| 19 | + | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 20 | + | requestCount++ | |
| 21 | + | ||
| 22 | + | // Verify auth header | |
| 23 | + | assert.Equal(t, "Bearer test-token", r.Header.Get("Authorization")) | |
| 24 | + | ||
| 25 | + | // Verify query parameters | |
| 26 | + | name := r.URL.Query().Get("name") | |
| 27 | + | status := r.URL.Query().Get("status") | |
| 28 | + | assert.Equal(t, "active", status) | |
| 29 | + | ||
| 30 | + | switch name { | |
| 31 | + | case "api.prod.example.com": | |
| 32 | + | // Return empty - no zone found | |
| 33 | + | w.Header().Set("Content-Type", "application/json") | |
| 34 | + | json.NewEncoder(w).Encode(zoneAPIResponse{ | |
| 35 | + | Success: true, | |
| 36 | + | Result: []zoneAPIResult{}, | |
| 37 | + | }) | |
| 38 | + | case "prod.example.com": | |
| 39 | + | // Return empty - no zone found | |
| 40 | + | w.Header().Set("Content-Type", "application/json") | |
| 41 | + | json.NewEncoder(w).Encode(zoneAPIResponse{ | |
| 42 | + | Success: true, | |
| 43 | + | Result: []zoneAPIResult{}, | |
| 44 | + | }) | |
| 45 | + | case "example.com": | |
| 46 | + | // Return active zone | |
| 47 | + | w.Header().Set("Content-Type", "application/json") | |
| 48 | + | json.NewEncoder(w).Encode(zoneAPIResponse{ | |
| 49 | + | Success: true, | |
| 50 | + | Result: []zoneAPIResult{ | |
| 51 | + | { | |
| 52 | + | ID: "zone-id-123", | |
| 53 | + | Name: "example.com", | |
| 54 | + | Status: "active", | |
| 55 | + | }, | |
| 56 | + | }, | |
| 57 | + | }) | |
| 58 | + | default: | |
| 59 | + | t.Errorf("unexpected zone name query: %s", name) | |
| 60 | + | } | |
| 61 | + | })) | |
| 62 | + | defer server.Close() | |
| 63 | + | ||
| 64 | + | resolver := NewCloudflareZoneResolver("test-token", | |
| 65 | + | WithCloudflareBaseURL(server.URL)) | |
| 66 | + | ||
| 67 | + | zone, err := resolver.FindZone(context.Background(), "api.prod.example.com") | |
| 68 | + | require.NoError(t, err) | |
| 69 | + | assert.Equal(t, "zone-id-123", zone.ID) | |
| 70 | + | assert.Equal(t, "example.com", zone.Name) | |
| 71 | + | assert.Equal(t, 3, requestCount, "should have made 3 API calls") | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | func TestCloudflareZoneResolverDoesNotLeakTokenInError(t *testing.T) { | |
| 75 | + | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 76 | + | // Return a 500 error | |
| 77 | + | w.WriteHeader(http.StatusInternalServerError) | |
| 78 | + | w.Write([]byte(`{"success":false,"errors":[]}`)) | |
| 79 | + | })) | |
| 80 | + | defer server.Close() | |
| 81 | + | ||
| 82 | + | resolver := NewCloudflareZoneResolver("super-secret-token-abc123", | |
| 83 | + | WithCloudflareBaseURL(server.URL)) | |
| 84 | + | ||
| 85 | + | _, err := resolver.FindZone(context.Background(), "example.com") | |
| 86 | + | require.Error(t, err) | |
| 87 | + | errStr := err.Error() | |
| 88 | + | ||
| 89 | + | // The error must NOT contain the token | |
| 90 | + | assert.NotContains(t, errStr, "super-secret-token-abc123") | |
| 91 | + | assert.NotContains(t, errStr, "secret-token") | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | func TestCloudflareZoneResolverSimpleDomain(t *testing.T) { | |
| 95 | + | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 96 | + | name := r.URL.Query().Get("name") | |
| 97 | + | assert.Equal(t, "example.com", name) | |
| 98 | + | ||
| 99 | + | w.Header().Set("Content-Type", "application/json") | |
| 100 | + | json.NewEncoder(w).Encode(zoneAPIResponse{ | |
| 101 | + | Success: true, | |
| 102 | + | Result: []zoneAPIResult{ | |
| 103 | + | { | |
| 104 | + | ID: "zone-1", | |
| 105 | + | Name: "example.com", | |
| 106 | + | Status: "active", | |
| 107 | + | }, | |
| 108 | + | }, | |
| 109 | + | }) | |
| 110 | + | })) | |
| 111 | + | defer server.Close() | |
| 112 | + | ||
| 113 | + | resolver := NewCloudflareZoneResolver("test-token", | |
| 114 | + | WithCloudflareBaseURL(server.URL)) | |
| 115 | + | ||
| 116 | + | zone, err := resolver.FindZone(context.Background(), "example.com") | |
| 117 | + | require.NoError(t, err) | |
| 118 | + | assert.Equal(t, "zone-1", zone.ID) | |
| 119 | + | assert.Equal(t, "example.com", zone.Name) | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | func TestCloudflareZoneResolverNoZoneFound(t *testing.T) { | |
| 123 | + | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 124 | + | w.Header().Set("Content-Type", "application/json") | |
| 125 | + | json.NewEncoder(w).Encode(zoneAPIResponse{ | |
| 126 | + | Success: true, | |
| 127 | + | Result: []zoneAPIResult{}, | |
| 128 | + | }) | |
| 129 | + | })) | |
| 130 | + | defer server.Close() | |
| 131 | + | ||
| 132 | + | resolver := NewCloudflareZoneResolver("test-token", | |
| 133 | + | WithCloudflareBaseURL(server.URL)) | |
| 134 | + | ||
| 135 | + | _, err := resolver.FindZone(context.Background(), "missing.example.com") | |
| 136 | + | require.Error(t, err) | |
| 137 | + | assert.Contains(t, err.Error(), "no active zone found") | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | func TestCloudflareZoneResolverCachesErrorsWithShortTTL(t *testing.T) { | |
| 141 | + | resolver := NewCloudflareZoneResolver("token") | |
| 142 | + | resolver.storeCache("example.com", out.CloudflareZone{}, errors.New("temporary failure")) | |
| 143 | + | ||
| 144 | + | entry, ok := resolver.cached("example.com") | |
| 145 | + | require.True(t, ok) | |
| 146 | + | assert.Error(t, entry.err) | |
| 147 | + | assert.Equal(t, defaultErrorCacheTTL, entry.ttl) | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | func TestCloudflareZoneResolverUnsuccessfulAPI(t *testing.T) { | |
| 151 | + | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 152 | + | w.Header().Set("Content-Type", "application/json") | |
| 153 | + | json.NewEncoder(w).Encode(zoneAPIResponse{ | |
| 154 | + | Success: false, | |
| 155 | + | Errors: []zoneAPIError{ | |
| 156 | + | {Code: 1000, Message: "authentication error"}, | |
| 157 | + | }, | |
| 158 | + | Result: []zoneAPIResult{}, | |
| 159 | + | }) | |
| 160 | + | })) | |
| 161 | + | defer server.Close() | |
| 162 | + | ||
| 163 | + | resolver := NewCloudflareZoneResolver("bad-token", | |
| 164 | + | WithCloudflareBaseURL(server.URL)) | |
| 165 | + | ||
| 166 | + | _, err := resolver.FindZone(context.Background(), "example.com") | |
| 167 | + | require.Error(t, err) | |
| 168 | + | // The resolver iterates all candidates; the final error is the generic one. | |
| 169 | + | assert.Contains(t, err.Error(), "no active zone found") | |
| 170 | + | // Token must never leak in error messages. | |
| 171 | + | assert.NotContains(t, err.Error(), "bad-token") | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | // Ensure the interface compliance. | |
| 175 | + | func TestCloudflareZoneResolverImplementsInterface(t *testing.T) { | |
| 176 | + | var _ out.CloudflareZoneResolver = (*CloudflareZoneResolver)(nil) | |
| 177 | + | ||
| 178 | + | // Test NewCloudflareZoneResolver returns non-nil | |
| 179 | + | r := NewCloudflareZoneResolver("token") | |
| 180 | + | require.NotNil(t, r) | |
| 181 | + | ||
| 182 | + | // Check defaults | |
| 183 | + | assert.Equal(t, "https://api.cloudflare.com/client/v4", r.baseURL) | |
| 184 | + | assert.NotNil(t, r.client) | |
| 185 | + | assert.Equal(t, defaultCloudflareTimeout, r.client.Timeout) | |
| 186 | + | } | |
| 187 | + | ||
| 188 | + | func TestWithCloudflareHTTPClientNilLeavesDefault(t *testing.T) { | |
| 189 | + | // Passing nil must not replace the default HTTP client. | |
| 190 | + | r := NewCloudflareZoneResolver("token", WithCloudflareHTTPClient(nil)) | |
| 191 | + | require.NotNil(t, r.client) | |
| 192 | + | assert.Equal(t, defaultCloudflareTimeout, r.client.Timeout, | |
| 193 | + | "default timeout should be preserved when nil is passed") | |
| 194 | + | } | |
| 195 | + | ||
| 196 | + | func TestCloudflareZoneResolverFindZoneLowercasesInput(t *testing.T) { | |
| 197 | + | // The server expects lowercase names; uppercase input must be normalized. | |
| 198 | + | callCount := 0 | |
| 199 | + | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 200 | + | callCount++ | |
| 201 | + | name := r.URL.Query().Get("name") | |
| 202 | + | assert.Equal(t, "example.com", name, "should receive lowercased domain, got %q", name) | |
| 203 | + | ||
| 204 | + | w.Header().Set("Content-Type", "application/json") | |
| 205 | + | json.NewEncoder(w).Encode(zoneAPIResponse{ | |
| 206 | + | Success: true, | |
| 207 | + | Result: []zoneAPIResult{ | |
| 208 | + | {ID: "zone-1", Name: "example.com", Status: "active"}, | |
| 209 | + | }, | |
| 210 | + | }) | |
| 211 | + | })) | |
| 212 | + | defer server.Close() | |
| 213 | + | ||
| 214 | + | resolver := NewCloudflareZoneResolver("test-token", | |
| 215 | + | WithCloudflareBaseURL(server.URL)) | |
| 216 | + | ||
| 217 | + | // Uppercase input — should lowercase to EXAMPLE.COM -> example.com | |
| 218 | + | zone, err := resolver.FindZone(context.Background(), "EXAMPLE.COM") | |
| 219 | + | require.NoError(t, err) | |
| 220 | + | assert.Equal(t, "zone-1", zone.ID) | |
| 221 | + | assert.Equal(t, 1, callCount) | |
| 222 | + | } | |
| 223 | + | ||
| 224 | + | func TestCloudflareZoneResolverFindZoneMixedCaseWithTrailingDot(t *testing.T) { | |
| 225 | + | // Mixed case with trailing dot should be lowercased and dot-stripped. | |
| 226 | + | // The search chain tries "mixed.example.com" (empty), then "example.com" (found). | |
| 227 | + | callCount := 0 | |
| 228 | + | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 229 | + | callCount++ | |
| 230 | + | name := r.URL.Query().Get("name") | |
| 231 | + | switch name { | |
| 232 | + | case "mixed.example.com": | |
| 233 | + | // No zone found for subdomain | |
| 234 | + | w.Header().Set("Content-Type", "application/json") | |
| 235 | + | json.NewEncoder(w).Encode(zoneAPIResponse{ | |
| 236 | + | Success: true, | |
| 237 | + | Result: []zoneAPIResult{}, | |
| 238 | + | }) | |
| 239 | + | case "example.com": | |
| 240 | + | w.Header().Set("Content-Type", "application/json") | |
| 241 | + | json.NewEncoder(w).Encode(zoneAPIResponse{ | |
| 242 | + | Success: true, | |
| 243 | + | Result: []zoneAPIResult{ | |
| 244 | + | {ID: "zone-1", Name: "example.com", Status: "active"}, | |
| 245 | + | }, | |
| 246 | + | }) | |
| 247 | + | default: | |
| 248 | + | t.Errorf("unexpected zone name query: %s", name) | |
| 249 | + | } | |
| 250 | + | })) | |
| 251 | + | defer server.Close() | |
| 252 | + | ||
| 253 | + | resolver := NewCloudflareZoneResolver("test-token", | |
| 254 | + | WithCloudflareBaseURL(server.URL)) | |
| 255 | + | ||
| 256 | + | zone, err := resolver.FindZone(context.Background(), "Mixed.EXAMPLE.Com.") | |
| 257 | + | require.NoError(t, err) | |
| 258 | + | assert.Equal(t, "zone-1", zone.ID) | |
| 259 | + | // First call: mixed.example.com (empty), second: example.com (found) | |
| 260 | + | assert.Equal(t, 2, callCount, "should try mixed.example.com then find example.com") | |
| 261 | + | } | |
A internal/adapters/out/acmelego/http_provider.go +41 -0
| @@ -0,0 +1,41 @@ | |||
| 1 | + | // Package acmelego implements ACME certificate operations using the lego library. | |
| 2 | + | package acmelego | |
| 3 | + | ||
| 4 | + | import ( | |
| 5 | + | "fmt" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/gordon/internal/boundaries/out" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // HTTPProvider adapts an out.HTTPChallengeSink to the lego challenge.Provider | |
| 11 | + | // interface for HTTP-01 challenge solving. | |
| 12 | + | type HTTPProvider struct { | |
| 13 | + | sink out.HTTPChallengeSink | |
| 14 | + | } | |
| 15 | + | ||
| 16 | + | // NewHTTPProvider creates a new HTTPProvider. | |
| 17 | + | func NewHTTPProvider(sink out.HTTPChallengeSink) *HTTPProvider { | |
| 18 | + | return &HTTPProvider{sink: sink} | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | // Present stores the key authorization for the given token via the sink. | |
| 22 | + | func (p *HTTPProvider) Present(_domain, token, keyAuth string) error { | |
| 23 | + | if p.sink == nil { | |
| 24 | + | return fmt.Errorf("http-01 sink is nil: cannot Present") | |
| 25 | + | } | |
| 26 | + | if err := p.sink.Present(token, keyAuth); err != nil { | |
| 27 | + | return fmt.Errorf("present http-01 challenge: %w", err) | |
| 28 | + | } | |
| 29 | + | return nil | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | // CleanUp removes the key authorization for the given token via the sink. | |
| 33 | + | func (p *HTTPProvider) CleanUp(_domain, token, _keyAuth string) error { | |
| 34 | + | if p.sink == nil { | |
| 35 | + | return fmt.Errorf("http-01 sink is nil: cannot CleanUp") | |
| 36 | + | } | |
| 37 | + | if err := p.sink.CleanUp(token); err != nil { | |
| 38 | + | return fmt.Errorf("cleanup http-01 challenge: %w", err) | |
| 39 | + | } | |
| 40 | + | return nil | |
| 41 | + | } | |
A internal/adapters/out/acmelego/http_provider_test.go +35 -0
| @@ -0,0 +1,35 @@ | |||
| 1 | + | package acmelego | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/stretchr/testify/assert" | |
| 7 | + | ||
| 8 | + | outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func TestHTTPProviderStoresAndCleansChallenge(t *testing.T) { | |
| 12 | + | mockSink := outmocks.NewMockHTTPChallengeSink(t) | |
| 13 | + | mockSink.EXPECT().Present("token", "key-auth").Return(nil) | |
| 14 | + | mockSink.EXPECT().CleanUp("token").Return(nil) | |
| 15 | + | ||
| 16 | + | provider := NewHTTPProvider(mockSink) | |
| 17 | + | ||
| 18 | + | err := provider.Present("example.com", "token", "key-auth") | |
| 19 | + | assert.NoError(t, err) | |
| 20 | + | ||
| 21 | + | err = provider.CleanUp("example.com", "token", "key-auth") | |
| 22 | + | assert.NoError(t, err) | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | func TestHTTPProviderNilSink(t *testing.T) { | |
| 26 | + | provider := NewHTTPProvider(nil) | |
| 27 | + | ||
| 28 | + | err := provider.Present("example.com", "token", "key-auth") | |
| 29 | + | assert.Error(t, err) | |
| 30 | + | assert.Contains(t, err.Error(), "sink is nil") | |
| 31 | + | ||
| 32 | + | err = provider.CleanUp("example.com", "token", "key-auth") | |
| 33 | + | assert.Error(t, err) | |
| 34 | + | assert.Contains(t, err.Error(), "sink is nil") | |
| 35 | + | } | |
A internal/adapters/out/acmelego/issuer.go +356 -0
| @@ -0,0 +1,356 @@ | |||
| 1 | + | // Package acmelego implements ACME certificate operations using the lego library. | |
| 2 | + | package acmelego | |
| 3 | + | ||
| 4 | + | import ( | |
| 5 | + | "context" | |
| 6 | + | "crypto/tls" | |
| 7 | + | "crypto/x509" | |
| 8 | + | "encoding/json" | |
| 9 | + | "fmt" | |
| 10 | + | "net/http" | |
| 11 | + | "strings" | |
| 12 | + | "sync" | |
| 13 | + | "time" | |
| 14 | + | ||
| 15 | + | "github.com/go-acme/lego/v4/acme" | |
| 16 | + | "github.com/go-acme/lego/v4/certcrypto" | |
| 17 | + | "github.com/go-acme/lego/v4/certificate" | |
| 18 | + | "github.com/go-acme/lego/v4/lego" | |
| 19 | + | "github.com/go-acme/lego/v4/providers/dns/cloudflare" | |
| 20 | + | "github.com/go-acme/lego/v4/registration" | |
| 21 | + | ||
| 22 | + | "github.com/bnema/gordon/internal/boundaries/out" | |
| 23 | + | "github.com/bnema/gordon/internal/domain" | |
| 24 | + | ) | |
| 25 | + | ||
| 26 | + | // Config holds the configuration for the ACME certificate issuer. | |
| 27 | + | type Config struct { | |
| 28 | + | // Email is the ACME account contact email. | |
| 29 | + | Email string | |
| 30 | + | ||
| 31 | + | // Challenge is the ACME challenge mode to use. | |
| 32 | + | Challenge domain.ACMEChallengeMode | |
| 33 | + | ||
| 34 | + | // Token is the Cloudflare API token (required for DNS-01 challenge). | |
| 35 | + | Token string | |
| 36 | + | ||
| 37 | + | // Store persists ACME accounts and certificates. | |
| 38 | + | Store out.CertificateStore | |
| 39 | + | ||
| 40 | + | // HTTPChallengeSink is used for HTTP-01 challenge token storage. | |
| 41 | + | HTTPChallengeSink out.HTTPChallengeSink | |
| 42 | + | ||
| 43 | + | // CADirectoryURL is the ACME directory URL. If empty, letsencrypt production is used. | |
| 44 | + | CADirectoryURL string | |
| 45 | + | ||
| 46 | + | // HTTPClient is an optional HTTP client for the ACME client. If nil, a default is used. | |
| 47 | + | HTTPClient *http.Client | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | // Issuer implements out.PublicCertificateIssuer using the lego ACME client. | |
| 51 | + | type Issuer struct { | |
| 52 | + | cfg Config | |
| 53 | + | mu sync.Mutex | |
| 54 | + | client *lego.Client | |
| 55 | + | user *AccountUser | |
| 56 | + | initializing bool | |
| 57 | + | initDone chan struct{} | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | // NewIssuer creates a new Issuer. Config is validated; the ACME account is | |
| 61 | + | // loaded or created lazily on first Obtain or Renew call. | |
| 62 | + | func NewIssuer(cfg Config) (*Issuer, error) { | |
| 63 | + | if cfg.Email == "" { | |
| 64 | + | return nil, fmt.Errorf("acmelego: %w", domain.ErrACMEEmailRequired) | |
| 65 | + | } | |
| 66 | + | if cfg.Store == nil { | |
| 67 | + | return nil, fmt.Errorf("acmelego: %w", domain.ErrCertificateStoreRequired) | |
| 68 | + | } | |
| 69 | + | if cfg.CADirectoryURL != "" && !strings.HasPrefix(cfg.CADirectoryURL, "https://") { | |
| 70 | + | return nil, fmt.Errorf("acmelego: CADirectoryURL must use HTTPS, got %q", cfg.CADirectoryURL) | |
| 71 | + | } | |
| 72 | + | if cfg.Challenge == "" { | |
| 73 | + | cfg.Challenge = domain.ACMEChallengeHTTP01 | |
| 74 | + | } | |
| 75 | + | switch cfg.Challenge { | |
| 76 | + | case domain.ACMEChallengeHTTP01: | |
| 77 | + | if cfg.HTTPChallengeSink == nil { | |
| 78 | + | return nil, fmt.Errorf("acmelego: %w", domain.ErrHTTPChallengeSinkRequired) | |
| 79 | + | } | |
| 80 | + | case domain.ACMEChallengeCloudflareDNS01: | |
| 81 | + | if cfg.Token == "" { | |
| 82 | + | return nil, fmt.Errorf("acmelego: %w", domain.ErrCloudflareTokenMissing) | |
| 83 | + | } | |
| 84 | + | default: | |
| 85 | + | return nil, fmt.Errorf("acmelego: %w: %s", domain.ErrACMEChallengeInvalid, cfg.Challenge) | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | return &Issuer{cfg: cfg}, nil | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | // compile-time interface check | |
| 92 | + | var _ out.PublicCertificateIssuer = (*Issuer)(nil) | |
| 93 | + | ||
| 94 | + | // Obtain obtains a new certificate for the given order. The ACME client is | |
| 95 | + | // initialized lazily on the first call. | |
| 96 | + | func (i *Issuer) Obtain(ctx context.Context, order out.CertificateOrder) (*out.StoredCertificate, error) { | |
| 97 | + | if err := i.ensureClient(ctx); err != nil { | |
| 98 | + | return nil, err | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | resource, err := i.client.Certificate.Obtain(certificate.ObtainRequest{ | |
| 102 | + | Domains: order.Names, | |
| 103 | + | Bundle: true, | |
| 104 | + | }) | |
| 105 | + | if err != nil { | |
| 106 | + | return nil, fmt.Errorf("obtain certificate: %w", err) | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | return resourceToStored(order, resource) | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | // Renew renews an existing certificate. The ACME client is initialized lazily | |
| 113 | + | // on the first call. Bundle and MustStaple are set to true/false respectively. | |
| 114 | + | func (i *Issuer) Renew(ctx context.Context, cert out.StoredCertificate) (*out.StoredCertificate, error) { | |
| 115 | + | if err := i.ensureClient(ctx); err != nil { | |
| 116 | + | return nil, err | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | domainName := "" | |
| 120 | + | if len(cert.Names) > 0 { | |
| 121 | + | domainName = cert.Names[0] | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | legoCert := certificate.Resource{ | |
| 125 | + | Domain: domainName, | |
| 126 | + | PrivateKey: cert.PrivateKeyPEM, | |
| 127 | + | Certificate: cert.CertPEM, | |
| 128 | + | IssuerCertificate: cert.ChainPEM, | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | // If FullchainPEM is available but CertPEM is empty, use FullchainPEM. | |
| 132 | + | if len(legoCert.Certificate) == 0 && len(cert.FullchainPEM) > 0 { | |
| 133 | + | legoCert.Certificate = cert.FullchainPEM | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | renewed, err := i.client.Certificate.RenewWithOptions(legoCert, &certificate.RenewOptions{ | |
| 137 | + | Bundle: true, | |
| 138 | + | }) | |
| 139 | + | if err != nil { | |
| 140 | + | return nil, fmt.Errorf("renew certificate: %w", err) | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | // Preserve the original order info | |
| 144 | + | order := out.CertificateOrder{ | |
| 145 | + | ID: cert.ID, | |
| 146 | + | Names: cert.Names, | |
| 147 | + | Challenge: cert.Challenge, | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | return resourceToStored(order, renewed) | |
| 151 | + | } | |
| 152 | + | ||
| 153 | + | // ensureClient initializes the lego client and ACME account if not yet done. | |
| 154 | + | func (i *Issuer) ensureClient(ctx context.Context) error { | |
| 155 | + | for { | |
| 156 | + | i.mu.Lock() | |
| 157 | + | if i.client != nil { | |
| 158 | + | i.mu.Unlock() | |
| 159 | + | return nil | |
| 160 | + | } | |
| 161 | + | if i.initializing { | |
| 162 | + | done := i.initDone | |
| 163 | + | i.mu.Unlock() | |
| 164 | + | select { | |
| 165 | + | case <-done: | |
| 166 | + | continue | |
| 167 | + | case <-ctx.Done(): | |
| 168 | + | return ctx.Err() | |
| 169 | + | } | |
| 170 | + | } | |
| 171 | + | i.initializing = true | |
| 172 | + | i.initDone = make(chan struct{}) | |
| 173 | + | done := i.initDone | |
| 174 | + | i.mu.Unlock() | |
| 175 | + | ||
| 176 | + | user, client, err := i.buildClient(ctx) | |
| 177 | + | ||
| 178 | + | i.mu.Lock() | |
| 179 | + | if err == nil { | |
| 180 | + | i.user = user | |
| 181 | + | i.client = client | |
| 182 | + | } | |
| 183 | + | i.initializing = false | |
| 184 | + | close(done) | |
| 185 | + | i.initDone = nil | |
| 186 | + | i.mu.Unlock() | |
| 187 | + | ||
| 188 | + | return err | |
| 189 | + | } | |
| 190 | + | } | |
| 191 | + | ||
| 192 | + | func (i *Issuer) buildClient(ctx context.Context) (*AccountUser, *lego.Client, error) { | |
| 193 | + | // Load or create ACME account | |
| 194 | + | user, err := i.loadOrCreateAccount(ctx) | |
| 195 | + | if err != nil { | |
| 196 | + | return nil, nil, fmt.Errorf("acme account: %w", err) | |
| 197 | + | } | |
| 198 | + | ||
| 199 | + | client, err := lego.NewClient(i.newLegoConfig(user)) | |
| 200 | + | if err != nil { | |
| 201 | + | return nil, nil, fmt.Errorf("create lego client: %w", err) | |
| 202 | + | } | |
| 203 | + | ||
| 204 | + | // Set up challenge providers | |
| 205 | + | switch i.cfg.Challenge { | |
| 206 | + | case domain.ACMEChallengeHTTP01: | |
| 207 | + | if err := client.Challenge.SetHTTP01Provider(NewHTTPProvider(i.cfg.HTTPChallengeSink)); err != nil { | |
| 208 | + | return nil, nil, fmt.Errorf("set http-01 provider: %w", err) | |
| 209 | + | } | |
| 210 | + | case domain.ACMEChallengeCloudflareDNS01: | |
| 211 | + | cfCfg := cloudflare.NewDefaultConfig() | |
| 212 | + | cfCfg.AuthToken = i.cfg.Token | |
| 213 | + | cfProvider, err := cloudflare.NewDNSProviderConfig(cfCfg) | |
| 214 | + | if err != nil { | |
| 215 | + | return nil, nil, fmt.Errorf("create cloudflare dns provider: %w", err) | |
| 216 | + | } | |
| 217 | + | if err := client.Challenge.SetDNS01Provider(cfProvider); err != nil { | |
| 218 | + | return nil, nil, fmt.Errorf("set dns-01 provider: %w", err) | |
| 219 | + | } | |
| 220 | + | } | |
| 221 | + | ||
| 222 | + | return user, client, nil | |
| 223 | + | } | |
| 224 | + | ||
| 225 | + | // loadOrCreateAccount loads an existing ACME account from the store or creates | |
| 226 | + | // a new one. If creating, it registers the account with the ACME server. | |
| 227 | + | func (i *Issuer) loadOrCreateAccount(ctx context.Context) (*AccountUser, error) { | |
| 228 | + | stored, err := i.cfg.Store.LoadAccount(ctx) | |
| 229 | + | if err != nil { | |
| 230 | + | return nil, fmt.Errorf("load account: %w", err) | |
| 231 | + | } | |
| 232 | + | ||
| 233 | + | if stored != nil && len(stored.PrivateKeyPEM) > 0 { | |
| 234 | + | return restoreAccount(stored) | |
| 235 | + | } | |
| 236 | + | ||
| 237 | + | // Generate new ECDSA P-256 private key for the ACME account | |
| 238 | + | privateKey, err := certcrypto.GeneratePrivateKey(certcrypto.EC256) | |
| 239 | + | if err != nil { | |
| 240 | + | return nil, fmt.Errorf("generate account key: %w", err) | |
| 241 | + | } | |
| 242 | + | ||
| 243 | + | user := NewAccountUser(i.cfg.Email, privateKey, nil) | |
| 244 | + | ||
| 245 | + | // We need a temporary client to register the account | |
| 246 | + | tmpClient, err := lego.NewClient(i.newLegoConfig(user)) | |
| 247 | + | if err != nil { | |
| 248 | + | return nil, fmt.Errorf("create temporary lego client: %w", err) | |
| 249 | + | } | |
| 250 | + | ||
| 251 | + | reg, err := tmpClient.Registration.Register(registration.RegisterOptions{ | |
| 252 | + | TermsOfServiceAgreed: true, | |
| 253 | + | }) | |
| 254 | + | if err != nil { | |
| 255 | + | return nil, fmt.Errorf("register acme account: %w", err) | |
| 256 | + | } | |
| 257 | + | ||
| 258 | + | user = NewAccountUser(i.cfg.Email, privateKey, reg) | |
| 259 | + | ||
| 260 | + | // Persist the account | |
| 261 | + | bodyJSON, err := json.Marshal(reg.Body) | |
| 262 | + | if err != nil { | |
| 263 | + | return nil, fmt.Errorf("marshal account body: %w", err) | |
| 264 | + | } | |
| 265 | + | ||
| 266 | + | privateKeyPEM := certcrypto.PEMEncode(privateKey) | |
| 267 | + | ||
| 268 | + | if err := i.cfg.Store.SaveAccount(ctx, out.ACMEAccount{ | |
| 269 | + | Email: i.cfg.Email, | |
| 270 | + | PrivateKeyPEM: privateKeyPEM, | |
| 271 | + | RegistrationURI: reg.URI, | |
| 272 | + | BodyJSON: bodyJSON, | |
| 273 | + | }); err != nil { | |
| 274 | + | return nil, fmt.Errorf("save account: %w", err) | |
| 275 | + | } | |
| 276 | + | ||
| 277 | + | return user, nil | |
| 278 | + | } | |
| 279 | + | ||
| 280 | + | // restoreAccount reconstructs an AccountUser from a stored ACMEAccount. | |
| 281 | + | func restoreAccount(stored *out.ACMEAccount) (*AccountUser, error) { | |
| 282 | + | privateKey, err := certcrypto.ParsePEMPrivateKey(stored.PrivateKeyPEM) | |
| 283 | + | if err != nil { | |
| 284 | + | return nil, fmt.Errorf("parse account private key: %w", err) | |
| 285 | + | } | |
| 286 | + | ||
| 287 | + | reg := ®istration.Resource{ | |
| 288 | + | URI: stored.RegistrationURI, | |
| 289 | + | } | |
| 290 | + | ||
| 291 | + | if len(stored.BodyJSON) > 0 { | |
| 292 | + | var body acme.Account | |
| 293 | + | if err := json.Unmarshal(stored.BodyJSON, &body); err != nil { | |
| 294 | + | return nil, fmt.Errorf("unmarshal account body: %w", err) | |
| 295 | + | } | |
| 296 | + | reg.Body = body | |
| 297 | + | } | |
| 298 | + | ||
| 299 | + | return NewAccountUser(stored.Email, privateKey, reg), nil | |
| 300 | + | } | |
| 301 | + | ||
| 302 | + | // newLegoConfig creates a lego.Config with common settings from the Issuer config. | |
| 303 | + | func (i *Issuer) newLegoConfig(user *AccountUser) *lego.Config { | |
| 304 | + | legoCfg := lego.NewConfig(user) | |
| 305 | + | legoCfg.Certificate.KeyType = certcrypto.EC256 // ECDSA P-256 | |
| 306 | + | if i.cfg.CADirectoryURL != "" { | |
| 307 | + | legoCfg.CADirURL = i.cfg.CADirectoryURL | |
| 308 | + | } | |
| 309 | + | if i.cfg.HTTPClient != nil { | |
| 310 | + | legoCfg.HTTPClient = i.cfg.HTTPClient | |
| 311 | + | } | |
| 312 | + | return legoCfg | |
| 313 | + | } | |
| 314 | + | ||
| 315 | + | // resourceToStored converts a lego certificate.Resource to out.StoredCertificate. | |
| 316 | + | func resourceToStored(order out.CertificateOrder, res *certificate.Resource) (*out.StoredCertificate, error) { | |
| 317 | + | var fullchainPEM []byte | |
| 318 | + | if len(res.IssuerCertificate) > 0 { | |
| 319 | + | fullchainPEM = append(append([]byte{}, res.Certificate...), res.IssuerCertificate...) | |
| 320 | + | } else { | |
| 321 | + | fullchainPEM = res.Certificate | |
| 322 | + | } | |
| 323 | + | ||
| 324 | + | // Parse the certificate to extract NotAfter and populate tls.Certificate. | |
| 325 | + | var tlsCert tls.Certificate | |
| 326 | + | notAfter := time.Time{} | |
| 327 | + | parsedTLSCert, tlsErr := tls.X509KeyPair(fullchainPEM, res.PrivateKey) | |
| 328 | + | if tlsErr != nil { | |
| 329 | + | return nil, fmt.Errorf("parse tls certificate: %w", tlsErr) | |
| 330 | + | } | |
| 331 | + | tlsCert = parsedTLSCert | |
| 332 | + | if len(parsedTLSCert.Certificate) > 0 { | |
| 333 | + | parsed, parseErr := x509.ParseCertificate(parsedTLSCert.Certificate[0]) | |
| 334 | + | if parseErr != nil { | |
| 335 | + | return nil, fmt.Errorf("parse x509 certificate: %w", parseErr) | |
| 336 | + | } | |
| 337 | + | notAfter = parsed.NotAfter | |
| 338 | + | } | |
| 339 | + | ||
| 340 | + | names := order.Names | |
| 341 | + | if names == nil { | |
| 342 | + | names = []string{} | |
| 343 | + | } | |
| 344 | + | ||
| 345 | + | return &out.StoredCertificate{ | |
| 346 | + | ID: order.ID, | |
| 347 | + | Names: names, | |
| 348 | + | Challenge: order.Challenge, | |
| 349 | + | Certificate: tlsCert, | |
| 350 | + | CertPEM: res.Certificate, | |
| 351 | + | ChainPEM: res.IssuerCertificate, | |
| 352 | + | FullchainPEM: fullchainPEM, | |
| 353 | + | PrivateKeyPEM: res.PrivateKey, | |
| 354 | + | NotAfter: notAfter, | |
| 355 | + | }, nil | |
| 356 | + | } | |
A internal/adapters/out/acmelego/issuer_test.go +312 -0
| @@ -0,0 +1,312 @@ | |||
| 1 | + | package acmelego | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "crypto/ecdsa" | |
| 5 | + | "crypto/elliptic" | |
| 6 | + | "crypto/rand" | |
| 7 | + | "crypto/rsa" | |
| 8 | + | "crypto/tls" | |
| 9 | + | "crypto/x509" | |
| 10 | + | "crypto/x509/pkix" | |
| 11 | + | "encoding/json" | |
| 12 | + | "encoding/pem" | |
| 13 | + | "errors" | |
| 14 | + | "math/big" | |
| 15 | + | "testing" | |
| 16 | + | "time" | |
| 17 | + | ||
| 18 | + | "github.com/go-acme/lego/v4/acme" | |
| 19 | + | "github.com/go-acme/lego/v4/certcrypto" | |
| 20 | + | "github.com/go-acme/lego/v4/certificate" | |
| 21 | + | "github.com/stretchr/testify/assert" | |
| 22 | + | "github.com/stretchr/testify/require" | |
| 23 | + | ||
| 24 | + | "github.com/bnema/gordon/internal/boundaries/out" | |
| 25 | + | outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks" | |
| 26 | + | "github.com/bnema/gordon/internal/domain" | |
| 27 | + | ) | |
| 28 | + | ||
| 29 | + | func TestNewIssuerValidatesEmail(t *testing.T) { | |
| 30 | + | _, err := NewIssuer(Config{}) | |
| 31 | + | assert.Error(t, err) | |
| 32 | + | assert.ErrorIs(t, err, domain.ErrACMEEmailRequired) | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | func TestNewIssuerValidatesStore(t *testing.T) { | |
| 36 | + | _, err := NewIssuer(Config{Email: "test@example.com"}) | |
| 37 | + | assert.Error(t, err) | |
| 38 | + | assert.ErrorIs(t, err, domain.ErrCertificateStoreRequired) | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | func TestNewIssuerValidatesHTTPChallengeSink(t *testing.T) { | |
| 42 | + | _, err := NewIssuer(Config{ | |
| 43 | + | Email: "test@example.com", | |
| 44 | + | Challenge: domain.ACMEChallengeHTTP01, | |
| 45 | + | Store: outmocks.NewMockCertificateStore(t), | |
| 46 | + | }) | |
| 47 | + | assert.Error(t, err) | |
| 48 | + | assert.ErrorIs(t, err, domain.ErrHTTPChallengeSinkRequired) | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | func TestNewIssuerValidatesChallengeMode(t *testing.T) { | |
| 52 | + | _, err := NewIssuer(Config{ | |
| 53 | + | Email: "test@example.com", | |
| 54 | + | Challenge: domain.ACMEChallengeMode("tls-alpn-01"), | |
| 55 | + | Store: outmocks.NewMockCertificateStore(t), | |
| 56 | + | HTTPChallengeSink: outmocks.NewMockHTTPChallengeSink(t), | |
| 57 | + | }) | |
| 58 | + | assert.Error(t, err) | |
| 59 | + | assert.ErrorIs(t, err, domain.ErrACMEChallengeInvalid) | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | func TestNewIssuerValidatesCloudflareToken(t *testing.T) { | |
| 63 | + | _, err := NewIssuer(Config{ | |
| 64 | + | Email: "test@example.com", | |
| 65 | + | Challenge: domain.ACMEChallengeCloudflareDNS01, | |
| 66 | + | Store: outmocks.NewMockCertificateStore(t), | |
| 67 | + | }) | |
| 68 | + | assert.Error(t, err) | |
| 69 | + | assert.ErrorIs(t, err, domain.ErrCloudflareTokenMissing) | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | func TestNewIssuerRejectsNonHTTPSURL(t *testing.T) { | |
| 73 | + | _, err := NewIssuer(Config{ | |
| 74 | + | Email: "test@example.com", | |
| 75 | + | Challenge: domain.ACMEChallengeHTTP01, | |
| 76 | + | Store: outmocks.NewMockCertificateStore(t), | |
| 77 | + | HTTPChallengeSink: outmocks.NewMockHTTPChallengeSink(t), | |
| 78 | + | CADirectoryURL: "http://acme.test/directory", | |
| 79 | + | }) | |
| 80 | + | require.Error(t, err) | |
| 81 | + | assert.Contains(t, err.Error(), "must use HTTPS") | |
| 82 | + | ||
| 83 | + | // HTTPS should be accepted | |
| 84 | + | _, err = NewIssuer(Config{ | |
| 85 | + | Email: "test@example.com", | |
| 86 | + | Challenge: domain.ACMEChallengeHTTP01, | |
| 87 | + | Store: outmocks.NewMockCertificateStore(t), | |
| 88 | + | HTTPChallengeSink: outmocks.NewMockHTTPChallengeSink(t), | |
| 89 | + | CADirectoryURL: "https://acme.test/directory", | |
| 90 | + | }) | |
| 91 | + | require.NoError(t, err) | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | func TestNewIssuerValidWithDefaults(t *testing.T) { | |
| 95 | + | issuer, err := NewIssuer(Config{ | |
| 96 | + | Email: "test@example.com", | |
| 97 | + | Store: outmocks.NewMockCertificateStore(t), | |
| 98 | + | HTTPChallengeSink: outmocks.NewMockHTTPChallengeSink(t), | |
| 99 | + | }) | |
| 100 | + | require.NoError(t, err) | |
| 101 | + | require.NotNil(t, issuer) | |
| 102 | + | assert.Equal(t, domain.ACMEChallengeHTTP01, issuer.cfg.Challenge) | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | func TestPrivateKeyRoundTrip(t *testing.T) { | |
| 106 | + | // Generate an ECDSA P-256 private key | |
| 107 | + | privateKey, err := certcrypto.GeneratePrivateKey(certcrypto.EC256) | |
| 108 | + | require.NoError(t, err) | |
| 109 | + | require.NotNil(t, privateKey) | |
| 110 | + | ||
| 111 | + | // PEM encode | |
| 112 | + | pemBytes := certcrypto.PEMEncode(privateKey) | |
| 113 | + | require.NotEmpty(t, pemBytes) | |
| 114 | + | ||
| 115 | + | // Parse back | |
| 116 | + | parsedKey, err := certcrypto.ParsePEMPrivateKey(pemBytes) | |
| 117 | + | require.NoError(t, err) | |
| 118 | + | require.NotNil(t, parsedKey) | |
| 119 | + | ||
| 120 | + | // Verify it's the same curve (ECDSA P-256) | |
| 121 | + | ecKey, ok := parsedKey.(*ecdsa.PrivateKey) | |
| 122 | + | require.True(t, ok, "expected *ecdsa.PrivateKey") | |
| 123 | + | assert.Equal(t, elliptic.P256(), ecKey.Curve) | |
| 124 | + | ||
| 125 | + | // Verify we can sign with the original and verify with the parsed | |
| 126 | + | digest := []byte("test message") | |
| 127 | + | sig, err := ecdsa.SignASN1(rand.Reader, privateKey.(*ecdsa.PrivateKey), digest[:]) | |
| 128 | + | require.NoError(t, err) | |
| 129 | + | assert.True(t, ecdsa.VerifyASN1(&ecKey.PublicKey, digest[:], sig)) | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | func TestPrivateKeyRSARoundTrip(t *testing.T) { | |
| 133 | + | // Test RSA private key round-trip via certcrypto | |
| 134 | + | privateKey, err := rsa.GenerateKey(rand.Reader, 2048) | |
| 135 | + | require.NoError(t, err) | |
| 136 | + | ||
| 137 | + | pemBytes := certcrypto.PEMEncode(privateKey) | |
| 138 | + | require.NotEmpty(t, pemBytes) | |
| 139 | + | ||
| 140 | + | parsedKey, err := certcrypto.ParsePEMPrivateKey(pemBytes) | |
| 141 | + | require.NoError(t, err) | |
| 142 | + | require.NotNil(t, parsedKey) | |
| 143 | + | ||
| 144 | + | _, ok := parsedKey.(*rsa.PrivateKey) | |
| 145 | + | require.True(t, ok) | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | func TestResourceConversionToStoredCertificate(t *testing.T) { | |
| 149 | + | // Generate a self-signed certificate for testing | |
| 150 | + | certPEM, keyPEM, err := generateSelfSignedCert("example.com") | |
| 151 | + | require.NoError(t, err) | |
| 152 | + | ||
| 153 | + | // Generate a fake issuer certificate | |
| 154 | + | issuerPEM, _, err := generateSelfSignedCert("fake-ca.example.com") | |
| 155 | + | require.NoError(t, err) | |
| 156 | + | ||
| 157 | + | resource := &certificate.Resource{ | |
| 158 | + | Domain: "example.com", | |
| 159 | + | CertURL: "https://acme-staging-v02.api.letsencrypt.org/acme/cert/123", | |
| 160 | + | CertStableURL: "https://acme-staging-v02.api.letsencrypt.org/acme/cert/123", | |
| 161 | + | PrivateKey: keyPEM, | |
| 162 | + | Certificate: certPEM, | |
| 163 | + | IssuerCertificate: issuerPEM, | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | order := out.CertificateOrder{ | |
| 167 | + | ID: "test-cert", | |
| 168 | + | Names: []string{"example.com"}, | |
| 169 | + | Challenge: domain.ACMEChallengeHTTP01, | |
| 170 | + | } | |
| 171 | + | ||
| 172 | + | stored, err := resourceToStored(order, resource) | |
| 173 | + | require.NoError(t, err) | |
| 174 | + | require.NotNil(t, stored) | |
| 175 | + | assert.Equal(t, "test-cert", stored.ID) | |
| 176 | + | assert.Equal(t, []string{"example.com"}, stored.Names) | |
| 177 | + | assert.Equal(t, domain.ACMEChallengeHTTP01, stored.Challenge) | |
| 178 | + | assert.Equal(t, certPEM, stored.CertPEM) | |
| 179 | + | assert.Equal(t, issuerPEM, stored.ChainPEM) | |
| 180 | + | ||
| 181 | + | // FullchainPEM should be cert + issuer | |
| 182 | + | expectedFullchain := append(append([]byte{}, certPEM...), issuerPEM...) | |
| 183 | + | assert.Equal(t, expectedFullchain, stored.FullchainPEM) | |
| 184 | + | assert.Equal(t, keyPEM, stored.PrivateKeyPEM) | |
| 185 | + | ||
| 186 | + | // NotAfter should be set | |
| 187 | + | assert.False(t, stored.NotAfter.IsZero(), "NotAfter should be set") | |
| 188 | + | ||
| 189 | + | // Verify tls.Certificate is populated from the PEM data | |
| 190 | + | assert.NotEmpty(t, stored.Certificate.Certificate, "tls.Certificate should be populated from PEM") | |
| 191 | + | assert.NotNil(t, stored.Certificate.PrivateKey, "private key should be populated") | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | func TestResourceConversionNoIssuer(t *testing.T) { | |
| 195 | + | certPEM, keyPEM, err := generateSelfSignedCert("example.com") | |
| 196 | + | require.NoError(t, err) | |
| 197 | + | ||
| 198 | + | resource := &certificate.Resource{ | |
| 199 | + | Domain: "example.com", | |
| 200 | + | PrivateKey: keyPEM, | |
| 201 | + | Certificate: certPEM, | |
| 202 | + | } | |
| 203 | + | ||
| 204 | + | order := out.CertificateOrder{ | |
| 205 | + | ID: "test-cert-no-issuer", | |
| 206 | + | Names: []string{"example.com"}, | |
| 207 | + | } | |
| 208 | + | ||
| 209 | + | stored, err := resourceToStored(order, resource) | |
| 210 | + | require.NoError(t, err) | |
| 211 | + | require.NotNil(t, stored) | |
| 212 | + | assert.Equal(t, certPEM, stored.FullchainPEM, "fullchain should equal cert when no issuer") | |
| 213 | + | assert.Empty(t, stored.ChainPEM) | |
| 214 | + | } | |
| 215 | + | ||
| 216 | + | func TestAccountJSONRoundTrip(t *testing.T) { | |
| 217 | + | // Simulate an ACME account with BodyJSON containing the serialized acme.Account | |
| 218 | + | body := acme.Account{ | |
| 219 | + | Status: "valid", | |
| 220 | + | Contact: []string{"mailto:test@example.com"}, | |
| 221 | + | TermsOfServiceAgreed: true, | |
| 222 | + | Orders: "https://acme-v02.api.letsencrypt.org/acme/orders/abc123", | |
| 223 | + | } | |
| 224 | + | ||
| 225 | + | bodyJSON, err := json.Marshal(body) | |
| 226 | + | require.NoError(t, err) | |
| 227 | + | ||
| 228 | + | var restored acme.Account | |
| 229 | + | err = json.Unmarshal(bodyJSON, &restored) | |
| 230 | + | require.NoError(t, err) | |
| 231 | + | assert.Equal(t, body.Status, restored.Status) | |
| 232 | + | assert.Equal(t, body.Contact, restored.Contact) | |
| 233 | + | assert.Equal(t, body.TermsOfServiceAgreed, restored.TermsOfServiceAgreed) | |
| 234 | + | assert.Equal(t, body.Orders, restored.Orders) | |
| 235 | + | } | |
| 236 | + | ||
| 237 | + | // generateSelfSignedCert creates a self-signed certificate and its private key | |
| 238 | + | // for testing purposes. | |
| 239 | + | func generateSelfSignedCert(domain string) (certPEM, keyPEM []byte, err error) { | |
| 240 | + | privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) | |
| 241 | + | if err != nil { | |
| 242 | + | return nil, nil, err | |
| 243 | + | } | |
| 244 | + | ||
| 245 | + | serialNumber, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) | |
| 246 | + | if err != nil { | |
| 247 | + | return nil, nil, err | |
| 248 | + | } | |
| 249 | + | ||
| 250 | + | template := &x509.Certificate{ | |
| 251 | + | SerialNumber: serialNumber, | |
| 252 | + | Subject: pkix.Name{ | |
| 253 | + | CommonName: domain, | |
| 254 | + | Organization: []string{"Test"}, | |
| 255 | + | }, | |
| 256 | + | DNSNames: []string{domain}, | |
| 257 | + | NotBefore: time.Now().Add(-1 * time.Hour), | |
| 258 | + | NotAfter: time.Now().Add(24 * time.Hour), | |
| 259 | + | KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, | |
| 260 | + | ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, | |
| 261 | + | BasicConstraintsValid: true, | |
| 262 | + | } | |
| 263 | + | ||
| 264 | + | certDER, err := x509.CreateCertificate(rand.Reader, template, template, &privateKey.PublicKey, privateKey) | |
| 265 | + | if err != nil { | |
| 266 | + | return nil, nil, err | |
| 267 | + | } | |
| 268 | + | ||
| 269 | + | certPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}) | |
| 270 | + | if certPEM == nil { | |
| 271 | + | return nil, nil, errors.New("pem encode certificate returned nil") | |
| 272 | + | } | |
| 273 | + | ||
| 274 | + | keyDER, err := x509.MarshalECPrivateKey(privateKey) | |
| 275 | + | if err != nil { | |
| 276 | + | return nil, nil, err | |
| 277 | + | } | |
| 278 | + | ||
| 279 | + | keyPEM = pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}) | |
| 280 | + | if keyPEM == nil { | |
| 281 | + | return nil, nil, errors.New("pem encode private key returned nil") | |
| 282 | + | } | |
| 283 | + | ||
| 284 | + | return certPEM, keyPEM, nil | |
| 285 | + | } | |
| 286 | + | ||
| 287 | + | // Ensure TestResourceConversion also validates the tls.X509KeyPair parse works. | |
| 288 | + | func TestParsedCertificateNotAfter(t *testing.T) { | |
| 289 | + | certPEM, keyPEM, err := generateSelfSignedCert("test.example.com") | |
| 290 | + | require.NoError(t, err) | |
| 291 | + | ||
| 292 | + | resource := &certificate.Resource{ | |
| 293 | + | Domain: "test.example.com", | |
| 294 | + | PrivateKey: keyPEM, | |
| 295 | + | Certificate: certPEM, | |
| 296 | + | } | |
| 297 | + | ||
| 298 | + | order := out.CertificateOrder{ | |
| 299 | + | ID: "parse-test", | |
| 300 | + | Names: []string{"test.example.com"}, | |
| 301 | + | } | |
| 302 | + | ||
| 303 | + | stored, err := resourceToStored(order, resource) | |
| 304 | + | require.NoError(t, err) | |
| 305 | + | require.NotNil(t, stored) | |
| 306 | + | assert.False(t, stored.NotAfter.IsZero()) | |
| 307 | + | assert.True(t, stored.NotAfter.After(time.Now())) | |
| 308 | + | ||
| 309 | + | // Confirm tls.X509KeyPair succeeds on the stored PEMs | |
| 310 | + | _, parseErr := tls.X509KeyPair(stored.FullchainPEM, stored.PrivateKeyPEM) | |
| 311 | + | assert.NoError(t, parseErr, "tls.X509KeyPair should succeed") | |
| 312 | + | } | |
A internal/adapters/out/acmelego/user.go +42 -0
| @@ -0,0 +1,42 @@ | |||
| 1 | + | package acmelego | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "crypto" | |
| 5 | + | ||
| 6 | + | "github.com/go-acme/lego/v4/registration" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | // AccountUser implements the lego registration.User interface for ACME | |
| 10 | + | // account registration and certificate operations. | |
| 11 | + | type AccountUser struct { | |
| 12 | + | email string | |
| 13 | + | privateKey crypto.PrivateKey | |
| 14 | + | registration *registration.Resource | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | // NewAccountUser creates a new AccountUser. | |
| 18 | + | func NewAccountUser(email string, key crypto.PrivateKey, reg *registration.Resource) *AccountUser { | |
| 19 | + | return &AccountUser{ | |
| 20 | + | email: email, | |
| 21 | + | privateKey: key, | |
| 22 | + | registration: reg, | |
| 23 | + | } | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | // GetEmail returns the account email. | |
| 27 | + | func (u *AccountUser) GetEmail() string { | |
| 28 | + | return u.email | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | // GetRegistration returns the ACME registration resource. | |
| 32 | + | func (u *AccountUser) GetRegistration() *registration.Resource { | |
| 33 | + | return u.registration | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | // GetPrivateKey returns the account private key. | |
| 37 | + | func (u *AccountUser) GetPrivateKey() crypto.PrivateKey { | |
| 38 | + | return u.privateKey | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | // compile-time interface check | |
| 42 | + | var _ registration.User = (*AccountUser)(nil) | |
A internal/adapters/out/acmestore/store.go +511 -0
| @@ -0,0 +1,511 @@ | |||
| 1 | + | package acmestore | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "crypto/tls" | |
| 6 | + | "encoding/json" | |
| 7 | + | "errors" | |
| 8 | + | "fmt" | |
| 9 | + | "os" | |
| 10 | + | "path/filepath" | |
| 11 | + | "strings" | |
| 12 | + | "syscall" | |
| 13 | + | "time" | |
| 14 | + | ||
| 15 | + | "github.com/bnema/gordon/internal/boundaries/out" | |
| 16 | + | "github.com/bnema/gordon/internal/domain" | |
| 17 | + | ) | |
| 18 | + | ||
| 19 | + | // certMetadata is the JSON structure persisted in each certificate directory. | |
| 20 | + | type certMetadata struct { | |
| 21 | + | ID string `json:"id"` | |
| 22 | + | Names []string `json:"names"` | |
| 23 | + | Challenge string `json:"challenge"` | |
| 24 | + | NotAfter time.Time `json:"not_after"` | |
| 25 | + | LastError string `json:"last_error,omitempty"` | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | const ( | |
| 29 | + | lockFile = ".lock" | |
| 30 | + | accountFile = "account.json" | |
| 31 | + | certDir = "certs" | |
| 32 | + | backupSuffix = ".old" | |
| 33 | + | tempInfix = ".tmp-" | |
| 34 | + | certFile = "cert.pem" | |
| 35 | + | chainFile = "chain.pem" | |
| 36 | + | fullchainFile = "fullchain.pem" | |
| 37 | + | privkeyFile = "privkey.pem" | |
| 38 | + | metaFile = "metadata.json" | |
| 39 | + | ||
| 40 | + | dirMode os.FileMode = 0700 | |
| 41 | + | privKeyMode os.FileMode = 0600 | |
| 42 | + | metaMode os.FileMode = 0600 | |
| 43 | + | accountMode os.FileMode = 0600 | |
| 44 | + | lockMode os.FileMode = 0600 | |
| 45 | + | pemMode os.FileMode = 0644 | |
| 46 | + | ) | |
| 47 | + | ||
| 48 | + | var ErrLockHeld = errors.New("acmestore: lock already held") | |
| 49 | + | ||
| 50 | + | // Store implements out.CertificateStore using the local filesystem. | |
| 51 | + | type Store struct { | |
| 52 | + | root string | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | // New creates a Store rooted at root. root must be non-empty and is created | |
| 56 | + | // along with the certs subdirectory if they do not exist. | |
| 57 | + | func New(root string) (*Store, error) { | |
| 58 | + | if root == "" { | |
| 59 | + | return nil, errors.New("acmestore: root is required") | |
| 60 | + | } | |
| 61 | + | if err := os.MkdirAll(root, dirMode); err != nil { | |
| 62 | + | return nil, fmt.Errorf("acmestore: mkdir root: %w", err) | |
| 63 | + | } | |
| 64 | + | if _, err := ensureCertsDir(root); err != nil { | |
| 65 | + | return nil, err | |
| 66 | + | } | |
| 67 | + | return &Store{root: root}, nil | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | // LoadAccount reads the account from <root>/account.json. | |
| 71 | + | // It returns nil, nil when the file does not exist. | |
| 72 | + | func (s *Store) LoadAccount(_ context.Context) (*out.ACMEAccount, error) { | |
| 73 | + | path := filepath.Join(s.root, accountFile) | |
| 74 | + | data, err := os.ReadFile(path) | |
| 75 | + | if err != nil { | |
| 76 | + | if os.IsNotExist(err) { | |
| 77 | + | return nil, nil | |
| 78 | + | } | |
| 79 | + | return nil, fmt.Errorf("acmestore: read account: %w", err) | |
| 80 | + | } | |
| 81 | + | var acct out.ACMEAccount | |
| 82 | + | if err := json.Unmarshal(data, &acct); err != nil { | |
| 83 | + | return nil, fmt.Errorf("acmestore: unmarshal account: %w", err) | |
| 84 | + | } | |
| 85 | + | return &acct, nil | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | // SaveAccount writes the account to <root>/account.json atomically with mode 0600. | |
| 89 | + | func (s *Store) SaveAccount(_ context.Context, account out.ACMEAccount) error { | |
| 90 | + | data, err := json.MarshalIndent(account, "", " ") | |
| 91 | + | if err != nil { | |
| 92 | + | return fmt.Errorf("acmestore: marshal account: %w", err) | |
| 93 | + | } | |
| 94 | + | if err := writeAtomic(filepath.Join(s.root, accountFile), data, accountMode); err != nil { | |
| 95 | + | return fmt.Errorf("acmestore: save account: %w", err) | |
| 96 | + | } | |
| 97 | + | return nil | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | // LoadAll reads all certificate directories under <root>/certs/ and returns | |
| 101 | + | // the stored certificates. Directories with unsafe IDs are skipped. | |
| 102 | + | func (s *Store) LoadAll(_ context.Context) ([]out.StoredCertificate, error) { | |
| 103 | + | certsPath := filepath.Join(s.root, certDir) | |
| 104 | + | entries, err := os.ReadDir(certsPath) | |
| 105 | + | if err != nil { | |
| 106 | + | if os.IsNotExist(err) { | |
| 107 | + | return nil, nil | |
| 108 | + | } | |
| 109 | + | return nil, fmt.Errorf("acmestore: list certs: %w", err) | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | var result []out.StoredCertificate | |
| 113 | + | for _, entry := range entries { | |
| 114 | + | if !entry.IsDir() { | |
| 115 | + | continue | |
| 116 | + | } | |
| 117 | + | id := entry.Name() | |
| 118 | + | if !safeID(id) { | |
| 119 | + | continue | |
| 120 | + | } | |
| 121 | + | // Skip temporary and backup directories created by Save. | |
| 122 | + | if isStoreInternalCertDir(id) { | |
| 123 | + | continue | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | cert, err := s.loadOne(id) | |
| 127 | + | if err != nil { | |
| 128 | + | return nil, fmt.Errorf("acmestore: load %s: %w", id, err) | |
| 129 | + | } | |
| 130 | + | result = append(result, *cert) | |
| 131 | + | } | |
| 132 | + | return result, nil | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | // loadOne reads a single certificate directory and returns the stored | |
| 136 | + | // certificate. | |
| 137 | + | func (s *Store) loadOne(id string) (*out.StoredCertificate, error) { | |
| 138 | + | dir := filepath.Join(s.root, certDir, id) | |
| 139 | + | ||
| 140 | + | meta, err := readMetadata(filepath.Join(dir, metaFile)) | |
| 141 | + | if err != nil { | |
| 142 | + | return nil, fmt.Errorf("acmestore: metadata %s: %w", id, err) | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | certPEM, err := readPEM(filepath.Join(dir, certFile)) | |
| 146 | + | if err != nil { | |
| 147 | + | return nil, fmt.Errorf("acmestore: read %s cert.pem: %w", id, err) | |
| 148 | + | } | |
| 149 | + | chainPEM, err := readPEM(filepath.Join(dir, chainFile)) | |
| 150 | + | if err != nil { | |
| 151 | + | return nil, fmt.Errorf("acmestore: read %s chain.pem: %w", id, err) | |
| 152 | + | } | |
| 153 | + | fullchainPEM, err := readPEM(filepath.Join(dir, fullchainFile)) | |
| 154 | + | if err != nil { | |
| 155 | + | return nil, fmt.Errorf("acmestore: read %s fullchain.pem: %w", id, err) | |
| 156 | + | } | |
| 157 | + | privKeyPEM, err := readPEM(filepath.Join(dir, privkeyFile)) | |
| 158 | + | if err != nil { | |
| 159 | + | return nil, fmt.Errorf("acmestore: read %s privkey.pem: %w", id, err) | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | var tlsCert tls.Certificate | |
| 163 | + | if len(fullchainPEM) > 0 && len(privKeyPEM) > 0 { | |
| 164 | + | parsed, err := tls.X509KeyPair(fullchainPEM, privKeyPEM) | |
| 165 | + | if err != nil { | |
| 166 | + | return nil, fmt.Errorf("acmestore: parse key pair %s: %w", id, err) | |
| 167 | + | } | |
| 168 | + | tlsCert = parsed | |
| 169 | + | } | |
| 170 | + | ||
| 171 | + | challenge, err := domain.ParseACMEChallengeMode(meta.Challenge) | |
| 172 | + | if err != nil { | |
| 173 | + | return nil, fmt.Errorf("acmestore: challenge %s: %w", id, err) | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | return &out.StoredCertificate{ | |
| 177 | + | ID: id, | |
| 178 | + | Names: meta.Names, | |
| 179 | + | Challenge: challenge, | |
| 180 | + | Certificate: tlsCert, | |
| 181 | + | CertPEM: certPEM, | |
| 182 | + | ChainPEM: chainPEM, | |
| 183 | + | FullchainPEM: fullchainPEM, | |
| 184 | + | PrivateKeyPEM: privKeyPEM, | |
| 185 | + | NotAfter: meta.NotAfter, | |
| 186 | + | LastError: meta.LastError, | |
| 187 | + | }, nil | |
| 188 | + | } | |
| 189 | + | ||
| 190 | + | // Save persists a certificate to <root>/certs/<safe-id>/. | |
| 191 | + | // All PEM files and metadata are written atomically. | |
| 192 | + | func (s *Store) Save(_ context.Context, cert out.StoredCertificate) error { | |
| 193 | + | if !safeID(cert.ID) { | |
| 194 | + | return fmt.Errorf("acmestore: unsafe certificate id %q: %w", cert.ID, domain.ErrPathTraversal) | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | parent := filepath.Join(s.root, certDir) | |
| 198 | + | if err := os.MkdirAll(parent, dirMode); err != nil { | |
| 199 | + | return fmt.Errorf("acmestore: mkdir certs: %w", err) | |
| 200 | + | } | |
| 201 | + | if err := recoverCertificateDir(parent, cert.ID); err != nil { | |
| 202 | + | return fmt.Errorf("acmestore: recover cert %s: %w", cert.ID, err) | |
| 203 | + | } | |
| 204 | + | ||
| 205 | + | dir := filepath.Join(parent, cert.ID) | |
| 206 | + | tmpDir, err := os.MkdirTemp(parent, cert.ID+".tmp-*") | |
| 207 | + | if err != nil { | |
| 208 | + | return fmt.Errorf("acmestore: create temp dir: %w", err) | |
| 209 | + | } | |
| 210 | + | committed := false | |
| 211 | + | defer func() { | |
| 212 | + | if !committed { | |
| 213 | + | os.RemoveAll(tmpDir) | |
| 214 | + | } | |
| 215 | + | }() | |
| 216 | + | ||
| 217 | + | if err := writeCertificateFiles(tmpDir, cert); err != nil { | |
| 218 | + | return err | |
| 219 | + | } | |
| 220 | + | ||
| 221 | + | backupDir := dir + backupSuffix | |
| 222 | + | _ = os.RemoveAll(backupDir) | |
| 223 | + | hadExisting := false | |
| 224 | + | if err := os.Rename(dir, backupDir); err != nil { | |
| 225 | + | if !os.IsNotExist(err) { | |
| 226 | + | return fmt.Errorf("acmestore: backup existing cert dir: %w", err) | |
| 227 | + | } | |
| 228 | + | } else { | |
| 229 | + | hadExisting = true | |
| 230 | + | } | |
| 231 | + | ||
| 232 | + | if err := os.Rename(tmpDir, dir); err != nil { | |
| 233 | + | if hadExisting { | |
| 234 | + | _ = os.Rename(backupDir, dir) | |
| 235 | + | } | |
| 236 | + | return fmt.Errorf("acmestore: commit cert dir: %w", err) | |
| 237 | + | } | |
| 238 | + | committed = true | |
| 239 | + | if hadExisting { | |
| 240 | + | _ = os.RemoveAll(backupDir) | |
| 241 | + | } | |
| 242 | + | return nil | |
| 243 | + | } | |
| 244 | + | ||
| 245 | + | func writeCertificateFiles(dir string, cert out.StoredCertificate) error { | |
| 246 | + | meta := certMetadata{ | |
| 247 | + | ID: cert.ID, | |
| 248 | + | Names: cert.Names, | |
| 249 | + | Challenge: string(cert.Challenge), | |
| 250 | + | NotAfter: cert.NotAfter, | |
| 251 | + | LastError: cert.LastError, | |
| 252 | + | } | |
| 253 | + | ||
| 254 | + | if err := writeFileIfNonNil(filepath.Join(dir, certFile), cert.CertPEM, pemMode); err != nil { | |
| 255 | + | return fmt.Errorf("acmestore: write cert.pem: %w", err) | |
| 256 | + | } | |
| 257 | + | if err := writeFileIfNonNil(filepath.Join(dir, chainFile), cert.ChainPEM, pemMode); err != nil { | |
| 258 | + | return fmt.Errorf("acmestore: write chain.pem: %w", err) | |
| 259 | + | } | |
| 260 | + | if err := writeFileIfNonNil(filepath.Join(dir, fullchainFile), cert.FullchainPEM, pemMode); err != nil { | |
| 261 | + | return fmt.Errorf("acmestore: write fullchain.pem: %w", err) | |
| 262 | + | } | |
| 263 | + | if err := writeFileIfNonNil(filepath.Join(dir, privkeyFile), cert.PrivateKeyPEM, privKeyMode); err != nil { | |
| 264 | + | return fmt.Errorf("acmestore: write privkey.pem: %w", err) | |
| 265 | + | } | |
| 266 | + | ||
| 267 | + | metaData, err := json.MarshalIndent(meta, "", " ") | |
| 268 | + | if err != nil { | |
| 269 | + | return fmt.Errorf("acmestore: marshal metadata: %w", err) | |
| 270 | + | } | |
| 271 | + | if err := writeAtomic(filepath.Join(dir, metaFile), metaData, metaMode); err != nil { | |
| 272 | + | return fmt.Errorf("acmestore: write metadata: %w", err) | |
| 273 | + | } | |
| 274 | + | return nil | |
| 275 | + | } | |
| 276 | + | ||
| 277 | + | // Lock acquires an exclusive advisory lock using a lock file at <root>/.lock. | |
| 278 | + | // It is fail-fast and non-blocking: if another live process holds the lock, | |
| 279 | + | // Lock returns an error immediately without waiting. Context cancellation is | |
| 280 | + | // respected before attempting acquisition. The returned unlock function releases | |
| 281 | + | // the OS advisory lock, closes the file descriptor, and removes the lock file. | |
| 282 | + | func (s *Store) Lock(ctx context.Context) (func() error, error) { | |
| 283 | + | select { | |
| 284 | + | case <-ctx.Done(): | |
| 285 | + | return nil, ctx.Err() | |
| 286 | + | default: | |
| 287 | + | } | |
| 288 | + | ||
| 289 | + | path := filepath.Join(s.root, lockFile) | |
| 290 | + | f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, lockMode) | |
| 291 | + | if err != nil { | |
| 292 | + | return nil, fmt.Errorf("acmestore: open lock file: %w", err) | |
| 293 | + | } | |
| 294 | + | ||
| 295 | + | fd, err := fileDescriptor(f) | |
| 296 | + | if err != nil { | |
| 297 | + | _ = f.Close() | |
| 298 | + | return nil, err | |
| 299 | + | } | |
| 300 | + | if err := syscall.Flock(fd, syscall.LOCK_EX|syscall.LOCK_NB); err != nil { | |
| 301 | + | _ = f.Close() | |
| 302 | + | if errors.Is(err, syscall.EWOULDBLOCK) || errors.Is(err, syscall.EAGAIN) { | |
| 303 | + | return nil, ErrLockHeld | |
| 304 | + | } | |
| 305 | + | return nil, fmt.Errorf("acmestore: acquire lock: %w", err) | |
| 306 | + | } | |
| 307 | + | ||
| 308 | + | if err := writeLockMetadata(f); err != nil { | |
| 309 | + | _ = syscall.Flock(fd, syscall.LOCK_UN) | |
| 310 | + | _ = f.Close() | |
| 311 | + | return nil, err | |
| 312 | + | } | |
| 313 | + | ||
| 314 | + | return func() error { | |
| 315 | + | return releaseLockFile(f, fd) | |
| 316 | + | }, nil | |
| 317 | + | } | |
| 318 | + | ||
| 319 | + | func writeLockMetadata(f *os.File) error { | |
| 320 | + | if err := f.Truncate(0); err != nil { | |
| 321 | + | return fmt.Errorf("acmestore: truncate lock file: %w", err) | |
| 322 | + | } | |
| 323 | + | if _, err := f.Seek(0, 0); err != nil { | |
| 324 | + | return fmt.Errorf("acmestore: seek lock file: %w", err) | |
| 325 | + | } | |
| 326 | + | metadata := fmt.Sprintf("pid=%d\nhost=%s\ncreated_at=%s\n", os.Getpid(), hostname(), time.Now().UTC().Format(time.RFC3339Nano)) | |
| 327 | + | if _, err := f.WriteString(metadata); err != nil { | |
| 328 | + | return fmt.Errorf("acmestore: write lock metadata: %w", err) | |
| 329 | + | } | |
| 330 | + | return nil | |
| 331 | + | } | |
| 332 | + | ||
| 333 | + | func releaseLockFile(f *os.File, fd int) error { | |
| 334 | + | var errs []error | |
| 335 | + | if err := syscall.Flock(fd, syscall.LOCK_UN); err != nil { | |
| 336 | + | errs = append(errs, fmt.Errorf("unlock: %w", err)) | |
| 337 | + | } | |
| 338 | + | if err := f.Close(); err != nil { | |
| 339 | + | errs = append(errs, fmt.Errorf("close: %w", err)) | |
| 340 | + | } | |
| 341 | + | if err := errors.Join(errs...); err != nil { | |
| 342 | + | return fmt.Errorf("acmestore: release lock: %w", err) | |
| 343 | + | } | |
| 344 | + | return nil | |
| 345 | + | } | |
| 346 | + | ||
| 347 | + | func fileDescriptor(f *os.File) (int, error) { | |
| 348 | + | fd := f.Fd() | |
| 349 | + | if fd > uintptr(^uint(0)>>1) { | |
| 350 | + | return 0, fmt.Errorf("acmestore: lock file descriptor overflows int") | |
| 351 | + | } | |
| 352 | + | return int(fd), nil | |
| 353 | + | } | |
| 354 | + | ||
| 355 | + | func hostname() string { | |
| 356 | + | host, err := os.Hostname() | |
| 357 | + | if err != nil { | |
| 358 | + | return "unknown" | |
| 359 | + | } | |
| 360 | + | return host | |
| 361 | + | } | |
| 362 | + | ||
| 363 | + | func ensureCertsDir(root string) (string, error) { | |
| 364 | + | certsPath := filepath.Join(root, certDir) | |
| 365 | + | if err := os.MkdirAll(certsPath, dirMode); err != nil { | |
| 366 | + | return "", fmt.Errorf("acmestore: mkdir certs: %w", err) | |
| 367 | + | } | |
| 368 | + | if err := recoverCertificateDirs(certsPath); err != nil { | |
| 369 | + | return "", fmt.Errorf("acmestore: recover certs: %w", err) | |
| 370 | + | } | |
| 371 | + | return certsPath, nil | |
| 372 | + | } | |
| 373 | + | ||
| 374 | + | func isStoreInternalTempDir(id string) bool { | |
| 375 | + | return strings.Contains(id, tempInfix) | |
| 376 | + | } | |
| 377 | + | ||
| 378 | + | func isStoreInternalCertDir(id string) bool { | |
| 379 | + | return isStoreInternalTempDir(id) || strings.HasSuffix(id, backupSuffix) | |
| 380 | + | } | |
| 381 | + | ||
| 382 | + | func recoverCertificateDir(certsPath, id string) error { | |
| 383 | + | if !safeID(id) { | |
| 384 | + | return nil | |
| 385 | + | } | |
| 386 | + | backupDir := filepath.Join(certsPath, id+backupSuffix) | |
| 387 | + | dir := filepath.Join(certsPath, id) | |
| 388 | + | if _, err := os.Stat(dir); err == nil { | |
| 389 | + | return nil | |
| 390 | + | } else if !os.IsNotExist(err) { | |
| 391 | + | return err | |
| 392 | + | } | |
| 393 | + | if _, err := os.Stat(backupDir); err != nil { | |
| 394 | + | if os.IsNotExist(err) { | |
| 395 | + | return nil | |
| 396 | + | } | |
| 397 | + | return err | |
| 398 | + | } | |
| 399 | + | if err := os.Rename(backupDir, dir); err != nil { | |
| 400 | + | return fmt.Errorf("restore backup %s: %w", filepath.Base(backupDir), err) | |
| 401 | + | } | |
| 402 | + | return nil | |
| 403 | + | } | |
| 404 | + | ||
| 405 | + | func recoverCertificateDirs(certsPath string) error { | |
| 406 | + | entries, err := os.ReadDir(certsPath) | |
| 407 | + | if err != nil { | |
| 408 | + | if os.IsNotExist(err) { | |
| 409 | + | return nil | |
| 410 | + | } | |
| 411 | + | return err | |
| 412 | + | } | |
| 413 | + | for _, entry := range entries { | |
| 414 | + | if !entry.IsDir() { | |
| 415 | + | continue | |
| 416 | + | } | |
| 417 | + | name := entry.Name() | |
| 418 | + | if isStoreInternalTempDir(name) { | |
| 419 | + | if err := os.RemoveAll(filepath.Join(certsPath, name)); err != nil { | |
| 420 | + | return fmt.Errorf("remove temp %s: %w", name, err) | |
| 421 | + | } | |
| 422 | + | continue | |
| 423 | + | } | |
| 424 | + | if base, ok := strings.CutSuffix(name, backupSuffix); ok { | |
| 425 | + | if err := recoverCertificateDir(certsPath, base); err != nil { | |
| 426 | + | return err | |
| 427 | + | } | |
| 428 | + | } | |
| 429 | + | } | |
| 430 | + | return nil | |
| 431 | + | } | |
| 432 | + | ||
| 433 | + | // safeID rejects IDs that could cause path traversal or collide with store-internal artifacts. | |
| 434 | + | func safeID(id string) bool { | |
| 435 | + | return id != "" && !strings.HasPrefix(id, ".") && !isStoreInternalCertDir(id) && !strings.Contains(id, "/") && !strings.Contains(id, "\\") && !strings.Contains(id, "..") && !strings.Contains(id, "\x00") | |
| 436 | + | } | |
| 437 | + | ||
| 438 | + | // writeAtomic writes data to path atomically by writing to a temporary file | |
| 439 | + | // in the same directory and then renaming. | |
| 440 | + | func writeAtomic(path string, data []byte, mode os.FileMode) error { | |
| 441 | + | dir := filepath.Dir(path) | |
| 442 | + | tmp, err := os.CreateTemp(dir, ".tmp-*") | |
| 443 | + | if err != nil { | |
| 444 | + | return fmt.Errorf("create temp: %w", err) | |
| 445 | + | } | |
| 446 | + | tmpName := tmp.Name() | |
| 447 | + | ||
| 448 | + | // Clean up temp file on any failure | |
| 449 | + | removeOnErr := true | |
| 450 | + | defer func() { | |
| 451 | + | if removeOnErr { | |
| 452 | + | os.Remove(tmpName) | |
| 453 | + | } | |
| 454 | + | }() | |
| 455 | + | ||
| 456 | + | if err := tmp.Chmod(mode); err != nil { | |
| 457 | + | tmp.Close() | |
| 458 | + | return fmt.Errorf("chmod temp: %w", err) | |
| 459 | + | } | |
| 460 | + | if _, err := tmp.Write(data); err != nil { | |
| 461 | + | tmp.Close() | |
| 462 | + | return fmt.Errorf("write temp: %w", err) | |
| 463 | + | } | |
| 464 | + | // Sync before close to ensure data is on disk. | |
| 465 | + | if err := tmp.Sync(); err != nil { | |
| 466 | + | tmp.Close() | |
| 467 | + | return fmt.Errorf("sync temp: %w", err) | |
| 468 | + | } | |
| 469 | + | if err := tmp.Close(); err != nil { | |
| 470 | + | return fmt.Errorf("close temp: %w", err) | |
| 471 | + | } | |
| 472 | + | if err := os.Rename(tmpName, path); err != nil { | |
| 473 | + | return fmt.Errorf("rename temp: %w", err) | |
| 474 | + | } | |
| 475 | + | removeOnErr = false | |
| 476 | + | return nil | |
| 477 | + | } | |
| 478 | + | ||
| 479 | + | // writeFileIfNonNil writes data to path atomically only when data is non-nil. | |
| 480 | + | func writeFileIfNonNil(path string, data []byte, mode os.FileMode) error { | |
| 481 | + | if data == nil { | |
| 482 | + | return nil | |
| 483 | + | } | |
| 484 | + | return writeAtomic(path, data, mode) | |
| 485 | + | } | |
| 486 | + | ||
| 487 | + | // readPEM reads a PEM file from path. If the file does not exist it returns | |
| 488 | + | // nil, nil without error. Other read errors are returned. | |
| 489 | + | func readPEM(path string) ([]byte, error) { | |
| 490 | + | data, err := os.ReadFile(path) | |
| 491 | + | if err != nil { | |
| 492 | + | if os.IsNotExist(err) { | |
| 493 | + | return nil, nil | |
| 494 | + | } | |
| 495 | + | return nil, err | |
| 496 | + | } | |
| 497 | + | return data, nil | |
| 498 | + | } | |
| 499 | + | ||
| 500 | + | // readMetadata reads and unmarshals a metadata JSON file. | |
| 501 | + | func readMetadata(path string) (*certMetadata, error) { | |
| 502 | + | data, err := os.ReadFile(path) | |
| 503 | + | if err != nil { | |
| 504 | + | return nil, fmt.Errorf("read: %w", err) | |
| 505 | + | } | |
| 506 | + | var meta certMetadata | |
| 507 | + | if err := json.Unmarshal(data, &meta); err != nil { | |
| 508 | + | return nil, fmt.Errorf("unmarshal: %w", err) | |
| 509 | + | } | |
| 510 | + | return &meta, nil | |
| 511 | + | } | |
A internal/adapters/out/acmestore/store_test.go +417 -0
| @@ -0,0 +1,417 @@ | |||
| 1 | + | package acmestore | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "crypto/ecdsa" | |
| 6 | + | "crypto/elliptic" | |
| 7 | + | "crypto/rand" | |
| 8 | + | "crypto/x509" | |
| 9 | + | "crypto/x509/pkix" | |
| 10 | + | "encoding/json" | |
| 11 | + | "encoding/pem" | |
| 12 | + | "math/big" | |
| 13 | + | "os" | |
| 14 | + | "path/filepath" | |
| 15 | + | "testing" | |
| 16 | + | "time" | |
| 17 | + | ||
| 18 | + | "github.com/stretchr/testify/assert" | |
| 19 | + | "github.com/stretchr/testify/require" | |
| 20 | + | ||
| 21 | + | "github.com/bnema/gordon/internal/boundaries/out" | |
| 22 | + | "github.com/bnema/gordon/internal/domain" | |
| 23 | + | ) | |
| 24 | + | ||
| 25 | + | // generateTestCertPEM creates a self-signed ECDSA P256 certificate and its | |
| 26 | + | // private key, both PEM-encoded, for use in tests that require a valid | |
| 27 | + | // tls.Certificate. | |
| 28 | + | func generateTestCertPEM(t *testing.T) (certPEM, keyPEM []byte) { | |
| 29 | + | t.Helper() | |
| 30 | + | ||
| 31 | + | key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) | |
| 32 | + | require.NoError(t, err) | |
| 33 | + | ||
| 34 | + | keyDER, err := x509.MarshalECPrivateKey(key) | |
| 35 | + | require.NoError(t, err) | |
| 36 | + | ||
| 37 | + | keyPEM = pem.EncodeToMemory(&pem.Block{ | |
| 38 | + | Type: "EC PRIVATE KEY", | |
| 39 | + | Bytes: keyDER, | |
| 40 | + | }) | |
| 41 | + | ||
| 42 | + | template := &x509.Certificate{ | |
| 43 | + | SerialNumber: big.NewInt(1), | |
| 44 | + | Subject: pkix.Name{ | |
| 45 | + | CommonName: "test.example.com", | |
| 46 | + | }, | |
| 47 | + | NotBefore: time.Now().Add(-1 * time.Hour), | |
| 48 | + | NotAfter: time.Now().Add(24 * time.Hour), | |
| 49 | + | KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | certDER, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) | |
| 53 | + | require.NoError(t, err) | |
| 54 | + | ||
| 55 | + | certPEM = pem.EncodeToMemory(&pem.Block{ | |
| 56 | + | Type: "CERTIFICATE", | |
| 57 | + | Bytes: certDER, | |
| 58 | + | }) | |
| 59 | + | ||
| 60 | + | return certPEM, keyPEM | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | func TestStoreSaveLoadAll(t *testing.T) { | |
| 64 | + | root := t.TempDir() | |
| 65 | + | ctx := context.Background() | |
| 66 | + | ||
| 67 | + | store, err := New(root) | |
| 68 | + | require.NoError(t, err) | |
| 69 | + | ||
| 70 | + | now := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC) | |
| 71 | + | ||
| 72 | + | fullchainPEM, privKeyPEM := generateTestCertPEM(t) | |
| 73 | + | ||
| 74 | + | cert := out.StoredCertificate{ | |
| 75 | + | ID: "dns01-example.com", | |
| 76 | + | Names: []string{"example.com", "*.example.com"}, | |
| 77 | + | Challenge: domain.ACMEChallengeCloudflareDNS01, | |
| 78 | + | CertPEM: []byte("dummy-cert"), | |
| 79 | + | ChainPEM: []byte("dummy-chain"), | |
| 80 | + | FullchainPEM: fullchainPEM, | |
| 81 | + | PrivateKeyPEM: privKeyPEM, | |
| 82 | + | NotAfter: now, | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | err = store.Save(ctx, cert) | |
| 86 | + | require.NoError(t, err) | |
| 87 | + | ||
| 88 | + | certs, err := store.LoadAll(ctx) | |
| 89 | + | require.NoError(t, err) | |
| 90 | + | require.Len(t, certs, 1) | |
| 91 | + | ||
| 92 | + | assert.Equal(t, "dns01-example.com", certs[0].ID) | |
| 93 | + | assert.Equal(t, []string{"example.com", "*.example.com"}, certs[0].Names) | |
| 94 | + | assert.Equal(t, privKeyPEM, certs[0].PrivateKeyPEM) | |
| 95 | + | // Verify tls.Certificate is populated from valid PEM | |
| 96 | + | assert.NotEmpty(t, certs[0].Certificate.Certificate, "tls.Certificate.Certificate should be populated") | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | func TestStorePrivateKeyMode(t *testing.T) { | |
| 100 | + | root := t.TempDir() | |
| 101 | + | ctx := context.Background() | |
| 102 | + | ||
| 103 | + | store, err := New(root) | |
| 104 | + | require.NoError(t, err) | |
| 105 | + | ||
| 106 | + | cert := out.StoredCertificate{ | |
| 107 | + | ID: "http01-app.example.com", | |
| 108 | + | Names: []string{"app.example.com"}, | |
| 109 | + | Challenge: domain.ACMEChallengeHTTP01, | |
| 110 | + | PrivateKeyPEM: []byte("key"), | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | err = store.Save(ctx, cert) | |
| 114 | + | require.NoError(t, err) | |
| 115 | + | ||
| 116 | + | privkeyPath := filepath.Join(root, certDir, "http01-app.example.com", privkeyFile) | |
| 117 | + | info, err := os.Stat(privkeyPath) | |
| 118 | + | require.NoError(t, err) | |
| 119 | + | ||
| 120 | + | // Mode 0600 (owner read+write only) | |
| 121 | + | assert.Equal(t, os.FileMode(0600), info.Mode().Perm()) | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | func TestStoreRejectsUnsafeID(t *testing.T) { | |
| 125 | + | root := t.TempDir() | |
| 126 | + | ctx := context.Background() | |
| 127 | + | ||
| 128 | + | store, err := New(root) | |
| 129 | + | require.NoError(t, err) | |
| 130 | + | ||
| 131 | + | for _, id := range []string{"../escape", ".", ".hidden", "tenant.old", "tenant.tmp-123"} { | |
| 132 | + | t.Run(id, func(t *testing.T) { | |
| 133 | + | cert := out.StoredCertificate{ | |
| 134 | + | ID: id, | |
| 135 | + | PrivateKeyPEM: []byte("key"), | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | err = store.Save(ctx, cert) | |
| 139 | + | require.Error(t, err) | |
| 140 | + | assert.Contains(t, err.Error(), "unsafe certificate id") | |
| 141 | + | assert.ErrorIs(t, err, domain.ErrPathTraversal) | |
| 142 | + | }) | |
| 143 | + | } | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | func TestStoreLockAcquireRelease(t *testing.T) { | |
| 147 | + | root := t.TempDir() | |
| 148 | + | ctx := context.Background() | |
| 149 | + | ||
| 150 | + | store, err := New(root) | |
| 151 | + | require.NoError(t, err) | |
| 152 | + | ||
| 153 | + | unlock, err := store.Lock(ctx) | |
| 154 | + | require.NoError(t, err) | |
| 155 | + | assert.FileExists(t, filepath.Join(root, lockFile)) | |
| 156 | + | ||
| 157 | + | _, err = store.Lock(ctx) | |
| 158 | + | require.Error(t, err) | |
| 159 | + | assert.Contains(t, err.Error(), "lock already held") | |
| 160 | + | ||
| 161 | + | require.NoError(t, unlock()) | |
| 162 | + | assert.FileExists(t, filepath.Join(root, lockFile)) | |
| 163 | + | ||
| 164 | + | unlock, err = store.Lock(ctx) | |
| 165 | + | require.NoError(t, err) | |
| 166 | + | require.NoError(t, unlock()) | |
| 167 | + | } | |
| 168 | + | ||
| 169 | + | func TestStoreLockContextCanceled(t *testing.T) { | |
| 170 | + | root := t.TempDir() | |
| 171 | + | store, err := New(root) | |
| 172 | + | require.NoError(t, err) | |
| 173 | + | ||
| 174 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 175 | + | cancel() | |
| 176 | + | ||
| 177 | + | _, err = store.Lock(ctx) | |
| 178 | + | require.ErrorIs(t, err, context.Canceled) | |
| 179 | + | assert.NoFileExists(t, filepath.Join(root, lockFile)) | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | func TestStoreNewRestoresBackupDir(t *testing.T) { | |
| 183 | + | root := t.TempDir() | |
| 184 | + | ctx := context.Background() | |
| 185 | + | ||
| 186 | + | store, err := New(root) | |
| 187 | + | require.NoError(t, err) | |
| 188 | + | ||
| 189 | + | fullchainPEM, privKeyPEM := generateTestCertPEM(t) | |
| 190 | + | cert := out.StoredCertificate{ | |
| 191 | + | ID: "restore.example.com", | |
| 192 | + | Names: []string{"restore.example.com"}, | |
| 193 | + | Challenge: domain.ACMEChallengeHTTP01, | |
| 194 | + | CertPEM: fullchainPEM, | |
| 195 | + | FullchainPEM: fullchainPEM, | |
| 196 | + | PrivateKeyPEM: privKeyPEM, | |
| 197 | + | } | |
| 198 | + | require.NoError(t, store.Save(ctx, cert)) | |
| 199 | + | ||
| 200 | + | certPath := filepath.Join(root, certDir, cert.ID) | |
| 201 | + | backupPath := certPath + ".old" | |
| 202 | + | require.NoError(t, os.Rename(certPath, backupPath)) | |
| 203 | + | require.NoDirExists(t, certPath) | |
| 204 | + | ||
| 205 | + | store, err = New(root) | |
| 206 | + | require.NoError(t, err) | |
| 207 | + | assert.DirExists(t, certPath) | |
| 208 | + | ||
| 209 | + | certs, err := store.LoadAll(ctx) | |
| 210 | + | require.NoError(t, err) | |
| 211 | + | require.Len(t, certs, 1) | |
| 212 | + | assert.Equal(t, cert.ID, certs[0].ID) | |
| 213 | + | } | |
| 214 | + | ||
| 215 | + | func TestStoreSaveLoadAccount(t *testing.T) { | |
| 216 | + | root := t.TempDir() | |
| 217 | + | ctx := context.Background() | |
| 218 | + | ||
| 219 | + | store, err := New(root) | |
| 220 | + | require.NoError(t, err) | |
| 221 | + | ||
| 222 | + | acct := out.ACMEAccount{ | |
| 223 | + | Email: "admin@example.com", | |
| 224 | + | PrivateKeyPEM: []byte("key"), | |
| 225 | + | RegistrationURI: "https://acme.test/acct/1", | |
| 226 | + | BodyJSON: []byte(`{"status":"valid"}`), | |
| 227 | + | } | |
| 228 | + | ||
| 229 | + | err = store.SaveAccount(ctx, acct) | |
| 230 | + | require.NoError(t, err) | |
| 231 | + | ||
| 232 | + | loaded, err := store.LoadAccount(ctx) | |
| 233 | + | require.NoError(t, err) | |
| 234 | + | require.NotNil(t, loaded) | |
| 235 | + | ||
| 236 | + | assert.Equal(t, "admin@example.com", loaded.Email) | |
| 237 | + | assert.Equal(t, []byte("key"), loaded.PrivateKeyPEM) | |
| 238 | + | assert.Equal(t, "https://acme.test/acct/1", loaded.RegistrationURI) | |
| 239 | + | assert.Equal(t, []byte(`{"status":"valid"}`), loaded.BodyJSON) | |
| 240 | + | } | |
| 241 | + | ||
| 242 | + | func TestStoreAccountFileMode(t *testing.T) { | |
| 243 | + | root := t.TempDir() | |
| 244 | + | ctx := context.Background() | |
| 245 | + | ||
| 246 | + | store, err := New(root) | |
| 247 | + | require.NoError(t, err) | |
| 248 | + | ||
| 249 | + | acct := out.ACMEAccount{ | |
| 250 | + | Email: "test@example.com", | |
| 251 | + | PrivateKeyPEM: []byte("test-key"), | |
| 252 | + | RegistrationURI: "https://acme.test/acct/99", | |
| 253 | + | BodyJSON: []byte(`{"status":"valid"}`), | |
| 254 | + | } | |
| 255 | + | ||
| 256 | + | err = store.SaveAccount(ctx, acct) | |
| 257 | + | require.NoError(t, err) | |
| 258 | + | ||
| 259 | + | accountPath := filepath.Join(root, accountFile) | |
| 260 | + | info, err := os.Stat(accountPath) | |
| 261 | + | require.NoError(t, err) | |
| 262 | + | ||
| 263 | + | assert.Equal(t, os.FileMode(0600), info.Mode().Perm()) | |
| 264 | + | } | |
| 265 | + | ||
| 266 | + | func TestStoreLoadAllInvalidChallenge(t *testing.T) { | |
| 267 | + | root := t.TempDir() | |
| 268 | + | ctx := context.Background() | |
| 269 | + | ||
| 270 | + | store, err := New(root) | |
| 271 | + | require.NoError(t, err) | |
| 272 | + | ||
| 273 | + | // Create a cert dir manually with invalid challenge in metadata | |
| 274 | + | certDirPath := filepath.Join(root, certDir, "bad-challenge.example.com") | |
| 275 | + | err = os.MkdirAll(certDirPath, dirMode) | |
| 276 | + | require.NoError(t, err) | |
| 277 | + | ||
| 278 | + | meta := certMetadata{ | |
| 279 | + | ID: "bad-challenge.example.com", | |
| 280 | + | Names: []string{"bad-challenge.example.com"}, | |
| 281 | + | Challenge: "invalid-challenge-mode", | |
| 282 | + | } | |
| 283 | + | metaData, err := json.MarshalIndent(meta, "", " ") | |
| 284 | + | require.NoError(t, err) | |
| 285 | + | err = os.WriteFile(filepath.Join(certDirPath, metaFile), metaData, metaMode) | |
| 286 | + | require.NoError(t, err) | |
| 287 | + | ||
| 288 | + | // LoadAll should return an error wrapping domain.ErrACMEChallengeInvalid | |
| 289 | + | _, err = store.LoadAll(ctx) | |
| 290 | + | require.Error(t, err) | |
| 291 | + | assert.ErrorIs(t, err, domain.ErrACMEChallengeInvalid) | |
| 292 | + | } | |
| 293 | + | ||
| 294 | + | func TestStoreLoadAllCorruptPrivkey(t *testing.T) { | |
| 295 | + | root := t.TempDir() | |
| 296 | + | ctx := context.Background() | |
| 297 | + | ||
| 298 | + | store, err := New(root) | |
| 299 | + | require.NoError(t, err) | |
| 300 | + | ||
| 301 | + | // Save a valid cert first | |
| 302 | + | cert := out.StoredCertificate{ | |
| 303 | + | ID: "corrupt-privkey.example.com", | |
| 304 | + | Names: []string{"corrupt-privkey.example.com"}, | |
| 305 | + | Challenge: domain.ACMEChallengeHTTP01, | |
| 306 | + | FullchainPEM: []byte("fullchain-data"), | |
| 307 | + | PrivateKeyPEM: []byte("privkey-data"), | |
| 308 | + | } | |
| 309 | + | err = store.Save(ctx, cert) | |
| 310 | + | require.NoError(t, err) | |
| 311 | + | ||
| 312 | + | // Replace privkey.pem with an unreadable directory (simulate corruption) | |
| 313 | + | privkeyPath := filepath.Join(root, certDir, "corrupt-privkey.example.com", privkeyFile) | |
| 314 | + | err = os.Remove(privkeyPath) | |
| 315 | + | require.NoError(t, err) | |
| 316 | + | // Create a directory in its place to cause a read error | |
| 317 | + | err = os.MkdirAll(privkeyPath, 0700) | |
| 318 | + | require.NoError(t, err) | |
| 319 | + | ||
| 320 | + | // LoadAll should return an error | |
| 321 | + | _, err = store.LoadAll(ctx) | |
| 322 | + | require.Error(t, err) | |
| 323 | + | assert.Contains(t, err.Error(), "privkey.pem") | |
| 324 | + | } | |
| 325 | + | ||
| 326 | + | func TestStoreLoadAllCorruptFullchain(t *testing.T) { | |
| 327 | + | root := t.TempDir() | |
| 328 | + | ctx := context.Background() | |
| 329 | + | ||
| 330 | + | store, err := New(root) | |
| 331 | + | require.NoError(t, err) | |
| 332 | + | ||
| 333 | + | // Save a valid cert first | |
| 334 | + | cert := out.StoredCertificate{ | |
| 335 | + | ID: "corrupt-fullchain.example.com", | |
| 336 | + | Names: []string{"corrupt-fullchain.example.com"}, | |
| 337 | + | Challenge: domain.ACMEChallengeHTTP01, | |
| 338 | + | FullchainPEM: []byte("fullchain-data"), | |
| 339 | + | PrivateKeyPEM: []byte("privkey-data"), | |
| 340 | + | } | |
| 341 | + | err = store.Save(ctx, cert) | |
| 342 | + | require.NoError(t, err) | |
| 343 | + | ||
| 344 | + | // Replace fullchain.pem with an unreadable directory | |
| 345 | + | fullchainPath := filepath.Join(root, certDir, "corrupt-fullchain.example.com", fullchainFile) | |
| 346 | + | err = os.Remove(fullchainPath) | |
| 347 | + | require.NoError(t, err) | |
| 348 | + | err = os.MkdirAll(fullchainPath, 0700) | |
| 349 | + | require.NoError(t, err) | |
| 350 | + | ||
| 351 | + | // LoadAll should return an error | |
| 352 | + | _, err = store.LoadAll(ctx) | |
| 353 | + | require.Error(t, err) | |
| 354 | + | assert.Contains(t, err.Error(), "fullchain.pem") | |
| 355 | + | } | |
| 356 | + | ||
| 357 | + | func TestStoreLoadAllSkipsTempAndBackupDirs(t *testing.T) { | |
| 358 | + | root := t.TempDir() | |
| 359 | + | ctx := context.Background() | |
| 360 | + | ||
| 361 | + | store, err := New(root) | |
| 362 | + | require.NoError(t, err) | |
| 363 | + | ||
| 364 | + | fullchainPEM, privKeyPEM := generateTestCertPEM(t) | |
| 365 | + | ||
| 366 | + | // Save a valid certificate first. | |
| 367 | + | cert := out.StoredCertificate{ | |
| 368 | + | ID: "valid.example.com", | |
| 369 | + | Names: []string{"valid.example.com"}, | |
| 370 | + | Challenge: domain.ACMEChallengeHTTP01, | |
| 371 | + | CertPEM: fullchainPEM, | |
| 372 | + | FullchainPEM: fullchainPEM, | |
| 373 | + | PrivateKeyPEM: privKeyPEM, | |
| 374 | + | } | |
| 375 | + | err = store.Save(ctx, cert) | |
| 376 | + | require.NoError(t, err) | |
| 377 | + | ||
| 378 | + | // Create a tmp-* directory that Save would have left behind. | |
| 379 | + | tmpDir := filepath.Join(root, certDir, "orphan.tmp-abc123") | |
| 380 | + | err = os.MkdirAll(tmpDir, dirMode) | |
| 381 | + | require.NoError(t, err) | |
| 382 | + | ||
| 383 | + | // Create a .old directory that Save would have left behind. | |
| 384 | + | oldDir := filepath.Join(root, certDir, "old-cert.example.com.old") | |
| 385 | + | err = os.MkdirAll(oldDir, dirMode) | |
| 386 | + | require.NoError(t, err) | |
| 387 | + | ||
| 388 | + | // LoadAll should return only the valid cert, skipping both temp and backup dirs. | |
| 389 | + | certs, err := store.LoadAll(ctx) | |
| 390 | + | require.NoError(t, err) | |
| 391 | + | require.Len(t, certs, 1, "should skip tmp-* and *.old directories") | |
| 392 | + | assert.Equal(t, "valid.example.com", certs[0].ID) | |
| 393 | + | } | |
| 394 | + | ||
| 395 | + | func TestStoreLoadAllInvalidPEM(t *testing.T) { | |
| 396 | + | root := t.TempDir() | |
| 397 | + | ctx := context.Background() | |
| 398 | + | ||
| 399 | + | store, err := New(root) | |
| 400 | + | require.NoError(t, err) | |
| 401 | + | ||
| 402 | + | // Save a cert with non-empty but invalid PEM bytes (mismatched key) | |
| 403 | + | cert := out.StoredCertificate{ | |
| 404 | + | ID: "invalid-pem.example.com", | |
| 405 | + | Names: []string{"invalid-pem.example.com"}, | |
| 406 | + | Challenge: domain.ACMEChallengeHTTP01, | |
| 407 | + | FullchainPEM: []byte("invalid-fullchain"), | |
| 408 | + | PrivateKeyPEM: []byte("invalid-privkey"), | |
| 409 | + | } | |
| 410 | + | err = store.Save(ctx, cert) | |
| 411 | + | require.NoError(t, err) | |
| 412 | + | ||
| 413 | + | // LoadAll should return an error because tls.X509KeyPair fails | |
| 414 | + | _, err = store.LoadAll(ctx) | |
| 415 | + | require.Error(t, err) | |
| 416 | + | assert.Contains(t, err.Error(), "parse key pair") | |
| 417 | + | } | |
A internal/adapters/out/secrets/public_tls_resolver.go +142 -0
| @@ -0,0 +1,142 @@ | |||
| 1 | + | package secrets | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "fmt" | |
| 7 | + | "os" | |
| 8 | + | "strings" | |
| 9 | + | "time" | |
| 10 | + | ||
| 11 | + | "github.com/bnema/zerowrap" | |
| 12 | + | ||
| 13 | + | "github.com/bnema/gordon/internal/boundaries/out" | |
| 14 | + | "github.com/bnema/gordon/internal/domain" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | const defaultCloudflareTokenPassName = "gordon/cloudflare/api-token" | |
| 18 | + | ||
| 19 | + | // Package-level disabled logger and pass provider reused across calls. | |
| 20 | + | var disabledLogger = zerowrap.New(zerowrap.Config{Level: "disabled"}) | |
| 21 | + | var defaultPassProvider = NewPassProvider(disabledLogger) | |
| 22 | + | ||
| 23 | + | // PublicTLSResolverConfig holds optional overrides for PublicTLSResolver. | |
| 24 | + | type PublicTLSResolverConfig struct { | |
| 25 | + | PassLookup func(ctx context.Context, name string) (string, error) | |
| 26 | + | EnvLookup func(key string) string | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | // PublicTLSResolver resolves Cloudflare API tokens using pass, file, or env. | |
| 30 | + | type PublicTLSResolver struct { | |
| 31 | + | passLookup func(ctx context.Context, name string) (string, error) | |
| 32 | + | envLookup func(key string) string | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | var _ out.SecretResolver = (*PublicTLSResolver)(nil) | |
| 36 | + | ||
| 37 | + | // NewPublicTLSResolver creates a new PublicTLSResolver with the given config. | |
| 38 | + | // Defaults PassLookup to defaultPassLookup and EnvLookup to os.Getenv. | |
| 39 | + | func NewPublicTLSResolver(cfg PublicTLSResolverConfig) *PublicTLSResolver { | |
| 40 | + | r := &PublicTLSResolver{ | |
| 41 | + | passLookup: defaultPassLookup, | |
| 42 | + | envLookup: os.Getenv, | |
| 43 | + | } | |
| 44 | + | if cfg.PassLookup != nil { | |
| 45 | + | r.passLookup = cfg.PassLookup | |
| 46 | + | } | |
| 47 | + | if cfg.EnvLookup != nil { | |
| 48 | + | r.envLookup = cfg.EnvLookup | |
| 49 | + | } | |
| 50 | + | return r | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | // ResolveCloudflareToken resolves the Cloudflare API token. | |
| 54 | + | // Lookup order: pass, token file env, token env. | |
| 55 | + | func (r *PublicTLSResolver) ResolveCloudflareToken(ctx context.Context) (out.SecretValue, error) { | |
| 56 | + | var passErr error | |
| 57 | + | var fileErr error | |
| 58 | + | ||
| 59 | + | // 1. Try pass | |
| 60 | + | if token, err := r.passLookup(ctx, defaultCloudflareTokenPassName); err == nil { | |
| 61 | + | if trimmed := strings.TrimSpace(token); trimmed != "" { | |
| 62 | + | return out.SecretValue{Value: trimmed, Source: domain.ACMETokenSourcePass}, nil | |
| 63 | + | } | |
| 64 | + | } else if !errors.Is(err, os.ErrNotExist) { | |
| 65 | + | passErr = err | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | // 2. Try token file env | |
| 69 | + | if filePath := r.envLookup("GORDON_CLOUDFLARE_API_TOKEN_FILE"); filePath != "" { | |
| 70 | + | if data, err := readFileWithTimeout(ctx, filePath, 5*time.Second); err == nil { | |
| 71 | + | if trimmed := strings.TrimSpace(string(data)); trimmed != "" { | |
| 72 | + | return out.SecretValue{Value: trimmed, Source: domain.ACMETokenSourceFile}, nil | |
| 73 | + | } | |
| 74 | + | } else { | |
| 75 | + | fileErr = fmt.Errorf("read token file: %w", err) | |
| 76 | + | } | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | // 3. Try token env | |
| 80 | + | if token := r.envLookup("GORDON_CLOUDFLARE_API_TOKEN"); token != "" { | |
| 81 | + | if trimmed := strings.TrimSpace(token); trimmed != "" { | |
| 82 | + | return out.SecretValue{Value: trimmed, Source: domain.ACMETokenSourceEnv}, nil | |
| 83 | + | } | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | // 4. No token found — return remembered errors if any | |
| 87 | + | if passErr != nil { | |
| 88 | + | return out.SecretValue{Source: domain.ACMETokenSourceNone}, fmt.Errorf("resolve pass token: %w", passErr) | |
| 89 | + | } | |
| 90 | + | if fileErr != nil { | |
| 91 | + | return out.SecretValue{Source: domain.ACMETokenSourceNone}, fileErr | |
| 92 | + | } | |
| 93 | + | return out.SecretValue{Source: domain.ACMETokenSourceNone}, nil | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | // defaultPassLookup executes "pass show <name>" with a 2-second timeout. | |
| 97 | + | // Returns the raw output with trailing newline. Never logs token values. | |
| 98 | + | func defaultPassLookup(ctx context.Context, name string) (string, error) { | |
| 99 | + | // Only allow the known Cloudflare token pass name. | |
| 100 | + | if name != defaultCloudflareTokenPassName { | |
| 101 | + | return "", fmt.Errorf("pass lookup: disallowed name %q", name) | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | return defaultPassProvider.GetSecret(ctx, name) | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | // readFileWithTimeout reads a file with a context-aware deadline. | |
| 108 | + | // It runs os.ReadFile in a goroutine and selects on context expiry. | |
| 109 | + | // If the context has a deadline, it is used; otherwise a timeout is applied. | |
| 110 | + | func readFileWithTimeout(ctx context.Context, path string, timeout time.Duration) ([]byte, error) { | |
| 111 | + | readCtx := ctx | |
| 112 | + | if _, hasDeadline := ctx.Deadline(); !hasDeadline { | |
| 113 | + | var cancel context.CancelFunc | |
| 114 | + | readCtx, cancel = context.WithTimeout(ctx, timeout) | |
| 115 | + | defer cancel() | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | type result struct { | |
| 119 | + | data []byte | |
| 120 | + | err error | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | ch := make(chan result, 1) | |
| 124 | + | go func() { | |
| 125 | + | // os.ReadFile is not context-cancellable. The buffered channel ensures | |
| 126 | + | // this goroutine can finish without blocking if readCtx expires first; | |
| 127 | + | // for local config files this trade-off is acceptable, though reads on | |
| 128 | + | // slow NFS/FUSE mounts may continue after cancellation. | |
| 129 | + | data, err := os.ReadFile(path) | |
| 130 | + | select { | |
| 131 | + | case ch <- result{data: data, err: err}: | |
| 132 | + | case <-readCtx.Done(): | |
| 133 | + | } | |
| 134 | + | }() | |
| 135 | + | ||
| 136 | + | select { | |
| 137 | + | case <-readCtx.Done(): | |
| 138 | + | return nil, readCtx.Err() | |
| 139 | + | case r := <-ch: | |
| 140 | + | return r.data, r.err | |
| 141 | + | } | |
| 142 | + | } | |
A internal/adapters/out/secrets/public_tls_resolver_test.go +197 -0
| @@ -0,0 +1,197 @@ | |||
| 1 | + | package secrets | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "os" | |
| 7 | + | "path/filepath" | |
| 8 | + | "testing" | |
| 9 | + | ||
| 10 | + | "github.com/stretchr/testify/assert" | |
| 11 | + | "github.com/stretchr/testify/require" | |
| 12 | + | ||
| 13 | + | "github.com/bnema/gordon/internal/boundaries/out" | |
| 14 | + | "github.com/bnema/gordon/internal/domain" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | func TestPublicTLSResolverPrefersPass(t *testing.T) { | |
| 18 | + | env := map[string]string{ | |
| 19 | + | "GORDON_CLOUDFLARE_API_TOKEN": "env-token", | |
| 20 | + | } | |
| 21 | + | resolver := NewPublicTLSResolver(PublicTLSResolverConfig{ | |
| 22 | + | PassLookup: func(_ context.Context, _ string) (string, error) { | |
| 23 | + | return " pass-token\n", nil | |
| 24 | + | }, | |
| 25 | + | EnvLookup: func(key string) string { | |
| 26 | + | return env[key] | |
| 27 | + | }, | |
| 28 | + | }) | |
| 29 | + | ||
| 30 | + | sv, err := resolver.ResolveCloudflareToken(context.Background()) | |
| 31 | + | require.NoError(t, err) | |
| 32 | + | assert.Equal(t, "pass-token", sv.Value) | |
| 33 | + | assert.Equal(t, domain.ACMETokenSourcePass, sv.Source) | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | func TestPublicTLSResolverFallsBackToTokenFile(t *testing.T) { | |
| 37 | + | tmpDir := t.TempDir() | |
| 38 | + | tmpFile := filepath.Join(tmpDir, "cf-token") | |
| 39 | + | err := os.WriteFile(tmpFile, []byte("file-token\n"), 0644) | |
| 40 | + | require.NoError(t, err) | |
| 41 | + | ||
| 42 | + | env := map[string]string{ | |
| 43 | + | "GORDON_CLOUDFLARE_API_TOKEN_FILE": tmpFile, | |
| 44 | + | } | |
| 45 | + | resolver := NewPublicTLSResolver(PublicTLSResolverConfig{ | |
| 46 | + | PassLookup: func(_ context.Context, _ string) (string, error) { | |
| 47 | + | return "", os.ErrNotExist | |
| 48 | + | }, | |
| 49 | + | EnvLookup: func(key string) string { | |
| 50 | + | return env[key] | |
| 51 | + | }, | |
| 52 | + | }) | |
| 53 | + | ||
| 54 | + | sv, err := resolver.ResolveCloudflareToken(context.Background()) | |
| 55 | + | require.NoError(t, err) | |
| 56 | + | assert.Equal(t, "file-token", sv.Value) | |
| 57 | + | assert.Equal(t, domain.ACMETokenSourceFile, sv.Source) | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | func TestPublicTLSResolverFallsBackToEnv(t *testing.T) { | |
| 61 | + | env := map[string]string{ | |
| 62 | + | "GORDON_CLOUDFLARE_API_TOKEN": "env-token", | |
| 63 | + | } | |
| 64 | + | resolver := NewPublicTLSResolver(PublicTLSResolverConfig{ | |
| 65 | + | PassLookup: func(_ context.Context, _ string) (string, error) { | |
| 66 | + | return "", os.ErrNotExist | |
| 67 | + | }, | |
| 68 | + | EnvLookup: func(key string) string { | |
| 69 | + | return env[key] | |
| 70 | + | }, | |
| 71 | + | }) | |
| 72 | + | ||
| 73 | + | sv, err := resolver.ResolveCloudflareToken(context.Background()) | |
| 74 | + | require.NoError(t, err) | |
| 75 | + | assert.Equal(t, "env-token", sv.Value) | |
| 76 | + | assert.Equal(t, domain.ACMETokenSourceEnv, sv.Source) | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | func TestPublicTLSResolverNoToken(t *testing.T) { | |
| 80 | + | env := map[string]string{} | |
| 81 | + | resolver := NewPublicTLSResolver(PublicTLSResolverConfig{ | |
| 82 | + | PassLookup: func(_ context.Context, _ string) (string, error) { | |
| 83 | + | return "", os.ErrNotExist | |
| 84 | + | }, | |
| 85 | + | EnvLookup: func(key string) string { | |
| 86 | + | return env[key] | |
| 87 | + | }, | |
| 88 | + | }) | |
| 89 | + | ||
| 90 | + | sv, err := resolver.ResolveCloudflareToken(context.Background()) | |
| 91 | + | require.NoError(t, err) | |
| 92 | + | assert.Empty(t, sv.Value) | |
| 93 | + | assert.Equal(t, domain.ACMETokenSourceNone, sv.Source) | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | func TestPublicTLSResolverPassErrorWithoutFallback(t *testing.T) { | |
| 97 | + | expectedErr := errors.New("pass show failed") | |
| 98 | + | resolver := NewPublicTLSResolver(PublicTLSResolverConfig{ | |
| 99 | + | PassLookup: func(_ context.Context, _ string) (string, error) { | |
| 100 | + | return "", expectedErr | |
| 101 | + | }, | |
| 102 | + | EnvLookup: func(key string) string { | |
| 103 | + | return "" | |
| 104 | + | }, | |
| 105 | + | }) | |
| 106 | + | ||
| 107 | + | sv, err := resolver.ResolveCloudflareToken(context.Background()) | |
| 108 | + | require.Error(t, err) | |
| 109 | + | assert.Contains(t, err.Error(), "resolve pass token") | |
| 110 | + | assert.Contains(t, err.Error(), expectedErr.Error()) | |
| 111 | + | assert.Empty(t, sv.Value) | |
| 112 | + | assert.Equal(t, domain.ACMETokenSourceNone, sv.Source) | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | func TestPublicTLSResolverPassErrorWithEnvFallback(t *testing.T) { | |
| 116 | + | resolver := NewPublicTLSResolver(PublicTLSResolverConfig{ | |
| 117 | + | PassLookup: func(_ context.Context, _ string) (string, error) { | |
| 118 | + | return "", errors.New("pass not available") | |
| 119 | + | }, | |
| 120 | + | EnvLookup: func(key string) string { | |
| 121 | + | if key == "GORDON_CLOUDFLARE_API_TOKEN" { | |
| 122 | + | return "env-token" | |
| 123 | + | } | |
| 124 | + | return "" | |
| 125 | + | }, | |
| 126 | + | }) | |
| 127 | + | ||
| 128 | + | sv, err := resolver.ResolveCloudflareToken(context.Background()) | |
| 129 | + | require.NoError(t, err) | |
| 130 | + | assert.Equal(t, "env-token", sv.Value) | |
| 131 | + | assert.Equal(t, domain.ACMETokenSourceEnv, sv.Source) | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | func TestPublicTLSResolverTokenFileErrorWithoutFallback(t *testing.T) { | |
| 135 | + | resolver := NewPublicTLSResolver(PublicTLSResolverConfig{ | |
| 136 | + | PassLookup: func(_ context.Context, _ string) (string, error) { | |
| 137 | + | return "", os.ErrNotExist | |
| 138 | + | }, | |
| 139 | + | EnvLookup: func(key string) string { | |
| 140 | + | if key == "GORDON_CLOUDFLARE_API_TOKEN_FILE" { | |
| 141 | + | return "/nonexistent/token-file" | |
| 142 | + | } | |
| 143 | + | return "" | |
| 144 | + | }, | |
| 145 | + | }) | |
| 146 | + | ||
| 147 | + | sv, err := resolver.ResolveCloudflareToken(context.Background()) | |
| 148 | + | require.Error(t, err) | |
| 149 | + | assert.Contains(t, err.Error(), "read token file") | |
| 150 | + | assert.Empty(t, sv.Value) | |
| 151 | + | assert.Equal(t, domain.ACMETokenSourceNone, sv.Source) | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | func TestPublicTLSResolverTokenFileErrorWithEnvFallback(t *testing.T) { | |
| 155 | + | resolver := NewPublicTLSResolver(PublicTLSResolverConfig{ | |
| 156 | + | PassLookup: func(_ context.Context, _ string) (string, error) { | |
| 157 | + | return "", os.ErrNotExist | |
| 158 | + | }, | |
| 159 | + | EnvLookup: func(key string) string { | |
| 160 | + | if key == "GORDON_CLOUDFLARE_API_TOKEN_FILE" { | |
| 161 | + | return "/nonexistent/token-file" | |
| 162 | + | } | |
| 163 | + | if key == "GORDON_CLOUDFLARE_API_TOKEN" { | |
| 164 | + | return "env-token" | |
| 165 | + | } | |
| 166 | + | return "" | |
| 167 | + | }, | |
| 168 | + | }) | |
| 169 | + | ||
| 170 | + | sv, err := resolver.ResolveCloudflareToken(context.Background()) | |
| 171 | + | require.NoError(t, err) | |
| 172 | + | assert.Equal(t, "env-token", sv.Value) | |
| 173 | + | assert.Equal(t, domain.ACMETokenSourceEnv, sv.Source) | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | func TestPublicTLSResolverEmptyPassValueFallback(t *testing.T) { | |
| 177 | + | // Empty string from pass (no error) should fall through to other sources | |
| 178 | + | resolver := NewPublicTLSResolver(PublicTLSResolverConfig{ | |
| 179 | + | PassLookup: func(_ context.Context, _ string) (string, error) { | |
| 180 | + | return "", nil // no error but empty value | |
| 181 | + | }, | |
| 182 | + | EnvLookup: func(key string) string { | |
| 183 | + | if key == "GORDON_CLOUDFLARE_API_TOKEN" { | |
| 184 | + | return "env-token" | |
| 185 | + | } | |
| 186 | + | return "" | |
| 187 | + | }, | |
| 188 | + | }) | |
| 189 | + | ||
| 190 | + | sv, err := resolver.ResolveCloudflareToken(context.Background()) | |
| 191 | + | require.NoError(t, err) | |
| 192 | + | assert.Equal(t, "env-token", sv.Value) | |
| 193 | + | assert.Equal(t, domain.ACMETokenSourceEnv, sv.Source) | |
| 194 | + | } | |
| 195 | + | ||
| 196 | + | // Ensure PublicTLSResolver implements out.SecretResolver at compile time. | |
| 197 | + | var _ out.SecretResolver = (*PublicTLSResolver)(nil) | |
A internal/app/acme_config_test.go +17 -0
| @@ -0,0 +1,17 @@ | |||
| 1 | + | package app | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/spf13/viper" | |
| 7 | + | "github.com/stretchr/testify/assert" | |
| 8 | + | "github.com/stretchr/testify/require" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func TestLoadConfig_ACMEDefaults(t *testing.T) { | |
| 12 | + | v := viper.New() | |
| 13 | + | require.NoError(t, loadConfig(v, "")) | |
| 14 | + | assert.False(t, v.GetBool("tls.acme.enabled")) | |
| 15 | + | assert.Equal(t, "", v.GetString("tls.acme.email")) | |
| 16 | + | assert.Equal(t, "auto", v.GetString("tls.acme.challenge")) | |
| 17 | + | } | |
M internal/app/kernel.go +22 -2
| @@ -4,6 +4,7 @@ import ( | |||
| 4 | 4 | "context" | |
| 5 | 5 | "fmt" | |
| 6 | 6 | "io" | |
| 7 | + | "time" | |
| 7 | 8 | ||
| 8 | 9 | "github.com/bnema/zerowrap" | |
| 9 | 10 | ||
| @@ -25,6 +26,7 @@ type Kernel struct { | |||
| 25 | 26 | healthSvc in.HealthService | |
| 26 | 27 | logSvc in.LogService | |
| 27 | 28 | volumeSvc in.VolumeService | |
| 29 | + | publicTLSSvc in.PublicTLSService | |
| 28 | 30 | cleanup func() | |
| 29 | 31 | } | |
| 30 | 32 | ||
| @@ -59,7 +61,22 @@ func newKernel(configPath string, initLog kernelLoggerInit) (*Kernel, error) { | |||
| 59 | 61 | ||
| 60 | 62 | // Prefer full service wiring so local CLI can execute the same operations | |
| 61 | 63 | // as remote mode without going through HTTP admin endpoints. | |
| 62 | - | if svc, fullErr := createServices(ctx, v, cfg, log); fullErr == nil { | |
| 64 | + | // Use StartPublicTLS: false to prevent ACME Reconcile and renewal loop | |
| 65 | + | // side effects during read-only CLI commands (e.g. gordon tls status). | |
| 66 | + | if svc, fullErr := createServicesWithOptions(ctx, v, cfg, log, serviceOptions{StartPublicTLS: false}); fullErr == nil { | |
| 67 | + | // Wrap cleanup to stop public TLS service (with its renewal loop) | |
| 68 | + | // before the logger is cleaned up. | |
| 69 | + | wrappedCleanup := func() { | |
| 70 | + | if svc.publicTLSSvc != nil { | |
| 71 | + | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | |
| 72 | + | defer cancel() | |
| 73 | + | if err := svc.publicTLSSvc.Stop(ctx); err != nil { | |
| 74 | + | log.Warn().Err(err).Msg("failed to stop public TLS service") | |
| 75 | + | } | |
| 76 | + | } | |
| 77 | + | cleanup() | |
| 78 | + | } | |
| 79 | + | ||
| 63 | 80 | return &Kernel{ | |
| 64 | 81 | authEnabled: cfg.Auth.Enabled, | |
| 65 | 82 | configSvc: svc.configSvc, | |
| @@ -70,7 +87,8 @@ func newKernel(configPath string, initLog kernelLoggerInit) (*Kernel, error) { | |||
| 70 | 87 | healthSvc: svc.healthSvc, | |
| 71 | 88 | logSvc: svc.logSvc, | |
| 72 | 89 | volumeSvc: svc.volumeSvc, | |
| 73 | - | cleanup: cleanup, | |
| 90 | + | publicTLSSvc: svc.publicTLSSvc, | |
| 91 | + | cleanup: wrappedCleanup, | |
| 74 | 92 | }, nil | |
| 75 | 93 | } else { | |
| 76 | 94 | log.Warn().Err(fullErr).Msg("local kernel running in minimal mode") | |
| @@ -126,4 +144,6 @@ func (k *Kernel) Logs() in.LogService { return k.logSvc } | |||
| 126 | 144 | ||
| 127 | 145 | func (k *Kernel) Volumes() in.VolumeService { return k.volumeSvc } | |
| 128 | 146 | ||
| 147 | + | func (k *Kernel) PublicTLS() in.PublicTLSService { return k.publicTLSSvc } | |
| 148 | + | ||
| 129 | 149 | func (k *Kernel) AuthEnabled() bool { return k != nil && k.authEnabled } | |
M internal/app/migrate_env_test.go +3 -3
| @@ -32,7 +32,7 @@ func requirePass(t *testing.T) { | |||
| 32 | 32 | } | |
| 33 | 33 | } | |
| 34 | 34 | ||
| 35 | - | func cleanupPassDomain(t *testing.T, domainName string, keys []string) { | |
| 35 | + | func cleanupPassDomain(domainName string, keys []string) { | |
| 36 | 36 | safeDomain, err := domain.SanitizeDomainForEnvFile(domainName) | |
| 37 | 37 | if err != nil { | |
| 38 | 38 | return | |
| @@ -60,7 +60,7 @@ func TestMigrateEnvFile(t *testing.T) { | |||
| 60 | 60 | ||
| 61 | 61 | domainName := fmt.Sprintf("test.%d.example.com", time.Now().UnixNano()) | |
| 62 | 62 | keys := []string{"API_KEY", "DB_PASSWORD"} | |
| 63 | - | defer cleanupPassDomain(t, domainName, keys) | |
| 63 | + | defer cleanupPassDomain(domainName, keys) | |
| 64 | 64 | ||
| 65 | 65 | envContent := "API_KEY=secret123\nDB_PASSWORD=pass456\n" | |
| 66 | 66 | envFileName := domainName + ".env" | |
| @@ -87,7 +87,7 @@ func TestMigrateEnvFile(t *testing.T) { | |||
| 87 | 87 | assert.Equal(t, "pass456", values["DB_PASSWORD"]) | |
| 88 | 88 | } | |
| 89 | 89 | ||
| 90 | - | func cleanupPassAttachment(t *testing.T, containerName string, keys []string) { | |
| 90 | + | func cleanupPassAttachment(_ *testing.T, containerName string, keys []string) { | |
| 91 | 91 | for _, key := range keys { | |
| 92 | 92 | path := fmt.Sprintf("%s/%s/%s", domainsecrets.PassAttachmentPath, containerName, key) | |
| 93 | 93 | _ = passCmd("rm", "-f", path) | |
A internal/app/public_tls_wiring_test.go +151 -0
| @@ -0,0 +1,151 @@ | |||
| 1 | + | package app | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "crypto/rand" | |
| 6 | + | "crypto/rsa" | |
| 7 | + | "crypto/tls" | |
| 8 | + | "crypto/x509" | |
| 9 | + | "math/big" | |
| 10 | + | "net" | |
| 11 | + | "testing" | |
| 12 | + | "time" | |
| 13 | + | ||
| 14 | + | "github.com/stretchr/testify/assert" | |
| 15 | + | "github.com/stretchr/testify/mock" | |
| 16 | + | "github.com/stretchr/testify/require" | |
| 17 | + | ||
| 18 | + | pkiadapter "github.com/bnema/gordon/internal/adapters/out/pki" | |
| 19 | + | inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks" | |
| 20 | + | outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks" | |
| 21 | + | "github.com/bnema/gordon/internal/domain" | |
| 22 | + | pkiusecase "github.com/bnema/gordon/internal/usecase/pki" | |
| 23 | + | "github.com/bnema/zerowrap" | |
| 24 | + | ) | |
| 25 | + | ||
| 26 | + | // generateTestCert creates a self-signed certificate for the given hostnames. | |
| 27 | + | func generateTestCert(t *testing.T, hosts ...string) tls.Certificate { | |
| 28 | + | t.Helper() | |
| 29 | + | ||
| 30 | + | privKey, err := rsa.GenerateKey(rand.Reader, 2048) | |
| 31 | + | require.NoError(t, err) | |
| 32 | + | ||
| 33 | + | template := &x509.Certificate{ | |
| 34 | + | SerialNumber: big.NewInt(1), | |
| 35 | + | NotBefore: time.Now().Add(-1 * time.Hour), | |
| 36 | + | NotAfter: time.Now().Add(24 * time.Hour), | |
| 37 | + | IPAddresses: []net.IP{net.ParseIP("127.0.0.1")}, | |
| 38 | + | } | |
| 39 | + | for _, host := range hosts { | |
| 40 | + | if ip := net.ParseIP(host); ip != nil { | |
| 41 | + | template.IPAddresses = append(template.IPAddresses, ip) | |
| 42 | + | } else { | |
| 43 | + | template.DNSNames = append(template.DNSNames, host) | |
| 44 | + | } | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | certDER, err := x509.CreateCertificate(rand.Reader, template, template, &privKey.PublicKey, privKey) | |
| 48 | + | require.NoError(t, err) | |
| 49 | + | ||
| 50 | + | return tls.Certificate{ | |
| 51 | + | Certificate: [][]byte{certDER}, | |
| 52 | + | PrivateKey: privKey, | |
| 53 | + | } | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | // TestCertificateSelector_StaticCertWins tests that a static certificate matching | |
| 57 | + | // the server name is returned before public TLS. | |
| 58 | + | func TestCertificateSelector_StaticCertWins(t *testing.T) { | |
| 59 | + | staticCert := generateTestCert(t, "static.example.com") | |
| 60 | + | selector := &certificateSelector{ | |
| 61 | + | staticCerts: prepareStaticTLSCertificates([]tls.Certificate{staticCert}), | |
| 62 | + | publicTLS: inmocks.NewMockPublicTLSService(t), | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | got, err := selector.GetCertificate(&tls.ClientHelloInfo{ServerName: "static.example.com"}) | |
| 66 | + | require.NoError(t, err) | |
| 67 | + | require.NotNil(t, got) | |
| 68 | + | assert.Equal(t, staticCert.Certificate[0], got.Certificate[0], | |
| 69 | + | "static cert should be returned, not public TLS") | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | func TestCertificateSelector_PublicTLSErrorFallsThroughWithoutLocalPKI(t *testing.T) { | |
| 73 | + | publicTLS := inmocks.NewMockPublicTLSService(t) | |
| 74 | + | publicTLS.EXPECT().GetCertificateForHost("acme-required.example.com").Return(nil, domain.ErrTLSRouteNotCovered) | |
| 75 | + | selector := &certificateSelector{publicTLS: publicTLS} | |
| 76 | + | ||
| 77 | + | got, err := selector.GetCertificate(&tls.ClientHelloInfo{ServerName: "acme-required.example.com"}) | |
| 78 | + | require.NoError(t, err) | |
| 79 | + | assert.Nil(t, got, "public TLS error with no local PKI returns nil,nil") | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | // TestCertificateSelector_PublicTLSNilNil tests that when public TLS returns | |
| 83 | + | // nil,nil and no local PKI, the selector returns nil,nil. | |
| 84 | + | func TestCertificateSelector_PublicTLSErrorFallsThroughToLocalPKI(t *testing.T) { | |
| 85 | + | publicTLS := inmocks.NewMockPublicTLSService(t) | |
| 86 | + | publicTLS.EXPECT().GetCertificateForHost("acme-required.example.com").Return(nil, domain.ErrTLSRouteNotCovered) | |
| 87 | + | ||
| 88 | + | routes := outmocks.NewMockRouteChecker(t) | |
| 89 | + | routes.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{{Domain: "acme-required.example.com"}}).Maybe() | |
| 90 | + | routes.EXPECT().GetExternalRoutes().Return(map[string]string{}).Maybe() | |
| 91 | + | ca, err := pkiadapter.NewCA(t.TempDir(), zerowrap.Default()) | |
| 92 | + | require.NoError(t, err) | |
| 93 | + | pkiSvc := pkiusecase.NewService(context.Background(), ca, routes, zerowrap.Default()) | |
| 94 | + | defer pkiSvc.Stop() | |
| 95 | + | ||
| 96 | + | selector := &certificateSelector{publicTLS: publicTLS, localPKI: pkiSvc} | |
| 97 | + | got, err := selector.GetCertificate(&tls.ClientHelloInfo{ServerName: "acme-required.example.com"}) | |
| 98 | + | require.NoError(t, err) | |
| 99 | + | require.NotNil(t, got, "public TLS error should fall through to local PKI") | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | func TestCertificateSelector_PublicTLSNilNil(t *testing.T) { | |
| 103 | + | publicTLS := inmocks.NewMockPublicTLSService(t) | |
| 104 | + | publicTLS.EXPECT().GetCertificateForHost("non-acme.example.com").Return(nil, nil) | |
| 105 | + | selector := &certificateSelector{publicTLS: publicTLS} | |
| 106 | + | ||
| 107 | + | got, err := selector.GetCertificate(&tls.ClientHelloInfo{ServerName: "non-acme.example.com"}) | |
| 108 | + | require.NoError(t, err) | |
| 109 | + | assert.Nil(t, got, "nil,nil from public TLS with no local PKI returns nil,nil") | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | // TestCertificateSelector_NoSource tests that when no source has a matching cert, | |
| 113 | + | // nil, nil is returned. | |
| 114 | + | func TestCertificateSelector_NoSource(t *testing.T) { | |
| 115 | + | selector := &certificateSelector{} | |
| 116 | + | ||
| 117 | + | got, err := selector.GetCertificate(&tls.ClientHelloInfo{ServerName: "unknown.example.com"}) | |
| 118 | + | assert.NoError(t, err) | |
| 119 | + | assert.Nil(t, got) | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | // TestMatchingStaticCert tests the matchingStaticCert helper. | |
| 123 | + | func TestMatchingStaticCert(t *testing.T) { | |
| 124 | + | certA := generateTestCert(t, "app.example.com") | |
| 125 | + | certB := generateTestCert(t, "api.example.com") | |
| 126 | + | ||
| 127 | + | certs := []tls.Certificate{certA, certB} | |
| 128 | + | ||
| 129 | + | // Exact match | |
| 130 | + | got := matchingStaticCert(certs, "app.example.com") | |
| 131 | + | require.NotNil(t, got) | |
| 132 | + | assert.Equal(t, certA.Certificate[0], got.Certificate[0]) | |
| 133 | + | ||
| 134 | + | // Second cert match | |
| 135 | + | got = matchingStaticCert(certs, "api.example.com") | |
| 136 | + | require.NotNil(t, got) | |
| 137 | + | assert.Equal(t, certB.Certificate[0], got.Certificate[0]) | |
| 138 | + | ||
| 139 | + | // No match | |
| 140 | + | got = matchingStaticCert(certs, "other.example.com") | |
| 141 | + | assert.Nil(t, got) | |
| 142 | + | ||
| 143 | + | // Empty server name returns the first configured static certificate. | |
| 144 | + | got = matchingStaticCert(certs, "") | |
| 145 | + | require.NotNil(t, got) | |
| 146 | + | assert.Equal(t, certA.Certificate[0], got.Certificate[0]) | |
| 147 | + | ||
| 148 | + | // Empty cert list | |
| 149 | + | got = matchingStaticCert(nil, "app.example.com") | |
| 150 | + | assert.Nil(t, got) | |
| 151 | + | } | |
M internal/app/run.go +295 -42
| @@ -5,6 +5,7 @@ import ( | |||
| 5 | 5 | "context" | |
| 6 | 6 | "crypto/rand" | |
| 7 | 7 | "crypto/tls" | |
| 8 | + | "crypto/x509" | |
| 8 | 9 | "encoding/hex" | |
| 9 | 10 | "encoding/json" | |
| 10 | 11 | "errors" | |
| @@ -27,6 +28,8 @@ import ( | |||
| 27 | 28 | ||
| 28 | 29 | // Adapters - Output | |
| 29 | 30 | "github.com/bnema/gordon/internal/adapters/out/accesslog" | |
| 31 | + | acmelego "github.com/bnema/gordon/internal/adapters/out/acmelego" | |
| 32 | + | acmestore "github.com/bnema/gordon/internal/adapters/out/acmestore" | |
| 30 | 33 | "github.com/bnema/gordon/internal/adapters/out/docker" | |
| 31 | 34 | "github.com/bnema/gordon/internal/adapters/out/domainsecrets" | |
| 32 | 35 | "github.com/bnema/gordon/internal/adapters/out/envloader" | |
| @@ -45,6 +48,7 @@ import ( | |||
| 45 | 48 | ||
| 46 | 49 | // Adapters - Input | |
| 47 | 50 | "github.com/bnema/gordon/internal/adapters/dto" | |
| 51 | + | acmehttp "github.com/bnema/gordon/internal/adapters/in/http/acme" | |
| 48 | 52 | "github.com/bnema/gordon/internal/adapters/in/http/admin" | |
| 49 | 53 | authhandler "github.com/bnema/gordon/internal/adapters/in/http/auth" | |
| 50 | 54 | "github.com/bnema/gordon/internal/adapters/in/http/httphelper" | |
| @@ -54,6 +58,7 @@ import ( | |||
| 54 | 58 | "github.com/bnema/gordon/internal/adapters/in/http/registry" | |
| 55 | 59 | ||
| 56 | 60 | // Boundaries | |
| 61 | + | "github.com/bnema/gordon/internal/boundaries/in" | |
| 57 | 62 | "github.com/bnema/gordon/internal/boundaries/out" | |
| 58 | 63 | ||
| 59 | 64 | // Domain | |
| @@ -75,6 +80,7 @@ import ( | |||
| 75 | 80 | "github.com/bnema/gordon/internal/usecase/logs" | |
| 76 | 81 | pkiusecase "github.com/bnema/gordon/internal/usecase/pki" | |
| 77 | 82 | "github.com/bnema/gordon/internal/usecase/proxy" | |
| 83 | + | "github.com/bnema/gordon/internal/usecase/publictls" | |
| 78 | 84 | registrySvc "github.com/bnema/gordon/internal/usecase/registry" | |
| 79 | 85 | secretsSvc "github.com/bnema/gordon/internal/usecase/secrets" | |
| 80 | 86 | volumesSvc "github.com/bnema/gordon/internal/usecase/volumes" | |
| @@ -189,6 +195,14 @@ type Config struct { | |||
| 189 | 195 | } `mapstructure:"containers"` | |
| 190 | 196 | ||
| 191 | 197 | Telemetry telemetry.Config `mapstructure:"telemetry"` | |
| 198 | + | ||
| 199 | + | TLS struct { | |
| 200 | + | ACME struct { | |
| 201 | + | Enabled bool `mapstructure:"enabled"` | |
| 202 | + | Email string `mapstructure:"email"` | |
| 203 | + | Challenge string `mapstructure:"challenge"` | |
| 204 | + | } `mapstructure:"acme"` | |
| 205 | + | } `mapstructure:"tls"` | |
| 192 | 206 | } | |
| 193 | 207 | ||
| 194 | 208 | // services holds all the services used by the application. | |
| @@ -224,6 +238,7 @@ type services struct { | |||
| 224 | 238 | caAdapter *pkiadapter.CA | |
| 225 | 239 | pkiSvc *pkiusecase.Service | |
| 226 | 240 | reloadCoordinator *reloadCoordinator | |
| 241 | + | publicTLSSvc in.PublicTLSService | |
| 227 | 242 | registryHandler interface { | |
| 228 | 243 | UpdateBlobLimits(maxBlobChunkSize, maxBlobSize int64) | |
| 229 | 244 | } | |
| @@ -401,27 +416,53 @@ func initAccessLog(cfg Config, log zerowrap.Logger) (*accesslog.Writer, error) { | |||
| 401 | 416 | return writer, nil | |
| 402 | 417 | } | |
| 403 | 418 | ||
| 419 | + | // serviceOptions controls optional behavior for createServicesWithOptions. | |
| 420 | + | type serviceOptions struct { | |
| 421 | + | // StartPublicTLS controls whether ACME Reconcile and renewal loop are started. | |
| 422 | + | // Server runtime sets true; Kernel/local CLI sets false (read-only status). | |
| 423 | + | StartPublicTLS bool | |
| 424 | + | } | |
| 425 | + | ||
| 404 | 426 | // serviceInit holds the shared context for service initialization helpers. | |
| 405 | 427 | type serviceInit struct { | |
| 406 | - | ctx context.Context | |
| 407 | - | v *viper.Viper | |
| 408 | - | cfg Config | |
| 409 | - | log zerowrap.Logger | |
| 410 | - | svc *services | |
| 428 | + | ctx context.Context | |
| 429 | + | v *viper.Viper | |
| 430 | + | cfg Config | |
| 431 | + | log zerowrap.Logger | |
| 432 | + | svc *services | |
| 433 | + | startPublicTLS bool | |
| 411 | 434 | } | |
| 412 | 435 | ||
| 413 | - | // createServices creates all the application services. | |
| 436 | + | // createServices creates all the application services for server runtime. | |
| 437 | + | // It calls createServicesWithOptions with StartPublicTLS: true. | |
| 414 | 438 | func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowrap.Logger) (_ *services, retErr error) { | |
| 439 | + | return createServicesWithOptions(ctx, v, cfg, log, serviceOptions{StartPublicTLS: true}) | |
| 440 | + | } | |
| 441 | + | ||
| 442 | + | // createServicesWithOptions creates all the application services with the given options. | |
| 443 | + | // When StartPublicTLS is false, ACME Reconcile and renewal loop are skipped so that | |
| 444 | + | // read-only CLI commands (e.g. gordon tls status) do not perform ACME side effects. | |
| 445 | + | func createServicesWithOptions(ctx context.Context, v *viper.Viper, cfg Config, log zerowrap.Logger, opts serviceOptions) (_ *services, retErr error) { | |
| 415 | 446 | si := &serviceInit{ | |
| 416 | - | ctx: ctx, | |
| 417 | - | v: v, | |
| 418 | - | cfg: cfg, | |
| 419 | - | log: log, | |
| 420 | - | svc: &services{}, | |
| 447 | + | ctx: ctx, | |
| 448 | + | v: v, | |
| 449 | + | cfg: cfg, | |
| 450 | + | log: log, | |
| 451 | + | svc: &services{}, | |
| 452 | + | startPublicTLS: opts.StartPublicTLS, | |
| 421 | 453 | } | |
| 422 | 454 | defer func() { | |
| 423 | - | if retErr != nil && si.svc.pkiSvc != nil { | |
| 424 | - | si.svc.pkiSvc.Stop() | |
| 455 | + | if retErr != nil { | |
| 456 | + | if si.svc.pkiSvc != nil { | |
| 457 | + | si.svc.pkiSvc.Stop() | |
| 458 | + | } | |
| 459 | + | if si.svc.publicTLSSvc != nil { | |
| 460 | + | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | |
| 461 | + | defer cancel() | |
| 462 | + | if err := si.svc.publicTLSSvc.Stop(ctx); err != nil { | |
| 463 | + | si.log.Warn().Err(err).Msg("failed to stop public TLS service during createServices cleanup") | |
| 464 | + | } | |
| 465 | + | } | |
| 425 | 466 | } | |
| 426 | 467 | }() | |
| 427 | 468 | var err error | |
| @@ -465,11 +506,15 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra | |||
| 465 | 506 | return nil, err | |
| 466 | 507 | } | |
| 467 | 508 | ||
| 509 | + | if err := si.initPublicTLS(); err != nil { | |
| 510 | + | return nil, err | |
| 511 | + | } | |
| 512 | + | ||
| 468 | 513 | if err := si.initRuntimeAndProxy(); err != nil { | |
| 469 | 514 | return nil, err | |
| 470 | 515 | } | |
| 471 | 516 | ||
| 472 | - | si.svc.reloadCoordinator = newReloadCoordinator(v, si.svc.configSvc, si.svc.proxySvc, nil, si.svc.eventBus, log) | |
| 517 | + | si.svc.reloadCoordinator = newReloadCoordinator(v, si.svc.configSvc, si.svc.proxySvc, nil, si.svc.eventBus, si.svc.publicTLSSvc, log) | |
| 473 | 518 | ||
| 474 | 519 | si.initHandlers() | |
| 475 | 520 | ||
| @@ -496,6 +541,92 @@ func (si *serviceInit) initPKI() error { | |||
| 496 | 541 | return nil | |
| 497 | 542 | } | |
| 498 | 543 | ||
| 544 | + | // initPublicTLS initializes the public ACME TLS service if enabled. | |
| 545 | + | func (si *serviceInit) initPublicTLS() error { | |
| 546 | + | if !si.cfg.TLS.ACME.Enabled { | |
| 547 | + | return nil | |
| 548 | + | } | |
| 549 | + | ||
| 550 | + | if si.cfg.Server.TLSPort == 0 { | |
| 551 | + | return fmt.Errorf("%w: tls.acme.enabled requires server.tls_port > 0", domain.ErrACMEChallengeInvalid) | |
| 552 | + | } | |
| 553 | + | ||
| 554 | + | ctx := si.ctx | |
| 555 | + | log := si.log | |
| 556 | + | ||
| 557 | + | publicTLSCfg := publictls.Config{ | |
| 558 | + | Enabled: si.cfg.TLS.ACME.Enabled, | |
| 559 | + | Email: si.cfg.TLS.ACME.Email, | |
| 560 | + | Challenge: si.cfg.TLS.ACME.Challenge, | |
| 561 | + | HTTPPort: si.cfg.Server.Port, | |
| 562 | + | TLSPort: si.cfg.Server.TLSPort, | |
| 563 | + | DataDir: resolveDataDir(si.cfg.Server.DataDir), | |
| 564 | + | } | |
| 565 | + | ||
| 566 | + | tokenResolver := secrets.NewPublicTLSResolver(secrets.PublicTLSResolverConfig{}) | |
| 567 | + | ||
| 568 | + | effective, err := publictls.ResolveEffectiveChallenge(ctx, publicTLSCfg, tokenResolver) | |
| 569 | + | if err != nil { | |
| 570 | + | return log.WrapErr(err, "resolve ACME challenge") | |
| 571 | + | } | |
| 572 | + | ||
| 573 | + | store, err := acmestore.New(filepath.Join(resolveDataDir(si.cfg.Server.DataDir), "acme")) | |
| 574 | + | if err != nil { | |
| 575 | + | return log.WrapErr(err, "create ACME store") | |
| 576 | + | } | |
| 577 | + | ||
| 578 | + | challenges := publictls.NewHTTP01Challenges() | |
| 579 | + | ||
| 580 | + | var zoneResolver *acmelego.CloudflareZoneResolver | |
| 581 | + | if effective.Mode == domain.ACMEChallengeCloudflareDNS01 { | |
| 582 | + | zoneResolver = acmelego.NewCloudflareZoneResolver(effective.Token) | |
| 583 | + | } | |
| 584 | + | ||
| 585 | + | issuer, err := acmelego.NewIssuer(acmelego.Config{ | |
| 586 | + | Email: si.cfg.TLS.ACME.Email, | |
| 587 | + | Challenge: effective.Mode, | |
| 588 | + | Token: effective.Token, | |
| 589 | + | Store: store, | |
| 590 | + | HTTPChallengeSink: challenges, | |
| 591 | + | }) | |
| 592 | + | if err != nil { | |
| 593 | + | return log.WrapErr(err, "create ACME issuer") | |
| 594 | + | } | |
| 595 | + | ||
| 596 | + | svc := publictls.NewService(publicTLSCfg, publictls.ServiceDeps{ | |
| 597 | + | Routes: si.svc.configSvc, | |
| 598 | + | Issuer: issuer, | |
| 599 | + | Store: store, | |
| 600 | + | ZoneResolver: zoneResolver, | |
| 601 | + | Challenges: challenges, | |
| 602 | + | Effective: effective, | |
| 603 | + | }) | |
| 604 | + | ||
| 605 | + | if err := svc.Load(ctx); err != nil { | |
| 606 | + | log.Warn().Err(err).Msg("failed to load ACME certificates, continuing") | |
| 607 | + | } | |
| 608 | + | ||
| 609 | + | if si.startPublicTLS { | |
| 610 | + | if err := svc.Reconcile(ctx); err != nil { | |
| 611 | + | log.Warn().Err(err).Msg("failed to reconcile ACME certificates, continuing") | |
| 612 | + | } | |
| 613 | + | svc.StartRenewalLoop(ctx, time.Hour) | |
| 614 | + | log.Info(). | |
| 615 | + | Str("email", si.cfg.TLS.ACME.Email). | |
| 616 | + | Str("challenge", string(effective.Mode)). | |
| 617 | + | Str("reason", effective.Reason). | |
| 618 | + | Msg("public ACME TLS initialized") | |
| 619 | + | } else { | |
| 620 | + | log.Info(). | |
| 621 | + | Str("email", si.cfg.TLS.ACME.Email). | |
| 622 | + | Str("challenge", string(effective.Mode)). | |
| 623 | + | Msg("public ACME TLS initialized (read-only, no renewal loop)") | |
| 624 | + | } | |
| 625 | + | ||
| 626 | + | si.svc.publicTLSSvc = svc | |
| 627 | + | return nil | |
| 628 | + | } | |
| 629 | + | ||
| 499 | 630 | // initSecrets creates the domain secret store, env loader, and secret service. | |
| 500 | 631 | func (si *serviceInit) initSecrets() error { | |
| 501 | 632 | envDir, backend, passStore, domainSecretStore, err := createDomainSecretStore(si.cfg, si.log) | |
| @@ -576,6 +707,7 @@ func (si *serviceInit) initHandlers() { | |||
| 576 | 707 | PreviewSvc: si.svc.previewService, | |
| 577 | 708 | ImageSvc: si.svc.imageSvc, | |
| 578 | 709 | VolumeSvc: si.svc.volumeSvc, | |
| 710 | + | PublicTLSSvc: si.svc.publicTLSSvc, | |
| 579 | 711 | }) | |
| 580 | 712 | } | |
| 581 | 713 | ||
| @@ -595,13 +727,7 @@ func initPreviewService(ctx context.Context, cfg Config, svc *services, log zero | |||
| 595 | 727 | } | |
| 596 | 728 | ||
| 597 | 729 | // Derive sweep interval from TTL: half the TTL, capped at 1 hour, minimum 1 minute. | |
| 598 | - | sweepInterval := previewConfig.TTL / 2 | |
| 599 | - | if sweepInterval > 1*time.Hour { | |
| 600 | - | sweepInterval = 1 * time.Hour | |
| 601 | - | } | |
| 602 | - | if sweepInterval < 1*time.Minute { | |
| 603 | - | sweepInterval = 1 * time.Minute | |
| 604 | - | } | |
| 730 | + | sweepInterval := max(min(previewConfig.TTL/2, time.Hour), time.Minute) | |
| 605 | 731 | ||
| 606 | 732 | svc.previewService.StartTicker(ctx, sweepInterval, func(ctx context.Context, p domain.PreviewRoute) { | |
| 607 | 733 | teardownTrackedPreview(ctx, svc, p) | |
| @@ -840,8 +966,8 @@ func resolveRuntimeConfig(value string) string { | |||
| 840 | 966 | return "/var/run/docker.sock" | |
| 841 | 967 | } | |
| 842 | 968 | // Explicit socket path — strip URI scheme if present. | |
| 843 | - | if strings.HasPrefix(value, "unix://") { | |
| 844 | - | return strings.TrimPrefix(value, "unix://") | |
| 969 | + | if socketPath, ok := strings.CutPrefix(value, "unix://"); ok { | |
| 970 | + | return socketPath | |
| 845 | 971 | } | |
| 846 | 972 | return value | |
| 847 | 973 | } | |
| @@ -1430,6 +1556,11 @@ type configWatcher interface { | |||
| 1430 | 1556 | Watch(ctx context.Context, onChange func()) error | |
| 1431 | 1557 | } | |
| 1432 | 1558 | ||
| 1559 | + | // publicTLSReconciler is the interface for reconciling public TLS certificates. | |
| 1560 | + | type publicTLSReconciler interface { | |
| 1561 | + | Reconcile(context.Context) error | |
| 1562 | + | } | |
| 1563 | + | ||
| 1433 | 1564 | type configReloader interface { | |
| 1434 | 1565 | Reload(ctx context.Context) error | |
| 1435 | 1566 | } | |
| @@ -1457,13 +1588,14 @@ type reloadCoordinator struct { | |||
| 1457 | 1588 | registryLimits interface { | |
| 1458 | 1589 | UpdateBlobLimits(maxBlobChunkSize, maxBlobSize int64) | |
| 1459 | 1590 | } | |
| 1460 | - | eventBus out.EventPublisher | |
| 1461 | - | log zerowrap.Logger | |
| 1591 | + | eventBus out.EventPublisher | |
| 1592 | + | publicTLS publicTLSReconciler | |
| 1593 | + | log zerowrap.Logger | |
| 1462 | 1594 | } | |
| 1463 | 1595 | ||
| 1464 | 1596 | func newReloadCoordinator(v *viper.Viper, configSvc configReloader, proxySvc proxyConfigUpdater, registryLimits interface { | |
| 1465 | 1597 | UpdateBlobLimits(maxBlobChunkSize, maxBlobSize int64) | |
| 1466 | - | }, eventBus out.EventPublisher, log zerowrap.Logger) *reloadCoordinator { | |
| 1598 | + | }, eventBus out.EventPublisher, publicTLS publicTLSReconciler, log zerowrap.Logger) *reloadCoordinator { | |
| 1467 | 1599 | return &reloadCoordinator{ | |
| 1468 | 1600 | debounce: 500 * time.Millisecond, | |
| 1469 | 1601 | configSvc: configSvc, | |
| @@ -1471,6 +1603,7 @@ func newReloadCoordinator(v *viper.Viper, configSvc configReloader, proxySvc pro | |||
| 1471 | 1603 | proxySvc: proxySvc, | |
| 1472 | 1604 | registryLimits: registryLimits, | |
| 1473 | 1605 | eventBus: eventBus, | |
| 1606 | + | publicTLS: publicTLS, | |
| 1474 | 1607 | log: log, | |
| 1475 | 1608 | } | |
| 1476 | 1609 | } | |
| @@ -1511,14 +1644,14 @@ func (c *reloadCoordinator) reloadLocked(ctx context.Context, loadConfig bool) e | |||
| 1511 | 1644 | } | |
| 1512 | 1645 | } | |
| 1513 | 1646 | ||
| 1514 | - | if err := c.applyLoadedConfig(now); err != nil { | |
| 1647 | + | if err := c.applyLoadedConfig(ctx, now); err != nil { | |
| 1515 | 1648 | return err | |
| 1516 | 1649 | } | |
| 1517 | 1650 | ||
| 1518 | 1651 | return nil | |
| 1519 | 1652 | } | |
| 1520 | 1653 | ||
| 1521 | - | func (c *reloadCoordinator) applyLoadedConfig(now time.Time) error { | |
| 1654 | + | func (c *reloadCoordinator) applyLoadedConfig(ctx context.Context, now time.Time) error { | |
| 1522 | 1655 | var reloadCfg Config | |
| 1523 | 1656 | if err := c.v.Unmarshal(&reloadCfg); err != nil { | |
| 1524 | 1657 | c.log.Error().Err(err).Msg("failed to unmarshal config on reload") | |
| @@ -1545,6 +1678,14 @@ func (c *reloadCoordinator) applyLoadedConfig(now time.Time) error { | |||
| 1545 | 1678 | ||
| 1546 | 1679 | c.lastRun = now | |
| 1547 | 1680 | ||
| 1681 | + | // Reconcile public TLS certificates after config reload. | |
| 1682 | + | // Log and continue on failure — a transient ACME issue should not abort the entire reload. | |
| 1683 | + | if c.publicTLS != nil { | |
| 1684 | + | if err := c.publicTLS.Reconcile(ctx); err != nil { | |
| 1685 | + | c.log.Warn().Err(err).Msg("failed to reconcile public TLS certificates after reload, continuing") | |
| 1686 | + | } | |
| 1687 | + | } | |
| 1688 | + | ||
| 1548 | 1689 | c.log.Debug().Msg("config hot reload complete") | |
| 1549 | 1690 | return nil | |
| 1550 | 1691 | } | |
| @@ -1925,7 +2066,16 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger, accessWr | |||
| 1925 | 2066 | // HTTP proxyMux: trusted proxy traffic flows through the normal proxy chain. | |
| 1926 | 2067 | // Direct clients get an onboarding gate (when CA is available) placed BEFORE | |
| 1927 | 2068 | // HTTPSRedirect so force_https_redirect cannot bypass onboarding. | |
| 2069 | + | // ACME HTTP-01 challenge handler is registered before the catch-all "/" so | |
| 2070 | + | // it gets first chance regardless of source IP. | |
| 1928 | 2071 | proxyMux := http.NewServeMux() | |
| 2072 | + | ||
| 2073 | + | // Register ACME HTTP-01 challenge handler before all other routes so | |
| 2074 | + | // Let's Encrypt validation always succeeds, even for onboarding clients. | |
| 2075 | + | if svc.publicTLSSvc != nil { | |
| 2076 | + | proxyMux.Handle(acmehttp.Prefix, acmehttp.NewHandler(svc.publicTLSSvc)) | |
| 2077 | + | } | |
| 2078 | + | ||
| 1929 | 2079 | if proxyCIDRMiddleware != nil && proxyAllowedNets == nil { | |
| 1930 | 2080 | // Invalid proxy_allowed_ips: deny all traffic (fail-closed). | |
| 1931 | 2081 | proxyMux.Handle("/", proxyCIDRMiddleware(httpProxyWithMiddleware)) | |
| @@ -2300,7 +2450,7 @@ func runServers(ctx context.Context, v *viper.Viper, cfg Config, svc *services, | |||
| 2300 | 2450 | } | |
| 2301 | 2451 | } | |
| 2302 | 2452 | ||
| 2303 | - | proxySrv, proxyReady, tlsSrv, tlsReady, err := startProxyServers(cfg, httpProxyHandler, httpsProxyHandler, svc.pkiSvc, errChan, log) | |
| 2453 | + | proxySrv, proxyReady, tlsSrv, tlsReady, err := startProxyServers(cfg, httpProxyHandler, httpsProxyHandler, svc.pkiSvc, svc.publicTLSSvc, errChan, log) | |
| 2304 | 2454 | if err != nil { | |
| 2305 | 2455 | closeStarted(registrySrv) | |
| 2306 | 2456 | return err | |
| @@ -2344,7 +2494,7 @@ func runServers(ctx context.Context, v *viper.Viper, cfg Config, svc *services, | |||
| 2344 | 2494 | ||
| 2345 | 2495 | waitForShutdown(ctx, errChan, reloadChan, deployChan, reload, svc.eventBus, log) | |
| 2346 | 2496 | cleanupHandlers() // Stop debounce timers before draining containers | |
| 2347 | - | gracefulShutdown(registrySrv, proxySrv, tlsSrv, svc.containerSvc, svc.proxySvc, svc.pkiSvc, log) | |
| 2497 | + | gracefulShutdown(registrySrv, proxySrv, tlsSrv, svc.containerSvc, svc.proxySvc, svc.pkiSvc, svc.publicTLSSvc, log) | |
| 2348 | 2498 | return nil | |
| 2349 | 2499 | } | |
| 2350 | 2500 | ||
| @@ -2544,7 +2694,7 @@ func waitForShutdown(ctx context.Context, errChan <-chan error, reloadChan, depl | |||
| 2544 | 2694 | ||
| 2545 | 2695 | // gracefulShutdown stops HTTP servers with a 30s timeout, then shuts down | |
| 2546 | 2696 | // the container service and cleans up runtime files. | |
| 2547 | - | func gracefulShutdown(registrySrv, proxySrv, tlsSrv *http.Server, containerSvc *container.Service, proxySvc *proxy.Service, pkiSvc *pkiusecase.Service, log zerowrap.Logger) { | |
| 2697 | + | func gracefulShutdown(registrySrv, proxySrv, tlsSrv *http.Server, containerSvc *container.Service, proxySvc *proxy.Service, pkiSvc *pkiusecase.Service, publicTLS in.PublicTLSService, log zerowrap.Logger) { | |
| 2548 | 2698 | log.Info().Msg("shutting down Gordon...") | |
| 2549 | 2699 | ||
| 2550 | 2700 | shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second) | |
| @@ -2565,6 +2715,13 @@ func gracefulShutdown(registrySrv, proxySrv, tlsSrv *http.Server, containerSvc * | |||
| 2565 | 2715 | pkiSvc.Stop() | |
| 2566 | 2716 | } | |
| 2567 | 2717 | ||
| 2718 | + | // Stop public ACME TLS renewal loop | |
| 2719 | + | if publicTLS != nil { | |
| 2720 | + | if err := publicTLS.Stop(shutdownCtx); err != nil { | |
| 2721 | + | log.Warn().Err(err).Msg("public TLS stop error") | |
| 2722 | + | } | |
| 2723 | + | } | |
| 2724 | + | ||
| 2568 | 2725 | // Phase 2: Drain in-flight registry push sessions before stopping the backend | |
| 2569 | 2726 | if proxySvc != nil { | |
| 2570 | 2727 | log.Info().Msg("draining in-flight registry requests...") | |
| @@ -2592,7 +2749,90 @@ func gracefulShutdown(registrySrv, proxySrv, tlsSrv *http.Server, containerSvc * | |||
| 2592 | 2749 | ||
| 2593 | 2750 | // startProxyServers sets up the HTTP proxy server and, when tls_port != 0, | |
| 2594 | 2751 | // an HTTPS proxy server with on-demand TLS certificates from the internal CA. | |
| 2595 | - | func startProxyServers(cfg Config, httpHandler, httpsHandler http.Handler, pkiSvc *pkiusecase.Service, errChan chan<- error, log zerowrap.Logger) (*http.Server, <-chan struct{}, *http.Server, <-chan struct{}, error) { | |
| 2752 | + | // certificateSelector implements a multi-source TLS certificate lookup. | |
| 2753 | + | // Priority: static certs → public ACME TLS → local PKI (internal CA). | |
| 2754 | + | type certificateSelector struct { | |
| 2755 | + | staticCerts []staticTLSCertificate | |
| 2756 | + | publicTLS in.PublicTLSService | |
| 2757 | + | localPKI *pkiusecase.Service | |
| 2758 | + | } | |
| 2759 | + | ||
| 2760 | + | type staticTLSCertificate struct { | |
| 2761 | + | cert tls.Certificate | |
| 2762 | + | leaf *x509.Certificate | |
| 2763 | + | } | |
| 2764 | + | ||
| 2765 | + | // GetCertificate selects a TLS certificate based on the ClientHello SNI. | |
| 2766 | + | // | |
| 2767 | + | // Priority: | |
| 2768 | + | // 1. Static certs — exact SNI match (leaf VerifyHostname) | |
| 2769 | + | // 2. Public ACME TLS — if the host requires ACME coverage | |
| 2770 | + | // 3. Local PKI (internal CA) — fallback for all other hosts | |
| 2771 | + | // 4. nil, nil — if no source can serve the host | |
| 2772 | + | func (s *certificateSelector) GetCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate, error) { | |
| 2773 | + | // 1. Static certs — exact match via leaf VerifyHostname. | |
| 2774 | + | if cert := matchingPreparedStaticCert(s.staticCerts, hello.ServerName); cert != nil { | |
| 2775 | + | return cert, nil | |
| 2776 | + | } | |
| 2777 | + | ||
| 2778 | + | // 2. Public ACME TLS. | |
| 2779 | + | if s.publicTLS != nil { | |
| 2780 | + | cert, err := s.publicTLS.GetCertificateForHost(hello.ServerName) | |
| 2781 | + | if err == nil && cert != nil { | |
| 2782 | + | return cert, nil | |
| 2783 | + | } | |
| 2784 | + | // nil, nil means this host is not an ACME-required route. Errors mean | |
| 2785 | + | // public ACME cannot currently serve this host. In both cases, fall | |
| 2786 | + | // through to local PKI instead of aborting the TLS handshake. | |
| 2787 | + | } | |
| 2788 | + | ||
| 2789 | + | // 3. Local PKI (internal CA). | |
| 2790 | + | if s.localPKI != nil { | |
| 2791 | + | return s.localPKI.GetCertificate(hello) | |
| 2792 | + | } | |
| 2793 | + | ||
| 2794 | + | return nil, nil | |
| 2795 | + | } | |
| 2796 | + | ||
| 2797 | + | func prepareStaticTLSCertificates(certs []tls.Certificate) []staticTLSCertificate { | |
| 2798 | + | prepared := make([]staticTLSCertificate, 0, len(certs)) | |
| 2799 | + | for _, cert := range certs { | |
| 2800 | + | if cert.Leaf == nil && len(cert.Certificate) > 0 { | |
| 2801 | + | leaf, err := x509.ParseCertificate(cert.Certificate[0]) | |
| 2802 | + | if err == nil { | |
| 2803 | + | cert.Leaf = leaf | |
| 2804 | + | } | |
| 2805 | + | } | |
| 2806 | + | prepared = append(prepared, staticTLSCertificate{cert: cert, leaf: cert.Leaf}) | |
| 2807 | + | } | |
| 2808 | + | return prepared | |
| 2809 | + | } | |
| 2810 | + | ||
| 2811 | + | func matchingPreparedStaticCert(certs []staticTLSCertificate, serverName string) *tls.Certificate { | |
| 2812 | + | if serverName == "" { | |
| 2813 | + | if len(certs) == 0 { | |
| 2814 | + | return nil | |
| 2815 | + | } | |
| 2816 | + | return &certs[0].cert | |
| 2817 | + | } | |
| 2818 | + | for i := range certs { | |
| 2819 | + | if certs[i].leaf == nil { | |
| 2820 | + | continue | |
| 2821 | + | } | |
| 2822 | + | if err := certs[i].leaf.VerifyHostname(serverName); err == nil { | |
| 2823 | + | return &certs[i].cert | |
| 2824 | + | } | |
| 2825 | + | } | |
| 2826 | + | return nil | |
| 2827 | + | } | |
| 2828 | + | ||
| 2829 | + | // matchingStaticCert returns a pointer to the first static certificate whose | |
| 2830 | + | // leaf verifies the given serverName. Returns nil if no match is found. | |
| 2831 | + | func matchingStaticCert(certs []tls.Certificate, serverName string) *tls.Certificate { | |
| 2832 | + | return matchingPreparedStaticCert(prepareStaticTLSCertificates(certs), serverName) | |
| 2833 | + | } | |
| 2834 | + | ||
| 2835 | + | func startProxyServers(cfg Config, httpHandler, httpsHandler http.Handler, pkiSvc *pkiusecase.Service, publicTLS in.PublicTLSService, errChan chan<- error, log zerowrap.Logger) (*http.Server, <-chan struct{}, *http.Server, <-chan struct{}, error) { | |
| 2596 | 2836 | // HTTP listener (Cloudflare proxy + onboarding for direct clients) | |
| 2597 | 2837 | var httpProtos http.Protocols | |
| 2598 | 2838 | httpProtos.SetHTTP1(true) | |
| @@ -2610,23 +2850,33 @@ func startProxyServers(cfg Config, httpHandler, httpsHandler http.Handler, pkiSv | |||
| 2610 | 2850 | return httpSrv, httpReady, nil, nil, nil | |
| 2611 | 2851 | } | |
| 2612 | 2852 | ||
| 2613 | - | // HTTPS listener — static cert (if provided) takes priority by SNI match, | |
| 2614 | - | // unmatched domains fall through to on-demand internal CA certs. | |
| 2615 | - | tlsConfig := &tls.Config{ | |
| 2616 | - | MinVersion: tls.VersionTLS12, | |
| 2617 | - | GetCertificate: pkiSvc.GetCertificate, | |
| 2618 | - | NextProtos: []string{"h2", "http/1.1"}, | |
| 2619 | - | } | |
| 2853 | + | // Load static cert into a local slice (not tls.Config.Certificates) so the | |
| 2854 | + | // certificate selector has full control over priority ordering. | |
| 2855 | + | var staticCerts []tls.Certificate | |
| 2620 | 2856 | if cfg.Server.TLSCertFile != "" { | |
| 2621 | 2857 | staticCert, err := tls.LoadX509KeyPair(cfg.Server.TLSCertFile, cfg.Server.TLSKeyFile) | |
| 2622 | 2858 | if err != nil { | |
| 2623 | 2859 | return nil, nil, nil, nil, fmt.Errorf("load TLS keypair: %w", err) | |
| 2624 | 2860 | } | |
| 2625 | - | tlsConfig.Certificates = []tls.Certificate{staticCert} | |
| 2861 | + | staticCerts = []tls.Certificate{staticCert} | |
| 2626 | 2862 | log.Info(). | |
| 2627 | 2863 | Str("cert", cfg.Server.TLSCertFile). | |
| 2628 | 2864 | Str("key", cfg.Server.TLSKeyFile). | |
| 2629 | - | Msg("loaded static TLS certificate (internal CA handles remaining domains)") | |
| 2865 | + | Msg("loaded static TLS certificate (public ACME and internal CA handle remaining domains)") | |
| 2866 | + | } | |
| 2867 | + | ||
| 2868 | + | selector := &certificateSelector{ | |
| 2869 | + | staticCerts: prepareStaticTLSCertificates(staticCerts), | |
| 2870 | + | publicTLS: publicTLS, | |
| 2871 | + | localPKI: pkiSvc, | |
| 2872 | + | } | |
| 2873 | + | ||
| 2874 | + | // HTTPS listener — static cert (if provided) takes priority by SNI match via | |
| 2875 | + | // certificateSelector, then public ACME TLS, then on-demand internal CA certs. | |
| 2876 | + | tlsConfig := &tls.Config{ | |
| 2877 | + | MinVersion: tls.VersionTLS12, | |
| 2878 | + | GetCertificate: selector.GetCertificate, | |
| 2879 | + | NextProtos: []string{"h2", "http/1.1"}, | |
| 2630 | 2880 | } | |
| 2631 | 2881 | tlsSrv, tlsReady := startTLSServerWithConfig( | |
| 2632 | 2882 | fmt.Sprintf(":%d", cfg.Server.TLSPort), | |
| @@ -2831,7 +3081,7 @@ func createPidFile(log zerowrap.Logger) string { | |||
| 2831 | 3081 | if err := os.MkdirAll(filepath.Dir(location), 0700); err != nil { | |
| 2832 | 3082 | continue | |
| 2833 | 3083 | } | |
| 2834 | - | if err := os.WriteFile(location, []byte(fmt.Sprintf("%d", pid)), 0600); err == nil { | |
| 3084 | + | if err := os.WriteFile(location, fmt.Appendf(nil, "%d", pid), 0600); err == nil { | |
| 2835 | 3085 | log.Debug().Str("pid_file", location).Int("pid", pid).Msg("created PID file") | |
| 2836 | 3086 | return location | |
| 2837 | 3087 | } | |
| @@ -2966,6 +3216,9 @@ func loadConfig(v *viper.Viper, configPath string) error { | |||
| 2966 | 3216 | v.SetDefault("server.tls_port", 8443) | |
| 2967 | 3217 | v.SetDefault("server.tls_cert_file", "") | |
| 2968 | 3218 | v.SetDefault("server.tls_key_file", "") | |
| 3219 | + | v.SetDefault("tls.acme.enabled", false) | |
| 3220 | + | v.SetDefault("tls.acme.email", "") | |
| 3221 | + | v.SetDefault("tls.acme.challenge", "auto") | |
| 2969 | 3222 | v.SetDefault("server.force_https_redirect", false) | |
| 2970 | 3223 | v.SetDefault("server.data_dir", DefaultDataDir()) | |
| 2971 | 3224 | v.SetDefault("server.runtime", "auto") | |
M internal/app/run_reload_test.go +5 -5
| @@ -185,7 +185,7 @@ func TestReloadCoordinator_ApplyLoadedConfig_RebuildsProxyConfigAndPublishesEven | |||
| 185 | 185 | events := &eventBusRecorder{} | |
| 186 | 186 | ||
| 187 | 187 | registryLimits := ®istryLimitsRecorder{} | |
| 188 | - | coord := newReloadCoordinator(v, reloadSvc, proxySvc, registryLimits, events, zerowrap.Default()) | |
| 188 | + | coord := newReloadCoordinator(v, reloadSvc, proxySvc, registryLimits, events, nil, zerowrap.Default()) | |
| 189 | 189 | require.NoError(t, coord.ApplyLoadedConfig(ctx)) | |
| 190 | 190 | ||
| 191 | 191 | require.Equal(t, 0, reloadSvc.Calls()) | |
| @@ -220,7 +220,7 @@ func TestReloadCoordinator_DebouncesRepeatedWatchCallbacks(t *testing.T) { | |||
| 220 | 220 | events := &eventBusRecorder{} | |
| 221 | 221 | ||
| 222 | 222 | registryLimits := ®istryLimitsRecorder{} | |
| 223 | - | coord := newReloadCoordinator(v, reloadSvc, proxySvc, registryLimits, events, zerowrap.Default()) | |
| 223 | + | coord := newReloadCoordinator(v, reloadSvc, proxySvc, registryLimits, events, nil, zerowrap.Default()) | |
| 224 | 224 | require.NoError(t, coord.Trigger(ctx)) | |
| 225 | 225 | require.NoError(t, coord.Trigger(ctx)) | |
| 226 | 226 | ||
| @@ -253,7 +253,7 @@ func TestReloadCoordinator_RetriesImmediatelyAfterFailedReload(t *testing.T) { | |||
| 253 | 253 | reloadErr := errors.New("reload failed") | |
| 254 | 254 | reloadSvc := &reloadRecorder{err: reloadErr} | |
| 255 | 255 | proxySvc := &proxyRecorder{} | |
| 256 | - | coord := newReloadCoordinator(v, reloadSvc, proxySvc, nil, nil, zerowrap.Default()) | |
| 256 | + | coord := newReloadCoordinator(v, reloadSvc, proxySvc, nil, nil, nil, zerowrap.Default()) | |
| 257 | 257 | ||
| 258 | 258 | err := coord.Trigger(ctx) | |
| 259 | 259 | require.ErrorIs(t, err, reloadErr) | |
| @@ -282,7 +282,7 @@ func TestReloadCoordinator_PublishErrorDoesNotAdvanceDebounceState(t *testing.T) | |||
| 282 | 282 | proxySvc := &proxyRecorder{} | |
| 283 | 283 | events := &eventBusRecorder{err: publishErr} | |
| 284 | 284 | ||
| 285 | - | coord := newReloadCoordinator(v, reloadSvc, proxySvc, nil, events, zerowrap.Default()) | |
| 285 | + | coord := newReloadCoordinator(v, reloadSvc, proxySvc, nil, events, nil, zerowrap.Default()) | |
| 286 | 286 | ||
| 287 | 287 | err := coord.Trigger(ctx) | |
| 288 | 288 | require.ErrorIs(t, err, publishErr) | |
| @@ -314,7 +314,7 @@ func TestReloadCoordinator_SerializesOverlappingReloadRequests(t *testing.T) { | |||
| 314 | 314 | <-release | |
| 315 | 315 | }} | |
| 316 | 316 | proxySvc := &proxyRecorder{} | |
| 317 | - | coord := newReloadCoordinator(v, reloadSvc, proxySvc, nil, nil, zerowrap.Default()) | |
| 317 | + | coord := newReloadCoordinator(v, reloadSvc, proxySvc, nil, nil, nil, zerowrap.Default()) | |
| 318 | 318 | ||
| 319 | 319 | firstDone := make(chan struct{}) | |
| 320 | 320 | go func() { | |
A internal/boundaries/in/mocks/mock_public_tls_service.go +321 -0
| @@ -0,0 +1,321 @@ | |||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |
| 2 | + | // github.com/vektra/mockery | |
| 3 | + | // template: testify | |
| 4 | + | ||
| 5 | + | package mocks | |
| 6 | + | ||
| 7 | + | import ( | |
| 8 | + | "context" | |
| 9 | + | "crypto/tls" | |
| 10 | + | ||
| 11 | + | "github.com/bnema/gordon/internal/domain" | |
| 12 | + | mock "github.com/stretchr/testify/mock" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | // NewMockPublicTLSService creates a new instance of MockPublicTLSService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 16 | + | // The first argument is typically a *testing.T value. | |
| 17 | + | func NewMockPublicTLSService(t interface { | |
| 18 | + | mock.TestingT | |
| 19 | + | Cleanup(func()) | |
| 20 | + | }) *MockPublicTLSService { | |
| 21 | + | mock := &MockPublicTLSService{} | |
| 22 | + | mock.Mock.Test(t) | |
| 23 | + | ||
| 24 | + | t.Cleanup(func() { mock.AssertExpectations(t) }) | |
| 25 | + | ||
| 26 | + | return mock | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | // MockPublicTLSService is an autogenerated mock type for the PublicTLSService type | |
| 30 | + | type MockPublicTLSService struct { | |
| 31 | + | mock.Mock | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | type MockPublicTLSService_Expecter struct { | |
| 35 | + | mock *mock.Mock | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | func (_m *MockPublicTLSService) EXPECT() *MockPublicTLSService_Expecter { | |
| 39 | + | return &MockPublicTLSService_Expecter{mock: &_m.Mock} | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | // GetCertificateForHost provides a mock function for the type MockPublicTLSService | |
| 43 | + | func (_mock *MockPublicTLSService) GetCertificateForHost(host string) (*tls.Certificate, error) { | |
| 44 | + | ret := _mock.Called(host) | |
| 45 | + | ||
| 46 | + | if len(ret) == 0 { | |
| 47 | + | panic("no return value specified for GetCertificateForHost") | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | var r0 *tls.Certificate | |
| 51 | + | var r1 error | |
| 52 | + | if returnFunc, ok := ret.Get(0).(func(string) (*tls.Certificate, error)); ok { | |
| 53 | + | return returnFunc(host) | |
| 54 | + | } | |
| 55 | + | if returnFunc, ok := ret.Get(0).(func(string) *tls.Certificate); ok { | |
| 56 | + | r0 = returnFunc(host) | |
| 57 | + | } else { | |
| 58 | + | if ret.Get(0) != nil { | |
| 59 | + | r0 = ret.Get(0).(*tls.Certificate) | |
| 60 | + | } | |
| 61 | + | } | |
| 62 | + | if returnFunc, ok := ret.Get(1).(func(string) error); ok { | |
| 63 | + | r1 = returnFunc(host) | |
| 64 | + | } else { | |
| 65 | + | r1 = ret.Error(1) | |
| 66 | + | } | |
| 67 | + | return r0, r1 | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | // MockPublicTLSService_GetCertificateForHost_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetCertificateForHost' | |
| 71 | + | type MockPublicTLSService_GetCertificateForHost_Call struct { | |
| 72 | + | *mock.Call | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | // GetCertificateForHost is a helper method to define mock.On call | |
| 76 | + | // - host string | |
| 77 | + | func (_e *MockPublicTLSService_Expecter) GetCertificateForHost(host any) *MockPublicTLSService_GetCertificateForHost_Call { | |
| 78 | + | return &MockPublicTLSService_GetCertificateForHost_Call{Call: _e.mock.On("GetCertificateForHost", host)} | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | func (_c *MockPublicTLSService_GetCertificateForHost_Call) Run(run func(host string)) *MockPublicTLSService_GetCertificateForHost_Call { | |
| 82 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 83 | + | var arg0 string | |
| 84 | + | if args[0] != nil { | |
| 85 | + | arg0 = args[0].(string) | |
| 86 | + | } | |
| 87 | + | run( | |
| 88 | + | arg0, | |
| 89 | + | ) | |
| 90 | + | }) | |
| 91 | + | return _c | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | func (_c *MockPublicTLSService_GetCertificateForHost_Call) Return(certificate *tls.Certificate, err error) *MockPublicTLSService_GetCertificateForHost_Call { | |
| 95 | + | _c.Call.Return(certificate, err) | |
| 96 | + | return _c | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | func (_c *MockPublicTLSService_GetCertificateForHost_Call) RunAndReturn(run func(host string) (*tls.Certificate, error)) *MockPublicTLSService_GetCertificateForHost_Call { | |
| 100 | + | _c.Call.Return(run) | |
| 101 | + | return _c | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | // GetHTTP01Challenge provides a mock function for the type MockPublicTLSService | |
| 105 | + | func (_mock *MockPublicTLSService) GetHTTP01Challenge(ctx context.Context, token string) (string, bool) { | |
| 106 | + | ret := _mock.Called(ctx, token) | |
| 107 | + | ||
| 108 | + | if len(ret) == 0 { | |
| 109 | + | panic("no return value specified for GetHTTP01Challenge") | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | var r0 string | |
| 113 | + | var r1 bool | |
| 114 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) (string, bool)); ok { | |
| 115 | + | return returnFunc(ctx, token) | |
| 116 | + | } | |
| 117 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) string); ok { | |
| 118 | + | r0 = returnFunc(ctx, token) | |
| 119 | + | } else { | |
| 120 | + | r0 = ret.Get(0).(string) | |
| 121 | + | } | |
| 122 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, string) bool); ok { | |
| 123 | + | r1 = returnFunc(ctx, token) | |
| 124 | + | } else { | |
| 125 | + | r1 = ret.Get(1).(bool) | |
| 126 | + | } | |
| 127 | + | return r0, r1 | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | // MockPublicTLSService_GetHTTP01Challenge_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetHTTP01Challenge' | |
| 131 | + | type MockPublicTLSService_GetHTTP01Challenge_Call struct { | |
| 132 | + | *mock.Call | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | // GetHTTP01Challenge is a helper method to define mock.On call | |
| 136 | + | // - ctx context.Context | |
| 137 | + | // - token string | |
| 138 | + | func (_e *MockPublicTLSService_Expecter) GetHTTP01Challenge(ctx any, token any) *MockPublicTLSService_GetHTTP01Challenge_Call { | |
| 139 | + | return &MockPublicTLSService_GetHTTP01Challenge_Call{Call: _e.mock.On("GetHTTP01Challenge", ctx, token)} | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | func (_c *MockPublicTLSService_GetHTTP01Challenge_Call) Run(run func(ctx context.Context, token string)) *MockPublicTLSService_GetHTTP01Challenge_Call { | |
| 143 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 144 | + | var arg0 context.Context | |
| 145 | + | if args[0] != nil { | |
| 146 | + | arg0 = args[0].(context.Context) | |
| 147 | + | } | |
| 148 | + | var arg1 string | |
| 149 | + | if args[1] != nil { | |
| 150 | + | arg1 = args[1].(string) | |
| 151 | + | } | |
| 152 | + | run( | |
| 153 | + | arg0, | |
| 154 | + | arg1, | |
| 155 | + | ) | |
| 156 | + | }) | |
| 157 | + | return _c | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | func (_c *MockPublicTLSService_GetHTTP01Challenge_Call) Return(keyAuth string, ok bool) *MockPublicTLSService_GetHTTP01Challenge_Call { | |
| 161 | + | _c.Call.Return(keyAuth, ok) | |
| 162 | + | return _c | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | func (_c *MockPublicTLSService_GetHTTP01Challenge_Call) RunAndReturn(run func(ctx context.Context, token string) (string, bool)) *MockPublicTLSService_GetHTTP01Challenge_Call { | |
| 166 | + | _c.Call.Return(run) | |
| 167 | + | return _c | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | // Reconcile provides a mock function for the type MockPublicTLSService | |
| 171 | + | func (_mock *MockPublicTLSService) Reconcile(ctx context.Context) error { | |
| 172 | + | ret := _mock.Called(ctx) | |
| 173 | + | ||
| 174 | + | if len(ret) == 0 { | |
| 175 | + | panic("no return value specified for Reconcile") | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | var r0 error | |
| 179 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) error); ok { | |
| 180 | + | r0 = returnFunc(ctx) | |
| 181 | + | } else { | |
| 182 | + | r0 = ret.Error(0) | |
| 183 | + | } | |
| 184 | + | return r0 | |
| 185 | + | } | |
| 186 | + | ||
| 187 | + | // MockPublicTLSService_Reconcile_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Reconcile' | |
| 188 | + | type MockPublicTLSService_Reconcile_Call struct { | |
| 189 | + | *mock.Call | |
| 190 | + | } | |
| 191 | + | ||
| 192 | + | // Reconcile is a helper method to define mock.On call | |
| 193 | + | // - ctx context.Context | |
| 194 | + | func (_e *MockPublicTLSService_Expecter) Reconcile(ctx any) *MockPublicTLSService_Reconcile_Call { | |
| 195 | + | return &MockPublicTLSService_Reconcile_Call{Call: _e.mock.On("Reconcile", ctx)} | |
| 196 | + | } | |
| 197 | + | ||
| 198 | + | func (_c *MockPublicTLSService_Reconcile_Call) Run(run func(ctx context.Context)) *MockPublicTLSService_Reconcile_Call { | |
| 199 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 200 | + | var arg0 context.Context | |
| 201 | + | if args[0] != nil { | |
| 202 | + | arg0 = args[0].(context.Context) | |
| 203 | + | } | |
| 204 | + | run( | |
| 205 | + | arg0, | |
| 206 | + | ) | |
| 207 | + | }) | |
| 208 | + | return _c | |
| 209 | + | } | |
| 210 | + | ||
| 211 | + | func (_c *MockPublicTLSService_Reconcile_Call) Return(err error) *MockPublicTLSService_Reconcile_Call { | |
| 212 | + | _c.Call.Return(err) | |
| 213 | + | return _c | |
| 214 | + | } | |
| 215 | + | ||
| 216 | + | func (_c *MockPublicTLSService_Reconcile_Call) RunAndReturn(run func(ctx context.Context) error) *MockPublicTLSService_Reconcile_Call { | |
| 217 | + | _c.Call.Return(run) | |
| 218 | + | return _c | |
| 219 | + | } | |
| 220 | + | ||
| 221 | + | // Status provides a mock function for the type MockPublicTLSService | |
| 222 | + | func (_mock *MockPublicTLSService) Status(ctx context.Context) domain.PublicTLSStatus { | |
| 223 | + | ret := _mock.Called(ctx) | |
| 224 | + | ||
| 225 | + | if len(ret) == 0 { | |
| 226 | + | panic("no return value specified for Status") | |
| 227 | + | } | |
| 228 | + | ||
| 229 | + | var r0 domain.PublicTLSStatus | |
| 230 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) domain.PublicTLSStatus); ok { | |
| 231 | + | r0 = returnFunc(ctx) | |
| 232 | + | } else { | |
| 233 | + | r0 = ret.Get(0).(domain.PublicTLSStatus) | |
| 234 | + | } | |
| 235 | + | return r0 | |
| 236 | + | } | |
| 237 | + | ||
| 238 | + | // MockPublicTLSService_Status_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Status' | |
| 239 | + | type MockPublicTLSService_Status_Call struct { | |
| 240 | + | *mock.Call | |
| 241 | + | } | |
| 242 | + | ||
| 243 | + | // Status is a helper method to define mock.On call | |
| 244 | + | // - ctx context.Context | |
| 245 | + | func (_e *MockPublicTLSService_Expecter) Status(ctx any) *MockPublicTLSService_Status_Call { | |
| 246 | + | return &MockPublicTLSService_Status_Call{Call: _e.mock.On("Status", ctx)} | |
| 247 | + | } | |
| 248 | + | ||
| 249 | + | func (_c *MockPublicTLSService_Status_Call) Run(run func(ctx context.Context)) *MockPublicTLSService_Status_Call { | |
| 250 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 251 | + | var arg0 context.Context | |
| 252 | + | if args[0] != nil { | |
| 253 | + | arg0 = args[0].(context.Context) | |
| 254 | + | } | |
| 255 | + | run( | |
| 256 | + | arg0, | |
| 257 | + | ) | |
| 258 | + | }) | |
| 259 | + | return _c | |
| 260 | + | } | |
| 261 | + | ||
| 262 | + | func (_c *MockPublicTLSService_Status_Call) Return(publicTLSStatus domain.PublicTLSStatus) *MockPublicTLSService_Status_Call { | |
| 263 | + | _c.Call.Return(publicTLSStatus) | |
| 264 | + | return _c | |
| 265 | + | } | |
| 266 | + | ||
| 267 | + | func (_c *MockPublicTLSService_Status_Call) RunAndReturn(run func(ctx context.Context) domain.PublicTLSStatus) *MockPublicTLSService_Status_Call { | |
| 268 | + | _c.Call.Return(run) | |
| 269 | + | return _c | |
| 270 | + | } | |
| 271 | + | ||
| 272 | + | // Stop provides a mock function for the type MockPublicTLSService | |
| 273 | + | func (_mock *MockPublicTLSService) Stop(ctx context.Context) error { | |
| 274 | + | ret := _mock.Called(ctx) | |
| 275 | + | ||
| 276 | + | if len(ret) == 0 { | |
| 277 | + | panic("no return value specified for Stop") | |
| 278 | + | } | |
| 279 | + | ||
| 280 | + | var r0 error | |
| 281 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) error); ok { | |
| 282 | + | r0 = returnFunc(ctx) | |
| 283 | + | } else { | |
| 284 | + | r0 = ret.Error(0) | |
| 285 | + | } | |
| 286 | + | return r0 | |
| 287 | + | } | |
| 288 | + | ||
| 289 | + | // MockPublicTLSService_Stop_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Stop' | |
| 290 | + | type MockPublicTLSService_Stop_Call struct { | |
| 291 | + | *mock.Call | |
| 292 | + | } | |
| 293 | + | ||
| 294 | + | // Stop is a helper method to define mock.On call | |
| 295 | + | // - ctx context.Context | |
| 296 | + | func (_e *MockPublicTLSService_Expecter) Stop(ctx any) *MockPublicTLSService_Stop_Call { | |
| 297 | + | return &MockPublicTLSService_Stop_Call{Call: _e.mock.On("Stop", ctx)} | |
| 298 | + | } | |
| 299 | + | ||
| 300 | + | func (_c *MockPublicTLSService_Stop_Call) Run(run func(ctx context.Context)) *MockPublicTLSService_Stop_Call { | |
| 301 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 302 | + | var arg0 context.Context | |
| 303 | + | if args[0] != nil { | |
| 304 | + | arg0 = args[0].(context.Context) | |
| 305 | + | } | |
| 306 | + | run( | |
| 307 | + | arg0, | |
| 308 | + | ) | |
| 309 | + | }) | |
| 310 | + | return _c | |
| 311 | + | } | |
| 312 | + | ||
| 313 | + | func (_c *MockPublicTLSService_Stop_Call) Return(err error) *MockPublicTLSService_Stop_Call { | |
| 314 | + | _c.Call.Return(err) | |
| 315 | + | return _c | |
| 316 | + | } | |
| 317 | + | ||
| 318 | + | func (_c *MockPublicTLSService_Stop_Call) RunAndReturn(run func(ctx context.Context) error) *MockPublicTLSService_Stop_Call { | |
| 319 | + | _c.Call.Return(run) | |
| 320 | + | return _c | |
| 321 | + | } | |
A internal/boundaries/in/public_tls.go +29 -0
| @@ -0,0 +1,29 @@ | |||
| 1 | + | // Package in defines input ports (interfaces) for use cases. | |
| 2 | + | // These interfaces define the contract between driving adapters (HTTP, CLI) | |
| 3 | + | // and the business logic (use cases). | |
| 4 | + | package in | |
| 5 | + | ||
| 6 | + | import ( | |
| 7 | + | "context" | |
| 8 | + | "crypto/tls" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/gordon/internal/domain" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | // PublicTLSService defines the contract for public TLS / ACME certificate management. | |
| 14 | + | type PublicTLSService interface { | |
| 15 | + | // GetCertificateForHost returns a TLS certificate for the given SNI host. | |
| 16 | + | GetCertificateForHost(host string) (*tls.Certificate, error) | |
| 17 | + | ||
| 18 | + | // GetHTTP01Challenge returns the key authorization for an HTTP-01 challenge token. | |
| 19 | + | GetHTTP01Challenge(ctx context.Context, token string) (keyAuth string, ok bool) | |
| 20 | + | ||
| 21 | + | // Status returns the current public TLS status. | |
| 22 | + | Status(ctx context.Context) domain.PublicTLSStatus | |
| 23 | + | ||
| 24 | + | // Reconcile ensures the desired certificate state matches the actual state. | |
| 25 | + | Reconcile(ctx context.Context) error | |
| 26 | + | ||
| 27 | + | // Stop gracefully shuts down the public TLS service. | |
| 28 | + | Stop(ctx context.Context) error | |
| 29 | + | } | |
A internal/boundaries/out/mocks/mock_certificate_store.go +339 -0
| @@ -0,0 +1,339 @@ | |||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |
| 2 | + | // github.com/vektra/mockery | |
| 3 | + | // template: testify | |
| 4 | + | ||
| 5 | + | package mocks | |
| 6 | + | ||
| 7 | + | import ( | |
| 8 | + | "context" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/gordon/internal/boundaries/out" | |
| 11 | + | mock "github.com/stretchr/testify/mock" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // NewMockCertificateStore creates a new instance of MockCertificateStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 15 | + | // The first argument is typically a *testing.T value. | |
| 16 | + | func NewMockCertificateStore(t interface { | |
| 17 | + | mock.TestingT | |
| 18 | + | Cleanup(func()) | |
| 19 | + | }) *MockCertificateStore { | |
| 20 | + | mock := &MockCertificateStore{} | |
| 21 | + | mock.Mock.Test(t) | |
| 22 | + | ||
| 23 | + | t.Cleanup(func() { mock.AssertExpectations(t) }) | |
| 24 | + | ||
| 25 | + | return mock | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | // MockCertificateStore is an autogenerated mock type for the CertificateStore type | |
| 29 | + | type MockCertificateStore struct { | |
| 30 | + | mock.Mock | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | type MockCertificateStore_Expecter struct { | |
| 34 | + | mock *mock.Mock | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | func (_m *MockCertificateStore) EXPECT() *MockCertificateStore_Expecter { | |
| 38 | + | return &MockCertificateStore_Expecter{mock: &_m.Mock} | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | // LoadAccount provides a mock function for the type MockCertificateStore | |
| 42 | + | func (_mock *MockCertificateStore) LoadAccount(ctx context.Context) (*out.ACMEAccount, error) { | |
| 43 | + | ret := _mock.Called(ctx) | |
| 44 | + | ||
| 45 | + | if len(ret) == 0 { | |
| 46 | + | panic("no return value specified for LoadAccount") | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | var r0 *out.ACMEAccount | |
| 50 | + | var r1 error | |
| 51 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) (*out.ACMEAccount, error)); ok { | |
| 52 | + | return returnFunc(ctx) | |
| 53 | + | } | |
| 54 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) *out.ACMEAccount); ok { | |
| 55 | + | r0 = returnFunc(ctx) | |
| 56 | + | } else { | |
| 57 | + | if ret.Get(0) != nil { | |
| 58 | + | r0 = ret.Get(0).(*out.ACMEAccount) | |
| 59 | + | } | |
| 60 | + | } | |
| 61 | + | if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok { | |
| 62 | + | r1 = returnFunc(ctx) | |
| 63 | + | } else { | |
| 64 | + | r1 = ret.Error(1) | |
| 65 | + | } | |
| 66 | + | return r0, r1 | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | // MockCertificateStore_LoadAccount_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'LoadAccount' | |
| 70 | + | type MockCertificateStore_LoadAccount_Call struct { | |
| 71 | + | *mock.Call | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | // LoadAccount is a helper method to define mock.On call | |
| 75 | + | // - ctx context.Context | |
| 76 | + | func (_e *MockCertificateStore_Expecter) LoadAccount(ctx any) *MockCertificateStore_LoadAccount_Call { | |
| 77 | + | return &MockCertificateStore_LoadAccount_Call{Call: _e.mock.On("LoadAccount", ctx)} | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | func (_c *MockCertificateStore_LoadAccount_Call) Run(run func(ctx context.Context)) *MockCertificateStore_LoadAccount_Call { | |
| 81 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 82 | + | var arg0 context.Context | |
| 83 | + | if args[0] != nil { | |
| 84 | + | arg0 = args[0].(context.Context) | |
| 85 | + | } | |
| 86 | + | run( | |
| 87 | + | arg0, | |
| 88 | + | ) | |
| 89 | + | }) | |
| 90 | + | return _c | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | func (_c *MockCertificateStore_LoadAccount_Call) Return(aCMEAccount *out.ACMEAccount, err error) *MockCertificateStore_LoadAccount_Call { | |
| 94 | + | _c.Call.Return(aCMEAccount, err) | |
| 95 | + | return _c | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | func (_c *MockCertificateStore_LoadAccount_Call) RunAndReturn(run func(ctx context.Context) (*out.ACMEAccount, error)) *MockCertificateStore_LoadAccount_Call { | |
| 99 | + | _c.Call.Return(run) | |
| 100 | + | return _c | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | // LoadAll provides a mock function for the type MockCertificateStore | |
| 104 | + | func (_mock *MockCertificateStore) LoadAll(ctx context.Context) ([]out.StoredCertificate, error) { | |
| 105 | + | ret := _mock.Called(ctx) | |
| 106 | + | ||
| 107 | + | if len(ret) == 0 { | |
| 108 | + | panic("no return value specified for LoadAll") | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | var r0 []out.StoredCertificate | |
| 112 | + | var r1 error | |
| 113 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) ([]out.StoredCertificate, error)); ok { | |
| 114 | + | return returnFunc(ctx) | |
| 115 | + | } | |
| 116 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) []out.StoredCertificate); ok { | |
| 117 | + | r0 = returnFunc(ctx) | |
| 118 | + | } else { | |
| 119 | + | if ret.Get(0) != nil { | |
| 120 | + | r0 = ret.Get(0).([]out.StoredCertificate) | |
| 121 | + | } | |
| 122 | + | } | |
| 123 | + | if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok { | |
| 124 | + | r1 = returnFunc(ctx) | |
| 125 | + | } else { | |
| 126 | + | r1 = ret.Error(1) | |
| 127 | + | } | |
| 128 | + | return r0, r1 | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | // MockCertificateStore_LoadAll_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'LoadAll' | |
| 132 | + | type MockCertificateStore_LoadAll_Call struct { | |
| 133 | + | *mock.Call | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | // LoadAll is a helper method to define mock.On call | |
| 137 | + | // - ctx context.Context | |
| 138 | + | func (_e *MockCertificateStore_Expecter) LoadAll(ctx any) *MockCertificateStore_LoadAll_Call { | |
| 139 | + | return &MockCertificateStore_LoadAll_Call{Call: _e.mock.On("LoadAll", ctx)} | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | func (_c *MockCertificateStore_LoadAll_Call) Run(run func(ctx context.Context)) *MockCertificateStore_LoadAll_Call { | |
| 143 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 144 | + | var arg0 context.Context | |
| 145 | + | if args[0] != nil { | |
| 146 | + | arg0 = args[0].(context.Context) | |
| 147 | + | } | |
| 148 | + | run( | |
| 149 | + | arg0, | |
| 150 | + | ) | |
| 151 | + | }) | |
| 152 | + | return _c | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | func (_c *MockCertificateStore_LoadAll_Call) Return(storedCertificates []out.StoredCertificate, err error) *MockCertificateStore_LoadAll_Call { | |
| 156 | + | _c.Call.Return(storedCertificates, err) | |
| 157 | + | return _c | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | func (_c *MockCertificateStore_LoadAll_Call) RunAndReturn(run func(ctx context.Context) ([]out.StoredCertificate, error)) *MockCertificateStore_LoadAll_Call { | |
| 161 | + | _c.Call.Return(run) | |
| 162 | + | return _c | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | // Lock provides a mock function for the type MockCertificateStore | |
| 166 | + | func (_mock *MockCertificateStore) Lock(ctx context.Context) (func() error, error) { | |
| 167 | + | ret := _mock.Called(ctx) | |
| 168 | + | ||
| 169 | + | if len(ret) == 0 { | |
| 170 | + | panic("no return value specified for Lock") | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | var r0 func() error | |
| 174 | + | var r1 error | |
| 175 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) (func() error, error)); ok { | |
| 176 | + | return returnFunc(ctx) | |
| 177 | + | } | |
| 178 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) func() error); ok { | |
| 179 | + | r0 = returnFunc(ctx) | |
| 180 | + | } else { | |
| 181 | + | if ret.Get(0) != nil { | |
| 182 | + | r0 = ret.Get(0).(func() error) | |
| 183 | + | } | |
| 184 | + | } | |
| 185 | + | if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok { | |
| 186 | + | r1 = returnFunc(ctx) | |
| 187 | + | } else { | |
| 188 | + | r1 = ret.Error(1) | |
| 189 | + | } | |
| 190 | + | return r0, r1 | |
| 191 | + | } | |
| 192 | + | ||
| 193 | + | // MockCertificateStore_Lock_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Lock' | |
| 194 | + | type MockCertificateStore_Lock_Call struct { | |
| 195 | + | *mock.Call | |
| 196 | + | } | |
| 197 | + | ||
| 198 | + | // Lock is a helper method to define mock.On call | |
| 199 | + | // - ctx context.Context | |
| 200 | + | func (_e *MockCertificateStore_Expecter) Lock(ctx any) *MockCertificateStore_Lock_Call { | |
| 201 | + | return &MockCertificateStore_Lock_Call{Call: _e.mock.On("Lock", ctx)} | |
| 202 | + | } | |
| 203 | + | ||
| 204 | + | func (_c *MockCertificateStore_Lock_Call) Run(run func(ctx context.Context)) *MockCertificateStore_Lock_Call { | |
| 205 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 206 | + | var arg0 context.Context | |
| 207 | + | if args[0] != nil { | |
| 208 | + | arg0 = args[0].(context.Context) | |
| 209 | + | } | |
| 210 | + | run( | |
| 211 | + | arg0, | |
| 212 | + | ) | |
| 213 | + | }) | |
| 214 | + | return _c | |
| 215 | + | } | |
| 216 | + | ||
| 217 | + | func (_c *MockCertificateStore_Lock_Call) Return(unlock func() error, err error) *MockCertificateStore_Lock_Call { | |
| 218 | + | _c.Call.Return(unlock, err) | |
| 219 | + | return _c | |
| 220 | + | } | |
| 221 | + | ||
| 222 | + | func (_c *MockCertificateStore_Lock_Call) RunAndReturn(run func(ctx context.Context) (func() error, error)) *MockCertificateStore_Lock_Call { | |
| 223 | + | _c.Call.Return(run) | |
| 224 | + | return _c | |
| 225 | + | } | |
| 226 | + | ||
| 227 | + | // Save provides a mock function for the type MockCertificateStore | |
| 228 | + | func (_mock *MockCertificateStore) Save(ctx context.Context, cert out.StoredCertificate) error { | |
| 229 | + | ret := _mock.Called(ctx, cert) | |
| 230 | + | ||
| 231 | + | if len(ret) == 0 { | |
| 232 | + | panic("no return value specified for Save") | |
| 233 | + | } | |
| 234 | + | ||
| 235 | + | var r0 error | |
| 236 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, out.StoredCertificate) error); ok { | |
| 237 | + | r0 = returnFunc(ctx, cert) | |
| 238 | + | } else { | |
| 239 | + | r0 = ret.Error(0) | |
| 240 | + | } | |
| 241 | + | return r0 | |
| 242 | + | } | |
| 243 | + | ||
| 244 | + | // MockCertificateStore_Save_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Save' | |
| 245 | + | type MockCertificateStore_Save_Call struct { | |
| 246 | + | *mock.Call | |
| 247 | + | } | |
| 248 | + | ||
| 249 | + | // Save is a helper method to define mock.On call | |
| 250 | + | // - ctx context.Context | |
| 251 | + | // - cert out.StoredCertificate | |
| 252 | + | func (_e *MockCertificateStore_Expecter) Save(ctx any, cert any) *MockCertificateStore_Save_Call { | |
| 253 | + | return &MockCertificateStore_Save_Call{Call: _e.mock.On("Save", ctx, cert)} | |
| 254 | + | } | |
| 255 | + | ||
| 256 | + | func (_c *MockCertificateStore_Save_Call) Run(run func(ctx context.Context, cert out.StoredCertificate)) *MockCertificateStore_Save_Call { | |
| 257 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 258 | + | var arg0 context.Context | |
| 259 | + | if args[0] != nil { | |
| 260 | + | arg0 = args[0].(context.Context) | |
| 261 | + | } | |
| 262 | + | var arg1 out.StoredCertificate | |
| 263 | + | if args[1] != nil { | |
| 264 | + | arg1 = args[1].(out.StoredCertificate) | |
| 265 | + | } | |
| 266 | + | run( | |
| 267 | + | arg0, | |
| 268 | + | arg1, | |
| 269 | + | ) | |
| 270 | + | }) | |
| 271 | + | return _c | |
| 272 | + | } | |
| 273 | + | ||
| 274 | + | func (_c *MockCertificateStore_Save_Call) Return(err error) *MockCertificateStore_Save_Call { | |
| 275 | + | _c.Call.Return(err) | |
| 276 | + | return _c | |
| 277 | + | } | |
| 278 | + | ||
| 279 | + | func (_c *MockCertificateStore_Save_Call) RunAndReturn(run func(ctx context.Context, cert out.StoredCertificate) error) *MockCertificateStore_Save_Call { | |
| 280 | + | _c.Call.Return(run) | |
| 281 | + | return _c | |
| 282 | + | } | |
| 283 | + | ||
| 284 | + | // SaveAccount provides a mock function for the type MockCertificateStore | |
| 285 | + | func (_mock *MockCertificateStore) SaveAccount(ctx context.Context, account out.ACMEAccount) error { | |
| 286 | + | ret := _mock.Called(ctx, account) | |
| 287 | + | ||
| 288 | + | if len(ret) == 0 { | |
| 289 | + | panic("no return value specified for SaveAccount") | |
| 290 | + | } | |
| 291 | + | ||
| 292 | + | var r0 error | |
| 293 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, out.ACMEAccount) error); ok { | |
| 294 | + | r0 = returnFunc(ctx, account) | |
| 295 | + | } else { | |
| 296 | + | r0 = ret.Error(0) | |
| 297 | + | } | |
| 298 | + | return r0 | |
| 299 | + | } | |
| 300 | + | ||
| 301 | + | // MockCertificateStore_SaveAccount_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SaveAccount' | |
| 302 | + | type MockCertificateStore_SaveAccount_Call struct { | |
| 303 | + | *mock.Call | |
| 304 | + | } | |
| 305 | + | ||
| 306 | + | // SaveAccount is a helper method to define mock.On call | |
| 307 | + | // - ctx context.Context | |
| 308 | + | // - account out.ACMEAccount | |
| 309 | + | func (_e *MockCertificateStore_Expecter) SaveAccount(ctx any, account any) *MockCertificateStore_SaveAccount_Call { | |
| 310 | + | return &MockCertificateStore_SaveAccount_Call{Call: _e.mock.On("SaveAccount", ctx, account)} | |
| 311 | + | } | |
| 312 | + | ||
| 313 | + | func (_c *MockCertificateStore_SaveAccount_Call) Run(run func(ctx context.Context, account out.ACMEAccount)) *MockCertificateStore_SaveAccount_Call { | |
| 314 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 315 | + | var arg0 context.Context | |
| 316 | + | if args[0] != nil { | |
| 317 | + | arg0 = args[0].(context.Context) | |
| 318 | + | } | |
| 319 | + | var arg1 out.ACMEAccount | |
| 320 | + | if args[1] != nil { | |
| 321 | + | arg1 = args[1].(out.ACMEAccount) | |
| 322 | + | } | |
| 323 | + | run( | |
| 324 | + | arg0, | |
| 325 | + | arg1, | |
| 326 | + | ) | |
| 327 | + | }) | |
| 328 | + | return _c | |
| 329 | + | } | |
| 330 | + | ||
| 331 | + | func (_c *MockCertificateStore_SaveAccount_Call) Return(err error) *MockCertificateStore_SaveAccount_Call { | |
| 332 | + | _c.Call.Return(err) | |
| 333 | + | return _c | |
| 334 | + | } | |
| 335 | + | ||
| 336 | + | func (_c *MockCertificateStore_SaveAccount_Call) RunAndReturn(run func(ctx context.Context, account out.ACMEAccount) error) *MockCertificateStore_SaveAccount_Call { | |
| 337 | + | _c.Call.Return(run) | |
| 338 | + | return _c | |
| 339 | + | } | |
A internal/boundaries/out/mocks/mock_cloudflare_zone_resolver.go +105 -0
| @@ -0,0 +1,105 @@ | |||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |
| 2 | + | // github.com/vektra/mockery | |
| 3 | + | // template: testify | |
| 4 | + | ||
| 5 | + | package mocks | |
| 6 | + | ||
| 7 | + | import ( | |
| 8 | + | "context" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/gordon/internal/boundaries/out" | |
| 11 | + | mock "github.com/stretchr/testify/mock" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // NewMockCloudflareZoneResolver creates a new instance of MockCloudflareZoneResolver. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 15 | + | // The first argument is typically a *testing.T value. | |
| 16 | + | func NewMockCloudflareZoneResolver(t interface { | |
| 17 | + | mock.TestingT | |
| 18 | + | Cleanup(func()) | |
| 19 | + | }) *MockCloudflareZoneResolver { | |
| 20 | + | mock := &MockCloudflareZoneResolver{} | |
| 21 | + | mock.Mock.Test(t) | |
| 22 | + | ||
| 23 | + | t.Cleanup(func() { mock.AssertExpectations(t) }) | |
| 24 | + | ||
| 25 | + | return mock | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | // MockCloudflareZoneResolver is an autogenerated mock type for the CloudflareZoneResolver type | |
| 29 | + | type MockCloudflareZoneResolver struct { | |
| 30 | + | mock.Mock | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | type MockCloudflareZoneResolver_Expecter struct { | |
| 34 | + | mock *mock.Mock | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | func (_m *MockCloudflareZoneResolver) EXPECT() *MockCloudflareZoneResolver_Expecter { | |
| 38 | + | return &MockCloudflareZoneResolver_Expecter{mock: &_m.Mock} | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | // FindZone provides a mock function for the type MockCloudflareZoneResolver | |
| 42 | + | func (_mock *MockCloudflareZoneResolver) FindZone(ctx context.Context, domain string) (out.CloudflareZone, error) { | |
| 43 | + | ret := _mock.Called(ctx, domain) | |
| 44 | + | ||
| 45 | + | if len(ret) == 0 { | |
| 46 | + | panic("no return value specified for FindZone") | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | var r0 out.CloudflareZone | |
| 50 | + | var r1 error | |
| 51 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) (out.CloudflareZone, error)); ok { | |
| 52 | + | return returnFunc(ctx, domain) | |
| 53 | + | } | |
| 54 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) out.CloudflareZone); ok { | |
| 55 | + | r0 = returnFunc(ctx, domain) | |
| 56 | + | } else { | |
| 57 | + | r0 = ret.Get(0).(out.CloudflareZone) | |
| 58 | + | } | |
| 59 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok { | |
| 60 | + | r1 = returnFunc(ctx, domain) | |
| 61 | + | } else { | |
| 62 | + | r1 = ret.Error(1) | |
| 63 | + | } | |
| 64 | + | return r0, r1 | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | // MockCloudflareZoneResolver_FindZone_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FindZone' | |
| 68 | + | type MockCloudflareZoneResolver_FindZone_Call struct { | |
| 69 | + | *mock.Call | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | // FindZone is a helper method to define mock.On call | |
| 73 | + | // - ctx context.Context | |
| 74 | + | // - domain string | |
| 75 | + | func (_e *MockCloudflareZoneResolver_Expecter) FindZone(ctx any, domain any) *MockCloudflareZoneResolver_FindZone_Call { | |
| 76 | + | return &MockCloudflareZoneResolver_FindZone_Call{Call: _e.mock.On("FindZone", ctx, domain)} | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | func (_c *MockCloudflareZoneResolver_FindZone_Call) Run(run func(ctx context.Context, domain string)) *MockCloudflareZoneResolver_FindZone_Call { | |
| 80 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 81 | + | var arg0 context.Context | |
| 82 | + | if args[0] != nil { | |
| 83 | + | arg0 = args[0].(context.Context) | |
| 84 | + | } | |
| 85 | + | var arg1 string | |
| 86 | + | if args[1] != nil { | |
| 87 | + | arg1 = args[1].(string) | |
| 88 | + | } | |
| 89 | + | run( | |
| 90 | + | arg0, | |
| 91 | + | arg1, | |
| 92 | + | ) | |
| 93 | + | }) | |
| 94 | + | return _c | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | func (_c *MockCloudflareZoneResolver_FindZone_Call) Return(cloudflareZone out.CloudflareZone, err error) *MockCloudflareZoneResolver_FindZone_Call { | |
| 98 | + | _c.Call.Return(cloudflareZone, err) | |
| 99 | + | return _c | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | func (_c *MockCloudflareZoneResolver_FindZone_Call) RunAndReturn(run func(ctx context.Context, domain string) (out.CloudflareZone, error)) *MockCloudflareZoneResolver_FindZone_Call { | |
| 103 | + | _c.Call.Return(run) | |
| 104 | + | return _c | |
| 105 | + | } | |
A internal/boundaries/out/mocks/mock_http_challenge_sink.go +144 -0
| @@ -0,0 +1,144 @@ | |||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |
| 2 | + | // github.com/vektra/mockery | |
| 3 | + | // template: testify | |
| 4 | + | ||
| 5 | + | package mocks | |
| 6 | + | ||
| 7 | + | import ( | |
| 8 | + | mock "github.com/stretchr/testify/mock" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // NewMockHTTPChallengeSink creates a new instance of MockHTTPChallengeSink. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 12 | + | // The first argument is typically a *testing.T value. | |
| 13 | + | func NewMockHTTPChallengeSink(t interface { | |
| 14 | + | mock.TestingT | |
| 15 | + | Cleanup(func()) | |
| 16 | + | }) *MockHTTPChallengeSink { | |
| 17 | + | mock := &MockHTTPChallengeSink{} | |
| 18 | + | mock.Mock.Test(t) | |
| 19 | + | ||
| 20 | + | t.Cleanup(func() { mock.AssertExpectations(t) }) | |
| 21 | + | ||
| 22 | + | return mock | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | // MockHTTPChallengeSink is an autogenerated mock type for the HTTPChallengeSink type | |
| 26 | + | type MockHTTPChallengeSink struct { | |
| 27 | + | mock.Mock | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | type MockHTTPChallengeSink_Expecter struct { | |
| 31 | + | mock *mock.Mock | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | func (_m *MockHTTPChallengeSink) EXPECT() *MockHTTPChallengeSink_Expecter { | |
| 35 | + | return &MockHTTPChallengeSink_Expecter{mock: &_m.Mock} | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | // CleanUp provides a mock function for the type MockHTTPChallengeSink | |
| 39 | + | func (_mock *MockHTTPChallengeSink) CleanUp(token string) error { | |
| 40 | + | ret := _mock.Called(token) | |
| 41 | + | ||
| 42 | + | if len(ret) == 0 { | |
| 43 | + | panic("no return value specified for CleanUp") | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | var r0 error | |
| 47 | + | if returnFunc, ok := ret.Get(0).(func(string) error); ok { | |
| 48 | + | r0 = returnFunc(token) | |
| 49 | + | } else { | |
| 50 | + | r0 = ret.Error(0) | |
| 51 | + | } | |
| 52 | + | return r0 | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | // MockHTTPChallengeSink_CleanUp_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CleanUp' | |
| 56 | + | type MockHTTPChallengeSink_CleanUp_Call struct { | |
| 57 | + | *mock.Call | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | // CleanUp is a helper method to define mock.On call | |
| 61 | + | // - token string | |
| 62 | + | func (_e *MockHTTPChallengeSink_Expecter) CleanUp(token any) *MockHTTPChallengeSink_CleanUp_Call { | |
| 63 | + | return &MockHTTPChallengeSink_CleanUp_Call{Call: _e.mock.On("CleanUp", token)} | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | func (_c *MockHTTPChallengeSink_CleanUp_Call) Run(run func(token string)) *MockHTTPChallengeSink_CleanUp_Call { | |
| 67 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 68 | + | var arg0 string | |
| 69 | + | if args[0] != nil { | |
| 70 | + | arg0 = args[0].(string) | |
| 71 | + | } | |
| 72 | + | run( | |
| 73 | + | arg0, | |
| 74 | + | ) | |
| 75 | + | }) | |
| 76 | + | return _c | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | func (_c *MockHTTPChallengeSink_CleanUp_Call) Return(err error) *MockHTTPChallengeSink_CleanUp_Call { | |
| 80 | + | _c.Call.Return(err) | |
| 81 | + | return _c | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | func (_c *MockHTTPChallengeSink_CleanUp_Call) RunAndReturn(run func(token string) error) *MockHTTPChallengeSink_CleanUp_Call { | |
| 85 | + | _c.Call.Return(run) | |
| 86 | + | return _c | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | // Present provides a mock function for the type MockHTTPChallengeSink | |
| 90 | + | func (_mock *MockHTTPChallengeSink) Present(token string, keyAuth string) error { | |
| 91 | + | ret := _mock.Called(token, keyAuth) | |
| 92 | + | ||
| 93 | + | if len(ret) == 0 { | |
| 94 | + | panic("no return value specified for Present") | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | var r0 error | |
| 98 | + | if returnFunc, ok := ret.Get(0).(func(string, string) error); ok { | |
| 99 | + | r0 = returnFunc(token, keyAuth) | |
| 100 | + | } else { | |
| 101 | + | r0 = ret.Error(0) | |
| 102 | + | } | |
| 103 | + | return r0 | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | // MockHTTPChallengeSink_Present_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Present' | |
| 107 | + | type MockHTTPChallengeSink_Present_Call struct { | |
| 108 | + | *mock.Call | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | // Present is a helper method to define mock.On call | |
| 112 | + | // - token string | |
| 113 | + | // - keyAuth string | |
| 114 | + | func (_e *MockHTTPChallengeSink_Expecter) Present(token any, keyAuth any) *MockHTTPChallengeSink_Present_Call { | |
| 115 | + | return &MockHTTPChallengeSink_Present_Call{Call: _e.mock.On("Present", token, keyAuth)} | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | func (_c *MockHTTPChallengeSink_Present_Call) Run(run func(token string, keyAuth string)) *MockHTTPChallengeSink_Present_Call { | |
| 119 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 120 | + | var arg0 string | |
| 121 | + | if args[0] != nil { | |
| 122 | + | arg0 = args[0].(string) | |
| 123 | + | } | |
| 124 | + | var arg1 string | |
| 125 | + | if args[1] != nil { | |
| 126 | + | arg1 = args[1].(string) | |
| 127 | + | } | |
| 128 | + | run( | |
| 129 | + | arg0, | |
| 130 | + | arg1, | |
| 131 | + | ) | |
| 132 | + | }) | |
| 133 | + | return _c | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | func (_c *MockHTTPChallengeSink_Present_Call) Return(err error) *MockHTTPChallengeSink_Present_Call { | |
| 137 | + | _c.Call.Return(err) | |
| 138 | + | return _c | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | func (_c *MockHTTPChallengeSink_Present_Call) RunAndReturn(run func(token string, keyAuth string) error) *MockHTTPChallengeSink_Present_Call { | |
| 142 | + | _c.Call.Return(run) | |
| 143 | + | return _c | |
| 144 | + | } | |
A internal/boundaries/out/mocks/mock_public_certificate_issuer.go +175 -0
| @@ -0,0 +1,175 @@ | |||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |
| 2 | + | // github.com/vektra/mockery | |
| 3 | + | // template: testify | |
| 4 | + | ||
| 5 | + | package mocks | |
| 6 | + | ||
| 7 | + | import ( | |
| 8 | + | "context" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/gordon/internal/boundaries/out" | |
| 11 | + | mock "github.com/stretchr/testify/mock" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // NewMockPublicCertificateIssuer creates a new instance of MockPublicCertificateIssuer. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 15 | + | // The first argument is typically a *testing.T value. | |
| 16 | + | func NewMockPublicCertificateIssuer(t interface { | |
| 17 | + | mock.TestingT | |
| 18 | + | Cleanup(func()) | |
| 19 | + | }) *MockPublicCertificateIssuer { | |
| 20 | + | mock := &MockPublicCertificateIssuer{} | |
| 21 | + | mock.Mock.Test(t) | |
| 22 | + | ||
| 23 | + | t.Cleanup(func() { mock.AssertExpectations(t) }) | |
| 24 | + | ||
| 25 | + | return mock | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | // MockPublicCertificateIssuer is an autogenerated mock type for the PublicCertificateIssuer type | |
| 29 | + | type MockPublicCertificateIssuer struct { | |
| 30 | + | mock.Mock | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | type MockPublicCertificateIssuer_Expecter struct { | |
| 34 | + | mock *mock.Mock | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | func (_m *MockPublicCertificateIssuer) EXPECT() *MockPublicCertificateIssuer_Expecter { | |
| 38 | + | return &MockPublicCertificateIssuer_Expecter{mock: &_m.Mock} | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | // Obtain provides a mock function for the type MockPublicCertificateIssuer | |
| 42 | + | func (_mock *MockPublicCertificateIssuer) Obtain(ctx context.Context, order out.CertificateOrder) (*out.StoredCertificate, error) { | |
| 43 | + | ret := _mock.Called(ctx, order) | |
| 44 | + | ||
| 45 | + | if len(ret) == 0 { | |
| 46 | + | panic("no return value specified for Obtain") | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | var r0 *out.StoredCertificate | |
| 50 | + | var r1 error | |
| 51 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, out.CertificateOrder) (*out.StoredCertificate, error)); ok { | |
| 52 | + | return returnFunc(ctx, order) | |
| 53 | + | } | |
| 54 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, out.CertificateOrder) *out.StoredCertificate); ok { | |
| 55 | + | r0 = returnFunc(ctx, order) | |
| 56 | + | } else { | |
| 57 | + | if ret.Get(0) != nil { | |
| 58 | + | r0 = ret.Get(0).(*out.StoredCertificate) | |
| 59 | + | } | |
| 60 | + | } | |
| 61 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, out.CertificateOrder) error); ok { | |
| 62 | + | r1 = returnFunc(ctx, order) | |
| 63 | + | } else { | |
| 64 | + | r1 = ret.Error(1) | |
| 65 | + | } | |
| 66 | + | return r0, r1 | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | // MockPublicCertificateIssuer_Obtain_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Obtain' | |
| 70 | + | type MockPublicCertificateIssuer_Obtain_Call struct { | |
| 71 | + | *mock.Call | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | // Obtain is a helper method to define mock.On call | |
| 75 | + | // - ctx context.Context | |
| 76 | + | // - order out.CertificateOrder | |
| 77 | + | func (_e *MockPublicCertificateIssuer_Expecter) Obtain(ctx any, order any) *MockPublicCertificateIssuer_Obtain_Call { | |
| 78 | + | return &MockPublicCertificateIssuer_Obtain_Call{Call: _e.mock.On("Obtain", ctx, order)} | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | func (_c *MockPublicCertificateIssuer_Obtain_Call) Run(run func(ctx context.Context, order out.CertificateOrder)) *MockPublicCertificateIssuer_Obtain_Call { | |
| 82 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 83 | + | var arg0 context.Context | |
| 84 | + | if args[0] != nil { | |
| 85 | + | arg0 = args[0].(context.Context) | |
| 86 | + | } | |
| 87 | + | var arg1 out.CertificateOrder | |
| 88 | + | if args[1] != nil { | |
| 89 | + | arg1 = args[1].(out.CertificateOrder) | |
| 90 | + | } | |
| 91 | + | run( | |
| 92 | + | arg0, | |
| 93 | + | arg1, | |
| 94 | + | ) | |
| 95 | + | }) | |
| 96 | + | return _c | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | func (_c *MockPublicCertificateIssuer_Obtain_Call) Return(storedCertificate *out.StoredCertificate, err error) *MockPublicCertificateIssuer_Obtain_Call { | |
| 100 | + | _c.Call.Return(storedCertificate, err) | |
| 101 | + | return _c | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | func (_c *MockPublicCertificateIssuer_Obtain_Call) RunAndReturn(run func(ctx context.Context, order out.CertificateOrder) (*out.StoredCertificate, error)) *MockPublicCertificateIssuer_Obtain_Call { | |
| 105 | + | _c.Call.Return(run) | |
| 106 | + | return _c | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | // Renew provides a mock function for the type MockPublicCertificateIssuer | |
| 110 | + | func (_mock *MockPublicCertificateIssuer) Renew(ctx context.Context, cert out.StoredCertificate) (*out.StoredCertificate, error) { | |
| 111 | + | ret := _mock.Called(ctx, cert) | |
| 112 | + | ||
| 113 | + | if len(ret) == 0 { | |
| 114 | + | panic("no return value specified for Renew") | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | var r0 *out.StoredCertificate | |
| 118 | + | var r1 error | |
| 119 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, out.StoredCertificate) (*out.StoredCertificate, error)); ok { | |
| 120 | + | return returnFunc(ctx, cert) | |
| 121 | + | } | |
| 122 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, out.StoredCertificate) *out.StoredCertificate); ok { | |
| 123 | + | r0 = returnFunc(ctx, cert) | |
| 124 | + | } else { | |
| 125 | + | if ret.Get(0) != nil { | |
| 126 | + | r0 = ret.Get(0).(*out.StoredCertificate) | |
| 127 | + | } | |
| 128 | + | } | |
| 129 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, out.StoredCertificate) error); ok { | |
| 130 | + | r1 = returnFunc(ctx, cert) | |
| 131 | + | } else { | |
| 132 | + | r1 = ret.Error(1) | |
| 133 | + | } | |
| 134 | + | return r0, r1 | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | // MockPublicCertificateIssuer_Renew_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Renew' | |
| 138 | + | type MockPublicCertificateIssuer_Renew_Call struct { | |
| 139 | + | *mock.Call | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | // Renew is a helper method to define mock.On call | |
| 143 | + | // - ctx context.Context | |
| 144 | + | // - cert out.StoredCertificate | |
| 145 | + | func (_e *MockPublicCertificateIssuer_Expecter) Renew(ctx any, cert any) *MockPublicCertificateIssuer_Renew_Call { | |
| 146 | + | return &MockPublicCertificateIssuer_Renew_Call{Call: _e.mock.On("Renew", ctx, cert)} | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | func (_c *MockPublicCertificateIssuer_Renew_Call) Run(run func(ctx context.Context, cert out.StoredCertificate)) *MockPublicCertificateIssuer_Renew_Call { | |
| 150 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 151 | + | var arg0 context.Context | |
| 152 | + | if args[0] != nil { | |
| 153 | + | arg0 = args[0].(context.Context) | |
| 154 | + | } | |
| 155 | + | var arg1 out.StoredCertificate | |
| 156 | + | if args[1] != nil { | |
| 157 | + | arg1 = args[1].(out.StoredCertificate) | |
| 158 | + | } | |
| 159 | + | run( | |
| 160 | + | arg0, | |
| 161 | + | arg1, | |
| 162 | + | ) | |
| 163 | + | }) | |
| 164 | + | return _c | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | func (_c *MockPublicCertificateIssuer_Renew_Call) Return(storedCertificate *out.StoredCertificate, err error) *MockPublicCertificateIssuer_Renew_Call { | |
| 168 | + | _c.Call.Return(storedCertificate, err) | |
| 169 | + | return _c | |
| 170 | + | } | |
| 171 | + | ||
| 172 | + | func (_c *MockPublicCertificateIssuer_Renew_Call) RunAndReturn(run func(ctx context.Context, cert out.StoredCertificate) (*out.StoredCertificate, error)) *MockPublicCertificateIssuer_Renew_Call { | |
| 173 | + | _c.Call.Return(run) | |
| 174 | + | return _c | |
| 175 | + | } | |
A internal/boundaries/out/mocks/mock_secret_resolver.go +99 -0
| @@ -0,0 +1,99 @@ | |||
| 1 | + | // Code generated by mockery; DO NOT EDIT. | |
| 2 | + | // github.com/vektra/mockery | |
| 3 | + | // template: testify | |
| 4 | + | ||
| 5 | + | package mocks | |
| 6 | + | ||
| 7 | + | import ( | |
| 8 | + | "context" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/gordon/internal/boundaries/out" | |
| 11 | + | mock "github.com/stretchr/testify/mock" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // NewMockSecretResolver creates a new instance of MockSecretResolver. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 15 | + | // The first argument is typically a *testing.T value. | |
| 16 | + | func NewMockSecretResolver(t interface { | |
| 17 | + | mock.TestingT | |
| 18 | + | Cleanup(func()) | |
| 19 | + | }) *MockSecretResolver { | |
| 20 | + | mock := &MockSecretResolver{} | |
| 21 | + | mock.Mock.Test(t) | |
| 22 | + | ||
| 23 | + | t.Cleanup(func() { mock.AssertExpectations(t) }) | |
| 24 | + | ||
| 25 | + | return mock | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | // MockSecretResolver is an autogenerated mock type for the SecretResolver type | |
| 29 | + | type MockSecretResolver struct { | |
| 30 | + | mock.Mock | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | type MockSecretResolver_Expecter struct { | |
| 34 | + | mock *mock.Mock | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | func (_m *MockSecretResolver) EXPECT() *MockSecretResolver_Expecter { | |
| 38 | + | return &MockSecretResolver_Expecter{mock: &_m.Mock} | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | // ResolveCloudflareToken provides a mock function for the type MockSecretResolver | |
| 42 | + | func (_mock *MockSecretResolver) ResolveCloudflareToken(ctx context.Context) (out.SecretValue, error) { | |
| 43 | + | ret := _mock.Called(ctx) | |
| 44 | + | ||
| 45 | + | if len(ret) == 0 { | |
| 46 | + | panic("no return value specified for ResolveCloudflareToken") | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | var r0 out.SecretValue | |
| 50 | + | var r1 error | |
| 51 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) (out.SecretValue, error)); ok { | |
| 52 | + | return returnFunc(ctx) | |
| 53 | + | } | |
| 54 | + | if returnFunc, ok := ret.Get(0).(func(context.Context) out.SecretValue); ok { | |
| 55 | + | r0 = returnFunc(ctx) | |
| 56 | + | } else { | |
| 57 | + | r0 = ret.Get(0).(out.SecretValue) | |
| 58 | + | } | |
| 59 | + | if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok { | |
| 60 | + | r1 = returnFunc(ctx) | |
| 61 | + | } else { | |
| 62 | + | r1 = ret.Error(1) | |
| 63 | + | } | |
| 64 | + | return r0, r1 | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | // MockSecretResolver_ResolveCloudflareToken_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ResolveCloudflareToken' | |
| 68 | + | type MockSecretResolver_ResolveCloudflareToken_Call struct { | |
| 69 | + | *mock.Call | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | // ResolveCloudflareToken is a helper method to define mock.On call | |
| 73 | + | // - ctx context.Context | |
| 74 | + | func (_e *MockSecretResolver_Expecter) ResolveCloudflareToken(ctx any) *MockSecretResolver_ResolveCloudflareToken_Call { | |
| 75 | + | return &MockSecretResolver_ResolveCloudflareToken_Call{Call: _e.mock.On("ResolveCloudflareToken", ctx)} | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | func (_c *MockSecretResolver_ResolveCloudflareToken_Call) Run(run func(ctx context.Context)) *MockSecretResolver_ResolveCloudflareToken_Call { | |
| 79 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 80 | + | var arg0 context.Context | |
| 81 | + | if args[0] != nil { | |
| 82 | + | arg0 = args[0].(context.Context) | |
| 83 | + | } | |
| 84 | + | run( | |
| 85 | + | arg0, | |
| 86 | + | ) | |
| 87 | + | }) | |
| 88 | + | return _c | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | func (_c *MockSecretResolver_ResolveCloudflareToken_Call) Return(secretValue out.SecretValue, err error) *MockSecretResolver_ResolveCloudflareToken_Call { | |
| 92 | + | _c.Call.Return(secretValue, err) | |
| 93 | + | return _c | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | func (_c *MockSecretResolver_ResolveCloudflareToken_Call) RunAndReturn(run func(ctx context.Context) (out.SecretValue, error)) *MockSecretResolver_ResolveCloudflareToken_Call { | |
| 97 | + | _c.Call.Return(run) | |
| 98 | + | return _c | |
| 99 | + | } | |
A internal/boundaries/out/public_tls.go +102 -0
| @@ -0,0 +1,102 @@ | |||
| 1 | + | // Package out defines output ports (interfaces) for infrastructure. | |
| 2 | + | // These interfaces define the contract between use cases and driven adapters. | |
| 3 | + | package out | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "context" | |
| 7 | + | "crypto/tls" | |
| 8 | + | "time" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/gordon/internal/domain" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | // CertificateOrder represents a request to obtain or renew a certificate. | |
| 14 | + | type CertificateOrder struct { | |
| 15 | + | ID string | |
| 16 | + | Names []string | |
| 17 | + | Challenge domain.ACMEChallengeMode | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | // StoredCertificate represents a persisted certificate with its metadata. | |
| 21 | + | type StoredCertificate struct { | |
| 22 | + | ID string | |
| 23 | + | Names []string | |
| 24 | + | Challenge domain.ACMEChallengeMode | |
| 25 | + | Certificate tls.Certificate | |
| 26 | + | CertPEM []byte | |
| 27 | + | ChainPEM []byte | |
| 28 | + | FullchainPEM []byte | |
| 29 | + | PrivateKeyPEM []byte | |
| 30 | + | NotAfter time.Time | |
| 31 | + | LastError string | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | // ACMEAccount represents a stored ACME account registration. | |
| 35 | + | type ACMEAccount struct { | |
| 36 | + | Email string | |
| 37 | + | PrivateKeyPEM []byte | |
| 38 | + | RegistrationURI string | |
| 39 | + | BodyJSON []byte | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | // SecretValue holds a resolved secret value and its source. | |
| 43 | + | type SecretValue struct { | |
| 44 | + | Value string | |
| 45 | + | Source domain.ACMETokenSource | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | // CloudflareZone represents a Cloudflare DNS zone. | |
| 49 | + | type CloudflareZone struct { | |
| 50 | + | ID string | |
| 51 | + | Name string | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | // HTTPChallengeSink defines the contract for storing HTTP-01 challenge tokens | |
| 55 | + | // while an ACME issuer completes domain validation. | |
| 56 | + | type HTTPChallengeSink interface { | |
| 57 | + | // Present stores the key authorization for a challenge token. | |
| 58 | + | Present(token, keyAuth string) error | |
| 59 | + | ||
| 60 | + | // CleanUp removes the key authorization for a challenge token. | |
| 61 | + | CleanUp(token string) error | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | // PublicCertificateIssuer defines the contract for obtaining and renewing | |
| 65 | + | // TLS certificates via ACME. | |
| 66 | + | type PublicCertificateIssuer interface { | |
| 67 | + | // Obtain requests a new certificate for the given order. | |
| 68 | + | Obtain(ctx context.Context, order CertificateOrder) (*StoredCertificate, error) | |
| 69 | + | ||
| 70 | + | // Renew renews an existing certificate. | |
| 71 | + | Renew(ctx context.Context, cert StoredCertificate) (*StoredCertificate, error) | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | // CertificateStore defines the contract for persisting ACME accounts and certificates. | |
| 75 | + | type CertificateStore interface { | |
| 76 | + | // LoadAccount retrieves an ACME account. | |
| 77 | + | LoadAccount(ctx context.Context) (*ACMEAccount, error) | |
| 78 | + | ||
| 79 | + | // SaveAccount persists an ACME account. | |
| 80 | + | SaveAccount(ctx context.Context, account ACMEAccount) error | |
| 81 | + | ||
| 82 | + | // LoadAll returns all stored certificates. | |
| 83 | + | LoadAll(ctx context.Context) ([]StoredCertificate, error) | |
| 84 | + | ||
| 85 | + | // Save persists a certificate. | |
| 86 | + | Save(ctx context.Context, cert StoredCertificate) error | |
| 87 | + | ||
| 88 | + | // Lock acquires a lock to prevent concurrent certificate operations. | |
| 89 | + | Lock(ctx context.Context) (unlock func() error, err error) | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | // SecretResolver defines the contract for resolving Cloudflare API tokens. | |
| 93 | + | type SecretResolver interface { | |
| 94 | + | // ResolveCloudflareToken retrieves the Cloudflare API token from the configured source. | |
| 95 | + | ResolveCloudflareToken(ctx context.Context) (SecretValue, error) | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | // CloudflareZoneResolver defines the contract for resolving DNS zones. | |
| 99 | + | type CloudflareZoneResolver interface { | |
| 100 | + | // FindZone finds the Cloudflare zone for the given domain. | |
| 101 | + | FindZone(ctx context.Context, domain string) (CloudflareZone, error) | |
| 102 | + | } | |
M internal/domain/errors.go +11 -0
| @@ -91,4 +91,15 @@ var ( | |||
| 91 | 91 | ErrAttachmentImageEmpty = errors.New("attachment image cannot be empty") | |
| 92 | 92 | ErrAttachmentTargetEmpty = errors.New("attachment target (domain or group) cannot be empty") | |
| 93 | 93 | ErrAttachmentNotDeployed = errors.New("configured attachment is not deployed; run gordon deploy to create it") | |
| 94 | + | ||
| 95 | + | // TLS / ACME errors | |
| 96 | + | ErrACMEDisabled = errors.New("acme disabled") | |
| 97 | + | ErrACMEEmailRequired = errors.New("acme email required") | |
| 98 | + | ErrACMEChallengeInvalid = errors.New("acme challenge invalid") | |
| 99 | + | ErrCloudflareTokenMissing = errors.New("cloudflare api token missing") | |
| 100 | + | ErrCertificateStoreRequired = errors.New("certificate store required") | |
| 101 | + | ErrCertificateIssuerRequired = errors.New("certificate issuer required") | |
| 102 | + | ErrRouteSourceRequired = errors.New("route source required") | |
| 103 | + | ErrHTTPChallengeSinkRequired = errors.New("http challenge sink required") | |
| 104 | + | ErrTLSRouteNotCovered = errors.New("tls route not covered by public certificate") | |
| 94 | 105 | ) | |
A internal/domain/public_tls.go +124 -0
| @@ -0,0 +1,124 @@ | |||
| 1 | + | package domain | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "strings" | |
| 6 | + | "time" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | type ACMEChallengeMode string | |
| 10 | + | ||
| 11 | + | const ( | |
| 12 | + | ACMEChallengeAuto ACMEChallengeMode = "auto" | |
| 13 | + | ACMEChallengeHTTP01 ACMEChallengeMode = "http-01" | |
| 14 | + | ACMEChallengeCloudflareDNS01 ACMEChallengeMode = "cloudflare-dns-01" | |
| 15 | + | ||
| 16 | + | MaxHTTP01TokenLength = 256 | |
| 17 | + | TLSRenewalWindow = 30 * 24 * time.Hour | |
| 18 | + | ) | |
| 19 | + | ||
| 20 | + | func IsValidHTTP01Token(token string) bool { | |
| 21 | + | return token != "" && len(token) <= MaxHTTP01TokenLength && !strings.Contains(token, "/") && !strings.Contains(token, "\\") && !strings.Contains(token, "..") && !strings.Contains(token, "\x00") | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | func ParseACMEChallengeMode(value string) (ACMEChallengeMode, error) { | |
| 25 | + | switch strings.TrimSpace(value) { | |
| 26 | + | case "", string(ACMEChallengeAuto): | |
| 27 | + | return ACMEChallengeAuto, nil | |
| 28 | + | case string(ACMEChallengeHTTP01): | |
| 29 | + | return ACMEChallengeHTTP01, nil | |
| 30 | + | case string(ACMEChallengeCloudflareDNS01): | |
| 31 | + | return ACMEChallengeCloudflareDNS01, nil | |
| 32 | + | default: | |
| 33 | + | return "", fmt.Errorf("%w: %q", ErrACMEChallengeInvalid, value) | |
| 34 | + | } | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | type ACMETokenSource string | |
| 38 | + | ||
| 39 | + | const ( | |
| 40 | + | ACMETokenSourceNone ACMETokenSource = "none" | |
| 41 | + | ACMETokenSourcePass ACMETokenSource = "pass" | |
| 42 | + | ACMETokenSourceFile ACMETokenSource = "file" | |
| 43 | + | ACMETokenSourceEnv ACMETokenSource = "env" | |
| 44 | + | ) | |
| 45 | + | ||
| 46 | + | type TLSCertificateStatus string | |
| 47 | + | ||
| 48 | + | const ( | |
| 49 | + | TLSCertificateStatusMissing TLSCertificateStatus = "missing" | |
| 50 | + | TLSCertificateStatusValid TLSCertificateStatus = "valid" | |
| 51 | + | TLSCertificateStatusWarning TLSCertificateStatus = "warning" | |
| 52 | + | TLSCertificateStatusExpired TLSCertificateStatus = "expired" | |
| 53 | + | TLSCertificateStatusError TLSCertificateStatus = "error" | |
| 54 | + | ) | |
| 55 | + | ||
| 56 | + | type ManagedCertificate struct { | |
| 57 | + | ID string | |
| 58 | + | Names []string | |
| 59 | + | Challenge ACMEChallengeMode | |
| 60 | + | Status TLSCertificateStatus | |
| 61 | + | NotAfter time.Time | |
| 62 | + | LastError string | |
| 63 | + | RenewalPending bool | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | func (c ManagedCertificate) Covers(host string) bool { | |
| 67 | + | return CertificateNamesCoverHost(c.Names, host) | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | // CertificateNamesCoverHost reports whether any certificate name covers host. | |
| 71 | + | func CertificateNamesCoverHost(names []string, host string) bool { | |
| 72 | + | host = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(host)), ".") | |
| 73 | + | if host == "" { | |
| 74 | + | return false | |
| 75 | + | } | |
| 76 | + | for _, name := range names { | |
| 77 | + | name = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(name)), ".") | |
| 78 | + | if name == host { | |
| 79 | + | return true | |
| 80 | + | } | |
| 81 | + | if suffix, ok := strings.CutPrefix(name, "*."); ok && strings.HasSuffix(host, "."+suffix) { | |
| 82 | + | left := strings.TrimSuffix(host, "."+suffix) | |
| 83 | + | if left != "" && !strings.Contains(left, ".") { | |
| 84 | + | return true | |
| 85 | + | } | |
| 86 | + | } | |
| 87 | + | } | |
| 88 | + | return false | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | func (c ManagedCertificate) Health(now time.Time) TLSCertificateStatus { | |
| 92 | + | if c.LastError != "" { | |
| 93 | + | return TLSCertificateStatusError | |
| 94 | + | } | |
| 95 | + | if c.NotAfter.IsZero() { | |
| 96 | + | return TLSCertificateStatusMissing | |
| 97 | + | } | |
| 98 | + | if !now.Before(c.NotAfter) { | |
| 99 | + | return TLSCertificateStatusExpired | |
| 100 | + | } | |
| 101 | + | if !now.Add(TLSRenewalWindow).Before(c.NotAfter) { | |
| 102 | + | return TLSCertificateStatusWarning | |
| 103 | + | } | |
| 104 | + | return TLSCertificateStatusValid | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | type TLSRouteCoverage struct { | |
| 108 | + | Domain string | |
| 109 | + | Covered bool | |
| 110 | + | CoveredBy string | |
| 111 | + | RequiredACME bool | |
| 112 | + | Error string | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | type PublicTLSStatus struct { | |
| 116 | + | ACMEEnabled bool | |
| 117 | + | ConfiguredMode ACMEChallengeMode | |
| 118 | + | EffectiveMode ACMEChallengeMode | |
| 119 | + | SelectionReason string | |
| 120 | + | TokenSource ACMETokenSource | |
| 121 | + | Certificates []ManagedCertificate | |
| 122 | + | Routes []TLSRouteCoverage | |
| 123 | + | Errors []string | |
| 124 | + | } | |
A internal/domain/public_tls_test.go +135 -0
| @@ -0,0 +1,135 @@ | |||
| 1 | + | package domain | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "strings" | |
| 5 | + | "testing" | |
| 6 | + | "time" | |
| 7 | + | ||
| 8 | + | "github.com/stretchr/testify/assert" | |
| 9 | + | "github.com/stretchr/testify/require" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | func TestIsValidHTTP01Token(t *testing.T) { | |
| 13 | + | tests := []struct { | |
| 14 | + | name string | |
| 15 | + | token string | |
| 16 | + | want bool | |
| 17 | + | }{ | |
| 18 | + | {name: "valid", token: "abc123_-token", want: true}, | |
| 19 | + | {name: "empty", token: "", want: false}, | |
| 20 | + | {name: "slash", token: "abc/def", want: false}, | |
| 21 | + | {name: "backslash", token: `abc\def`, want: false}, | |
| 22 | + | {name: "dot dot", token: "abc..def", want: false}, | |
| 23 | + | {name: "nul", token: "abc\x00def", want: false}, | |
| 24 | + | {name: "too long", token: strings.Repeat("a", MaxHTTP01TokenLength+1), want: false}, | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | for _, tt := range tests { | |
| 28 | + | t.Run(tt.name, func(t *testing.T) { | |
| 29 | + | assert.Equal(t, tt.want, IsValidHTTP01Token(tt.token)) | |
| 30 | + | }) | |
| 31 | + | } | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | func TestACMEChallengeModeParse(t *testing.T) { | |
| 35 | + | tests := []struct { | |
| 36 | + | input string | |
| 37 | + | want ACMEChallengeMode | |
| 38 | + | ok bool | |
| 39 | + | }{ | |
| 40 | + | {"", ACMEChallengeAuto, true}, | |
| 41 | + | {"auto", ACMEChallengeAuto, true}, | |
| 42 | + | {"http-01", ACMEChallengeHTTP01, true}, | |
| 43 | + | {"cloudflare-dns-01", ACMEChallengeCloudflareDNS01, true}, | |
| 44 | + | {"dns", "", false}, | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | for _, tt := range tests { | |
| 48 | + | got, err := ParseACMEChallengeMode(tt.input) | |
| 49 | + | if tt.ok { | |
| 50 | + | require.NoError(t, err) | |
| 51 | + | assert.Equal(t, tt.want, got) | |
| 52 | + | } else { | |
| 53 | + | require.Error(t, err) | |
| 54 | + | assert.ErrorIs(t, err, ErrACMEChallengeInvalid) | |
| 55 | + | } | |
| 56 | + | } | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | func TestManagedCertificateCovers(t *testing.T) { | |
| 60 | + | apexCert := ManagedCertificate{Names: []string{"example.com", "*.example.com"}} | |
| 61 | + | prodCert := ManagedCertificate{Names: []string{"prod.example.com", "*.prod.example.com"}} | |
| 62 | + | ||
| 63 | + | assert.True(t, apexCert.Covers("example.com")) | |
| 64 | + | assert.True(t, apexCert.Covers("app.example.com")) | |
| 65 | + | assert.False(t, apexCert.Covers("api.prod.example.com"), "*.example.com must not cover two labels") | |
| 66 | + | assert.True(t, prodCert.Covers("api.prod.example.com")) | |
| 67 | + | assert.False(t, apexCert.Covers("other.test")) | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | func TestManagedCertificateHealth(t *testing.T) { | |
| 71 | + | // Fixed reference time for deterministic tests. | |
| 72 | + | now := time.Date(2026, 4, 29, 12, 0, 0, 0, time.UTC) | |
| 73 | + | ||
| 74 | + | tests := []struct { | |
| 75 | + | name string | |
| 76 | + | cert ManagedCertificate | |
| 77 | + | want TLSCertificateStatus | |
| 78 | + | }{ | |
| 79 | + | { | |
| 80 | + | name: "valid certificate far from expiry", | |
| 81 | + | cert: ManagedCertificate{NotAfter: now.Add(60 * 24 * time.Hour)}, | |
| 82 | + | want: TLSCertificateStatusValid, | |
| 83 | + | }, | |
| 84 | + | { | |
| 85 | + | name: "expired certificate", | |
| 86 | + | cert: ManagedCertificate{NotAfter: now.Add(-time.Hour)}, | |
| 87 | + | want: TLSCertificateStatusExpired, | |
| 88 | + | }, | |
| 89 | + | { | |
| 90 | + | name: "warning when less than 30 days remain", | |
| 91 | + | cert: ManagedCertificate{NotAfter: now.Add(15 * 24 * time.Hour)}, | |
| 92 | + | want: TLSCertificateStatusWarning, | |
| 93 | + | }, | |
| 94 | + | { | |
| 95 | + | name: "warning exactly at 30 day boundary", | |
| 96 | + | cert: ManagedCertificate{NotAfter: now.Add(30 * 24 * time.Hour)}, | |
| 97 | + | want: TLSCertificateStatusWarning, | |
| 98 | + | }, | |
| 99 | + | { | |
| 100 | + | name: "missing when NotAfter is zero", | |
| 101 | + | cert: ManagedCertificate{}, | |
| 102 | + | want: TLSCertificateStatusMissing, | |
| 103 | + | }, | |
| 104 | + | { | |
| 105 | + | name: "error when LastError is set", | |
| 106 | + | cert: ManagedCertificate{ | |
| 107 | + | NotAfter: now.Add(60 * 24 * time.Hour), | |
| 108 | + | LastError: "issuer unavailable", | |
| 109 | + | }, | |
| 110 | + | want: TLSCertificateStatusError, | |
| 111 | + | }, | |
| 112 | + | { | |
| 113 | + | name: "error takes priority over expired", | |
| 114 | + | cert: ManagedCertificate{ | |
| 115 | + | NotAfter: now.Add(-time.Hour), | |
| 116 | + | LastError: "something went wrong", | |
| 117 | + | }, | |
| 118 | + | want: TLSCertificateStatusError, | |
| 119 | + | }, | |
| 120 | + | { | |
| 121 | + | name: "error takes priority over zero NotAfter", | |
| 122 | + | cert: ManagedCertificate{ | |
| 123 | + | LastError: "something went wrong", | |
| 124 | + | }, | |
| 125 | + | want: TLSCertificateStatusError, | |
| 126 | + | }, | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | for _, tt := range tests { | |
| 130 | + | t.Run(tt.name, func(t *testing.T) { | |
| 131 | + | got := tt.cert.Health(now) | |
| 132 | + | assert.Equal(t, tt.want, got) | |
| 133 | + | }) | |
| 134 | + | } | |
| 135 | + | } | |
A internal/usecase/publictls/config.go +135 -0
| @@ -0,0 +1,135 @@ | |||
| 1 | + | package publictls | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "fmt" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/gordon/internal/boundaries/out" | |
| 8 | + | "github.com/bnema/gordon/internal/domain" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // Config holds the resolved configuration for public ACME TLS. | |
| 12 | + | type Config struct { | |
| 13 | + | Enabled bool | |
| 14 | + | Email string | |
| 15 | + | Challenge string | |
| 16 | + | HTTPPort int | |
| 17 | + | TLSPort int | |
| 18 | + | DataDir string | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | // EffectiveChallenge represents the resolved ACME challenge configuration. | |
| 22 | + | type EffectiveChallenge struct { | |
| 23 | + | ConfiguredMode domain.ACMEChallengeMode | |
| 24 | + | Mode domain.ACMEChallengeMode | |
| 25 | + | Token string | |
| 26 | + | TokenSource domain.ACMETokenSource | |
| 27 | + | Reason string | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | // validPort checks that port is in the valid TCP port range 1..65535. | |
| 31 | + | func validPort(p int) bool { | |
| 32 | + | return p >= 1 && p <= 65535 | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | // ResolveEffectiveChallenge resolves the effective ACME challenge configuration | |
| 36 | + | // from the given Config and optional token resolver. | |
| 37 | + | func ResolveEffectiveChallenge(ctx context.Context, cfg Config, resolver out.SecretResolver) (EffectiveChallenge, error) { | |
| 38 | + | if !cfg.Enabled { | |
| 39 | + | configuredMode := domain.ACMEChallengeAuto | |
| 40 | + | if parsed, err := domain.ParseACMEChallengeMode(cfg.Challenge); err == nil { | |
| 41 | + | configuredMode = parsed | |
| 42 | + | } | |
| 43 | + | return EffectiveChallenge{ | |
| 44 | + | ConfiguredMode: configuredMode, | |
| 45 | + | Mode: domain.ACMEChallengeAuto, | |
| 46 | + | TokenSource: domain.ACMETokenSourceNone, | |
| 47 | + | Reason: "ACME disabled", | |
| 48 | + | }, nil | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | if cfg.Email == "" { | |
| 52 | + | return EffectiveChallenge{}, domain.ErrACMEEmailRequired | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | if !validPort(cfg.TLSPort) { | |
| 56 | + | return EffectiveChallenge{}, fmt.Errorf("%w: tls_port must be in range 1..65535", domain.ErrACMEChallengeInvalid) | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | parsedMode, err := domain.ParseACMEChallengeMode(cfg.Challenge) | |
| 60 | + | if err != nil { | |
| 61 | + | return EffectiveChallenge{}, err | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | switch parsedMode { | |
| 65 | + | case domain.ACMEChallengeHTTP01: | |
| 66 | + | if !validPort(cfg.HTTPPort) { | |
| 67 | + | return EffectiveChallenge{}, fmt.Errorf("%w: http_port must be in range 1..65535 for http-01 challenge", domain.ErrACMEChallengeInvalid) | |
| 68 | + | } | |
| 69 | + | return EffectiveChallenge{ | |
| 70 | + | ConfiguredMode: domain.ACMEChallengeHTTP01, | |
| 71 | + | Mode: domain.ACMEChallengeHTTP01, | |
| 72 | + | TokenSource: domain.ACMETokenSourceNone, | |
| 73 | + | Reason: "http-01 challenge selected", | |
| 74 | + | }, nil | |
| 75 | + | ||
| 76 | + | case domain.ACMEChallengeCloudflareDNS01: | |
| 77 | + | token, source, err := resolveToken(ctx, resolver, true) | |
| 78 | + | if err != nil { | |
| 79 | + | return EffectiveChallenge{}, fmt.Errorf("resolve Cloudflare token: %w", err) | |
| 80 | + | } | |
| 81 | + | if token == "" { | |
| 82 | + | return EffectiveChallenge{}, domain.ErrCloudflareTokenMissing | |
| 83 | + | } | |
| 84 | + | return EffectiveChallenge{ | |
| 85 | + | ConfiguredMode: domain.ACMEChallengeCloudflareDNS01, | |
| 86 | + | Mode: domain.ACMEChallengeCloudflareDNS01, | |
| 87 | + | Token: token, | |
| 88 | + | TokenSource: source, | |
| 89 | + | Reason: "cloudflare dns-01 challenge selected", | |
| 90 | + | }, nil | |
| 91 | + | ||
| 92 | + | case domain.ACMEChallengeAuto: | |
| 93 | + | token, source, _ := resolveToken(ctx, resolver, false) | |
| 94 | + | if token != "" { | |
| 95 | + | return EffectiveChallenge{ | |
| 96 | + | ConfiguredMode: domain.ACMEChallengeAuto, | |
| 97 | + | Mode: domain.ACMEChallengeCloudflareDNS01, | |
| 98 | + | Token: token, | |
| 99 | + | TokenSource: source, | |
| 100 | + | Reason: "auto selected cloudflare dns-01 (token available)", | |
| 101 | + | }, nil | |
| 102 | + | } | |
| 103 | + | if !validPort(cfg.HTTPPort) { | |
| 104 | + | return EffectiveChallenge{}, fmt.Errorf("%w: http_port must be in range 1..65535 for http-01 fallback in auto mode", domain.ErrACMEChallengeInvalid) | |
| 105 | + | } | |
| 106 | + | return EffectiveChallenge{ | |
| 107 | + | ConfiguredMode: domain.ACMEChallengeAuto, | |
| 108 | + | Mode: domain.ACMEChallengeHTTP01, | |
| 109 | + | TokenSource: domain.ACMETokenSourceNone, | |
| 110 | + | Reason: "auto selected http-01 (no cloudflare token)", | |
| 111 | + | }, nil | |
| 112 | + | ||
| 113 | + | default: | |
| 114 | + | return EffectiveChallenge{}, fmt.Errorf("%w: %q", domain.ErrACMEChallengeInvalid, parsedMode) | |
| 115 | + | } | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | // resolveToken resolves the Cloudflare token via the resolver. When strict is | |
| 119 | + | // false, resolver errors are ignored so auto mode can fall back to HTTP-01. | |
| 120 | + | func resolveToken(ctx context.Context, resolver out.SecretResolver, strict bool) (string, domain.ACMETokenSource, error) { | |
| 121 | + | if resolver == nil { | |
| 122 | + | return "", domain.ACMETokenSourceNone, nil | |
| 123 | + | } | |
| 124 | + | sv, err := resolver.ResolveCloudflareToken(ctx) | |
| 125 | + | if err != nil { | |
| 126 | + | if strict { | |
| 127 | + | return "", domain.ACMETokenSourceNone, err | |
| 128 | + | } | |
| 129 | + | return "", domain.ACMETokenSourceNone, nil | |
| 130 | + | } | |
| 131 | + | if sv.Value == "" { | |
| 132 | + | return "", domain.ACMETokenSourceNone, nil | |
| 133 | + | } | |
| 134 | + | return sv.Value, sv.Source, nil | |
| 135 | + | } | |
A internal/usecase/publictls/config_test.go +151 -0
| @@ -0,0 +1,151 @@ | |||
| 1 | + | package publictls | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "testing" | |
| 7 | + | ||
| 8 | + | "github.com/stretchr/testify/assert" | |
| 9 | + | "github.com/stretchr/testify/mock" | |
| 10 | + | "github.com/stretchr/testify/require" | |
| 11 | + | ||
| 12 | + | "github.com/bnema/gordon/internal/boundaries/out" | |
| 13 | + | outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks" | |
| 14 | + | "github.com/bnema/gordon/internal/domain" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | func newSecretResolverMock(t *testing.T, value out.SecretValue, err error) *outmocks.MockSecretResolver { | |
| 18 | + | t.Helper() | |
| 19 | + | resolver := outmocks.NewMockSecretResolver(t) | |
| 20 | + | resolver.EXPECT().ResolveCloudflareToken(mock.Anything).Return(value, err) | |
| 21 | + | return resolver | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | func TestResolveEffectiveChallenge(t *testing.T) { | |
| 25 | + | t.Run("disabled returns auto mode with none source", func(t *testing.T) { | |
| 26 | + | cfg := Config{Enabled: false} | |
| 27 | + | ec, err := ResolveEffectiveChallenge(context.Background(), cfg, nil) | |
| 28 | + | require.NoError(t, err) | |
| 29 | + | assert.Equal(t, domain.ACMEChallengeAuto, ec.ConfiguredMode) | |
| 30 | + | assert.Equal(t, domain.ACMEChallengeAuto, ec.Mode) | |
| 31 | + | assert.Equal(t, domain.ACMETokenSourceNone, ec.TokenSource) | |
| 32 | + | assert.Equal(t, "ACME disabled", ec.Reason) | |
| 33 | + | }) | |
| 34 | + | ||
| 35 | + | t.Run("disabled preserves configured challenge in ConfiguredMode", func(t *testing.T) { | |
| 36 | + | cfg := Config{Enabled: false, Challenge: "http-01"} | |
| 37 | + | ec, err := ResolveEffectiveChallenge(context.Background(), cfg, nil) | |
| 38 | + | require.NoError(t, err) | |
| 39 | + | assert.Equal(t, domain.ACMEChallengeHTTP01, ec.ConfiguredMode, "should preserve http-01") | |
| 40 | + | assert.Equal(t, domain.ACMEChallengeAuto, ec.Mode, "Mode should remain auto") | |
| 41 | + | assert.Equal(t, domain.ACMETokenSourceNone, ec.TokenSource) | |
| 42 | + | assert.Equal(t, "ACME disabled", ec.Reason) | |
| 43 | + | }) | |
| 44 | + | ||
| 45 | + | t.Run("disabled preserves cloudflare-dns-01 in ConfiguredMode", func(t *testing.T) { | |
| 46 | + | cfg := Config{Enabled: false, Challenge: "cloudflare-dns-01"} | |
| 47 | + | ec, err := ResolveEffectiveChallenge(context.Background(), cfg, nil) | |
| 48 | + | require.NoError(t, err) | |
| 49 | + | assert.Equal(t, domain.ACMEChallengeCloudflareDNS01, ec.ConfiguredMode, "should preserve cloudflare-dns-01") | |
| 50 | + | assert.Equal(t, domain.ACMEChallengeAuto, ec.Mode, "Mode should remain auto") | |
| 51 | + | assert.Equal(t, domain.ACMETokenSourceNone, ec.TokenSource) | |
| 52 | + | assert.Equal(t, "ACME disabled", ec.Reason) | |
| 53 | + | }) | |
| 54 | + | ||
| 55 | + | t.Run("disabled with unparseable challenge defaults ConfiguredMode to auto", func(t *testing.T) { | |
| 56 | + | cfg := Config{Enabled: false, Challenge: "unknown-challenge"} | |
| 57 | + | ec, err := ResolveEffectiveChallenge(context.Background(), cfg, nil) | |
| 58 | + | require.NoError(t, err) | |
| 59 | + | assert.Equal(t, domain.ACMEChallengeAuto, ec.ConfiguredMode, "unparseable defaults to auto") | |
| 60 | + | assert.Equal(t, domain.ACMEChallengeAuto, ec.Mode) | |
| 61 | + | assert.Equal(t, "ACME disabled", ec.Reason) | |
| 62 | + | }) | |
| 63 | + | ||
| 64 | + | t.Run("enabled requires non-empty email", func(t *testing.T) { | |
| 65 | + | cfg := Config{Enabled: true, Email: "", TLSPort: 8443} | |
| 66 | + | _, err := ResolveEffectiveChallenge(context.Background(), cfg, nil) | |
| 67 | + | require.Error(t, err) | |
| 68 | + | assert.ErrorIs(t, err, domain.ErrACMEEmailRequired) | |
| 69 | + | }) | |
| 70 | + | ||
| 71 | + | t.Run("enabled requires valid tls port in 1..65535", func(t *testing.T) { | |
| 72 | + | cfg := Config{Enabled: true, Email: "admin@example.com", TLSPort: 0} | |
| 73 | + | _, err := ResolveEffectiveChallenge(context.Background(), cfg, nil) | |
| 74 | + | require.Error(t, err) | |
| 75 | + | assert.ErrorIs(t, err, domain.ErrACMEChallengeInvalid) | |
| 76 | + | assert.Contains(t, err.Error(), "tls_port") | |
| 77 | + | ||
| 78 | + | cfg2 := Config{Enabled: true, Email: "admin@example.com", TLSPort: 65536} | |
| 79 | + | _, err2 := ResolveEffectiveChallenge(context.Background(), cfg2, nil) | |
| 80 | + | require.Error(t, err2) | |
| 81 | + | assert.ErrorIs(t, err2, domain.ErrACMEChallengeInvalid) | |
| 82 | + | assert.Contains(t, err2.Error(), "tls_port") | |
| 83 | + | }) | |
| 84 | + | ||
| 85 | + | t.Run("auto with cloudflare token returns dns-01", func(t *testing.T) { | |
| 86 | + | cfg := Config{Enabled: true, Email: "admin@example.com", Challenge: "auto", TLSPort: 8443, HTTPPort: 8088} | |
| 87 | + | resolver := newSecretResolverMock(t, out.SecretValue{Value: "token", Source: domain.ACMETokenSourcePass}, nil) | |
| 88 | + | ec, err := ResolveEffectiveChallenge(context.Background(), cfg, resolver) | |
| 89 | + | require.NoError(t, err) | |
| 90 | + | assert.Equal(t, domain.ACMEChallengeCloudflareDNS01, ec.Mode) | |
| 91 | + | assert.Equal(t, domain.ACMETokenSourcePass, ec.TokenSource) | |
| 92 | + | assert.Equal(t, "token", ec.Token) | |
| 93 | + | assert.Equal(t, domain.ACMEChallengeAuto, ec.ConfiguredMode) | |
| 94 | + | }) | |
| 95 | + | ||
| 96 | + | t.Run("auto without token returns http-01", func(t *testing.T) { | |
| 97 | + | cfg := Config{Enabled: true, Email: "admin@example.com", Challenge: "auto", TLSPort: 8443, HTTPPort: 8088} | |
| 98 | + | resolver := newSecretResolverMock(t, out.SecretValue{Value: "", Source: domain.ACMETokenSourceNone}, nil) | |
| 99 | + | ec, err := ResolveEffectiveChallenge(context.Background(), cfg, resolver) | |
| 100 | + | require.NoError(t, err) | |
| 101 | + | assert.Equal(t, domain.ACMEChallengeHTTP01, ec.Mode) | |
| 102 | + | assert.Equal(t, domain.ACMETokenSourceNone, ec.TokenSource) | |
| 103 | + | assert.Equal(t, domain.ACMEChallengeAuto, ec.ConfiguredMode) | |
| 104 | + | }) | |
| 105 | + | ||
| 106 | + | t.Run("forced cloudflare-dns-01 requires token", func(t *testing.T) { | |
| 107 | + | cfg := Config{Enabled: true, Email: "admin@example.com", Challenge: "cloudflare-dns-01", TLSPort: 8443, HTTPPort: 8088} | |
| 108 | + | resolver := newSecretResolverMock(t, out.SecretValue{Value: "", Source: domain.ACMETokenSourceNone}, nil) | |
| 109 | + | _, err := ResolveEffectiveChallenge(context.Background(), cfg, resolver) | |
| 110 | + | require.Error(t, err) | |
| 111 | + | assert.ErrorIs(t, err, domain.ErrCloudflareTokenMissing) | |
| 112 | + | }) | |
| 113 | + | ||
| 114 | + | t.Run("http-01 requires valid http port in 1..65535", func(t *testing.T) { | |
| 115 | + | cfg := Config{Enabled: true, Email: "admin@example.com", Challenge: "http-01", TLSPort: 8443, HTTPPort: 0} | |
| 116 | + | _, err := ResolveEffectiveChallenge(context.Background(), cfg, nil) | |
| 117 | + | require.Error(t, err) | |
| 118 | + | assert.ErrorIs(t, err, domain.ErrACMEChallengeInvalid) | |
| 119 | + | assert.Contains(t, err.Error(), "http_port") | |
| 120 | + | ||
| 121 | + | cfg2 := Config{Enabled: true, Email: "admin@example.com", Challenge: "http-01", TLSPort: 8443, HTTPPort: 65536} | |
| 122 | + | _, err2 := ResolveEffectiveChallenge(context.Background(), cfg2, nil) | |
| 123 | + | require.Error(t, err2) | |
| 124 | + | assert.ErrorIs(t, err2, domain.ErrACMEChallengeInvalid) | |
| 125 | + | assert.Contains(t, err2.Error(), "http_port") | |
| 126 | + | }) | |
| 127 | + | ||
| 128 | + | t.Run("cloudflare-dns-01 preserves resolver errors", func(t *testing.T) { | |
| 129 | + | cfg := Config{Enabled: true, Email: "admin@example.com", Challenge: "cloudflare-dns-01", TLSPort: 8443} | |
| 130 | + | resolver := newSecretResolverMock(t, out.SecretValue{}, errors.New("pass failed")) | |
| 131 | + | _, err := ResolveEffectiveChallenge(context.Background(), cfg, resolver) | |
| 132 | + | require.Error(t, err) | |
| 133 | + | assert.Contains(t, err.Error(), "resolve Cloudflare token") | |
| 134 | + | assert.False(t, errors.Is(err, domain.ErrCloudflareTokenMissing), "must not collapse to missing token") | |
| 135 | + | }) | |
| 136 | + | ||
| 137 | + | t.Run("nil resolver in auto mode returns http-01", func(t *testing.T) { | |
| 138 | + | cfg := Config{Enabled: true, Email: "admin@example.com", Challenge: "auto", TLSPort: 8443, HTTPPort: 8088} | |
| 139 | + | ec, err := ResolveEffectiveChallenge(context.Background(), cfg, nil) | |
| 140 | + | require.NoError(t, err) | |
| 141 | + | assert.Equal(t, domain.ACMEChallengeHTTP01, ec.Mode) | |
| 142 | + | assert.Equal(t, domain.ACMETokenSourceNone, ec.TokenSource) | |
| 143 | + | }) | |
| 144 | + | ||
| 145 | + | t.Run("forced cloudflare-dns-01 with nil resolver returns missing token", func(t *testing.T) { | |
| 146 | + | cfg := Config{Enabled: true, Email: "admin@example.com", Challenge: "cloudflare-dns-01", TLSPort: 8443} | |
| 147 | + | _, err := ResolveEffectiveChallenge(context.Background(), cfg, nil) | |
| 148 | + | require.Error(t, err) | |
| 149 | + | assert.ErrorIs(t, err, domain.ErrCloudflareTokenMissing) | |
| 150 | + | }) | |
| 151 | + | } | |
A internal/usecase/publictls/http01.go +58 -0
| @@ -0,0 +1,58 @@ | |||
| 1 | + | package publictls | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "sync" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/gordon/internal/domain" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // HTTP01Challenges stores ACME HTTP-01 challenge tokens and their key | |
| 11 | + | // authorizations. It is safe for concurrent use. | |
| 12 | + | type HTTP01Challenges struct { | |
| 13 | + | mu sync.RWMutex | |
| 14 | + | data map[string]string | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | // NewHTTP01Challenges returns an initialized HTTP01Challenges. | |
| 18 | + | func NewHTTP01Challenges() *HTTP01Challenges { | |
| 19 | + | return &HTTP01Challenges{ | |
| 20 | + | data: make(map[string]string), | |
| 21 | + | } | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | // Present stores the key authorization for the given token. It silently | |
| 25 | + | // ignores unsafe tokens and empty keyAuth values. | |
| 26 | + | func (c *HTTP01Challenges) Present(token, keyAuth string) error { | |
| 27 | + | if !domain.IsValidHTTP01Token(token) || keyAuth == "" { | |
| 28 | + | return nil | |
| 29 | + | } | |
| 30 | + | c.mu.Lock() | |
| 31 | + | defer c.mu.Unlock() | |
| 32 | + | c.data[token] = keyAuth | |
| 33 | + | return nil | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | // CleanUp removes the stored key authorization for the given token. It silently | |
| 37 | + | // ignores unsafe tokens. | |
| 38 | + | func (c *HTTP01Challenges) CleanUp(token string) error { | |
| 39 | + | if !domain.IsValidHTTP01Token(token) { | |
| 40 | + | return nil | |
| 41 | + | } | |
| 42 | + | c.mu.Lock() | |
| 43 | + | defer c.mu.Unlock() | |
| 44 | + | delete(c.data, token) | |
| 45 | + | return nil | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | // Get returns the key authorization for the given token, and a boolean | |
| 49 | + | // indicating whether it was found. It returns false for unsafe tokens. | |
| 50 | + | func (c *HTTP01Challenges) Get(_ context.Context, token string) (string, bool) { | |
| 51 | + | if !domain.IsValidHTTP01Token(token) { | |
| 52 | + | return "", false | |
| 53 | + | } | |
| 54 | + | c.mu.RLock() | |
| 55 | + | defer c.mu.RUnlock() | |
| 56 | + | keyAuth, ok := c.data[token] | |
| 57 | + | return keyAuth, ok | |
| 58 | + | } | |
A internal/usecase/publictls/http01_test.go +164 -0
| @@ -0,0 +1,164 @@ | |||
| 1 | + | package publictls | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "fmt" | |
| 6 | + | "sync" | |
| 7 | + | "testing" | |
| 8 | + | ||
| 9 | + | "github.com/stretchr/testify/assert" | |
| 10 | + | "github.com/stretchr/testify/require" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | func TestHTTP01Challenges(t *testing.T) { | |
| 14 | + | ctx := context.Background() | |
| 15 | + | ||
| 16 | + | t.Run("full lifecycle", func(t *testing.T) { | |
| 17 | + | ch := NewHTTP01Challenges() | |
| 18 | + | require.NotNil(t, ch) | |
| 19 | + | ||
| 20 | + | // Present a valid token | |
| 21 | + | ch.Present("token", "key-auth") | |
| 22 | + | got, ok := ch.Get(ctx, "token") | |
| 23 | + | assert.True(t, ok) | |
| 24 | + | assert.Equal(t, "key-auth", got) | |
| 25 | + | ||
| 26 | + | // Clean up | |
| 27 | + | ch.CleanUp("token") | |
| 28 | + | _, ok = ch.Get(ctx, "token") | |
| 29 | + | assert.False(t, ok) | |
| 30 | + | }) | |
| 31 | + | ||
| 32 | + | t.Run("concurrent access is correct", func(t *testing.T) { | |
| 33 | + | ch := NewHTTP01Challenges() | |
| 34 | + | ||
| 35 | + | // Pre-populate some tokens. | |
| 36 | + | ch.Present("existing-token", "existing-keyauth") | |
| 37 | + | ||
| 38 | + | const numGoroutines = 20 | |
| 39 | + | var wg sync.WaitGroup | |
| 40 | + | errCh := make(chan error, numGoroutines) | |
| 41 | + | ||
| 42 | + | // Spawn concurrent readers and writers. | |
| 43 | + | for i := range numGoroutines { | |
| 44 | + | wg.Add(1) | |
| 45 | + | go func(id int) { | |
| 46 | + | defer wg.Done() | |
| 47 | + | token := fmt.Sprintf("token-%d", id) | |
| 48 | + | keyAuth := fmt.Sprintf("ka-%d", id) | |
| 49 | + | ||
| 50 | + | // Every goroutine presents its own token. | |
| 51 | + | ch.Present(token, keyAuth) | |
| 52 | + | ||
| 53 | + | // Every goroutine reads back its own token. | |
| 54 | + | got, ok := ch.Get(ctx, token) | |
| 55 | + | if !ok { | |
| 56 | + | errCh <- fmt.Errorf("token %q not found after Present", token) | |
| 57 | + | return | |
| 58 | + | } | |
| 59 | + | if got != keyAuth { | |
| 60 | + | errCh <- fmt.Errorf("token %q: got %q, want %q", token, got, keyAuth) | |
| 61 | + | return | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | // Every goroutine reads the pre-populated existing token. | |
| 65 | + | existing, ok := ch.Get(ctx, "existing-token") | |
| 66 | + | if !ok { | |
| 67 | + | errCh <- fmt.Errorf("existing-token not found by goroutine %d", id) | |
| 68 | + | return | |
| 69 | + | } | |
| 70 | + | if existing != "existing-keyauth" { | |
| 71 | + | errCh <- fmt.Errorf("existing-token: got %q, want existing-keyauth", existing) | |
| 72 | + | return | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | // Every goroutine cleans up its own token. | |
| 76 | + | ch.CleanUp(token) | |
| 77 | + | _, ok = ch.Get(ctx, token) | |
| 78 | + | if ok { | |
| 79 | + | errCh <- fmt.Errorf("token %q should be gone after CleanUp", token) | |
| 80 | + | return | |
| 81 | + | } | |
| 82 | + | }(i) | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | wg.Wait() | |
| 86 | + | close(errCh) | |
| 87 | + | ||
| 88 | + | // Collect any errors from goroutines. | |
| 89 | + | for err := range errCh { | |
| 90 | + | t.Error(err) | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | // Verify that pre-populated token is still present after all concurrent access. | |
| 94 | + | existing, ok := ch.Get(ctx, "existing-token") | |
| 95 | + | require.True(t, ok, "existing-token should survive concurrent access") | |
| 96 | + | assert.Equal(t, "existing-keyauth", existing) | |
| 97 | + | }) | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | func TestHTTP01ChallengesRejectUnsafeToken(t *testing.T) { | |
| 101 | + | ctx := context.Background() | |
| 102 | + | ||
| 103 | + | t.Run("path traversal token ../secret is rejected", func(t *testing.T) { | |
| 104 | + | ch := NewHTTP01Challenges() | |
| 105 | + | ||
| 106 | + | ch.Present("../secret", "key-auth") | |
| 107 | + | _, ok := ch.Get(ctx, "../secret") | |
| 108 | + | assert.False(t, ok, "Present should silently ignore ../secret") | |
| 109 | + | }) | |
| 110 | + | ||
| 111 | + | t.Run("token with slash is rejected", func(t *testing.T) { | |
| 112 | + | ch := NewHTTP01Challenges() | |
| 113 | + | ||
| 114 | + | ch.Present("a/b", "key-auth") | |
| 115 | + | _, ok := ch.Get(ctx, "a/b") | |
| 116 | + | assert.False(t, ok, "token containing / should be rejected") | |
| 117 | + | ||
| 118 | + | // Get also rejects the unsafe token | |
| 119 | + | _, ok = ch.Get(ctx, "safe/token") | |
| 120 | + | assert.False(t, ok) | |
| 121 | + | }) | |
| 122 | + | ||
| 123 | + | t.Run("token with backslash is rejected", func(t *testing.T) { | |
| 124 | + | ch := NewHTTP01Challenges() | |
| 125 | + | ||
| 126 | + | ch.Present("a\\b", "key-auth") | |
| 127 | + | _, ok := ch.Get(ctx, "a\\b") | |
| 128 | + | assert.False(t, ok, "token containing backslash should be rejected") | |
| 129 | + | ||
| 130 | + | _, ok = ch.Get(ctx, "safe\\token") | |
| 131 | + | assert.False(t, ok) | |
| 132 | + | }) | |
| 133 | + | ||
| 134 | + | t.Run("empty token is rejected", func(t *testing.T) { | |
| 135 | + | ch := NewHTTP01Challenges() | |
| 136 | + | ||
| 137 | + | ch.Present("", "key-auth") | |
| 138 | + | _, ok := ch.Get(ctx, "") | |
| 139 | + | assert.False(t, ok, "empty token should be rejected") | |
| 140 | + | }) | |
| 141 | + | ||
| 142 | + | t.Run("'..' token is rejected", func(t *testing.T) { | |
| 143 | + | ch := NewHTTP01Challenges() | |
| 144 | + | ||
| 145 | + | ch.Present("..", "key-auth") | |
| 146 | + | _, ok := ch.Get(ctx, "..") | |
| 147 | + | assert.False(t, ok, "token '..' should be rejected") | |
| 148 | + | }) | |
| 149 | + | ||
| 150 | + | t.Run("empty keyAuth is silently ignored", func(t *testing.T) { | |
| 151 | + | ch := NewHTTP01Challenges() | |
| 152 | + | ch.Present("valid-token", "") | |
| 153 | + | _, ok := ch.Get(ctx, "valid-token") | |
| 154 | + | assert.False(t, ok, "Present with empty keyAuth should not store anything") | |
| 155 | + | }) | |
| 156 | + | ||
| 157 | + | t.Run("valid tokens are accepted and retrievable", func(t *testing.T) { | |
| 158 | + | ch := NewHTTP01Challenges() | |
| 159 | + | ch.Present("valid-token", "key-auth-value") | |
| 160 | + | got, ok := ch.Get(ctx, "valid-token") | |
| 161 | + | assert.True(t, ok) | |
| 162 | + | assert.Equal(t, "key-auth-value", got) | |
| 163 | + | }) | |
| 164 | + | } | |
A internal/usecase/publictls/integration_test.go +296 -0
| @@ -0,0 +1,296 @@ | |||
| 1 | + | package publictls | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/stretchr/testify/assert" | |
| 8 | + | "github.com/stretchr/testify/mock" | |
| 9 | + | "github.com/stretchr/testify/require" | |
| 10 | + | ||
| 11 | + | "github.com/bnema/gordon/internal/boundaries/out" | |
| 12 | + | outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks" | |
| 13 | + | "github.com/bnema/gordon/internal/domain" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | // --------------------------------------------------------------------------- | |
| 17 | + | // Integration-style tests using fakes (no real ACME / Cloudflare) | |
| 18 | + | // --------------------------------------------------------------------------- | |
| 19 | + | ||
| 20 | + | func TestPublicTLSIntegration_DNS01WildcardStatusAndCertificateLookup(t *testing.T) { | |
| 21 | + | ctx := context.Background() | |
| 22 | + | ||
| 23 | + | // Routes: app.example.com, api.prod.example.com, example.com | |
| 24 | + | routes := &fakeRoutes{ | |
| 25 | + | routes: []domain.Route{ | |
| 26 | + | {Domain: "app.example.com"}, | |
| 27 | + | {Domain: "api.prod.example.com"}, | |
| 28 | + | {Domain: "example.com"}, | |
| 29 | + | }, | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | // Mock zone resolver returns "example.com" zone for 3 routes. | |
| 33 | + | zoneResolver := outmocks.NewMockCloudflareZoneResolver(t) | |
| 34 | + | zoneResolver.EXPECT().FindZone(mock.Anything, mock.Anything).Return(out.CloudflareZone{Name: "example.com"}, nil).Times(3) | |
| 35 | + | ||
| 36 | + | // Mock issuer: returns valid stored cert for any order. | |
| 37 | + | issuer, recorder := newMockPublicCertificateIssuer(t, func(_ context.Context, order out.CertificateOrder) (*out.StoredCertificate, error) { | |
| 38 | + | return defaultStoredCert(order) | |
| 39 | + | }, nil) | |
| 40 | + | store, _ := newMockCertificateStore(t) | |
| 41 | + | ||
| 42 | + | cfg := Config{ | |
| 43 | + | Enabled: true, | |
| 44 | + | Email: "admin@example.com", | |
| 45 | + | Challenge: "cloudflare-dns-01", | |
| 46 | + | TLSPort: 8443, | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | svc := NewService(cfg, ServiceDeps{ | |
| 50 | + | Config: cfg, | |
| 51 | + | Routes: routes, | |
| 52 | + | Issuer: issuer, | |
| 53 | + | Store: store, | |
| 54 | + | ZoneResolver: zoneResolver, | |
| 55 | + | Challenges: NewHTTP01Challenges(), | |
| 56 | + | Effective: EffectiveChallenge{ | |
| 57 | + | ConfiguredMode: domain.ACMEChallengeCloudflareDNS01, | |
| 58 | + | Mode: domain.ACMEChallengeCloudflareDNS01, | |
| 59 | + | TokenSource: domain.ACMETokenSourceNone, | |
| 60 | + | Reason: "cloudflare dns-01 challenge selected", | |
| 61 | + | }, | |
| 62 | + | }) | |
| 63 | + | ||
| 64 | + | // Load (nothing stored). | |
| 65 | + | err := svc.Load(ctx) | |
| 66 | + | require.NoError(t, err) | |
| 67 | + | ||
| 68 | + | // Reconcile — should obtain DNS wildcard base targets: | |
| 69 | + | // example.com (for app.example.com + example.com) and prod.example.com (for api.prod.example.com). | |
| 70 | + | err = svc.Reconcile(ctx) | |
| 71 | + | require.NoError(t, err) | |
| 72 | + | ||
| 73 | + | // Verify issuer received exactly 2 orders. | |
| 74 | + | orders := recorder.Orders() | |
| 75 | + | ||
| 76 | + | require.Len(t, orders, 2, "expected 2 DNS-01 orders") | |
| 77 | + | ||
| 78 | + | // Check order details (orders are sorted alphabetically by target base). | |
| 79 | + | order0 := orders[0] | |
| 80 | + | assert.Equal(t, "dns01-example.com", order0.ID) | |
| 81 | + | assert.ElementsMatch(t, []string{"example.com", "*.example.com"}, order0.Names) | |
| 82 | + | assert.Equal(t, domain.ACMEChallengeCloudflareDNS01, order0.Challenge) | |
| 83 | + | ||
| 84 | + | order1 := orders[1] | |
| 85 | + | assert.Equal(t, "dns01-prod.example.com", order1.ID) | |
| 86 | + | assert.ElementsMatch(t, []string{"prod.example.com", "*.prod.example.com"}, order1.Names) | |
| 87 | + | assert.Equal(t, domain.ACMEChallengeCloudflareDNS01, order1.Challenge) | |
| 88 | + | ||
| 89 | + | // --- Status reports routes covered --- | |
| 90 | + | status := svc.Status(ctx) | |
| 91 | + | require.True(t, status.ACMEEnabled) | |
| 92 | + | require.Len(t, status.Routes, 3, "expected 3 routes in status") | |
| 93 | + | ||
| 94 | + | // Build a map for easier assertions. | |
| 95 | + | routeMap := make(map[string]domain.TLSRouteCoverage) | |
| 96 | + | for _, r := range status.Routes { | |
| 97 | + | routeMap[r.Domain] = r | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | for _, routeDomain := range []string{"app.example.com", "api.prod.example.com", "example.com"} { | |
| 101 | + | rc, ok := routeMap[routeDomain] | |
| 102 | + | require.True(t, ok, "route %s should be present in status", routeDomain) | |
| 103 | + | assert.True(t, rc.Covered, "route %s should be covered", routeDomain) | |
| 104 | + | assert.True(t, rc.RequiredACME) | |
| 105 | + | assert.NotEmpty(t, rc.CoveredBy, "route %s should have CoveredBy set", routeDomain) | |
| 106 | + | assert.Empty(t, rc.Error, "route %s should have no error", routeDomain) | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | // app.example.com should be covered by the wildcard cert (dns01-example.com). | |
| 110 | + | assert.Equal(t, "dns01-example.com", routeMap["app.example.com"].CoveredBy, | |
| 111 | + | "app.example.com should be covered by dns01-example.com (wildcard *.example.com)") | |
| 112 | + | ||
| 113 | + | // example.com should be covered by the base cert (dns01-example.com). | |
| 114 | + | assert.Equal(t, "dns01-example.com", routeMap["example.com"].CoveredBy, | |
| 115 | + | "example.com should be covered by dns01-example.com") | |
| 116 | + | ||
| 117 | + | // api.prod.example.com should be covered by the wildcard cert (dns01-prod.example.com). | |
| 118 | + | assert.Equal(t, "dns01-prod.example.com", routeMap["api.prod.example.com"].CoveredBy, | |
| 119 | + | "api.prod.example.com should be covered by dns01-prod.example.com (wildcard *.prod.example.com)") | |
| 120 | + | ||
| 121 | + | // --- GetCertificate returns non-nil certs --- | |
| 122 | + | t.Run("GetCertificate_returns_cert_for_app_example_com", func(t *testing.T) { | |
| 123 | + | cert, err := svc.GetCertificateForHost("app.example.com") | |
| 124 | + | require.NoError(t, err) | |
| 125 | + | require.NotNil(t, cert) | |
| 126 | + | require.NotEmpty(t, cert.Certificate) | |
| 127 | + | }) | |
| 128 | + | ||
| 129 | + | t.Run("GetCertificate_returns_cert_for_api_prod_example_com", func(t *testing.T) { | |
| 130 | + | cert, err := svc.GetCertificateForHost("api.prod.example.com") | |
| 131 | + | require.NoError(t, err) | |
| 132 | + | require.NotNil(t, cert) | |
| 133 | + | require.NotEmpty(t, cert.Certificate) | |
| 134 | + | }) | |
| 135 | + | ||
| 136 | + | t.Run("GetCertificate_returns_cert_for_example_com", func(t *testing.T) { | |
| 137 | + | cert, err := svc.GetCertificateForHost("example.com") | |
| 138 | + | require.NoError(t, err) | |
| 139 | + | require.NotNil(t, cert) | |
| 140 | + | require.NotEmpty(t, cert.Certificate) | |
| 141 | + | }) | |
| 142 | + | ||
| 143 | + | // Also verify that a host not in the routes returns nil (no required ACME). | |
| 144 | + | // "unrelated.com" is not covered by any wildcard base in requiredHosts. | |
| 145 | + | t.Run("GetCertificate_returns_nil_for_unrelated_host", func(t *testing.T) { | |
| 146 | + | cert, err := svc.GetCertificateForHost("unrelated.com") | |
| 147 | + | require.NoError(t, err) | |
| 148 | + | assert.Nil(t, cert, "unrelated host should not require ACME coverage") | |
| 149 | + | }) | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | func TestPublicTLSIntegration_HTTP01PerRouteChallengeFlow(t *testing.T) { | |
| 153 | + | ctx := context.Background() | |
| 154 | + | ||
| 155 | + | routes := &fakeRoutes{ | |
| 156 | + | routes: []domain.Route{ | |
| 157 | + | {Domain: "app.example.com"}, | |
| 158 | + | }, | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | issuer, recorder := newMockPublicCertificateIssuer(t, nil, nil) | |
| 162 | + | store, _ := newMockCertificateStore(t) | |
| 163 | + | challenges := NewHTTP01Challenges() | |
| 164 | + | ||
| 165 | + | cfg := Config{ | |
| 166 | + | Enabled: true, | |
| 167 | + | Email: "admin@example.com", | |
| 168 | + | Challenge: "http-01", | |
| 169 | + | HTTPPort: 8088, | |
| 170 | + | TLSPort: 8443, | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | svc := NewService(cfg, ServiceDeps{ | |
| 174 | + | Config: cfg, | |
| 175 | + | Routes: routes, | |
| 176 | + | Issuer: issuer, | |
| 177 | + | Store: store, | |
| 178 | + | Challenges: challenges, | |
| 179 | + | Effective: EffectiveChallenge{ | |
| 180 | + | ConfiguredMode: domain.ACMEChallengeHTTP01, | |
| 181 | + | Mode: domain.ACMEChallengeHTTP01, | |
| 182 | + | TokenSource: domain.ACMETokenSourceNone, | |
| 183 | + | Reason: "http-01 challenge selected", | |
| 184 | + | }, | |
| 185 | + | }) | |
| 186 | + | ||
| 187 | + | // Present an HTTP-01 challenge token/keyAuth. | |
| 188 | + | token := "test-token-abc123" | |
| 189 | + | keyAuth := "test-key-auth-xyz789" | |
| 190 | + | challenges.Present(token, keyAuth) | |
| 191 | + | ||
| 192 | + | // GetHTTP01Challenge should return the keyAuth. | |
| 193 | + | gotKeyAuth, ok := svc.GetHTTP01Challenge(ctx, token) | |
| 194 | + | require.True(t, ok, "GetHTTP01Challenge should find the token") | |
| 195 | + | assert.Equal(t, keyAuth, gotKeyAuth) | |
| 196 | + | ||
| 197 | + | // Load the service (nothing stored). | |
| 198 | + | err := svc.Load(ctx) | |
| 199 | + | require.NoError(t, err) | |
| 200 | + | ||
| 201 | + | // Reconcile — should create per-route target app.example.com. | |
| 202 | + | err = svc.Reconcile(ctx) | |
| 203 | + | require.NoError(t, err) | |
| 204 | + | ||
| 205 | + | // Verify the issuer received exactly 1 order. | |
| 206 | + | orders := recorder.Orders() | |
| 207 | + | ||
| 208 | + | require.Len(t, orders, 1, "expected 1 HTTP-01 order") | |
| 209 | + | assert.Equal(t, "http01-app.example.com", orders[0].ID) | |
| 210 | + | assert.Equal(t, []string{"app.example.com"}, orders[0].Names) | |
| 211 | + | assert.Equal(t, domain.ACMEChallengeHTTP01, orders[0].Challenge) | |
| 212 | + | ||
| 213 | + | // Status reports covered route. | |
| 214 | + | status := svc.Status(ctx) | |
| 215 | + | require.True(t, status.ACMEEnabled) | |
| 216 | + | require.Len(t, status.Routes, 1, "expected 1 route in status") | |
| 217 | + | assert.Equal(t, "app.example.com", status.Routes[0].Domain) | |
| 218 | + | assert.True(t, status.Routes[0].Covered, "route should be covered") | |
| 219 | + | assert.Equal(t, "http01-app.example.com", status.Routes[0].CoveredBy) | |
| 220 | + | assert.Empty(t, status.Routes[0].Error) | |
| 221 | + | ||
| 222 | + | // GetCertificateForHost returns non-nil cert for app.example.com. | |
| 223 | + | cert, err := svc.GetCertificateForHost("app.example.com") | |
| 224 | + | require.NoError(t, err) | |
| 225 | + | require.NotNil(t, cert) | |
| 226 | + | require.NotEmpty(t, cert.Certificate) | |
| 227 | + | ||
| 228 | + | // GetCertificateForHost returns nil for unrelated host. | |
| 229 | + | cert, err = svc.GetCertificateForHost("other.example.com") | |
| 230 | + | require.NoError(t, err) | |
| 231 | + | assert.Nil(t, cert) | |
| 232 | + | } | |
| 233 | + | ||
| 234 | + | func TestPublicTLSIntegration_MissingRequiredCertReportsCoverageError(t *testing.T) { | |
| 235 | + | ctx := context.Background() | |
| 236 | + | ||
| 237 | + | routes := &fakeRoutes{ | |
| 238 | + | routes: []domain.Route{ | |
| 239 | + | {Domain: "missing.example.com"}, | |
| 240 | + | }, | |
| 241 | + | } | |
| 242 | + | ||
| 243 | + | // Issuer returns error so no cert is obtained. | |
| 244 | + | issuer, _ := newMockPublicCertificateIssuer(t, func(_ context.Context, _ out.CertificateOrder) (*out.StoredCertificate, error) { | |
| 245 | + | return nil, assert.AnError | |
| 246 | + | }, nil) | |
| 247 | + | store, _ := newMockCertificateStore(t) | |
| 248 | + | ||
| 249 | + | cfg := Config{ | |
| 250 | + | Enabled: true, | |
| 251 | + | Email: "admin@example.com", | |
| 252 | + | Challenge: "http-01", | |
| 253 | + | HTTPPort: 8088, | |
| 254 | + | TLSPort: 8443, | |
| 255 | + | } | |
| 256 | + | ||
| 257 | + | svc := NewService(cfg, ServiceDeps{ | |
| 258 | + | Config: cfg, | |
| 259 | + | Routes: routes, | |
| 260 | + | Issuer: issuer, | |
| 261 | + | Store: store, | |
| 262 | + | Challenges: NewHTTP01Challenges(), | |
| 263 | + | Effective: EffectiveChallenge{ | |
| 264 | + | ConfiguredMode: domain.ACMEChallengeHTTP01, | |
| 265 | + | Mode: domain.ACMEChallengeHTTP01, | |
| 266 | + | TokenSource: domain.ACMETokenSourceNone, | |
| 267 | + | Reason: "http-01 challenge selected", | |
| 268 | + | }, | |
| 269 | + | }) | |
| 270 | + | ||
| 271 | + | // Load (nothing stored). | |
| 272 | + | err := svc.Load(ctx) | |
| 273 | + | require.NoError(t, err) | |
| 274 | + | ||
| 275 | + | // Reconcile — issuer fails, so no cert is cached but requiredHosts is set. | |
| 276 | + | err = svc.Reconcile(ctx) | |
| 277 | + | require.NoError(t, err) // Reconcile swallows per-target obtain errors | |
| 278 | + | ||
| 279 | + | // GetCertificateForHost for missing.example.com should return ErrTLSRouteNotCovered. | |
| 280 | + | _, err = svc.GetCertificateForHost("missing.example.com") | |
| 281 | + | require.Error(t, err) | |
| 282 | + | assert.ErrorIs(t, err, domain.ErrTLSRouteNotCovered) | |
| 283 | + | // The error message should contain the hostname. | |
| 284 | + | assert.Contains(t, err.Error(), "missing.example.com") | |
| 285 | + | ||
| 286 | + | // Status should show the route is NOT covered and has an error. | |
| 287 | + | status := svc.Status(ctx) | |
| 288 | + | require.True(t, status.ACMEEnabled) | |
| 289 | + | require.Len(t, status.Routes, 1, "expected 1 route in status") | |
| 290 | + | rc := status.Routes[0] | |
| 291 | + | assert.Equal(t, "missing.example.com", rc.Domain) | |
| 292 | + | assert.False(t, rc.Covered, "route should NOT be covered") | |
| 293 | + | assert.True(t, rc.RequiredACME) | |
| 294 | + | ||
| 295 | + | assert.Contains(t, rc.Error, "obtain certificate") | |
| 296 | + | } | |
A internal/usecase/publictls/renewal.go +174 -0
| @@ -0,0 +1,174 @@ | |||
| 1 | + | package publictls | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "fmt" | |
| 6 | + | "time" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/zerowrap" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/gordon/internal/boundaries/out" | |
| 11 | + | "github.com/bnema/gordon/internal/domain" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // ShouldRenew reports whether a certificate should be renewed at the given time. | |
| 15 | + | // A certificate with a zero NotAfter is always considered due for renewal. | |
| 16 | + | func ShouldRenew(cert out.StoredCertificate, now time.Time) bool { | |
| 17 | + | if cert.NotAfter.IsZero() { | |
| 18 | + | return true | |
| 19 | + | } | |
| 20 | + | return !now.Before(cert.NotAfter.Add(-domain.TLSRenewalWindow)) | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | // StartRenewalLoop starts a background goroutine that periodically reconciles | |
| 24 | + | // certificates and renews due certificates. Returns a channel that is closed | |
| 25 | + | // when the loop exits (ctx is cancelled). | |
| 26 | + | // | |
| 27 | + | // If interval <= 0, a default of 1 hour is used. | |
| 28 | + | func (s *Service) StartRenewalLoop(ctx context.Context, interval time.Duration) <-chan struct{} { | |
| 29 | + | if interval <= 0 { | |
| 30 | + | interval = time.Hour | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | done := make(chan struct{}) | |
| 34 | + | ||
| 35 | + | // Derive a cancellable context so Stop can terminate the loop. | |
| 36 | + | loopCtx, cancel := context.WithCancel(ctx) | |
| 37 | + | loopCtx = zerowrap.CtxWithFields(loopCtx, map[string]any{ | |
| 38 | + | zerowrap.FieldLayer: "usecase", | |
| 39 | + | zerowrap.FieldUseCase: "RenewDueCertificates", | |
| 40 | + | }) | |
| 41 | + | ||
| 42 | + | // Atomically swap loop state under lock before cancelling the old loop, | |
| 43 | + | // preventing overlapping loop goroutines and race conditions. | |
| 44 | + | s.mu.Lock() | |
| 45 | + | oldCancel := s.cancel | |
| 46 | + | oldDone := s.done | |
| 47 | + | s.cancel = cancel | |
| 48 | + | s.done = done | |
| 49 | + | s.mu.Unlock() | |
| 50 | + | ||
| 51 | + | if oldCancel != nil { | |
| 52 | + | oldCancel() | |
| 53 | + | } | |
| 54 | + | if oldDone != nil { | |
| 55 | + | select { | |
| 56 | + | case <-oldDone: | |
| 57 | + | case <-ctx.Done(): | |
| 58 | + | case <-time.After(30 * time.Second): | |
| 59 | + | } | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | go func() { | |
| 63 | + | defer close(done) | |
| 64 | + | defer cancel() | |
| 65 | + | ||
| 66 | + | ticker := time.NewTicker(interval) | |
| 67 | + | defer ticker.Stop() | |
| 68 | + | ||
| 69 | + | log := zerowrap.FromCtx(loopCtx) | |
| 70 | + | ||
| 71 | + | // Startup and reload paths reconcile routes; the periodic loop only renews | |
| 72 | + | // due certificates to avoid repeated zone lookups and ACME ordering work. | |
| 73 | + | if err := s.renewDueCertificates(loopCtx, time.Now()); err != nil { | |
| 74 | + | log.Warn().Err(err).Msg("failed to renew due public TLS certificates") | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | for { | |
| 78 | + | select { | |
| 79 | + | case <-loopCtx.Done(): | |
| 80 | + | return | |
| 81 | + | case <-ticker.C: | |
| 82 | + | if err := s.renewDueCertificates(loopCtx, time.Now()); err != nil { | |
| 83 | + | log.Warn().Err(err).Msg("failed to renew due public TLS certificates") | |
| 84 | + | } | |
| 85 | + | } | |
| 86 | + | } | |
| 87 | + | }() | |
| 88 | + | ||
| 89 | + | return done | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | // renewDueCertificates renews all certificates in the cache that are due | |
| 93 | + | // for renewal according to ShouldRenew. | |
| 94 | + | // | |
| 95 | + | // Per-certificate errors are recorded in lastErr and do not halt the loop. | |
| 96 | + | // Returns nil unless the store or issuer is nil (systemic error). | |
| 97 | + | func (s *Service) renewDueCertificates(ctx context.Context, now time.Time) error { | |
| 98 | + | log := zerowrap.FromCtx(ctx) | |
| 99 | + | if !s.cfg.Enabled { | |
| 100 | + | return nil | |
| 101 | + | } | |
| 102 | + | if s.deps.Store == nil { | |
| 103 | + | return fmt.Errorf("%w: certificate store is nil", domain.ErrCertificateStoreRequired) | |
| 104 | + | } | |
| 105 | + | if s.deps.Issuer == nil { | |
| 106 | + | return fmt.Errorf("%w: certificate issuer is nil", domain.ErrCertificateIssuerRequired) | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | // Collect due certs from in-memory cache under s.mu (no store lock needed). | |
| 110 | + | s.mu.Lock() | |
| 111 | + | due := make([]out.StoredCertificate, 0, len(s.certs)) | |
| 112 | + | for _, cert := range s.certs { | |
| 113 | + | if ShouldRenew(*cert, now) { | |
| 114 | + | due = append(due, *cert) | |
| 115 | + | } | |
| 116 | + | } | |
| 117 | + | s.mu.Unlock() | |
| 118 | + | ||
| 119 | + | for _, cert := range due { | |
| 120 | + | if err := ctx.Err(); err != nil { | |
| 121 | + | return err | |
| 122 | + | } | |
| 123 | + | renewed, err := s.deps.Issuer.Renew(ctx, cert) | |
| 124 | + | if err != nil { | |
| 125 | + | s.mu.Lock() | |
| 126 | + | s.lastErr[cert.ID] = fmt.Sprintf("renew: %v", err) | |
| 127 | + | s.mu.Unlock() | |
| 128 | + | continue | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | if renewed == nil { | |
| 132 | + | s.mu.Lock() | |
| 133 | + | s.lastErr[cert.ID] = "renew returned nil certificate" | |
| 134 | + | s.mu.Unlock() | |
| 135 | + | continue | |
| 136 | + | } | |
| 137 | + | if err := populateStoredCertificate(renewed); err != nil { | |
| 138 | + | s.mu.Lock() | |
| 139 | + | s.lastErr[cert.ID] = err.Error() | |
| 140 | + | s.mu.Unlock() | |
| 141 | + | continue | |
| 142 | + | } | |
| 143 | + | renewed.LastError = "" | |
| 144 | + | ||
| 145 | + | // Acquire store lock only around Save to avoid holding it across | |
| 146 | + | // the potentially long-running Issuer.Renew call. | |
| 147 | + | unlock, err := s.deps.Store.Lock(ctx) | |
| 148 | + | if err != nil { | |
| 149 | + | s.mu.Lock() | |
| 150 | + | s.lastErr[cert.ID] = fmt.Sprintf("acquire store lock: %v", err) | |
| 151 | + | s.mu.Unlock() | |
| 152 | + | continue | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | saveErr := s.deps.Store.Save(ctx, *renewed) | |
| 156 | + | if unlockErr := unlock(); unlockErr != nil { | |
| 157 | + | log.Warn().Err(unlockErr).Msg("failed to release store lock") | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | if saveErr != nil { | |
| 161 | + | s.mu.Lock() | |
| 162 | + | s.lastErr[cert.ID] = fmt.Sprintf("save renewed certificate: %v", saveErr) | |
| 163 | + | s.mu.Unlock() | |
| 164 | + | continue | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | s.mu.Lock() | |
| 168 | + | s.certs[renewed.ID] = renewed | |
| 169 | + | delete(s.lastErr, renewed.ID) | |
| 170 | + | s.mu.Unlock() | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | return nil | |
| 174 | + | } | |
A internal/usecase/publictls/renewal_test.go +208 -0
| @@ -0,0 +1,208 @@ | |||
| 1 | + | package publictls | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "crypto/tls" | |
| 6 | + | "testing" | |
| 7 | + | "time" | |
| 8 | + | ||
| 9 | + | "github.com/stretchr/testify/assert" | |
| 10 | + | "github.com/stretchr/testify/require" | |
| 11 | + | ||
| 12 | + | "github.com/bnema/gordon/internal/boundaries/out" | |
| 13 | + | "github.com/bnema/gordon/internal/domain" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | // --------------------------------------------------------------------------- | |
| 17 | + | // ShouldRenew | |
| 18 | + | // --------------------------------------------------------------------------- | |
| 19 | + | ||
| 20 | + | func TestShouldRenew(t *testing.T) { | |
| 21 | + | // Fixed "now" for deterministic tests. | |
| 22 | + | now := time.Date(2026, 4, 29, 12, 0, 0, 0, time.UTC) | |
| 23 | + | ||
| 24 | + | tests := []struct { | |
| 25 | + | name string | |
| 26 | + | cert out.StoredCertificate | |
| 27 | + | want bool | |
| 28 | + | }{ | |
| 29 | + | { | |
| 30 | + | name: "cert with NotAfter 45 days from now should not renew", | |
| 31 | + | cert: out.StoredCertificate{NotAfter: now.Add(45 * 24 * time.Hour)}, | |
| 32 | + | want: false, | |
| 33 | + | }, | |
| 34 | + | { | |
| 35 | + | name: "cert with NotAfter 10 days from now should renew", | |
| 36 | + | cert: out.StoredCertificate{NotAfter: now.Add(10 * 24 * time.Hour)}, | |
| 37 | + | want: true, | |
| 38 | + | }, | |
| 39 | + | { | |
| 40 | + | name: "expired cert should renew", | |
| 41 | + | cert: out.StoredCertificate{NotAfter: now.Add(-1 * time.Hour)}, | |
| 42 | + | want: true, | |
| 43 | + | }, | |
| 44 | + | { | |
| 45 | + | name: "cert with zero NotAfter should renew", | |
| 46 | + | cert: out.StoredCertificate{}, | |
| 47 | + | want: true, | |
| 48 | + | }, | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | for _, tt := range tests { | |
| 52 | + | t.Run(tt.name, func(t *testing.T) { | |
| 53 | + | got := ShouldRenew(tt.cert, now) | |
| 54 | + | assert.Equal(t, tt.want, got) | |
| 55 | + | }) | |
| 56 | + | } | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | func TestRenewDueCertificatesRequiresDependencies(t *testing.T) { | |
| 60 | + | cfg := Config{Enabled: true} | |
| 61 | + | svc := NewService(cfg, ServiceDeps{}) | |
| 62 | + | ||
| 63 | + | err := svc.renewDueCertificates(context.Background(), time.Now()) | |
| 64 | + | require.Error(t, err) | |
| 65 | + | assert.ErrorIs(t, err, domain.ErrCertificateStoreRequired) | |
| 66 | + | ||
| 67 | + | svc.deps.Store, _ = newMockCertificateStore(t) | |
| 68 | + | err = svc.renewDueCertificates(context.Background(), time.Now()) | |
| 69 | + | require.Error(t, err) | |
| 70 | + | assert.ErrorIs(t, err, domain.ErrCertificateIssuerRequired) | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | // --------------------------------------------------------------------------- | |
| 74 | + | // RenewalLoopStops | |
| 75 | + | // --------------------------------------------------------------------------- | |
| 76 | + | ||
| 77 | + | func TestRenewalLoopStops(t *testing.T) { | |
| 78 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 79 | + | ||
| 80 | + | cfg := Config{ | |
| 81 | + | Enabled: true, | |
| 82 | + | Email: "admin@example.com", | |
| 83 | + | Challenge: "http-01", | |
| 84 | + | HTTPPort: 8088, | |
| 85 | + | TLSPort: 8443, | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | issuer, _ := newMockPublicCertificateIssuer(t, nil, nil) | |
| 89 | + | store, _ := newMockCertificateStore(t) | |
| 90 | + | ||
| 91 | + | svc := NewService(cfg, ServiceDeps{ | |
| 92 | + | Config: cfg, | |
| 93 | + | Routes: &fakeRoutes{}, | |
| 94 | + | Issuer: issuer, | |
| 95 | + | Store: store, | |
| 96 | + | Challenges: NewHTTP01Challenges(), | |
| 97 | + | Effective: EffectiveChallenge{ | |
| 98 | + | ConfiguredMode: domain.ACMEChallengeHTTP01, | |
| 99 | + | Mode: domain.ACMEChallengeHTTP01, | |
| 100 | + | TokenSource: domain.ACMETokenSourceNone, | |
| 101 | + | Reason: "http-01 challenge selected", | |
| 102 | + | }, | |
| 103 | + | }) | |
| 104 | + | ||
| 105 | + | done := svc.StartRenewalLoop(ctx, 10*time.Millisecond) | |
| 106 | + | cancel() | |
| 107 | + | ||
| 108 | + | require.Eventually(t, func() bool { | |
| 109 | + | select { | |
| 110 | + | case <-done: | |
| 111 | + | return true | |
| 112 | + | default: | |
| 113 | + | return false | |
| 114 | + | } | |
| 115 | + | }, 5*time.Second, 10*time.Millisecond, "renewal loop should stop after context cancel") | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | // --------------------------------------------------------------------------- | |
| 119 | + | // RenewDueCertificates | |
| 120 | + | // --------------------------------------------------------------------------- | |
| 121 | + | ||
| 122 | + | func TestRenewDueCertificatesSavesAndUpdatesCache(t *testing.T) { | |
| 123 | + | ctx := context.Background() | |
| 124 | + | ||
| 125 | + | certID := "http01-app.example.com" | |
| 126 | + | now := time.Date(2026, 4, 29, 12, 0, 0, 0, time.UTC) | |
| 127 | + | notAfterSoon := now.Add(10 * 24 * time.Hour) // 10 days from now — due for renewal | |
| 128 | + | notAfterRenewed := now.Add(90 * 24 * time.Hour) // renewed cert | |
| 129 | + | ||
| 130 | + | // Generate a test certificate. | |
| 131 | + | certPEM, keyPEM, err := generateTestCertPEM([]string{"app.example.com"}) | |
| 132 | + | require.NoError(t, err) | |
| 133 | + | tlsCert, err := tls.X509KeyPair(certPEM, keyPEM) | |
| 134 | + | require.NoError(t, err) | |
| 135 | + | ||
| 136 | + | store, storeState := newMockCertificateStore(t, out.StoredCertificate{ | |
| 137 | + | ID: certID, | |
| 138 | + | Names: []string{"app.example.com"}, | |
| 139 | + | Challenge: domain.ACMEChallengeHTTP01, | |
| 140 | + | Certificate: tlsCert, | |
| 141 | + | FullchainPEM: certPEM, | |
| 142 | + | PrivateKeyPEM: keyPEM, | |
| 143 | + | NotAfter: notAfterSoon, | |
| 144 | + | }) | |
| 145 | + | ||
| 146 | + | issuer, _ := newMockPublicCertificateIssuer(t, nil, func(_ context.Context, cert out.StoredCertificate) (*out.StoredCertificate, error) { | |
| 147 | + | renewed := cert | |
| 148 | + | renewed.Certificate = tls.Certificate{} | |
| 149 | + | renewed.LastError = "previous renewal error" | |
| 150 | + | renewed.NotAfter = notAfterRenewed | |
| 151 | + | return &renewed, nil | |
| 152 | + | }) | |
| 153 | + | ||
| 154 | + | cfg := Config{ | |
| 155 | + | Enabled: true, | |
| 156 | + | Email: "admin@example.com", | |
| 157 | + | Challenge: "http-01", | |
| 158 | + | HTTPPort: 8088, | |
| 159 | + | TLSPort: 8443, | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | svc := NewService(cfg, ServiceDeps{ | |
| 163 | + | Config: cfg, | |
| 164 | + | Routes: &fakeRoutes{}, | |
| 165 | + | Issuer: issuer, | |
| 166 | + | Store: store, | |
| 167 | + | Challenges: NewHTTP01Challenges(), | |
| 168 | + | Effective: EffectiveChallenge{ | |
| 169 | + | ConfiguredMode: domain.ACMEChallengeHTTP01, | |
| 170 | + | Mode: domain.ACMEChallengeHTTP01, | |
| 171 | + | TokenSource: domain.ACMETokenSourceNone, | |
| 172 | + | Reason: "http-01 challenge selected", | |
| 173 | + | }, | |
| 174 | + | }) | |
| 175 | + | ||
| 176 | + | // Load certs into the cache from the store. | |
| 177 | + | err = svc.Load(ctx) | |
| 178 | + | require.NoError(t, err) | |
| 179 | + | ||
| 180 | + | // Verify cert is in cache with original NotAfter via Status. | |
| 181 | + | status := svc.Status(ctx) | |
| 182 | + | require.Len(t, status.Certificates, 1, "expected 1 managed certificate after Load") | |
| 183 | + | assert.True(t, status.Certificates[0].NotAfter.Equal(notAfterSoon), | |
| 184 | + | "cache should have original NotAfter (got %v, want %v)", | |
| 185 | + | status.Certificates[0].NotAfter, notAfterSoon) | |
| 186 | + | ||
| 187 | + | // Run renewDueCertificates. | |
| 188 | + | err = svc.renewDueCertificates(ctx, now) | |
| 189 | + | require.NoError(t, err) | |
| 190 | + | ||
| 191 | + | // Verify the store has the updated certificate. | |
| 192 | + | all := storeState.All() | |
| 193 | + | require.Len(t, all, 1) | |
| 194 | + | assert.True(t, all[0].NotAfter.Equal(notAfterRenewed), "store should have renewed NotAfter") | |
| 195 | + | assert.NotEmpty(t, all[0].Certificate.Certificate, "store should have normalized tls.Certificate") | |
| 196 | + | assert.Empty(t, all[0].LastError, "store should not persist stale LastError") | |
| 197 | + | ||
| 198 | + | // Verify the cache has the updated certificate via Status. | |
| 199 | + | status = svc.Status(ctx) | |
| 200 | + | require.Len(t, status.Certificates, 1, "expected 1 managed certificate after renewal") | |
| 201 | + | assert.True(t, status.Certificates[0].NotAfter.Equal(notAfterRenewed), | |
| 202 | + | "cache should have renewed NotAfter (got %v, want %v)", | |
| 203 | + | status.Certificates[0].NotAfter, notAfterRenewed) | |
| 204 | + | ||
| 205 | + | // Verify lastErr is empty for the renewed certificate. | |
| 206 | + | assert.Empty(t, status.Certificates[0].LastError, | |
| 207 | + | "LastError should be empty for renewed cert") | |
| 208 | + | } | |
A internal/usecase/publictls/service.go +465 -0
| @@ -0,0 +1,465 @@ | |||
| 1 | + | package publictls | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "crypto/tls" | |
| 6 | + | "fmt" | |
| 7 | + | "reflect" | |
| 8 | + | "strings" | |
| 9 | + | "sync" | |
| 10 | + | "time" | |
| 11 | + | ||
| 12 | + | "github.com/bnema/zerowrap" | |
| 13 | + | ||
| 14 | + | "github.com/bnema/gordon/internal/boundaries/out" | |
| 15 | + | "github.com/bnema/gordon/internal/domain" | |
| 16 | + | ) | |
| 17 | + | ||
| 18 | + | // RouteSource provides routes from which certificate targets are derived. | |
| 19 | + | type RouteSource interface { | |
| 20 | + | GetRoutes(ctx context.Context) []domain.Route | |
| 21 | + | GetExternalRoutes() map[string]string | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | // ServiceDeps contains the dependencies for the Service. | |
| 25 | + | type ServiceDeps struct { | |
| 26 | + | Config Config | |
| 27 | + | Routes RouteSource | |
| 28 | + | Issuer out.PublicCertificateIssuer | |
| 29 | + | Store out.CertificateStore | |
| 30 | + | ZoneResolver out.CloudflareZoneResolver | |
| 31 | + | Challenges *HTTP01Challenges | |
| 32 | + | Effective EffectiveChallenge | |
| 33 | + | Log zerowrap.Logger | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | // Service manages public TLS certificates via ACME. | |
| 37 | + | type Service struct { | |
| 38 | + | mu sync.RWMutex | |
| 39 | + | cfg Config | |
| 40 | + | deps ServiceDeps | |
| 41 | + | log zerowrap.Logger | |
| 42 | + | certs map[string]*out.StoredCertificate // indexed by cert ID | |
| 43 | + | lastErr map[string]string // indexed by cert ID | |
| 44 | + | routeErr map[string]string // indexed by host | |
| 45 | + | ||
| 46 | + | // requiredHosts is the set of hosts that must be covered by ACME certs. | |
| 47 | + | requiredHosts map[string]struct{} | |
| 48 | + | ||
| 49 | + | // cancel cancels the renewal loop context. | |
| 50 | + | cancel context.CancelFunc | |
| 51 | + | // done is closed when the renewal loop exits. | |
| 52 | + | done chan struct{} | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | // NewService creates a new public TLS Service. | |
| 56 | + | func NewService(cfg Config, deps ServiceDeps) *Service { | |
| 57 | + | if deps.Challenges == nil { | |
| 58 | + | deps.Challenges = NewHTTP01Challenges() | |
| 59 | + | } | |
| 60 | + | if reflect.ValueOf(deps.Log).IsZero() { | |
| 61 | + | deps.Log = zerowrap.Default() | |
| 62 | + | } | |
| 63 | + | return &Service{ | |
| 64 | + | cfg: cfg, | |
| 65 | + | deps: deps, | |
| 66 | + | log: deps.Log, | |
| 67 | + | certs: make(map[string]*out.StoredCertificate), | |
| 68 | + | lastErr: make(map[string]string), | |
| 69 | + | routeErr: make(map[string]string), | |
| 70 | + | requiredHosts: make(map[string]struct{}), | |
| 71 | + | } | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | // Load loads all stored certificates from the store into the internal cache. | |
| 75 | + | // If the store is nil, this is a no-op. | |
| 76 | + | func (s *Service) Load(ctx context.Context) error { | |
| 77 | + | if s.deps.Store == nil { | |
| 78 | + | return nil | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | stored, err := s.deps.Store.LoadAll(ctx) | |
| 82 | + | if err != nil { | |
| 83 | + | return fmt.Errorf("load stored certificates: %w", err) | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | s.mu.Lock() | |
| 87 | + | defer s.mu.Unlock() | |
| 88 | + | ||
| 89 | + | s.certs = make(map[string]*out.StoredCertificate, len(stored)) | |
| 90 | + | s.lastErr = make(map[string]string) | |
| 91 | + | s.requiredHosts = make(map[string]struct{}) | |
| 92 | + | ||
| 93 | + | for i := range stored { | |
| 94 | + | cert := &stored[i] | |
| 95 | + | if parseErr := populateStoredCertificate(cert); parseErr != nil { | |
| 96 | + | s.lastErr[cert.ID] = parseErr.Error() | |
| 97 | + | continue | |
| 98 | + | } | |
| 99 | + | s.certs[cert.ID] = cert | |
| 100 | + | addNamesToRequiredHosts(s.requiredHosts, cert.Names) | |
| 101 | + | if cert.LastError != "" { | |
| 102 | + | s.lastErr[cert.ID] = cert.LastError | |
| 103 | + | } | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | return nil | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | // Reconcile ensures all desired certificates are obtained and cached. | |
| 110 | + | // If ACME is disabled, it is a no-op. | |
| 111 | + | func (s *Service) Reconcile(ctx context.Context) error { | |
| 112 | + | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | |
| 113 | + | zerowrap.FieldLayer: "usecase", | |
| 114 | + | zerowrap.FieldUseCase: "Reconcile", | |
| 115 | + | }) | |
| 116 | + | log := zerowrap.FromCtx(ctx) | |
| 117 | + | ||
| 118 | + | if !s.cfg.Enabled { | |
| 119 | + | log.Debug().Msg("acme disabled, skipping reconcile") | |
| 120 | + | return nil | |
| 121 | + | } | |
| 122 | + | log.Debug().Msg("starting certificate reconciliation") | |
| 123 | + | ||
| 124 | + | if s.deps.Store == nil { | |
| 125 | + | log.Warn().Msg("certificate store is nil, cannot reconcile") | |
| 126 | + | return fmt.Errorf("%w: certificate store is nil", domain.ErrCertificateStoreRequired) | |
| 127 | + | } | |
| 128 | + | if s.deps.Issuer == nil { | |
| 129 | + | log.Warn().Msg("certificate issuer is nil, cannot reconcile") | |
| 130 | + | return fmt.Errorf("%w: certificate issuer is nil", domain.ErrCertificateIssuerRequired) | |
| 131 | + | } | |
| 132 | + | if s.deps.Routes == nil { | |
| 133 | + | log.Warn().Msg("route source is nil, cannot reconcile") | |
| 134 | + | return fmt.Errorf("%w: route source is nil", domain.ErrRouteSourceRequired) | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | // Determine effective challenge mode. | |
| 138 | + | effective := s.deps.Effective | |
| 139 | + | if effective.Mode == "" { | |
| 140 | + | resolved, err := ResolveEffectiveChallenge(ctx, s.cfg, nil) | |
| 141 | + | if err != nil { | |
| 142 | + | return fmt.Errorf("resolve effective challenge: %w", err) | |
| 143 | + | } | |
| 144 | + | effective = resolved | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | // Get route hosts early to build required hosts set before target derivation. | |
| 148 | + | // This ensures GetCertificate returns ErrTLSRouteNotCovered even if | |
| 149 | + | // DeriveCertificateTargets fails (e.g. broken DNS-01 zone resolver). | |
| 150 | + | routes := s.deps.Routes.GetRoutes(ctx) | |
| 151 | + | external := s.deps.Routes.GetExternalRoutes() | |
| 152 | + | hosts := routeHosts(routes, external) | |
| 153 | + | ||
| 154 | + | // Build required hosts set from route hosts (before target derivation). | |
| 155 | + | required := canonicalHostSet(hosts) | |
| 156 | + | ||
| 157 | + | // Set requiredHosts before DeriveCertificateTargets so GetCertificate | |
| 158 | + | // returns ErrTLSRouteNotCovered if target derivation/issuer fails. | |
| 159 | + | s.mu.Lock() | |
| 160 | + | s.requiredHosts = required | |
| 161 | + | clearRouteErrorsLocked(s.routeErr, required) | |
| 162 | + | s.mu.Unlock() | |
| 163 | + | ||
| 164 | + | // Derive desired targets. | |
| 165 | + | targets, err := DeriveCertificateTargets(ctx, effective.Mode, | |
| 166 | + | routes, external, | |
| 167 | + | s.deps.ZoneResolver, | |
| 168 | + | ) | |
| 169 | + | if err != nil { | |
| 170 | + | s.mu.Lock() | |
| 171 | + | setRouteErrorsLocked(s.routeErr, required, fmt.Sprintf("derive certificate targets: %v", err)) | |
| 172 | + | s.mu.Unlock() | |
| 173 | + | return fmt.Errorf("derive certificate targets: %w", err) | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | // Merge target names into required hosts set. | |
| 177 | + | // For DNS-01 wildcard targets, this adds wildcard entries as well; | |
| 178 | + | // the exact route hosts already present suffice for isRequiredHostLocked. | |
| 179 | + | addTargetNames(required, targets) | |
| 180 | + | ||
| 181 | + | // Under mu: update requiredHosts (with target names) and compute missing targets. | |
| 182 | + | s.mu.Lock() | |
| 183 | + | s.requiredHosts = required | |
| 184 | + | ||
| 185 | + | missing := make([]CertificateTarget, 0) | |
| 186 | + | for _, target := range targets { | |
| 187 | + | if s.certificateExistsLocked(target) { | |
| 188 | + | continue | |
| 189 | + | } | |
| 190 | + | missing = append(missing, target) | |
| 191 | + | } | |
| 192 | + | s.mu.Unlock() | |
| 193 | + | ||
| 194 | + | // Process missing targets without holding s.mu or the store lock so | |
| 195 | + | // GetCertificate/Status are not blocked during network I/O (Obtain). | |
| 196 | + | s.reconcileMissingTargets(ctx, missing, required) | |
| 197 | + | ||
| 198 | + | log.Debug().Int("missing_count", len(missing)).Msg("reconciled missing targets") | |
| 199 | + | return nil | |
| 200 | + | } | |
| 201 | + | ||
| 202 | + | func populateStoredCertificate(cert *out.StoredCertificate) error { | |
| 203 | + | if cert == nil { | |
| 204 | + | return fmt.Errorf("stored certificate is nil") | |
| 205 | + | } | |
| 206 | + | if len(cert.Certificate.Certificate) == 0 && len(cert.FullchainPEM) > 0 && len(cert.PrivateKeyPEM) > 0 { | |
| 207 | + | parsed, err := tls.X509KeyPair(cert.FullchainPEM, cert.PrivateKeyPEM) | |
| 208 | + | if err != nil { | |
| 209 | + | return fmt.Errorf("parse stored certificate: %w", err) | |
| 210 | + | } | |
| 211 | + | cert.Certificate = parsed | |
| 212 | + | } | |
| 213 | + | if len(cert.Certificate.Certificate) == 0 { | |
| 214 | + | return fmt.Errorf("stored certificate is empty") | |
| 215 | + | } | |
| 216 | + | return nil | |
| 217 | + | } | |
| 218 | + | ||
| 219 | + | func canonicalHostSet(hosts []string) map[string]struct{} { | |
| 220 | + | required := make(map[string]struct{}, len(hosts)) | |
| 221 | + | for _, host := range hosts { | |
| 222 | + | host = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(host)), ".") | |
| 223 | + | if host != "" { | |
| 224 | + | required[host] = struct{}{} | |
| 225 | + | } | |
| 226 | + | } | |
| 227 | + | return required | |
| 228 | + | } | |
| 229 | + | ||
| 230 | + | func addNamesToRequiredHosts(required map[string]struct{}, names []string) { | |
| 231 | + | for _, name := range names { | |
| 232 | + | name = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(name)), ".") | |
| 233 | + | if name != "" { | |
| 234 | + | required[name] = struct{}{} | |
| 235 | + | } | |
| 236 | + | } | |
| 237 | + | } | |
| 238 | + | ||
| 239 | + | func addTargetNames(required map[string]struct{}, targets []CertificateTarget) { | |
| 240 | + | for _, t := range targets { | |
| 241 | + | addNamesToRequiredHosts(required, t.Names) | |
| 242 | + | } | |
| 243 | + | } | |
| 244 | + | ||
| 245 | + | func clearRouteErrorsLocked(routeErr map[string]string, hosts map[string]struct{}) { | |
| 246 | + | for host := range hosts { | |
| 247 | + | delete(routeErr, host) | |
| 248 | + | } | |
| 249 | + | } | |
| 250 | + | ||
| 251 | + | func setRouteErrorsLocked(routeErr map[string]string, hosts map[string]struct{}, message string) { | |
| 252 | + | for host := range hosts { | |
| 253 | + | routeErr[host] = message | |
| 254 | + | } | |
| 255 | + | } | |
| 256 | + | ||
| 257 | + | func setTargetRouteErrorLocked(routeErr map[string]string, required map[string]struct{}, target CertificateTarget, message string) { | |
| 258 | + | for host := range required { | |
| 259 | + | if hostMatchesCert(target.Names, host) { | |
| 260 | + | routeErr[host] = message | |
| 261 | + | } | |
| 262 | + | } | |
| 263 | + | } | |
| 264 | + | ||
| 265 | + | func clearTargetRouteErrorLocked(routeErr map[string]string, required map[string]struct{}, target CertificateTarget) { | |
| 266 | + | for host := range required { | |
| 267 | + | if hostMatchesCert(target.Names, host) { | |
| 268 | + | delete(routeErr, host) | |
| 269 | + | } | |
| 270 | + | } | |
| 271 | + | } | |
| 272 | + | ||
| 273 | + | // reconcileMissingTargets obtains and saves certificates for each missing | |
| 274 | + | // target. Called without s.mu held so network I/O does not block readers. | |
| 275 | + | func (s *Service) reconcileMissingTargets(ctx context.Context, missing []CertificateTarget, required map[string]struct{}) { | |
| 276 | + | log := zerowrap.FromCtx(ctx) | |
| 277 | + | for _, target := range missing { | |
| 278 | + | order := out.CertificateOrder{ | |
| 279 | + | ID: target.ID, | |
| 280 | + | Names: target.Names, | |
| 281 | + | Challenge: target.Challenge, | |
| 282 | + | } | |
| 283 | + | ||
| 284 | + | stored, err := s.deps.Issuer.Obtain(ctx, order) | |
| 285 | + | if err != nil { | |
| 286 | + | errorMessage := fmt.Sprintf("obtain certificate: %v", err) | |
| 287 | + | s.mu.Lock() | |
| 288 | + | s.lastErr[target.ID] = errorMessage | |
| 289 | + | setTargetRouteErrorLocked(s.routeErr, required, target, errorMessage) | |
| 290 | + | s.mu.Unlock() | |
| 291 | + | continue | |
| 292 | + | } | |
| 293 | + | if stored == nil { | |
| 294 | + | errorMessage := "obtain certificate returned nil" | |
| 295 | + | s.mu.Lock() | |
| 296 | + | s.lastErr[target.ID] = errorMessage | |
| 297 | + | setTargetRouteErrorLocked(s.routeErr, required, target, errorMessage) | |
| 298 | + | s.mu.Unlock() | |
| 299 | + | continue | |
| 300 | + | } | |
| 301 | + | if err := populateStoredCertificate(stored); err != nil { | |
| 302 | + | errorMessage := err.Error() | |
| 303 | + | s.mu.Lock() | |
| 304 | + | s.lastErr[target.ID] = errorMessage | |
| 305 | + | setTargetRouteErrorLocked(s.routeErr, required, target, errorMessage) | |
| 306 | + | s.mu.Unlock() | |
| 307 | + | continue | |
| 308 | + | } | |
| 309 | + | ||
| 310 | + | unlock, err := s.deps.Store.Lock(ctx) | |
| 311 | + | if err != nil { | |
| 312 | + | errorMessage := fmt.Sprintf("acquire store lock: %v", err) | |
| 313 | + | s.mu.Lock() | |
| 314 | + | s.lastErr[target.ID] = errorMessage | |
| 315 | + | setTargetRouteErrorLocked(s.routeErr, required, target, errorMessage) | |
| 316 | + | s.mu.Unlock() | |
| 317 | + | continue | |
| 318 | + | } | |
| 319 | + | saveErr := s.deps.Store.Save(ctx, *stored) | |
| 320 | + | if unlockErr := unlock(); unlockErr != nil { | |
| 321 | + | log.Warn().Err(unlockErr).Msg("failed to release store lock") | |
| 322 | + | } | |
| 323 | + | if saveErr != nil { | |
| 324 | + | errorMessage := fmt.Sprintf("save certificate: %v", saveErr) | |
| 325 | + | s.mu.Lock() | |
| 326 | + | s.lastErr[target.ID] = errorMessage | |
| 327 | + | setTargetRouteErrorLocked(s.routeErr, required, target, errorMessage) | |
| 328 | + | s.mu.Unlock() | |
| 329 | + | continue | |
| 330 | + | } | |
| 331 | + | ||
| 332 | + | s.mu.Lock() | |
| 333 | + | s.certs[stored.ID] = stored | |
| 334 | + | delete(s.lastErr, stored.ID) | |
| 335 | + | clearTargetRouteErrorLocked(s.routeErr, required, target) | |
| 336 | + | s.mu.Unlock() | |
| 337 | + | } | |
| 338 | + | } | |
| 339 | + | ||
| 340 | + | // certificateExistsLocked checks if a valid non-expired cached certificate | |
| 341 | + | // covers the target. Must be called with s.mu held. | |
| 342 | + | func (s *Service) certificateExistsLocked(target CertificateTarget) bool { | |
| 343 | + | now := time.Now() | |
| 344 | + | for _, cert := range s.certs { | |
| 345 | + | if cert.NotAfter.IsZero() || now.After(cert.NotAfter) { | |
| 346 | + | continue // expired or no expiry | |
| 347 | + | } | |
| 348 | + | if exactNamesCoverAll(cert.Names, target.Names) { | |
| 349 | + | return true | |
| 350 | + | } | |
| 351 | + | } | |
| 352 | + | return false | |
| 353 | + | } | |
| 354 | + | ||
| 355 | + | // exactNamesCoverAll reports whether names contains every required name exactly, case-insensitively. | |
| 356 | + | func exactNamesCoverAll(names, required []string) bool { | |
| 357 | + | covered := make(map[string]bool) | |
| 358 | + | for _, n := range names { | |
| 359 | + | covered[strings.ToLower(strings.TrimSuffix(n, "."))] = true | |
| 360 | + | } | |
| 361 | + | for _, r := range required { | |
| 362 | + | r = strings.ToLower(strings.TrimSuffix(r, ".")) | |
| 363 | + | if !covered[r] { | |
| 364 | + | return false | |
| 365 | + | } | |
| 366 | + | } | |
| 367 | + | return true | |
| 368 | + | } | |
| 369 | + | ||
| 370 | + | // hostMatchesCert checks if any name in the given list matches host, | |
| 371 | + | // including wildcard matching. | |
| 372 | + | func hostMatchesCert(names []string, host string) bool { | |
| 373 | + | return domain.CertificateNamesCoverHost(names, host) | |
| 374 | + | } | |
| 375 | + | ||
| 376 | + | // GetCertificateForHost returns a TLS certificate for the given SNI host. | |
| 377 | + | // | |
| 378 | + | // If the SNI host does not require ACME coverage, returns nil, nil. | |
| 379 | + | // If ACME is required but no cert covers the host, returns | |
| 380 | + | // ErrTLSRouteNotCovered. | |
| 381 | + | // If a valid cert is found, returns a pointer to it. | |
| 382 | + | func (s *Service) GetCertificateForHost(host string) (*tls.Certificate, error) { | |
| 383 | + | host = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(host)), ".") | |
| 384 | + | if host == "" { | |
| 385 | + | return nil, nil | |
| 386 | + | } | |
| 387 | + | ||
| 388 | + | s.mu.RLock() | |
| 389 | + | defer s.mu.RUnlock() | |
| 390 | + | ||
| 391 | + | // Check if this host requires ACME coverage. | |
| 392 | + | if !s.isRequiredHostLocked(host) { | |
| 393 | + | return nil, nil | |
| 394 | + | } | |
| 395 | + | ||
| 396 | + | // Look for a valid non-expired cached certificate covering this host. | |
| 397 | + | now := time.Now() | |
| 398 | + | for _, cert := range s.certs { | |
| 399 | + | if cert.NotAfter.IsZero() || now.After(cert.NotAfter) { | |
| 400 | + | continue | |
| 401 | + | } | |
| 402 | + | if hostMatchesCert(cert.Names, host) { | |
| 403 | + | return &cert.Certificate, nil | |
| 404 | + | } | |
| 405 | + | } | |
| 406 | + | ||
| 407 | + | return nil, fmt.Errorf("%w: %s", domain.ErrTLSRouteNotCovered, host) | |
| 408 | + | } | |
| 409 | + | ||
| 410 | + | // isRequiredHostLocked checks if host or any wildcard covering it is in | |
| 411 | + | // the required hosts set. Must be called with s.mu held. | |
| 412 | + | func (s *Service) isRequiredHostLocked(host string) bool { | |
| 413 | + | if _, ok := s.requiredHosts[host]; ok { | |
| 414 | + | return true | |
| 415 | + | } | |
| 416 | + | // Check wildcard: split at the first dot. | |
| 417 | + | parts := strings.SplitN(host, ".", 2) | |
| 418 | + | if len(parts) == 2 { | |
| 419 | + | if _, ok := s.requiredHosts["*."+parts[1]]; ok { | |
| 420 | + | return true | |
| 421 | + | } | |
| 422 | + | } | |
| 423 | + | return false | |
| 424 | + | } | |
| 425 | + | ||
| 426 | + | // GetHTTP01Challenge delegates to the HTTP-01 challenge store. | |
| 427 | + | func (s *Service) GetHTTP01Challenge(ctx context.Context, token string) (string, bool) { | |
| 428 | + | return s.deps.Challenges.Get(ctx, token) | |
| 429 | + | } | |
| 430 | + | ||
| 431 | + | // getStoredCertificate returns a copy of the stored certificate for the given | |
| 432 | + | // ID, or nil if not found. | |
| 433 | + | func (s *Service) getStoredCertificate(id string) *out.StoredCertificate { | |
| 434 | + | s.mu.RLock() | |
| 435 | + | defer s.mu.RUnlock() | |
| 436 | + | cert := s.certs[id] | |
| 437 | + | if cert == nil { | |
| 438 | + | return nil | |
| 439 | + | } | |
| 440 | + | cpy := *cert | |
| 441 | + | return &cpy | |
| 442 | + | } | |
| 443 | + | ||
| 444 | + | // Stop gracefully stops the service. If a renewal loop is running, it is | |
| 445 | + | // cancelled and Stop waits for it to finish (subject to ctx deadline). | |
| 446 | + | func (s *Service) Stop(ctx context.Context) error { | |
| 447 | + | s.mu.Lock() | |
| 448 | + | cancel := s.cancel | |
| 449 | + | done := s.done | |
| 450 | + | s.cancel = nil | |
| 451 | + | s.done = nil | |
| 452 | + | s.mu.Unlock() | |
| 453 | + | ||
| 454 | + | if cancel != nil { | |
| 455 | + | cancel() | |
| 456 | + | } | |
| 457 | + | if done != nil { | |
| 458 | + | select { | |
| 459 | + | case <-done: | |
| 460 | + | case <-ctx.Done(): | |
| 461 | + | return ctx.Err() | |
| 462 | + | } | |
| 463 | + | } | |
| 464 | + | return nil | |
| 465 | + | } | |
A internal/usecase/publictls/service_test.go +687 -0
| @@ -0,0 +1,687 @@ | |||
| 1 | + | package publictls | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "crypto/rand" | |
| 6 | + | "crypto/rsa" | |
| 7 | + | "crypto/tls" | |
| 8 | + | "crypto/x509" | |
| 9 | + | "crypto/x509/pkix" | |
| 10 | + | "encoding/pem" | |
| 11 | + | "fmt" | |
| 12 | + | "maps" | |
| 13 | + | "math/big" | |
| 14 | + | "sync" | |
| 15 | + | "testing" | |
| 16 | + | "time" | |
| 17 | + | ||
| 18 | + | "github.com/stretchr/testify/assert" | |
| 19 | + | "github.com/stretchr/testify/mock" | |
| 20 | + | "github.com/stretchr/testify/require" | |
| 21 | + | ||
| 22 | + | "github.com/bnema/gordon/internal/boundaries/out" | |
| 23 | + | outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks" | |
| 24 | + | "github.com/bnema/gordon/internal/domain" | |
| 25 | + | ) | |
| 26 | + | ||
| 27 | + | // --------------------------------------------------------------------------- | |
| 28 | + | // Fakes | |
| 29 | + | // --------------------------------------------------------------------------- | |
| 30 | + | ||
| 31 | + | // fakeRoutes models a stateful RouteSource using an internal mutex and | |
| 32 | + | // copy-on-read semantics. Generated mocks cannot easily replicate this | |
| 33 | + | // pattern because the interface methods return slices/maps by value and | |
| 34 | + | // callers may modify the returned data; a hand-rolled fake ensures each | |
| 35 | + | // GetRoutes/GetExternalRoutes call returns a defensive copy. | |
| 36 | + | type fakeRoutes struct { | |
| 37 | + | mu sync.Mutex | |
| 38 | + | routes []domain.Route | |
| 39 | + | external map[string]string | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | func (f *fakeRoutes) GetRoutes(_ context.Context) []domain.Route { | |
| 43 | + | f.mu.Lock() | |
| 44 | + | defer f.mu.Unlock() | |
| 45 | + | routesCopy := make([]domain.Route, len(f.routes)) | |
| 46 | + | copy(routesCopy, f.routes) | |
| 47 | + | return routesCopy | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | func (f *fakeRoutes) GetExternalRoutes() map[string]string { | |
| 51 | + | f.mu.Lock() | |
| 52 | + | defer f.mu.Unlock() | |
| 53 | + | externalCopy := make(map[string]string, len(f.external)) | |
| 54 | + | maps.Copy(externalCopy, f.external) | |
| 55 | + | return externalCopy | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | type mockIssuerRecorder struct { | |
| 59 | + | mu sync.Mutex | |
| 60 | + | orders []out.CertificateOrder | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | func (r *mockIssuerRecorder) record(order out.CertificateOrder) { | |
| 64 | + | r.mu.Lock() | |
| 65 | + | defer r.mu.Unlock() | |
| 66 | + | r.orders = append(r.orders, order) | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | func (r *mockIssuerRecorder) Orders() []out.CertificateOrder { | |
| 70 | + | r.mu.Lock() | |
| 71 | + | defer r.mu.Unlock() | |
| 72 | + | orders := make([]out.CertificateOrder, len(r.orders)) | |
| 73 | + | copy(orders, r.orders) | |
| 74 | + | return orders | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | func newMockPublicCertificateIssuer( | |
| 78 | + | t *testing.T, | |
| 79 | + | obtain func(context.Context, out.CertificateOrder) (*out.StoredCertificate, error), | |
| 80 | + | renew func(context.Context, out.StoredCertificate) (*out.StoredCertificate, error), | |
| 81 | + | ) (*outmocks.MockPublicCertificateIssuer, *mockIssuerRecorder) { | |
| 82 | + | t.Helper() | |
| 83 | + | recorder := &mockIssuerRecorder{} | |
| 84 | + | issuer := outmocks.NewMockPublicCertificateIssuer(t) | |
| 85 | + | ||
| 86 | + | obtainCall := issuer.EXPECT().Obtain(mock.Anything, mock.Anything).RunAndReturn( | |
| 87 | + | func(ctx context.Context, order out.CertificateOrder) (*out.StoredCertificate, error) { | |
| 88 | + | recorder.record(order) | |
| 89 | + | if obtain != nil { | |
| 90 | + | return obtain(ctx, order) | |
| 91 | + | } | |
| 92 | + | return defaultStoredCert(order) | |
| 93 | + | }, | |
| 94 | + | ) | |
| 95 | + | obtainCall.Maybe() | |
| 96 | + | ||
| 97 | + | renewCall := issuer.EXPECT().Renew(mock.Anything, mock.Anything).RunAndReturn( | |
| 98 | + | func(ctx context.Context, cert out.StoredCertificate) (*out.StoredCertificate, error) { | |
| 99 | + | if renew != nil { | |
| 100 | + | return renew(ctx, cert) | |
| 101 | + | } | |
| 102 | + | return &cert, nil | |
| 103 | + | }, | |
| 104 | + | ) | |
| 105 | + | renewCall.Maybe() | |
| 106 | + | ||
| 107 | + | return issuer, recorder | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | // defaultStoredCert creates a StoredCertificate with a self-signed TLS cert | |
| 111 | + | // for the given order. | |
| 112 | + | func defaultStoredCert(order out.CertificateOrder) (*out.StoredCertificate, error) { | |
| 113 | + | certPEM, keyPEM, err := generateTestCertPEM(order.Names) | |
| 114 | + | if err != nil { | |
| 115 | + | return nil, err | |
| 116 | + | } | |
| 117 | + | tlsCert, err := tls.X509KeyPair(certPEM, keyPEM) | |
| 118 | + | if err != nil { | |
| 119 | + | return nil, err | |
| 120 | + | } | |
| 121 | + | return &out.StoredCertificate{ | |
| 122 | + | ID: order.ID, | |
| 123 | + | Names: order.Names, | |
| 124 | + | Challenge: order.Challenge, | |
| 125 | + | Certificate: tlsCert, | |
| 126 | + | FullchainPEM: certPEM, | |
| 127 | + | PrivateKeyPEM: keyPEM, | |
| 128 | + | NotAfter: time.Now().Add(90 * 24 * time.Hour), | |
| 129 | + | }, nil | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | type mockCertificateStoreState struct { | |
| 133 | + | mu sync.Mutex | |
| 134 | + | account *out.ACMEAccount | |
| 135 | + | certs []out.StoredCertificate | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | func (s *mockCertificateStoreState) All() []out.StoredCertificate { | |
| 139 | + | s.mu.Lock() | |
| 140 | + | defer s.mu.Unlock() | |
| 141 | + | certs := make([]out.StoredCertificate, len(s.certs)) | |
| 142 | + | copy(certs, s.certs) | |
| 143 | + | return certs | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | func newMockCertificateStore(t *testing.T, initial ...out.StoredCertificate) (*outmocks.MockCertificateStore, *mockCertificateStoreState) { | |
| 147 | + | t.Helper() | |
| 148 | + | state := &mockCertificateStoreState{certs: append([]out.StoredCertificate(nil), initial...)} | |
| 149 | + | store := outmocks.NewMockCertificateStore(t) | |
| 150 | + | ||
| 151 | + | loadAccountCall := store.EXPECT().LoadAccount(mock.Anything).RunAndReturn(func(context.Context) (*out.ACMEAccount, error) { | |
| 152 | + | state.mu.Lock() | |
| 153 | + | defer state.mu.Unlock() | |
| 154 | + | if state.account == nil { | |
| 155 | + | return nil, nil | |
| 156 | + | } | |
| 157 | + | account := *state.account | |
| 158 | + | return &account, nil | |
| 159 | + | }) | |
| 160 | + | loadAccountCall.Maybe() | |
| 161 | + | ||
| 162 | + | saveAccountCall := store.EXPECT().SaveAccount(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, account out.ACMEAccount) error { | |
| 163 | + | state.mu.Lock() | |
| 164 | + | defer state.mu.Unlock() | |
| 165 | + | accountCopy := account | |
| 166 | + | state.account = &accountCopy | |
| 167 | + | return nil | |
| 168 | + | }) | |
| 169 | + | saveAccountCall.Maybe() | |
| 170 | + | ||
| 171 | + | loadAllCall := store.EXPECT().LoadAll(mock.Anything).RunAndReturn(func(context.Context) ([]out.StoredCertificate, error) { | |
| 172 | + | return state.All(), nil | |
| 173 | + | }) | |
| 174 | + | loadAllCall.Maybe() | |
| 175 | + | ||
| 176 | + | saveCall := store.EXPECT().Save(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, cert out.StoredCertificate) error { | |
| 177 | + | state.mu.Lock() | |
| 178 | + | defer state.mu.Unlock() | |
| 179 | + | for i, existing := range state.certs { | |
| 180 | + | if existing.ID == cert.ID { | |
| 181 | + | state.certs[i] = cert | |
| 182 | + | return nil | |
| 183 | + | } | |
| 184 | + | } | |
| 185 | + | state.certs = append(state.certs, cert) | |
| 186 | + | return nil | |
| 187 | + | }) | |
| 188 | + | saveCall.Maybe() | |
| 189 | + | ||
| 190 | + | lockCall := store.EXPECT().Lock(mock.Anything).Return(func() error { return nil }, nil) | |
| 191 | + | lockCall.Maybe() | |
| 192 | + | ||
| 193 | + | return store, state | |
| 194 | + | } | |
| 195 | + | ||
| 196 | + | // --------------------------------------------------------------------------- | |
| 197 | + | // Test certificate helpers | |
| 198 | + | // --------------------------------------------------------------------------- | |
| 199 | + | ||
| 200 | + | // generateTestCertPEM generates a self-signed certificate for the given names. | |
| 201 | + | // Returns PEM-encoded fullchain and private key. | |
| 202 | + | func generateTestCertPEM(names []string) (certPEM []byte, keyPEM []byte, err error) { | |
| 203 | + | if len(names) == 0 { | |
| 204 | + | names = []string{"localhost"} | |
| 205 | + | } | |
| 206 | + | ||
| 207 | + | key, err := rsa.GenerateKey(rand.Reader, 2048) | |
| 208 | + | if err != nil { | |
| 209 | + | return nil, nil, fmt.Errorf("generate key: %w", err) | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) | |
| 213 | + | if err != nil { | |
| 214 | + | return nil, nil, fmt.Errorf("generate serial: %w", err) | |
| 215 | + | } | |
| 216 | + | ||
| 217 | + | now := time.Now() | |
| 218 | + | tmpl := &x509.Certificate{ | |
| 219 | + | SerialNumber: serial, | |
| 220 | + | Subject: pkix.Name{ | |
| 221 | + | CommonName: names[0], | |
| 222 | + | }, | |
| 223 | + | NotBefore: now.Add(-1 * time.Hour), | |
| 224 | + | NotAfter: now.Add(90 * 24 * time.Hour), | |
| 225 | + | KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, | |
| 226 | + | ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, | |
| 227 | + | BasicConstraintsValid: true, | |
| 228 | + | DNSNames: names, | |
| 229 | + | } | |
| 230 | + | ||
| 231 | + | certDER, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key) | |
| 232 | + | if err != nil { | |
| 233 | + | return nil, nil, fmt.Errorf("create certificate: %w", err) | |
| 234 | + | } | |
| 235 | + | ||
| 236 | + | certPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}) | |
| 237 | + | if certPEM == nil { | |
| 238 | + | return nil, nil, fmt.Errorf("encode cert PEM") | |
| 239 | + | } | |
| 240 | + | ||
| 241 | + | keyDER := x509.MarshalPKCS1PrivateKey(key) | |
| 242 | + | keyPEM = pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: keyDER}) | |
| 243 | + | if keyPEM == nil { | |
| 244 | + | return nil, nil, fmt.Errorf("encode key PEM") | |
| 245 | + | } | |
| 246 | + | ||
| 247 | + | return certPEM, keyPEM, nil | |
| 248 | + | } | |
| 249 | + | ||
| 250 | + | // --------------------------------------------------------------------------- | |
| 251 | + | // Tests | |
| 252 | + | // --------------------------------------------------------------------------- | |
| 253 | + | ||
| 254 | + | func TestServiceReconcileObtainsMissingHTTP01Cert(t *testing.T) { | |
| 255 | + | ctx := context.Background() | |
| 256 | + | ||
| 257 | + | routes := &fakeRoutes{ | |
| 258 | + | routes: []domain.Route{ | |
| 259 | + | {Domain: "app.example.com"}, | |
| 260 | + | }, | |
| 261 | + | } | |
| 262 | + | issuer, recorder := newMockPublicCertificateIssuer(t, nil, nil) | |
| 263 | + | store, storeState := newMockCertificateStore(t) | |
| 264 | + | ||
| 265 | + | cfg := Config{ | |
| 266 | + | Enabled: true, | |
| 267 | + | Email: "admin@example.com", | |
| 268 | + | Challenge: "http-01", | |
| 269 | + | HTTPPort: 8088, | |
| 270 | + | TLSPort: 8443, | |
| 271 | + | } | |
| 272 | + | ||
| 273 | + | svc := NewService(cfg, ServiceDeps{ | |
| 274 | + | Config: cfg, | |
| 275 | + | Routes: routes, | |
| 276 | + | Issuer: issuer, | |
| 277 | + | Store: store, | |
| 278 | + | Challenges: NewHTTP01Challenges(), | |
| 279 | + | Effective: EffectiveChallenge{ | |
| 280 | + | ConfiguredMode: domain.ACMEChallengeHTTP01, | |
| 281 | + | Mode: domain.ACMEChallengeHTTP01, | |
| 282 | + | TokenSource: domain.ACMETokenSourceNone, | |
| 283 | + | Reason: "http-01 challenge selected", | |
| 284 | + | }, | |
| 285 | + | }) | |
| 286 | + | ||
| 287 | + | // Load (nothing stored yet). | |
| 288 | + | err := svc.Load(ctx) | |
| 289 | + | require.NoError(t, err) | |
| 290 | + | ||
| 291 | + | // Reconcile — should obtain one certificate for app.example.com. | |
| 292 | + | err = svc.Reconcile(ctx) | |
| 293 | + | require.NoError(t, err) | |
| 294 | + | ||
| 295 | + | // Verify the issuer received exactly one order. | |
| 296 | + | orders := recorder.Orders() | |
| 297 | + | ||
| 298 | + | require.Len(t, orders, 1, "expected one order to be placed") | |
| 299 | + | assert.Equal(t, "http01-app.example.com", orders[0].ID) | |
| 300 | + | assert.Equal(t, []string{"app.example.com"}, orders[0].Names) | |
| 301 | + | assert.Equal(t, domain.ACMEChallengeHTTP01, orders[0].Challenge) | |
| 302 | + | ||
| 303 | + | // Verify the store has the saved certificate. | |
| 304 | + | stored := storeState.All() | |
| 305 | + | require.Len(t, stored, 1) | |
| 306 | + | assert.Equal(t, "http01-app.example.com", stored[0].ID) | |
| 307 | + | assert.Equal(t, []string{"app.example.com"}, stored[0].Names) | |
| 308 | + | assert.False(t, stored[0].NotAfter.IsZero()) | |
| 309 | + | assert.NotEmpty(t, stored[0].Certificate.Certificate) | |
| 310 | + | } | |
| 311 | + | ||
| 312 | + | func TestServiceReconcileResolvesMissingEffectiveChallenge(t *testing.T) { | |
| 313 | + | ctx := context.Background() | |
| 314 | + | routes := &fakeRoutes{routes: []domain.Route{{Domain: "app.example.com"}}} | |
| 315 | + | issuer, _ := newMockPublicCertificateIssuer(t, nil, nil) | |
| 316 | + | store, _ := newMockCertificateStore(t) | |
| 317 | + | cfg := Config{ | |
| 318 | + | Enabled: true, | |
| 319 | + | Email: "admin@example.com", | |
| 320 | + | Challenge: "http-01", | |
| 321 | + | HTTPPort: 8088, | |
| 322 | + | // Missing TLSPort should be caught by ResolveEffectiveChallenge rather | |
| 323 | + | // than silently defaulting an effective mode. | |
| 324 | + | } | |
| 325 | + | ||
| 326 | + | svc := NewService(cfg, ServiceDeps{ | |
| 327 | + | Config: cfg, | |
| 328 | + | Routes: routes, | |
| 329 | + | Issuer: issuer, | |
| 330 | + | Store: store, | |
| 331 | + | }) | |
| 332 | + | ||
| 333 | + | err := svc.Reconcile(ctx) | |
| 334 | + | require.Error(t, err) | |
| 335 | + | assert.ErrorIs(t, err, domain.ErrACMEChallengeInvalid) | |
| 336 | + | } | |
| 337 | + | ||
| 338 | + | func TestServiceStatusReportsCoverage(t *testing.T) { | |
| 339 | + | ctx := context.Background() | |
| 340 | + | ||
| 341 | + | // Pre-populate the store with a valid certificate. | |
| 342 | + | certPEM, keyPEM, err := generateTestCertPEM([]string{"app.example.com"}) | |
| 343 | + | require.NoError(t, err) | |
| 344 | + | tlsCert, err := tls.X509KeyPair(certPEM, keyPEM) | |
| 345 | + | require.NoError(t, err) | |
| 346 | + | ||
| 347 | + | store, _ := newMockCertificateStore(t, out.StoredCertificate{ | |
| 348 | + | ID: "http01-app.example.com", | |
| 349 | + | Names: []string{"app.example.com"}, | |
| 350 | + | Challenge: domain.ACMEChallengeHTTP01, | |
| 351 | + | Certificate: tlsCert, | |
| 352 | + | FullchainPEM: certPEM, | |
| 353 | + | PrivateKeyPEM: keyPEM, | |
| 354 | + | NotAfter: time.Now().Add(90 * 24 * time.Hour), | |
| 355 | + | }) | |
| 356 | + | ||
| 357 | + | routes := &fakeRoutes{ | |
| 358 | + | routes: []domain.Route{ | |
| 359 | + | {Domain: "app.example.com"}, | |
| 360 | + | }, | |
| 361 | + | } | |
| 362 | + | cfg := Config{ | |
| 363 | + | Enabled: true, | |
| 364 | + | Email: "admin@example.com", | |
| 365 | + | Challenge: "http-01", | |
| 366 | + | HTTPPort: 8088, | |
| 367 | + | TLSPort: 8443, | |
| 368 | + | } | |
| 369 | + | ||
| 370 | + | svc := NewService(cfg, ServiceDeps{ | |
| 371 | + | Config: cfg, | |
| 372 | + | Routes: routes, | |
| 373 | + | Store: store, | |
| 374 | + | Effective: EffectiveChallenge{ | |
| 375 | + | ConfiguredMode: domain.ACMEChallengeHTTP01, | |
| 376 | + | Mode: domain.ACMEChallengeHTTP01, | |
| 377 | + | TokenSource: domain.ACMETokenSourceNone, | |
| 378 | + | Reason: "http-01 challenge selected", | |
| 379 | + | }, | |
| 380 | + | }) | |
| 381 | + | ||
| 382 | + | // Load the cert into the cache. | |
| 383 | + | err = svc.Load(ctx) | |
| 384 | + | require.NoError(t, err) | |
| 385 | + | ||
| 386 | + | // Get status. | |
| 387 | + | status := svc.Status(ctx) | |
| 388 | + | ||
| 389 | + | assert.True(t, status.ACMEEnabled) | |
| 390 | + | assert.Equal(t, domain.ACMEChallengeHTTP01, status.ConfiguredMode) | |
| 391 | + | assert.Equal(t, domain.ACMEChallengeHTTP01, status.EffectiveMode) | |
| 392 | + | ||
| 393 | + | require.Len(t, status.Certificates, 1, "expected one managed certificate") | |
| 394 | + | assert.Equal(t, "http01-app.example.com", status.Certificates[0].ID) | |
| 395 | + | assert.Equal(t, domain.TLSCertificateStatusValid, status.Certificates[0].Status) | |
| 396 | + | ||
| 397 | + | require.Len(t, status.Routes, 1, "expected one route coverage") | |
| 398 | + | assert.Equal(t, "app.example.com", status.Routes[0].Domain) | |
| 399 | + | assert.True(t, status.Routes[0].Covered, "route should be covered") | |
| 400 | + | assert.Equal(t, "http01-app.example.com", status.Routes[0].CoveredBy) | |
| 401 | + | assert.True(t, status.Routes[0].RequiredACME) | |
| 402 | + | } | |
| 403 | + | ||
| 404 | + | func TestServiceGetCertificateReturnsErrTLSRouteNotCovered(t *testing.T) { | |
| 405 | + | ctx := context.Background() | |
| 406 | + | ||
| 407 | + | routes := &fakeRoutes{ | |
| 408 | + | routes: []domain.Route{ | |
| 409 | + | {Domain: "app.example.com"}, | |
| 410 | + | }, | |
| 411 | + | } | |
| 412 | + | // Make Obtain fail so no cert is cached. | |
| 413 | + | issuer, _ := newMockPublicCertificateIssuer(t, func(_ context.Context, _ out.CertificateOrder) (*out.StoredCertificate, error) { | |
| 414 | + | return nil, fmt.Errorf("acme provider unavailable") | |
| 415 | + | }, nil) | |
| 416 | + | store, _ := newMockCertificateStore(t) | |
| 417 | + | ||
| 418 | + | cfg := Config{ | |
| 419 | + | Enabled: true, | |
| 420 | + | Email: "admin@example.com", | |
| 421 | + | Challenge: "http-01", | |
| 422 | + | HTTPPort: 8088, | |
| 423 | + | TLSPort: 8443, | |
| 424 | + | } | |
| 425 | + | ||
| 426 | + | svc := NewService(cfg, ServiceDeps{ | |
| 427 | + | Config: cfg, | |
| 428 | + | Routes: routes, | |
| 429 | + | Issuer: issuer, | |
| 430 | + | Store: store, | |
| 431 | + | Challenges: NewHTTP01Challenges(), | |
| 432 | + | Effective: EffectiveChallenge{ | |
| 433 | + | ConfiguredMode: domain.ACMEChallengeHTTP01, | |
| 434 | + | Mode: domain.ACMEChallengeHTTP01, | |
| 435 | + | TokenSource: domain.ACMETokenSourceNone, | |
| 436 | + | Reason: "http-01 challenge selected", | |
| 437 | + | }, | |
| 438 | + | }) | |
| 439 | + | ||
| 440 | + | // Load (no certs stored). | |
| 441 | + | err := svc.Load(ctx) | |
| 442 | + | require.NoError(t, err) | |
| 443 | + | ||
| 444 | + | // Reconcile — issuer fails, no cert cached but requiredHosts is set. | |
| 445 | + | err = svc.Reconcile(ctx) | |
| 446 | + | require.NoError(t, err) // Reconcile swallows per-target obtain errors | |
| 447 | + | ||
| 448 | + | // GetCertificateForHost for app.example.com should return ErrTLSRouteNotCovered. | |
| 449 | + | _, err = svc.GetCertificateForHost("app.example.com") | |
| 450 | + | require.Error(t, err) | |
| 451 | + | assert.ErrorIs(t, err, domain.ErrTLSRouteNotCovered) | |
| 452 | + | assert.Contains(t, err.Error(), "app.example.com") | |
| 453 | + | } | |
| 454 | + | ||
| 455 | + | func TestServiceReconcileDNS01BrokenResolverReturnsErrTLSRouteNotCovered(t *testing.T) { | |
| 456 | + | ctx := context.Background() | |
| 457 | + | ||
| 458 | + | routes := &fakeRoutes{ | |
| 459 | + | routes: []domain.Route{ | |
| 460 | + | {Domain: "app.example.com"}, | |
| 461 | + | }, | |
| 462 | + | } | |
| 463 | + | issuer, _ := newMockPublicCertificateIssuer(t, nil, nil) | |
| 464 | + | store, _ := newMockCertificateStore(t) | |
| 465 | + | ||
| 466 | + | // Use generated mock that always returns an error. | |
| 467 | + | zoneResolver := outmocks.NewMockCloudflareZoneResolver(t) | |
| 468 | + | zoneResolver.EXPECT().FindZone(mock.Anything, "app.example.com").Return( | |
| 469 | + | out.CloudflareZone{}, fmt.Errorf("zone resolver unavailable for app.example.com"), | |
| 470 | + | ) | |
| 471 | + | ||
| 472 | + | cfg := Config{ | |
| 473 | + | Enabled: true, | |
| 474 | + | Email: "admin@example.com", | |
| 475 | + | Challenge: "dns-01", | |
| 476 | + | HTTPPort: 8088, | |
| 477 | + | TLSPort: 8443, | |
| 478 | + | } | |
| 479 | + | ||
| 480 | + | svc := NewService(cfg, ServiceDeps{ | |
| 481 | + | Config: cfg, | |
| 482 | + | Routes: routes, | |
| 483 | + | Issuer: issuer, | |
| 484 | + | Store: store, | |
| 485 | + | ZoneResolver: zoneResolver, // generated mock that always fails | |
| 486 | + | Challenges: NewHTTP01Challenges(), | |
| 487 | + | Effective: EffectiveChallenge{ | |
| 488 | + | ConfiguredMode: domain.ACMEChallengeCloudflareDNS01, | |
| 489 | + | Mode: domain.ACMEChallengeCloudflareDNS01, | |
| 490 | + | TokenSource: domain.ACMETokenSourceNone, | |
| 491 | + | Reason: "dns-01 challenge selected", | |
| 492 | + | }, | |
| 493 | + | }) | |
| 494 | + | ||
| 495 | + | // Load (nothing stored). | |
| 496 | + | err := svc.Load(ctx) | |
| 497 | + | require.NoError(t, err) | |
| 498 | + | ||
| 499 | + | // Reconcile — zone resolver is broken, so DeriveCertificateTargets fails. | |
| 500 | + | err = svc.Reconcile(ctx) | |
| 501 | + | require.Error(t, err) | |
| 502 | + | assert.Contains(t, err.Error(), "derive certificate targets") | |
| 503 | + | ||
| 504 | + | // GetCertificateForHost for the configured route should return ErrTLSRouteNotCovered, | |
| 505 | + | // not nil,nil, because requiredHosts was set from route hosts before | |
| 506 | + | // the failed DeriveCertificateTargets call. | |
| 507 | + | _, err = svc.GetCertificateForHost("app.example.com") | |
| 508 | + | require.Error(t, err) | |
| 509 | + | assert.ErrorIs(t, err, domain.ErrTLSRouteNotCovered) | |
| 510 | + | assert.Contains(t, err.Error(), "app.example.com") | |
| 511 | + | } | |
| 512 | + | ||
| 513 | + | func TestServiceLoadParsesPEMIntoTLSCertificate(t *testing.T) { | |
| 514 | + | ctx := context.Background() | |
| 515 | + | ||
| 516 | + | // Generate valid PEM cert data. | |
| 517 | + | certPEM, keyPEM, err := generateTestCertPEM([]string{"test.example.com"}) | |
| 518 | + | require.NoError(t, err) | |
| 519 | + | ||
| 520 | + | // Store a cert with PEM data but empty tls.Certificate. | |
| 521 | + | store, _ := newMockCertificateStore(t, out.StoredCertificate{ | |
| 522 | + | ID: "http01-test.example.com", | |
| 523 | + | Names: []string{"test.example.com"}, | |
| 524 | + | Challenge: domain.ACMEChallengeHTTP01, | |
| 525 | + | FullchainPEM: certPEM, | |
| 526 | + | PrivateKeyPEM: keyPEM, | |
| 527 | + | NotAfter: time.Now().Add(90 * 24 * time.Hour), | |
| 528 | + | // Certificate field is zero — will be populated by Load. | |
| 529 | + | }) | |
| 530 | + | ||
| 531 | + | routes := &fakeRoutes{} | |
| 532 | + | cfg := Config{Enabled: true} | |
| 533 | + | ||
| 534 | + | svc := NewService(cfg, ServiceDeps{ | |
| 535 | + | Config: cfg, | |
| 536 | + | Routes: routes, | |
| 537 | + | Store: store, | |
| 538 | + | }) | |
| 539 | + | ||
| 540 | + | // Load — should parse PEM into tls.Certificate. | |
| 541 | + | err = svc.Load(ctx) | |
| 542 | + | require.NoError(t, err) | |
| 543 | + | ||
| 544 | + | // Verify the cached cert has a valid tls.Certificate via the accessor. | |
| 545 | + | cached := svc.getStoredCertificate("http01-test.example.com") | |
| 546 | + | require.NotNil(t, cached, "cert should be cached") | |
| 547 | + | assert.NotEmpty(t, cached.Certificate.Certificate, "tls.Certificate should be populated") | |
| 548 | + | assert.NotNil(t, cached.Certificate.PrivateKey, "private key should be populated") | |
| 549 | + | ||
| 550 | + | // Verify it's a valid TLS cert by performing a parse round-trip. | |
| 551 | + | leaf, err := x509.ParseCertificate(cached.Certificate.Certificate[0]) | |
| 552 | + | require.NoError(t, err) | |
| 553 | + | assert.Contains(t, leaf.DNSNames, "test.example.com") | |
| 554 | + | ||
| 555 | + | served, err := svc.GetCertificateForHost("test.example.com") | |
| 556 | + | require.NoError(t, err) | |
| 557 | + | require.NotNil(t, served, "loaded certificates should seed required hosts") | |
| 558 | + | } | |
| 559 | + | ||
| 560 | + | func TestServiceStatusReportsRouteErrorAfterObtainFailure(t *testing.T) { | |
| 561 | + | ctx := context.Background() | |
| 562 | + | routes := &fakeRoutes{routes: []domain.Route{{Domain: "app.example.com"}}} | |
| 563 | + | issuer, _ := newMockPublicCertificateIssuer(t, func(_ context.Context, _ out.CertificateOrder) (*out.StoredCertificate, error) { | |
| 564 | + | return nil, fmt.Errorf("acme unavailable token=sk-secret") | |
| 565 | + | }, nil) | |
| 566 | + | store, _ := newMockCertificateStore(t) | |
| 567 | + | cfg := Config{ | |
| 568 | + | Enabled: true, | |
| 569 | + | Email: "admin@example.com", | |
| 570 | + | Challenge: "http-01", | |
| 571 | + | HTTPPort: 8088, | |
| 572 | + | TLSPort: 8443, | |
| 573 | + | } | |
| 574 | + | svc := NewService(cfg, ServiceDeps{ | |
| 575 | + | Config: cfg, | |
| 576 | + | Routes: routes, | |
| 577 | + | Issuer: issuer, | |
| 578 | + | Store: store, | |
| 579 | + | Effective: EffectiveChallenge{ | |
| 580 | + | ConfiguredMode: domain.ACMEChallengeHTTP01, | |
| 581 | + | Mode: domain.ACMEChallengeHTTP01, | |
| 582 | + | TokenSource: domain.ACMETokenSourceNone, | |
| 583 | + | }, | |
| 584 | + | }) | |
| 585 | + | ||
| 586 | + | require.NoError(t, svc.Reconcile(ctx)) | |
| 587 | + | status := svc.Status(ctx) | |
| 588 | + | require.Len(t, status.Routes, 1) | |
| 589 | + | assert.False(t, status.Routes[0].Covered) | |
| 590 | + | assert.Contains(t, status.Routes[0].Error, "token=redacted") | |
| 591 | + | assert.NotContains(t, status.Routes[0].Error, "sk-secret") | |
| 592 | + | } | |
| 593 | + | ||
| 594 | + | func TestServiceStatusCoverageUsesServingCertDespiteTransientError(t *testing.T) { | |
| 595 | + | ctx := context.Background() | |
| 596 | + | certPEM, keyPEM, err := generateTestCertPEM([]string{"app.example.com"}) | |
| 597 | + | require.NoError(t, err) | |
| 598 | + | tlsCert, err := tls.X509KeyPair(certPEM, keyPEM) | |
| 599 | + | require.NoError(t, err) | |
| 600 | + | store, _ := newMockCertificateStore(t, out.StoredCertificate{ | |
| 601 | + | ID: "http01-app.example.com", | |
| 602 | + | Names: []string{"app.example.com"}, | |
| 603 | + | Challenge: domain.ACMEChallengeHTTP01, | |
| 604 | + | Certificate: tlsCert, | |
| 605 | + | FullchainPEM: certPEM, | |
| 606 | + | PrivateKeyPEM: keyPEM, | |
| 607 | + | NotAfter: time.Now().Add(90 * 24 * time.Hour), | |
| 608 | + | LastError: "transient renewal error", | |
| 609 | + | }) | |
| 610 | + | cfg := Config{Enabled: true} | |
| 611 | + | svc := NewService(cfg, ServiceDeps{ | |
| 612 | + | Config: cfg, | |
| 613 | + | Routes: &fakeRoutes{routes: []domain.Route{{Domain: "app.example.com"}}}, | |
| 614 | + | Store: store, | |
| 615 | + | }) | |
| 616 | + | require.NoError(t, svc.Load(ctx)) | |
| 617 | + | ||
| 618 | + | status := svc.Status(ctx) | |
| 619 | + | require.Len(t, status.Routes, 1) | |
| 620 | + | assert.True(t, status.Routes[0].Covered) | |
| 621 | + | assert.Equal(t, "http01-app.example.com", status.Routes[0].CoveredBy) | |
| 622 | + | } | |
| 623 | + | ||
| 624 | + | func TestServiceStatusRedactsSensitiveStrings(t *testing.T) { | |
| 625 | + | ctx := context.Background() | |
| 626 | + | ||
| 627 | + | // Pre-populate the store with a certificate that has a LastError | |
| 628 | + | // containing sensitive data. | |
| 629 | + | certPEM, keyPEM, err := generateTestCertPEM([]string{"app.example.com"}) | |
| 630 | + | require.NoError(t, err) | |
| 631 | + | tlsCert, err := tls.X509KeyPair(certPEM, keyPEM) | |
| 632 | + | require.NoError(t, err) | |
| 633 | + | ||
| 634 | + | store, _ := newMockCertificateStore(t, out.StoredCertificate{ | |
| 635 | + | ID: "http01-app.example.com", | |
| 636 | + | Names: []string{"app.example.com"}, | |
| 637 | + | Challenge: domain.ACMEChallengeHTTP01, | |
| 638 | + | Certificate: tlsCert, | |
| 639 | + | FullchainPEM: certPEM, | |
| 640 | + | PrivateKeyPEM: keyPEM, | |
| 641 | + | NotAfter: time.Now().Add(90 * 24 * time.Hour), | |
| 642 | + | LastError: "failed to obtain: token=sk-secret-goes-here provider said invalid", | |
| 643 | + | }) | |
| 644 | + | ||
| 645 | + | routes := &fakeRoutes{ | |
| 646 | + | routes: []domain.Route{ | |
| 647 | + | {Domain: "app.example.com"}, | |
| 648 | + | }, | |
| 649 | + | } | |
| 650 | + | cfg := Config{ | |
| 651 | + | Enabled: true, | |
| 652 | + | Email: "admin@example.com", | |
| 653 | + | Challenge: "http-01", | |
| 654 | + | HTTPPort: 8088, | |
| 655 | + | TLSPort: 8443, | |
| 656 | + | } | |
| 657 | + | ||
| 658 | + | svc := NewService(cfg, ServiceDeps{ | |
| 659 | + | Config: cfg, | |
| 660 | + | Routes: routes, | |
| 661 | + | Store: store, | |
| 662 | + | Effective: EffectiveChallenge{ | |
| 663 | + | ConfiguredMode: domain.ACMEChallengeHTTP01, | |
| 664 | + | Mode: domain.ACMEChallengeHTTP01, | |
| 665 | + | TokenSource: domain.ACMETokenSourceNone, | |
| 666 | + | Reason: "http-01 challenge selected", | |
| 667 | + | }, | |
| 668 | + | }) | |
| 669 | + | ||
| 670 | + | // Load the cert into the cache. | |
| 671 | + | err = svc.Load(ctx) | |
| 672 | + | require.NoError(t, err) | |
| 673 | + | ||
| 674 | + | // Get status. | |
| 675 | + | status := svc.Status(ctx) | |
| 676 | + | ||
| 677 | + | require.Len(t, status.Certificates, 1) | |
| 678 | + | lastErr := status.Certificates[0].LastError | |
| 679 | + | t.Logf("sanitized LastError: %q", lastErr) | |
| 680 | + | ||
| 681 | + | // The original error contained "token=sk-secret-goes-here". | |
| 682 | + | // After sanitization, the secret value should be redacted. | |
| 683 | + | assert.NotContains(t, lastErr, "sk-secret-goes-here", | |
| 684 | + | "LastError must not contain the raw secret value") | |
| 685 | + | assert.Contains(t, lastErr, "token=redacted", | |
| 686 | + | "LastError should contain token=redacted") | |
| 687 | + | } | |
A internal/usecase/publictls/status.go +191 -0
| @@ -0,0 +1,191 @@ | |||
| 1 | + | package publictls | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "regexp" | |
| 6 | + | "sort" | |
| 7 | + | "time" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/gordon/internal/domain" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | // Status returns the current public TLS status. | |
| 13 | + | func (s *Service) Status(ctx context.Context) domain.PublicTLSStatus { | |
| 14 | + | // Collect route domains outside the lock so route source methods | |
| 15 | + | // (GetRoutes/GetExternalRoutes) are not called while holding s.mu. | |
| 16 | + | routeDomains := collectRouteDomains(ctx, s.deps.Routes) | |
| 17 | + | ||
| 18 | + | s.mu.Lock() | |
| 19 | + | defer s.mu.Unlock() | |
| 20 | + | ||
| 21 | + | status := domain.PublicTLSStatus{ | |
| 22 | + | ACMEEnabled: s.cfg.Enabled, | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | if s.deps.Effective.Mode != "" { | |
| 26 | + | status.ConfiguredMode = s.deps.Effective.ConfiguredMode | |
| 27 | + | status.EffectiveMode = s.deps.Effective.Mode | |
| 28 | + | status.SelectionReason = s.deps.Effective.Reason | |
| 29 | + | status.TokenSource = s.deps.Effective.TokenSource | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | status.Certificates = s.buildManagedCertificatesLocked() | |
| 33 | + | status.Routes = s.buildRouteCoverageLocked(routeDomains, status.Certificates) | |
| 34 | + | status.Errors = buildStatusErrors(status.Certificates, status.Routes) | |
| 35 | + | return status | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | // buildManagedCertificatesLocked returns sorted managed certificates. | |
| 39 | + | // Must be called with s.mu held. | |
| 40 | + | func (s *Service) buildManagedCertificatesLocked() []domain.ManagedCertificate { | |
| 41 | + | certs := make([]domain.ManagedCertificate, 0, len(s.certs)) | |
| 42 | + | now := time.Now() | |
| 43 | + | for _, cert := range s.certs { | |
| 44 | + | lastErr := cert.LastError | |
| 45 | + | if e, ok := s.lastErr[cert.ID]; ok && e != "" { | |
| 46 | + | lastErr = e | |
| 47 | + | } | |
| 48 | + | mc := domain.ManagedCertificate{ | |
| 49 | + | ID: cert.ID, | |
| 50 | + | Names: cert.Names, | |
| 51 | + | Challenge: cert.Challenge, | |
| 52 | + | NotAfter: cert.NotAfter, | |
| 53 | + | LastError: sanitizeError(lastErr), | |
| 54 | + | } | |
| 55 | + | mc.Status = mc.Health(now) | |
| 56 | + | certs = append(certs, mc) | |
| 57 | + | } | |
| 58 | + | sort.Slice(certs, func(i, j int) bool { | |
| 59 | + | return certs[i].ID < certs[j].ID | |
| 60 | + | }) | |
| 61 | + | return certs | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | func buildStatusErrors(certs []domain.ManagedCertificate, routes []domain.TLSRouteCoverage) []string { | |
| 65 | + | seen := make(map[string]struct{}) | |
| 66 | + | var errorsList []string | |
| 67 | + | add := func(message string) { | |
| 68 | + | message = sanitizeError(message) | |
| 69 | + | if message == "" { | |
| 70 | + | return | |
| 71 | + | } | |
| 72 | + | if _, ok := seen[message]; ok { | |
| 73 | + | return | |
| 74 | + | } | |
| 75 | + | seen[message] = struct{}{} | |
| 76 | + | errorsList = append(errorsList, message) | |
| 77 | + | } | |
| 78 | + | for _, cert := range certs { | |
| 79 | + | add(cert.LastError) | |
| 80 | + | } | |
| 81 | + | for _, route := range routes { | |
| 82 | + | add(route.Error) | |
| 83 | + | } | |
| 84 | + | return errorsList | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | // buildRouteCoverageLocked returns route coverage for the given domains. | |
| 88 | + | // Must be called with s.mu held. | |
| 89 | + | func (s *Service) buildRouteCoverageLocked(routeDomains []string, managedCerts []domain.ManagedCertificate) []domain.TLSRouteCoverage { | |
| 90 | + | sort.Strings(routeDomains) | |
| 91 | + | ||
| 92 | + | now := time.Now() | |
| 93 | + | routes := make([]domain.TLSRouteCoverage, 0, len(routeDomains)) | |
| 94 | + | for _, d := range routeDomains { | |
| 95 | + | coverage := domain.TLSRouteCoverage{ | |
| 96 | + | Domain: d, | |
| 97 | + | RequiredACME: s.cfg.Enabled, | |
| 98 | + | } | |
| 99 | + | coverage.Covered, coverage.CoveredBy = s.findCoverageLocked(d, managedCerts, now) | |
| 100 | + | if err, hasErr := s.routeErr[d]; hasErr { | |
| 101 | + | coverage.Error = sanitizeError(err) | |
| 102 | + | } | |
| 103 | + | routes = append(routes, coverage) | |
| 104 | + | } | |
| 105 | + | return routes | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | // collectRouteDomains returns all unique canonical route domains. | |
| 109 | + | // Does not hold s.mu so route source methods may be called safely. | |
| 110 | + | func collectRouteDomains(ctx context.Context, routes RouteSource) []string { | |
| 111 | + | routeSet := make(map[string]struct{}) | |
| 112 | + | var domains []string | |
| 113 | + | ||
| 114 | + | for _, r := range routes.GetRoutes(ctx) { | |
| 115 | + | canonical, ok := domain.CanonicalRouteDomain(r.Domain) | |
| 116 | + | if ok { | |
| 117 | + | if _, exists := routeSet[canonical]; !exists { | |
| 118 | + | routeSet[canonical] = struct{}{} | |
| 119 | + | domains = append(domains, canonical) | |
| 120 | + | } | |
| 121 | + | } | |
| 122 | + | } | |
| 123 | + | for h := range routes.GetExternalRoutes() { | |
| 124 | + | canonical, ok := domain.CanonicalRouteDomain(h) | |
| 125 | + | if ok { | |
| 126 | + | if _, exists := routeSet[canonical]; !exists { | |
| 127 | + | routeSet[canonical] = struct{}{} | |
| 128 | + | domains = append(domains, canonical) | |
| 129 | + | } | |
| 130 | + | } | |
| 131 | + | } | |
| 132 | + | return domains | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | // findCoverageLocked checks if any managed certificate covers the given domain. | |
| 136 | + | // Must be called with s.mu held. | |
| 137 | + | func (s *Service) findCoverageLocked(routeDomain string, managedCerts []domain.ManagedCertificate, now time.Time) (bool, string) { | |
| 138 | + | for _, mc := range managedCerts { | |
| 139 | + | if !mc.Covers(routeDomain) { | |
| 140 | + | continue | |
| 141 | + | } | |
| 142 | + | switch mc.Status { | |
| 143 | + | case domain.TLSCertificateStatusExpired, domain.TLSCertificateStatusMissing: | |
| 144 | + | continue | |
| 145 | + | } | |
| 146 | + | // Double-check the raw stored cert expiry to guard against stale | |
| 147 | + | // ManagedCertificate.Status values that may not reflect real expiry. | |
| 148 | + | if sc, ok := s.certs[mc.ID]; ok { | |
| 149 | + | if !sc.NotAfter.IsZero() && !now.Before(sc.NotAfter) { | |
| 150 | + | continue | |
| 151 | + | } | |
| 152 | + | } | |
| 153 | + | return true, mc.ID | |
| 154 | + | } | |
| 155 | + | return false, "" | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | // Sensitive key names that may appear in error messages. | |
| 159 | + | var sensitiveKeys = `(token|secret|key|password|pass|api_key|apikey|auth)` | |
| 160 | + | ||
| 161 | + | // Patterns for redacting sensitive values in common formats. | |
| 162 | + | var ( | |
| 163 | + | // key=value (unquoted, case-insensitive) | |
| 164 | + | keyEqValueRe = regexp.MustCompile(`(?i)` + sensitiveKeys + `=[^\s"']+`) | |
| 165 | + | // key="value" (double-quoted) | |
| 166 | + | keyEqQuotedRe = regexp.MustCompile(`(?i)` + sensitiveKeys + `="[^"]*"`) | |
| 167 | + | // key='value' (single-quoted) | |
| 168 | + | keyEqSingleQuotedRe = regexp.MustCompile(`(?i)` + sensitiveKeys + `='[^']*'`) | |
| 169 | + | // JSON "key": "value" | |
| 170 | + | jsonKeyValueRe = regexp.MustCompile(`(?i)"` + sensitiveKeys + `"\s*:\s*"[^"]*"`) | |
| 171 | + | // YAML key: value (start of line or after indent) | |
| 172 | + | yamlKeyValueRe = regexp.MustCompile(`(?i)(?m)^\s*` + sensitiveKeys + `\s*:\s+\S+`) | |
| 173 | + | ) | |
| 174 | + | ||
| 175 | + | // sanitizeError removes obvious sensitive strings from error messages. | |
| 176 | + | // It redacts key=value, key="value", key='value', JSON "key": "value", | |
| 177 | + | // and YAML key: value patterns for known sensitive keys while preserving | |
| 178 | + | // safe context. It does not blanket-redact messages just because they | |
| 179 | + | // contain the word "secret" or similar. | |
| 180 | + | func sanitizeError(err string) string { | |
| 181 | + | if err == "" { | |
| 182 | + | return "" | |
| 183 | + | } | |
| 184 | + | ||
| 185 | + | result := keyEqValueRe.ReplaceAllString(err, "$1=redacted") | |
| 186 | + | result = keyEqQuotedRe.ReplaceAllString(result, `$1="redacted"`) | |
| 187 | + | result = keyEqSingleQuotedRe.ReplaceAllString(result, "$1='redacted'") | |
| 188 | + | result = jsonKeyValueRe.ReplaceAllString(result, `"$1":"redacted"`) | |
| 189 | + | result = yamlKeyValueRe.ReplaceAllString(result, "$1: redacted") | |
| 190 | + | return result | |
| 191 | + | } | |
A internal/usecase/publictls/status_test.go +77 -0
| @@ -0,0 +1,77 @@ | |||
| 1 | + | package publictls | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/stretchr/testify/assert" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | func TestSanitizeError_Empty(t *testing.T) { | |
| 10 | + | assert.Equal(t, "", sanitizeError("")) | |
| 11 | + | } | |
| 12 | + | ||
| 13 | + | func TestSanitizeError_NoSensitiveData(t *testing.T) { | |
| 14 | + | msg := "failed to obtain certificate: acme server returned 429" | |
| 15 | + | assert.Equal(t, msg, sanitizeError(msg)) | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | func TestSanitizeError_KeyEqValue(t *testing.T) { | |
| 19 | + | result := sanitizeError("token=sk-secret-goes-here provider said invalid") | |
| 20 | + | assert.NotContains(t, result, "sk-secret-goes-here") | |
| 21 | + | assert.Contains(t, result, "token=redacted") | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | func TestSanitizeError_KeyEqQuotedValue(t *testing.T) { | |
| 25 | + | result := sanitizeError(`secret="my-secret-value" is invalid`) | |
| 26 | + | assert.NotContains(t, result, "my-secret-value") | |
| 27 | + | assert.Contains(t, result, `secret="redacted"`) | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | func TestSanitizeError_KeyEqSingleQuotedValue(t *testing.T) { | |
| 31 | + | result := sanitizeError(`key='my-api-key-12345' not found`) | |
| 32 | + | assert.NotContains(t, result, "my-api-key-12345") | |
| 33 | + | assert.Contains(t, result, "key='redacted'") | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | func TestSanitizeError_JSONKeyValue(t *testing.T) { | |
| 37 | + | result := sanitizeError(`{"token":"s3kr1t","domain":"example.com"}`) | |
| 38 | + | assert.NotContains(t, result, "s3kr1t") | |
| 39 | + | assert.Contains(t, result, `"token":"redacted"`) | |
| 40 | + | // Non-sensitive fields should be preserved | |
| 41 | + | assert.Contains(t, result, `"domain":"example.com"`) | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | func TestSanitizeError_YAMLKeyValue(t *testing.T) { | |
| 45 | + | result := sanitizeError("configuration error:\n api_key: abcdef123456\n password: hunter2") | |
| 46 | + | assert.NotContains(t, result, "abcdef123456") | |
| 47 | + | assert.NotContains(t, result, "hunter2") | |
| 48 | + | assert.Contains(t, result, "api_key: redacted") | |
| 49 | + | assert.Contains(t, result, "password: redacted") | |
| 50 | + | // Non-sensitive context preserved | |
| 51 | + | assert.Contains(t, result, "configuration error:") | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | func TestSanitizeError_CaseInsensitive(t *testing.T) { | |
| 55 | + | result := sanitizeError("Token=sk-live-abc123 and SECRET=top-secret-value") | |
| 56 | + | assert.NotContains(t, result, "sk-live-abc123") | |
| 57 | + | assert.NotContains(t, result, "top-secret-value") | |
| 58 | + | assert.Contains(t, result, "Token=redacted") | |
| 59 | + | assert.Contains(t, result, "SECRET=redacted") | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | func TestSanitizeError_MixedFormats(t *testing.T) { | |
| 63 | + | result := sanitizeError(`token=plain, secret="quoted", auth='single-quoted', and json {"password":"p4ss"}`) | |
| 64 | + | assert.NotContains(t, result, "plain") | |
| 65 | + | assert.NotContains(t, result, "quoted") | |
| 66 | + | assert.NotContains(t, result, "single-quoted") | |
| 67 | + | assert.NotContains(t, result, "p4ss") | |
| 68 | + | assert.Contains(t, result, "token=redacted") | |
| 69 | + | assert.Contains(t, result, `secret="redacted"`) | |
| 70 | + | assert.Contains(t, result, "auth='redacted'") | |
| 71 | + | assert.Contains(t, result, `"password":"redacted"`) | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | func TestSanitizeError_PreservesSafeContext(t *testing.T) { | |
| 75 | + | msg := "certificate for example.com failed: acme error 403" | |
| 76 | + | assert.Equal(t, msg, sanitizeError(msg)) | |
| 77 | + | } | |
A internal/usecase/publictls/targets.go +153 -0
| @@ -0,0 +1,153 @@ | |||
| 1 | + | package publictls | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "fmt" | |
| 6 | + | "sort" | |
| 7 | + | "strings" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/gordon/internal/boundaries/out" | |
| 10 | + | "github.com/bnema/gordon/internal/domain" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | // CertificateTarget represents a single certificate to obtain or renew. | |
| 14 | + | type CertificateTarget struct { | |
| 15 | + | ID string | |
| 16 | + | Names []string | |
| 17 | + | Challenge domain.ACMEChallengeMode | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | // DeriveCertificateTargets derives certificate targets from routes and external | |
| 21 | + | // routes based on the given ACME challenge mode. | |
| 22 | + | func DeriveCertificateTargets( | |
| 23 | + | ctx context.Context, | |
| 24 | + | mode domain.ACMEChallengeMode, | |
| 25 | + | routes []domain.Route, | |
| 26 | + | external map[string]string, | |
| 27 | + | resolver out.CloudflareZoneResolver, | |
| 28 | + | ) ([]CertificateTarget, error) { | |
| 29 | + | hosts := routeHosts(routes, external) | |
| 30 | + | ||
| 31 | + | switch mode { | |
| 32 | + | case domain.ACMEChallengeHTTP01: | |
| 33 | + | return deriveHTTP01Targets(hosts), nil | |
| 34 | + | case domain.ACMEChallengeCloudflareDNS01: | |
| 35 | + | return deriveDNS01Targets(ctx, hosts, resolver) | |
| 36 | + | default: | |
| 37 | + | return nil, fmt.Errorf("unsupported challenge mode: %s", mode) | |
| 38 | + | } | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | // routeHosts collects all unique canonical hosts from routes and external keys, | |
| 42 | + | // sorted alphabetically. Trailing dots are stripped before canonicalization. | |
| 43 | + | func routeHosts(routes []domain.Route, external map[string]string) []string { | |
| 44 | + | seen := make(map[string]struct{}) | |
| 45 | + | var hosts []string | |
| 46 | + | ||
| 47 | + | addHost := func(raw string) { | |
| 48 | + | raw = strings.TrimSuffix(raw, ".") | |
| 49 | + | canonical, ok := domain.CanonicalRouteDomain(raw) | |
| 50 | + | if !ok { | |
| 51 | + | return | |
| 52 | + | } | |
| 53 | + | if _, exists := seen[canonical]; !exists { | |
| 54 | + | seen[canonical] = struct{}{} | |
| 55 | + | hosts = append(hosts, canonical) | |
| 56 | + | } | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | for _, r := range routes { | |
| 60 | + | addHost(r.Domain) | |
| 61 | + | } | |
| 62 | + | for h := range external { | |
| 63 | + | addHost(h) | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | sort.Strings(hosts) | |
| 67 | + | return hosts | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | // deriveHTTP01Targets creates one target per host for HTTP-01 challenge. | |
| 71 | + | func deriveHTTP01Targets(hosts []string) []CertificateTarget { | |
| 72 | + | targets := make([]CertificateTarget, len(hosts)) | |
| 73 | + | for i, host := range hosts { | |
| 74 | + | targets[i] = CertificateTarget{ | |
| 75 | + | ID: "http01-" + host, | |
| 76 | + | Names: []string{host}, | |
| 77 | + | Challenge: domain.ACMEChallengeHTTP01, | |
| 78 | + | } | |
| 79 | + | } | |
| 80 | + | return targets | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | // deriveDNS01Targets creates targets grouped by wildcard base for DNS-01 challenge. | |
| 84 | + | func deriveDNS01Targets(ctx context.Context, hosts []string, resolver out.CloudflareZoneResolver) ([]CertificateTarget, error) { | |
| 85 | + | if resolver == nil { | |
| 86 | + | return nil, fmt.Errorf("dns-01 resolver is nil: cannot resolve DNS zones") | |
| 87 | + | } | |
| 88 | + | baseSeen := make(map[string]struct{}) | |
| 89 | + | var bases []string | |
| 90 | + | ||
| 91 | + | for _, host := range hosts { | |
| 92 | + | zone, err := resolver.FindZone(ctx, host) | |
| 93 | + | if err != nil { | |
| 94 | + | return nil, fmt.Errorf("find zone for %s: %w", host, err) | |
| 95 | + | } | |
| 96 | + | base, err := wildcardBase(host, zone.Name) | |
| 97 | + | if err != nil { | |
| 98 | + | return nil, fmt.Errorf("find wildcard base for %s: %w", host, err) | |
| 99 | + | } | |
| 100 | + | if _, exists := baseSeen[base]; !exists { | |
| 101 | + | baseSeen[base] = struct{}{} | |
| 102 | + | bases = append(bases, base) | |
| 103 | + | } | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | sort.Strings(bases) | |
| 107 | + | ||
| 108 | + | targets := make([]CertificateTarget, len(bases)) | |
| 109 | + | for i, base := range bases { | |
| 110 | + | targets[i] = CertificateTarget{ | |
| 111 | + | ID: "dns01-" + base, | |
| 112 | + | Names: []string{base, "*." + base}, | |
| 113 | + | Challenge: domain.ACMEChallengeCloudflareDNS01, | |
| 114 | + | } | |
| 115 | + | } | |
| 116 | + | return targets, nil | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | // wildcardBase determines the wildcard base for a host within a zone. | |
| 120 | + | // This is used by deriveDNS01Targets to group hosts under a common | |
| 121 | + | // wildcard certificate (e.g. example.com + *.example.com). Hosts that | |
| 122 | + | // share the same wildcard base are combined into a single DNS-01 target. | |
| 123 | + | // | |
| 124 | + | // If host == zone, returns zone. | |
| 125 | + | // If host is a direct child of zone, returns zone. | |
| 126 | + | // If host is deeper (e.g. api.prod.example.com within example.com), | |
| 127 | + | // returns the parent label under the zone (prod.example.com). | |
| 128 | + | func wildcardBase(host, zone string) (string, error) { | |
| 129 | + | host = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(host)), ".") | |
| 130 | + | zone = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(zone)), ".") | |
| 131 | + | if host == "" || zone == "" { | |
| 132 | + | return "", fmt.Errorf("host and zone must be non-empty") | |
| 133 | + | } | |
| 134 | + | if host == zone { | |
| 135 | + | return zone, nil | |
| 136 | + | } | |
| 137 | + | if !strings.HasSuffix(host, "."+zone) { | |
| 138 | + | return "", fmt.Errorf("zone %q does not match host %q", zone, host) | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | // Remove the zone suffix (prefixed by a dot) to isolate subdomain parts. | |
| 142 | + | trimmed := strings.TrimSuffix(host, "."+zone) | |
| 143 | + | ||
| 144 | + | if !strings.Contains(trimmed, ".") { | |
| 145 | + | // Direct child of the zone (e.g. app.example.com). | |
| 146 | + | return zone, nil | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | // Deeper than direct child: extract the parent under the zone. | |
| 150 | + | // e.g. "api.prod.example.com" -> trimmed "api.prod" -> parent "prod" | |
| 151 | + | _, parent, _ := strings.Cut(trimmed, ".") | |
| 152 | + | return parent + "." + zone, nil | |
| 153 | + | } | |
A internal/usecase/publictls/targets_test.go +81 -0
| @@ -0,0 +1,81 @@ | |||
| 1 | + | package publictls | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/stretchr/testify/assert" | |
| 8 | + | "github.com/stretchr/testify/mock" | |
| 9 | + | "github.com/stretchr/testify/require" | |
| 10 | + | ||
| 11 | + | "github.com/bnema/gordon/internal/boundaries/out" | |
| 12 | + | outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks" | |
| 13 | + | "github.com/bnema/gordon/internal/domain" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | func TestDeriveTargets_HTTP01PerRoute(t *testing.T) { | |
| 17 | + | routes := []domain.Route{{Domain: "app.example.com"}, {Domain: "registry.example.com"}} | |
| 18 | + | targets, err := DeriveCertificateTargets(context.Background(), domain.ACMEChallengeHTTP01, routes, nil, nil) | |
| 19 | + | require.NoError(t, err) | |
| 20 | + | require.Len(t, targets, 2) | |
| 21 | + | ||
| 22 | + | assert.Equal(t, "http01-app.example.com", targets[0].ID) | |
| 23 | + | assert.Equal(t, []string{"app.example.com"}, targets[0].Names) | |
| 24 | + | assert.Equal(t, domain.ACMEChallengeHTTP01, targets[0].Challenge) | |
| 25 | + | assert.Equal(t, "http01-registry.example.com", targets[1].ID) | |
| 26 | + | assert.Equal(t, []string{"registry.example.com"}, targets[1].Names) | |
| 27 | + | assert.Equal(t, domain.ACMEChallengeHTTP01, targets[1].Challenge) | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | func TestDeriveTargets_DNS01WildcardBases(t *testing.T) { | |
| 31 | + | routes := []domain.Route{{Domain: "app.example.com"}, {Domain: "api.prod.example.com"}, {Domain: "example.com"}} | |
| 32 | + | resolver := outmocks.NewMockCloudflareZoneResolver(t) | |
| 33 | + | resolver.EXPECT().FindZone(mock.Anything, mock.Anything).Return(out.CloudflareZone{Name: "example.com"}, nil).Times(3) | |
| 34 | + | targets, err := DeriveCertificateTargets(context.Background(), domain.ACMEChallengeCloudflareDNS01, routes, nil, resolver) | |
| 35 | + | require.NoError(t, err) | |
| 36 | + | require.Len(t, targets, 2) | |
| 37 | + | ||
| 38 | + | assert.Equal(t, "dns01-example.com", targets[0].ID) | |
| 39 | + | assert.Equal(t, []string{"example.com", "*.example.com"}, targets[0].Names) | |
| 40 | + | assert.Equal(t, domain.ACMEChallengeCloudflareDNS01, targets[0].Challenge) | |
| 41 | + | assert.Equal(t, "dns01-prod.example.com", targets[1].ID) | |
| 42 | + | assert.Equal(t, []string{"prod.example.com", "*.prod.example.com"}, targets[1].Names) | |
| 43 | + | assert.Equal(t, domain.ACMEChallengeCloudflareDNS01, targets[1].Challenge) | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | func TestDeriveTargets_IncludesExternalRoutes(t *testing.T) { | |
| 47 | + | routes := []domain.Route{{Domain: "app.example.com"}} | |
| 48 | + | external := map[string]string{"external.example.com": "127.0.0.1:8080"} | |
| 49 | + | targets, err := DeriveCertificateTargets(context.Background(), domain.ACMEChallengeHTTP01, routes, external, nil) | |
| 50 | + | require.NoError(t, err) | |
| 51 | + | require.Len(t, targets, 2) | |
| 52 | + | ||
| 53 | + | assert.Equal(t, "http01-app.example.com", targets[0].ID) | |
| 54 | + | assert.Equal(t, "http01-external.example.com", targets[1].ID) | |
| 55 | + | assert.Equal(t, []string{"external.example.com"}, targets[1].Names) | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | func TestDeriveTargets_DNS01NilResolver_ReturnsError(t *testing.T) { | |
| 59 | + | routes := []domain.Route{{Domain: "app.example.com"}} | |
| 60 | + | _, err := DeriveCertificateTargets(context.Background(), domain.ACMEChallengeCloudflareDNS01, routes, nil, nil) | |
| 61 | + | require.Error(t, err) | |
| 62 | + | assert.Contains(t, err.Error(), "resolver is nil") | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | func TestDeriveTargets_DNS01MismatchedZone_ReturnsError(t *testing.T) { | |
| 66 | + | routes := []domain.Route{{Domain: "app.example.com"}} | |
| 67 | + | resolver := outmocks.NewMockCloudflareZoneResolver(t) | |
| 68 | + | resolver.EXPECT().FindZone(mock.Anything, mock.Anything).Return(out.CloudflareZone{Name: "other.test"}, nil).Once() | |
| 69 | + | _, err := DeriveCertificateTargets(context.Background(), domain.ACMEChallengeCloudflareDNS01, routes, nil, resolver) | |
| 70 | + | require.Error(t, err) | |
| 71 | + | assert.Contains(t, err.Error(), "does not match host") | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | func TestDeriveTargets_DuplicateCanonicalization(t *testing.T) { | |
| 75 | + | routes := []domain.Route{{Domain: "App.Example.Com"}, {Domain: "app.example.com"}} | |
| 76 | + | targets, err := DeriveCertificateTargets(context.Background(), domain.ACMEChallengeHTTP01, routes, nil, nil) | |
| 77 | + | require.NoError(t, err) | |
| 78 | + | require.Len(t, targets, 1, "mixed-case duplicates should collapse to one target") | |
| 79 | + | assert.Equal(t, "http01-app.example.com", targets[0].ID) | |
| 80 | + | assert.Equal(t, []string{"app.example.com"}, targets[0].Names) | |
| 81 | + | } | |