detached 6a38a35

Merge pull request #183 from bnema/feature/acme-http-onboarding-hardening

Harden ACME HTTP onboarding flow
commit
6a38a35a8535310ca7b1044971d393854ec07b4e
author
bnema · · 2026-05-01 11:02 UTC
committer
GitHub · · 2026-05-01 11:02 UTC
merge of
0c0cc56 + 86e5b3b · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
refs
tag: v2.31.0-dev12
17 files · +922 -187
M docs/cli/ca.md +4 -4
@@ -83,13 +83,13 @@ sudo gordon ca install --uninstall
8383
8484 ## Client Trust Setup
8585
86-Clients that connect directly to Gordon's HTTPS port need the root CA certificate in their trust store. Gordon provides the certificate through a browser-accessible onboarding page at `https://<gordon-host>/ca`, or over plain HTTP at `http://<gordon-host>/ca` for first-time setup.
86+Clients that connect directly to Gordon's HTTPS port need the root CA certificate in their trust store. Gordon provides the certificate through a browser-accessible onboarding page at `https://<gordon-host>/.well-known/gordon/ca`, or over plain HTTP at `http://<gordon-host>/.well-known/gordon/ca` for first-time setup.
8787
8888 ### Firefox / Zen (NSS certificate store)
8989
9090 Firefox and Zen use their own certificate store, separate from the OS.
9191
92-1. Open `https://<gordon-host>/ca.crt` — Firefox will prompt to import the certificate.
92+1. Open `https://<gordon-host>/.well-known/gordon/ca.crt` — Firefox will prompt to import the certificate.
9393 2. Check **Trust this CA to identify websites** and confirm.
9494
9595 Alternatively, import manually: **Settings > Privacy & Security > Certificates > View Certificates > Import**.
@@ -98,7 +98,7 @@ Alternatively, import manually: **Settings > Privacy & Security > Certificates >
9898
9999 ```bash
100100 # Download the certificate
101-curl -k https://<gordon-host>/ca.crt -o gordon-ca.pem
101+curl -k https://<gordon-host>/.well-known/gordon/ca.crt -o gordon-ca.pem
102102
103103 # Debian / Ubuntu
104104 sudo cp gordon-ca.pem /usr/local/share/ca-certificates/gordon-ca.crt
@@ -111,7 +111,7 @@ sudo update-ca-trust
111111
112112 ### Mobile Devices (iOS / Android)
113113
114-Visit `http://<gordon-host>/ca` from the device browser. The onboarding page offers:
114+Visit `http://<gordon-host>/.well-known/gordon/ca` from the device browser. The onboarding page offers:
115115
116116 - **iOS:** Download the `.mobileconfig` profile, then install it in **Settings > General > VPN & Device Management**.
117117 - **Android:** Download `ca.crt`, then install it in **Settings > Security > Encryption & credentials > Install a certificate**.
@@ -83,13 +83,13 @@ sudo gordon ca install --uninstall
83 83
84 ## Client Trust Setup84 ## Client Trust Setup
85 85
86-Clients that connect directly to Gordon's HTTPS port need the root CA certificate in their trust store. Gordon provides the certificate through a browser-accessible onboarding page at `https://<gordon-host>/ca`, or over plain HTTP at `http://<gordon-host>/ca` for first-time setup.86+Clients that connect directly to Gordon's HTTPS port need the root CA certificate in their trust store. Gordon provides the certificate through a browser-accessible onboarding page at `https://<gordon-host>/.well-known/gordon/ca`, or over plain HTTP at `http://<gordon-host>/.well-known/gordon/ca` for first-time setup.
87 87
88 ### Firefox / Zen (NSS certificate store)88 ### Firefox / Zen (NSS certificate store)
89 89
90 Firefox and Zen use their own certificate store, separate from the OS.90 Firefox and Zen use their own certificate store, separate from the OS.
91 91
92-1. Open `https://<gordon-host>/ca.crt` — Firefox will prompt to import the certificate.92+1. Open `https://<gordon-host>/.well-known/gordon/ca.crt` — Firefox will prompt to import the certificate.
93 2. Check **Trust this CA to identify websites** and confirm.93 2. Check **Trust this CA to identify websites** and confirm.
94 94
95 Alternatively, import manually: **Settings > Privacy & Security > Certificates > View Certificates > Import**.95 Alternatively, import manually: **Settings > Privacy & Security > Certificates > View Certificates > Import**.
@@ -98,7 +98,7 @@ Alternatively, import manually: **Settings > Privacy & Security > Certificates >
98 98
99 ```bash99 ```bash
100 # Download the certificate100 # Download the certificate
101-curl -k https://<gordon-host>/ca.crt -o gordon-ca.pem101+curl -k https://<gordon-host>/.well-known/gordon/ca.crt -o gordon-ca.pem
102 102
103 # Debian / Ubuntu103 # Debian / Ubuntu
104 sudo cp gordon-ca.pem /usr/local/share/ca-certificates/gordon-ca.crt104 sudo cp gordon-ca.pem /usr/local/share/ca-certificates/gordon-ca.crt
@@ -111,7 +111,7 @@ sudo update-ca-trust
111 111
112 ### Mobile Devices (iOS / Android)112 ### Mobile Devices (iOS / Android)
113 113
114-Visit `http://<gordon-host>/ca` from the device browser. The onboarding page offers:114+Visit `http://<gordon-host>/.well-known/gordon/ca` from the device browser. The onboarding page offers:
115 115
116 - **iOS:** Download the `.mobileconfig` profile, then install it in **Settings > General > VPN & Device Management**.116 - **iOS:** Download the `.mobileconfig` profile, then install it in **Settings > General > VPN & Device Management**.
117 - **Android:** Download `ca.crt`, then install it in **Settings > Security > Encryption & credentials > Install a certificate**.117 - **Android:** Download `ca.crt`, then install it in **Settings > Security > Encryption & credentials > Install a certificate**.
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 +13 -4
@@ -69,7 +69,7 @@ With TLS enabled:
6969 - Gordon runs an internal CA (root + intermediate) stored in `{data_dir}/pki/`
7070 - Leaf certificates are issued on-demand per domain (SNI-based) and cached in memory
7171 - The intermediate CA auto-renews before expiry
72-- An onboarding page at `https://<gordon-host>:<tls_port>/ca` lets clients download the root CA certificate
72+- An onboarding page at `https://<gordon-host>:<tls_port>/.well-known/gordon/` lets clients download the root CA certificate
7373 - The root CA is stable across restarts (generated once, persisted to disk)
7474
7575 #### Custom certificates
@@ -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,27 @@ 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 other explicit reconcile runs continue issuing remaining certificates.
110+
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+
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) is not end-to-end HTTPS.
114+
106115 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.
107116
108117 #### Direct HTTP Onboarding
109118
110119 When `tls_port` is non-zero, direct HTTP clients (those not arriving through a trusted proxy) are restricted to CA onboarding paths only:
111120
112-- `/`, `/ca`, `/ca.crt`, `/ca.mobileconfig` — serve the onboarding page and CA certificate downloads
121+- `/.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
113122 - `/.well-known/acme-challenge/*` — serves ACME HTTP-01 challenges when public ACME HTTP-01 is enabled, otherwise returns `404 Not Found`
114123 - All other paths return `403 Forbidden`
115124
116125 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.
117126
118-The onboarding page is also available on HTTPS at `/ca`.
127+On HTTPS, onboarding paths are served only on `server.gordon_domain`; app hosts can use their own routes without Gordon intercepting them. If `gordon_domain` is empty, HTTPS onboarding paths are not served at all to avoid intercepting app hosts.
119128
120129 #### HTTP to HTTPS redirect
121130
@@ -69,7 +69,7 @@ With TLS enabled:
69 - Gordon runs an internal CA (root + intermediate) stored in `{data_dir}/pki/`69 - Gordon runs an internal CA (root + intermediate) stored in `{data_dir}/pki/`
70 - Leaf certificates are issued on-demand per domain (SNI-based) and cached in memory70 - Leaf certificates are issued on-demand per domain (SNI-based) and cached in memory
71 - The intermediate CA auto-renews before expiry71 - The intermediate CA auto-renews before expiry
72-- An onboarding page at `https://<gordon-host>:<tls_port>/ca` lets clients download the root CA certificate72+- An onboarding page at `https://<gordon-host>:<tls_port>/.well-known/gordon/` lets clients download the root CA certificate
73 - The root CA is stable across restarts (generated once, persisted to disk)73 - The root CA is stable across restarts (generated once, persisted to disk)
74 74
75 #### Custom certificates75 #### Custom certificates
@@ -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,27 @@ 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 other explicit reconcile runs continue issuing remaining certificates.
110+
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+
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) is not end-to-end HTTPS.
114+
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.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.
107 116
108 #### Direct HTTP Onboarding117 #### Direct HTTP Onboarding
109 118
110 When `tls_port` is non-zero, direct HTTP clients (those not arriving through a trusted proxy) are restricted to CA onboarding paths only:119 When `tls_port` is non-zero, direct HTTP clients (those not arriving through a trusted proxy) are restricted to CA onboarding paths only:
111 120
112-- `/`, `/ca`, `/ca.crt`, `/ca.mobileconfig` — serve the onboarding page and CA certificate downloads121+- `/.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
113 - `/.well-known/acme-challenge/*` — serves ACME HTTP-01 challenges when public ACME HTTP-01 is enabled, otherwise returns `404 Not Found`122 - `/.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`123 - All other paths return `403 Forbidden`
115 124
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.125 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 126
118-The onboarding page is also available on HTTPS at `/ca`.127+On HTTPS, onboarding paths are served only on `server.gordon_domain`; app hosts can use their own routes without Gordon intercepting them. If `gordon_domain` is empty, HTTPS onboarding paths are not served at all to avoid intercepting app hosts.
119 128
120 #### HTTP to HTTPS redirect129 #### HTTP to HTTPS redirect
121 130
M internal/adapters/in/http/onboarding/handler.go +4 -4
@@ -113,25 +113,25 @@ func (h *Handler) ServeOnboardingPage(w http.ResponseWriter, r *http.Request) {
113113
114114 <div class="platform" id="ios">
115115 <h2>iOS / iPadOS</h2>
116- <p><a class="btn" href="/ca.mobileconfig">Download Profile</a></p>
116+ <p><a class="btn" href="/.well-known/gordon/ca.mobileconfig">Download Profile</a></p>
117117 <p>After downloading, go to <strong>Settings → General → VPN &amp; Device Management</strong> to install, then <strong>Settings → General → About → Certificate Trust Settings</strong> to enable full trust.</p>
118118 </div>
119119
120120 <div class="platform" id="android">
121121 <h2>Android</h2>
122- <p><a class="btn" href="/ca.crt">Download Certificate</a></p>
122+ <p><a class="btn" href="/.well-known/gordon/ca.crt">Download Certificate</a></p>
123123 <p>Go to <strong>Settings → Security → Install a certificate → CA certificate</strong> and select the downloaded file.</p>
124124 </div>
125125
126126 <div class="platform" id="desktop">
127127 <h2>macOS / Linux / Windows</h2>
128- <p><a class="btn" href="/ca.crt">Download Certificate</a></p>
128+ <p><a class="btn" href="/.well-known/gordon/ca.crt">Download Certificate</a></p>
129129 <p>Double-click the downloaded file and add it to your system trust store, or import it via your OS certificate manager.</p>
130130 </div>
131131
132132 <div class="platform" id="firefox">
133133 <h2>Firefox / Zen</h2>
134- <p><a class="btn" href="/ca.crt">Download Certificate</a></p>
134+ <p><a class="btn" href="/.well-known/gordon/ca.crt">Download Certificate</a></p>
135135 <p>Firefox and Zen use their own certificate store (NSS). Go to <strong>Settings → Privacy &amp; Security → Certificates → View Certificates → Authorities → Import</strong> and select the downloaded file. Check <strong>Trust this CA to identify websites</strong>.</p>
136136 </div>
137137
@@ -113,25 +113,25 @@ func (h *Handler) ServeOnboardingPage(w http.ResponseWriter, r *http.Request) {
113 113
114 <div class="platform" id="ios">114 <div class="platform" id="ios">
115 <h2>iOS / iPadOS</h2>115 <h2>iOS / iPadOS</h2>
116- <p><a class="btn" href="/ca.mobileconfig">Download Profile</a></p>116+ <p><a class="btn" href="/.well-known/gordon/ca.mobileconfig">Download Profile</a></p>
117 <p>After downloading, go to <strong>Settings → General → VPN &amp; Device Management</strong> to install, then <strong>Settings → General → About → Certificate Trust Settings</strong> to enable full trust.</p>117 <p>After downloading, go to <strong>Settings → General → VPN &amp; Device Management</strong> to install, then <strong>Settings → General → About → Certificate Trust Settings</strong> to enable full trust.</p>
118 </div>118 </div>
119 119
120 <div class="platform" id="android">120 <div class="platform" id="android">
121 <h2>Android</h2>121 <h2>Android</h2>
122- <p><a class="btn" href="/ca.crt">Download Certificate</a></p>122+ <p><a class="btn" href="/.well-known/gordon/ca.crt">Download Certificate</a></p>
123 <p>Go to <strong>Settings → Security → Install a certificate → CA certificate</strong> and select the downloaded file.</p>123 <p>Go to <strong>Settings → Security → Install a certificate → CA certificate</strong> and select the downloaded file.</p>
124 </div>124 </div>
125 125
126 <div class="platform" id="desktop">126 <div class="platform" id="desktop">
127 <h2>macOS / Linux / Windows</h2>127 <h2>macOS / Linux / Windows</h2>
128- <p><a class="btn" href="/ca.crt">Download Certificate</a></p>128+ <p><a class="btn" href="/.well-known/gordon/ca.crt">Download Certificate</a></p>
129 <p>Double-click the downloaded file and add it to your system trust store, or import it via your OS certificate manager.</p>129 <p>Double-click the downloaded file and add it to your system trust store, or import it via your OS certificate manager.</p>
130 </div>130 </div>
131 131
132 <div class="platform" id="firefox">132 <div class="platform" id="firefox">
133 <h2>Firefox / Zen</h2>133 <h2>Firefox / Zen</h2>
134- <p><a class="btn" href="/ca.crt">Download Certificate</a></p>134+ <p><a class="btn" href="/.well-known/gordon/ca.crt">Download Certificate</a></p>
135 <p>Firefox and Zen use their own certificate store (NSS). Go to <strong>Settings → Privacy &amp; Security → Certificates → View Certificates → Authorities → Import</strong> and select the downloaded file. Check <strong>Trust this CA to identify websites</strong>.</p>135 <p>Firefox and Zen use their own certificate store (NSS). Go to <strong>Settings → Privacy &amp; Security → Certificates → View Certificates → Authorities → Import</strong> and select the downloaded file. Check <strong>Trust this CA to identify websites</strong>.</p>
136 </div>136 </div>
137 137
M internal/adapters/in/http/onboarding/handler_test.go +18 -16
@@ -31,22 +31,24 @@ func newTestServer(t *testing.T) (*httptest.Server, []byte, []byte) {
3131 h, rootPEM, mobileconfig := newTestHandler()
3232
3333 mux := http.NewServeMux()
34- mux.HandleFunc("GET /ca", h.ServeOnboardingPage)
35- mux.HandleFunc("HEAD /ca", h.ServeOnboardingPage)
36- mux.HandleFunc("GET /ca.crt", h.ServeCACert)
37- mux.HandleFunc("HEAD /ca.crt", h.ServeCACert)
38- mux.HandleFunc("GET /ca.mobileconfig", h.ServeMobileconfig)
39- mux.HandleFunc("HEAD /ca.mobileconfig", h.ServeMobileconfig)
34+ mux.HandleFunc("GET /.well-known/gordon/{$}", h.ServeOnboardingPage)
35+ mux.HandleFunc("HEAD /.well-known/gordon/{$}", h.ServeOnboardingPage)
36+ mux.HandleFunc("GET /.well-known/gordon/ca", h.ServeOnboardingPage)
37+ mux.HandleFunc("HEAD /.well-known/gordon/ca", h.ServeOnboardingPage)
38+ mux.HandleFunc("GET /.well-known/gordon/ca.crt", h.ServeCACert)
39+ mux.HandleFunc("HEAD /.well-known/gordon/ca.crt", h.ServeCACert)
40+ mux.HandleFunc("GET /.well-known/gordon/ca.mobileconfig", h.ServeMobileconfig)
41+ mux.HandleFunc("HEAD /.well-known/gordon/ca.mobileconfig", h.ServeMobileconfig)
4042
4143 srv := httptest.NewServer(mux)
4244 t.Cleanup(srv.Close)
4345 return srv, rootPEM, mobileconfig
4446 }
4547
46-// getOnboardingBody issues a GET /ca with a custom Host header and returns the body.
48+// getOnboardingBody issues a GET /.well-known/gordon/ca with a custom Host header and returns the body.
4749 func getOnboardingBody(t *testing.T, srv *httptest.Server, host string) string {
4850 t.Helper()
49- req, err := http.NewRequest(http.MethodGet, srv.URL+"/ca", nil)
51+ req, err := http.NewRequest(http.MethodGet, srv.URL+"/.well-known/gordon/ca", nil)
5052 require.NoError(t, err)
5153 req.Host = host
5254
@@ -63,7 +65,7 @@ func getOnboardingBody(t *testing.T, srv *httptest.Server, host string) string {
6365 func TestHandler_CACert(t *testing.T) {
6466 srv, rootPEM, _ := newTestServer(t)
6567
66- resp, err := http.Get(srv.URL + "/ca.crt")
68+ resp, err := http.Get(srv.URL + "/.well-known/gordon/ca.crt")
6769 require.NoError(t, err)
6870 defer resp.Body.Close()
6971
@@ -78,7 +80,7 @@ func TestHandler_CACert(t *testing.T) {
7880 func TestHandler_Mobileconfig(t *testing.T) {
7981 srv, _, _ := newTestServer(t)
8082
81- resp, err := http.Get(srv.URL + "/ca.mobileconfig")
83+ resp, err := http.Get(srv.URL + "/.well-known/gordon/ca.mobileconfig")
8284 require.NoError(t, err)
8385 defer resp.Body.Close()
8486
@@ -93,7 +95,7 @@ func TestHandler_Mobileconfig(t *testing.T) {
9395 func TestHandler_OnboardingPage(t *testing.T) {
9496 srv, _, _ := newTestServer(t)
9597
96- resp, err := http.Get(srv.URL + "/ca")
98+ resp, err := http.Get(srv.URL + "/.well-known/gordon/")
9799 require.NoError(t, err)
98100 defer resp.Body.Close()
99101
@@ -103,8 +105,8 @@ func TestHandler_OnboardingPage(t *testing.T) {
103105 assert.Equal(t, http.StatusOK, resp.StatusCode)
104106 assert.Equal(t, "text/html; charset=utf-8", resp.Header.Get("Content-Type"))
105107 assert.Contains(t, string(body), "Gordon")
106- assert.Contains(t, string(body), "/ca.crt")
107- assert.Contains(t, string(body), "/ca.mobileconfig")
108+ assert.Contains(t, string(body), "/.well-known/gordon/ca.crt")
109+ assert.Contains(t, string(body), "/.well-known/gordon/ca.mobileconfig")
108110 }
109111
110112 func TestHandler_OnboardingPage_DefaultHTTPSURLOmitsInternalTLSPort(t *testing.T) {
@@ -164,7 +166,7 @@ func TestHandler_OnboardingPage_GoToSiteLinkIsPlainLink(t *testing.T) {
164166 func TestHandler_OnboardingPage_HEAD(t *testing.T) {
165167 srv, _, _ := newTestServer(t)
166168
167- req, err := http.NewRequest(http.MethodHead, srv.URL+"/ca", nil)
169+ req, err := http.NewRequest(http.MethodHead, srv.URL+"/.well-known/gordon/ca", nil)
168170 require.NoError(t, err)
169171
170172 resp, err := http.DefaultClient.Do(req)
@@ -182,7 +184,7 @@ func TestHandler_OnboardingPage_HEAD(t *testing.T) {
182184 func TestHandler_CACert_HEAD(t *testing.T) {
183185 srv, _, _ := newTestServer(t)
184186
185- req, err := http.NewRequest(http.MethodHead, srv.URL+"/ca.crt", nil)
187+ req, err := http.NewRequest(http.MethodHead, srv.URL+"/.well-known/gordon/ca.crt", nil)
186188 require.NoError(t, err)
187189
188190 resp, err := http.DefaultClient.Do(req)
@@ -200,7 +202,7 @@ func TestHandler_CACert_HEAD(t *testing.T) {
200202 func TestHandler_Mobileconfig_HEAD(t *testing.T) {
201203 srv, _, _ := newTestServer(t)
202204
203- req, err := http.NewRequest(http.MethodHead, srv.URL+"/ca.mobileconfig", nil)
205+ req, err := http.NewRequest(http.MethodHead, srv.URL+"/.well-known/gordon/ca.mobileconfig", nil)
204206 require.NoError(t, err)
205207
206208 resp, err := http.DefaultClient.Do(req)
@@ -31,22 +31,24 @@ func newTestServer(t *testing.T) (*httptest.Server, []byte, []byte) {
31 h, rootPEM, mobileconfig := newTestHandler()31 h, rootPEM, mobileconfig := newTestHandler()
32 32
33 mux := http.NewServeMux()33 mux := http.NewServeMux()
34- mux.HandleFunc("GET /ca", h.ServeOnboardingPage)34+ mux.HandleFunc("GET /.well-known/gordon/{$}", h.ServeOnboardingPage)
35- mux.HandleFunc("HEAD /ca", h.ServeOnboardingPage)35+ mux.HandleFunc("HEAD /.well-known/gordon/{$}", h.ServeOnboardingPage)
36- mux.HandleFunc("GET /ca.crt", h.ServeCACert)36+ mux.HandleFunc("GET /.well-known/gordon/ca", h.ServeOnboardingPage)
37- mux.HandleFunc("HEAD /ca.crt", h.ServeCACert)37+ mux.HandleFunc("HEAD /.well-known/gordon/ca", h.ServeOnboardingPage)
38- mux.HandleFunc("GET /ca.mobileconfig", h.ServeMobileconfig)38+ mux.HandleFunc("GET /.well-known/gordon/ca.crt", h.ServeCACert)
39- mux.HandleFunc("HEAD /ca.mobileconfig", h.ServeMobileconfig)39+ mux.HandleFunc("HEAD /.well-known/gordon/ca.crt", h.ServeCACert)
40+ mux.HandleFunc("GET /.well-known/gordon/ca.mobileconfig", h.ServeMobileconfig)
41+ mux.HandleFunc("HEAD /.well-known/gordon/ca.mobileconfig", h.ServeMobileconfig)
40 42
41 srv := httptest.NewServer(mux)43 srv := httptest.NewServer(mux)
42 t.Cleanup(srv.Close)44 t.Cleanup(srv.Close)
43 return srv, rootPEM, mobileconfig45 return srv, rootPEM, mobileconfig
44 }46 }
45 47
46-// getOnboardingBody issues a GET /ca with a custom Host header and returns the body.48+// getOnboardingBody issues a GET /.well-known/gordon/ca with a custom Host header and returns the body.
47 func getOnboardingBody(t *testing.T, srv *httptest.Server, host string) string {49 func getOnboardingBody(t *testing.T, srv *httptest.Server, host string) string {
48 t.Helper()50 t.Helper()
49- req, err := http.NewRequest(http.MethodGet, srv.URL+"/ca", nil)51+ req, err := http.NewRequest(http.MethodGet, srv.URL+"/.well-known/gordon/ca", nil)
50 require.NoError(t, err)52 require.NoError(t, err)
51 req.Host = host53 req.Host = host
52 54
@@ -63,7 +65,7 @@ func getOnboardingBody(t *testing.T, srv *httptest.Server, host string) string {
63 func TestHandler_CACert(t *testing.T) {65 func TestHandler_CACert(t *testing.T) {
64 srv, rootPEM, _ := newTestServer(t)66 srv, rootPEM, _ := newTestServer(t)
65 67
66- resp, err := http.Get(srv.URL + "/ca.crt")68+ resp, err := http.Get(srv.URL + "/.well-known/gordon/ca.crt")
67 require.NoError(t, err)69 require.NoError(t, err)
68 defer resp.Body.Close()70 defer resp.Body.Close()
69 71
@@ -78,7 +80,7 @@ func TestHandler_CACert(t *testing.T) {
78 func TestHandler_Mobileconfig(t *testing.T) {80 func TestHandler_Mobileconfig(t *testing.T) {
79 srv, _, _ := newTestServer(t)81 srv, _, _ := newTestServer(t)
80 82
81- resp, err := http.Get(srv.URL + "/ca.mobileconfig")83+ resp, err := http.Get(srv.URL + "/.well-known/gordon/ca.mobileconfig")
82 require.NoError(t, err)84 require.NoError(t, err)
83 defer resp.Body.Close()85 defer resp.Body.Close()
84 86
@@ -93,7 +95,7 @@ func TestHandler_Mobileconfig(t *testing.T) {
93 func TestHandler_OnboardingPage(t *testing.T) {95 func TestHandler_OnboardingPage(t *testing.T) {
94 srv, _, _ := newTestServer(t)96 srv, _, _ := newTestServer(t)
95 97
96- resp, err := http.Get(srv.URL + "/ca")98+ resp, err := http.Get(srv.URL + "/.well-known/gordon/")
97 require.NoError(t, err)99 require.NoError(t, err)
98 defer resp.Body.Close()100 defer resp.Body.Close()
99 101
@@ -103,8 +105,8 @@ func TestHandler_OnboardingPage(t *testing.T) {
103 assert.Equal(t, http.StatusOK, resp.StatusCode)105 assert.Equal(t, http.StatusOK, resp.StatusCode)
104 assert.Equal(t, "text/html; charset=utf-8", resp.Header.Get("Content-Type"))106 assert.Equal(t, "text/html; charset=utf-8", resp.Header.Get("Content-Type"))
105 assert.Contains(t, string(body), "Gordon")107 assert.Contains(t, string(body), "Gordon")
106- assert.Contains(t, string(body), "/ca.crt")108+ assert.Contains(t, string(body), "/.well-known/gordon/ca.crt")
107- assert.Contains(t, string(body), "/ca.mobileconfig")109+ assert.Contains(t, string(body), "/.well-known/gordon/ca.mobileconfig")
108 }110 }
109 111
110 func TestHandler_OnboardingPage_DefaultHTTPSURLOmitsInternalTLSPort(t *testing.T) {112 func TestHandler_OnboardingPage_DefaultHTTPSURLOmitsInternalTLSPort(t *testing.T) {
@@ -164,7 +166,7 @@ func TestHandler_OnboardingPage_GoToSiteLinkIsPlainLink(t *testing.T) {
164 func TestHandler_OnboardingPage_HEAD(t *testing.T) {166 func TestHandler_OnboardingPage_HEAD(t *testing.T) {
165 srv, _, _ := newTestServer(t)167 srv, _, _ := newTestServer(t)
166 168
167- req, err := http.NewRequest(http.MethodHead, srv.URL+"/ca", nil)169+ req, err := http.NewRequest(http.MethodHead, srv.URL+"/.well-known/gordon/ca", nil)
168 require.NoError(t, err)170 require.NoError(t, err)
169 171
170 resp, err := http.DefaultClient.Do(req)172 resp, err := http.DefaultClient.Do(req)
@@ -182,7 +184,7 @@ func TestHandler_OnboardingPage_HEAD(t *testing.T) {
182 func TestHandler_CACert_HEAD(t *testing.T) {184 func TestHandler_CACert_HEAD(t *testing.T) {
183 srv, _, _ := newTestServer(t)185 srv, _, _ := newTestServer(t)
184 186
185- req, err := http.NewRequest(http.MethodHead, srv.URL+"/ca.crt", nil)187+ req, err := http.NewRequest(http.MethodHead, srv.URL+"/.well-known/gordon/ca.crt", nil)
186 require.NoError(t, err)188 require.NoError(t, err)
187 189
188 resp, err := http.DefaultClient.Do(req)190 resp, err := http.DefaultClient.Do(req)
@@ -200,7 +202,7 @@ func TestHandler_CACert_HEAD(t *testing.T) {
200 func TestHandler_Mobileconfig_HEAD(t *testing.T) {202 func TestHandler_Mobileconfig_HEAD(t *testing.T) {
201 srv, _, _ := newTestServer(t)203 srv, _, _ := newTestServer(t)
202 204
203- req, err := http.NewRequest(http.MethodHead, srv.URL+"/ca.mobileconfig", nil)205+ req, err := http.NewRequest(http.MethodHead, srv.URL+"/.well-known/gordon/ca.mobileconfig", nil)
204 require.NoError(t, err)206 require.NoError(t, err)
205 207
206 resp, err := http.DefaultClient.Do(req)208 resp, err := http.DefaultClient.Do(req)
M internal/adapters/out/acmestore/store.go +42 -0
@@ -25,9 +25,15 @@ type certMetadata struct {
2525 LastError string `json:"last_error,omitempty"`
2626 }
2727
28+// storeState is the JSON structure persisted for ACME reconciliation state.
29+type storeState struct {
30+ ObtainCursor int `json:"obtain_cursor"`
31+}
32+
2833 const (
2934 lockFile = ".lock"
3035 accountFile = "account.json"
36+ stateFile = "state.json"
3137 certDir = "certs"
3238 backupSuffix = ".old"
3339 tempInfix = ".tmp-"
@@ -97,6 +103,42 @@ func (s *Store) SaveAccount(_ context.Context, account out.ACMEAccount) error {
97103 return nil
98104 }
99105
106+// LoadState reads ACME reconciliation state from <root>/state.json.
107+// It returns zero state when the file does not exist.
108+func (s *Store) LoadState(_ context.Context) (out.CertificateStoreState, error) {
109+ path := filepath.Join(s.root, stateFile)
110+ data, err := os.ReadFile(path)
111+ if err != nil {
112+ if os.IsNotExist(err) {
113+ return out.CertificateStoreState{}, nil
114+ }
115+ return out.CertificateStoreState{}, fmt.Errorf("acmestore: read state: %w", err)
116+ }
117+ var state storeState
118+ if err := json.Unmarshal(data, &state); err != nil {
119+ return out.CertificateStoreState{}, fmt.Errorf("acmestore: unmarshal state: %w", err)
120+ }
121+ if state.ObtainCursor < 0 {
122+ state.ObtainCursor = 0
123+ }
124+ return out.CertificateStoreState{ObtainCursor: state.ObtainCursor}, nil
125+}
126+
127+// SaveState writes ACME reconciliation state to <root>/state.json atomically.
128+func (s *Store) SaveState(_ context.Context, state out.CertificateStoreState) error {
129+ if state.ObtainCursor < 0 {
130+ state.ObtainCursor = 0
131+ }
132+ data, err := json.MarshalIndent(storeState{ObtainCursor: state.ObtainCursor}, "", " ")
133+ if err != nil {
134+ return fmt.Errorf("acmestore: marshal state: %w", err)
135+ }
136+ if err := writeAtomic(filepath.Join(s.root, stateFile), data, metaMode); err != nil {
137+ return fmt.Errorf("acmestore: save state: %w", err)
138+ }
139+ return nil
140+}
141+
100142 // LoadAll reads all certificate directories under <root>/certs/ and returns
101143 // the stored certificates. Directories with unsafe IDs are skipped.
102144 func (s *Store) LoadAll(_ context.Context) ([]out.StoredCertificate, error) {
@@ -25,9 +25,15 @@ type certMetadata struct {
25 LastError string `json:"last_error,omitempty"`25 LastError string `json:"last_error,omitempty"`
26 }26 }
27 27
28+// storeState is the JSON structure persisted for ACME reconciliation state.
29+type storeState struct {
30+ ObtainCursor int `json:"obtain_cursor"`
31+}
32+
28 const (33 const (
29 lockFile = ".lock"34 lockFile = ".lock"
30 accountFile = "account.json"35 accountFile = "account.json"
36+ stateFile = "state.json"
31 certDir = "certs"37 certDir = "certs"
32 backupSuffix = ".old"38 backupSuffix = ".old"
33 tempInfix = ".tmp-"39 tempInfix = ".tmp-"
@@ -97,6 +103,42 @@ func (s *Store) SaveAccount(_ context.Context, account out.ACMEAccount) error {
97 return nil103 return nil
98 }104 }
99 105
106+// LoadState reads ACME reconciliation state from <root>/state.json.
107+// It returns zero state when the file does not exist.
108+func (s *Store) LoadState(_ context.Context) (out.CertificateStoreState, error) {
109+ path := filepath.Join(s.root, stateFile)
110+ data, err := os.ReadFile(path)
111+ if err != nil {
112+ if os.IsNotExist(err) {
113+ return out.CertificateStoreState{}, nil
114+ }
115+ return out.CertificateStoreState{}, fmt.Errorf("acmestore: read state: %w", err)
116+ }
117+ var state storeState
118+ if err := json.Unmarshal(data, &state); err != nil {
119+ return out.CertificateStoreState{}, fmt.Errorf("acmestore: unmarshal state: %w", err)
120+ }
121+ if state.ObtainCursor < 0 {
122+ state.ObtainCursor = 0
123+ }
124+ return out.CertificateStoreState{ObtainCursor: state.ObtainCursor}, nil
125+}
126+
127+// SaveState writes ACME reconciliation state to <root>/state.json atomically.
128+func (s *Store) SaveState(_ context.Context, state out.CertificateStoreState) error {
129+ if state.ObtainCursor < 0 {
130+ state.ObtainCursor = 0
131+ }
132+ data, err := json.MarshalIndent(storeState{ObtainCursor: state.ObtainCursor}, "", " ")
133+ if err != nil {
134+ return fmt.Errorf("acmestore: marshal state: %w", err)
135+ }
136+ if err := writeAtomic(filepath.Join(s.root, stateFile), data, metaMode); err != nil {
137+ return fmt.Errorf("acmestore: save state: %w", err)
138+ }
139+ return nil
140+}
141+
100 // LoadAll reads all certificate directories under <root>/certs/ and returns142 // LoadAll reads all certificate directories under <root>/certs/ and returns
101 // the stored certificates. Directories with unsafe IDs are skipped.143 // the stored certificates. Directories with unsafe IDs are skipped.
102 func (s *Store) LoadAll(_ context.Context) ([]out.StoredCertificate, error) {144 func (s *Store) LoadAll(_ context.Context) ([]out.StoredCertificate, error) {
M internal/adapters/out/acmestore/store_test.go +23 -0
@@ -263,6 +263,29 @@ func TestStoreAccountFileMode(t *testing.T) {
263263 assert.Equal(t, os.FileMode(0600), info.Mode().Perm())
264264 }
265265
266+func TestStoreSaveLoadState(t *testing.T) {
267+ root := t.TempDir()
268+ ctx := context.Background()
269+
270+ store, err := New(root)
271+ require.NoError(t, err)
272+
273+ state, err := store.LoadState(ctx)
274+ require.NoError(t, err)
275+ assert.Equal(t, 0, state.ObtainCursor)
276+
277+ err = store.SaveState(ctx, out.CertificateStoreState{ObtainCursor: 3})
278+ require.NoError(t, err)
279+
280+ state, err = store.LoadState(ctx)
281+ require.NoError(t, err)
282+ assert.Equal(t, 3, state.ObtainCursor)
283+
284+ info, err := os.Stat(filepath.Join(root, stateFile))
285+ require.NoError(t, err)
286+ assert.Equal(t, os.FileMode(0600), info.Mode().Perm())
287+}
288+
266289 func TestStoreLoadAllInvalidChallenge(t *testing.T) {
267290 root := t.TempDir()
268291 ctx := context.Background()
@@ -263,6 +263,29 @@ func TestStoreAccountFileMode(t *testing.T) {
263 assert.Equal(t, os.FileMode(0600), info.Mode().Perm())263 assert.Equal(t, os.FileMode(0600), info.Mode().Perm())
264 }264 }
265 265
266+func TestStoreSaveLoadState(t *testing.T) {
267+ root := t.TempDir()
268+ ctx := context.Background()
269+
270+ store, err := New(root)
271+ require.NoError(t, err)
272+
273+ state, err := store.LoadState(ctx)
274+ require.NoError(t, err)
275+ assert.Equal(t, 0, state.ObtainCursor)
276+
277+ err = store.SaveState(ctx, out.CertificateStoreState{ObtainCursor: 3})
278+ require.NoError(t, err)
279+
280+ state, err = store.LoadState(ctx)
281+ require.NoError(t, err)
282+ assert.Equal(t, 3, state.ObtainCursor)
283+
284+ info, err := os.Stat(filepath.Join(root, stateFile))
285+ require.NoError(t, err)
286+ assert.Equal(t, os.FileMode(0600), info.Mode().Perm())
287+}
288+
266 func TestStoreLoadAllInvalidChallenge(t *testing.T) {289 func TestStoreLoadAllInvalidChallenge(t *testing.T) {
267 root := t.TempDir()290 root := t.TempDir()
268 ctx := context.Background()291 ctx := context.Background()
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/kernel.go +3 -3
@@ -61,9 +61,9 @@ func newKernel(configPath string, initLog kernelLoggerInit) (*Kernel, error) {
6161
6262 // Prefer full service wiring so local CLI can execute the same operations
6363 // 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 {
6767 // Wrap cleanup to stop public TLS service (with its renewal loop)
6868 // before the logger is cleaned up.
6969 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 operations62 // 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 loop64+ // 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 +109 -59
@@ -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 }
@@ -416,40 +418,31 @@ func initAccessLog(cfg Config, log zerowrap.Logger) (*accesslog.Writer, error) {
416418 return writer, nil
417419 }
418420
419-// serviceOptions controls optional behavior for createServicesWithOptions.
420-type serviceOptions struct {
421- // StartPublicTLS controls whether ACME Reconcile and renewal loop are started.
422- // Server runtime sets true; Kernel/local CLI sets false (read-only status).
423- StartPublicTLS bool
424-}
425-
426421 // serviceInit holds the shared context for service initialization helpers.
427422 type serviceInit struct {
428- ctx context.Context
429- v *viper.Viper
430- cfg Config
431- log zerowrap.Logger
432- svc *services
433- startPublicTLS bool
423+ ctx context.Context
424+ v *viper.Viper
425+ cfg Config
426+ log zerowrap.Logger
427+ svc *services
434428 }
435429
436430 // createServices creates all the application services for server runtime.
437-// It calls createServicesWithOptions with StartPublicTLS: true.
431+// Public ACME reconciliation is started later, after the HTTP listener is bound.
438432 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})
433+ return createServicesWithOptions(ctx, v, cfg, log)
440434 }
441435
442-// createServicesWithOptions creates all the application services with the given options.
443-// When StartPublicTLS is false, ACME Reconcile and renewal loop are skipped so that
444-// read-only CLI commands (e.g. gordon tls status) do not perform ACME side effects.
445-func createServicesWithOptions(ctx context.Context, v *viper.Viper, cfg Config, log zerowrap.Logger, opts serviceOptions) (_ *services, retErr error) {
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) {
446440 si := &serviceInit{
447- ctx: ctx,
448- v: v,
449- cfg: cfg,
450- log: log,
451- svc: &services{},
452- startPublicTLS: opts.StartPublicTLS,
441+ ctx: ctx,
442+ v: v,
443+ cfg: cfg,
444+ log: log,
445+ svc: &services{},
453446 }
454447 defer func() {
455448 if retErr != nil {
@@ -555,12 +548,13 @@ func (si *serviceInit) initPublicTLS() error {
555548 log := si.log
556549
557550 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),
551+ Enabled: si.cfg.TLS.ACME.Enabled,
552+ Email: si.cfg.TLS.ACME.Email,
553+ Challenge: si.cfg.TLS.ACME.Challenge,
554+ HTTPPort: si.cfg.Server.Port,
555+ TLSPort: si.cfg.Server.TLSPort,
556+ DataDir: resolveDataDir(si.cfg.Server.DataDir),
557+ ObtainBatchSize: si.cfg.TLS.ACME.ObtainBatchSize,
564558 }
565559
566560 tokenResolver := secrets.NewPublicTLSResolver(secrets.PublicTLSResolverConfig{})
@@ -606,27 +600,34 @@ func (si *serviceInit) initPublicTLS() error {
606600 log.Warn().Err(err).Msg("failed to load ACME certificates, continuing")
607601 }
608602
609- if si.startPublicTLS {
610- if err := svc.Reconcile(ctx); err != nil {
611- log.Warn().Err(err).Msg("failed to reconcile ACME certificates, continuing")
612- }
613- svc.StartRenewalLoop(ctx, time.Hour)
614- log.Info().
615- Str("email", si.cfg.TLS.ACME.Email).
616- Str("challenge", string(effective.Mode)).
617- Str("reason", effective.Reason).
618- Msg("public ACME TLS initialized")
619- } else {
620- log.Info().
621- Str("email", si.cfg.TLS.ACME.Email).
622- Str("challenge", string(effective.Mode)).
623- Msg("public ACME TLS initialized (read-only, no renewal loop)")
624- }
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)")
625607
626608 si.svc.publicTLSSvc = svc
609+ si.svc.publicTLSRuntime = svc
627610 return nil
628611 }
629612
613+// publicTLSRuntime is the subset of in.PublicTLSService needed at server runtime
614+// after the HTTP listener is bound: reconcile missing certs and start the renewal
615+// loop. It is nil when ACME is disabled.
616+type publicTLSRuntime interface {
617+ Reconcile(context.Context) error
618+ StartRenewalLoop(context.Context, time.Duration) <-chan struct{}
619+}
620+
621+func startPublicTLSRuntime(ctx context.Context, svc publicTLSRuntime, log zerowrap.Logger) error {
622+ if svc == nil {
623+ return nil
624+ }
625+ reconcileErr := svc.Reconcile(ctx)
626+ svc.StartRenewalLoop(ctx, time.Hour)
627+ log.Info().Msg("public ACME TLS runtime started")
628+ return reconcileErr
629+}
630+
630631 // initSecrets creates the domain secret store, env loader, and secret service.
631632 func (si *serviceInit) initSecrets() error {
632633 envDir, backend, passStore, domainSecretStore, err := createDomainSecretStore(si.cfg, si.log)
@@ -2098,12 +2099,20 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger, accessWr
20982099 }
20992100
21002101 httpsMux := http.NewServeMux()
2101- 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)
2102+ if obHandler != nil && cfg.Server.GordonDomain != "" {
2103+ gordonDomain := strings.TrimSuffix(strings.ToLower(strings.TrimSpace(cfg.Server.GordonDomain)), ".")
2104+ onboardingMux := http.NewServeMux()
2105+ registerOnboardingRoutes(onboardingMux, obHandler)
2106+ // Register onboarding paths host-gated so normal traffic hits
2107+ // proxyHandler directly through the catch-all / pattern.
2108+ httpsMux.Handle("GET /.well-known/gordon/", gordonDomainOnboardingGate(gordonDomain, onboardingMux, proxyHandler))
2109+ httpsMux.Handle("GET /.well-known/gordon/ca", gordonDomainOnboardingGate(gordonDomain, onboardingMux, proxyHandler))
2110+ httpsMux.Handle("GET /.well-known/gordon/ca.crt", gordonDomainOnboardingGate(gordonDomain, onboardingMux, proxyHandler))
2111+ httpsMux.Handle("GET /.well-known/gordon/ca.mobileconfig", gordonDomainOnboardingGate(gordonDomain, onboardingMux, proxyHandler))
2112+ httpsMux.Handle("/", proxyHandler)
2113+ } else {
2114+ httpsMux.Handle("/", proxyHandler)
21052115 }
2106- httpsMux.Handle("/", proxyHandler)
21072116
21082117 httpsHandler := otelhttp.NewHandler(middleware.Chain(httpsProxyMiddlewares...)(httpsMux), "gordon.proxy.tls")
21092118
@@ -2121,6 +2130,15 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger, accessWr
21212130 return registryOut, proxyOut, httpsOut
21222131 }
21232132
2133+// registerOnboardingRoutes registers CA onboarding well-known HTTP routes on
2134+// the given mux. Both direct-HTTP and Gordon-domain HTTPS onboarding use this.
2135+func registerOnboardingRoutes(mux *http.ServeMux, ob *onboarding.Handler) {
2136+ mux.HandleFunc("GET /.well-known/gordon/", ob.ServeOnboardingPage)
2137+ mux.HandleFunc("GET /.well-known/gordon/ca", ob.ServeOnboardingPage)
2138+ mux.HandleFunc("GET /.well-known/gordon/ca.crt", ob.ServeCACert)
2139+ mux.HandleFunc("GET /.well-known/gordon/ca.mobileconfig", ob.ServeMobileconfig)
2140+}
2141+
21242142 // directHTTPOnboardingGate returns an http.Handler that splits HTTP traffic
21252143 // by source IP. Trusted proxy IPs flow through to the normal proxy chain.
21262144 // Direct clients are served the CA onboarding flow on allowed paths and
@@ -2129,10 +2147,7 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger, accessWr
21292147 func directHTTPOnboardingGate(ob *onboarding.Handler, proxyNets []*net.IPNet, proxyChain http.Handler, log zerowrap.Logger) http.Handler {
21302148 // Build a small mux for direct-client onboarding paths.
21312149 onboardingMux := http.NewServeMux()
2132- onboardingMux.HandleFunc("GET /{$}", ob.ServeOnboardingPage)
2133- onboardingMux.HandleFunc("GET /ca", ob.ServeOnboardingPage)
2134- onboardingMux.HandleFunc("GET /ca.crt", ob.ServeCACert)
2135- onboardingMux.HandleFunc("GET /ca.mobileconfig", ob.ServeMobileconfig)
2150+ registerOnboardingRoutes(onboardingMux, ob)
21362151
21372152 // Reserve ACME challenge path for future use.
21382153 onboardingMux.HandleFunc("/.well-known/acme-challenge/", func(w http.ResponseWriter, _ *http.Request) {
@@ -2159,6 +2174,32 @@ func directHTTPOnboardingGate(ob *onboarding.Handler, proxyNets []*net.IPNet, pr
21592174 })
21602175 }
21612176
2177+// canonicalHostsEqual compares two hosts after normalising both: stripping
2178+// port, trimming spaces, lowercasing, and removing trailing dot.
2179+func canonicalHostsEqual(host, expected string) bool {
2180+ host = strings.TrimSpace(host)
2181+ if h, _, err := net.SplitHostPort(host); err == nil {
2182+ host = h
2183+ }
2184+ host = strings.TrimSuffix(strings.ToLower(host), ".")
2185+ expected = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(expected)), ".")
2186+ return host == expected
2187+}
2188+
2189+// gordonDomainOnboardingGate returns a handler that serves onboarding routes
2190+// only when the request host matches gordonDomain. For mismatched hosts it
2191+// delegates to proxyHandler. gordonDomain must already be canonicalised
2192+// (trimmed, lowered, trailing dot removed).
2193+func gordonDomainOnboardingGate(gordonDomain string, onboardingMux, proxyHandler http.Handler) http.Handler {
2194+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
2195+ if gordonDomain != "" && canonicalHostsEqual(r.Host, gordonDomain) {
2196+ onboardingMux.ServeHTTP(w, r)
2197+ return
2198+ }
2199+ proxyHandler.ServeHTTP(w, r)
2200+ })
2201+}
2202+
21622203 // directHTTPForbidden responds with 403 for non-onboarding HTTP paths.
21632204 // HEAD requests get an empty body per HTTP semantics.
21642205 func directHTTPForbidden(w http.ResponseWriter, r *http.Request) {
@@ -2481,6 +2522,8 @@ func runServers(ctx context.Context, v *viper.Viper, cfg Config, svc *services,
24812522 }
24822523 logEvent.Msg("Gordon is running")
24832524
2525+ startPublicTLSRuntimeWithWarning(ctx, svc.publicTLSRuntime, log)
2526+
24842527 schedulerCleanup, err := startOptionalSchedulers(ctx, cfg, svc, log, v)
24852528 if err != nil {
24862529 return err
@@ -2498,6 +2541,12 @@ func runServers(ctx context.Context, v *viper.Viper, cfg Config, svc *services,
24982541 return nil
24992542 }
25002543
2544+func startPublicTLSRuntimeWithWarning(ctx context.Context, svc publicTLSRuntime, log zerowrap.Logger) {
2545+ if err := startPublicTLSRuntime(ctx, svc, log); err != nil {
2546+ log.Warn().Err(err).Msg("initial public ACME reconcile failed, continuing with renewal loop")
2547+ }
2548+}
2549+
25012550 func waitForServerReady(ready <-chan struct{}, errChan <-chan error) error {
25022551 select {
25032552 case <-ready:
@@ -3219,6 +3268,7 @@ func loadConfig(v *viper.Viper, configPath string) error {
32193268 v.SetDefault("tls.acme.enabled", false)
32203269 v.SetDefault("tls.acme.email", "")
32213270 v.SetDefault("tls.acme.challenge", "auto")
3271+ v.SetDefault("tls.acme.obtain_batch_size", 1)
32223272 v.SetDefault("server.force_https_redirect", false)
32233273 v.SetDefault("server.data_dir", DefaultDataDir())
32243274 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 }
@@ -416,40 +418,31 @@ func initAccessLog(cfg Config, log zerowrap.Logger) (*accesslog.Writer, error) {
416 return writer, nil418 return writer, nil
417 }419 }
418 420
419-// serviceOptions controls optional behavior for createServicesWithOptions.
420-type serviceOptions struct {
421- // StartPublicTLS controls whether ACME Reconcile and renewal loop are started.
422- // Server runtime sets true; Kernel/local CLI sets false (read-only status).
423- StartPublicTLS bool
424-}
425-
426 // serviceInit holds the shared context for service initialization helpers.421 // serviceInit holds the shared context for service initialization helpers.
427 type serviceInit struct {422 type serviceInit struct {
428- ctx context.Context423+ ctx context.Context
429- v *viper.Viper424+ v *viper.Viper
430- cfg Config425+ cfg Config
431- log zerowrap.Logger426+ log zerowrap.Logger
432- svc *services427+ svc *services
433- startPublicTLS bool
434 }428 }
435 429
436 // createServices creates all the application services for server runtime.430 // createServices creates all the application services for server runtime.
437-// It calls createServicesWithOptions with StartPublicTLS: true.431+// 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) {432 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})433+ return createServicesWithOptions(ctx, v, cfg, log)
440 }434 }
441 435
442-// createServicesWithOptions creates all the application services with the given options.436+// createServicesWithOptions creates all the application services.
443-// When StartPublicTLS is false, ACME Reconcile and renewal loop are skipped so that437+// ACME Reconcile and renewal loop are started later from runServers,
444-// read-only CLI commands (e.g. gordon tls status) do not perform ACME side effects.438+// after HTTP listeners are bound.
445-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) {
446 si := &serviceInit{440 si := &serviceInit{
447- ctx: ctx,441+ ctx: ctx,
448- v: v,442+ v: v,
449- cfg: cfg,443+ cfg: cfg,
450- log: log,444+ log: log,
451- svc: &services{},445+ svc: &services{},
452- startPublicTLS: opts.StartPublicTLS,
453 }446 }
454 defer func() {447 defer func() {
455 if retErr != nil {448 if retErr != nil {
@@ -555,12 +548,13 @@ func (si *serviceInit) initPublicTLS() error {
555 log := si.log548 log := si.log
556 549
557 publicTLSCfg := publictls.Config{550 publicTLSCfg := publictls.Config{
558- Enabled: si.cfg.TLS.ACME.Enabled,551+ Enabled: si.cfg.TLS.ACME.Enabled,
559- Email: si.cfg.TLS.ACME.Email,552+ Email: si.cfg.TLS.ACME.Email,
560- Challenge: si.cfg.TLS.ACME.Challenge,553+ Challenge: si.cfg.TLS.ACME.Challenge,
561- HTTPPort: si.cfg.Server.Port,554+ HTTPPort: si.cfg.Server.Port,
562- TLSPort: si.cfg.Server.TLSPort,555+ TLSPort: si.cfg.Server.TLSPort,
563- DataDir: resolveDataDir(si.cfg.Server.DataDir),556+ DataDir: resolveDataDir(si.cfg.Server.DataDir),
557+ ObtainBatchSize: si.cfg.TLS.ACME.ObtainBatchSize,
564 }558 }
565 559
566 tokenResolver := secrets.NewPublicTLSResolver(secrets.PublicTLSResolverConfig{})560 tokenResolver := secrets.NewPublicTLSResolver(secrets.PublicTLSResolverConfig{})
@@ -606,27 +600,34 @@ func (si *serviceInit) initPublicTLS() error {
606 log.Warn().Err(err).Msg("failed to load ACME certificates, continuing")600 log.Warn().Err(err).Msg("failed to load ACME certificates, continuing")
607 }601 }
608 602
609- if si.startPublicTLS {603+ log.Info().
610- if err := svc.Reconcile(ctx); err != nil {604+ Str("email", si.cfg.TLS.ACME.Email).
611- log.Warn().Err(err).Msg("failed to reconcile ACME certificates, continuing")605+ Str("challenge", string(effective.Mode)).
612- }606+ Msg("public ACME TLS initialized (runtime start deferred)")
613- svc.StartRenewalLoop(ctx, time.Hour)
614- log.Info().
615- Str("email", si.cfg.TLS.ACME.Email).
616- Str("challenge", string(effective.Mode)).
617- Str("reason", effective.Reason).
618- Msg("public ACME TLS initialized")
619- } else {
620- log.Info().
621- Str("email", si.cfg.TLS.ACME.Email).
622- Str("challenge", string(effective.Mode)).
623- Msg("public ACME TLS initialized (read-only, no renewal loop)")
624- }
625 607
626 si.svc.publicTLSSvc = svc608 si.svc.publicTLSSvc = svc
609+ si.svc.publicTLSRuntime = svc
627 return nil610 return nil
628 }611 }
629 612
613+// publicTLSRuntime is the subset of in.PublicTLSService needed at server runtime
614+// after the HTTP listener is bound: reconcile missing certs and start the renewal
615+// loop. It is nil when ACME is disabled.
616+type publicTLSRuntime interface {
617+ Reconcile(context.Context) error
618+ StartRenewalLoop(context.Context, time.Duration) <-chan struct{}
619+}
620+
621+func startPublicTLSRuntime(ctx context.Context, svc publicTLSRuntime, log zerowrap.Logger) error {
622+ if svc == nil {
623+ return nil
624+ }
625+ reconcileErr := svc.Reconcile(ctx)
626+ svc.StartRenewalLoop(ctx, time.Hour)
627+ log.Info().Msg("public ACME TLS runtime started")
628+ return reconcileErr
629+}
630+
630 // initSecrets creates the domain secret store, env loader, and secret service.631 // initSecrets creates the domain secret store, env loader, and secret service.
631 func (si *serviceInit) initSecrets() error {632 func (si *serviceInit) initSecrets() error {
632 envDir, backend, passStore, domainSecretStore, err := createDomainSecretStore(si.cfg, si.log)633 envDir, backend, passStore, domainSecretStore, err := createDomainSecretStore(si.cfg, si.log)
@@ -2098,12 +2099,20 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger, accessWr
2098 }2099 }
2099 2100
2100 httpsMux := http.NewServeMux()2101 httpsMux := http.NewServeMux()
2101- if obHandler != nil {2102+ if obHandler != nil && cfg.Server.GordonDomain != "" {
2102- httpsMux.HandleFunc("GET /ca", obHandler.ServeOnboardingPage)2103+ gordonDomain := strings.TrimSuffix(strings.ToLower(strings.TrimSpace(cfg.Server.GordonDomain)), ".")
2103- httpsMux.HandleFunc("GET /ca.crt", obHandler.ServeCACert)2104+ onboardingMux := http.NewServeMux()
2104- httpsMux.HandleFunc("GET /ca.mobileconfig", obHandler.ServeMobileconfig)2105+ registerOnboardingRoutes(onboardingMux, obHandler)
2106+ // Register onboarding paths host-gated so normal traffic hits
2107+ // proxyHandler directly through the catch-all / pattern.
2108+ httpsMux.Handle("GET /.well-known/gordon/", gordonDomainOnboardingGate(gordonDomain, onboardingMux, proxyHandler))
2109+ httpsMux.Handle("GET /.well-known/gordon/ca", gordonDomainOnboardingGate(gordonDomain, onboardingMux, proxyHandler))
2110+ httpsMux.Handle("GET /.well-known/gordon/ca.crt", gordonDomainOnboardingGate(gordonDomain, onboardingMux, proxyHandler))
2111+ httpsMux.Handle("GET /.well-known/gordon/ca.mobileconfig", gordonDomainOnboardingGate(gordonDomain, onboardingMux, proxyHandler))
2112+ httpsMux.Handle("/", proxyHandler)
2113+ } else {
2114+ httpsMux.Handle("/", proxyHandler)
2105 }2115 }
2106- httpsMux.Handle("/", proxyHandler)
2107 2116
2108 httpsHandler := otelhttp.NewHandler(middleware.Chain(httpsProxyMiddlewares...)(httpsMux), "gordon.proxy.tls")2117 httpsHandler := otelhttp.NewHandler(middleware.Chain(httpsProxyMiddlewares...)(httpsMux), "gordon.proxy.tls")
2109 2118
@@ -2121,6 +2130,15 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger, accessWr
2121 return registryOut, proxyOut, httpsOut2130 return registryOut, proxyOut, httpsOut
2122 }2131 }
2123 2132
2133+// registerOnboardingRoutes registers CA onboarding well-known HTTP routes on
2134+// the given mux. Both direct-HTTP and Gordon-domain HTTPS onboarding use this.
2135+func registerOnboardingRoutes(mux *http.ServeMux, ob *onboarding.Handler) {
2136+ mux.HandleFunc("GET /.well-known/gordon/", ob.ServeOnboardingPage)
2137+ mux.HandleFunc("GET /.well-known/gordon/ca", ob.ServeOnboardingPage)
2138+ mux.HandleFunc("GET /.well-known/gordon/ca.crt", ob.ServeCACert)
2139+ mux.HandleFunc("GET /.well-known/gordon/ca.mobileconfig", ob.ServeMobileconfig)
2140+}
2141+
2124 // directHTTPOnboardingGate returns an http.Handler that splits HTTP traffic2142 // directHTTPOnboardingGate returns an http.Handler that splits HTTP traffic
2125 // by source IP. Trusted proxy IPs flow through to the normal proxy chain.2143 // by source IP. Trusted proxy IPs flow through to the normal proxy chain.
2126 // Direct clients are served the CA onboarding flow on allowed paths and2144 // Direct clients are served the CA onboarding flow on allowed paths and
@@ -2129,10 +2147,7 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger, accessWr
2129 func directHTTPOnboardingGate(ob *onboarding.Handler, proxyNets []*net.IPNet, proxyChain http.Handler, log zerowrap.Logger) http.Handler {2147 func directHTTPOnboardingGate(ob *onboarding.Handler, proxyNets []*net.IPNet, proxyChain http.Handler, log zerowrap.Logger) http.Handler {
2130 // Build a small mux for direct-client onboarding paths.2148 // Build a small mux for direct-client onboarding paths.
2131 onboardingMux := http.NewServeMux()2149 onboardingMux := http.NewServeMux()
2132- onboardingMux.HandleFunc("GET /{$}", ob.ServeOnboardingPage)2150+ registerOnboardingRoutes(onboardingMux, ob)
2133- onboardingMux.HandleFunc("GET /ca", ob.ServeOnboardingPage)
2134- onboardingMux.HandleFunc("GET /ca.crt", ob.ServeCACert)
2135- onboardingMux.HandleFunc("GET /ca.mobileconfig", ob.ServeMobileconfig)
2136 2151
2137 // Reserve ACME challenge path for future use.2152 // Reserve ACME challenge path for future use.
2138 onboardingMux.HandleFunc("/.well-known/acme-challenge/", func(w http.ResponseWriter, _ *http.Request) {2153 onboardingMux.HandleFunc("/.well-known/acme-challenge/", func(w http.ResponseWriter, _ *http.Request) {
@@ -2159,6 +2174,32 @@ func directHTTPOnboardingGate(ob *onboarding.Handler, proxyNets []*net.IPNet, pr
2159 })2174 })
2160 }2175 }
2161 2176
2177+// canonicalHostsEqual compares two hosts after normalising both: stripping
2178+// port, trimming spaces, lowercasing, and removing trailing dot.
2179+func canonicalHostsEqual(host, expected string) bool {
2180+ host = strings.TrimSpace(host)
2181+ if h, _, err := net.SplitHostPort(host); err == nil {
2182+ host = h
2183+ }
2184+ host = strings.TrimSuffix(strings.ToLower(host), ".")
2185+ expected = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(expected)), ".")
2186+ return host == expected
2187+}
2188+
2189+// gordonDomainOnboardingGate returns a handler that serves onboarding routes
2190+// only when the request host matches gordonDomain. For mismatched hosts it
2191+// delegates to proxyHandler. gordonDomain must already be canonicalised
2192+// (trimmed, lowered, trailing dot removed).
2193+func gordonDomainOnboardingGate(gordonDomain string, onboardingMux, proxyHandler http.Handler) http.Handler {
2194+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
2195+ if gordonDomain != "" && canonicalHostsEqual(r.Host, gordonDomain) {
2196+ onboardingMux.ServeHTTP(w, r)
2197+ return
2198+ }
2199+ proxyHandler.ServeHTTP(w, r)
2200+ })
2201+}
2202+
2162 // directHTTPForbidden responds with 403 for non-onboarding HTTP paths.2203 // directHTTPForbidden responds with 403 for non-onboarding HTTP paths.
2163 // HEAD requests get an empty body per HTTP semantics.2204 // HEAD requests get an empty body per HTTP semantics.
2164 func directHTTPForbidden(w http.ResponseWriter, r *http.Request) {2205 func directHTTPForbidden(w http.ResponseWriter, r *http.Request) {
@@ -2481,6 +2522,8 @@ func runServers(ctx context.Context, v *viper.Viper, cfg Config, svc *services,
2481 }2522 }
2482 logEvent.Msg("Gordon is running")2523 logEvent.Msg("Gordon is running")
2483 2524
2525+ startPublicTLSRuntimeWithWarning(ctx, svc.publicTLSRuntime, log)
2526+
2484 schedulerCleanup, err := startOptionalSchedulers(ctx, cfg, svc, log, v)2527 schedulerCleanup, err := startOptionalSchedulers(ctx, cfg, svc, log, v)
2485 if err != nil {2528 if err != nil {
2486 return err2529 return err
@@ -2498,6 +2541,12 @@ func runServers(ctx context.Context, v *viper.Viper, cfg Config, svc *services,
2498 return nil2541 return nil
2499 }2542 }
2500 2543
2544+func startPublicTLSRuntimeWithWarning(ctx context.Context, svc publicTLSRuntime, log zerowrap.Logger) {
2545+ if err := startPublicTLSRuntime(ctx, svc, log); err != nil {
2546+ log.Warn().Err(err).Msg("initial public ACME reconcile failed, continuing with renewal loop")
2547+ }
2548+}
2549+
2501 func waitForServerReady(ready <-chan struct{}, errChan <-chan error) error {2550 func waitForServerReady(ready <-chan struct{}, errChan <-chan error) error {
2502 select {2551 select {
2503 case <-ready:2552 case <-ready:
@@ -3219,6 +3268,7 @@ func loadConfig(v *viper.Viper, configPath string) error {
3219 v.SetDefault("tls.acme.enabled", false)3268 v.SetDefault("tls.acme.enabled", false)
3220 v.SetDefault("tls.acme.email", "")3269 v.SetDefault("tls.acme.email", "")
3221 v.SetDefault("tls.acme.challenge", "auto")3270 v.SetDefault("tls.acme.challenge", "auto")
3271+ v.SetDefault("tls.acme.obtain_batch_size", 1)
3222 v.SetDefault("server.force_https_redirect", false)3272 v.SetDefault("server.force_https_redirect", false)
3223 v.SetDefault("server.data_dir", DefaultDataDir())3273 v.SetDefault("server.data_dir", DefaultDataDir())
3224 v.SetDefault("server.runtime", "auto")3274 v.SetDefault("server.runtime", "auto")
M internal/app/run_http_handlers_test.go +152 -65
@@ -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
@@ -120,60 +135,50 @@ const directAddr = "203.0.113.10:12345"
120135 // trustedProxyAddr is an IP inside the 173.245.48.0/20 range.
121136 const trustedProxyAddr = "173.245.48.5:12345"
122137
123-func TestCreateHTTPHandlers_DirectHTTPRoot_ServesOnboarding(t *testing.T) {
124- t.Parallel()
125- ca := newTestCA(t)
126- cfg := newOnboardingConfig()
127- svc := &services{caAdapter: ca}
128-
129- _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil)
130-
131- req := httptest.NewRequest(http.MethodGet, "/", nil)
132- req.RemoteAddr = directAddr
133- req.Host = "o2.bnema.dev"
134- rec := httptest.NewRecorder()
135- httpHandler.ServeHTTP(rec, req)
136-
137- assert.Equal(t, http.StatusOK, rec.Code)
138- assert.Contains(t, rec.Header().Get("Content-Type"), "text/html")
139- assert.Contains(t, rec.Body.String(), "Trust CA Certificate")
140-}
141-
142-func TestCreateHTTPHandlers_DirectHTTPCertPath_ServesCACert(t *testing.T) {
143- t.Parallel()
144- ca := newTestCA(t)
145- cfg := newOnboardingConfig()
146- svc := &services{caAdapter: ca}
147-
148- _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil)
149-
150- req := httptest.NewRequest(http.MethodGet, "/ca.crt", nil)
151- req.RemoteAddr = directAddr
152- req.Host = "o2.bnema.dev"
153- rec := httptest.NewRecorder()
154- httpHandler.ServeHTTP(rec, req)
155-
156- assert.Equal(t, http.StatusOK, rec.Code)
157- assert.Contains(t, rec.Header().Get("Content-Type"), "application/x-x509-ca-cert")
158- assert.Contains(t, rec.Body.String(), "BEGIN CERTIFICATE")
159-}
160-
161-func TestCreateHTTPHandlers_DirectHTTPUnknownPath_ReturnsForbidden(t *testing.T) {
138+func TestCreateHTTPHandlers_DirectHTTPOnboarding_AllPaths(t *testing.T) {
162139 t.Parallel()
163- ca := newTestCA(t)
164- cfg := newOnboardingConfig()
165- svc := &services{caAdapter: ca}
166140
167- _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil)
168-
169- req := httptest.NewRequest(http.MethodGet, "/web", nil)
170- req.RemoteAddr = directAddr
171- req.Host = "o2.bnema.dev"
172- rec := httptest.NewRecorder()
173- httpHandler.ServeHTTP(rec, req)
141+ tests := []struct {
142+ name string
143+ path string
144+ wantCode int
145+ wantContent string // substring check
146+ wantType string // Content-Type substring
147+ }{
148+ {name: ".well-known/gordon/ serves onboarding", path: "/.well-known/gordon/", wantCode: http.StatusOK, wantContent: "Trust CA Certificate", wantType: "text/html"},
149+ {name: ".well-known/gordon/ca serves onboarding", path: "/.well-known/gordon/ca", wantCode: http.StatusOK, wantContent: "Trust CA Certificate", wantType: "text/html"},
150+ {name: ".well-known/gordon/ca.crt serves CA cert", path: "/.well-known/gordon/ca.crt", wantCode: http.StatusOK, wantContent: "BEGIN CERTIFICATE", wantType: "application/x-x509-ca-cert"},
151+ {name: ".well-known/gordon/ca.mobileconfig serves mobileconfig", path: "/.well-known/gordon/ca.mobileconfig", wantCode: http.StatusOK, wantContent: "<?xml", wantType: "application/x-apple-aspen-config"},
152+ {name: "root returns forbidden", path: "/", wantCode: http.StatusForbidden, wantContent: "Only certificate onboarding is available", wantType: "text/plain"},
153+ {name: "former /ca alias returns forbidden", path: "/ca", wantCode: http.StatusForbidden, wantContent: "Only certificate onboarding is available", wantType: "text/plain"},
154+ {name: "former /ca.crt alias returns forbidden", path: "/ca.crt", wantCode: http.StatusForbidden, wantContent: "Only certificate onboarding is available", wantType: "text/plain"},
155+ {name: "former /ca.mobileconfig alias returns forbidden", path: "/ca.mobileconfig", wantCode: http.StatusForbidden, wantContent: "Only certificate onboarding is available", wantType: "text/plain"},
156+ {name: "unknown path returns forbidden", path: "/web", wantCode: http.StatusForbidden, wantContent: "Only certificate onboarding is available", wantType: "text/plain"},
157+ }
174158
175- assert.Equal(t, http.StatusForbidden, rec.Code)
176- assert.Contains(t, rec.Header().Get("Content-Type"), "text/plain")
159+ for _, tc := range tests {
160+ t.Run(tc.name, func(t *testing.T) {
161+ ca := newTestCA(t)
162+ cfg := newOnboardingConfig()
163+ svc := &services{caAdapter: ca}
164+
165+ _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil)
166+
167+ req := httptest.NewRequest(http.MethodGet, tc.path, nil)
168+ req.RemoteAddr = directAddr
169+ req.Host = "o2.bnema.dev"
170+ rec := httptest.NewRecorder()
171+ httpHandler.ServeHTTP(rec, req)
172+
173+ assert.Equal(t, tc.wantCode, rec.Code, "%s: status code", tc.name)
174+ if tc.wantType != "" {
175+ assert.Contains(t, rec.Header().Get("Content-Type"), tc.wantType, "%s: content type", tc.name)
176+ }
177+ if tc.wantContent != "" {
178+ assert.Contains(t, rec.Body.String(), tc.wantContent, "%s: body content", tc.name)
179+ }
180+ })
181+ }
177182 }
178183
179184 func TestCreateHTTPHandlers_TrustedProxyHTTP_DoesNotServeOnboarding(t *testing.T) {
@@ -196,7 +201,7 @@ func TestCreateHTTPHandlers_TrustedProxyHTTP_DoesNotServeOnboarding(t *testing.T
196201 assert.NotContains(t, rec.Body.String(), "Trust CA Certificate")
197202 }
198203
199-func TestCreateHTTPHandlers_TrustedProxyCACertPath_DoesNotServeOnboardingResource(t *testing.T) {
204+func TestCreateHTTPHandlers_TrustedProxyWellKnownCACertPath_DoesNotServeOnboardingResource(t *testing.T) {
200205 t.Parallel()
201206 ca := newTestCA(t)
202207 cfg := newOnboardingConfig()
@@ -204,7 +209,7 @@ func TestCreateHTTPHandlers_TrustedProxyCACertPath_DoesNotServeOnboardingResourc
204209
205210 _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil)
206211
207- req := httptest.NewRequest(http.MethodGet, "/ca.crt", nil)
212+ req := httptest.NewRequest(http.MethodGet, "/.well-known/gordon/ca.crt", nil)
208213 req.RemoteAddr = trustedProxyAddr
209214 req.Host = "app.example.com"
210215 rec := httptest.NewRecorder()
@@ -224,7 +229,7 @@ func TestCreateHTTPHandlers_ForceHTTPSRedirect_DoesNotBypassDirectOnboarding(t *
224229
225230 _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil)
226231
227- req := httptest.NewRequest(http.MethodGet, "/", nil)
232+ req := httptest.NewRequest(http.MethodGet, "/.well-known/gordon/", nil)
228233 req.RemoteAddr = directAddr
229234 req.Host = "o2.bnema.dev"
230235 rec := httptest.NewRecorder()
@@ -235,29 +240,61 @@ func TestCreateHTTPHandlers_ForceHTTPSRedirect_DoesNotBypassDirectOnboarding(t *
235240 assert.Contains(t, rec.Body.String(), "Trust CA Certificate")
236241 }
237242
238-func TestCreateHTTPHandlers_HTTPSOnboarding_RemainsAvailable(t *testing.T) {
243+func TestCreateHTTPHandlers_HTTPSOnboarding_RemainsAvailableOnGordonDomain(t *testing.T) {
239244 t.Parallel()
240245 ca := newTestCA(t)
241246 cfg := newOnboardingConfig()
242- svc := &services{caAdapter: ca}
247+ cfg.Server.GordonDomain = "gordon.example.com"
248+ svc := &services{caAdapter: ca, proxySvc: newNotFoundProxyService(t)}
243249
244250 _, _, httpsHandler := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil)
245251
246- req := httptest.NewRequest(http.MethodGet, "/ca", nil)
252+ for _, host := range []string{"gordon.example.com", "Gordon.Example.Com.:8443"} {
253+ req := httptest.NewRequest(http.MethodGet, "/.well-known/gordon/ca", nil)
254+ req.RemoteAddr = directAddr
255+ req.Host = host
256+ rec := httptest.NewRecorder()
257+ httpsHandler.ServeHTTP(rec, req)
258+
259+ assert.Equal(t, http.StatusOK, rec.Code, "host %s", host)
260+ assert.Contains(t, rec.Body.String(), "Trust CA Certificate", "host %s", host)
261+ }
262+}
263+
264+func TestCreateHTTPHandlers_HTTPSOnboarding_DoesNotPreemptAppHostCAPath(t *testing.T) {
265+ t.Parallel()
266+ ca := newTestCA(t)
267+ cfg := newOnboardingConfig()
268+ cfg.Server.GordonDomain = "gordon.example.com"
269+ svc := &services{caAdapter: ca, proxySvc: newNotFoundProxyService(t)}
270+
271+ _, _, httpsHandler := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil)
272+
273+ req := httptest.NewRequest(http.MethodGet, "/.well-known/gordon/ca", nil)
247274 req.RemoteAddr = directAddr
248- req.Host = "o2.bnema.dev"
275+ req.Host = "app.example.com"
249276 rec := httptest.NewRecorder()
250277 httpsHandler.ServeHTTP(rec, req)
251278
252- assert.Equal(t, http.StatusOK, rec.Code)
253- assert.Contains(t, rec.Body.String(), "Trust CA Certificate")
279+ assert.Equal(t, http.StatusNotFound, rec.Code)
280+ assert.NotContains(t, rec.Body.String(), "Trust CA Certificate")
281+
282+ normalizedReq := httptest.NewRequest(http.MethodGet, "/.well-known/gordon/ca", nil)
283+ normalizedReq.RemoteAddr = directAddr
284+ normalizedReq.Host = "Gordon.Example.Com.:8443"
285+ normalizedRec := httptest.NewRecorder()
286+ httpsHandler.ServeHTTP(normalizedRec, normalizedReq)
287+
288+ assert.Equal(t, http.StatusOK, normalizedRec.Code)
289+ assert.Contains(t, normalizedRec.Body.String(), "Trust CA Certificate")
254290 }
255291
256292 func TestCreateHTTPHandlers_HTTPSOnboarding_HEADRoutesRemainAvailable(t *testing.T) {
257293 t.Parallel()
258294 ca := newTestCA(t)
259295 cfg := newOnboardingConfig()
260- svc := &services{caAdapter: ca}
296+ cfg.Server.GordonDomain = "gordon.example.com"
297+ svc := &services{caAdapter: ca, proxySvc: newNotFoundProxyService(t)}
261298
262299 _, _, httpsHandler := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil)
263300
@@ -266,8 +303,11 @@ func TestCreateHTTPHandlers_HTTPSOnboarding_HEADRoutesRemainAvailable(t *testing
266303 ts := httptest.NewServer(httpsHandler)
267304 defer ts.Close()
268305
269- for _, path := range []string{"/ca", "/ca.crt", "/ca.mobileconfig"} {
270- resp, err := ts.Client().Head(ts.URL + path)
306+ for _, path := range []string{"/.well-known/gordon/ca", "/.well-known/gordon/ca.crt", "/.well-known/gordon/ca.mobileconfig"} {
307+ req, err := http.NewRequest(http.MethodHead, ts.URL+path, nil)
308+ require.NoError(t, err)
309+ req.Host = "Gordon.Example.Com.:8443"
310+ resp, err := ts.Client().Do(req)
271311 require.NoError(t, err)
272312
273313 body, err := io.ReadAll(resp.Body)
@@ -298,8 +338,8 @@ func TestCreateHTTPHandlers_DirectHTTPOnboarding_HEADRoutesRemainAvailable(t *te
298338 path string
299339 contentType string
300340 }{
301- {path: "/ca", contentType: "text/html"},
302- {path: "/ca.crt", contentType: "application/x-x509-ca-cert"},
341+ {path: "/.well-known/gordon/ca", contentType: "text/html"},
342+ {path: "/.well-known/gordon/ca.crt", contentType: "application/x-x509-ca-cert"},
303343 } {
304344 resp, err := ts.Client().Head(ts.URL + tc.path)
305345 require.NoError(t, err)
@@ -376,6 +416,53 @@ func TestCreateHTTPHandlers_TLSDisabled_DoesNotServeHTTPOnboarding(t *testing.T)
376416 assert.NotContains(t, rec.Body.String(), "Trust CA Certificate")
377417 }
378418
419+type recordingPublicTLSRuntime struct {
420+ reconcileCalls int
421+ loopCalls int
422+ reconcileErr error
423+}
424+
425+func (r *recordingPublicTLSRuntime) Reconcile(context.Context) error {
426+ r.reconcileCalls++
427+ return r.reconcileErr
428+}
429+
430+func (r *recordingPublicTLSRuntime) StartRenewalLoop(context.Context, time.Duration) <-chan struct{} {
431+ r.loopCalls++
432+ done := make(chan struct{})
433+ close(done)
434+ return done
435+}
436+
437+func TestStartPublicTLSRuntime_ReconcilesAndStartsRenewalLoop(t *testing.T) {
438+ t.Parallel()
439+
440+ runtime := &recordingPublicTLSRuntime{}
441+ err := startPublicTLSRuntime(context.Background(), runtime, zerowrap.Default())
442+
443+ require.NoError(t, err)
444+ assert.Equal(t, 1, runtime.reconcileCalls)
445+ assert.Equal(t, 1, runtime.loopCalls)
446+}
447+
448+func TestStartPublicTLSRuntime_NilSvc(t *testing.T) {
449+ t.Parallel()
450+
451+ err := startPublicTLSRuntime(context.Background(), nil, zerowrap.Default())
452+ require.NoError(t, err)
453+}
454+
455+func TestStartPublicTLSRuntime_StartsRenewalLoopWhenReconcileFails(t *testing.T) {
456+ t.Parallel()
457+
458+ runtime := &recordingPublicTLSRuntime{reconcileErr: errors.New("boom")}
459+ err := startPublicTLSRuntime(context.Background(), runtime, zerowrap.Default())
460+
461+ require.Error(t, err)
462+ assert.Equal(t, 1, runtime.reconcileCalls)
463+ assert.Equal(t, 1, runtime.loopCalls)
464+}
465+
379466 func TestCreateHTTPHandlers_TLSConfiguredWithoutCA_FailsStartup(t *testing.T) {
380467 t.Parallel()
381468 // 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
@@ -120,60 +135,50 @@ const directAddr = "203.0.113.10:12345"
120 // trustedProxyAddr is an IP inside the 173.245.48.0/20 range.135 // trustedProxyAddr is an IP inside the 173.245.48.0/20 range.
121 const trustedProxyAddr = "173.245.48.5:12345"136 const trustedProxyAddr = "173.245.48.5:12345"
122 137
123-func TestCreateHTTPHandlers_DirectHTTPRoot_ServesOnboarding(t *testing.T) {138+func TestCreateHTTPHandlers_DirectHTTPOnboarding_AllPaths(t *testing.T) {
124- t.Parallel()
125- ca := newTestCA(t)
126- cfg := newOnboardingConfig()
127- svc := &services{caAdapter: ca}
128-
129- _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil)
130-
131- req := httptest.NewRequest(http.MethodGet, "/", nil)
132- req.RemoteAddr = directAddr
133- req.Host = "o2.bnema.dev"
134- rec := httptest.NewRecorder()
135- httpHandler.ServeHTTP(rec, req)
136-
137- assert.Equal(t, http.StatusOK, rec.Code)
138- assert.Contains(t, rec.Header().Get("Content-Type"), "text/html")
139- assert.Contains(t, rec.Body.String(), "Trust CA Certificate")
140-}
141-
142-func TestCreateHTTPHandlers_DirectHTTPCertPath_ServesCACert(t *testing.T) {
143- t.Parallel()
144- ca := newTestCA(t)
145- cfg := newOnboardingConfig()
146- svc := &services{caAdapter: ca}
147-
148- _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil)
149-
150- req := httptest.NewRequest(http.MethodGet, "/ca.crt", nil)
151- req.RemoteAddr = directAddr
152- req.Host = "o2.bnema.dev"
153- rec := httptest.NewRecorder()
154- httpHandler.ServeHTTP(rec, req)
155-
156- assert.Equal(t, http.StatusOK, rec.Code)
157- assert.Contains(t, rec.Header().Get("Content-Type"), "application/x-x509-ca-cert")
158- assert.Contains(t, rec.Body.String(), "BEGIN CERTIFICATE")
159-}
160-
161-func TestCreateHTTPHandlers_DirectHTTPUnknownPath_ReturnsForbidden(t *testing.T) {
162 t.Parallel()139 t.Parallel()
163- ca := newTestCA(t)
164- cfg := newOnboardingConfig()
165- svc := &services{caAdapter: ca}
166 140
167- _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil)141+ tests := []struct {
168-142+ name string
169- req := httptest.NewRequest(http.MethodGet, "/web", nil)143+ path string
170- req.RemoteAddr = directAddr144+ wantCode int
171- req.Host = "o2.bnema.dev"145+ wantContent string // substring check
172- rec := httptest.NewRecorder()146+ wantType string // Content-Type substring
173- httpHandler.ServeHTTP(rec, req)147+ }{
148+ {name: ".well-known/gordon/ serves onboarding", path: "/.well-known/gordon/", wantCode: http.StatusOK, wantContent: "Trust CA Certificate", wantType: "text/html"},
149+ {name: ".well-known/gordon/ca serves onboarding", path: "/.well-known/gordon/ca", wantCode: http.StatusOK, wantContent: "Trust CA Certificate", wantType: "text/html"},
150+ {name: ".well-known/gordon/ca.crt serves CA cert", path: "/.well-known/gordon/ca.crt", wantCode: http.StatusOK, wantContent: "BEGIN CERTIFICATE", wantType: "application/x-x509-ca-cert"},
151+ {name: ".well-known/gordon/ca.mobileconfig serves mobileconfig", path: "/.well-known/gordon/ca.mobileconfig", wantCode: http.StatusOK, wantContent: "<?xml", wantType: "application/x-apple-aspen-config"},
152+ {name: "root returns forbidden", path: "/", wantCode: http.StatusForbidden, wantContent: "Only certificate onboarding is available", wantType: "text/plain"},
153+ {name: "former /ca alias returns forbidden", path: "/ca", wantCode: http.StatusForbidden, wantContent: "Only certificate onboarding is available", wantType: "text/plain"},
154+ {name: "former /ca.crt alias returns forbidden", path: "/ca.crt", wantCode: http.StatusForbidden, wantContent: "Only certificate onboarding is available", wantType: "text/plain"},
155+ {name: "former /ca.mobileconfig alias returns forbidden", path: "/ca.mobileconfig", wantCode: http.StatusForbidden, wantContent: "Only certificate onboarding is available", wantType: "text/plain"},
156+ {name: "unknown path returns forbidden", path: "/web", wantCode: http.StatusForbidden, wantContent: "Only certificate onboarding is available", wantType: "text/plain"},
157+ }
174 158
175- assert.Equal(t, http.StatusForbidden, rec.Code)159+ for _, tc := range tests {
176- assert.Contains(t, rec.Header().Get("Content-Type"), "text/plain")160+ t.Run(tc.name, func(t *testing.T) {
161+ ca := newTestCA(t)
162+ cfg := newOnboardingConfig()
163+ svc := &services{caAdapter: ca}
164+
165+ _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil)
166+
167+ req := httptest.NewRequest(http.MethodGet, tc.path, nil)
168+ req.RemoteAddr = directAddr
169+ req.Host = "o2.bnema.dev"
170+ rec := httptest.NewRecorder()
171+ httpHandler.ServeHTTP(rec, req)
172+
173+ assert.Equal(t, tc.wantCode, rec.Code, "%s: status code", tc.name)
174+ if tc.wantType != "" {
175+ assert.Contains(t, rec.Header().Get("Content-Type"), tc.wantType, "%s: content type", tc.name)
176+ }
177+ if tc.wantContent != "" {
178+ assert.Contains(t, rec.Body.String(), tc.wantContent, "%s: body content", tc.name)
179+ }
180+ })
181+ }
177 }182 }
178 183
179 func TestCreateHTTPHandlers_TrustedProxyHTTP_DoesNotServeOnboarding(t *testing.T) {184 func TestCreateHTTPHandlers_TrustedProxyHTTP_DoesNotServeOnboarding(t *testing.T) {
@@ -196,7 +201,7 @@ func TestCreateHTTPHandlers_TrustedProxyHTTP_DoesNotServeOnboarding(t *testing.T
196 assert.NotContains(t, rec.Body.String(), "Trust CA Certificate")201 assert.NotContains(t, rec.Body.String(), "Trust CA Certificate")
197 }202 }
198 203
199-func TestCreateHTTPHandlers_TrustedProxyCACertPath_DoesNotServeOnboardingResource(t *testing.T) {204+func TestCreateHTTPHandlers_TrustedProxyWellKnownCACertPath_DoesNotServeOnboardingResource(t *testing.T) {
200 t.Parallel()205 t.Parallel()
201 ca := newTestCA(t)206 ca := newTestCA(t)
202 cfg := newOnboardingConfig()207 cfg := newOnboardingConfig()
@@ -204,7 +209,7 @@ func TestCreateHTTPHandlers_TrustedProxyCACertPath_DoesNotServeOnboardingResourc
204 209
205 _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil)210 _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil)
206 211
207- req := httptest.NewRequest(http.MethodGet, "/ca.crt", nil)212+ req := httptest.NewRequest(http.MethodGet, "/.well-known/gordon/ca.crt", nil)
208 req.RemoteAddr = trustedProxyAddr213 req.RemoteAddr = trustedProxyAddr
209 req.Host = "app.example.com"214 req.Host = "app.example.com"
210 rec := httptest.NewRecorder()215 rec := httptest.NewRecorder()
@@ -224,7 +229,7 @@ func TestCreateHTTPHandlers_ForceHTTPSRedirect_DoesNotBypassDirectOnboarding(t *
224 229
225 _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil)230 _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil)
226 231
227- req := httptest.NewRequest(http.MethodGet, "/", nil)232+ req := httptest.NewRequest(http.MethodGet, "/.well-known/gordon/", nil)
228 req.RemoteAddr = directAddr233 req.RemoteAddr = directAddr
229 req.Host = "o2.bnema.dev"234 req.Host = "o2.bnema.dev"
230 rec := httptest.NewRecorder()235 rec := httptest.NewRecorder()
@@ -235,29 +240,61 @@ func TestCreateHTTPHandlers_ForceHTTPSRedirect_DoesNotBypassDirectOnboarding(t *
235 assert.Contains(t, rec.Body.String(), "Trust CA Certificate")240 assert.Contains(t, rec.Body.String(), "Trust CA Certificate")
236 }241 }
237 242
238-func TestCreateHTTPHandlers_HTTPSOnboarding_RemainsAvailable(t *testing.T) {243+func TestCreateHTTPHandlers_HTTPSOnboarding_RemainsAvailableOnGordonDomain(t *testing.T) {
239 t.Parallel()244 t.Parallel()
240 ca := newTestCA(t)245 ca := newTestCA(t)
241 cfg := newOnboardingConfig()246 cfg := newOnboardingConfig()
242- svc := &services{caAdapter: ca}247+ cfg.Server.GordonDomain = "gordon.example.com"
248+ svc := &services{caAdapter: ca, proxySvc: newNotFoundProxyService(t)}
243 249
244 _, _, httpsHandler := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil)250 _, _, httpsHandler := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil)
245 251
246- req := httptest.NewRequest(http.MethodGet, "/ca", nil)252+ for _, host := range []string{"gordon.example.com", "Gordon.Example.Com.:8443"} {
253+ req := httptest.NewRequest(http.MethodGet, "/.well-known/gordon/ca", nil)
254+ req.RemoteAddr = directAddr
255+ req.Host = host
256+ rec := httptest.NewRecorder()
257+ httpsHandler.ServeHTTP(rec, req)
258+
259+ assert.Equal(t, http.StatusOK, rec.Code, "host %s", host)
260+ assert.Contains(t, rec.Body.String(), "Trust CA Certificate", "host %s", host)
261+ }
262+}
263+
264+func TestCreateHTTPHandlers_HTTPSOnboarding_DoesNotPreemptAppHostCAPath(t *testing.T) {
265+ t.Parallel()
266+ ca := newTestCA(t)
267+ cfg := newOnboardingConfig()
268+ cfg.Server.GordonDomain = "gordon.example.com"
269+ svc := &services{caAdapter: ca, proxySvc: newNotFoundProxyService(t)}
270+
271+ _, _, httpsHandler := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil)
272+
273+ req := httptest.NewRequest(http.MethodGet, "/.well-known/gordon/ca", nil)
247 req.RemoteAddr = directAddr274 req.RemoteAddr = directAddr
248- req.Host = "o2.bnema.dev"275+ req.Host = "app.example.com"
249 rec := httptest.NewRecorder()276 rec := httptest.NewRecorder()
250 httpsHandler.ServeHTTP(rec, req)277 httpsHandler.ServeHTTP(rec, req)
251 278
252- assert.Equal(t, http.StatusOK, rec.Code)279+ assert.Equal(t, http.StatusNotFound, rec.Code)
253- assert.Contains(t, rec.Body.String(), "Trust CA Certificate")280+ assert.NotContains(t, rec.Body.String(), "Trust CA Certificate")
281+
282+ normalizedReq := httptest.NewRequest(http.MethodGet, "/.well-known/gordon/ca", nil)
283+ normalizedReq.RemoteAddr = directAddr
284+ normalizedReq.Host = "Gordon.Example.Com.:8443"
285+ normalizedRec := httptest.NewRecorder()
286+ httpsHandler.ServeHTTP(normalizedRec, normalizedReq)
287+
288+ assert.Equal(t, http.StatusOK, normalizedRec.Code)
289+ assert.Contains(t, normalizedRec.Body.String(), "Trust CA Certificate")
254 }290 }
255 291
256 func TestCreateHTTPHandlers_HTTPSOnboarding_HEADRoutesRemainAvailable(t *testing.T) {292 func TestCreateHTTPHandlers_HTTPSOnboarding_HEADRoutesRemainAvailable(t *testing.T) {
257 t.Parallel()293 t.Parallel()
258 ca := newTestCA(t)294 ca := newTestCA(t)
259 cfg := newOnboardingConfig()295 cfg := newOnboardingConfig()
260- svc := &services{caAdapter: ca}296+ cfg.Server.GordonDomain = "gordon.example.com"
297+ svc := &services{caAdapter: ca, proxySvc: newNotFoundProxyService(t)}
261 298
262 _, _, httpsHandler := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil)299 _, _, httpsHandler := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil)
263 300
@@ -266,8 +303,11 @@ func TestCreateHTTPHandlers_HTTPSOnboarding_HEADRoutesRemainAvailable(t *testing
266 ts := httptest.NewServer(httpsHandler)303 ts := httptest.NewServer(httpsHandler)
267 defer ts.Close()304 defer ts.Close()
268 305
269- for _, path := range []string{"/ca", "/ca.crt", "/ca.mobileconfig"} {306+ for _, path := range []string{"/.well-known/gordon/ca", "/.well-known/gordon/ca.crt", "/.well-known/gordon/ca.mobileconfig"} {
270- resp, err := ts.Client().Head(ts.URL + path)307+ req, err := http.NewRequest(http.MethodHead, ts.URL+path, nil)
308+ require.NoError(t, err)
309+ req.Host = "Gordon.Example.Com.:8443"
310+ resp, err := ts.Client().Do(req)
271 require.NoError(t, err)311 require.NoError(t, err)
272 312
273 body, err := io.ReadAll(resp.Body)313 body, err := io.ReadAll(resp.Body)
@@ -298,8 +338,8 @@ func TestCreateHTTPHandlers_DirectHTTPOnboarding_HEADRoutesRemainAvailable(t *te
298 path string338 path string
299 contentType string339 contentType string
300 }{340 }{
301- {path: "/ca", contentType: "text/html"},341+ {path: "/.well-known/gordon/ca", contentType: "text/html"},
302- {path: "/ca.crt", contentType: "application/x-x509-ca-cert"},342+ {path: "/.well-known/gordon/ca.crt", contentType: "application/x-x509-ca-cert"},
303 } {343 } {
304 resp, err := ts.Client().Head(ts.URL + tc.path)344 resp, err := ts.Client().Head(ts.URL + tc.path)
305 require.NoError(t, err)345 require.NoError(t, err)
@@ -376,6 +416,53 @@ func TestCreateHTTPHandlers_TLSDisabled_DoesNotServeHTTPOnboarding(t *testing.T)
376 assert.NotContains(t, rec.Body.String(), "Trust CA Certificate")416 assert.NotContains(t, rec.Body.String(), "Trust CA Certificate")
377 }417 }
378 418
419+type recordingPublicTLSRuntime struct {
420+ reconcileCalls int
421+ loopCalls int
422+ reconcileErr error
423+}
424+
425+func (r *recordingPublicTLSRuntime) Reconcile(context.Context) error {
426+ r.reconcileCalls++
427+ return r.reconcileErr
428+}
429+
430+func (r *recordingPublicTLSRuntime) StartRenewalLoop(context.Context, time.Duration) <-chan struct{} {
431+ r.loopCalls++
432+ done := make(chan struct{})
433+ close(done)
434+ return done
435+}
436+
437+func TestStartPublicTLSRuntime_ReconcilesAndStartsRenewalLoop(t *testing.T) {
438+ t.Parallel()
439+
440+ runtime := &recordingPublicTLSRuntime{}
441+ err := startPublicTLSRuntime(context.Background(), runtime, zerowrap.Default())
442+
443+ require.NoError(t, err)
444+ assert.Equal(t, 1, runtime.reconcileCalls)
445+ assert.Equal(t, 1, runtime.loopCalls)
446+}
447+
448+func TestStartPublicTLSRuntime_NilSvc(t *testing.T) {
449+ t.Parallel()
450+
451+ err := startPublicTLSRuntime(context.Background(), nil, zerowrap.Default())
452+ require.NoError(t, err)
453+}
454+
455+func TestStartPublicTLSRuntime_StartsRenewalLoopWhenReconcileFails(t *testing.T) {
456+ t.Parallel()
457+
458+ runtime := &recordingPublicTLSRuntime{reconcileErr: errors.New("boom")}
459+ err := startPublicTLSRuntime(context.Background(), runtime, zerowrap.Default())
460+
461+ require.Error(t, err)
462+ assert.Equal(t, 1, runtime.reconcileCalls)
463+ assert.Equal(t, 1, runtime.loopCalls)
464+}
465+
379 func TestCreateHTTPHandlers_TLSConfiguredWithoutCA_FailsStartup(t *testing.T) {466 func TestCreateHTTPHandlers_TLSConfiguredWithoutCA_FailsStartup(t *testing.T) {
380 t.Parallel()467 t.Parallel()
381 // Verify that initPKI returns an error when TLS is enabled but CA init fails.468 // Verify that initPKI returns an error when TLS is enabled but CA init fails.
M internal/boundaries/out/mocks/mock_certificate_store.go +122 -5
@@ -73,7 +73,7 @@ type MockCertificateStore_LoadAccount_Call struct {
7373
7474 // LoadAccount is a helper method to define mock.On call
7575 // - ctx context.Context
76-func (_e *MockCertificateStore_Expecter) LoadAccount(ctx any) *MockCertificateStore_LoadAccount_Call {
76+func (_e *MockCertificateStore_Expecter) LoadAccount(ctx interface{}) *MockCertificateStore_LoadAccount_Call {
7777 return &MockCertificateStore_LoadAccount_Call{Call: _e.mock.On("LoadAccount", ctx)}
7878 }
7979
@@ -135,7 +135,7 @@ type MockCertificateStore_LoadAll_Call struct {
135135
136136 // LoadAll is a helper method to define mock.On call
137137 // - ctx context.Context
138-func (_e *MockCertificateStore_Expecter) LoadAll(ctx any) *MockCertificateStore_LoadAll_Call {
138+func (_e *MockCertificateStore_Expecter) LoadAll(ctx interface{}) *MockCertificateStore_LoadAll_Call {
139139 return &MockCertificateStore_LoadAll_Call{Call: _e.mock.On("LoadAll", ctx)}
140140 }
141141
@@ -162,6 +162,66 @@ func (_c *MockCertificateStore_LoadAll_Call) RunAndReturn(run func(ctx context.C
162162 return _c
163163 }
164164
165+// LoadState provides a mock function for the type MockCertificateStore
166+func (_mock *MockCertificateStore) LoadState(ctx context.Context) (out.CertificateStoreState, error) {
167+ ret := _mock.Called(ctx)
168+
169+ if len(ret) == 0 {
170+ panic("no return value specified for LoadState")
171+ }
172+
173+ var r0 out.CertificateStoreState
174+ var r1 error
175+ if returnFunc, ok := ret.Get(0).(func(context.Context) (out.CertificateStoreState, error)); ok {
176+ return returnFunc(ctx)
177+ }
178+ if returnFunc, ok := ret.Get(0).(func(context.Context) out.CertificateStoreState); ok {
179+ r0 = returnFunc(ctx)
180+ } else {
181+ r0 = ret.Get(0).(out.CertificateStoreState)
182+ }
183+ if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
184+ r1 = returnFunc(ctx)
185+ } else {
186+ r1 = ret.Error(1)
187+ }
188+ return r0, r1
189+}
190+
191+// MockCertificateStore_LoadState_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'LoadState'
192+type MockCertificateStore_LoadState_Call struct {
193+ *mock.Call
194+}
195+
196+// LoadState is a helper method to define mock.On call
197+// - ctx context.Context
198+func (_e *MockCertificateStore_Expecter) LoadState(ctx interface{}) *MockCertificateStore_LoadState_Call {
199+ return &MockCertificateStore_LoadState_Call{Call: _e.mock.On("LoadState", ctx)}
200+}
201+
202+func (_c *MockCertificateStore_LoadState_Call) Run(run func(ctx context.Context)) *MockCertificateStore_LoadState_Call {
203+ _c.Call.Run(func(args mock.Arguments) {
204+ var arg0 context.Context
205+ if args[0] != nil {
206+ arg0 = args[0].(context.Context)
207+ }
208+ run(
209+ arg0,
210+ )
211+ })
212+ return _c
213+}
214+
215+func (_c *MockCertificateStore_LoadState_Call) Return(certificateStoreState out.CertificateStoreState, err error) *MockCertificateStore_LoadState_Call {
216+ _c.Call.Return(certificateStoreState, err)
217+ return _c
218+}
219+
220+func (_c *MockCertificateStore_LoadState_Call) RunAndReturn(run func(ctx context.Context) (out.CertificateStoreState, error)) *MockCertificateStore_LoadState_Call {
221+ _c.Call.Return(run)
222+ return _c
223+}
224+
165225 // Lock provides a mock function for the type MockCertificateStore
166226 func (_mock *MockCertificateStore) Lock(ctx context.Context) (func() error, error) {
167227 ret := _mock.Called(ctx)
@@ -197,7 +257,7 @@ type MockCertificateStore_Lock_Call struct {
197257
198258 // Lock is a helper method to define mock.On call
199259 // - ctx context.Context
200-func (_e *MockCertificateStore_Expecter) Lock(ctx any) *MockCertificateStore_Lock_Call {
260+func (_e *MockCertificateStore_Expecter) Lock(ctx interface{}) *MockCertificateStore_Lock_Call {
201261 return &MockCertificateStore_Lock_Call{Call: _e.mock.On("Lock", ctx)}
202262 }
203263
@@ -249,7 +309,7 @@ type MockCertificateStore_Save_Call struct {
249309 // Save is a helper method to define mock.On call
250310 // - ctx context.Context
251311 // - cert out.StoredCertificate
252-func (_e *MockCertificateStore_Expecter) Save(ctx any, cert any) *MockCertificateStore_Save_Call {
312+func (_e *MockCertificateStore_Expecter) Save(ctx interface{}, cert interface{}) *MockCertificateStore_Save_Call {
253313 return &MockCertificateStore_Save_Call{Call: _e.mock.On("Save", ctx, cert)}
254314 }
255315
@@ -306,7 +366,7 @@ type MockCertificateStore_SaveAccount_Call struct {
306366 // SaveAccount is a helper method to define mock.On call
307367 // - ctx context.Context
308368 // - account out.ACMEAccount
309-func (_e *MockCertificateStore_Expecter) SaveAccount(ctx any, account any) *MockCertificateStore_SaveAccount_Call {
369+func (_e *MockCertificateStore_Expecter) SaveAccount(ctx interface{}, account interface{}) *MockCertificateStore_SaveAccount_Call {
310370 return &MockCertificateStore_SaveAccount_Call{Call: _e.mock.On("SaveAccount", ctx, account)}
311371 }
312372
@@ -337,3 +397,60 @@ func (_c *MockCertificateStore_SaveAccount_Call) RunAndReturn(run func(ctx conte
337397 _c.Call.Return(run)
338398 return _c
339399 }
400+
401+// SaveState provides a mock function for the type MockCertificateStore
402+func (_mock *MockCertificateStore) SaveState(ctx context.Context, state out.CertificateStoreState) error {
403+ ret := _mock.Called(ctx, state)
404+
405+ if len(ret) == 0 {
406+ panic("no return value specified for SaveState")
407+ }
408+
409+ var r0 error
410+ if returnFunc, ok := ret.Get(0).(func(context.Context, out.CertificateStoreState) error); ok {
411+ r0 = returnFunc(ctx, state)
412+ } else {
413+ r0 = ret.Error(0)
414+ }
415+ return r0
416+}
417+
418+// MockCertificateStore_SaveState_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SaveState'
419+type MockCertificateStore_SaveState_Call struct {
420+ *mock.Call
421+}
422+
423+// SaveState is a helper method to define mock.On call
424+// - ctx context.Context
425+// - state out.CertificateStoreState
426+func (_e *MockCertificateStore_Expecter) SaveState(ctx interface{}, state interface{}) *MockCertificateStore_SaveState_Call {
427+ return &MockCertificateStore_SaveState_Call{Call: _e.mock.On("SaveState", ctx, state)}
428+}
429+
430+func (_c *MockCertificateStore_SaveState_Call) Run(run func(ctx context.Context, state out.CertificateStoreState)) *MockCertificateStore_SaveState_Call {
431+ _c.Call.Run(func(args mock.Arguments) {
432+ var arg0 context.Context
433+ if args[0] != nil {
434+ arg0 = args[0].(context.Context)
435+ }
436+ var arg1 out.CertificateStoreState
437+ if args[1] != nil {
438+ arg1 = args[1].(out.CertificateStoreState)
439+ }
440+ run(
441+ arg0,
442+ arg1,
443+ )
444+ })
445+ return _c
446+}
447+
448+func (_c *MockCertificateStore_SaveState_Call) Return(err error) *MockCertificateStore_SaveState_Call {
449+ _c.Call.Return(err)
450+ return _c
451+}
452+
453+func (_c *MockCertificateStore_SaveState_Call) RunAndReturn(run func(ctx context.Context, state out.CertificateStoreState) error) *MockCertificateStore_SaveState_Call {
454+ _c.Call.Return(run)
455+ return _c
456+}
@@ -73,7 +73,7 @@ type MockCertificateStore_LoadAccount_Call struct {
73 73
74 // LoadAccount is a helper method to define mock.On call74 // LoadAccount is a helper method to define mock.On call
75 // - ctx context.Context75 // - ctx context.Context
76-func (_e *MockCertificateStore_Expecter) LoadAccount(ctx any) *MockCertificateStore_LoadAccount_Call {76+func (_e *MockCertificateStore_Expecter) LoadAccount(ctx interface{}) *MockCertificateStore_LoadAccount_Call {
77 return &MockCertificateStore_LoadAccount_Call{Call: _e.mock.On("LoadAccount", ctx)}77 return &MockCertificateStore_LoadAccount_Call{Call: _e.mock.On("LoadAccount", ctx)}
78 }78 }
79 79
@@ -135,7 +135,7 @@ type MockCertificateStore_LoadAll_Call struct {
135 135
136 // LoadAll is a helper method to define mock.On call136 // LoadAll is a helper method to define mock.On call
137 // - ctx context.Context137 // - ctx context.Context
138-func (_e *MockCertificateStore_Expecter) LoadAll(ctx any) *MockCertificateStore_LoadAll_Call {138+func (_e *MockCertificateStore_Expecter) LoadAll(ctx interface{}) *MockCertificateStore_LoadAll_Call {
139 return &MockCertificateStore_LoadAll_Call{Call: _e.mock.On("LoadAll", ctx)}139 return &MockCertificateStore_LoadAll_Call{Call: _e.mock.On("LoadAll", ctx)}
140 }140 }
141 141
@@ -162,6 +162,66 @@ func (_c *MockCertificateStore_LoadAll_Call) RunAndReturn(run func(ctx context.C
162 return _c162 return _c
163 }163 }
164 164
165+// LoadState provides a mock function for the type MockCertificateStore
166+func (_mock *MockCertificateStore) LoadState(ctx context.Context) (out.CertificateStoreState, error) {
167+ ret := _mock.Called(ctx)
168+
169+ if len(ret) == 0 {
170+ panic("no return value specified for LoadState")
171+ }
172+
173+ var r0 out.CertificateStoreState
174+ var r1 error
175+ if returnFunc, ok := ret.Get(0).(func(context.Context) (out.CertificateStoreState, error)); ok {
176+ return returnFunc(ctx)
177+ }
178+ if returnFunc, ok := ret.Get(0).(func(context.Context) out.CertificateStoreState); ok {
179+ r0 = returnFunc(ctx)
180+ } else {
181+ r0 = ret.Get(0).(out.CertificateStoreState)
182+ }
183+ if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
184+ r1 = returnFunc(ctx)
185+ } else {
186+ r1 = ret.Error(1)
187+ }
188+ return r0, r1
189+}
190+
191+// MockCertificateStore_LoadState_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'LoadState'
192+type MockCertificateStore_LoadState_Call struct {
193+ *mock.Call
194+}
195+
196+// LoadState is a helper method to define mock.On call
197+// - ctx context.Context
198+func (_e *MockCertificateStore_Expecter) LoadState(ctx interface{}) *MockCertificateStore_LoadState_Call {
199+ return &MockCertificateStore_LoadState_Call{Call: _e.mock.On("LoadState", ctx)}
200+}
201+
202+func (_c *MockCertificateStore_LoadState_Call) Run(run func(ctx context.Context)) *MockCertificateStore_LoadState_Call {
203+ _c.Call.Run(func(args mock.Arguments) {
204+ var arg0 context.Context
205+ if args[0] != nil {
206+ arg0 = args[0].(context.Context)
207+ }
208+ run(
209+ arg0,
210+ )
211+ })
212+ return _c
213+}
214+
215+func (_c *MockCertificateStore_LoadState_Call) Return(certificateStoreState out.CertificateStoreState, err error) *MockCertificateStore_LoadState_Call {
216+ _c.Call.Return(certificateStoreState, err)
217+ return _c
218+}
219+
220+func (_c *MockCertificateStore_LoadState_Call) RunAndReturn(run func(ctx context.Context) (out.CertificateStoreState, error)) *MockCertificateStore_LoadState_Call {
221+ _c.Call.Return(run)
222+ return _c
223+}
224+
165 // Lock provides a mock function for the type MockCertificateStore225 // Lock provides a mock function for the type MockCertificateStore
166 func (_mock *MockCertificateStore) Lock(ctx context.Context) (func() error, error) {226 func (_mock *MockCertificateStore) Lock(ctx context.Context) (func() error, error) {
167 ret := _mock.Called(ctx)227 ret := _mock.Called(ctx)
@@ -197,7 +257,7 @@ type MockCertificateStore_Lock_Call struct {
197 257
198 // Lock is a helper method to define mock.On call258 // Lock is a helper method to define mock.On call
199 // - ctx context.Context259 // - ctx context.Context
200-func (_e *MockCertificateStore_Expecter) Lock(ctx any) *MockCertificateStore_Lock_Call {260+func (_e *MockCertificateStore_Expecter) Lock(ctx interface{}) *MockCertificateStore_Lock_Call {
201 return &MockCertificateStore_Lock_Call{Call: _e.mock.On("Lock", ctx)}261 return &MockCertificateStore_Lock_Call{Call: _e.mock.On("Lock", ctx)}
202 }262 }
203 263
@@ -249,7 +309,7 @@ type MockCertificateStore_Save_Call struct {
249 // Save is a helper method to define mock.On call309 // Save is a helper method to define mock.On call
250 // - ctx context.Context310 // - ctx context.Context
251 // - cert out.StoredCertificate311 // - cert out.StoredCertificate
252-func (_e *MockCertificateStore_Expecter) Save(ctx any, cert any) *MockCertificateStore_Save_Call {312+func (_e *MockCertificateStore_Expecter) Save(ctx interface{}, cert interface{}) *MockCertificateStore_Save_Call {
253 return &MockCertificateStore_Save_Call{Call: _e.mock.On("Save", ctx, cert)}313 return &MockCertificateStore_Save_Call{Call: _e.mock.On("Save", ctx, cert)}
254 }314 }
255 315
@@ -306,7 +366,7 @@ type MockCertificateStore_SaveAccount_Call struct {
306 // SaveAccount is a helper method to define mock.On call366 // SaveAccount is a helper method to define mock.On call
307 // - ctx context.Context367 // - ctx context.Context
308 // - account out.ACMEAccount368 // - account out.ACMEAccount
309-func (_e *MockCertificateStore_Expecter) SaveAccount(ctx any, account any) *MockCertificateStore_SaveAccount_Call {369+func (_e *MockCertificateStore_Expecter) SaveAccount(ctx interface{}, account interface{}) *MockCertificateStore_SaveAccount_Call {
310 return &MockCertificateStore_SaveAccount_Call{Call: _e.mock.On("SaveAccount", ctx, account)}370 return &MockCertificateStore_SaveAccount_Call{Call: _e.mock.On("SaveAccount", ctx, account)}
311 }371 }
312 372
@@ -337,3 +397,60 @@ func (_c *MockCertificateStore_SaveAccount_Call) RunAndReturn(run func(ctx conte
337 _c.Call.Return(run)397 _c.Call.Return(run)
338 return _c398 return _c
339 }399 }
400+
401+// SaveState provides a mock function for the type MockCertificateStore
402+func (_mock *MockCertificateStore) SaveState(ctx context.Context, state out.CertificateStoreState) error {
403+ ret := _mock.Called(ctx, state)
404+
405+ if len(ret) == 0 {
406+ panic("no return value specified for SaveState")
407+ }
408+
409+ var r0 error
410+ if returnFunc, ok := ret.Get(0).(func(context.Context, out.CertificateStoreState) error); ok {
411+ r0 = returnFunc(ctx, state)
412+ } else {
413+ r0 = ret.Error(0)
414+ }
415+ return r0
416+}
417+
418+// MockCertificateStore_SaveState_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SaveState'
419+type MockCertificateStore_SaveState_Call struct {
420+ *mock.Call
421+}
422+
423+// SaveState is a helper method to define mock.On call
424+// - ctx context.Context
425+// - state out.CertificateStoreState
426+func (_e *MockCertificateStore_Expecter) SaveState(ctx interface{}, state interface{}) *MockCertificateStore_SaveState_Call {
427+ return &MockCertificateStore_SaveState_Call{Call: _e.mock.On("SaveState", ctx, state)}
428+}
429+
430+func (_c *MockCertificateStore_SaveState_Call) Run(run func(ctx context.Context, state out.CertificateStoreState)) *MockCertificateStore_SaveState_Call {
431+ _c.Call.Run(func(args mock.Arguments) {
432+ var arg0 context.Context
433+ if args[0] != nil {
434+ arg0 = args[0].(context.Context)
435+ }
436+ var arg1 out.CertificateStoreState
437+ if args[1] != nil {
438+ arg1 = args[1].(out.CertificateStoreState)
439+ }
440+ run(
441+ arg0,
442+ arg1,
443+ )
444+ })
445+ return _c
446+}
447+
448+func (_c *MockCertificateStore_SaveState_Call) Return(err error) *MockCertificateStore_SaveState_Call {
449+ _c.Call.Return(err)
450+ return _c
451+}
452+
453+func (_c *MockCertificateStore_SaveState_Call) RunAndReturn(run func(ctx context.Context, state out.CertificateStoreState) error) *MockCertificateStore_SaveState_Call {
454+ _c.Call.Return(run)
455+ return _c
456+}
M internal/boundaries/out/public_tls.go +11 -0
@@ -39,6 +39,11 @@ type ACMEAccount struct {
3939 BodyJSON []byte
4040 }
4141
42+// CertificateStoreState represents persisted ACME reconciliation state.
43+type CertificateStoreState struct {
44+ ObtainCursor int
45+}
46+
4247 // SecretValue holds a resolved secret value and its source.
4348 type SecretValue struct {
4449 Value string
@@ -85,6 +90,12 @@ type CertificateStore interface {
8590 // Save persists a certificate.
8691 Save(ctx context.Context, cert StoredCertificate) error
8792
93+ // LoadState returns persisted reconciliation state.
94+ LoadState(ctx context.Context) (CertificateStoreState, error)
95+
96+ // SaveState persists reconciliation state.
97+ SaveState(ctx context.Context, state CertificateStoreState) error
98+
8899 // Lock acquires a lock to prevent concurrent certificate operations.
89100 Lock(ctx context.Context) (unlock func() error, err error)
90101 }
@@ -39,6 +39,11 @@ type ACMEAccount struct {
39 BodyJSON []byte39 BodyJSON []byte
40 }40 }
41 41
42+// CertificateStoreState represents persisted ACME reconciliation state.
43+type CertificateStoreState struct {
44+ ObtainCursor int
45+}
46+
42 // SecretValue holds a resolved secret value and its source.47 // SecretValue holds a resolved secret value and its source.
43 type SecretValue struct {48 type SecretValue struct {
44 Value string49 Value string
@@ -85,6 +90,12 @@ type CertificateStore interface {
85 // Save persists a certificate.90 // Save persists a certificate.
86 Save(ctx context.Context, cert StoredCertificate) error91 Save(ctx context.Context, cert StoredCertificate) error
87 92
93+ // LoadState returns persisted reconciliation state.
94+ LoadState(ctx context.Context) (CertificateStoreState, error)
95+
96+ // SaveState persists reconciliation state.
97+ SaveState(ctx context.Context, state CertificateStoreState) error
98+
88 // Lock acquires a lock to prevent concurrent certificate operations.99 // Lock acquires a lock to prevent concurrent certificate operations.
89 Lock(ctx context.Context) (unlock func() error, err error)100 Lock(ctx context.Context) (unlock func() error, err error)
90 }101 }
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 +95 -14
@@ -33,15 +33,18 @@ 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 {
38- mu sync.RWMutex
39- cfg Config
40- deps ServiceDeps
41- log zerowrap.Logger
42- certs map[string]*out.StoredCertificate // indexed by cert ID
43- lastErr map[string]string // indexed by cert ID
44- routeErr map[string]string // indexed by host
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
4548
4649 // requiredHosts is the set of hosts that must be covered by ACME certs.
4750 requiredHosts map[string]struct{}
@@ -82,6 +85,14 @@ func (s *Service) Load(ctx context.Context) error {
8285 if err != nil {
8386 return fmt.Errorf("load stored certificates: %w", err)
8487 }
88+ state, err := s.deps.Store.LoadState(ctx)
89+ if err != nil {
90+ s.log.Warn().Err(err).Msg("failed to load certificate store state; using in-memory ACME obtain cursor")
91+ state = out.CertificateStoreState{}
92+ }
93+ if state.ObtainCursor < 0 {
94+ state.ObtainCursor = 0
95+ }
8596
8697 s.mu.Lock()
8798 defer s.mu.Unlock()
@@ -89,6 +100,7 @@ func (s *Service) Load(ctx context.Context) error {
89100 s.certs = make(map[string]*out.StoredCertificate, len(stored))
90101 s.lastErr = make(map[string]string)
91102 s.requiredHosts = make(map[string]struct{})
103+ s.obtainCursor = state.ObtainCursor
92104
93105 for i := range stored {
94106 cert := &stored[i]
@@ -182,20 +194,89 @@ func (s *Service) Reconcile(ctx context.Context) error {
182194 s.mu.Lock()
183195 s.requiredHosts = required
184196
185- missing := make([]CertificateTarget, 0)
186- for _, target := range targets {
187- if s.certificateExistsLocked(target) {
188- continue
189- }
190- missing = append(missing, target)
197+ batchSize := s.cfg.ObtainBatchSize
198+ if batchSize <= 0 {
199+ batchSize = defaultObtainBatchSize
191200 }
201+
202+ previousCursor := s.obtainCursor
203+ missing, pendingCount, nextCursor, cursorChanged := s.selectMissingTargetsLocked(targets, batchSize)
192204 s.mu.Unlock()
193205
206+ if cursorChanged {
207+ if err := s.persistObtainCursor(ctx, nextCursor); err != nil {
208+ log.Warn().Err(err).Int("obtain_cursor", nextCursor).Msg("failed to persist ACME obtain cursor; continuing with in-memory cursor")
209+ }
210+ s.mu.Lock()
211+ if s.obtainCursor == previousCursor {
212+ s.obtainCursor = nextCursor
213+ }
214+ s.mu.Unlock()
215+ }
216+
194217 // Process missing targets without holding s.mu or the store lock so
195218 // GetCertificate/Status are not blocked during network I/O (Obtain).
219+ if remaining := pendingCount - len(missing); remaining > 0 {
220+ log.Info().
221+ Int("obtain_batch_size", len(missing)).
222+ Int("remaining_count", remaining).
223+ Msg("ACME obtain batch limit reached; remaining certificates will be reconciled later")
224+ }
196225 s.reconcileMissingTargets(ctx, missing, required)
197226
198- log.Debug().Int("missing_count", len(missing)).Msg("reconciled missing targets")
227+ log.Debug().Int("missing_count", len(missing)).Int("pending_count", pendingCount).Msg("reconciled missing targets")
228+ return nil
229+}
230+
231+// selectMissingTargetsLocked returns up to batchSize missing certificate targets.
232+// It rotates the starting point across reconcile runs so one repeatedly failing
233+// target cannot indefinitely block later targets when batching is enabled.
234+// Must be called with s.mu held.
235+func (s *Service) selectMissingTargetsLocked(targets []CertificateTarget, batchSize int) ([]CertificateTarget, int, int, bool) {
236+ if batchSize <= 0 {
237+ batchSize = defaultObtainBatchSize
238+ }
239+ previousCursor := s.obtainCursor
240+
241+ allMissing := make([]CertificateTarget, 0, len(targets))
242+ for _, target := range targets {
243+ if !s.certificateExistsLocked(target) {
244+ allMissing = append(allMissing, target)
245+ }
246+ }
247+
248+ pendingCount := len(allMissing)
249+ if pendingCount == 0 {
250+ nextCursor := 0
251+ return nil, 0, nextCursor, nextCursor != previousCursor
252+ }
253+ if batchSize >= pendingCount {
254+ nextCursor := 0
255+ return allMissing, pendingCount, nextCursor, nextCursor != previousCursor
256+ }
257+
258+ start := s.obtainCursor % pendingCount
259+ missing := make([]CertificateTarget, 0, batchSize)
260+ for i := range batchSize {
261+ missing = append(missing, allMissing[(start+i)%pendingCount])
262+ }
263+ nextCursor := (start + batchSize) % pendingCount
264+ return missing, pendingCount, nextCursor, nextCursor != previousCursor
265+}
266+
267+func (s *Service) persistObtainCursor(ctx context.Context, obtainCursor int) error {
268+ unlock, err := s.deps.Store.Lock(ctx)
269+ if err != nil {
270+ return fmt.Errorf("acquire store lock: %w", err)
271+ }
272+ saveErr := s.deps.Store.SaveState(ctx, out.CertificateStoreState{ObtainCursor: obtainCursor})
273+ if unlockErr := unlock(); unlockErr != nil {
274+ log := zerowrap.FromCtx(ctx)
275+ log.Warn().Err(unlockErr).Msg("failed to release store lock")
276+ }
277+ if saveErr != nil {
278+ return fmt.Errorf("save certificate store state: %w", saveErr)
279+ }
199280 return nil
200281 }
201282
@@ -33,15 +33,18 @@ 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
39- cfg Config41+ cfg Config
40- deps ServiceDeps42+ deps ServiceDeps
41- log zerowrap.Logger43+ log zerowrap.Logger
42- certs map[string]*out.StoredCertificate // indexed by cert ID44+ certs map[string]*out.StoredCertificate // indexed by cert ID
43- lastErr map[string]string // indexed by cert ID45+ lastErr map[string]string // indexed by cert ID
44- routeErr map[string]string // indexed by host46+ routeErr map[string]string // indexed by host
47+ obtainCursor int // next missing target index for batched obtains
45 48
46 // 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.
47 requiredHosts map[string]struct{}50 requiredHosts map[string]struct{}
@@ -82,6 +85,14 @@ func (s *Service) Load(ctx context.Context) error {
82 if err != nil {85 if err != nil {
83 return fmt.Errorf("load stored certificates: %w", err)86 return fmt.Errorf("load stored certificates: %w", err)
84 }87 }
88+ state, err := s.deps.Store.LoadState(ctx)
89+ if err != nil {
90+ s.log.Warn().Err(err).Msg("failed to load certificate store state; using in-memory ACME obtain cursor")
91+ state = out.CertificateStoreState{}
92+ }
93+ if state.ObtainCursor < 0 {
94+ state.ObtainCursor = 0
95+ }
85 96
86 s.mu.Lock()97 s.mu.Lock()
87 defer s.mu.Unlock()98 defer s.mu.Unlock()
@@ -89,6 +100,7 @@ func (s *Service) Load(ctx context.Context) error {
89 s.certs = make(map[string]*out.StoredCertificate, len(stored))100 s.certs = make(map[string]*out.StoredCertificate, len(stored))
90 s.lastErr = make(map[string]string)101 s.lastErr = make(map[string]string)
91 s.requiredHosts = make(map[string]struct{})102 s.requiredHosts = make(map[string]struct{})
103+ s.obtainCursor = state.ObtainCursor
92 104
93 for i := range stored {105 for i := range stored {
94 cert := &stored[i]106 cert := &stored[i]
@@ -182,20 +194,89 @@ func (s *Service) Reconcile(ctx context.Context) error {
182 s.mu.Lock()194 s.mu.Lock()
183 s.requiredHosts = required195 s.requiredHosts = required
184 196
185- missing := make([]CertificateTarget, 0)197+ batchSize := s.cfg.ObtainBatchSize
186- for _, target := range targets {198+ if batchSize <= 0 {
187- if s.certificateExistsLocked(target) {199+ batchSize = defaultObtainBatchSize
188- continue
189- }
190- missing = append(missing, target)
191 }200 }
201+
202+ previousCursor := s.obtainCursor
203+ missing, pendingCount, nextCursor, cursorChanged := s.selectMissingTargetsLocked(targets, batchSize)
192 s.mu.Unlock()204 s.mu.Unlock()
193 205
206+ if cursorChanged {
207+ if err := s.persistObtainCursor(ctx, nextCursor); err != nil {
208+ log.Warn().Err(err).Int("obtain_cursor", nextCursor).Msg("failed to persist ACME obtain cursor; continuing with in-memory cursor")
209+ }
210+ s.mu.Lock()
211+ if s.obtainCursor == previousCursor {
212+ s.obtainCursor = nextCursor
213+ }
214+ s.mu.Unlock()
215+ }
216+
194 // Process missing targets without holding s.mu or the store lock so217 // Process missing targets without holding s.mu or the store lock so
195 // GetCertificate/Status are not blocked during network I/O (Obtain).218 // GetCertificate/Status are not blocked during network I/O (Obtain).
219+ if remaining := pendingCount - len(missing); remaining > 0 {
220+ log.Info().
221+ Int("obtain_batch_size", len(missing)).
222+ Int("remaining_count", remaining).
223+ Msg("ACME obtain batch limit reached; remaining certificates will be reconciled later")
224+ }
196 s.reconcileMissingTargets(ctx, missing, required)225 s.reconcileMissingTargets(ctx, missing, required)
197 226
198- log.Debug().Int("missing_count", len(missing)).Msg("reconciled missing targets")227+ log.Debug().Int("missing_count", len(missing)).Int("pending_count", pendingCount).Msg("reconciled missing targets")
228+ return nil
229+}
230+
231+// selectMissingTargetsLocked returns up to batchSize missing certificate targets.
232+// It rotates the starting point across reconcile runs so one repeatedly failing
233+// target cannot indefinitely block later targets when batching is enabled.
234+// Must be called with s.mu held.
235+func (s *Service) selectMissingTargetsLocked(targets []CertificateTarget, batchSize int) ([]CertificateTarget, int, int, bool) {
236+ if batchSize <= 0 {
237+ batchSize = defaultObtainBatchSize
238+ }
239+ previousCursor := s.obtainCursor
240+
241+ allMissing := make([]CertificateTarget, 0, len(targets))
242+ for _, target := range targets {
243+ if !s.certificateExistsLocked(target) {
244+ allMissing = append(allMissing, target)
245+ }
246+ }
247+
248+ pendingCount := len(allMissing)
249+ if pendingCount == 0 {
250+ nextCursor := 0
251+ return nil, 0, nextCursor, nextCursor != previousCursor
252+ }
253+ if batchSize >= pendingCount {
254+ nextCursor := 0
255+ return allMissing, pendingCount, nextCursor, nextCursor != previousCursor
256+ }
257+
258+ start := s.obtainCursor % pendingCount
259+ missing := make([]CertificateTarget, 0, batchSize)
260+ for i := range batchSize {
261+ missing = append(missing, allMissing[(start+i)%pendingCount])
262+ }
263+ nextCursor := (start + batchSize) % pendingCount
264+ return missing, pendingCount, nextCursor, nextCursor != previousCursor
265+}
266+
267+func (s *Service) persistObtainCursor(ctx context.Context, obtainCursor int) error {
268+ unlock, err := s.deps.Store.Lock(ctx)
269+ if err != nil {
270+ return fmt.Errorf("acquire store lock: %w", err)
271+ }
272+ saveErr := s.deps.Store.SaveState(ctx, out.CertificateStoreState{ObtainCursor: obtainCursor})
273+ if unlockErr := unlock(); unlockErr != nil {
274+ log := zerowrap.FromCtx(ctx)
275+ log.Warn().Err(unlockErr).Msg("failed to release store lock")
276+ }
277+ if saveErr != nil {
278+ return fmt.Errorf("save certificate store state: %w", saveErr)
279+ }
199 return nil280 return nil
200 }281 }
201 282
M internal/usecase/publictls/service_test.go +311 -3
@@ -130,9 +130,12 @@ func defaultStoredCert(order out.CertificateOrder) (*out.StoredCertificate, erro
130130 }
131131
132132 type mockCertificateStoreState struct {
133- mu sync.Mutex
134- account *out.ACMEAccount
135- certs []out.StoredCertificate
133+ mu sync.Mutex
134+ account *out.ACMEAccount
135+ certs []out.StoredCertificate
136+ state out.CertificateStoreState
137+ loadStateErr error
138+ saveStateErr error
136139 }
137140
138141 func (s *mockCertificateStoreState) All() []out.StoredCertificate {
@@ -143,6 +146,30 @@ func (s *mockCertificateStoreState) All() []out.StoredCertificate {
143146 return certs
144147 }
145148
149+func (s *mockCertificateStoreState) State() out.CertificateStoreState {
150+ s.mu.Lock()
151+ defer s.mu.Unlock()
152+ return s.state
153+}
154+
155+func (s *mockCertificateStoreState) SetState(state out.CertificateStoreState) {
156+ s.mu.Lock()
157+ defer s.mu.Unlock()
158+ s.state = state
159+}
160+
161+func (s *mockCertificateStoreState) SetLoadStateError(err error) {
162+ s.mu.Lock()
163+ defer s.mu.Unlock()
164+ s.loadStateErr = err
165+}
166+
167+func (s *mockCertificateStoreState) SetSaveStateError(err error) {
168+ s.mu.Lock()
169+ defer s.mu.Unlock()
170+ s.saveStateErr = err
171+}
172+
146173 func newMockCertificateStore(t *testing.T, initial ...out.StoredCertificate) (*outmocks.MockCertificateStore, *mockCertificateStoreState) {
147174 t.Helper()
148175 state := &mockCertificateStoreState{certs: append([]out.StoredCertificate(nil), initial...)}
@@ -173,6 +200,27 @@ func newMockCertificateStore(t *testing.T, initial ...out.StoredCertificate) (*o
173200 })
174201 loadAllCall.Maybe()
175202
203+ loadStateCall := store.EXPECT().LoadState(mock.Anything).RunAndReturn(func(context.Context) (out.CertificateStoreState, error) {
204+ state.mu.Lock()
205+ defer state.mu.Unlock()
206+ if state.loadStateErr != nil {
207+ return out.CertificateStoreState{}, state.loadStateErr
208+ }
209+ return state.state, nil
210+ })
211+ loadStateCall.Maybe()
212+
213+ saveStateCall := store.EXPECT().SaveState(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, storeState out.CertificateStoreState) error {
214+ state.mu.Lock()
215+ defer state.mu.Unlock()
216+ if state.saveStateErr != nil {
217+ return state.saveStateErr
218+ }
219+ state.state = storeState
220+ return nil
221+ })
222+ saveStateCall.Maybe()
223+
176224 saveCall := store.EXPECT().Save(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, cert out.StoredCertificate) error {
177225 state.mu.Lock()
178226 defer state.mu.Unlock()
@@ -251,6 +299,266 @@ func generateTestCertPEM(names []string) (certPEM []byte, keyPEM []byte, err err
251299 // Tests
252300 // ---------------------------------------------------------------------------
253301
302+func TestServiceReconcileLimitsMissingObtainsPerRun(t *testing.T) {
303+ ctx := context.Background()
304+
305+ routes := &fakeRoutes{
306+ routes: []domain.Route{
307+ {Domain: "one.example.com"},
308+ {Domain: "two.example.com"},
309+ {Domain: "three.example.com"},
310+ },
311+ }
312+ issuer, recorder := newMockPublicCertificateIssuer(t, nil, nil)
313+ store, storeState := newMockCertificateStore(t)
314+
315+ cfg := Config{
316+ Enabled: true,
317+ Email: "admin@example.com",
318+ Challenge: "http-01",
319+ HTTPPort: 8088,
320+ TLSPort: 8443,
321+ ObtainBatchSize: 2,
322+ }
323+
324+ svc := NewService(cfg, ServiceDeps{
325+ Config: cfg,
326+ Routes: routes,
327+ Issuer: issuer,
328+ Store: store,
329+ Challenges: NewHTTP01Challenges(),
330+ Effective: EffectiveChallenge{
331+ ConfiguredMode: domain.ACMEChallengeHTTP01,
332+ Mode: domain.ACMEChallengeHTTP01,
333+ TokenSource: domain.ACMETokenSourceNone,
334+ Reason: "http-01 challenge selected",
335+ },
336+ })
337+
338+ require.NoError(t, svc.Load(ctx))
339+ require.NoError(t, svc.Reconcile(ctx))
340+
341+ orders := recorder.Orders()
342+ require.Len(t, orders, 2, "first reconcile should respect configured obtain batch size")
343+ assert.Len(t, storeState.All(), 2)
344+
345+ require.NoError(t, svc.Reconcile(ctx))
346+ orders = recorder.Orders()
347+ require.Len(t, orders, 3, "second reconcile should continue remaining missing targets")
348+ assert.Len(t, storeState.All(), 3)
349+}
350+
351+func TestServiceReconcileRotatesBatchAfterFailedObtain(t *testing.T) {
352+ ctx := context.Background()
353+
354+ routes := &fakeRoutes{
355+ routes: []domain.Route{
356+ {Domain: "b.example.com"},
357+ {Domain: "a.example.com"},
358+ },
359+ }
360+ issuer, recorder := newMockPublicCertificateIssuer(t, func(_ context.Context, order out.CertificateOrder) (*out.StoredCertificate, error) {
361+ if order.ID == "http01-a.example.com" {
362+ return nil, fmt.Errorf("acme validation failed")
363+ }
364+ return defaultStoredCert(order)
365+ }, nil)
366+ store, storeState := newMockCertificateStore(t)
367+
368+ cfg := Config{
369+ Enabled: true,
370+ Email: "admin@example.com",
371+ Challenge: "http-01",
372+ HTTPPort: 8088,
373+ TLSPort: 8443,
374+ ObtainBatchSize: 1,
375+ }
376+
377+ svc := NewService(cfg, ServiceDeps{
378+ Config: cfg,
379+ Routes: routes,
380+ Issuer: issuer,
381+ Store: store,
382+ Challenges: NewHTTP01Challenges(),
383+ Effective: EffectiveChallenge{
384+ ConfiguredMode: domain.ACMEChallengeHTTP01,
385+ Mode: domain.ACMEChallengeHTTP01,
386+ TokenSource: domain.ACMETokenSourceNone,
387+ Reason: "http-01 challenge selected",
388+ },
389+ })
390+
391+ require.NoError(t, svc.Load(ctx))
392+ require.NoError(t, svc.Reconcile(ctx))
393+ require.NoError(t, svc.Reconcile(ctx))
394+
395+ orders := recorder.Orders()
396+ require.Len(t, orders, 2)
397+ assert.Equal(t, "http01-a.example.com", orders[0].ID)
398+ assert.Equal(t, "http01-b.example.com", orders[1].ID, "second reconcile should rotate past the failed first target")
399+
400+ stored := storeState.All()
401+ require.Len(t, stored, 1)
402+ assert.Equal(t, "http01-b.example.com", stored[0].ID)
403+}
404+
405+func TestServiceLoadUsesZeroCursorWhenLoadStateFails(t *testing.T) {
406+ ctx := context.Background()
407+
408+ routes := &fakeRoutes{
409+ routes: []domain.Route{
410+ {Domain: "b.example.com"},
411+ {Domain: "a.example.com"},
412+ },
413+ }
414+ issuer, recorder := newMockPublicCertificateIssuer(t, nil, nil)
415+ store, storeState := newMockCertificateStore(t)
416+ storeState.SetState(out.CertificateStoreState{ObtainCursor: 1})
417+ storeState.SetLoadStateError(fmt.Errorf("state unreadable"))
418+
419+ cfg := Config{
420+ Enabled: true,
421+ Email: "admin@example.com",
422+ Challenge: "http-01",
423+ HTTPPort: 8088,
424+ TLSPort: 8443,
425+ ObtainBatchSize: 1,
426+ }
427+ svc := NewService(cfg, ServiceDeps{
428+ Config: cfg,
429+ Routes: routes,
430+ Issuer: issuer,
431+ Store: store,
432+ Challenges: NewHTTP01Challenges(),
433+ Effective: EffectiveChallenge{
434+ ConfiguredMode: domain.ACMEChallengeHTTP01,
435+ Mode: domain.ACMEChallengeHTTP01,
436+ TokenSource: domain.ACMETokenSourceNone,
437+ Reason: "http-01 challenge selected",
438+ },
439+ })
440+
441+ require.NoError(t, svc.Load(ctx))
442+ require.NoError(t, svc.Reconcile(ctx))
443+
444+ orders := recorder.Orders()
445+ require.Len(t, orders, 1)
446+ assert.Equal(t, "http01-a.example.com", orders[0].ID, "LoadState failure should fall back to the zero cursor")
447+}
448+
449+func TestServiceReconcileUsesInMemoryCursorWhenPersistFails(t *testing.T) {
450+ ctx := context.Background()
451+
452+ routes := &fakeRoutes{
453+ routes: []domain.Route{
454+ {Domain: "b.example.com"},
455+ {Domain: "a.example.com"},
456+ },
457+ }
458+ issuer, recorder := newMockPublicCertificateIssuer(t, func(_ context.Context, order out.CertificateOrder) (*out.StoredCertificate, error) {
459+ if order.ID == "http01-a.example.com" {
460+ return nil, fmt.Errorf("acme validation failed")
461+ }
462+ return defaultStoredCert(order)
463+ }, nil)
464+ store, storeState := newMockCertificateStore(t)
465+
466+ cfg := Config{
467+ Enabled: true,
468+ Email: "admin@example.com",
469+ Challenge: "http-01",
470+ HTTPPort: 8088,
471+ TLSPort: 8443,
472+ ObtainBatchSize: 1,
473+ }
474+ svc := NewService(cfg, ServiceDeps{
475+ Config: cfg,
476+ Routes: routes,
477+ Issuer: issuer,
478+ Store: store,
479+ Challenges: NewHTTP01Challenges(),
480+ Effective: EffectiveChallenge{
481+ ConfiguredMode: domain.ACMEChallengeHTTP01,
482+ Mode: domain.ACMEChallengeHTTP01,
483+ TokenSource: domain.ACMETokenSourceNone,
484+ Reason: "http-01 challenge selected",
485+ },
486+ })
487+
488+ storeState.SetSaveStateError(fmt.Errorf("disk full"))
489+ require.NoError(t, svc.Load(ctx))
490+ require.NoError(t, svc.Reconcile(ctx))
491+ require.NoError(t, svc.Reconcile(ctx))
492+ assert.Equal(t, out.CertificateStoreState{}, storeState.State(), "failed SaveState should leave persisted state unchanged")
493+
494+ orders := recorder.Orders()
495+ require.Len(t, orders, 2)
496+ assert.Equal(t, "http01-a.example.com", orders[0].ID)
497+ assert.Equal(t, "http01-b.example.com", orders[1].ID, "SaveState failure should still advance the in-memory cursor")
498+
499+ stored := storeState.All()
500+ require.Len(t, stored, 1)
501+ assert.Equal(t, "http01-b.example.com", stored[0].ID)
502+}
503+
504+func TestServiceReconcilePersistsBatchCursorAcrossRestart(t *testing.T) {
505+ ctx := context.Background()
506+
507+ routes := &fakeRoutes{
508+ routes: []domain.Route{
509+ {Domain: "b.example.com"},
510+ {Domain: "a.example.com"},
511+ },
512+ }
513+ issuer, recorder := newMockPublicCertificateIssuer(t, func(_ context.Context, order out.CertificateOrder) (*out.StoredCertificate, error) {
514+ if order.ID == "http01-a.example.com" {
515+ return nil, fmt.Errorf("acme validation failed")
516+ }
517+ return defaultStoredCert(order)
518+ }, nil)
519+ store, storeState := newMockCertificateStore(t)
520+
521+ cfg := Config{
522+ Enabled: true,
523+ Email: "admin@example.com",
524+ Challenge: "http-01",
525+ HTTPPort: 8088,
526+ TLSPort: 8443,
527+ ObtainBatchSize: 1,
528+ }
529+ deps := ServiceDeps{
530+ Config: cfg,
531+ Routes: routes,
532+ Issuer: issuer,
533+ Store: store,
534+ Challenges: NewHTTP01Challenges(),
535+ Effective: EffectiveChallenge{
536+ ConfiguredMode: domain.ACMEChallengeHTTP01,
537+ Mode: domain.ACMEChallengeHTTP01,
538+ TokenSource: domain.ACMETokenSourceNone,
539+ Reason: "http-01 challenge selected",
540+ },
541+ }
542+
543+ firstSvc := NewService(cfg, deps)
544+ require.NoError(t, firstSvc.Load(ctx))
545+ require.NoError(t, firstSvc.Reconcile(ctx))
546+ assert.Equal(t, out.CertificateStoreState{ObtainCursor: 1}, storeState.State())
547+
548+ secondSvc := NewService(cfg, deps)
549+ require.NoError(t, secondSvc.Load(ctx))
550+ require.NoError(t, secondSvc.Reconcile(ctx))
551+
552+ orders := recorder.Orders()
553+ require.Len(t, orders, 2)
554+ assert.Equal(t, "http01-a.example.com", orders[0].ID)
555+ assert.Equal(t, "http01-b.example.com", orders[1].ID, "restart should resume from the persisted obtain cursor")
556+
557+ stored := storeState.All()
558+ require.Len(t, stored, 1)
559+ assert.Equal(t, "http01-b.example.com", stored[0].ID)
560+}
561+
254562 func TestServiceReconcileObtainsMissingHTTP01Cert(t *testing.T) {
255563 ctx := context.Background()
256564
@@ -130,9 +130,12 @@ func defaultStoredCert(order out.CertificateOrder) (*out.StoredCertificate, erro
130 }130 }
131 131
132 type mockCertificateStoreState struct {132 type mockCertificateStoreState struct {
133- mu sync.Mutex133+ mu sync.Mutex
134- account *out.ACMEAccount134+ account *out.ACMEAccount
135- certs []out.StoredCertificate135+ certs []out.StoredCertificate
136+ state out.CertificateStoreState
137+ loadStateErr error
138+ saveStateErr error
136 }139 }
137 140
138 func (s *mockCertificateStoreState) All() []out.StoredCertificate {141 func (s *mockCertificateStoreState) All() []out.StoredCertificate {
@@ -143,6 +146,30 @@ func (s *mockCertificateStoreState) All() []out.StoredCertificate {
143 return certs146 return certs
144 }147 }
145 148
149+func (s *mockCertificateStoreState) State() out.CertificateStoreState {
150+ s.mu.Lock()
151+ defer s.mu.Unlock()
152+ return s.state
153+}
154+
155+func (s *mockCertificateStoreState) SetState(state out.CertificateStoreState) {
156+ s.mu.Lock()
157+ defer s.mu.Unlock()
158+ s.state = state
159+}
160+
161+func (s *mockCertificateStoreState) SetLoadStateError(err error) {
162+ s.mu.Lock()
163+ defer s.mu.Unlock()
164+ s.loadStateErr = err
165+}
166+
167+func (s *mockCertificateStoreState) SetSaveStateError(err error) {
168+ s.mu.Lock()
169+ defer s.mu.Unlock()
170+ s.saveStateErr = err
171+}
172+
146 func newMockCertificateStore(t *testing.T, initial ...out.StoredCertificate) (*outmocks.MockCertificateStore, *mockCertificateStoreState) {173 func newMockCertificateStore(t *testing.T, initial ...out.StoredCertificate) (*outmocks.MockCertificateStore, *mockCertificateStoreState) {
147 t.Helper()174 t.Helper()
148 state := &mockCertificateStoreState{certs: append([]out.StoredCertificate(nil), initial...)}175 state := &mockCertificateStoreState{certs: append([]out.StoredCertificate(nil), initial...)}
@@ -173,6 +200,27 @@ func newMockCertificateStore(t *testing.T, initial ...out.StoredCertificate) (*o
173 })200 })
174 loadAllCall.Maybe()201 loadAllCall.Maybe()
175 202
203+ loadStateCall := store.EXPECT().LoadState(mock.Anything).RunAndReturn(func(context.Context) (out.CertificateStoreState, error) {
204+ state.mu.Lock()
205+ defer state.mu.Unlock()
206+ if state.loadStateErr != nil {
207+ return out.CertificateStoreState{}, state.loadStateErr
208+ }
209+ return state.state, nil
210+ })
211+ loadStateCall.Maybe()
212+
213+ saveStateCall := store.EXPECT().SaveState(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, storeState out.CertificateStoreState) error {
214+ state.mu.Lock()
215+ defer state.mu.Unlock()
216+ if state.saveStateErr != nil {
217+ return state.saveStateErr
218+ }
219+ state.state = storeState
220+ return nil
221+ })
222+ saveStateCall.Maybe()
223+
176 saveCall := store.EXPECT().Save(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, cert out.StoredCertificate) error {224 saveCall := store.EXPECT().Save(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, cert out.StoredCertificate) error {
177 state.mu.Lock()225 state.mu.Lock()
178 defer state.mu.Unlock()226 defer state.mu.Unlock()
@@ -251,6 +299,266 @@ func generateTestCertPEM(names []string) (certPEM []byte, keyPEM []byte, err err
251 // Tests299 // Tests
252 // ---------------------------------------------------------------------------300 // ---------------------------------------------------------------------------
253 301
302+func TestServiceReconcileLimitsMissingObtainsPerRun(t *testing.T) {
303+ ctx := context.Background()
304+
305+ routes := &fakeRoutes{
306+ routes: []domain.Route{
307+ {Domain: "one.example.com"},
308+ {Domain: "two.example.com"},
309+ {Domain: "three.example.com"},
310+ },
311+ }
312+ issuer, recorder := newMockPublicCertificateIssuer(t, nil, nil)
313+ store, storeState := newMockCertificateStore(t)
314+
315+ cfg := Config{
316+ Enabled: true,
317+ Email: "admin@example.com",
318+ Challenge: "http-01",
319+ HTTPPort: 8088,
320+ TLSPort: 8443,
321+ ObtainBatchSize: 2,
322+ }
323+
324+ svc := NewService(cfg, ServiceDeps{
325+ Config: cfg,
326+ Routes: routes,
327+ Issuer: issuer,
328+ Store: store,
329+ Challenges: NewHTTP01Challenges(),
330+ Effective: EffectiveChallenge{
331+ ConfiguredMode: domain.ACMEChallengeHTTP01,
332+ Mode: domain.ACMEChallengeHTTP01,
333+ TokenSource: domain.ACMETokenSourceNone,
334+ Reason: "http-01 challenge selected",
335+ },
336+ })
337+
338+ require.NoError(t, svc.Load(ctx))
339+ require.NoError(t, svc.Reconcile(ctx))
340+
341+ orders := recorder.Orders()
342+ require.Len(t, orders, 2, "first reconcile should respect configured obtain batch size")
343+ assert.Len(t, storeState.All(), 2)
344+
345+ require.NoError(t, svc.Reconcile(ctx))
346+ orders = recorder.Orders()
347+ require.Len(t, orders, 3, "second reconcile should continue remaining missing targets")
348+ assert.Len(t, storeState.All(), 3)
349+}
350+
351+func TestServiceReconcileRotatesBatchAfterFailedObtain(t *testing.T) {
352+ ctx := context.Background()
353+
354+ routes := &fakeRoutes{
355+ routes: []domain.Route{
356+ {Domain: "b.example.com"},
357+ {Domain: "a.example.com"},
358+ },
359+ }
360+ issuer, recorder := newMockPublicCertificateIssuer(t, func(_ context.Context, order out.CertificateOrder) (*out.StoredCertificate, error) {
361+ if order.ID == "http01-a.example.com" {
362+ return nil, fmt.Errorf("acme validation failed")
363+ }
364+ return defaultStoredCert(order)
365+ }, nil)
366+ store, storeState := newMockCertificateStore(t)
367+
368+ cfg := Config{
369+ Enabled: true,
370+ Email: "admin@example.com",
371+ Challenge: "http-01",
372+ HTTPPort: 8088,
373+ TLSPort: 8443,
374+ ObtainBatchSize: 1,
375+ }
376+
377+ svc := NewService(cfg, ServiceDeps{
378+ Config: cfg,
379+ Routes: routes,
380+ Issuer: issuer,
381+ Store: store,
382+ Challenges: NewHTTP01Challenges(),
383+ Effective: EffectiveChallenge{
384+ ConfiguredMode: domain.ACMEChallengeHTTP01,
385+ Mode: domain.ACMEChallengeHTTP01,
386+ TokenSource: domain.ACMETokenSourceNone,
387+ Reason: "http-01 challenge selected",
388+ },
389+ })
390+
391+ require.NoError(t, svc.Load(ctx))
392+ require.NoError(t, svc.Reconcile(ctx))
393+ require.NoError(t, svc.Reconcile(ctx))
394+
395+ orders := recorder.Orders()
396+ require.Len(t, orders, 2)
397+ assert.Equal(t, "http01-a.example.com", orders[0].ID)
398+ assert.Equal(t, "http01-b.example.com", orders[1].ID, "second reconcile should rotate past the failed first target")
399+
400+ stored := storeState.All()
401+ require.Len(t, stored, 1)
402+ assert.Equal(t, "http01-b.example.com", stored[0].ID)
403+}
404+
405+func TestServiceLoadUsesZeroCursorWhenLoadStateFails(t *testing.T) {
406+ ctx := context.Background()
407+
408+ routes := &fakeRoutes{
409+ routes: []domain.Route{
410+ {Domain: "b.example.com"},
411+ {Domain: "a.example.com"},
412+ },
413+ }
414+ issuer, recorder := newMockPublicCertificateIssuer(t, nil, nil)
415+ store, storeState := newMockCertificateStore(t)
416+ storeState.SetState(out.CertificateStoreState{ObtainCursor: 1})
417+ storeState.SetLoadStateError(fmt.Errorf("state unreadable"))
418+
419+ cfg := Config{
420+ Enabled: true,
421+ Email: "admin@example.com",
422+ Challenge: "http-01",
423+ HTTPPort: 8088,
424+ TLSPort: 8443,
425+ ObtainBatchSize: 1,
426+ }
427+ svc := NewService(cfg, ServiceDeps{
428+ Config: cfg,
429+ Routes: routes,
430+ Issuer: issuer,
431+ Store: store,
432+ Challenges: NewHTTP01Challenges(),
433+ Effective: EffectiveChallenge{
434+ ConfiguredMode: domain.ACMEChallengeHTTP01,
435+ Mode: domain.ACMEChallengeHTTP01,
436+ TokenSource: domain.ACMETokenSourceNone,
437+ Reason: "http-01 challenge selected",
438+ },
439+ })
440+
441+ require.NoError(t, svc.Load(ctx))
442+ require.NoError(t, svc.Reconcile(ctx))
443+
444+ orders := recorder.Orders()
445+ require.Len(t, orders, 1)
446+ assert.Equal(t, "http01-a.example.com", orders[0].ID, "LoadState failure should fall back to the zero cursor")
447+}
448+
449+func TestServiceReconcileUsesInMemoryCursorWhenPersistFails(t *testing.T) {
450+ ctx := context.Background()
451+
452+ routes := &fakeRoutes{
453+ routes: []domain.Route{
454+ {Domain: "b.example.com"},
455+ {Domain: "a.example.com"},
456+ },
457+ }
458+ issuer, recorder := newMockPublicCertificateIssuer(t, func(_ context.Context, order out.CertificateOrder) (*out.StoredCertificate, error) {
459+ if order.ID == "http01-a.example.com" {
460+ return nil, fmt.Errorf("acme validation failed")
461+ }
462+ return defaultStoredCert(order)
463+ }, nil)
464+ store, storeState := newMockCertificateStore(t)
465+
466+ cfg := Config{
467+ Enabled: true,
468+ Email: "admin@example.com",
469+ Challenge: "http-01",
470+ HTTPPort: 8088,
471+ TLSPort: 8443,
472+ ObtainBatchSize: 1,
473+ }
474+ svc := NewService(cfg, ServiceDeps{
475+ Config: cfg,
476+ Routes: routes,
477+ Issuer: issuer,
478+ Store: store,
479+ Challenges: NewHTTP01Challenges(),
480+ Effective: EffectiveChallenge{
481+ ConfiguredMode: domain.ACMEChallengeHTTP01,
482+ Mode: domain.ACMEChallengeHTTP01,
483+ TokenSource: domain.ACMETokenSourceNone,
484+ Reason: "http-01 challenge selected",
485+ },
486+ })
487+
488+ storeState.SetSaveStateError(fmt.Errorf("disk full"))
489+ require.NoError(t, svc.Load(ctx))
490+ require.NoError(t, svc.Reconcile(ctx))
491+ require.NoError(t, svc.Reconcile(ctx))
492+ assert.Equal(t, out.CertificateStoreState{}, storeState.State(), "failed SaveState should leave persisted state unchanged")
493+
494+ orders := recorder.Orders()
495+ require.Len(t, orders, 2)
496+ assert.Equal(t, "http01-a.example.com", orders[0].ID)
497+ assert.Equal(t, "http01-b.example.com", orders[1].ID, "SaveState failure should still advance the in-memory cursor")
498+
499+ stored := storeState.All()
500+ require.Len(t, stored, 1)
501+ assert.Equal(t, "http01-b.example.com", stored[0].ID)
502+}
503+
504+func TestServiceReconcilePersistsBatchCursorAcrossRestart(t *testing.T) {
505+ ctx := context.Background()
506+
507+ routes := &fakeRoutes{
508+ routes: []domain.Route{
509+ {Domain: "b.example.com"},
510+ {Domain: "a.example.com"},
511+ },
512+ }
513+ issuer, recorder := newMockPublicCertificateIssuer(t, func(_ context.Context, order out.CertificateOrder) (*out.StoredCertificate, error) {
514+ if order.ID == "http01-a.example.com" {
515+ return nil, fmt.Errorf("acme validation failed")
516+ }
517+ return defaultStoredCert(order)
518+ }, nil)
519+ store, storeState := newMockCertificateStore(t)
520+
521+ cfg := Config{
522+ Enabled: true,
523+ Email: "admin@example.com",
524+ Challenge: "http-01",
525+ HTTPPort: 8088,
526+ TLSPort: 8443,
527+ ObtainBatchSize: 1,
528+ }
529+ deps := ServiceDeps{
530+ Config: cfg,
531+ Routes: routes,
532+ Issuer: issuer,
533+ Store: store,
534+ Challenges: NewHTTP01Challenges(),
535+ Effective: EffectiveChallenge{
536+ ConfiguredMode: domain.ACMEChallengeHTTP01,
537+ Mode: domain.ACMEChallengeHTTP01,
538+ TokenSource: domain.ACMETokenSourceNone,
539+ Reason: "http-01 challenge selected",
540+ },
541+ }
542+
543+ firstSvc := NewService(cfg, deps)
544+ require.NoError(t, firstSvc.Load(ctx))
545+ require.NoError(t, firstSvc.Reconcile(ctx))
546+ assert.Equal(t, out.CertificateStoreState{ObtainCursor: 1}, storeState.State())
547+
548+ secondSvc := NewService(cfg, deps)
549+ require.NoError(t, secondSvc.Load(ctx))
550+ require.NoError(t, secondSvc.Reconcile(ctx))
551+
552+ orders := recorder.Orders()
553+ require.Len(t, orders, 2)
554+ assert.Equal(t, "http01-a.example.com", orders[0].ID)
555+ assert.Equal(t, "http01-b.example.com", orders[1].ID, "restart should resume from the persisted obtain cursor")
556+
557+ stored := storeState.All()
558+ require.Len(t, stored, 1)
559+ assert.Equal(t, "http01-b.example.com", stored[0].ID)
560+}
561+
254 func TestServiceReconcileObtainsMissingHTTP01Cert(t *testing.T) {562 func TestServiceReconcileObtainsMissingHTTP01Cert(t *testing.T) {
255 ctx := context.Background()563 ctx := context.Background()
256 564

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