Address ACME hardening review feedback
- commit
- b026f158cbd3028768585456f747241b788d1981
- author
- brice · · 2026-04-30 07:24 UTC
- parent
- d015ae1
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
4 files · +35 -53
M docs/config/server.md +3 -1
| @@ -108,6 +108,8 @@ HTTP-01 requires public access to port 80 for each hostname being validated. If | |||
| 108 | 108 | ||
| 109 | 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 | 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. | |
| 112 | + | ||
| 111 | 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. | |
| 112 | 114 | ||
| 113 | 115 | 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. | |
| @@ -123,7 +125,7 @@ When `tls_port` is non-zero, direct HTTP clients (those not arriving through a t | |||
| 123 | 125 | ||
| 124 | 126 | 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. | |
| 125 | 127 | ||
| 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. | |
| 128 | + | On HTTPS, onboarding paths are served only on `server.gordon_domain`; app hosts can use `/ca` for their own routes without Gordon intercepting it. If `gordon_domain` is empty, HTTPS onboarding paths are not served at all to avoid intercepting app hosts. | |
| 127 | 129 | ||
| 128 | 130 | #### HTTP to HTTPS redirect | |
| 129 | 131 | ||
| @@ -108,6 +108,8 @@ HTTP-01 requires public access to port 80 for each hostname being validated. If | |||||
| 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 renewal-loop reconciles 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. | |||
| 112 | + | ||||
| 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. | 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. | ||
| 112 | 114 | ||||
| 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. | 115 | 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. | ||
| @@ -123,7 +125,7 @@ When `tls_port` is non-zero, direct HTTP clients (those not arriving through a t | |||||
| 123 | 125 | ||||
| 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. | 126 | 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. | ||
| 125 | 127 | ||||
| 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. | 128 | + | On HTTPS, onboarding paths are served only on `server.gordon_domain`; app hosts can use `/ca` for their own routes without Gordon intercepting it. If `gordon_domain` is empty, HTTPS onboarding paths are not served at all to avoid intercepting app hosts. |
| 127 | 129 | ||||
| 128 | #### HTTP to HTTPS redirect | 130 | #### HTTP to HTTPS redirect | ||
| 129 | 131 | ||||
M docs/superpowers/plans/2026-04-30-acme-http-onboarding-hardening.md +10 -10
| @@ -18,24 +18,24 @@ | |||
| 18 | 18 | - Modify: `internal/app/run_http_handlers_test.go` | |
| 19 | 19 | - Modify/Create: `internal/app/public_tls_startup_test.go` if helper isolation is needed | |
| 20 | 20 | ||
| 21 | - | #### Sub-task 1: Well-known HTTP onboarding paths | |
| 21 | + | ### Sub-task 1: Well-known HTTP onboarding paths | |
| 22 | 22 | ||
| 23 | 23 | - [ ] Add tests showing direct HTTP serves `/.well-known/gordon/`, `/.well-known/gordon/ca.crt`, and `/.well-known/gordon/ca.mobileconfig`. | |
| 24 | 24 | - [ ] Keep existing direct HTTP `/`, `/ca`, `/ca.crt`, `/ca.mobileconfig` compatibility tests green. | |
| 25 | 25 | - [ ] Run `rtk go test ./internal/app/... -run 'TestCreateHTTPHandlers_DirectHTTP.*Gordon|TestCreateHTTPHandlers_DirectHTTP.*CA'` and confirm new tests fail before implementation. | |
| 26 | 26 | ||
| 27 | - | #### Sub-task 2: HTTPS host gating | |
| 27 | + | ### Sub-task 2: HTTPS host gating | |
| 28 | 28 | ||
| 29 | 29 | - [ ] Add tests showing `httpsHandler` serves `/ca` only when `req.Host == cfg.Server.GordonDomain`. | |
| 30 | 30 | - [ ] Add tests showing `/ca` on an arbitrary app host reaches the proxy path instead of onboarding. | |
| 31 | 31 | - [ ] Run `rtk go test ./internal/app/... -run 'TestCreateHTTPHandlers_HTTPSOnboarding'` and confirm the arbitrary-host test fails before implementation. | |
| 32 | 32 | ||
| 33 | - | #### Sub-task 3: Delayed ACME runtime startup | |
| 33 | + | ### Sub-task 3: Delayed ACME runtime startup | |
| 34 | 34 | ||
| 35 | 35 | - [ ] Add a small app-level test helper/fake proving runtime public TLS `Reconcile` and `StartRenewalLoop` happen after proxy readiness. | |
| 36 | 36 | - [ ] Run the targeted test and confirm it fails with current service-construction-time reconciliation. | |
| 37 | 37 | ||
| 38 | - | #### Sub-task 4: ACME obtain batch limiting | |
| 38 | + | ### Sub-task 4: ACME obtain batch limiting | |
| 39 | 39 | ||
| 40 | 40 | - [ ] Add usecase tests proving one reconcile run obtains only a bounded number of missing certificates. | |
| 41 | 41 | - [ ] Add a test proving a later reconcile run can continue with the remaining missing certificates. | |
| @@ -53,25 +53,25 @@ | |||
| 53 | 53 | - Modify: `internal/app/run.go` | |
| 54 | 54 | - Modify: `internal/app/run_http_handlers_test.go` | |
| 55 | 55 | ||
| 56 | - | #### Sub-task 1: Onboarding route changes | |
| 56 | + | ### Sub-task 1: Onboarding route changes | |
| 57 | 57 | ||
| 58 | 58 | - [ ] Add `/.well-known/gordon/` direct HTTP onboarding routes. | |
| 59 | 59 | - [ ] Keep legacy direct HTTP `/`, `/ca`, `/ca.crt`, `/ca.mobileconfig` routes. | |
| 60 | 60 | - [ ] Replace unconditional HTTPS `/ca*` registrations with a host-gated handler that only serves onboarding on `server.gordon_domain`. | |
| 61 | 61 | ||
| 62 | - | #### Sub-task 2: Delayed runtime ACME startup | |
| 62 | + | ### Sub-task 2: Delayed runtime ACME startup | |
| 63 | 63 | ||
| 64 | 64 | - [ ] Change runtime service creation to initialize public TLS without immediate reconcile/renewal loop side effects. | |
| 65 | 65 | - [ ] After registry, HTTP proxy, and TLS listeners are ready, call public TLS `Reconcile` and `StartRenewalLoop`. | |
| 66 | 66 | - [ ] Preserve local CLI read-only behavior: `NewKernelQuiet` must still avoid reconcile/renewal loop side effects. | |
| 67 | 67 | ||
| 68 | - | #### Sub-task 3: ACME obtain batch limiting | |
| 68 | + | ### Sub-task 3: ACME obtain batch limiting | |
| 69 | 69 | ||
| 70 | 70 | - [ ] Add a conservative default obtain batch size for runtime reconciliation. | |
| 71 | 71 | - [ ] Make `Service.Reconcile` process at most the configured number of missing targets per run. | |
| 72 | 72 | - [ ] Log when targets remain pending so operators understand that issuance is intentionally staged. | |
| 73 | 73 | ||
| 74 | - | #### Sub-task 4: Compatibility check | |
| 74 | + | ### Sub-task 4: Compatibility check | |
| 75 | 75 | ||
| 76 | 76 | - [ ] Verify ACME challenge handler is still registered before onboarding/proxy handlers. | |
| 77 | 77 | - [ ] Verify trusted proxy traffic keeps existing behavior. | |
| @@ -91,7 +91,7 @@ | |||
| 91 | 91 | - Modify: `docs/cli/tls.md` if status docs need wording | |
| 92 | 92 | - Modify: `docs/config/server.md` to document Cloudflare Full/Strict origin HTTPS expectations | |
| 93 | 93 | ||
| 94 | - | #### Sub-task 1: Documentation | |
| 94 | + | ### Sub-task 1: Documentation | |
| 95 | 95 | ||
| 96 | 96 | - [ ] Document that gray-cloud HTTP-01 requires public/NAT/firewall access to port 80. | |
| 97 | 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. | |
| @@ -99,7 +99,7 @@ | |||
| 99 | 99 | - [ ] Explain Cloudflare flexible HTTP origin as legacy/explicit, not preferred for end-to-end HTTPS. | |
| 100 | 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 | 101 | ||
| 102 | - | #### Sub-task 2: Verification | |
| 102 | + | ### Sub-task 2: Verification | |
| 103 | 103 | ||
| 104 | 104 | - [ ] Run `rtk gofmt -w` on changed Go files. | |
| 105 | 105 | - [ ] Run targeted app tests. | |
| @@ -18,24 +18,24 @@ | |||||
| 18 | - Modify: `internal/app/run_http_handlers_test.go` | 18 | - Modify: `internal/app/run_http_handlers_test.go` | ||
| 19 | - Modify/Create: `internal/app/public_tls_startup_test.go` if helper isolation is needed | 19 | - Modify/Create: `internal/app/public_tls_startup_test.go` if helper isolation is needed | ||
| 20 | 20 | ||||
| 21 | - | #### Sub-task 1: Well-known HTTP onboarding paths | 21 | + | ### Sub-task 1: Well-known HTTP onboarding paths |
| 22 | 22 | ||||
| 23 | - [ ] Add tests showing direct HTTP serves `/.well-known/gordon/`, `/.well-known/gordon/ca.crt`, and `/.well-known/gordon/ca.mobileconfig`. | 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. | 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. | 25 | - [ ] Run `rtk go test ./internal/app/... -run 'TestCreateHTTPHandlers_DirectHTTP.*Gordon|TestCreateHTTPHandlers_DirectHTTP.*CA'` and confirm new tests fail before implementation. | ||
| 26 | 26 | ||||
| 27 | - | #### Sub-task 2: HTTPS host gating | 27 | + | ### Sub-task 2: HTTPS host gating |
| 28 | 28 | ||||
| 29 | - [ ] Add tests showing `httpsHandler` serves `/ca` only when `req.Host == cfg.Server.GordonDomain`. | 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. | 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. | 31 | - [ ] Run `rtk go test ./internal/app/... -run 'TestCreateHTTPHandlers_HTTPSOnboarding'` and confirm the arbitrary-host test fails before implementation. | ||
| 32 | 32 | ||||
| 33 | - | #### Sub-task 3: Delayed ACME runtime startup | 33 | + | ### Sub-task 3: Delayed ACME runtime startup |
| 34 | 34 | ||||
| 35 | - [ ] Add a small app-level test helper/fake proving runtime public TLS `Reconcile` and `StartRenewalLoop` happen after proxy readiness. | 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. | 36 | - [ ] Run the targeted test and confirm it fails with current service-construction-time reconciliation. | ||
| 37 | 37 | ||||
| 38 | - | #### Sub-task 4: ACME obtain batch limiting | 38 | + | ### Sub-task 4: ACME obtain batch limiting |
| 39 | 39 | ||||
| 40 | - [ ] Add usecase tests proving one reconcile run obtains only a bounded number of missing certificates. | 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. | 41 | - [ ] Add a test proving a later reconcile run can continue with the remaining missing certificates. | ||
| @@ -53,25 +53,25 @@ | |||||
| 53 | - Modify: `internal/app/run.go` | 53 | - Modify: `internal/app/run.go` | ||
| 54 | - Modify: `internal/app/run_http_handlers_test.go` | 54 | - Modify: `internal/app/run_http_handlers_test.go` | ||
| 55 | 55 | ||||
| 56 | - | #### Sub-task 1: Onboarding route changes | 56 | + | ### Sub-task 1: Onboarding route changes |
| 57 | 57 | ||||
| 58 | - [ ] Add `/.well-known/gordon/` direct HTTP onboarding routes. | 58 | - [ ] Add `/.well-known/gordon/` direct HTTP onboarding routes. | ||
| 59 | - [ ] Keep legacy direct HTTP `/`, `/ca`, `/ca.crt`, `/ca.mobileconfig` 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`. | 60 | - [ ] Replace unconditional HTTPS `/ca*` registrations with a host-gated handler that only serves onboarding on `server.gordon_domain`. | ||
| 61 | 61 | ||||
| 62 | - | #### Sub-task 2: Delayed runtime ACME startup | 62 | + | ### Sub-task 2: Delayed runtime ACME startup |
| 63 | 63 | ||||
| 64 | - [ ] Change runtime service creation to initialize public TLS without immediate reconcile/renewal loop side effects. | 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`. | 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. | 66 | - [ ] Preserve local CLI read-only behavior: `NewKernelQuiet` must still avoid reconcile/renewal loop side effects. | ||
| 67 | 67 | ||||
| 68 | - | #### Sub-task 3: ACME obtain batch limiting | 68 | + | ### Sub-task 3: ACME obtain batch limiting |
| 69 | 69 | ||||
| 70 | - [ ] Add a conservative default obtain batch size for runtime reconciliation. | 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. | 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. | 72 | - [ ] Log when targets remain pending so operators understand that issuance is intentionally staged. | ||
| 73 | 73 | ||||
| 74 | - | #### Sub-task 4: Compatibility check | 74 | + | ### Sub-task 4: Compatibility check |
| 75 | 75 | ||||
| 76 | - [ ] Verify ACME challenge handler is still registered before onboarding/proxy handlers. | 76 | - [ ] Verify ACME challenge handler is still registered before onboarding/proxy handlers. | ||
| 77 | - [ ] Verify trusted proxy traffic keeps existing behavior. | 77 | - [ ] Verify trusted proxy traffic keeps existing behavior. | ||
| @@ -91,7 +91,7 @@ | |||||
| 91 | - Modify: `docs/cli/tls.md` if status docs need wording | 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 | 92 | - Modify: `docs/config/server.md` to document Cloudflare Full/Strict origin HTTPS expectations | ||
| 93 | 93 | ||||
| 94 | - | #### Sub-task 1: Documentation | 94 | + | ### Sub-task 1: Documentation |
| 95 | 95 | ||||
| 96 | - [ ] Document that gray-cloud HTTP-01 requires public/NAT/firewall access to port 80. | 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. | 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. | ||
| @@ -99,7 +99,7 @@ | |||||
| 99 | - [ ] Explain Cloudflare flexible HTTP origin as legacy/explicit, not preferred for end-to-end HTTPS. | 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. | 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 | 101 | ||||
| 102 | - | #### Sub-task 2: Verification | 102 | + | ### Sub-task 2: Verification |
| 103 | 103 | ||||
| 104 | - [ ] Run `rtk gofmt -w` on changed Go files. | 104 | - [ ] Run `rtk gofmt -w` on changed Go files. | ||
| 105 | - [ ] Run targeted app tests. | 105 | - [ ] Run targeted app tests. | ||
M internal/app/kernel.go +3 -3
| @@ -61,9 +61,9 @@ func newKernel(configPath string, initLog kernelLoggerInit) (*Kernel, error) { | |||
| 61 | 61 | ||
| 62 | 62 | // Prefer full service wiring so local CLI can execute the same operations | |
| 63 | 63 | // as remote mode without going through HTTP admin endpoints. | |
| 64 | - | // Use StartPublicTLS: false to prevent ACME Reconcile and renewal loop | |
| 65 | - | // side effects during read-only CLI commands (e.g. gordon tls status). | |
| 66 | - | if svc, fullErr := createServicesWithOptions(ctx, v, cfg, log, serviceOptions{StartPublicTLS: false}); fullErr == nil { | |
| 64 | + | // ACME Reconcile and renewal loop are only started from runServers, | |
| 65 | + | // so there are no side effects for read-only CLI commands. | |
| 66 | + | if svc, fullErr := createServicesWithOptions(ctx, v, cfg, log); fullErr == nil { | |
| 67 | 67 | // Wrap cleanup to stop public TLS service (with its renewal loop) | |
| 68 | 68 | // before the logger is cleaned up. | |
| 69 | 69 | wrappedCleanup := func() { | |
| @@ -61,9 +61,9 @@ func newKernel(configPath string, initLog kernelLoggerInit) (*Kernel, error) { | |||||
| 61 | 61 | ||||
| 62 | // Prefer full service wiring so local CLI can execute the same operations | 62 | // Prefer full service wiring so local CLI can execute the same operations | ||
| 63 | // as remote mode without going through HTTP admin endpoints. | 63 | // as remote mode without going through HTTP admin endpoints. | ||
| 64 | - | // Use StartPublicTLS: false to prevent ACME Reconcile and renewal loop | 64 | + | // ACME Reconcile and renewal loop are only started from runServers, |
| 65 | - | // side effects during read-only CLI commands (e.g. gordon tls status). | 65 | + | // so there are no side effects for read-only CLI commands. |
| 66 | - | if svc, fullErr := createServicesWithOptions(ctx, v, cfg, log, serviceOptions{StartPublicTLS: false}); fullErr == nil { | 66 | + | if svc, fullErr := createServicesWithOptions(ctx, v, cfg, log); fullErr == nil { |
| 67 | // Wrap cleanup to stop public TLS service (with its renewal loop) | 67 | // Wrap cleanup to stop public TLS service (with its renewal loop) | ||
| 68 | // before the logger is cleaned up. | 68 | // before the logger is cleaned up. | ||
| 69 | wrappedCleanup := func() { | 69 | wrappedCleanup := func() { | ||
M internal/app/run.go +19 -39
| @@ -418,40 +418,31 @@ func initAccessLog(cfg Config, log zerowrap.Logger) (*accesslog.Writer, error) { | |||
| 418 | 418 | return writer, nil | |
| 419 | 419 | } | |
| 420 | 420 | ||
| 421 | - | // serviceOptions controls optional behavior for createServicesWithOptions. | |
| 422 | - | type serviceOptions struct { | |
| 423 | - | // StartPublicTLS controls whether ACME Reconcile and renewal loop are started. | |
| 424 | - | // Runtime starts public TLS after listeners bind; local CLI uses false for read-only operations. | |
| 425 | - | StartPublicTLS bool | |
| 426 | - | } | |
| 427 | - | ||
| 428 | 421 | // serviceInit holds the shared context for service initialization helpers. | |
| 429 | 422 | type serviceInit struct { | |
| 430 | - | ctx context.Context | |
| 431 | - | v *viper.Viper | |
| 432 | - | cfg Config | |
| 433 | - | log zerowrap.Logger | |
| 434 | - | svc *services | |
| 435 | - | startPublicTLS bool | |
| 423 | + | ctx context.Context | |
| 424 | + | v *viper.Viper | |
| 425 | + | cfg Config | |
| 426 | + | log zerowrap.Logger | |
| 427 | + | svc *services | |
| 436 | 428 | } | |
| 437 | 429 | ||
| 438 | 430 | // createServices creates all the application services for server runtime. | |
| 439 | 431 | // Public ACME reconciliation is started later, after the HTTP listener is bound. | |
| 440 | 432 | func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowrap.Logger) (_ *services, retErr error) { | |
| 441 | - | return createServicesWithOptions(ctx, v, cfg, log, serviceOptions{StartPublicTLS: false}) | |
| 433 | + | return createServicesWithOptions(ctx, v, cfg, log) | |
| 442 | 434 | } | |
| 443 | 435 | ||
| 444 | - | // createServicesWithOptions creates all the application services with the given options. | |
| 445 | - | // When StartPublicTLS is false, ACME Reconcile and renewal loop are skipped so that | |
| 446 | - | // read-only CLI commands (e.g. gordon tls status) do not perform ACME side effects. | |
| 447 | - | func createServicesWithOptions(ctx context.Context, v *viper.Viper, cfg Config, log zerowrap.Logger, opts serviceOptions) (_ *services, retErr error) { | |
| 436 | + | // createServicesWithOptions creates all the application services. | |
| 437 | + | // ACME Reconcile and renewal loop are started later from runServers, | |
| 438 | + | // after HTTP listeners are bound. | |
| 439 | + | func createServicesWithOptions(ctx context.Context, v *viper.Viper, cfg Config, log zerowrap.Logger) (_ *services, retErr error) { | |
| 448 | 440 | si := &serviceInit{ | |
| 449 | - | ctx: ctx, | |
| 450 | - | v: v, | |
| 451 | - | cfg: cfg, | |
| 452 | - | log: log, | |
| 453 | - | svc: &services{}, | |
| 454 | - | startPublicTLS: opts.StartPublicTLS, | |
| 441 | + | ctx: ctx, | |
| 442 | + | v: v, | |
| 443 | + | cfg: cfg, | |
| 444 | + | log: log, | |
| 445 | + | svc: &services{}, | |
| 455 | 446 | } | |
| 456 | 447 | defer func() { | |
| 457 | 448 | if retErr != nil { | |
| @@ -609,21 +600,10 @@ func (si *serviceInit) initPublicTLS() error { | |||
| 609 | 600 | log.Warn().Err(err).Msg("failed to load ACME certificates, continuing") | |
| 610 | 601 | } | |
| 611 | 602 | ||
| 612 | - | if si.startPublicTLS { | |
| 613 | - | if err := startPublicTLSRuntime(ctx, svc, log); err != nil { | |
| 614 | - | log.Warn().Err(err).Msg("failed to start public ACME TLS runtime, continuing") | |
| 615 | - | } | |
| 616 | - | log.Info(). | |
| 617 | - | Str("email", si.cfg.TLS.ACME.Email). | |
| 618 | - | Str("challenge", string(effective.Mode)). | |
| 619 | - | Str("reason", effective.Reason). | |
| 620 | - | Msg("public ACME TLS initialized") | |
| 621 | - | } else { | |
| 622 | - | log.Info(). | |
| 623 | - | Str("email", si.cfg.TLS.ACME.Email). | |
| 624 | - | Str("challenge", string(effective.Mode)). | |
| 625 | - | Msg("public ACME TLS initialized (runtime start deferred)") | |
| 626 | - | } | |
| 603 | + | log.Info(). | |
| 604 | + | Str("email", si.cfg.TLS.ACME.Email). | |
| 605 | + | Str("challenge", string(effective.Mode)). | |
| 606 | + | Msg("public ACME TLS initialized (runtime start deferred)") | |
| 627 | 607 | ||
| 628 | 608 | si.svc.publicTLSSvc = svc | |
| 629 | 609 | si.svc.publicTLSRuntime = svc | |
| @@ -418,40 +418,31 @@ func initAccessLog(cfg Config, log zerowrap.Logger) (*accesslog.Writer, error) { | |||||
| 418 | return writer, nil | 418 | return writer, nil | ||
| 419 | } | 419 | } | ||
| 420 | 420 | ||||
| 421 | - | // serviceOptions controls optional behavior for createServicesWithOptions. | |||
| 422 | - | type serviceOptions struct { | |||
| 423 | - | // StartPublicTLS controls whether ACME Reconcile and renewal loop are started. | |||
| 424 | - | // Runtime starts public TLS after listeners bind; local CLI uses false for read-only operations. | |||
| 425 | - | StartPublicTLS bool | |||
| 426 | - | } | |||
| 427 | - | ||||
| 428 | // serviceInit holds the shared context for service initialization helpers. | 421 | // serviceInit holds the shared context for service initialization helpers. | ||
| 429 | type serviceInit struct { | 422 | type serviceInit struct { | ||
| 430 | - | ctx context.Context | 423 | + | ctx context.Context |
| 431 | - | v *viper.Viper | 424 | + | v *viper.Viper |
| 432 | - | cfg Config | 425 | + | cfg Config |
| 433 | - | log zerowrap.Logger | 426 | + | log zerowrap.Logger |
| 434 | - | svc *services | 427 | + | svc *services |
| 435 | - | startPublicTLS bool | |||
| 436 | } | 428 | } | ||
| 437 | 429 | ||||
| 438 | // createServices creates all the application services for server runtime. | 430 | // createServices creates all the application services for server runtime. | ||
| 439 | // Public ACME reconciliation is started later, after the HTTP listener is bound. | 431 | // Public ACME reconciliation is started later, after the HTTP listener is bound. | ||
| 440 | func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowrap.Logger) (_ *services, retErr error) { | 432 | func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowrap.Logger) (_ *services, retErr error) { | ||
| 441 | - | return createServicesWithOptions(ctx, v, cfg, log, serviceOptions{StartPublicTLS: false}) | 433 | + | return createServicesWithOptions(ctx, v, cfg, log) |
| 442 | } | 434 | } | ||
| 443 | 435 | ||||
| 444 | - | // createServicesWithOptions creates all the application services with the given options. | 436 | + | // createServicesWithOptions creates all the application services. |
| 445 | - | // When StartPublicTLS is false, ACME Reconcile and renewal loop are skipped so that | 437 | + | // ACME Reconcile and renewal loop are started later from runServers, |
| 446 | - | // read-only CLI commands (e.g. gordon tls status) do not perform ACME side effects. | 438 | + | // after HTTP listeners are bound. |
| 447 | - | func createServicesWithOptions(ctx context.Context, v *viper.Viper, cfg Config, log zerowrap.Logger, opts serviceOptions) (_ *services, retErr error) { | 439 | + | func createServicesWithOptions(ctx context.Context, v *viper.Viper, cfg Config, log zerowrap.Logger) (_ *services, retErr error) { |
| 448 | si := &serviceInit{ | 440 | si := &serviceInit{ | ||
| 449 | - | ctx: ctx, | 441 | + | ctx: ctx, |
| 450 | - | v: v, | 442 | + | v: v, |
| 451 | - | cfg: cfg, | 443 | + | cfg: cfg, |
| 452 | - | log: log, | 444 | + | log: log, |
| 453 | - | svc: &services{}, | 445 | + | svc: &services{}, |
| 454 | - | startPublicTLS: opts.StartPublicTLS, | |||
| 455 | } | 446 | } | ||
| 456 | defer func() { | 447 | defer func() { | ||
| 457 | if retErr != nil { | 448 | if retErr != nil { | ||
| @@ -609,21 +600,10 @@ func (si *serviceInit) initPublicTLS() error { | |||||
| 609 | log.Warn().Err(err).Msg("failed to load ACME certificates, continuing") | 600 | log.Warn().Err(err).Msg("failed to load ACME certificates, continuing") | ||
| 610 | } | 601 | } | ||
| 611 | 602 | ||||
| 612 | - | if si.startPublicTLS { | 603 | + | log.Info(). |
| 613 | - | if err := startPublicTLSRuntime(ctx, svc, log); err != nil { | 604 | + | Str("email", si.cfg.TLS.ACME.Email). |
| 614 | - | log.Warn().Err(err).Msg("failed to start public ACME TLS runtime, continuing") | 605 | + | Str("challenge", string(effective.Mode)). |
| 615 | - | } | 606 | + | Msg("public ACME TLS initialized (runtime start deferred)") |
| 616 | - | log.Info(). | |||
| 617 | - | Str("email", si.cfg.TLS.ACME.Email). | |||
| 618 | - | Str("challenge", string(effective.Mode)). | |||
| 619 | - | Str("reason", effective.Reason). | |||
| 620 | - | Msg("public ACME TLS initialized") | |||
| 621 | - | } else { | |||
| 622 | - | log.Info(). | |||
| 623 | - | Str("email", si.cfg.TLS.ACME.Email). | |||
| 624 | - | Str("challenge", string(effective.Mode)). | |||
| 625 | - | Msg("public ACME TLS initialized (runtime start deferred)") | |||
| 626 | - | } | |||
| 627 | 607 | ||||
| 628 | si.svc.publicTLSSvc = svc | 608 | si.svc.publicTLSSvc = svc | ||
| 629 | si.svc.publicTLSRuntime = svc | 609 | si.svc.publicTLSRuntime = svc | ||