detached d200400

Address ACME onboarding review feedback

commit
d200400726be9a444655f75d69cbcb28c6a328f4
author
brice · · 2026-05-01 10:03 UTC
committer
brice · · 2026-05-01 10:05 UTC
parent
b026f15
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +106 -137
M docs/config/server.md +2 -2
@@ -106,9 +106,9 @@ Challenge behavior:
106106
107107 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.
108108
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.
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 other explicit reconcile runs continue issuing remaining certificates.
110110
111-If the initial ACME reconcile fails (e.g. due to a transient network error or misconfiguration), Gordon logs the failure and does not start the renewal loop. Operators should fix the underlying error and restart/reload Gordon to trigger a new reconcile attempt.
111+If the initial ACME reconcile fails (e.g. due to a transient network error or misconfiguration), Gordon logs the failure but still starts the renewal loop for already cached certificates. Operators should fix the underlying error and restart/reload Gordon to trigger a new obtain attempt for missing certificates.
112112
113113 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.
114114
@@ -106,9 +106,9 @@ Challenge behavior:
106 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.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 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.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 other explicit reconcile runs continue issuing remaining certificates.
110 110
111-If the initial ACME reconcile fails (e.g. due to a transient network error or misconfiguration), Gordon logs the failure and does not start the renewal loop. Operators should fix the underlying error and restart/reload Gordon to trigger a new reconcile attempt.111+If the initial ACME reconcile fails (e.g. due to a transient network error or misconfiguration), Gordon logs the failure but still starts the renewal loop for already cached certificates. Operators should fix the underlying error and restart/reload Gordon to trigger a new obtain attempt for missing certificates.
112 112
113 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.113 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.
114 114
D docs/superpowers/plans/2026-04-30-acme-http-onboarding-hardening.md +0 -110
@@ -1,110 +0,0 @@
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.
@@ -1,110 +0,0 @@
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/run.go +3 -5
@@ -622,12 +622,10 @@ func startPublicTLSRuntime(ctx context.Context, svc publicTLSRuntime, log zerowr
622622 if svc == nil {
623623 return nil
624624 }
625- if err := svc.Reconcile(ctx); err != nil {
626- return err
627- }
625+ reconcileErr := svc.Reconcile(ctx)
628626 svc.StartRenewalLoop(ctx, time.Hour)
629627 log.Info().Msg("public ACME TLS runtime started")
630- return nil
628+ return reconcileErr
631629 }
632630
633631 // initSecrets creates the domain secret store, env loader, and secret service.
@@ -2552,7 +2550,7 @@ func runServers(ctx context.Context, v *viper.Viper, cfg Config, svc *services,
25522550
25532551 func startPublicTLSRuntimeWithWarning(ctx context.Context, svc publicTLSRuntime, log zerowrap.Logger) {
25542552 if err := startPublicTLSRuntime(ctx, svc, log); err != nil {
2555- log.Warn().Err(err).Msg("failed to start public ACME TLS runtime, continuing")
2553+ log.Warn().Err(err).Msg("initial public ACME reconcile failed, continuing with renewal loop")
25562554 }
25572555 }
25582556
@@ -622,12 +622,10 @@ func startPublicTLSRuntime(ctx context.Context, svc publicTLSRuntime, log zerowr
622 if svc == nil {622 if svc == nil {
623 return nil623 return nil
624 }624 }
625- if err := svc.Reconcile(ctx); err != nil {625+ reconcileErr := svc.Reconcile(ctx)
626- return err
627- }
628 svc.StartRenewalLoop(ctx, time.Hour)626 svc.StartRenewalLoop(ctx, time.Hour)
629 log.Info().Msg("public ACME TLS runtime started")627 log.Info().Msg("public ACME TLS runtime started")
630- return nil628+ return reconcileErr
631 }629 }
632 630
633 // initSecrets creates the domain secret store, env loader, and secret service.631 // initSecrets creates the domain secret store, env loader, and secret service.
@@ -2552,7 +2550,7 @@ func runServers(ctx context.Context, v *viper.Viper, cfg Config, svc *services,
2552 2550
2553 func startPublicTLSRuntimeWithWarning(ctx context.Context, svc publicTLSRuntime, log zerowrap.Logger) {2551 func startPublicTLSRuntimeWithWarning(ctx context.Context, svc publicTLSRuntime, log zerowrap.Logger) {
2554 if err := startPublicTLSRuntime(ctx, svc, log); err != nil {2552 if err := startPublicTLSRuntime(ctx, svc, log); err != nil {
2555- log.Warn().Err(err).Msg("failed to start public ACME TLS runtime, continuing")2553+ log.Warn().Err(err).Msg("initial public ACME reconcile failed, continuing with renewal loop")
2556 }2554 }
2557 }2555 }
2558 2556
M internal/app/run_http_handlers_test.go +2 -2
@@ -441,7 +441,7 @@ func TestStartPublicTLSRuntime_NilSvc(t *testing.T) {
441441 require.NoError(t, err)
442442 }
443443
444-func TestStartPublicTLSRuntime_DoesNotStartRenewalLoopWhenReconcileFails(t *testing.T) {
444+func TestStartPublicTLSRuntime_StartsRenewalLoopWhenReconcileFails(t *testing.T) {
445445 t.Parallel()
446446
447447 runtime := &recordingPublicTLSRuntime{reconcileErr: errors.New("boom")}
@@ -449,7 +449,7 @@ func TestStartPublicTLSRuntime_DoesNotStartRenewalLoopWhenReconcileFails(t *test
449449
450450 require.Error(t, err)
451451 assert.Equal(t, 1, runtime.reconcileCalls)
452- assert.Equal(t, 0, runtime.loopCalls)
452+ assert.Equal(t, 1, runtime.loopCalls)
453453 }
454454
455455 func TestCreateHTTPHandlers_TLSConfiguredWithoutCA_FailsStartup(t *testing.T) {
@@ -441,7 +441,7 @@ func TestStartPublicTLSRuntime_NilSvc(t *testing.T) {
441 require.NoError(t, err)441 require.NoError(t, err)
442 }442 }
443 443
444-func TestStartPublicTLSRuntime_DoesNotStartRenewalLoopWhenReconcileFails(t *testing.T) {444+func TestStartPublicTLSRuntime_StartsRenewalLoopWhenReconcileFails(t *testing.T) {
445 t.Parallel()445 t.Parallel()
446 446
447 runtime := &recordingPublicTLSRuntime{reconcileErr: errors.New("boom")}447 runtime := &recordingPublicTLSRuntime{reconcileErr: errors.New("boom")}
@@ -449,7 +449,7 @@ func TestStartPublicTLSRuntime_DoesNotStartRenewalLoopWhenReconcileFails(t *test
449 449
450 require.Error(t, err)450 require.Error(t, err)
451 assert.Equal(t, 1, runtime.reconcileCalls)451 assert.Equal(t, 1, runtime.reconcileCalls)
452- assert.Equal(t, 0, runtime.loopCalls)452+ assert.Equal(t, 1, runtime.loopCalls)
453 }453 }
454 454
455 func TestCreateHTTPHandlers_TLSConfiguredWithoutCA_FailsStartup(t *testing.T) {455 func TestCreateHTTPHandlers_TLSConfiguredWithoutCA_FailsStartup(t *testing.T) {
M internal/usecase/publictls/service.go +45 -18
@@ -37,13 +37,14 @@ const defaultObtainBatchSize = 1
3737
3838 // Service manages public TLS certificates via ACME.
3939 type Service struct {
40- mu sync.RWMutex
41- cfg Config
42- deps ServiceDeps
43- log zerowrap.Logger
44- certs map[string]*out.StoredCertificate // indexed by cert ID
45- lastErr map[string]string // indexed by cert ID
46- routeErr map[string]string // indexed by host
40+ mu sync.RWMutex
41+ cfg Config
42+ deps ServiceDeps
43+ log zerowrap.Logger
44+ certs map[string]*out.StoredCertificate // indexed by cert ID
45+ lastErr map[string]string // indexed by cert ID
46+ routeErr map[string]string // indexed by host
47+ obtainCursor int // next missing target index for batched obtains
4748
4849 // requiredHosts is the set of hosts that must be covered by ACME certs.
4950 requiredHosts map[string]struct{}
@@ -91,6 +92,7 @@ func (s *Service) Load(ctx context.Context) error {
9192 s.certs = make(map[string]*out.StoredCertificate, len(stored))
9293 s.lastErr = make(map[string]string)
9394 s.requiredHosts = make(map[string]struct{})
95+ s.obtainCursor = 0
9496
9597 for i := range stored {
9698 cert := &stored[i]
@@ -189,17 +191,7 @@ func (s *Service) Reconcile(ctx context.Context) error {
189191 batchSize = defaultObtainBatchSize
190192 }
191193
192- var missing []CertificateTarget
193- pendingCount := 0
194- for _, target := range targets {
195- if s.certificateExistsLocked(target) {
196- continue
197- }
198- pendingCount++
199- if len(missing) < batchSize {
200- missing = append(missing, target)
201- }
202- }
194+ missing, pendingCount := s.selectMissingTargetsLocked(targets, batchSize)
203195 s.mu.Unlock()
204196
205197 // Process missing targets without holding s.mu or the store lock so
@@ -216,6 +208,41 @@ func (s *Service) Reconcile(ctx context.Context) error {
216208 return nil
217209 }
218210
211+// selectMissingTargetsLocked returns up to batchSize missing certificate targets.
212+// It rotates the starting point across reconcile runs so one repeatedly failing
213+// target cannot indefinitely block later targets when batching is enabled.
214+// Must be called with s.mu held.
215+func (s *Service) selectMissingTargetsLocked(targets []CertificateTarget, batchSize int) ([]CertificateTarget, int) {
216+ if batchSize <= 0 {
217+ batchSize = defaultObtainBatchSize
218+ }
219+
220+ allMissing := make([]CertificateTarget, 0, len(targets))
221+ for _, target := range targets {
222+ if !s.certificateExistsLocked(target) {
223+ allMissing = append(allMissing, target)
224+ }
225+ }
226+
227+ pendingCount := len(allMissing)
228+ if pendingCount == 0 {
229+ s.obtainCursor = 0
230+ return nil, 0
231+ }
232+ if batchSize >= pendingCount {
233+ s.obtainCursor = 0
234+ return allMissing, pendingCount
235+ }
236+
237+ start := s.obtainCursor % pendingCount
238+ missing := make([]CertificateTarget, 0, batchSize)
239+ for i := range batchSize {
240+ missing = append(missing, allMissing[(start+i)%pendingCount])
241+ }
242+ s.obtainCursor = (start + batchSize) % pendingCount
243+ return missing, pendingCount
244+}
245+
219246 func populateStoredCertificate(cert *out.StoredCertificate) error {
220247 if cert == nil {
221248 return fmt.Errorf("stored certificate is nil")
@@ -37,13 +37,14 @@ const defaultObtainBatchSize = 1
37 37
38 // Service manages public TLS certificates via ACME.38 // Service manages public TLS certificates via ACME.
39 type Service struct {39 type Service struct {
40- mu sync.RWMutex40+ mu sync.RWMutex
41- cfg Config41+ cfg Config
42- deps ServiceDeps42+ deps ServiceDeps
43- log zerowrap.Logger43+ log zerowrap.Logger
44- certs map[string]*out.StoredCertificate // indexed by cert ID44+ certs map[string]*out.StoredCertificate // indexed by cert ID
45- lastErr map[string]string // indexed by cert ID45+ lastErr map[string]string // indexed by cert ID
46- routeErr map[string]string // indexed by host46+ routeErr map[string]string // indexed by host
47+ obtainCursor int // next missing target index for batched obtains
47 48
48 // requiredHosts is the set of hosts that must be covered by ACME certs.49 // requiredHosts is the set of hosts that must be covered by ACME certs.
49 requiredHosts map[string]struct{}50 requiredHosts map[string]struct{}
@@ -91,6 +92,7 @@ func (s *Service) Load(ctx context.Context) error {
91 s.certs = make(map[string]*out.StoredCertificate, len(stored))92 s.certs = make(map[string]*out.StoredCertificate, len(stored))
92 s.lastErr = make(map[string]string)93 s.lastErr = make(map[string]string)
93 s.requiredHosts = make(map[string]struct{})94 s.requiredHosts = make(map[string]struct{})
95+ s.obtainCursor = 0
94 96
95 for i := range stored {97 for i := range stored {
96 cert := &stored[i]98 cert := &stored[i]
@@ -189,17 +191,7 @@ func (s *Service) Reconcile(ctx context.Context) error {
189 batchSize = defaultObtainBatchSize191 batchSize = defaultObtainBatchSize
190 }192 }
191 193
192- var missing []CertificateTarget194+ missing, pendingCount := s.selectMissingTargetsLocked(targets, batchSize)
193- pendingCount := 0
194- for _, target := range targets {
195- if s.certificateExistsLocked(target) {
196- continue
197- }
198- pendingCount++
199- if len(missing) < batchSize {
200- missing = append(missing, target)
201- }
202- }
203 s.mu.Unlock()195 s.mu.Unlock()
204 196
205 // Process missing targets without holding s.mu or the store lock so197 // Process missing targets without holding s.mu or the store lock so
@@ -216,6 +208,41 @@ func (s *Service) Reconcile(ctx context.Context) error {
216 return nil208 return nil
217 }209 }
218 210
211+// selectMissingTargetsLocked returns up to batchSize missing certificate targets.
212+// It rotates the starting point across reconcile runs so one repeatedly failing
213+// target cannot indefinitely block later targets when batching is enabled.
214+// Must be called with s.mu held.
215+func (s *Service) selectMissingTargetsLocked(targets []CertificateTarget, batchSize int) ([]CertificateTarget, int) {
216+ if batchSize <= 0 {
217+ batchSize = defaultObtainBatchSize
218+ }
219+
220+ allMissing := make([]CertificateTarget, 0, len(targets))
221+ for _, target := range targets {
222+ if !s.certificateExistsLocked(target) {
223+ allMissing = append(allMissing, target)
224+ }
225+ }
226+
227+ pendingCount := len(allMissing)
228+ if pendingCount == 0 {
229+ s.obtainCursor = 0
230+ return nil, 0
231+ }
232+ if batchSize >= pendingCount {
233+ s.obtainCursor = 0
234+ return allMissing, pendingCount
235+ }
236+
237+ start := s.obtainCursor % pendingCount
238+ missing := make([]CertificateTarget, 0, batchSize)
239+ for i := range batchSize {
240+ missing = append(missing, allMissing[(start+i)%pendingCount])
241+ }
242+ s.obtainCursor = (start + batchSize) % pendingCount
243+ return missing, pendingCount
244+}
245+
219 func populateStoredCertificate(cert *out.StoredCertificate) error {246 func populateStoredCertificate(cert *out.StoredCertificate) error {
220 if cert == nil {247 if cert == nil {
221 return fmt.Errorf("stored certificate is nil")248 return fmt.Errorf("stored certificate is nil")
M internal/usecase/publictls/service_test.go +54 -0
@@ -300,6 +300,60 @@ func TestServiceReconcileLimitsMissingObtainsPerRun(t *testing.T) {
300300 assert.Len(t, storeState.All(), 3)
301301 }
302302
303+func TestServiceReconcileRotatesBatchAfterFailedObtain(t *testing.T) {
304+ ctx := context.Background()
305+
306+ routes := &fakeRoutes{
307+ routes: []domain.Route{
308+ {Domain: "b.example.com"},
309+ {Domain: "a.example.com"},
310+ },
311+ }
312+ issuer, recorder := newMockPublicCertificateIssuer(t, func(_ context.Context, order out.CertificateOrder) (*out.StoredCertificate, error) {
313+ if order.ID == "http01-a.example.com" {
314+ return nil, fmt.Errorf("acme validation failed")
315+ }
316+ return defaultStoredCert(order)
317+ }, nil)
318+ store, storeState := newMockCertificateStore(t)
319+
320+ cfg := Config{
321+ Enabled: true,
322+ Email: "admin@example.com",
323+ Challenge: "http-01",
324+ HTTPPort: 8088,
325+ TLSPort: 8443,
326+ ObtainBatchSize: 1,
327+ }
328+
329+ svc := NewService(cfg, ServiceDeps{
330+ Config: cfg,
331+ Routes: routes,
332+ Issuer: issuer,
333+ Store: store,
334+ Challenges: NewHTTP01Challenges(),
335+ Effective: EffectiveChallenge{
336+ ConfiguredMode: domain.ACMEChallengeHTTP01,
337+ Mode: domain.ACMEChallengeHTTP01,
338+ TokenSource: domain.ACMETokenSourceNone,
339+ Reason: "http-01 challenge selected",
340+ },
341+ })
342+
343+ require.NoError(t, svc.Load(ctx))
344+ require.NoError(t, svc.Reconcile(ctx))
345+ require.NoError(t, svc.Reconcile(ctx))
346+
347+ orders := recorder.Orders()
348+ require.Len(t, orders, 2)
349+ assert.Equal(t, "http01-a.example.com", orders[0].ID)
350+ assert.Equal(t, "http01-b.example.com", orders[1].ID, "second reconcile should rotate past the failed first target")
351+
352+ stored := storeState.All()
353+ require.Len(t, stored, 1)
354+ assert.Equal(t, "http01-b.example.com", stored[0].ID)
355+}
356+
303357 func TestServiceReconcileObtainsMissingHTTP01Cert(t *testing.T) {
304358 ctx := context.Background()
305359
@@ -300,6 +300,60 @@ func TestServiceReconcileLimitsMissingObtainsPerRun(t *testing.T) {
300 assert.Len(t, storeState.All(), 3)300 assert.Len(t, storeState.All(), 3)
301 }301 }
302 302
303+func TestServiceReconcileRotatesBatchAfterFailedObtain(t *testing.T) {
304+ ctx := context.Background()
305+
306+ routes := &fakeRoutes{
307+ routes: []domain.Route{
308+ {Domain: "b.example.com"},
309+ {Domain: "a.example.com"},
310+ },
311+ }
312+ issuer, recorder := newMockPublicCertificateIssuer(t, func(_ context.Context, order out.CertificateOrder) (*out.StoredCertificate, error) {
313+ if order.ID == "http01-a.example.com" {
314+ return nil, fmt.Errorf("acme validation failed")
315+ }
316+ return defaultStoredCert(order)
317+ }, nil)
318+ store, storeState := newMockCertificateStore(t)
319+
320+ cfg := Config{
321+ Enabled: true,
322+ Email: "admin@example.com",
323+ Challenge: "http-01",
324+ HTTPPort: 8088,
325+ TLSPort: 8443,
326+ ObtainBatchSize: 1,
327+ }
328+
329+ svc := NewService(cfg, ServiceDeps{
330+ Config: cfg,
331+ Routes: routes,
332+ Issuer: issuer,
333+ Store: store,
334+ Challenges: NewHTTP01Challenges(),
335+ Effective: EffectiveChallenge{
336+ ConfiguredMode: domain.ACMEChallengeHTTP01,
337+ Mode: domain.ACMEChallengeHTTP01,
338+ TokenSource: domain.ACMETokenSourceNone,
339+ Reason: "http-01 challenge selected",
340+ },
341+ })
342+
343+ require.NoError(t, svc.Load(ctx))
344+ require.NoError(t, svc.Reconcile(ctx))
345+ require.NoError(t, svc.Reconcile(ctx))
346+
347+ orders := recorder.Orders()
348+ require.Len(t, orders, 2)
349+ assert.Equal(t, "http01-a.example.com", orders[0].ID)
350+ assert.Equal(t, "http01-b.example.com", orders[1].ID, "second reconcile should rotate past the failed first target")
351+
352+ stored := storeState.All()
353+ require.Len(t, stored, 1)
354+ assert.Equal(t, "http01-b.example.com", stored[0].ID)
355+}
356+
303 func TestServiceReconcileObtainsMissingHTTP01Cert(t *testing.T) {357 func TestServiceReconcileObtainsMissingHTTP01Cert(t *testing.T) {
304 ctx := context.Background()358 ctx := context.Background()
305 359

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