detached e407583

Harden ACME HTTP onboarding flow

commit
e4075835d062490c30c459ce73792a767d1250a9
author
brice · · 2026-04-30 07:00 UTC
parent
0c0cc56
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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 (
4040 enabled = false # Enable public ACME certificates (requires server.tls_port > 0)
4141 email = "" # ACME account email when enabled
4242 challenge = "auto" # "auto", "http-01", or "cloudflare-dns-01"
43+obtain_batch_size = 1 # New certificate orders per reconcile run
4344
4445 # =============================================================================
4546 # API (applies to both Registry and Admin endpoints)
@@ -206,6 +207,7 @@ keep_last = 3 # Keep N newest tags per repository
206207 | `tls.acme.enabled` | `false` | Enable public ACME certificates (requires `server.tls_port > 0`) |
207208 | `tls.acme.email` | `""` | ACME account email when enabled |
208209 | `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 |
209211 | `auth.enabled` | `true` | Enable authentication; when `false`, run local-only mode (loopback-only `/v2/*`, `/admin/*` disabled) |
210212 | `auth.secrets_backend` | `"unsafe"` | Secrets storage |
211213 | `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 enabled41 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
9595 [tls.acme]
9696 enabled = true
9797 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
99100 ```
100101
101102 Challenge behavior:
@@ -103,19 +104,26 @@ Challenge behavior:
103104 - `cloudflare-dns-01` uses a Cloudflare API token from `pass`, `GORDON_CLOUDFLARE_API_TOKEN_FILE`, or `GORDON_CLOUDFLARE_API_TOKEN`.
104105 - `auto` selects Cloudflare DNS-01 when a token is available, otherwise falls back to HTTP-01.
105106
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+
106113 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.
107114
108115 #### Direct HTTP Onboarding
109116
110117 When `tls_port` is non-zero, direct HTTP clients (those not arriving through a trusted proxy) are restricted to CA onboarding paths only:
111118
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
113121 - `/.well-known/acme-challenge/*` — serves ACME HTTP-01 challenges when public ACME HTTP-01 is enabled, otherwise returns `404 Not Found`
114122 - All other paths return `403 Forbidden`
115123
116124 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.
117125
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.
119127
120128 #### HTTP to HTTPS redirect
121129
@@ -95,7 +95,8 @@ tls_port = 8443
95 [tls.acme]95 [tls.acme]
96 enabled = true96 enabled = true
97 email = "admin@example.com"97 email = "admin@example.com"
98-challenge = "auto" # auto, http-01, or cloudflare-dns-0198+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 Onboarding115 #### 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 downloads119+- `/.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 redirect128 #### 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) {
1414 assert.False(t, v.GetBool("tls.acme.enabled"))
1515 assert.Equal(t, "", v.GetString("tls.acme.email"))
1616 assert.Equal(t, "auto", v.GetString("tls.acme.challenge"))
17+ assert.Equal(t, 1, v.GetInt("tls.acme.obtain_batch_size"))
1718 }
@@ -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 {
198198
199199 TLS struct {
200200 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"`
204205 } `mapstructure:"acme"`
205206 } `mapstructure:"tls"`
206207 }
@@ -239,6 +240,7 @@ type services struct {
239240 pkiSvc *pkiusecase.Service
240241 reloadCoordinator *reloadCoordinator
241242 publicTLSSvc in.PublicTLSService
243+ publicTLSRuntime publicTLSRuntime
242244 registryHandler interface {
243245 UpdateBlobLimits(maxBlobChunkSize, maxBlobSize int64)
244246 }
@@ -419,7 +421,7 @@ func initAccessLog(cfg Config, log zerowrap.Logger) (*accesslog.Writer, error) {
419421 // serviceOptions controls optional behavior for createServicesWithOptions.
420422 type serviceOptions struct {
421423 // 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.
423425 StartPublicTLS bool
424426 }
425427
@@ -434,9 +436,9 @@ type serviceInit struct {
434436 }
435437
436438 // 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.
438440 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})
440442 }
441443
442444 // createServicesWithOptions creates all the application services with the given options.
@@ -555,12 +557,13 @@ func (si *serviceInit) initPublicTLS() error {
555557 log := si.log
556558
557559 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,
564567 }
565568
566569 tokenResolver := secrets.NewPublicTLSResolver(secrets.PublicTLSResolverConfig{})
@@ -607,10 +610,9 @@ func (si *serviceInit) initPublicTLS() error {
607610 }
608611
609612 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")
612615 }
613- svc.StartRenewalLoop(ctx, time.Hour)
614616 log.Info().
615617 Str("email", si.cfg.TLS.ACME.Email).
616618 Str("challenge", string(effective.Mode)).
@@ -624,6 +626,24 @@ func (si *serviceInit) initPublicTLS() error {
624626 }
625627
626628 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")
627647 return nil
628648 }
629649
@@ -2099,11 +2119,10 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger, accessWr
20992119
21002120 httpsMux := http.NewServeMux()
21012121 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)
21052125 }
2106- httpsMux.Handle("/", proxyHandler)
21072126
21082127 httpsHandler := otelhttp.NewHandler(middleware.Chain(httpsProxyMiddlewares...)(httpsMux), "gordon.proxy.tls")
21092128
@@ -2130,6 +2149,10 @@ func directHTTPOnboardingGate(ob *onboarding.Handler, proxyNets []*net.IPNet, pr
21302149 // Build a small mux for direct-client onboarding paths.
21312150 onboardingMux := http.NewServeMux()
21322151 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)
21332156 onboardingMux.HandleFunc("GET /ca", ob.ServeOnboardingPage)
21342157 onboardingMux.HandleFunc("GET /ca.crt", ob.ServeCACert)
21352158 onboardingMux.HandleFunc("GET /ca.mobileconfig", ob.ServeMobileconfig)
@@ -2159,6 +2182,35 @@ func directHTTPOnboardingGate(ob *onboarding.Handler, proxyNets []*net.IPNet, pr
21592182 })
21602183 }
21612184
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+
21622214 // directHTTPForbidden responds with 403 for non-onboarding HTTP paths.
21632215 // HEAD requests get an empty body per HTTP semantics.
21642216 func directHTTPForbidden(w http.ResponseWriter, r *http.Request) {
@@ -2481,6 +2533,8 @@ func runServers(ctx context.Context, v *viper.Viper, cfg Config, svc *services,
24812533 }
24822534 logEvent.Msg("Gordon is running")
24832535
2536+ startPublicTLSRuntimeWithWarning(ctx, svc.publicTLSRuntime, log)
2537+
24842538 schedulerCleanup, err := startOptionalSchedulers(ctx, cfg, svc, log, v)
24852539 if err != nil {
24862540 return err
@@ -2498,6 +2552,12 @@ func runServers(ctx context.Context, v *viper.Viper, cfg Config, svc *services,
24982552 return nil
24992553 }
25002554
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+
25012561 func waitForServerReady(ready <-chan struct{}, errChan <-chan error) error {
25022562 select {
25032563 case <-ready:
@@ -3219,6 +3279,7 @@ func loadConfig(v *viper.Viper, configPath string) error {
32193279 v.SetDefault("tls.acme.enabled", false)
32203280 v.SetDefault("tls.acme.email", "")
32213281 v.SetDefault("tls.acme.challenge", "auto")
3282+ v.SetDefault("tls.acme.obtain_batch_size", 1)
32223283 v.SetDefault("server.force_https_redirect", false)
32233284 v.SetDefault("server.data_dir", DefaultDataDir())
32243285 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.Service240 pkiSvc *pkiusecase.Service
240 reloadCoordinator *reloadCoordinator241 reloadCoordinator *reloadCoordinator
241 publicTLSSvc in.PublicTLSService242 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 bool425 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.log557 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 = svc628 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 nil647 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 err2540 return err
@@ -2498,6 +2552,12 @@ func runServers(ctx context.Context, v *viper.Viper, cfg Config, svc *services,
2498 return nil2552 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
22
33 import (
44 "context"
5+ "errors"
56 "io"
67 "net/http"
78 "net/http/httptest"
@@ -9,16 +10,30 @@ import (
910 "path/filepath"
1011 "sync"
1112 "testing"
13+ "time"
1214
1315 "github.com/bnema/zerowrap"
1416 "github.com/stretchr/testify/assert"
17+ "github.com/stretchr/testify/mock"
1518 "github.com/stretchr/testify/require"
1619
1720 adminhttp "github.com/bnema/gordon/internal/adapters/in/http/admin"
1821 pkiadapter "github.com/bnema/gordon/internal/adapters/out/pki"
22+ inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks"
1923 out "github.com/bnema/gordon/internal/boundaries/out"
24+ proxyusecase "github.com/bnema/gordon/internal/usecase/proxy"
2025 )
2126
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+
2237 // testAccessLogWriter is a thread-safe mock AccessLogWriter for tests.
2338 type testAccessLogWriter struct {
2439 mu sync.Mutex
@@ -139,6 +154,44 @@ func TestCreateHTTPHandlers_DirectHTTPRoot_ServesOnboarding(t *testing.T) {
139154 assert.Contains(t, rec.Body.String(), "Trust CA Certificate")
140155 }
141156
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+
142195 func TestCreateHTTPHandlers_DirectHTTPCertPath_ServesCACert(t *testing.T) {
143196 t.Parallel()
144197 ca := newTestCA(t)
@@ -235,17 +288,18 @@ func TestCreateHTTPHandlers_ForceHTTPSRedirect_DoesNotBypassDirectOnboarding(t *
235288 assert.Contains(t, rec.Body.String(), "Trust CA Certificate")
236289 }
237290
238-func TestCreateHTTPHandlers_HTTPSOnboarding_RemainsAvailable(t *testing.T) {
291+func TestCreateHTTPHandlers_HTTPSOnboarding_RemainsAvailableOnGordonDomain(t *testing.T) {
239292 t.Parallel()
240293 ca := newTestCA(t)
241294 cfg := newOnboardingConfig()
242- svc := &services{caAdapter: ca}
295+ cfg.Server.GordonDomain = "gordon.example.com"
296+ svc := &services{caAdapter: ca, proxySvc: newNotFoundProxyService(t)}
243297
244298 _, _, httpsHandler := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil)
245299
246300 req := httptest.NewRequest(http.MethodGet, "/ca", nil)
247301 req.RemoteAddr = directAddr
248- req.Host = "o2.bnema.dev"
302+ req.Host = "gordon.example.com"
249303 rec := httptest.NewRecorder()
250304 httpsHandler.ServeHTTP(rec, req)
251305
@@ -253,11 +307,31 @@ func TestCreateHTTPHandlers_HTTPSOnboarding_RemainsAvailable(t *testing.T) {
253307 assert.Contains(t, rec.Body.String(), "Trust CA Certificate")
254308 }
255309
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+
256329 func TestCreateHTTPHandlers_HTTPSOnboarding_HEADRoutesRemainAvailable(t *testing.T) {
257330 t.Parallel()
258331 ca := newTestCA(t)
259332 cfg := newOnboardingConfig()
260- svc := &services{caAdapter: ca}
333+ cfg.Server.GordonDomain = "gordon.example.com"
334+ svc := &services{caAdapter: ca, proxySvc: newNotFoundProxyService(t)}
261335
262336 _, _, httpsHandler := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil)
263337
@@ -267,7 +341,10 @@ func TestCreateHTTPHandlers_HTTPSOnboarding_HEADRoutesRemainAvailable(t *testing
267341 defer ts.Close()
268342
269343 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)
271348 require.NoError(t, err)
272349
273350 body, err := io.ReadAll(resp.Body)
@@ -376,6 +453,46 @@ func TestCreateHTTPHandlers_TLSDisabled_DoesNotServeHTTPOnboarding(t *testing.T)
376453 assert.NotContains(t, rec.Body.String(), "Trust CA Certificate")
377454 }
378455
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+
379496 func TestCreateHTTPHandlers_TLSConfiguredWithoutCA_FailsStartup(t *testing.T) {
380497 t.Parallel()
381498 // 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.Mutex39 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 = directAddr301 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 (
1010
1111 // Config holds the resolved configuration for public ACME TLS.
1212 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
1920 }
2021
2122 // 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 bool13+ Enabled bool
14- Email string14+ Email string
15- Challenge string15+ Challenge string
16- HTTPPort int16+ HTTPPort int
17- TLSPort int17+ TLSPort int
18- DataDir string18+ 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
4040 store, _ := newMockCertificateStore(t)
4141
4242 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,
4748 }
4849
4950 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 {
3333 Log zerowrap.Logger
3434 }
3535
36+const defaultObtainBatchSize = 1
37+
3638 // Service manages public TLS certificates via ACME.
3739 type Service struct {
3840 mu sync.RWMutex
@@ -193,12 +195,30 @@ func (s *Service) Reconcile(ctx context.Context) error {
193195
194196 // Process missing targets without holding s.mu or the store lock so
195197 // 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+ }
196206 s.reconcileMissingTargets(ctx, missing, required)
197207
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")
199209 return nil
200210 }
201211
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+
202222 func populateStoredCertificate(cert *out.StoredCertificate) error {
203223 if cert == nil {
204224 return fmt.Errorf("stored certificate is nil")
@@ -33,6 +33,8 @@ type ServiceDeps struct {
33 Log zerowrap.Logger33 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.RWMutex40 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 so196 // 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 nil209 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
251251 // Tests
252252 // ---------------------------------------------------------------------------
253253
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+
254303 func TestServiceReconcileObtainsMissingHTTP01Cert(t *testing.T) {
255304 ctx := context.Background()
256305
@@ -251,6 +251,55 @@ func generateTestCertPEM(names []string) (certPEM []byte, keyPEM []byte, err err
251 // Tests251 // 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

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close