Harden ACME HTTP onboarding flow
- commit
- e4075835d062490c30c459ce73792a767d1250a9
- author
- brice · · 2026-04-30 07:00 UTC
- parent
- 0c0cc56
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
10 files · +408 -38
M docs/config/reference.md +2 -0
| @@ -40,6 +40,7 @@ access_token_ttl = "15m" # Ephemeral access token lifetime ( | |||
| 40 | 40 | enabled = false # Enable public ACME certificates (requires server.tls_port > 0) | |
| 41 | 41 | email = "" # ACME account email when enabled | |
| 42 | 42 | challenge = "auto" # "auto", "http-01", or "cloudflare-dns-01" | |
| 43 | + | obtain_batch_size = 1 # New certificate orders per reconcile run | |
| 43 | 44 | ||
| 44 | 45 | # ============================================================================= | |
| 45 | 46 | # API (applies to both Registry and Admin endpoints) | |
| @@ -206,6 +207,7 @@ keep_last = 3 # Keep N newest tags per repository | |||
| 206 | 207 | | `tls.acme.enabled` | `false` | Enable public ACME certificates (requires `server.tls_port > 0`) | | |
| 207 | 208 | | `tls.acme.email` | `""` | ACME account email when enabled | | |
| 208 | 209 | | `tls.acme.challenge` | `"auto"` | ACME challenge mode: `auto`, `http-01`, or `cloudflare-dns-01` | | |
| 210 | + | | `tls.acme.obtain_batch_size` | `1` | Maximum new ACME certificate orders per reconcile run | | |
| 209 | 211 | | `auth.enabled` | `true` | Enable authentication; when `false`, run local-only mode (loopback-only `/v2/*`, `/admin/*` disabled) | | |
| 210 | 212 | | `auth.secrets_backend` | `"unsafe"` | Secrets storage | | |
| 211 | 213 | | `auth.token_expiry` | `"720h"` | 30 days | | |
| @@ -40,6 +40,7 @@ access_token_ttl = "15m" # Ephemeral access token lifetime ( | |||||
| 40 | enabled = false # Enable public ACME certificates (requires server.tls_port > 0) | 40 | enabled = false # Enable public ACME certificates (requires server.tls_port > 0) | ||
| 41 | email = "" # ACME account email when enabled | 41 | email = "" # ACME account email when enabled | ||
| 42 | challenge = "auto" # "auto", "http-01", or "cloudflare-dns-01" | 42 | challenge = "auto" # "auto", "http-01", or "cloudflare-dns-01" | ||
| 43 | + | obtain_batch_size = 1 # New certificate orders per reconcile run | |||
| 43 | 44 | ||||
| 44 | # ============================================================================= | 45 | # ============================================================================= | ||
| 45 | # API (applies to both Registry and Admin endpoints) | 46 | # API (applies to both Registry and Admin endpoints) | ||
| @@ -206,6 +207,7 @@ keep_last = 3 # Keep N newest tags per repository | |||||
| 206 | | `tls.acme.enabled` | `false` | Enable public ACME certificates (requires `server.tls_port > 0`) | | 207 | | `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.email` | `""` | ACME account email when enabled | | ||
| 208 | | `tls.acme.challenge` | `"auto"` | ACME challenge mode: `auto`, `http-01`, or `cloudflare-dns-01` | | 209 | | `tls.acme.challenge` | `"auto"` | ACME challenge mode: `auto`, `http-01`, or `cloudflare-dns-01` | | ||
| 210 | + | | `tls.acme.obtain_batch_size` | `1` | Maximum new ACME certificate orders per reconcile run | | |||
| 209 | | `auth.enabled` | `true` | Enable authentication; when `false`, run local-only mode (loopback-only `/v2/*`, `/admin/*` disabled) | | 211 | | `auth.enabled` | `true` | Enable authentication; when `false`, run local-only mode (loopback-only `/v2/*`, `/admin/*` disabled) | | ||
| 210 | | `auth.secrets_backend` | `"unsafe"` | Secrets storage | | 212 | | `auth.secrets_backend` | `"unsafe"` | Secrets storage | | ||
| 211 | | `auth.token_expiry` | `"720h"` | 30 days | | 213 | | `auth.token_expiry` | `"720h"` | 30 days | | ||
M docs/config/server.md +11 -3
| @@ -95,7 +95,8 @@ tls_port = 8443 | |||
| 95 | 95 | [tls.acme] | |
| 96 | 96 | enabled = true | |
| 97 | 97 | email = "admin@example.com" | |
| 98 | - | challenge = "auto" # auto, http-01, or cloudflare-dns-01 | |
| 98 | + | challenge = "auto" # auto, http-01, or cloudflare-dns-01 | |
| 99 | + | obtain_batch_size = 1 # maximum new certificate orders per reconcile run | |
| 99 | 100 | ``` | |
| 100 | 101 | ||
| 101 | 102 | Challenge behavior: | |
| @@ -103,19 +104,26 @@ Challenge behavior: | |||
| 103 | 104 | - `cloudflare-dns-01` uses a Cloudflare API token from `pass`, `GORDON_CLOUDFLARE_API_TOKEN_FILE`, or `GORDON_CLOUDFLARE_API_TOKEN`. | |
| 104 | 105 | - `auto` selects Cloudflare DNS-01 when a token is available, otherwise falls back to HTTP-01. | |
| 105 | 106 | ||
| 107 | + | HTTP-01 requires public access to port 80 for each hostname being validated. If you use Cloudflare in DNS-only/gray-cloud mode, your firewall/NAT must allow direct public traffic to Gordon's HTTP proxy port. A firewall rule that only allows Cloudflare source IPs on port 80 is compatible with orange-cloud proxying, but it blocks gray-cloud HTTP-01 validation; use DNS-01 or temporarily open port 80 for direct validation. | |
| 108 | + | ||
| 109 | + | Gordon limits new ACME certificate orders to `obtain_batch_size` per reconcile run (default `1`) so enabling ACME on an existing multi-route server does not burst through every route and hit Let's Encrypt rate limits. Later reloads, restarts, or renewal-loop reconciles continue issuing remaining certificates. | |
| 110 | + | ||
| 111 | + | For Cloudflare Full/Strict, Cloudflare terminates browser TLS at the edge and connects to Gordon over HTTPS. A public ACME certificate served by Gordon is the preferred origin certificate because Cloudflare Strict can validate it without custom origin trust. Cloudflare Flexible mode (HTTPS at the edge, HTTP to Gordon) remains a legacy/compatibility option, but it is not end-to-end HTTPS. | |
| 112 | + | ||
| 106 | 113 | 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 | 114 | ||
| 108 | 115 | #### Direct HTTP Onboarding | |
| 109 | 116 | ||
| 110 | 117 | When `tls_port` is non-zero, direct HTTP clients (those not arriving through a trusted proxy) are restricted to CA onboarding paths only: | |
| 111 | 118 | ||
| 112 | - | - `/`, `/ca`, `/ca.crt`, `/ca.mobileconfig` — serve the onboarding page and CA certificate downloads | |
| 119 | + | - `/.well-known/gordon/`, `/.well-known/gordon/ca`, `/.well-known/gordon/ca.crt`, `/.well-known/gordon/ca.mobileconfig` — serve the onboarding page and CA certificate downloads | |
| 120 | + | - `/`, `/ca`, `/ca.crt`, `/ca.mobileconfig` — legacy compatibility aliases for direct HTTP onboarding | |
| 113 | 121 | - `/.well-known/acme-challenge/*` — serves ACME HTTP-01 challenges when public ACME HTTP-01 is enabled, otherwise returns `404 Not Found` | |
| 114 | 122 | - All other paths return `403 Forbidden` | |
| 115 | 123 | ||
| 116 | 124 | 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. | |
| 117 | 125 | ||
| 118 | - | The onboarding page is also available on HTTPS at `/ca`. | |
| 126 | + | On HTTPS, onboarding paths are served only on `server.gordon_domain`; app hosts can use `/ca` for their own routes without Gordon intercepting it. | |
| 119 | 127 | ||
| 120 | 128 | #### HTTP to HTTPS redirect | |
| 121 | 129 | ||
| @@ -95,7 +95,8 @@ tls_port = 8443 | |||||
| 95 | [tls.acme] | 95 | [tls.acme] | ||
| 96 | enabled = true | 96 | enabled = true | ||
| 97 | email = "admin@example.com" | 97 | email = "admin@example.com" | ||
| 98 | - | challenge = "auto" # auto, http-01, or cloudflare-dns-01 | 98 | + | challenge = "auto" # auto, http-01, or cloudflare-dns-01 |
| 99 | + | obtain_batch_size = 1 # maximum new certificate orders per reconcile run | |||
| 99 | ``` | 100 | ``` | ||
| 100 | 101 | ||||
| 101 | Challenge behavior: | 102 | Challenge behavior: | ||
| @@ -103,19 +104,26 @@ Challenge behavior: | |||||
| 103 | - `cloudflare-dns-01` uses a Cloudflare API token from `pass`, `GORDON_CLOUDFLARE_API_TOKEN_FILE`, or `GORDON_CLOUDFLARE_API_TOKEN`. | 104 | - `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 | - `auto` selects Cloudflare DNS-01 when a token is available, otherwise falls back to HTTP-01. | ||
| 105 | 106 | ||||
| 107 | + | HTTP-01 requires public access to port 80 for each hostname being validated. If you use Cloudflare in DNS-only/gray-cloud mode, your firewall/NAT must allow direct public traffic to Gordon's HTTP proxy port. A firewall rule that only allows Cloudflare source IPs on port 80 is compatible with orange-cloud proxying, but it blocks gray-cloud HTTP-01 validation; use DNS-01 or temporarily open port 80 for direct validation. | |||
| 108 | + | ||||
| 109 | + | Gordon limits new ACME certificate orders to `obtain_batch_size` per reconcile run (default `1`) so enabling ACME on an existing multi-route server does not burst through every route and hit Let's Encrypt rate limits. Later reloads, restarts, or renewal-loop reconciles continue issuing remaining certificates. | |||
| 110 | + | ||||
| 111 | + | For Cloudflare Full/Strict, Cloudflare terminates browser TLS at the edge and connects to Gordon over HTTPS. A public ACME certificate served by Gordon is the preferred origin certificate because Cloudflare Strict can validate it without custom origin trust. Cloudflare Flexible mode (HTTPS at the edge, HTTP to Gordon) remains a legacy/compatibility option, but it is not end-to-end HTTPS. | |||
| 112 | + | ||||
| 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. | 113 | 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 | 114 | ||||
| 108 | #### Direct HTTP Onboarding | 115 | #### Direct HTTP Onboarding | ||
| 109 | 116 | ||||
| 110 | When `tls_port` is non-zero, direct HTTP clients (those not arriving through a trusted proxy) are restricted to CA onboarding paths only: | 117 | When `tls_port` is non-zero, direct HTTP clients (those not arriving through a trusted proxy) are restricted to CA onboarding paths only: | ||
| 111 | 118 | ||||
| 112 | - | - `/`, `/ca`, `/ca.crt`, `/ca.mobileconfig` — serve the onboarding page and CA certificate downloads | 119 | + | - `/.well-known/gordon/`, `/.well-known/gordon/ca`, `/.well-known/gordon/ca.crt`, `/.well-known/gordon/ca.mobileconfig` — serve the onboarding page and CA certificate downloads |
| 120 | + | - `/`, `/ca`, `/ca.crt`, `/ca.mobileconfig` — legacy compatibility aliases for direct HTTP onboarding | |||
| 113 | - `/.well-known/acme-challenge/*` — serves ACME HTTP-01 challenges when public ACME HTTP-01 is enabled, otherwise returns `404 Not Found` | 121 | - `/.well-known/acme-challenge/*` — serves ACME HTTP-01 challenges when public ACME HTTP-01 is enabled, otherwise returns `404 Not Found` | ||
| 114 | - All other paths return `403 Forbidden` | 122 | - All other paths return `403 Forbidden` | ||
| 115 | 123 | ||||
| 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. | 124 | 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. | ||
| 117 | 125 | ||||
| 118 | - | The onboarding page is also available on HTTPS at `/ca`. | 126 | + | On HTTPS, onboarding paths are served only on `server.gordon_domain`; app hosts can use `/ca` for their own routes without Gordon intercepting it. |
| 119 | 127 | ||||
| 120 | #### HTTP to HTTPS redirect | 128 | #### HTTP to HTTPS redirect | ||
| 121 | 129 | ||||
A docs/superpowers/plans/2026-04-30-acme-http-onboarding-hardening.md +110 -0
| @@ -0,0 +1,110 @@ | |||
| 1 | + | # ACME HTTP Onboarding Hardening Implementation Plan | |
| 2 | + | ||
| 3 | + | > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan phase-by-phase. Each phase contains sub-tasks with checkbox steps (`- [ ]`) for tracking. Review gates happen at the end of each phase, not after every sub-task. | |
| 4 | + | ||
| 5 | + | **Goal:** Make Gordon's public HTTP/TLS behavior safe and practical for ACME HTTP-01, Cloudflare Full/Strict origin HTTPS, and internal CA onboarding without stealing application paths. | |
| 6 | + | ||
| 7 | + | **Architecture:** Keep ACME HTTP-01 as the highest-priority public HTTP route. Move CA onboarding resources into a Gordon-owned `/.well-known/gordon/*` namespace, keep legacy direct-HTTP onboarding paths for compatibility, and gate HTTPS onboarding to `server.gordon_domain` so app hosts can own `/ca`. Delay runtime ACME reconciliation until listeners are bound. Treat Cloudflare Flexible HTTP-origin mode as legacy/compat; the target mode is Cloudflare Full/Strict with Cloudflare connecting to Gordon over HTTPS and validating a Gordon-served certificate. Limit ACME obtains per reconcile run so enabling ACME on an existing multi-route host does not burst through every route and risk Let's Encrypt rate limits. | |
| 8 | + | ||
| 9 | + | **Tech Stack:** Go 1.25, `net/http`, Cobra CLI, Gordon hexagonal app/usecase/adapters, testify tests. | |
| 10 | + | ||
| 11 | + | --- | |
| 12 | + | ||
| 13 | + | ### Phase 1: Failing behavior tests | |
| 14 | + | ||
| 15 | + | **Goal:** Codify desired HTTP/onboarding routing and ACME startup timing before production edits. | |
| 16 | + | ||
| 17 | + | **Files:** | |
| 18 | + | - Modify: `internal/app/run_http_handlers_test.go` | |
| 19 | + | - Modify/Create: `internal/app/public_tls_startup_test.go` if helper isolation is needed | |
| 20 | + | ||
| 21 | + | #### Sub-task 1: Well-known HTTP onboarding paths | |
| 22 | + | ||
| 23 | + | - [ ] Add tests showing direct HTTP serves `/.well-known/gordon/`, `/.well-known/gordon/ca.crt`, and `/.well-known/gordon/ca.mobileconfig`. | |
| 24 | + | - [ ] Keep existing direct HTTP `/`, `/ca`, `/ca.crt`, `/ca.mobileconfig` compatibility tests green. | |
| 25 | + | - [ ] Run `rtk go test ./internal/app/... -run 'TestCreateHTTPHandlers_DirectHTTP.*Gordon|TestCreateHTTPHandlers_DirectHTTP.*CA'` and confirm new tests fail before implementation. | |
| 26 | + | ||
| 27 | + | #### Sub-task 2: HTTPS host gating | |
| 28 | + | ||
| 29 | + | - [ ] Add tests showing `httpsHandler` serves `/ca` only when `req.Host == cfg.Server.GordonDomain`. | |
| 30 | + | - [ ] Add tests showing `/ca` on an arbitrary app host reaches the proxy path instead of onboarding. | |
| 31 | + | - [ ] Run `rtk go test ./internal/app/... -run 'TestCreateHTTPHandlers_HTTPSOnboarding'` and confirm the arbitrary-host test fails before implementation. | |
| 32 | + | ||
| 33 | + | #### Sub-task 3: Delayed ACME runtime startup | |
| 34 | + | ||
| 35 | + | - [ ] Add a small app-level test helper/fake proving runtime public TLS `Reconcile` and `StartRenewalLoop` happen after proxy readiness. | |
| 36 | + | - [ ] Run the targeted test and confirm it fails with current service-construction-time reconciliation. | |
| 37 | + | ||
| 38 | + | #### Sub-task 4: ACME obtain batch limiting | |
| 39 | + | ||
| 40 | + | - [ ] Add usecase tests proving one reconcile run obtains only a bounded number of missing certificates. | |
| 41 | + | - [ ] Add a test proving a later reconcile run can continue with the remaining missing certificates. | |
| 42 | + | - [ ] Run `rtk go test ./internal/usecase/publictls/... -run 'Batch|Limit|Reconcile'` and confirm the new batch-limit tests fail before implementation. | |
| 43 | + | ||
| 44 | + | **Phase review checkpoint:** Confirm tests express product behavior, not implementation details. | |
| 45 | + | ||
| 46 | + | --- | |
| 47 | + | ||
| 48 | + | ### Phase 2: Implementation | |
| 49 | + | ||
| 50 | + | **Goal:** Pass tests with minimal production changes. | |
| 51 | + | ||
| 52 | + | **Files:** | |
| 53 | + | - Modify: `internal/app/run.go` | |
| 54 | + | - Modify: `internal/app/run_http_handlers_test.go` | |
| 55 | + | ||
| 56 | + | #### Sub-task 1: Onboarding route changes | |
| 57 | + | ||
| 58 | + | - [ ] Add `/.well-known/gordon/` direct HTTP onboarding routes. | |
| 59 | + | - [ ] Keep legacy direct HTTP `/`, `/ca`, `/ca.crt`, `/ca.mobileconfig` routes. | |
| 60 | + | - [ ] Replace unconditional HTTPS `/ca*` registrations with a host-gated handler that only serves onboarding on `server.gordon_domain`. | |
| 61 | + | ||
| 62 | + | #### Sub-task 2: Delayed runtime ACME startup | |
| 63 | + | ||
| 64 | + | - [ ] Change runtime service creation to initialize public TLS without immediate reconcile/renewal loop side effects. | |
| 65 | + | - [ ] After registry, HTTP proxy, and TLS listeners are ready, call public TLS `Reconcile` and `StartRenewalLoop`. | |
| 66 | + | - [ ] Preserve local CLI read-only behavior: `NewKernelQuiet` must still avoid reconcile/renewal loop side effects. | |
| 67 | + | ||
| 68 | + | #### Sub-task 3: ACME obtain batch limiting | |
| 69 | + | ||
| 70 | + | - [ ] Add a conservative default obtain batch size for runtime reconciliation. | |
| 71 | + | - [ ] Make `Service.Reconcile` process at most the configured number of missing targets per run. | |
| 72 | + | - [ ] Log when targets remain pending so operators understand that issuance is intentionally staged. | |
| 73 | + | ||
| 74 | + | #### Sub-task 4: Compatibility check | |
| 75 | + | ||
| 76 | + | - [ ] Verify ACME challenge handler is still registered before onboarding/proxy handlers. | |
| 77 | + | - [ ] Verify trusted proxy traffic keeps existing behavior. | |
| 78 | + | - [ ] Verify TLS-disabled HTTP behavior is unchanged. | |
| 79 | + | ||
| 80 | + | **Phase review checkpoint:** Review for startup ordering, goroutine lifecycle, and route conflicts. | |
| 81 | + | ||
| 82 | + | --- | |
| 83 | + | ||
| 84 | + | ### Phase 3: Docs and verification | |
| 85 | + | ||
| 86 | + | **Goal:** Document the operational model and validate the branch. | |
| 87 | + | ||
| 88 | + | **Files:** | |
| 89 | + | - Modify: `docs/config/server.md` | |
| 90 | + | - Modify: `docs/concepts.md` if needed | |
| 91 | + | - Modify: `docs/cli/tls.md` if status docs need wording | |
| 92 | + | - Modify: `docs/config/server.md` to document Cloudflare Full/Strict origin HTTPS expectations | |
| 93 | + | ||
| 94 | + | #### Sub-task 1: Documentation | |
| 95 | + | ||
| 96 | + | - [ ] Document that gray-cloud HTTP-01 requires public/NAT/firewall access to port 80. | |
| 97 | + | - [ ] Document that Cloudflare-only IP allowlisting on port 80 is incompatible with gray-cloud HTTP-01; use DNS-01 or open 80 for direct validation. | |
| 98 | + | - [ ] Document `/.well-known/gordon/*` CA onboarding paths and mark `/ca*` direct HTTP paths as legacy compatibility. | |
| 99 | + | - [ ] Explain Cloudflare flexible HTTP origin as legacy/explicit, not preferred for end-to-end HTTPS. | |
| 100 | + | - [ ] Document Cloudflare Full/Strict expected flow: browser -> Cloudflare HTTPS edge cert, Cloudflare -> Gordon HTTPS origin cert. Public ACME certs served by Gordon are the preferred origin certificate because Cloudflare Strict can validate them without a custom origin trust setup. | |
| 101 | + | ||
| 102 | + | #### Sub-task 2: Verification | |
| 103 | + | ||
| 104 | + | - [ ] Run `rtk gofmt -w` on changed Go files. | |
| 105 | + | - [ ] Run targeted app tests. | |
| 106 | + | - [ ] Run `rtk go test ./...`. | |
| 107 | + | - [ ] Run `rtk golangci-lint run ./...`. | |
| 108 | + | - [ ] Request Go reviewer feedback and fix blocking issues. | |
| 109 | + | ||
| 110 | + | **Phase review checkpoint:** Ensure docs match code and Igor findings. | |
| @@ -0,0 +1,110 @@ | |||||
| 1 | + | # ACME HTTP Onboarding Hardening Implementation Plan | |||
| 2 | + | ||||
| 3 | + | > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan phase-by-phase. Each phase contains sub-tasks with checkbox steps (`- [ ]`) for tracking. Review gates happen at the end of each phase, not after every sub-task. | |||
| 4 | + | ||||
| 5 | + | **Goal:** Make Gordon's public HTTP/TLS behavior safe and practical for ACME HTTP-01, Cloudflare Full/Strict origin HTTPS, and internal CA onboarding without stealing application paths. | |||
| 6 | + | ||||
| 7 | + | **Architecture:** Keep ACME HTTP-01 as the highest-priority public HTTP route. Move CA onboarding resources into a Gordon-owned `/.well-known/gordon/*` namespace, keep legacy direct-HTTP onboarding paths for compatibility, and gate HTTPS onboarding to `server.gordon_domain` so app hosts can own `/ca`. Delay runtime ACME reconciliation until listeners are bound. Treat Cloudflare Flexible HTTP-origin mode as legacy/compat; the target mode is Cloudflare Full/Strict with Cloudflare connecting to Gordon over HTTPS and validating a Gordon-served certificate. Limit ACME obtains per reconcile run so enabling ACME on an existing multi-route host does not burst through every route and risk Let's Encrypt rate limits. | |||
| 8 | + | ||||
| 9 | + | **Tech Stack:** Go 1.25, `net/http`, Cobra CLI, Gordon hexagonal app/usecase/adapters, testify tests. | |||
| 10 | + | ||||
| 11 | + | --- | |||
| 12 | + | ||||
| 13 | + | ### Phase 1: Failing behavior tests | |||
| 14 | + | ||||
| 15 | + | **Goal:** Codify desired HTTP/onboarding routing and ACME startup timing before production edits. | |||
| 16 | + | ||||
| 17 | + | **Files:** | |||
| 18 | + | - Modify: `internal/app/run_http_handlers_test.go` | |||
| 19 | + | - Modify/Create: `internal/app/public_tls_startup_test.go` if helper isolation is needed | |||
| 20 | + | ||||
| 21 | + | #### Sub-task 1: Well-known HTTP onboarding paths | |||
| 22 | + | ||||
| 23 | + | - [ ] Add tests showing direct HTTP serves `/.well-known/gordon/`, `/.well-known/gordon/ca.crt`, and `/.well-known/gordon/ca.mobileconfig`. | |||
| 24 | + | - [ ] Keep existing direct HTTP `/`, `/ca`, `/ca.crt`, `/ca.mobileconfig` compatibility tests green. | |||
| 25 | + | - [ ] Run `rtk go test ./internal/app/... -run 'TestCreateHTTPHandlers_DirectHTTP.*Gordon|TestCreateHTTPHandlers_DirectHTTP.*CA'` and confirm new tests fail before implementation. | |||
| 26 | + | ||||
| 27 | + | #### Sub-task 2: HTTPS host gating | |||
| 28 | + | ||||
| 29 | + | - [ ] Add tests showing `httpsHandler` serves `/ca` only when `req.Host == cfg.Server.GordonDomain`. | |||
| 30 | + | - [ ] Add tests showing `/ca` on an arbitrary app host reaches the proxy path instead of onboarding. | |||
| 31 | + | - [ ] Run `rtk go test ./internal/app/... -run 'TestCreateHTTPHandlers_HTTPSOnboarding'` and confirm the arbitrary-host test fails before implementation. | |||
| 32 | + | ||||
| 33 | + | #### Sub-task 3: Delayed ACME runtime startup | |||
| 34 | + | ||||
| 35 | + | - [ ] Add a small app-level test helper/fake proving runtime public TLS `Reconcile` and `StartRenewalLoop` happen after proxy readiness. | |||
| 36 | + | - [ ] Run the targeted test and confirm it fails with current service-construction-time reconciliation. | |||
| 37 | + | ||||
| 38 | + | #### Sub-task 4: ACME obtain batch limiting | |||
| 39 | + | ||||
| 40 | + | - [ ] Add usecase tests proving one reconcile run obtains only a bounded number of missing certificates. | |||
| 41 | + | - [ ] Add a test proving a later reconcile run can continue with the remaining missing certificates. | |||
| 42 | + | - [ ] Run `rtk go test ./internal/usecase/publictls/... -run 'Batch|Limit|Reconcile'` and confirm the new batch-limit tests fail before implementation. | |||
| 43 | + | ||||
| 44 | + | **Phase review checkpoint:** Confirm tests express product behavior, not implementation details. | |||
| 45 | + | ||||
| 46 | + | --- | |||
| 47 | + | ||||
| 48 | + | ### Phase 2: Implementation | |||
| 49 | + | ||||
| 50 | + | **Goal:** Pass tests with minimal production changes. | |||
| 51 | + | ||||
| 52 | + | **Files:** | |||
| 53 | + | - Modify: `internal/app/run.go` | |||
| 54 | + | - Modify: `internal/app/run_http_handlers_test.go` | |||
| 55 | + | ||||
| 56 | + | #### Sub-task 1: Onboarding route changes | |||
| 57 | + | ||||
| 58 | + | - [ ] Add `/.well-known/gordon/` direct HTTP onboarding routes. | |||
| 59 | + | - [ ] Keep legacy direct HTTP `/`, `/ca`, `/ca.crt`, `/ca.mobileconfig` routes. | |||
| 60 | + | - [ ] Replace unconditional HTTPS `/ca*` registrations with a host-gated handler that only serves onboarding on `server.gordon_domain`. | |||
| 61 | + | ||||
| 62 | + | #### Sub-task 2: Delayed runtime ACME startup | |||
| 63 | + | ||||
| 64 | + | - [ ] Change runtime service creation to initialize public TLS without immediate reconcile/renewal loop side effects. | |||
| 65 | + | - [ ] After registry, HTTP proxy, and TLS listeners are ready, call public TLS `Reconcile` and `StartRenewalLoop`. | |||
| 66 | + | - [ ] Preserve local CLI read-only behavior: `NewKernelQuiet` must still avoid reconcile/renewal loop side effects. | |||
| 67 | + | ||||
| 68 | + | #### Sub-task 3: ACME obtain batch limiting | |||
| 69 | + | ||||
| 70 | + | - [ ] Add a conservative default obtain batch size for runtime reconciliation. | |||
| 71 | + | - [ ] Make `Service.Reconcile` process at most the configured number of missing targets per run. | |||
| 72 | + | - [ ] Log when targets remain pending so operators understand that issuance is intentionally staged. | |||
| 73 | + | ||||
| 74 | + | #### Sub-task 4: Compatibility check | |||
| 75 | + | ||||
| 76 | + | - [ ] Verify ACME challenge handler is still registered before onboarding/proxy handlers. | |||
| 77 | + | - [ ] Verify trusted proxy traffic keeps existing behavior. | |||
| 78 | + | - [ ] Verify TLS-disabled HTTP behavior is unchanged. | |||
| 79 | + | ||||
| 80 | + | **Phase review checkpoint:** Review for startup ordering, goroutine lifecycle, and route conflicts. | |||
| 81 | + | ||||
| 82 | + | --- | |||
| 83 | + | ||||
| 84 | + | ### Phase 3: Docs and verification | |||
| 85 | + | ||||
| 86 | + | **Goal:** Document the operational model and validate the branch. | |||
| 87 | + | ||||
| 88 | + | **Files:** | |||
| 89 | + | - Modify: `docs/config/server.md` | |||
| 90 | + | - Modify: `docs/concepts.md` if needed | |||
| 91 | + | - Modify: `docs/cli/tls.md` if status docs need wording | |||
| 92 | + | - Modify: `docs/config/server.md` to document Cloudflare Full/Strict origin HTTPS expectations | |||
| 93 | + | ||||
| 94 | + | #### Sub-task 1: Documentation | |||
| 95 | + | ||||
| 96 | + | - [ ] Document that gray-cloud HTTP-01 requires public/NAT/firewall access to port 80. | |||
| 97 | + | - [ ] Document that Cloudflare-only IP allowlisting on port 80 is incompatible with gray-cloud HTTP-01; use DNS-01 or open 80 for direct validation. | |||
| 98 | + | - [ ] Document `/.well-known/gordon/*` CA onboarding paths and mark `/ca*` direct HTTP paths as legacy compatibility. | |||
| 99 | + | - [ ] Explain Cloudflare flexible HTTP origin as legacy/explicit, not preferred for end-to-end HTTPS. | |||
| 100 | + | - [ ] Document Cloudflare Full/Strict expected flow: browser -> Cloudflare HTTPS edge cert, Cloudflare -> Gordon HTTPS origin cert. Public ACME certs served by Gordon are the preferred origin certificate because Cloudflare Strict can validate them without a custom origin trust setup. | |||
| 101 | + | ||||
| 102 | + | #### Sub-task 2: Verification | |||
| 103 | + | ||||
| 104 | + | - [ ] Run `rtk gofmt -w` on changed Go files. | |||
| 105 | + | - [ ] Run targeted app tests. | |||
| 106 | + | - [ ] Run `rtk go test ./...`. | |||
| 107 | + | - [ ] Run `rtk golangci-lint run ./...`. | |||
| 108 | + | - [ ] Request Go reviewer feedback and fix blocking issues. | |||
| 109 | + | ||||
| 110 | + | **Phase review checkpoint:** Ensure docs match code and Igor findings. | |||
M internal/app/acme_config_test.go +1 -0
| @@ -14,4 +14,5 @@ func TestLoadConfig_ACMEDefaults(t *testing.T) { | |||
| 14 | 14 | assert.False(t, v.GetBool("tls.acme.enabled")) | |
| 15 | 15 | assert.Equal(t, "", v.GetString("tls.acme.email")) | |
| 16 | 16 | assert.Equal(t, "auto", v.GetString("tls.acme.challenge")) | |
| 17 | + | assert.Equal(t, 1, v.GetInt("tls.acme.obtain_batch_size")) | |
| 17 | 18 | } | |
| @@ -14,4 +14,5 @@ func TestLoadConfig_ACMEDefaults(t *testing.T) { | |||||
| 14 | assert.False(t, v.GetBool("tls.acme.enabled")) | 14 | assert.False(t, v.GetBool("tls.acme.enabled")) | ||
| 15 | assert.Equal(t, "", v.GetString("tls.acme.email")) | 15 | assert.Equal(t, "", v.GetString("tls.acme.email")) | ||
| 16 | assert.Equal(t, "auto", v.GetString("tls.acme.challenge")) | 16 | assert.Equal(t, "auto", v.GetString("tls.acme.challenge")) | ||
| 17 | + | assert.Equal(t, 1, v.GetInt("tls.acme.obtain_batch_size")) | |||
| 17 | } | 18 | } | ||
M internal/app/run.go +80 -19
| @@ -198,9 +198,10 @@ type Config struct { | |||
| 198 | 198 | ||
| 199 | 199 | TLS struct { | |
| 200 | 200 | ACME struct { | |
| 201 | - | Enabled bool `mapstructure:"enabled"` | |
| 202 | - | Email string `mapstructure:"email"` | |
| 203 | - | Challenge string `mapstructure:"challenge"` | |
| 201 | + | Enabled bool `mapstructure:"enabled"` | |
| 202 | + | Email string `mapstructure:"email"` | |
| 203 | + | Challenge string `mapstructure:"challenge"` | |
| 204 | + | ObtainBatchSize int `mapstructure:"obtain_batch_size"` | |
| 204 | 205 | } `mapstructure:"acme"` | |
| 205 | 206 | } `mapstructure:"tls"` | |
| 206 | 207 | } | |
| @@ -239,6 +240,7 @@ type services struct { | |||
| 239 | 240 | pkiSvc *pkiusecase.Service | |
| 240 | 241 | reloadCoordinator *reloadCoordinator | |
| 241 | 242 | publicTLSSvc in.PublicTLSService | |
| 243 | + | publicTLSRuntime publicTLSRuntime | |
| 242 | 244 | registryHandler interface { | |
| 243 | 245 | UpdateBlobLimits(maxBlobChunkSize, maxBlobSize int64) | |
| 244 | 246 | } | |
| @@ -419,7 +421,7 @@ func initAccessLog(cfg Config, log zerowrap.Logger) (*accesslog.Writer, error) { | |||
| 419 | 421 | // serviceOptions controls optional behavior for createServicesWithOptions. | |
| 420 | 422 | type serviceOptions struct { | |
| 421 | 423 | // StartPublicTLS controls whether ACME Reconcile and renewal loop are started. | |
| 422 | - | // Server runtime sets true; Kernel/local CLI sets false (read-only status). | |
| 424 | + | // Runtime starts public TLS after listeners bind; local CLI uses false for read-only operations. | |
| 423 | 425 | StartPublicTLS bool | |
| 424 | 426 | } | |
| 425 | 427 | ||
| @@ -434,9 +436,9 @@ type serviceInit struct { | |||
| 434 | 436 | } | |
| 435 | 437 | ||
| 436 | 438 | // createServices creates all the application services for server runtime. | |
| 437 | - | // It calls createServicesWithOptions with StartPublicTLS: true. | |
| 439 | + | // Public ACME reconciliation is started later, after the HTTP listener is bound. | |
| 438 | 440 | 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}) | |
| 441 | + | return createServicesWithOptions(ctx, v, cfg, log, serviceOptions{StartPublicTLS: false}) | |
| 440 | 442 | } | |
| 441 | 443 | ||
| 442 | 444 | // createServicesWithOptions creates all the application services with the given options. | |
| @@ -555,12 +557,13 @@ func (si *serviceInit) initPublicTLS() error { | |||
| 555 | 557 | log := si.log | |
| 556 | 558 | ||
| 557 | 559 | 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), | |
| 560 | + | Enabled: si.cfg.TLS.ACME.Enabled, | |
| 561 | + | Email: si.cfg.TLS.ACME.Email, | |
| 562 | + | Challenge: si.cfg.TLS.ACME.Challenge, | |
| 563 | + | HTTPPort: si.cfg.Server.Port, | |
| 564 | + | TLSPort: si.cfg.Server.TLSPort, | |
| 565 | + | DataDir: resolveDataDir(si.cfg.Server.DataDir), | |
| 566 | + | ObtainBatchSize: si.cfg.TLS.ACME.ObtainBatchSize, | |
| 564 | 567 | } | |
| 565 | 568 | ||
| 566 | 569 | tokenResolver := secrets.NewPublicTLSResolver(secrets.PublicTLSResolverConfig{}) | |
| @@ -607,10 +610,9 @@ func (si *serviceInit) initPublicTLS() error { | |||
| 607 | 610 | } | |
| 608 | 611 | ||
| 609 | 612 | if si.startPublicTLS { | |
| 610 | - | if err := svc.Reconcile(ctx); err != nil { | |
| 611 | - | log.Warn().Err(err).Msg("failed to reconcile ACME certificates, continuing") | |
| 613 | + | if err := startPublicTLSRuntime(ctx, svc, log); err != nil { | |
| 614 | + | log.Warn().Err(err).Msg("failed to start public ACME TLS runtime, continuing") | |
| 612 | 615 | } | |
| 613 | - | svc.StartRenewalLoop(ctx, time.Hour) | |
| 614 | 616 | log.Info(). | |
| 615 | 617 | Str("email", si.cfg.TLS.ACME.Email). | |
| 616 | 618 | Str("challenge", string(effective.Mode)). | |
| @@ -624,6 +626,24 @@ func (si *serviceInit) initPublicTLS() error { | |||
| 624 | 626 | } | |
| 625 | 627 | ||
| 626 | 628 | si.svc.publicTLSSvc = svc | |
| 629 | + | si.svc.publicTLSRuntime = svc | |
| 630 | + | return nil | |
| 631 | + | } | |
| 632 | + | ||
| 633 | + | type publicTLSRuntime interface { | |
| 634 | + | Reconcile(context.Context) error | |
| 635 | + | StartRenewalLoop(context.Context, time.Duration) <-chan struct{} | |
| 636 | + | } | |
| 637 | + | ||
| 638 | + | func startPublicTLSRuntime(ctx context.Context, svc publicTLSRuntime, log zerowrap.Logger) error { | |
| 639 | + | if svc == nil { | |
| 640 | + | return nil | |
| 641 | + | } | |
| 642 | + | if err := svc.Reconcile(ctx); err != nil { | |
| 643 | + | return err | |
| 644 | + | } | |
| 645 | + | svc.StartRenewalLoop(ctx, time.Hour) | |
| 646 | + | log.Info().Msg("public ACME TLS runtime started") | |
| 627 | 647 | return nil | |
| 628 | 648 | } | |
| 629 | 649 | ||
| @@ -2099,11 +2119,10 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger, accessWr | |||
| 2099 | 2119 | ||
| 2100 | 2120 | httpsMux := http.NewServeMux() | |
| 2101 | 2121 | if obHandler != nil { | |
| 2102 | - | httpsMux.HandleFunc("GET /ca", obHandler.ServeOnboardingPage) | |
| 2103 | - | httpsMux.HandleFunc("GET /ca.crt", obHandler.ServeCACert) | |
| 2104 | - | httpsMux.HandleFunc("GET /ca.mobileconfig", obHandler.ServeMobileconfig) | |
| 2122 | + | httpsMux.Handle("/", gordonDomainOnboardingGate(cfg.Server.GordonDomain, obHandler, proxyHandler)) | |
| 2123 | + | } else { | |
| 2124 | + | httpsMux.Handle("/", proxyHandler) | |
| 2105 | 2125 | } | |
| 2106 | - | httpsMux.Handle("/", proxyHandler) | |
| 2107 | 2126 | ||
| 2108 | 2127 | httpsHandler := otelhttp.NewHandler(middleware.Chain(httpsProxyMiddlewares...)(httpsMux), "gordon.proxy.tls") | |
| 2109 | 2128 | ||
| @@ -2130,6 +2149,10 @@ func directHTTPOnboardingGate(ob *onboarding.Handler, proxyNets []*net.IPNet, pr | |||
| 2130 | 2149 | // Build a small mux for direct-client onboarding paths. | |
| 2131 | 2150 | onboardingMux := http.NewServeMux() | |
| 2132 | 2151 | onboardingMux.HandleFunc("GET /{$}", ob.ServeOnboardingPage) | |
| 2152 | + | onboardingMux.HandleFunc("GET /.well-known/gordon/", ob.ServeOnboardingPage) | |
| 2153 | + | onboardingMux.HandleFunc("GET /.well-known/gordon/ca", ob.ServeOnboardingPage) | |
| 2154 | + | onboardingMux.HandleFunc("GET /.well-known/gordon/ca.crt", ob.ServeCACert) | |
| 2155 | + | onboardingMux.HandleFunc("GET /.well-known/gordon/ca.mobileconfig", ob.ServeMobileconfig) | |
| 2133 | 2156 | onboardingMux.HandleFunc("GET /ca", ob.ServeOnboardingPage) | |
| 2134 | 2157 | onboardingMux.HandleFunc("GET /ca.crt", ob.ServeCACert) | |
| 2135 | 2158 | onboardingMux.HandleFunc("GET /ca.mobileconfig", ob.ServeMobileconfig) | |
| @@ -2159,6 +2182,35 @@ func directHTTPOnboardingGate(ob *onboarding.Handler, proxyNets []*net.IPNet, pr | |||
| 2159 | 2182 | }) | |
| 2160 | 2183 | } | |
| 2161 | 2184 | ||
| 2185 | + | func requestHostMatches(host, expected string) bool { | |
| 2186 | + | host = strings.TrimSpace(host) | |
| 2187 | + | if h, _, err := net.SplitHostPort(host); err == nil { | |
| 2188 | + | host = h | |
| 2189 | + | } | |
| 2190 | + | host = strings.TrimSuffix(strings.ToLower(host), ".") | |
| 2191 | + | expected = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(expected)), ".") | |
| 2192 | + | return host == expected | |
| 2193 | + | } | |
| 2194 | + | ||
| 2195 | + | func gordonDomainOnboardingGate(gordonDomain string, ob *onboarding.Handler, proxyHandler http.Handler) http.Handler { | |
| 2196 | + | mux := http.NewServeMux() | |
| 2197 | + | mux.HandleFunc("GET /ca", ob.ServeOnboardingPage) | |
| 2198 | + | mux.HandleFunc("GET /ca.crt", ob.ServeCACert) | |
| 2199 | + | mux.HandleFunc("GET /ca.mobileconfig", ob.ServeMobileconfig) | |
| 2200 | + | mux.HandleFunc("GET /.well-known/gordon/", ob.ServeOnboardingPage) | |
| 2201 | + | mux.HandleFunc("GET /.well-known/gordon/ca", ob.ServeOnboardingPage) | |
| 2202 | + | mux.HandleFunc("GET /.well-known/gordon/ca.crt", ob.ServeCACert) | |
| 2203 | + | mux.HandleFunc("GET /.well-known/gordon/ca.mobileconfig", ob.ServeMobileconfig) | |
| 2204 | + | ||
| 2205 | + | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 2206 | + | if gordonDomain != "" && requestHostMatches(r.Host, gordonDomain) { | |
| 2207 | + | mux.ServeHTTP(w, r) | |
| 2208 | + | return | |
| 2209 | + | } | |
| 2210 | + | proxyHandler.ServeHTTP(w, r) | |
| 2211 | + | }) | |
| 2212 | + | } | |
| 2213 | + | ||
| 2162 | 2214 | // directHTTPForbidden responds with 403 for non-onboarding HTTP paths. | |
| 2163 | 2215 | // HEAD requests get an empty body per HTTP semantics. | |
| 2164 | 2216 | func directHTTPForbidden(w http.ResponseWriter, r *http.Request) { | |
| @@ -2481,6 +2533,8 @@ func runServers(ctx context.Context, v *viper.Viper, cfg Config, svc *services, | |||
| 2481 | 2533 | } | |
| 2482 | 2534 | logEvent.Msg("Gordon is running") | |
| 2483 | 2535 | ||
| 2536 | + | startPublicTLSRuntimeWithWarning(ctx, svc.publicTLSRuntime, log) | |
| 2537 | + | ||
| 2484 | 2538 | schedulerCleanup, err := startOptionalSchedulers(ctx, cfg, svc, log, v) | |
| 2485 | 2539 | if err != nil { | |
| 2486 | 2540 | return err | |
| @@ -2498,6 +2552,12 @@ func runServers(ctx context.Context, v *viper.Viper, cfg Config, svc *services, | |||
| 2498 | 2552 | return nil | |
| 2499 | 2553 | } | |
| 2500 | 2554 | ||
| 2555 | + | func startPublicTLSRuntimeWithWarning(ctx context.Context, svc publicTLSRuntime, log zerowrap.Logger) { | |
| 2556 | + | if err := startPublicTLSRuntime(ctx, svc, log); err != nil { | |
| 2557 | + | log.Warn().Err(err).Msg("failed to start public ACME TLS runtime, continuing") | |
| 2558 | + | } | |
| 2559 | + | } | |
| 2560 | + | ||
| 2501 | 2561 | func waitForServerReady(ready <-chan struct{}, errChan <-chan error) error { | |
| 2502 | 2562 | select { | |
| 2503 | 2563 | case <-ready: | |
| @@ -3219,6 +3279,7 @@ func loadConfig(v *viper.Viper, configPath string) error { | |||
| 3219 | 3279 | v.SetDefault("tls.acme.enabled", false) | |
| 3220 | 3280 | v.SetDefault("tls.acme.email", "") | |
| 3221 | 3281 | v.SetDefault("tls.acme.challenge", "auto") | |
| 3282 | + | v.SetDefault("tls.acme.obtain_batch_size", 1) | |
| 3222 | 3283 | v.SetDefault("server.force_https_redirect", false) | |
| 3223 | 3284 | v.SetDefault("server.data_dir", DefaultDataDir()) | |
| 3224 | 3285 | v.SetDefault("server.runtime", "auto") | |
| @@ -198,9 +198,10 @@ type Config struct { | |||||
| 198 | 198 | ||||
| 199 | TLS struct { | 199 | TLS struct { | ||
| 200 | ACME struct { | 200 | ACME struct { | ||
| 201 | - | Enabled bool `mapstructure:"enabled"` | 201 | + | Enabled bool `mapstructure:"enabled"` |
| 202 | - | Email string `mapstructure:"email"` | 202 | + | Email string `mapstructure:"email"` |
| 203 | - | Challenge string `mapstructure:"challenge"` | 203 | + | Challenge string `mapstructure:"challenge"` |
| 204 | + | ObtainBatchSize int `mapstructure:"obtain_batch_size"` | |||
| 204 | } `mapstructure:"acme"` | 205 | } `mapstructure:"acme"` | ||
| 205 | } `mapstructure:"tls"` | 206 | } `mapstructure:"tls"` | ||
| 206 | } | 207 | } | ||
| @@ -239,6 +240,7 @@ type services struct { | |||||
| 239 | pkiSvc *pkiusecase.Service | 240 | pkiSvc *pkiusecase.Service | ||
| 240 | reloadCoordinator *reloadCoordinator | 241 | reloadCoordinator *reloadCoordinator | ||
| 241 | publicTLSSvc in.PublicTLSService | 242 | publicTLSSvc in.PublicTLSService | ||
| 243 | + | publicTLSRuntime publicTLSRuntime | |||
| 242 | registryHandler interface { | 244 | registryHandler interface { | ||
| 243 | UpdateBlobLimits(maxBlobChunkSize, maxBlobSize int64) | 245 | UpdateBlobLimits(maxBlobChunkSize, maxBlobSize int64) | ||
| 244 | } | 246 | } | ||
| @@ -419,7 +421,7 @@ func initAccessLog(cfg Config, log zerowrap.Logger) (*accesslog.Writer, error) { | |||||
| 419 | // serviceOptions controls optional behavior for createServicesWithOptions. | 421 | // serviceOptions controls optional behavior for createServicesWithOptions. | ||
| 420 | type serviceOptions struct { | 422 | type serviceOptions struct { | ||
| 421 | // StartPublicTLS controls whether ACME Reconcile and renewal loop are started. | 423 | // StartPublicTLS controls whether ACME Reconcile and renewal loop are started. | ||
| 422 | - | // Server runtime sets true; Kernel/local CLI sets false (read-only status). | 424 | + | // Runtime starts public TLS after listeners bind; local CLI uses false for read-only operations. |
| 423 | StartPublicTLS bool | 425 | StartPublicTLS bool | ||
| 424 | } | 426 | } | ||
| 425 | 427 | ||||
| @@ -434,9 +436,9 @@ type serviceInit struct { | |||||
| 434 | } | 436 | } | ||
| 435 | 437 | ||||
| 436 | // createServices creates all the application services for server runtime. | 438 | // createServices creates all the application services for server runtime. | ||
| 437 | - | // It calls createServicesWithOptions with StartPublicTLS: true. | 439 | + | // Public ACME reconciliation is started later, after the HTTP listener is bound. |
| 438 | func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowrap.Logger) (_ *services, retErr error) { | 440 | 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}) | 441 | + | return createServicesWithOptions(ctx, v, cfg, log, serviceOptions{StartPublicTLS: false}) |
| 440 | } | 442 | } | ||
| 441 | 443 | ||||
| 442 | // createServicesWithOptions creates all the application services with the given options. | 444 | // createServicesWithOptions creates all the application services with the given options. | ||
| @@ -555,12 +557,13 @@ func (si *serviceInit) initPublicTLS() error { | |||||
| 555 | log := si.log | 557 | log := si.log | ||
| 556 | 558 | ||||
| 557 | publicTLSCfg := publictls.Config{ | 559 | publicTLSCfg := publictls.Config{ | ||
| 558 | - | Enabled: si.cfg.TLS.ACME.Enabled, | 560 | + | Enabled: si.cfg.TLS.ACME.Enabled, |
| 559 | - | Email: si.cfg.TLS.ACME.Email, | 561 | + | Email: si.cfg.TLS.ACME.Email, |
| 560 | - | Challenge: si.cfg.TLS.ACME.Challenge, | 562 | + | Challenge: si.cfg.TLS.ACME.Challenge, |
| 561 | - | HTTPPort: si.cfg.Server.Port, | 563 | + | HTTPPort: si.cfg.Server.Port, |
| 562 | - | TLSPort: si.cfg.Server.TLSPort, | 564 | + | TLSPort: si.cfg.Server.TLSPort, |
| 563 | - | DataDir: resolveDataDir(si.cfg.Server.DataDir), | 565 | + | DataDir: resolveDataDir(si.cfg.Server.DataDir), |
| 566 | + | ObtainBatchSize: si.cfg.TLS.ACME.ObtainBatchSize, | |||
| 564 | } | 567 | } | ||
| 565 | 568 | ||||
| 566 | tokenResolver := secrets.NewPublicTLSResolver(secrets.PublicTLSResolverConfig{}) | 569 | tokenResolver := secrets.NewPublicTLSResolver(secrets.PublicTLSResolverConfig{}) | ||
| @@ -607,10 +610,9 @@ func (si *serviceInit) initPublicTLS() error { | |||||
| 607 | } | 610 | } | ||
| 608 | 611 | ||||
| 609 | if si.startPublicTLS { | 612 | if si.startPublicTLS { | ||
| 610 | - | if err := svc.Reconcile(ctx); err != nil { | 613 | + | if err := startPublicTLSRuntime(ctx, svc, log); err != nil { |
| 611 | - | log.Warn().Err(err).Msg("failed to reconcile ACME certificates, continuing") | 614 | + | log.Warn().Err(err).Msg("failed to start public ACME TLS runtime, continuing") |
| 612 | } | 615 | } | ||
| 613 | - | svc.StartRenewalLoop(ctx, time.Hour) | |||
| 614 | log.Info(). | 616 | log.Info(). | ||
| 615 | Str("email", si.cfg.TLS.ACME.Email). | 617 | Str("email", si.cfg.TLS.ACME.Email). | ||
| 616 | Str("challenge", string(effective.Mode)). | 618 | Str("challenge", string(effective.Mode)). | ||
| @@ -624,6 +626,24 @@ func (si *serviceInit) initPublicTLS() error { | |||||
| 624 | } | 626 | } | ||
| 625 | 627 | ||||
| 626 | si.svc.publicTLSSvc = svc | 628 | si.svc.publicTLSSvc = svc | ||
| 629 | + | si.svc.publicTLSRuntime = svc | |||
| 630 | + | return nil | |||
| 631 | + | } | |||
| 632 | + | ||||
| 633 | + | type publicTLSRuntime interface { | |||
| 634 | + | Reconcile(context.Context) error | |||
| 635 | + | StartRenewalLoop(context.Context, time.Duration) <-chan struct{} | |||
| 636 | + | } | |||
| 637 | + | ||||
| 638 | + | func startPublicTLSRuntime(ctx context.Context, svc publicTLSRuntime, log zerowrap.Logger) error { | |||
| 639 | + | if svc == nil { | |||
| 640 | + | return nil | |||
| 641 | + | } | |||
| 642 | + | if err := svc.Reconcile(ctx); err != nil { | |||
| 643 | + | return err | |||
| 644 | + | } | |||
| 645 | + | svc.StartRenewalLoop(ctx, time.Hour) | |||
| 646 | + | log.Info().Msg("public ACME TLS runtime started") | |||
| 627 | return nil | 647 | return nil | ||
| 628 | } | 648 | } | ||
| 629 | 649 | ||||
| @@ -2099,11 +2119,10 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger, accessWr | |||||
| 2099 | 2119 | ||||
| 2100 | httpsMux := http.NewServeMux() | 2120 | httpsMux := http.NewServeMux() | ||
| 2101 | if obHandler != nil { | 2121 | if obHandler != nil { | ||
| 2102 | - | httpsMux.HandleFunc("GET /ca", obHandler.ServeOnboardingPage) | 2122 | + | httpsMux.Handle("/", gordonDomainOnboardingGate(cfg.Server.GordonDomain, obHandler, proxyHandler)) |
| 2103 | - | httpsMux.HandleFunc("GET /ca.crt", obHandler.ServeCACert) | 2123 | + | } else { |
| 2104 | - | httpsMux.HandleFunc("GET /ca.mobileconfig", obHandler.ServeMobileconfig) | 2124 | + | httpsMux.Handle("/", proxyHandler) |
| 2105 | } | 2125 | } | ||
| 2106 | - | httpsMux.Handle("/", proxyHandler) | |||
| 2107 | 2126 | ||||
| 2108 | httpsHandler := otelhttp.NewHandler(middleware.Chain(httpsProxyMiddlewares...)(httpsMux), "gordon.proxy.tls") | 2127 | httpsHandler := otelhttp.NewHandler(middleware.Chain(httpsProxyMiddlewares...)(httpsMux), "gordon.proxy.tls") | ||
| 2109 | 2128 | ||||
| @@ -2130,6 +2149,10 @@ func directHTTPOnboardingGate(ob *onboarding.Handler, proxyNets []*net.IPNet, pr | |||||
| 2130 | // Build a small mux for direct-client onboarding paths. | 2149 | // Build a small mux for direct-client onboarding paths. | ||
| 2131 | onboardingMux := http.NewServeMux() | 2150 | onboardingMux := http.NewServeMux() | ||
| 2132 | onboardingMux.HandleFunc("GET /{$}", ob.ServeOnboardingPage) | 2151 | onboardingMux.HandleFunc("GET /{$}", ob.ServeOnboardingPage) | ||
| 2152 | + | onboardingMux.HandleFunc("GET /.well-known/gordon/", ob.ServeOnboardingPage) | |||
| 2153 | + | onboardingMux.HandleFunc("GET /.well-known/gordon/ca", ob.ServeOnboardingPage) | |||
| 2154 | + | onboardingMux.HandleFunc("GET /.well-known/gordon/ca.crt", ob.ServeCACert) | |||
| 2155 | + | onboardingMux.HandleFunc("GET /.well-known/gordon/ca.mobileconfig", ob.ServeMobileconfig) | |||
| 2133 | onboardingMux.HandleFunc("GET /ca", ob.ServeOnboardingPage) | 2156 | onboardingMux.HandleFunc("GET /ca", ob.ServeOnboardingPage) | ||
| 2134 | onboardingMux.HandleFunc("GET /ca.crt", ob.ServeCACert) | 2157 | onboardingMux.HandleFunc("GET /ca.crt", ob.ServeCACert) | ||
| 2135 | onboardingMux.HandleFunc("GET /ca.mobileconfig", ob.ServeMobileconfig) | 2158 | onboardingMux.HandleFunc("GET /ca.mobileconfig", ob.ServeMobileconfig) | ||
| @@ -2159,6 +2182,35 @@ func directHTTPOnboardingGate(ob *onboarding.Handler, proxyNets []*net.IPNet, pr | |||||
| 2159 | }) | 2182 | }) | ||
| 2160 | } | 2183 | } | ||
| 2161 | 2184 | ||||
| 2185 | + | func requestHostMatches(host, expected string) bool { | |||
| 2186 | + | host = strings.TrimSpace(host) | |||
| 2187 | + | if h, _, err := net.SplitHostPort(host); err == nil { | |||
| 2188 | + | host = h | |||
| 2189 | + | } | |||
| 2190 | + | host = strings.TrimSuffix(strings.ToLower(host), ".") | |||
| 2191 | + | expected = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(expected)), ".") | |||
| 2192 | + | return host == expected | |||
| 2193 | + | } | |||
| 2194 | + | ||||
| 2195 | + | func gordonDomainOnboardingGate(gordonDomain string, ob *onboarding.Handler, proxyHandler http.Handler) http.Handler { | |||
| 2196 | + | mux := http.NewServeMux() | |||
| 2197 | + | mux.HandleFunc("GET /ca", ob.ServeOnboardingPage) | |||
| 2198 | + | mux.HandleFunc("GET /ca.crt", ob.ServeCACert) | |||
| 2199 | + | mux.HandleFunc("GET /ca.mobileconfig", ob.ServeMobileconfig) | |||
| 2200 | + | mux.HandleFunc("GET /.well-known/gordon/", ob.ServeOnboardingPage) | |||
| 2201 | + | mux.HandleFunc("GET /.well-known/gordon/ca", ob.ServeOnboardingPage) | |||
| 2202 | + | mux.HandleFunc("GET /.well-known/gordon/ca.crt", ob.ServeCACert) | |||
| 2203 | + | mux.HandleFunc("GET /.well-known/gordon/ca.mobileconfig", ob.ServeMobileconfig) | |||
| 2204 | + | ||||
| 2205 | + | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 2206 | + | if gordonDomain != "" && requestHostMatches(r.Host, gordonDomain) { | |||
| 2207 | + | mux.ServeHTTP(w, r) | |||
| 2208 | + | return | |||
| 2209 | + | } | |||
| 2210 | + | proxyHandler.ServeHTTP(w, r) | |||
| 2211 | + | }) | |||
| 2212 | + | } | |||
| 2213 | + | ||||
| 2162 | // directHTTPForbidden responds with 403 for non-onboarding HTTP paths. | 2214 | // directHTTPForbidden responds with 403 for non-onboarding HTTP paths. | ||
| 2163 | // HEAD requests get an empty body per HTTP semantics. | 2215 | // HEAD requests get an empty body per HTTP semantics. | ||
| 2164 | func directHTTPForbidden(w http.ResponseWriter, r *http.Request) { | 2216 | func directHTTPForbidden(w http.ResponseWriter, r *http.Request) { | ||
| @@ -2481,6 +2533,8 @@ func runServers(ctx context.Context, v *viper.Viper, cfg Config, svc *services, | |||||
| 2481 | } | 2533 | } | ||
| 2482 | logEvent.Msg("Gordon is running") | 2534 | logEvent.Msg("Gordon is running") | ||
| 2483 | 2535 | ||||
| 2536 | + | startPublicTLSRuntimeWithWarning(ctx, svc.publicTLSRuntime, log) | |||
| 2537 | + | ||||
| 2484 | schedulerCleanup, err := startOptionalSchedulers(ctx, cfg, svc, log, v) | 2538 | schedulerCleanup, err := startOptionalSchedulers(ctx, cfg, svc, log, v) | ||
| 2485 | if err != nil { | 2539 | if err != nil { | ||
| 2486 | return err | 2540 | return err | ||
| @@ -2498,6 +2552,12 @@ func runServers(ctx context.Context, v *viper.Viper, cfg Config, svc *services, | |||||
| 2498 | return nil | 2552 | return nil | ||
| 2499 | } | 2553 | } | ||
| 2500 | 2554 | ||||
| 2555 | + | func startPublicTLSRuntimeWithWarning(ctx context.Context, svc publicTLSRuntime, log zerowrap.Logger) { | |||
| 2556 | + | if err := startPublicTLSRuntime(ctx, svc, log); err != nil { | |||
| 2557 | + | log.Warn().Err(err).Msg("failed to start public ACME TLS runtime, continuing") | |||
| 2558 | + | } | |||
| 2559 | + | } | |||
| 2560 | + | ||||
| 2501 | func waitForServerReady(ready <-chan struct{}, errChan <-chan error) error { | 2561 | func waitForServerReady(ready <-chan struct{}, errChan <-chan error) error { | ||
| 2502 | select { | 2562 | select { | ||
| 2503 | case <-ready: | 2563 | case <-ready: | ||
| @@ -3219,6 +3279,7 @@ func loadConfig(v *viper.Viper, configPath string) error { | |||||
| 3219 | v.SetDefault("tls.acme.enabled", false) | 3279 | v.SetDefault("tls.acme.enabled", false) | ||
| 3220 | v.SetDefault("tls.acme.email", "") | 3280 | v.SetDefault("tls.acme.email", "") | ||
| 3221 | v.SetDefault("tls.acme.challenge", "auto") | 3281 | v.SetDefault("tls.acme.challenge", "auto") | ||
| 3282 | + | v.SetDefault("tls.acme.obtain_batch_size", 1) | |||
| 3222 | v.SetDefault("server.force_https_redirect", false) | 3283 | v.SetDefault("server.force_https_redirect", false) | ||
| 3223 | v.SetDefault("server.data_dir", DefaultDataDir()) | 3284 | v.SetDefault("server.data_dir", DefaultDataDir()) | ||
| 3224 | v.SetDefault("server.runtime", "auto") | 3285 | v.SetDefault("server.runtime", "auto") | ||
M internal/app/run_http_handlers_test.go +122 -5
| @@ -2,6 +2,7 @@ package app | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "errors" | |
| 5 | 6 | "io" | |
| 6 | 7 | "net/http" | |
| 7 | 8 | "net/http/httptest" | |
| @@ -9,16 +10,30 @@ import ( | |||
| 9 | 10 | "path/filepath" | |
| 10 | 11 | "sync" | |
| 11 | 12 | "testing" | |
| 13 | + | "time" | |
| 12 | 14 | ||
| 13 | 15 | "github.com/bnema/zerowrap" | |
| 14 | 16 | "github.com/stretchr/testify/assert" | |
| 17 | + | "github.com/stretchr/testify/mock" | |
| 15 | 18 | "github.com/stretchr/testify/require" | |
| 16 | 19 | ||
| 17 | 20 | adminhttp "github.com/bnema/gordon/internal/adapters/in/http/admin" | |
| 18 | 21 | pkiadapter "github.com/bnema/gordon/internal/adapters/out/pki" | |
| 22 | + | inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks" | |
| 19 | 23 | out "github.com/bnema/gordon/internal/boundaries/out" | |
| 24 | + | proxyusecase "github.com/bnema/gordon/internal/usecase/proxy" | |
| 20 | 25 | ) | |
| 21 | 26 | ||
| 27 | + | func newNotFoundProxyService(t *testing.T) *proxyusecase.Service { | |
| 28 | + | t.Helper() | |
| 29 | + | configSvc := inmocks.NewMockConfigService(t) | |
| 30 | + | configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{}).Maybe() | |
| 31 | + | configSvc.EXPECT().GetRoutes(mock.Anything).Return(nil).Maybe() | |
| 32 | + | containerSvc := inmocks.NewMockContainerService(t) | |
| 33 | + | containerSvc.EXPECT().Get(mock.Anything, mock.Anything).Return(nil, false).Maybe() | |
| 34 | + | return proxyusecase.NewService(nil, containerSvc, configSvc, proxyusecase.Config{}) | |
| 35 | + | } | |
| 36 | + | ||
| 22 | 37 | // testAccessLogWriter is a thread-safe mock AccessLogWriter for tests. | |
| 23 | 38 | type testAccessLogWriter struct { | |
| 24 | 39 | mu sync.Mutex | |
| @@ -139,6 +154,44 @@ func TestCreateHTTPHandlers_DirectHTTPRoot_ServesOnboarding(t *testing.T) { | |||
| 139 | 154 | assert.Contains(t, rec.Body.String(), "Trust CA Certificate") | |
| 140 | 155 | } | |
| 141 | 156 | ||
| 157 | + | func TestCreateHTTPHandlers_DirectHTTPWellKnownGordonCACertPath_ServesCACert(t *testing.T) { | |
| 158 | + | t.Parallel() | |
| 159 | + | ca := newTestCA(t) | |
| 160 | + | cfg := newOnboardingConfig() | |
| 161 | + | svc := &services{caAdapter: ca} | |
| 162 | + | ||
| 163 | + | _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil) | |
| 164 | + | ||
| 165 | + | req := httptest.NewRequest(http.MethodGet, "/.well-known/gordon/ca.crt", nil) | |
| 166 | + | req.RemoteAddr = directAddr | |
| 167 | + | req.Host = "o2.bnema.dev" | |
| 168 | + | rec := httptest.NewRecorder() | |
| 169 | + | httpHandler.ServeHTTP(rec, req) | |
| 170 | + | ||
| 171 | + | assert.Equal(t, http.StatusOK, rec.Code) | |
| 172 | + | assert.Contains(t, rec.Header().Get("Content-Type"), "application/x-x509-ca-cert") | |
| 173 | + | assert.Contains(t, rec.Body.String(), "BEGIN CERTIFICATE") | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | func TestCreateHTTPHandlers_DirectHTTPWellKnownGordonOnboarding_ServesOnboarding(t *testing.T) { | |
| 177 | + | t.Parallel() | |
| 178 | + | ca := newTestCA(t) | |
| 179 | + | cfg := newOnboardingConfig() | |
| 180 | + | svc := &services{caAdapter: ca} | |
| 181 | + | ||
| 182 | + | _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil) | |
| 183 | + | ||
| 184 | + | req := httptest.NewRequest(http.MethodGet, "/.well-known/gordon/", nil) | |
| 185 | + | req.RemoteAddr = directAddr | |
| 186 | + | req.Host = "o2.bnema.dev" | |
| 187 | + | rec := httptest.NewRecorder() | |
| 188 | + | httpHandler.ServeHTTP(rec, req) | |
| 189 | + | ||
| 190 | + | assert.Equal(t, http.StatusOK, rec.Code) | |
| 191 | + | assert.Contains(t, rec.Header().Get("Content-Type"), "text/html") | |
| 192 | + | assert.Contains(t, rec.Body.String(), "Trust CA Certificate") | |
| 193 | + | } | |
| 194 | + | ||
| 142 | 195 | func TestCreateHTTPHandlers_DirectHTTPCertPath_ServesCACert(t *testing.T) { | |
| 143 | 196 | t.Parallel() | |
| 144 | 197 | ca := newTestCA(t) | |
| @@ -235,17 +288,18 @@ func TestCreateHTTPHandlers_ForceHTTPSRedirect_DoesNotBypassDirectOnboarding(t * | |||
| 235 | 288 | assert.Contains(t, rec.Body.String(), "Trust CA Certificate") | |
| 236 | 289 | } | |
| 237 | 290 | ||
| 238 | - | func TestCreateHTTPHandlers_HTTPSOnboarding_RemainsAvailable(t *testing.T) { | |
| 291 | + | func TestCreateHTTPHandlers_HTTPSOnboarding_RemainsAvailableOnGordonDomain(t *testing.T) { | |
| 239 | 292 | t.Parallel() | |
| 240 | 293 | ca := newTestCA(t) | |
| 241 | 294 | cfg := newOnboardingConfig() | |
| 242 | - | svc := &services{caAdapter: ca} | |
| 295 | + | cfg.Server.GordonDomain = "gordon.example.com" | |
| 296 | + | svc := &services{caAdapter: ca, proxySvc: newNotFoundProxyService(t)} | |
| 243 | 297 | ||
| 244 | 298 | _, _, httpsHandler := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil) | |
| 245 | 299 | ||
| 246 | 300 | req := httptest.NewRequest(http.MethodGet, "/ca", nil) | |
| 247 | 301 | req.RemoteAddr = directAddr | |
| 248 | - | req.Host = "o2.bnema.dev" | |
| 302 | + | req.Host = "gordon.example.com" | |
| 249 | 303 | rec := httptest.NewRecorder() | |
| 250 | 304 | httpsHandler.ServeHTTP(rec, req) | |
| 251 | 305 | ||
| @@ -253,11 +307,31 @@ func TestCreateHTTPHandlers_HTTPSOnboarding_RemainsAvailable(t *testing.T) { | |||
| 253 | 307 | assert.Contains(t, rec.Body.String(), "Trust CA Certificate") | |
| 254 | 308 | } | |
| 255 | 309 | ||
| 310 | + | func TestCreateHTTPHandlers_HTTPSOnboarding_DoesNotPreemptAppHostCAPath(t *testing.T) { | |
| 311 | + | t.Parallel() | |
| 312 | + | ca := newTestCA(t) | |
| 313 | + | cfg := newOnboardingConfig() | |
| 314 | + | cfg.Server.GordonDomain = "gordon.example.com" | |
| 315 | + | svc := &services{caAdapter: ca, proxySvc: newNotFoundProxyService(t)} | |
| 316 | + | ||
| 317 | + | _, _, httpsHandler := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil) | |
| 318 | + | ||
| 319 | + | req := httptest.NewRequest(http.MethodGet, "/ca", nil) | |
| 320 | + | req.RemoteAddr = directAddr | |
| 321 | + | req.Host = "app.example.com" | |
| 322 | + | rec := httptest.NewRecorder() | |
| 323 | + | httpsHandler.ServeHTTP(rec, req) | |
| 324 | + | ||
| 325 | + | assert.Equal(t, http.StatusNotFound, rec.Code) | |
| 326 | + | assert.NotContains(t, rec.Body.String(), "Trust CA Certificate") | |
| 327 | + | } | |
| 328 | + | ||
| 256 | 329 | func TestCreateHTTPHandlers_HTTPSOnboarding_HEADRoutesRemainAvailable(t *testing.T) { | |
| 257 | 330 | t.Parallel() | |
| 258 | 331 | ca := newTestCA(t) | |
| 259 | 332 | cfg := newOnboardingConfig() | |
| 260 | - | svc := &services{caAdapter: ca} | |
| 333 | + | cfg.Server.GordonDomain = "gordon.example.com" | |
| 334 | + | svc := &services{caAdapter: ca, proxySvc: newNotFoundProxyService(t)} | |
| 261 | 335 | ||
| 262 | 336 | _, _, httpsHandler := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil) | |
| 263 | 337 | ||
| @@ -267,7 +341,10 @@ func TestCreateHTTPHandlers_HTTPSOnboarding_HEADRoutesRemainAvailable(t *testing | |||
| 267 | 341 | defer ts.Close() | |
| 268 | 342 | ||
| 269 | 343 | for _, path := range []string{"/ca", "/ca.crt", "/ca.mobileconfig"} { | |
| 270 | - | resp, err := ts.Client().Head(ts.URL + path) | |
| 344 | + | req, err := http.NewRequest(http.MethodHead, ts.URL+path, nil) | |
| 345 | + | require.NoError(t, err) | |
| 346 | + | req.Host = "gordon.example.com" | |
| 347 | + | resp, err := ts.Client().Do(req) | |
| 271 | 348 | require.NoError(t, err) | |
| 272 | 349 | ||
| 273 | 350 | body, err := io.ReadAll(resp.Body) | |
| @@ -376,6 +453,46 @@ func TestCreateHTTPHandlers_TLSDisabled_DoesNotServeHTTPOnboarding(t *testing.T) | |||
| 376 | 453 | assert.NotContains(t, rec.Body.String(), "Trust CA Certificate") | |
| 377 | 454 | } | |
| 378 | 455 | ||
| 456 | + | type recordingPublicTLSRuntime struct { | |
| 457 | + | reconcileCalls int | |
| 458 | + | loopCalls int | |
| 459 | + | reconcileErr error | |
| 460 | + | } | |
| 461 | + | ||
| 462 | + | func (r *recordingPublicTLSRuntime) Reconcile(context.Context) error { | |
| 463 | + | r.reconcileCalls++ | |
| 464 | + | return r.reconcileErr | |
| 465 | + | } | |
| 466 | + | ||
| 467 | + | func (r *recordingPublicTLSRuntime) StartRenewalLoop(context.Context, time.Duration) <-chan struct{} { | |
| 468 | + | r.loopCalls++ | |
| 469 | + | done := make(chan struct{}) | |
| 470 | + | close(done) | |
| 471 | + | return done | |
| 472 | + | } | |
| 473 | + | ||
| 474 | + | func TestStartPublicTLSRuntime_ReconcilesAndStartsRenewalLoop(t *testing.T) { | |
| 475 | + | t.Parallel() | |
| 476 | + | ||
| 477 | + | runtime := &recordingPublicTLSRuntime{} | |
| 478 | + | err := startPublicTLSRuntime(context.Background(), runtime, zerowrap.Default()) | |
| 479 | + | ||
| 480 | + | require.NoError(t, err) | |
| 481 | + | assert.Equal(t, 1, runtime.reconcileCalls) | |
| 482 | + | assert.Equal(t, 1, runtime.loopCalls) | |
| 483 | + | } | |
| 484 | + | ||
| 485 | + | func TestStartPublicTLSRuntime_DoesNotStartRenewalLoopWhenReconcileFails(t *testing.T) { | |
| 486 | + | t.Parallel() | |
| 487 | + | ||
| 488 | + | runtime := &recordingPublicTLSRuntime{reconcileErr: errors.New("boom")} | |
| 489 | + | err := startPublicTLSRuntime(context.Background(), runtime, zerowrap.Default()) | |
| 490 | + | ||
| 491 | + | require.Error(t, err) | |
| 492 | + | assert.Equal(t, 1, runtime.reconcileCalls) | |
| 493 | + | assert.Equal(t, 0, runtime.loopCalls) | |
| 494 | + | } | |
| 495 | + | ||
| 379 | 496 | func TestCreateHTTPHandlers_TLSConfiguredWithoutCA_FailsStartup(t *testing.T) { | |
| 380 | 497 | t.Parallel() | |
| 381 | 498 | // Verify that initPKI returns an error when TLS is enabled but CA init fails. | |
| @@ -2,6 +2,7 @@ package app | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | + | "errors" | |||
| 5 | "io" | 6 | "io" | ||
| 6 | "net/http" | 7 | "net/http" | ||
| 7 | "net/http/httptest" | 8 | "net/http/httptest" | ||
| @@ -9,16 +10,30 @@ import ( | |||||
| 9 | "path/filepath" | 10 | "path/filepath" | ||
| 10 | "sync" | 11 | "sync" | ||
| 11 | "testing" | 12 | "testing" | ||
| 13 | + | "time" | |||
| 12 | 14 | ||||
| 13 | "github.com/bnema/zerowrap" | 15 | "github.com/bnema/zerowrap" | ||
| 14 | "github.com/stretchr/testify/assert" | 16 | "github.com/stretchr/testify/assert" | ||
| 17 | + | "github.com/stretchr/testify/mock" | |||
| 15 | "github.com/stretchr/testify/require" | 18 | "github.com/stretchr/testify/require" | ||
| 16 | 19 | ||||
| 17 | adminhttp "github.com/bnema/gordon/internal/adapters/in/http/admin" | 20 | adminhttp "github.com/bnema/gordon/internal/adapters/in/http/admin" | ||
| 18 | pkiadapter "github.com/bnema/gordon/internal/adapters/out/pki" | 21 | pkiadapter "github.com/bnema/gordon/internal/adapters/out/pki" | ||
| 22 | + | inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks" | |||
| 19 | out "github.com/bnema/gordon/internal/boundaries/out" | 23 | out "github.com/bnema/gordon/internal/boundaries/out" | ||
| 24 | + | proxyusecase "github.com/bnema/gordon/internal/usecase/proxy" | |||
| 20 | ) | 25 | ) | ||
| 21 | 26 | ||||
| 27 | + | func newNotFoundProxyService(t *testing.T) *proxyusecase.Service { | |||
| 28 | + | t.Helper() | |||
| 29 | + | configSvc := inmocks.NewMockConfigService(t) | |||
| 30 | + | configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{}).Maybe() | |||
| 31 | + | configSvc.EXPECT().GetRoutes(mock.Anything).Return(nil).Maybe() | |||
| 32 | + | containerSvc := inmocks.NewMockContainerService(t) | |||
| 33 | + | containerSvc.EXPECT().Get(mock.Anything, mock.Anything).Return(nil, false).Maybe() | |||
| 34 | + | return proxyusecase.NewService(nil, containerSvc, configSvc, proxyusecase.Config{}) | |||
| 35 | + | } | |||
| 36 | + | ||||
| 22 | // testAccessLogWriter is a thread-safe mock AccessLogWriter for tests. | 37 | // testAccessLogWriter is a thread-safe mock AccessLogWriter for tests. | ||
| 23 | type testAccessLogWriter struct { | 38 | type testAccessLogWriter struct { | ||
| 24 | mu sync.Mutex | 39 | mu sync.Mutex | ||
| @@ -139,6 +154,44 @@ func TestCreateHTTPHandlers_DirectHTTPRoot_ServesOnboarding(t *testing.T) { | |||||
| 139 | assert.Contains(t, rec.Body.String(), "Trust CA Certificate") | 154 | assert.Contains(t, rec.Body.String(), "Trust CA Certificate") | ||
| 140 | } | 155 | } | ||
| 141 | 156 | ||||
| 157 | + | func TestCreateHTTPHandlers_DirectHTTPWellKnownGordonCACertPath_ServesCACert(t *testing.T) { | |||
| 158 | + | t.Parallel() | |||
| 159 | + | ca := newTestCA(t) | |||
| 160 | + | cfg := newOnboardingConfig() | |||
| 161 | + | svc := &services{caAdapter: ca} | |||
| 162 | + | ||||
| 163 | + | _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil) | |||
| 164 | + | ||||
| 165 | + | req := httptest.NewRequest(http.MethodGet, "/.well-known/gordon/ca.crt", nil) | |||
| 166 | + | req.RemoteAddr = directAddr | |||
| 167 | + | req.Host = "o2.bnema.dev" | |||
| 168 | + | rec := httptest.NewRecorder() | |||
| 169 | + | httpHandler.ServeHTTP(rec, req) | |||
| 170 | + | ||||
| 171 | + | assert.Equal(t, http.StatusOK, rec.Code) | |||
| 172 | + | assert.Contains(t, rec.Header().Get("Content-Type"), "application/x-x509-ca-cert") | |||
| 173 | + | assert.Contains(t, rec.Body.String(), "BEGIN CERTIFICATE") | |||
| 174 | + | } | |||
| 175 | + | ||||
| 176 | + | func TestCreateHTTPHandlers_DirectHTTPWellKnownGordonOnboarding_ServesOnboarding(t *testing.T) { | |||
| 177 | + | t.Parallel() | |||
| 178 | + | ca := newTestCA(t) | |||
| 179 | + | cfg := newOnboardingConfig() | |||
| 180 | + | svc := &services{caAdapter: ca} | |||
| 181 | + | ||||
| 182 | + | _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil) | |||
| 183 | + | ||||
| 184 | + | req := httptest.NewRequest(http.MethodGet, "/.well-known/gordon/", nil) | |||
| 185 | + | req.RemoteAddr = directAddr | |||
| 186 | + | req.Host = "o2.bnema.dev" | |||
| 187 | + | rec := httptest.NewRecorder() | |||
| 188 | + | httpHandler.ServeHTTP(rec, req) | |||
| 189 | + | ||||
| 190 | + | assert.Equal(t, http.StatusOK, rec.Code) | |||
| 191 | + | assert.Contains(t, rec.Header().Get("Content-Type"), "text/html") | |||
| 192 | + | assert.Contains(t, rec.Body.String(), "Trust CA Certificate") | |||
| 193 | + | } | |||
| 194 | + | ||||
| 142 | func TestCreateHTTPHandlers_DirectHTTPCertPath_ServesCACert(t *testing.T) { | 195 | func TestCreateHTTPHandlers_DirectHTTPCertPath_ServesCACert(t *testing.T) { | ||
| 143 | t.Parallel() | 196 | t.Parallel() | ||
| 144 | ca := newTestCA(t) | 197 | ca := newTestCA(t) | ||
| @@ -235,17 +288,18 @@ func TestCreateHTTPHandlers_ForceHTTPSRedirect_DoesNotBypassDirectOnboarding(t * | |||||
| 235 | assert.Contains(t, rec.Body.String(), "Trust CA Certificate") | 288 | assert.Contains(t, rec.Body.String(), "Trust CA Certificate") | ||
| 236 | } | 289 | } | ||
| 237 | 290 | ||||
| 238 | - | func TestCreateHTTPHandlers_HTTPSOnboarding_RemainsAvailable(t *testing.T) { | 291 | + | func TestCreateHTTPHandlers_HTTPSOnboarding_RemainsAvailableOnGordonDomain(t *testing.T) { |
| 239 | t.Parallel() | 292 | t.Parallel() | ||
| 240 | ca := newTestCA(t) | 293 | ca := newTestCA(t) | ||
| 241 | cfg := newOnboardingConfig() | 294 | cfg := newOnboardingConfig() | ||
| 242 | - | svc := &services{caAdapter: ca} | 295 | + | cfg.Server.GordonDomain = "gordon.example.com" |
| 296 | + | svc := &services{caAdapter: ca, proxySvc: newNotFoundProxyService(t)} | |||
| 243 | 297 | ||||
| 244 | _, _, httpsHandler := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil) | 298 | _, _, httpsHandler := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil) | ||
| 245 | 299 | ||||
| 246 | req := httptest.NewRequest(http.MethodGet, "/ca", nil) | 300 | req := httptest.NewRequest(http.MethodGet, "/ca", nil) | ||
| 247 | req.RemoteAddr = directAddr | 301 | req.RemoteAddr = directAddr | ||
| 248 | - | req.Host = "o2.bnema.dev" | 302 | + | req.Host = "gordon.example.com" |
| 249 | rec := httptest.NewRecorder() | 303 | rec := httptest.NewRecorder() | ||
| 250 | httpsHandler.ServeHTTP(rec, req) | 304 | httpsHandler.ServeHTTP(rec, req) | ||
| 251 | 305 | ||||
| @@ -253,11 +307,31 @@ func TestCreateHTTPHandlers_HTTPSOnboarding_RemainsAvailable(t *testing.T) { | |||||
| 253 | assert.Contains(t, rec.Body.String(), "Trust CA Certificate") | 307 | assert.Contains(t, rec.Body.String(), "Trust CA Certificate") | ||
| 254 | } | 308 | } | ||
| 255 | 309 | ||||
| 310 | + | func TestCreateHTTPHandlers_HTTPSOnboarding_DoesNotPreemptAppHostCAPath(t *testing.T) { | |||
| 311 | + | t.Parallel() | |||
| 312 | + | ca := newTestCA(t) | |||
| 313 | + | cfg := newOnboardingConfig() | |||
| 314 | + | cfg.Server.GordonDomain = "gordon.example.com" | |||
| 315 | + | svc := &services{caAdapter: ca, proxySvc: newNotFoundProxyService(t)} | |||
| 316 | + | ||||
| 317 | + | _, _, httpsHandler := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil) | |||
| 318 | + | ||||
| 319 | + | req := httptest.NewRequest(http.MethodGet, "/ca", nil) | |||
| 320 | + | req.RemoteAddr = directAddr | |||
| 321 | + | req.Host = "app.example.com" | |||
| 322 | + | rec := httptest.NewRecorder() | |||
| 323 | + | httpsHandler.ServeHTTP(rec, req) | |||
| 324 | + | ||||
| 325 | + | assert.Equal(t, http.StatusNotFound, rec.Code) | |||
| 326 | + | assert.NotContains(t, rec.Body.String(), "Trust CA Certificate") | |||
| 327 | + | } | |||
| 328 | + | ||||
| 256 | func TestCreateHTTPHandlers_HTTPSOnboarding_HEADRoutesRemainAvailable(t *testing.T) { | 329 | func TestCreateHTTPHandlers_HTTPSOnboarding_HEADRoutesRemainAvailable(t *testing.T) { | ||
| 257 | t.Parallel() | 330 | t.Parallel() | ||
| 258 | ca := newTestCA(t) | 331 | ca := newTestCA(t) | ||
| 259 | cfg := newOnboardingConfig() | 332 | cfg := newOnboardingConfig() | ||
| 260 | - | svc := &services{caAdapter: ca} | 333 | + | cfg.Server.GordonDomain = "gordon.example.com" |
| 334 | + | svc := &services{caAdapter: ca, proxySvc: newNotFoundProxyService(t)} | |||
| 261 | 335 | ||||
| 262 | _, _, httpsHandler := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil) | 336 | _, _, httpsHandler := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil) | ||
| 263 | 337 | ||||
| @@ -267,7 +341,10 @@ func TestCreateHTTPHandlers_HTTPSOnboarding_HEADRoutesRemainAvailable(t *testing | |||||
| 267 | defer ts.Close() | 341 | defer ts.Close() | ||
| 268 | 342 | ||||
| 269 | for _, path := range []string{"/ca", "/ca.crt", "/ca.mobileconfig"} { | 343 | for _, path := range []string{"/ca", "/ca.crt", "/ca.mobileconfig"} { | ||
| 270 | - | resp, err := ts.Client().Head(ts.URL + path) | 344 | + | req, err := http.NewRequest(http.MethodHead, ts.URL+path, nil) |
| 345 | + | require.NoError(t, err) | |||
| 346 | + | req.Host = "gordon.example.com" | |||
| 347 | + | resp, err := ts.Client().Do(req) | |||
| 271 | require.NoError(t, err) | 348 | require.NoError(t, err) | ||
| 272 | 349 | ||||
| 273 | body, err := io.ReadAll(resp.Body) | 350 | body, err := io.ReadAll(resp.Body) | ||
| @@ -376,6 +453,46 @@ func TestCreateHTTPHandlers_TLSDisabled_DoesNotServeHTTPOnboarding(t *testing.T) | |||||
| 376 | assert.NotContains(t, rec.Body.String(), "Trust CA Certificate") | 453 | assert.NotContains(t, rec.Body.String(), "Trust CA Certificate") | ||
| 377 | } | 454 | } | ||
| 378 | 455 | ||||
| 456 | + | type recordingPublicTLSRuntime struct { | |||
| 457 | + | reconcileCalls int | |||
| 458 | + | loopCalls int | |||
| 459 | + | reconcileErr error | |||
| 460 | + | } | |||
| 461 | + | ||||
| 462 | + | func (r *recordingPublicTLSRuntime) Reconcile(context.Context) error { | |||
| 463 | + | r.reconcileCalls++ | |||
| 464 | + | return r.reconcileErr | |||
| 465 | + | } | |||
| 466 | + | ||||
| 467 | + | func (r *recordingPublicTLSRuntime) StartRenewalLoop(context.Context, time.Duration) <-chan struct{} { | |||
| 468 | + | r.loopCalls++ | |||
| 469 | + | done := make(chan struct{}) | |||
| 470 | + | close(done) | |||
| 471 | + | return done | |||
| 472 | + | } | |||
| 473 | + | ||||
| 474 | + | func TestStartPublicTLSRuntime_ReconcilesAndStartsRenewalLoop(t *testing.T) { | |||
| 475 | + | t.Parallel() | |||
| 476 | + | ||||
| 477 | + | runtime := &recordingPublicTLSRuntime{} | |||
| 478 | + | err := startPublicTLSRuntime(context.Background(), runtime, zerowrap.Default()) | |||
| 479 | + | ||||
| 480 | + | require.NoError(t, err) | |||
| 481 | + | assert.Equal(t, 1, runtime.reconcileCalls) | |||
| 482 | + | assert.Equal(t, 1, runtime.loopCalls) | |||
| 483 | + | } | |||
| 484 | + | ||||
| 485 | + | func TestStartPublicTLSRuntime_DoesNotStartRenewalLoopWhenReconcileFails(t *testing.T) { | |||
| 486 | + | t.Parallel() | |||
| 487 | + | ||||
| 488 | + | runtime := &recordingPublicTLSRuntime{reconcileErr: errors.New("boom")} | |||
| 489 | + | err := startPublicTLSRuntime(context.Background(), runtime, zerowrap.Default()) | |||
| 490 | + | ||||
| 491 | + | require.Error(t, err) | |||
| 492 | + | assert.Equal(t, 1, runtime.reconcileCalls) | |||
| 493 | + | assert.Equal(t, 0, runtime.loopCalls) | |||
| 494 | + | } | |||
| 495 | + | ||||
| 379 | func TestCreateHTTPHandlers_TLSConfiguredWithoutCA_FailsStartup(t *testing.T) { | 496 | func TestCreateHTTPHandlers_TLSConfiguredWithoutCA_FailsStartup(t *testing.T) { | ||
| 380 | t.Parallel() | 497 | t.Parallel() | ||
| 381 | // Verify that initPKI returns an error when TLS is enabled but CA init fails. | 498 | // Verify that initPKI returns an error when TLS is enabled but CA init fails. | ||
M internal/usecase/publictls/config.go +7 -6
| @@ -10,12 +10,13 @@ import ( | |||
| 10 | 10 | ||
| 11 | 11 | // Config holds the resolved configuration for public ACME TLS. | |
| 12 | 12 | type Config struct { | |
| 13 | - | Enabled bool | |
| 14 | - | Email string | |
| 15 | - | Challenge string | |
| 16 | - | HTTPPort int | |
| 17 | - | TLSPort int | |
| 18 | - | DataDir string | |
| 13 | + | Enabled bool | |
| 14 | + | Email string | |
| 15 | + | Challenge string | |
| 16 | + | HTTPPort int | |
| 17 | + | TLSPort int | |
| 18 | + | DataDir string | |
| 19 | + | ObtainBatchSize int | |
| 19 | 20 | } | |
| 20 | 21 | ||
| 21 | 22 | // EffectiveChallenge represents the resolved ACME challenge configuration. | |
| @@ -10,12 +10,13 @@ import ( | |||||
| 10 | 10 | ||||
| 11 | // Config holds the resolved configuration for public ACME TLS. | 11 | // Config holds the resolved configuration for public ACME TLS. | ||
| 12 | type Config struct { | 12 | type Config struct { | ||
| 13 | - | Enabled bool | 13 | + | Enabled bool |
| 14 | - | Email string | 14 | + | Email string |
| 15 | - | Challenge string | 15 | + | Challenge string |
| 16 | - | HTTPPort int | 16 | + | HTTPPort int |
| 17 | - | TLSPort int | 17 | + | TLSPort int |
| 18 | - | DataDir string | 18 | + | DataDir string |
| 19 | + | ObtainBatchSize int | |||
| 19 | } | 20 | } | ||
| 20 | 21 | ||||
| 21 | // EffectiveChallenge represents the resolved ACME challenge configuration. | 22 | // EffectiveChallenge represents the resolved ACME challenge configuration. | ||
M internal/usecase/publictls/integration_test.go +5 -4
| @@ -40,10 +40,11 @@ func TestPublicTLSIntegration_DNS01WildcardStatusAndCertificateLookup(t *testing | |||
| 40 | 40 | store, _ := newMockCertificateStore(t) | |
| 41 | 41 | ||
| 42 | 42 | cfg := Config{ | |
| 43 | - | Enabled: true, | |
| 44 | - | Email: "admin@example.com", | |
| 45 | - | Challenge: "cloudflare-dns-01", | |
| 46 | - | TLSPort: 8443, | |
| 43 | + | Enabled: true, | |
| 44 | + | Email: "admin@example.com", | |
| 45 | + | Challenge: "cloudflare-dns-01", | |
| 46 | + | TLSPort: 8443, | |
| 47 | + | ObtainBatchSize: 10, | |
| 47 | 48 | } | |
| 48 | 49 | ||
| 49 | 50 | svc := NewService(cfg, ServiceDeps{ | |
| @@ -40,10 +40,11 @@ func TestPublicTLSIntegration_DNS01WildcardStatusAndCertificateLookup(t *testing | |||||
| 40 | store, _ := newMockCertificateStore(t) | 40 | store, _ := newMockCertificateStore(t) | ||
| 41 | 41 | ||||
| 42 | cfg := Config{ | 42 | cfg := Config{ | ||
| 43 | - | Enabled: true, | 43 | + | Enabled: true, |
| 44 | - | Email: "admin@example.com", | 44 | + | Email: "admin@example.com", |
| 45 | - | Challenge: "cloudflare-dns-01", | 45 | + | Challenge: "cloudflare-dns-01", |
| 46 | - | TLSPort: 8443, | 46 | + | TLSPort: 8443, |
| 47 | + | ObtainBatchSize: 10, | |||
| 47 | } | 48 | } | ||
| 48 | 49 | ||||
| 49 | svc := NewService(cfg, ServiceDeps{ | 50 | svc := NewService(cfg, ServiceDeps{ | ||
M internal/usecase/publictls/service.go +21 -1
| @@ -33,6 +33,8 @@ type ServiceDeps struct { | |||
| 33 | 33 | Log zerowrap.Logger | |
| 34 | 34 | } | |
| 35 | 35 | ||
| 36 | + | const defaultObtainBatchSize = 1 | |
| 37 | + | ||
| 36 | 38 | // Service manages public TLS certificates via ACME. | |
| 37 | 39 | type Service struct { | |
| 38 | 40 | mu sync.RWMutex | |
| @@ -193,12 +195,30 @@ func (s *Service) Reconcile(ctx context.Context) error { | |||
| 193 | 195 | ||
| 194 | 196 | // Process missing targets without holding s.mu or the store lock so | |
| 195 | 197 | // GetCertificate/Status are not blocked during network I/O (Obtain). | |
| 198 | + | pendingCount := len(missing) | |
| 199 | + | missing = limitMissingTargets(missing, s.cfg.ObtainBatchSize) | |
| 200 | + | if remaining := pendingCount - len(missing); remaining > 0 { | |
| 201 | + | log.Info(). | |
| 202 | + | Int("obtain_batch_size", len(missing)). | |
| 203 | + | Int("remaining_count", remaining). | |
| 204 | + | Msg("ACME obtain batch limit reached; remaining certificates will be reconciled later") | |
| 205 | + | } | |
| 196 | 206 | s.reconcileMissingTargets(ctx, missing, required) | |
| 197 | 207 | ||
| 198 | - | log.Debug().Int("missing_count", len(missing)).Msg("reconciled missing targets") | |
| 208 | + | log.Debug().Int("missing_count", len(missing)).Int("pending_count", pendingCount).Msg("reconciled missing targets") | |
| 199 | 209 | return nil | |
| 200 | 210 | } | |
| 201 | 211 | ||
| 212 | + | func limitMissingTargets(targets []CertificateTarget, batchSize int) []CertificateTarget { | |
| 213 | + | if batchSize <= 0 { | |
| 214 | + | batchSize = defaultObtainBatchSize | |
| 215 | + | } | |
| 216 | + | if len(targets) <= batchSize { | |
| 217 | + | return targets | |
| 218 | + | } | |
| 219 | + | return targets[:batchSize] | |
| 220 | + | } | |
| 221 | + | ||
| 202 | 222 | func populateStoredCertificate(cert *out.StoredCertificate) error { | |
| 203 | 223 | if cert == nil { | |
| 204 | 224 | return fmt.Errorf("stored certificate is nil") | |
| @@ -33,6 +33,8 @@ type ServiceDeps struct { | |||||
| 33 | Log zerowrap.Logger | 33 | Log zerowrap.Logger | ||
| 34 | } | 34 | } | ||
| 35 | 35 | ||||
| 36 | + | const defaultObtainBatchSize = 1 | |||
| 37 | + | ||||
| 36 | // Service manages public TLS certificates via ACME. | 38 | // Service manages public TLS certificates via ACME. | ||
| 37 | type Service struct { | 39 | type Service struct { | ||
| 38 | mu sync.RWMutex | 40 | mu sync.RWMutex | ||
| @@ -193,12 +195,30 @@ func (s *Service) Reconcile(ctx context.Context) error { | |||||
| 193 | 195 | ||||
| 194 | // Process missing targets without holding s.mu or the store lock so | 196 | // Process missing targets without holding s.mu or the store lock so | ||
| 195 | // GetCertificate/Status are not blocked during network I/O (Obtain). | 197 | // GetCertificate/Status are not blocked during network I/O (Obtain). | ||
| 198 | + | pendingCount := len(missing) | |||
| 199 | + | missing = limitMissingTargets(missing, s.cfg.ObtainBatchSize) | |||
| 200 | + | if remaining := pendingCount - len(missing); remaining > 0 { | |||
| 201 | + | log.Info(). | |||
| 202 | + | Int("obtain_batch_size", len(missing)). | |||
| 203 | + | Int("remaining_count", remaining). | |||
| 204 | + | Msg("ACME obtain batch limit reached; remaining certificates will be reconciled later") | |||
| 205 | + | } | |||
| 196 | s.reconcileMissingTargets(ctx, missing, required) | 206 | s.reconcileMissingTargets(ctx, missing, required) | ||
| 197 | 207 | ||||
| 198 | - | log.Debug().Int("missing_count", len(missing)).Msg("reconciled missing targets") | 208 | + | log.Debug().Int("missing_count", len(missing)).Int("pending_count", pendingCount).Msg("reconciled missing targets") |
| 199 | return nil | 209 | return nil | ||
| 200 | } | 210 | } | ||
| 201 | 211 | ||||
| 212 | + | func limitMissingTargets(targets []CertificateTarget, batchSize int) []CertificateTarget { | |||
| 213 | + | if batchSize <= 0 { | |||
| 214 | + | batchSize = defaultObtainBatchSize | |||
| 215 | + | } | |||
| 216 | + | if len(targets) <= batchSize { | |||
| 217 | + | return targets | |||
| 218 | + | } | |||
| 219 | + | return targets[:batchSize] | |||
| 220 | + | } | |||
| 221 | + | ||||
| 202 | func populateStoredCertificate(cert *out.StoredCertificate) error { | 222 | func populateStoredCertificate(cert *out.StoredCertificate) error { | ||
| 203 | if cert == nil { | 223 | if cert == nil { | ||
| 204 | return fmt.Errorf("stored certificate is nil") | 224 | return fmt.Errorf("stored certificate is nil") | ||
M internal/usecase/publictls/service_test.go +49 -0
| @@ -251,6 +251,55 @@ func generateTestCertPEM(names []string) (certPEM []byte, keyPEM []byte, err err | |||
| 251 | 251 | // Tests | |
| 252 | 252 | // --------------------------------------------------------------------------- | |
| 253 | 253 | ||
| 254 | + | func TestServiceReconcileLimitsMissingObtainsPerRun(t *testing.T) { | |
| 255 | + | ctx := context.Background() | |
| 256 | + | ||
| 257 | + | routes := &fakeRoutes{ | |
| 258 | + | routes: []domain.Route{ | |
| 259 | + | {Domain: "one.example.com"}, | |
| 260 | + | {Domain: "two.example.com"}, | |
| 261 | + | {Domain: "three.example.com"}, | |
| 262 | + | }, | |
| 263 | + | } | |
| 264 | + | issuer, recorder := newMockPublicCertificateIssuer(t, nil, nil) | |
| 265 | + | store, storeState := newMockCertificateStore(t) | |
| 266 | + | ||
| 267 | + | cfg := Config{ | |
| 268 | + | Enabled: true, | |
| 269 | + | Email: "admin@example.com", | |
| 270 | + | Challenge: "http-01", | |
| 271 | + | HTTPPort: 8088, | |
| 272 | + | TLSPort: 8443, | |
| 273 | + | ObtainBatchSize: 2, | |
| 274 | + | } | |
| 275 | + | ||
| 276 | + | svc := NewService(cfg, ServiceDeps{ | |
| 277 | + | Config: cfg, | |
| 278 | + | Routes: routes, | |
| 279 | + | Issuer: issuer, | |
| 280 | + | Store: store, | |
| 281 | + | Challenges: NewHTTP01Challenges(), | |
| 282 | + | Effective: EffectiveChallenge{ | |
| 283 | + | ConfiguredMode: domain.ACMEChallengeHTTP01, | |
| 284 | + | Mode: domain.ACMEChallengeHTTP01, | |
| 285 | + | TokenSource: domain.ACMETokenSourceNone, | |
| 286 | + | Reason: "http-01 challenge selected", | |
| 287 | + | }, | |
| 288 | + | }) | |
| 289 | + | ||
| 290 | + | require.NoError(t, svc.Load(ctx)) | |
| 291 | + | require.NoError(t, svc.Reconcile(ctx)) | |
| 292 | + | ||
| 293 | + | orders := recorder.Orders() | |
| 294 | + | require.Len(t, orders, 2, "first reconcile should respect configured obtain batch size") | |
| 295 | + | assert.Len(t, storeState.All(), 2) | |
| 296 | + | ||
| 297 | + | require.NoError(t, svc.Reconcile(ctx)) | |
| 298 | + | orders = recorder.Orders() | |
| 299 | + | require.Len(t, orders, 3, "second reconcile should continue remaining missing targets") | |
| 300 | + | assert.Len(t, storeState.All(), 3) | |
| 301 | + | } | |
| 302 | + | ||
| 254 | 303 | func TestServiceReconcileObtainsMissingHTTP01Cert(t *testing.T) { | |
| 255 | 304 | ctx := context.Background() | |
| 256 | 305 | ||
| @@ -251,6 +251,55 @@ func generateTestCertPEM(names []string) (certPEM []byte, keyPEM []byte, err err | |||||
| 251 | // Tests | 251 | // Tests | ||
| 252 | // --------------------------------------------------------------------------- | 252 | // --------------------------------------------------------------------------- | ||
| 253 | 253 | ||||
| 254 | + | func TestServiceReconcileLimitsMissingObtainsPerRun(t *testing.T) { | |||
| 255 | + | ctx := context.Background() | |||
| 256 | + | ||||
| 257 | + | routes := &fakeRoutes{ | |||
| 258 | + | routes: []domain.Route{ | |||
| 259 | + | {Domain: "one.example.com"}, | |||
| 260 | + | {Domain: "two.example.com"}, | |||
| 261 | + | {Domain: "three.example.com"}, | |||
| 262 | + | }, | |||
| 263 | + | } | |||
| 264 | + | issuer, recorder := newMockPublicCertificateIssuer(t, nil, nil) | |||
| 265 | + | store, storeState := newMockCertificateStore(t) | |||
| 266 | + | ||||
| 267 | + | cfg := Config{ | |||
| 268 | + | Enabled: true, | |||
| 269 | + | Email: "admin@example.com", | |||
| 270 | + | Challenge: "http-01", | |||
| 271 | + | HTTPPort: 8088, | |||
| 272 | + | TLSPort: 8443, | |||
| 273 | + | ObtainBatchSize: 2, | |||
| 274 | + | } | |||
| 275 | + | ||||
| 276 | + | svc := NewService(cfg, ServiceDeps{ | |||
| 277 | + | Config: cfg, | |||
| 278 | + | Routes: routes, | |||
| 279 | + | Issuer: issuer, | |||
| 280 | + | Store: store, | |||
| 281 | + | Challenges: NewHTTP01Challenges(), | |||
| 282 | + | Effective: EffectiveChallenge{ | |||
| 283 | + | ConfiguredMode: domain.ACMEChallengeHTTP01, | |||
| 284 | + | Mode: domain.ACMEChallengeHTTP01, | |||
| 285 | + | TokenSource: domain.ACMETokenSourceNone, | |||
| 286 | + | Reason: "http-01 challenge selected", | |||
| 287 | + | }, | |||
| 288 | + | }) | |||
| 289 | + | ||||
| 290 | + | require.NoError(t, svc.Load(ctx)) | |||
| 291 | + | require.NoError(t, svc.Reconcile(ctx)) | |||
| 292 | + | ||||
| 293 | + | orders := recorder.Orders() | |||
| 294 | + | require.Len(t, orders, 2, "first reconcile should respect configured obtain batch size") | |||
| 295 | + | assert.Len(t, storeState.All(), 2) | |||
| 296 | + | ||||
| 297 | + | require.NoError(t, svc.Reconcile(ctx)) | |||
| 298 | + | orders = recorder.Orders() | |||
| 299 | + | require.Len(t, orders, 3, "second reconcile should continue remaining missing targets") | |||
| 300 | + | assert.Len(t, storeState.All(), 3) | |||
| 301 | + | } | |||
| 302 | + | ||||
| 254 | func TestServiceReconcileObtainsMissingHTTP01Cert(t *testing.T) { | 303 | func TestServiceReconcileObtainsMissingHTTP01Cert(t *testing.T) { | ||
| 255 | ctx := context.Background() | 304 | ctx := context.Background() | ||
| 256 | 305 | ||||