detached 90ab417

Simplify Gordon onboarding routes

commit
90ab417b001c60e840c6daddcc56ec5b88bb6e2e
author
brice · · 2026-05-01 10:38 UTC
parent
d200400
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
12 files · +450 -74
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/server.md +2 -3
@@ -110,7 +110,7 @@ Gordon limits new ACME certificate orders to `obtain_batch_size` per reconcile r
110110
111111 If the initial ACME reconcile fails (e.g. due to a transient network error or misconfiguration), Gordon logs the failure but still starts the renewal loop for already cached certificates. Operators should fix the underlying error and restart/reload Gordon to trigger a new obtain attempt for missing certificates.
112112
113-For Cloudflare Full/Strict, Cloudflare terminates browser TLS at the edge and connects to Gordon over HTTPS. A public ACME certificate served by Gordon is the preferred origin certificate because Cloudflare Strict can validate it without custom origin trust. Cloudflare Flexible mode (HTTPS at the edge, HTTP to Gordon) remains a legacy/compatibility option, but it is not end-to-end HTTPS.
113+For Cloudflare Full/Strict, Cloudflare terminates browser TLS at the edge and connects to Gordon over HTTPS. A public ACME certificate served by Gordon is the preferred origin certificate because Cloudflare Strict can validate it without custom origin trust. Cloudflare Flexible mode (HTTPS at the edge, HTTP to Gordon) is not end-to-end HTTPS.
114114
115115 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.
116116
@@ -119,13 +119,12 @@ Static certificates have priority, then public ACME certificates, then Gordon's
119119 When `tls_port` is non-zero, direct HTTP clients (those not arriving through a trusted proxy) are restricted to CA onboarding paths only:
120120
121121 - `/.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
122-- `/`, `/ca`, `/ca.crt`, `/ca.mobileconfig` — legacy compatibility aliases for direct HTTP onboarding
123122 - `/.well-known/acme-challenge/*` — serves ACME HTTP-01 challenges when public ACME HTTP-01 is enabled, otherwise returns `404 Not Found`
124123 - All other paths return `403 Forbidden`
125124
126125 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.
127126
128-On HTTPS, onboarding paths are served only on `server.gordon_domain`; app hosts can use `/ca` for their own routes without Gordon intercepting it. If `gordon_domain` is empty, HTTPS onboarding paths are not served at all to avoid intercepting app hosts.
127+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.
129128
130129 #### HTTP to HTTPS redirect
131130
@@ -110,7 +110,7 @@ Gordon limits new ACME certificate orders to `obtain_batch_size` per reconcile r
110 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.111 If the initial ACME reconcile fails (e.g. due to a transient network error or misconfiguration), Gordon logs the failure but still starts the renewal loop for already cached certificates. Operators should fix the underlying error and restart/reload Gordon to trigger a new obtain attempt for missing certificates.
112 112
113-For Cloudflare Full/Strict, Cloudflare terminates browser TLS at the edge and connects to Gordon over HTTPS. A public ACME certificate served by Gordon is the preferred origin certificate because Cloudflare Strict can validate it without custom origin trust. Cloudflare Flexible mode (HTTPS at the edge, HTTP to Gordon) remains a legacy/compatibility option, but it is not end-to-end HTTPS.113+For Cloudflare Full/Strict, Cloudflare terminates browser TLS at the edge and connects to Gordon over HTTPS. A public ACME certificate served by Gordon is the preferred origin certificate because Cloudflare Strict can validate it without custom origin trust. Cloudflare Flexible mode (HTTPS at the edge, HTTP to Gordon) is not end-to-end HTTPS.
114 114
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.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.
116 116
@@ -119,13 +119,12 @@ Static certificates have priority, then public ACME certificates, then Gordon's
119 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:
120 120
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 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
122-- `/`, `/ca`, `/ca.crt`, `/ca.mobileconfig` — legacy compatibility aliases for direct HTTP onboarding
123 - `/.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`
124 - All other paths return `403 Forbidden`123 - All other paths return `403 Forbidden`
125 124
126 This lets new clients discover and trust the internal CA over plain HTTP without exposing the full application. Trusted proxy traffic (e.g. from Cloudflare) continues through the normal HTTP proxy path unaffected.125 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.
127 126
128-On HTTPS, onboarding paths are served only on `server.gordon_domain`; app hosts can use `/ca` for their own routes without Gordon intercepting it. If `gordon_domain` is empty, HTTPS onboarding paths are not served at all to avoid intercepting app hosts.127+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.
129 128
130 #### HTTP to HTTPS redirect129 #### HTTP to HTTPS redirect
131 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 +16 -16
@@ -31,22 +31,22 @@ 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/ca", h.ServeOnboardingPage)
35+ mux.HandleFunc("HEAD /.well-known/gordon/ca", h.ServeOnboardingPage)
36+ mux.HandleFunc("GET /.well-known/gordon/ca.crt", h.ServeCACert)
37+ mux.HandleFunc("HEAD /.well-known/gordon/ca.crt", h.ServeCACert)
38+ mux.HandleFunc("GET /.well-known/gordon/ca.mobileconfig", h.ServeMobileconfig)
39+ mux.HandleFunc("HEAD /.well-known/gordon/ca.mobileconfig", h.ServeMobileconfig)
4040
4141 srv := httptest.NewServer(mux)
4242 t.Cleanup(srv.Close)
4343 return srv, rootPEM, mobileconfig
4444 }
4545
46-// getOnboardingBody issues a GET /ca with a custom Host header and returns the body.
46+// getOnboardingBody issues a GET /.well-known/gordon/ca with a custom Host header and returns the body.
4747 func getOnboardingBody(t *testing.T, srv *httptest.Server, host string) string {
4848 t.Helper()
49- req, err := http.NewRequest(http.MethodGet, srv.URL+"/ca", nil)
49+ req, err := http.NewRequest(http.MethodGet, srv.URL+"/.well-known/gordon/ca", nil)
5050 require.NoError(t, err)
5151 req.Host = host
5252
@@ -63,7 +63,7 @@ func getOnboardingBody(t *testing.T, srv *httptest.Server, host string) string {
6363 func TestHandler_CACert(t *testing.T) {
6464 srv, rootPEM, _ := newTestServer(t)
6565
66- resp, err := http.Get(srv.URL + "/ca.crt")
66+ resp, err := http.Get(srv.URL + "/.well-known/gordon/ca.crt")
6767 require.NoError(t, err)
6868 defer resp.Body.Close()
6969
@@ -78,7 +78,7 @@ func TestHandler_CACert(t *testing.T) {
7878 func TestHandler_Mobileconfig(t *testing.T) {
7979 srv, _, _ := newTestServer(t)
8080
81- resp, err := http.Get(srv.URL + "/ca.mobileconfig")
81+ resp, err := http.Get(srv.URL + "/.well-known/gordon/ca.mobileconfig")
8282 require.NoError(t, err)
8383 defer resp.Body.Close()
8484
@@ -93,7 +93,7 @@ func TestHandler_Mobileconfig(t *testing.T) {
9393 func TestHandler_OnboardingPage(t *testing.T) {
9494 srv, _, _ := newTestServer(t)
9595
96- resp, err := http.Get(srv.URL + "/ca")
96+ resp, err := http.Get(srv.URL + "/.well-known/gordon/ca")
9797 require.NoError(t, err)
9898 defer resp.Body.Close()
9999
@@ -103,8 +103,8 @@ func TestHandler_OnboardingPage(t *testing.T) {
103103 assert.Equal(t, http.StatusOK, resp.StatusCode)
104104 assert.Equal(t, "text/html; charset=utf-8", resp.Header.Get("Content-Type"))
105105 assert.Contains(t, string(body), "Gordon")
106- assert.Contains(t, string(body), "/ca.crt")
107- assert.Contains(t, string(body), "/ca.mobileconfig")
106+ assert.Contains(t, string(body), "/.well-known/gordon/ca.crt")
107+ assert.Contains(t, string(body), "/.well-known/gordon/ca.mobileconfig")
108108 }
109109
110110 func TestHandler_OnboardingPage_DefaultHTTPSURLOmitsInternalTLSPort(t *testing.T) {
@@ -164,7 +164,7 @@ func TestHandler_OnboardingPage_GoToSiteLinkIsPlainLink(t *testing.T) {
164164 func TestHandler_OnboardingPage_HEAD(t *testing.T) {
165165 srv, _, _ := newTestServer(t)
166166
167- req, err := http.NewRequest(http.MethodHead, srv.URL+"/ca", nil)
167+ req, err := http.NewRequest(http.MethodHead, srv.URL+"/.well-known/gordon/ca", nil)
168168 require.NoError(t, err)
169169
170170 resp, err := http.DefaultClient.Do(req)
@@ -182,7 +182,7 @@ func TestHandler_OnboardingPage_HEAD(t *testing.T) {
182182 func TestHandler_CACert_HEAD(t *testing.T) {
183183 srv, _, _ := newTestServer(t)
184184
185- req, err := http.NewRequest(http.MethodHead, srv.URL+"/ca.crt", nil)
185+ req, err := http.NewRequest(http.MethodHead, srv.URL+"/.well-known/gordon/ca.crt", nil)
186186 require.NoError(t, err)
187187
188188 resp, err := http.DefaultClient.Do(req)
@@ -200,7 +200,7 @@ func TestHandler_CACert_HEAD(t *testing.T) {
200200 func TestHandler_Mobileconfig_HEAD(t *testing.T) {
201201 srv, _, _ := newTestServer(t)
202202
203- req, err := http.NewRequest(http.MethodHead, srv.URL+"/ca.mobileconfig", nil)
203+ req, err := http.NewRequest(http.MethodHead, srv.URL+"/.well-known/gordon/ca.mobileconfig", nil)
204204 require.NoError(t, err)
205205
206206 resp, err := http.DefaultClient.Do(req)
@@ -31,22 +31,22 @@ 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/ca", h.ServeOnboardingPage)
35- mux.HandleFunc("HEAD /ca", h.ServeOnboardingPage)35+ mux.HandleFunc("HEAD /.well-known/gordon/ca", h.ServeOnboardingPage)
36- mux.HandleFunc("GET /ca.crt", h.ServeCACert)36+ mux.HandleFunc("GET /.well-known/gordon/ca.crt", h.ServeCACert)
37- mux.HandleFunc("HEAD /ca.crt", h.ServeCACert)37+ mux.HandleFunc("HEAD /.well-known/gordon/ca.crt", h.ServeCACert)
38- mux.HandleFunc("GET /ca.mobileconfig", h.ServeMobileconfig)38+ mux.HandleFunc("GET /.well-known/gordon/ca.mobileconfig", h.ServeMobileconfig)
39- mux.HandleFunc("HEAD /ca.mobileconfig", h.ServeMobileconfig)39+ mux.HandleFunc("HEAD /.well-known/gordon/ca.mobileconfig", h.ServeMobileconfig)
40 40
41 srv := httptest.NewServer(mux)41 srv := httptest.NewServer(mux)
42 t.Cleanup(srv.Close)42 t.Cleanup(srv.Close)
43 return srv, rootPEM, mobileconfig43 return srv, rootPEM, mobileconfig
44 }44 }
45 45
46-// getOnboardingBody issues a GET /ca with a custom Host header and returns the body.46+// 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 {47 func getOnboardingBody(t *testing.T, srv *httptest.Server, host string) string {
48 t.Helper()48 t.Helper()
49- req, err := http.NewRequest(http.MethodGet, srv.URL+"/ca", nil)49+ req, err := http.NewRequest(http.MethodGet, srv.URL+"/.well-known/gordon/ca", nil)
50 require.NoError(t, err)50 require.NoError(t, err)
51 req.Host = host51 req.Host = host
52 52
@@ -63,7 +63,7 @@ func getOnboardingBody(t *testing.T, srv *httptest.Server, host string) string {
63 func TestHandler_CACert(t *testing.T) {63 func TestHandler_CACert(t *testing.T) {
64 srv, rootPEM, _ := newTestServer(t)64 srv, rootPEM, _ := newTestServer(t)
65 65
66- resp, err := http.Get(srv.URL + "/ca.crt")66+ resp, err := http.Get(srv.URL + "/.well-known/gordon/ca.crt")
67 require.NoError(t, err)67 require.NoError(t, err)
68 defer resp.Body.Close()68 defer resp.Body.Close()
69 69
@@ -78,7 +78,7 @@ func TestHandler_CACert(t *testing.T) {
78 func TestHandler_Mobileconfig(t *testing.T) {78 func TestHandler_Mobileconfig(t *testing.T) {
79 srv, _, _ := newTestServer(t)79 srv, _, _ := newTestServer(t)
80 80
81- resp, err := http.Get(srv.URL + "/ca.mobileconfig")81+ resp, err := http.Get(srv.URL + "/.well-known/gordon/ca.mobileconfig")
82 require.NoError(t, err)82 require.NoError(t, err)
83 defer resp.Body.Close()83 defer resp.Body.Close()
84 84
@@ -93,7 +93,7 @@ func TestHandler_Mobileconfig(t *testing.T) {
93 func TestHandler_OnboardingPage(t *testing.T) {93 func TestHandler_OnboardingPage(t *testing.T) {
94 srv, _, _ := newTestServer(t)94 srv, _, _ := newTestServer(t)
95 95
96- resp, err := http.Get(srv.URL + "/ca")96+ resp, err := http.Get(srv.URL + "/.well-known/gordon/ca")
97 require.NoError(t, err)97 require.NoError(t, err)
98 defer resp.Body.Close()98 defer resp.Body.Close()
99 99
@@ -103,8 +103,8 @@ func TestHandler_OnboardingPage(t *testing.T) {
103 assert.Equal(t, http.StatusOK, resp.StatusCode)103 assert.Equal(t, http.StatusOK, resp.StatusCode)
104 assert.Equal(t, "text/html; charset=utf-8", resp.Header.Get("Content-Type"))104 assert.Equal(t, "text/html; charset=utf-8", resp.Header.Get("Content-Type"))
105 assert.Contains(t, string(body), "Gordon")105 assert.Contains(t, string(body), "Gordon")
106- assert.Contains(t, string(body), "/ca.crt")106+ assert.Contains(t, string(body), "/.well-known/gordon/ca.crt")
107- assert.Contains(t, string(body), "/ca.mobileconfig")107+ assert.Contains(t, string(body), "/.well-known/gordon/ca.mobileconfig")
108 }108 }
109 109
110 func TestHandler_OnboardingPage_DefaultHTTPSURLOmitsInternalTLSPort(t *testing.T) {110 func TestHandler_OnboardingPage_DefaultHTTPSURLOmitsInternalTLSPort(t *testing.T) {
@@ -164,7 +164,7 @@ func TestHandler_OnboardingPage_GoToSiteLinkIsPlainLink(t *testing.T) {
164 func TestHandler_OnboardingPage_HEAD(t *testing.T) {164 func TestHandler_OnboardingPage_HEAD(t *testing.T) {
165 srv, _, _ := newTestServer(t)165 srv, _, _ := newTestServer(t)
166 166
167- req, err := http.NewRequest(http.MethodHead, srv.URL+"/ca", nil)167+ req, err := http.NewRequest(http.MethodHead, srv.URL+"/.well-known/gordon/ca", nil)
168 require.NoError(t, err)168 require.NoError(t, err)
169 169
170 resp, err := http.DefaultClient.Do(req)170 resp, err := http.DefaultClient.Do(req)
@@ -182,7 +182,7 @@ func TestHandler_OnboardingPage_HEAD(t *testing.T) {
182 func TestHandler_CACert_HEAD(t *testing.T) {182 func TestHandler_CACert_HEAD(t *testing.T) {
183 srv, _, _ := newTestServer(t)183 srv, _, _ := newTestServer(t)
184 184
185- req, err := http.NewRequest(http.MethodHead, srv.URL+"/ca.crt", nil)185+ req, err := http.NewRequest(http.MethodHead, srv.URL+"/.well-known/gordon/ca.crt", nil)
186 require.NoError(t, err)186 require.NoError(t, err)
187 187
188 resp, err := http.DefaultClient.Do(req)188 resp, err := http.DefaultClient.Do(req)
@@ -200,7 +200,7 @@ func TestHandler_CACert_HEAD(t *testing.T) {
200 func TestHandler_Mobileconfig_HEAD(t *testing.T) {200 func TestHandler_Mobileconfig_HEAD(t *testing.T) {
201 srv, _, _ := newTestServer(t)201 srv, _, _ := newTestServer(t)
202 202
203- req, err := http.NewRequest(http.MethodHead, srv.URL+"/ca.mobileconfig", nil)203+ req, err := http.NewRequest(http.MethodHead, srv.URL+"/.well-known/gordon/ca.mobileconfig", nil)
204 require.NoError(t, err)204 require.NoError(t, err)
205 205
206 resp, err := http.DefaultClient.Do(req)206 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/run.go +2 -9
@@ -2105,9 +2105,6 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger, accessWr
21052105 registerOnboardingRoutes(onboardingMux, obHandler)
21062106 // Register onboarding paths host-gated so normal traffic hits
21072107 // proxyHandler directly through the catch-all / pattern.
2108- httpsMux.Handle("GET /ca", gordonDomainOnboardingGate(gordonDomain, onboardingMux, proxyHandler))
2109- httpsMux.Handle("GET /ca.crt", gordonDomainOnboardingGate(gordonDomain, onboardingMux, proxyHandler))
2110- httpsMux.Handle("GET /ca.mobileconfig", gordonDomainOnboardingGate(gordonDomain, onboardingMux, proxyHandler))
21112108 httpsMux.Handle("GET /.well-known/gordon/", gordonDomainOnboardingGate(gordonDomain, onboardingMux, proxyHandler))
21122109 httpsMux.Handle("GET /.well-known/gordon/ca", gordonDomainOnboardingGate(gordonDomain, onboardingMux, proxyHandler))
21132110 httpsMux.Handle("GET /.well-known/gordon/ca.crt", gordonDomainOnboardingGate(gordonDomain, onboardingMux, proxyHandler))
@@ -2133,17 +2130,13 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger, accessWr
21332130 return registryOut, proxyOut, httpsOut
21342131 }
21352132
2136-// registerOnboardingRoutes registers all CA onboarding HTTP routes on the
2137-// given mux. Both direct-HTTP and Gordon-domain HTTPS onboarding use this.
2133+// registerOnboardingRoutes registers CA onboarding well-known HTTP routes on
2134+// the given mux. Both direct-HTTP and Gordon-domain HTTPS onboarding use this.
21382135 func registerOnboardingRoutes(mux *http.ServeMux, ob *onboarding.Handler) {
2139- mux.HandleFunc("GET /{$}", ob.ServeOnboardingPage)
21402136 mux.HandleFunc("GET /.well-known/gordon/", ob.ServeOnboardingPage)
21412137 mux.HandleFunc("GET /.well-known/gordon/ca", ob.ServeOnboardingPage)
21422138 mux.HandleFunc("GET /.well-known/gordon/ca.crt", ob.ServeCACert)
21432139 mux.HandleFunc("GET /.well-known/gordon/ca.mobileconfig", ob.ServeMobileconfig)
2144- mux.HandleFunc("GET /ca", ob.ServeOnboardingPage)
2145- mux.HandleFunc("GET /ca.crt", ob.ServeCACert)
2146- mux.HandleFunc("GET /ca.mobileconfig", ob.ServeMobileconfig)
21472140 }
21482141
21492142 // directHTTPOnboardingGate returns an http.Handler that splits HTTP traffic
@@ -2105,9 +2105,6 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger, accessWr
2105 registerOnboardingRoutes(onboardingMux, obHandler)2105 registerOnboardingRoutes(onboardingMux, obHandler)
2106 // Register onboarding paths host-gated so normal traffic hits2106 // Register onboarding paths host-gated so normal traffic hits
2107 // proxyHandler directly through the catch-all / pattern.2107 // proxyHandler directly through the catch-all / pattern.
2108- httpsMux.Handle("GET /ca", gordonDomainOnboardingGate(gordonDomain, onboardingMux, proxyHandler))
2109- httpsMux.Handle("GET /ca.crt", gordonDomainOnboardingGate(gordonDomain, onboardingMux, proxyHandler))
2110- httpsMux.Handle("GET /ca.mobileconfig", gordonDomainOnboardingGate(gordonDomain, onboardingMux, proxyHandler))
2111 httpsMux.Handle("GET /.well-known/gordon/", gordonDomainOnboardingGate(gordonDomain, onboardingMux, proxyHandler))2108 httpsMux.Handle("GET /.well-known/gordon/", gordonDomainOnboardingGate(gordonDomain, onboardingMux, proxyHandler))
2112 httpsMux.Handle("GET /.well-known/gordon/ca", gordonDomainOnboardingGate(gordonDomain, onboardingMux, proxyHandler))2109 httpsMux.Handle("GET /.well-known/gordon/ca", gordonDomainOnboardingGate(gordonDomain, onboardingMux, proxyHandler))
2113 httpsMux.Handle("GET /.well-known/gordon/ca.crt", gordonDomainOnboardingGate(gordonDomain, onboardingMux, proxyHandler))2110 httpsMux.Handle("GET /.well-known/gordon/ca.crt", gordonDomainOnboardingGate(gordonDomain, onboardingMux, proxyHandler))
@@ -2133,17 +2130,13 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger, accessWr
2133 return registryOut, proxyOut, httpsOut2130 return registryOut, proxyOut, httpsOut
2134 }2131 }
2135 2132
2136-// registerOnboardingRoutes registers all CA onboarding HTTP routes on the2133+// registerOnboardingRoutes registers CA onboarding well-known HTTP routes on
2137-// given mux. Both direct-HTTP and Gordon-domain HTTPS onboarding use this.2134+// the given mux. Both direct-HTTP and Gordon-domain HTTPS onboarding use this.
2138 func registerOnboardingRoutes(mux *http.ServeMux, ob *onboarding.Handler) {2135 func registerOnboardingRoutes(mux *http.ServeMux, ob *onboarding.Handler) {
2139- mux.HandleFunc("GET /{$}", ob.ServeOnboardingPage)
2140 mux.HandleFunc("GET /.well-known/gordon/", ob.ServeOnboardingPage)2136 mux.HandleFunc("GET /.well-known/gordon/", ob.ServeOnboardingPage)
2141 mux.HandleFunc("GET /.well-known/gordon/ca", ob.ServeOnboardingPage)2137 mux.HandleFunc("GET /.well-known/gordon/ca", ob.ServeOnboardingPage)
2142 mux.HandleFunc("GET /.well-known/gordon/ca.crt", ob.ServeCACert)2138 mux.HandleFunc("GET /.well-known/gordon/ca.crt", ob.ServeCACert)
2143 mux.HandleFunc("GET /.well-known/gordon/ca.mobileconfig", ob.ServeMobileconfig)2139 mux.HandleFunc("GET /.well-known/gordon/ca.mobileconfig", ob.ServeMobileconfig)
2144- mux.HandleFunc("GET /ca", ob.ServeOnboardingPage)
2145- mux.HandleFunc("GET /ca.crt", ob.ServeCACert)
2146- mux.HandleFunc("GET /ca.mobileconfig", ob.ServeMobileconfig)
2147 }2140 }
2148 2141
2149 // directHTTPOnboardingGate returns an http.Handler that splits HTTP traffic2142 // directHTTPOnboardingGate returns an http.Handler that splits HTTP traffic
M internal/app/run_http_handlers_test.go +32 -21
@@ -145,15 +145,15 @@ func TestCreateHTTPHandlers_DirectHTTPOnboarding_AllPaths(t *testing.T) {
145145 wantContent string // substring check
146146 wantType string // Content-Type substring
147147 }{
148- {name: "root serves onboarding", path: "/", wantCode: http.StatusOK, wantContent: "Trust CA Certificate", wantType: "text/html"},
149148 {name: ".well-known/gordon/ serves onboarding", path: "/.well-known/gordon/", 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: "/ca.crt serves CA cert", path: "/ca.crt", wantCode: http.StatusOK, wantContent: "BEGIN CERTIFICATE", wantType: "application/x-x509-ca-cert"},
152- {name: "unknown path returns forbidden", path: "/web", wantCode: http.StatusForbidden, wantContent: "Only certificate onboarding is available", wantType: "text/plain"},
153149 {name: ".well-known/gordon/ca serves onboarding", path: "/.well-known/gordon/ca", wantCode: http.StatusOK, wantContent: "Trust CA Certificate", wantType: "text/html"},
154- {name: "/ca serves onboarding", path: "/ca", wantCode: http.StatusOK, wantContent: "Trust CA Certificate", wantType: "text/html"},
155- {name: "/ca.mobileconfig serves mobileconfig", path: "/ca.mobileconfig", wantCode: http.StatusOK, wantContent: "<?xml", wantType: "application/x-apple-aspen-config"},
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"},
156151 {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"},
157157 }
158158
159159 for _, tc := range tests {
@@ -201,7 +201,7 @@ func TestCreateHTTPHandlers_TrustedProxyHTTP_DoesNotServeOnboarding(t *testing.T
201201 assert.NotContains(t, rec.Body.String(), "Trust CA Certificate")
202202 }
203203
204-func TestCreateHTTPHandlers_TrustedProxyCACertPath_DoesNotServeOnboardingResource(t *testing.T) {
204+func TestCreateHTTPHandlers_TrustedProxyWellKnownCACertPath_DoesNotServeOnboardingResource(t *testing.T) {
205205 t.Parallel()
206206 ca := newTestCA(t)
207207 cfg := newOnboardingConfig()
@@ -209,7 +209,7 @@ func TestCreateHTTPHandlers_TrustedProxyCACertPath_DoesNotServeOnboardingResourc
209209
210210 _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil)
211211
212- req := httptest.NewRequest(http.MethodGet, "/ca.crt", nil)
212+ req := httptest.NewRequest(http.MethodGet, "/.well-known/gordon/ca.crt", nil)
213213 req.RemoteAddr = trustedProxyAddr
214214 req.Host = "app.example.com"
215215 rec := httptest.NewRecorder()
@@ -229,7 +229,7 @@ func TestCreateHTTPHandlers_ForceHTTPSRedirect_DoesNotBypassDirectOnboarding(t *
229229
230230 _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil)
231231
232- req := httptest.NewRequest(http.MethodGet, "/", nil)
232+ req := httptest.NewRequest(http.MethodGet, "/.well-known/gordon/", nil)
233233 req.RemoteAddr = directAddr
234234 req.Host = "o2.bnema.dev"
235235 rec := httptest.NewRecorder()
@@ -249,14 +249,16 @@ func TestCreateHTTPHandlers_HTTPSOnboarding_RemainsAvailableOnGordonDomain(t *te
249249
250250 _, _, httpsHandler := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil)
251251
252- req := httptest.NewRequest(http.MethodGet, "/ca", nil)
253- req.RemoteAddr = directAddr
254- req.Host = "gordon.example.com"
255- rec := httptest.NewRecorder()
256- httpsHandler.ServeHTTP(rec, req)
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)
257258
258- assert.Equal(t, http.StatusOK, rec.Code)
259- assert.Contains(t, rec.Body.String(), "Trust CA Certificate")
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+ }
260262 }
261263
262264 func TestCreateHTTPHandlers_HTTPSOnboarding_DoesNotPreemptAppHostCAPath(t *testing.T) {
@@ -268,7 +270,7 @@ func TestCreateHTTPHandlers_HTTPSOnboarding_DoesNotPreemptAppHostCAPath(t *testi
268270
269271 _, _, httpsHandler := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil)
270272
271- req := httptest.NewRequest(http.MethodGet, "/ca", nil)
273+ req := httptest.NewRequest(http.MethodGet, "/.well-known/gordon/ca", nil)
272274 req.RemoteAddr = directAddr
273275 req.Host = "app.example.com"
274276 rec := httptest.NewRecorder()
@@ -276,6 +278,15 @@ func TestCreateHTTPHandlers_HTTPSOnboarding_DoesNotPreemptAppHostCAPath(t *testi
276278
277279 assert.Equal(t, http.StatusNotFound, rec.Code)
278280 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")
279290 }
280291
281292 func TestCreateHTTPHandlers_HTTPSOnboarding_HEADRoutesRemainAvailable(t *testing.T) {
@@ -292,10 +303,10 @@ func TestCreateHTTPHandlers_HTTPSOnboarding_HEADRoutesRemainAvailable(t *testing
292303 ts := httptest.NewServer(httpsHandler)
293304 defer ts.Close()
294305
295- 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"} {
296307 req, err := http.NewRequest(http.MethodHead, ts.URL+path, nil)
297308 require.NoError(t, err)
298- req.Host = "gordon.example.com"
309+ req.Host = "Gordon.Example.Com.:8443"
299310 resp, err := ts.Client().Do(req)
300311 require.NoError(t, err)
301312
@@ -327,8 +338,8 @@ func TestCreateHTTPHandlers_DirectHTTPOnboarding_HEADRoutesRemainAvailable(t *te
327338 path string
328339 contentType string
329340 }{
330- {path: "/ca", contentType: "text/html"},
331- {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"},
332343 } {
333344 resp, err := ts.Client().Head(ts.URL + tc.path)
334345 require.NoError(t, err)
@@ -145,15 +145,15 @@ func TestCreateHTTPHandlers_DirectHTTPOnboarding_AllPaths(t *testing.T) {
145 wantContent string // substring check145 wantContent string // substring check
146 wantType string // Content-Type substring146 wantType string // Content-Type substring
147 }{147 }{
148- {name: "root serves onboarding", path: "/", wantCode: http.StatusOK, wantContent: "Trust CA Certificate", wantType: "text/html"},
149 {name: ".well-known/gordon/ serves onboarding", path: "/.well-known/gordon/", wantCode: http.StatusOK, wantContent: "Trust CA Certificate", wantType: "text/html"},148 {name: ".well-known/gordon/ serves onboarding", path: "/.well-known/gordon/", 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: "/ca.crt serves CA cert", path: "/ca.crt", wantCode: http.StatusOK, wantContent: "BEGIN CERTIFICATE", wantType: "application/x-x509-ca-cert"},
152- {name: "unknown path returns forbidden", path: "/web", wantCode: http.StatusForbidden, wantContent: "Only certificate onboarding is available", wantType: "text/plain"},
153 {name: ".well-known/gordon/ca serves onboarding", path: "/.well-known/gordon/ca", 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"},
154- {name: "/ca serves onboarding", path: "/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"},
155- {name: "/ca.mobileconfig serves mobileconfig", path: "/ca.mobileconfig", wantCode: http.StatusOK, wantContent: "<?xml", wantType: "application/x-apple-aspen-config"},
156 {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"},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 }157 }
158 158
159 for _, tc := range tests {159 for _, tc := range tests {
@@ -201,7 +201,7 @@ func TestCreateHTTPHandlers_TrustedProxyHTTP_DoesNotServeOnboarding(t *testing.T
201 assert.NotContains(t, rec.Body.String(), "Trust CA Certificate")201 assert.NotContains(t, rec.Body.String(), "Trust CA Certificate")
202 }202 }
203 203
204-func TestCreateHTTPHandlers_TrustedProxyCACertPath_DoesNotServeOnboardingResource(t *testing.T) {204+func TestCreateHTTPHandlers_TrustedProxyWellKnownCACertPath_DoesNotServeOnboardingResource(t *testing.T) {
205 t.Parallel()205 t.Parallel()
206 ca := newTestCA(t)206 ca := newTestCA(t)
207 cfg := newOnboardingConfig()207 cfg := newOnboardingConfig()
@@ -209,7 +209,7 @@ func TestCreateHTTPHandlers_TrustedProxyCACertPath_DoesNotServeOnboardingResourc
209 209
210 _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil)210 _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil)
211 211
212- req := httptest.NewRequest(http.MethodGet, "/ca.crt", nil)212+ req := httptest.NewRequest(http.MethodGet, "/.well-known/gordon/ca.crt", nil)
213 req.RemoteAddr = trustedProxyAddr213 req.RemoteAddr = trustedProxyAddr
214 req.Host = "app.example.com"214 req.Host = "app.example.com"
215 rec := httptest.NewRecorder()215 rec := httptest.NewRecorder()
@@ -229,7 +229,7 @@ func TestCreateHTTPHandlers_ForceHTTPSRedirect_DoesNotBypassDirectOnboarding(t *
229 229
230 _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil)230 _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil)
231 231
232- req := httptest.NewRequest(http.MethodGet, "/", nil)232+ req := httptest.NewRequest(http.MethodGet, "/.well-known/gordon/", nil)
233 req.RemoteAddr = directAddr233 req.RemoteAddr = directAddr
234 req.Host = "o2.bnema.dev"234 req.Host = "o2.bnema.dev"
235 rec := httptest.NewRecorder()235 rec := httptest.NewRecorder()
@@ -249,14 +249,16 @@ func TestCreateHTTPHandlers_HTTPSOnboarding_RemainsAvailableOnGordonDomain(t *te
249 249
250 _, _, httpsHandler := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil)250 _, _, httpsHandler := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil)
251 251
252- req := httptest.NewRequest(http.MethodGet, "/ca", nil)252+ for _, host := range []string{"gordon.example.com", "Gordon.Example.Com.:8443"} {
253- req.RemoteAddr = directAddr253+ req := httptest.NewRequest(http.MethodGet, "/.well-known/gordon/ca", nil)
254- req.Host = "gordon.example.com"254+ req.RemoteAddr = directAddr
255- rec := httptest.NewRecorder()255+ req.Host = host
256- httpsHandler.ServeHTTP(rec, req)256+ rec := httptest.NewRecorder()
257+ httpsHandler.ServeHTTP(rec, req)
257 258
258- assert.Equal(t, http.StatusOK, rec.Code)259+ assert.Equal(t, http.StatusOK, rec.Code, "host %s", host)
259- assert.Contains(t, rec.Body.String(), "Trust CA Certificate")260+ assert.Contains(t, rec.Body.String(), "Trust CA Certificate", "host %s", host)
261+ }
260 }262 }
261 263
262 func TestCreateHTTPHandlers_HTTPSOnboarding_DoesNotPreemptAppHostCAPath(t *testing.T) {264 func TestCreateHTTPHandlers_HTTPSOnboarding_DoesNotPreemptAppHostCAPath(t *testing.T) {
@@ -268,7 +270,7 @@ func TestCreateHTTPHandlers_HTTPSOnboarding_DoesNotPreemptAppHostCAPath(t *testi
268 270
269 _, _, httpsHandler := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil)271 _, _, httpsHandler := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil)
270 272
271- req := httptest.NewRequest(http.MethodGet, "/ca", nil)273+ req := httptest.NewRequest(http.MethodGet, "/.well-known/gordon/ca", nil)
272 req.RemoteAddr = directAddr274 req.RemoteAddr = directAddr
273 req.Host = "app.example.com"275 req.Host = "app.example.com"
274 rec := httptest.NewRecorder()276 rec := httptest.NewRecorder()
@@ -276,6 +278,15 @@ func TestCreateHTTPHandlers_HTTPSOnboarding_DoesNotPreemptAppHostCAPath(t *testi
276 278
277 assert.Equal(t, http.StatusNotFound, rec.Code)279 assert.Equal(t, http.StatusNotFound, rec.Code)
278 assert.NotContains(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")
279 }290 }
280 291
281 func TestCreateHTTPHandlers_HTTPSOnboarding_HEADRoutesRemainAvailable(t *testing.T) {292 func TestCreateHTTPHandlers_HTTPSOnboarding_HEADRoutesRemainAvailable(t *testing.T) {
@@ -292,10 +303,10 @@ func TestCreateHTTPHandlers_HTTPSOnboarding_HEADRoutesRemainAvailable(t *testing
292 ts := httptest.NewServer(httpsHandler)303 ts := httptest.NewServer(httpsHandler)
293 defer ts.Close()304 defer ts.Close()
294 305
295- 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"} {
296 req, err := http.NewRequest(http.MethodHead, ts.URL+path, nil)307 req, err := http.NewRequest(http.MethodHead, ts.URL+path, nil)
297 require.NoError(t, err)308 require.NoError(t, err)
298- req.Host = "gordon.example.com"309+ req.Host = "Gordon.Example.Com.:8443"
299 resp, err := ts.Client().Do(req)310 resp, err := ts.Client().Do(req)
300 require.NoError(t, err)311 require.NoError(t, err)
301 312
@@ -327,8 +338,8 @@ func TestCreateHTTPHandlers_DirectHTTPOnboarding_HEADRoutesRemainAvailable(t *te
327 path string338 path string
328 contentType string339 contentType string
329 }{340 }{
330- {path: "/ca", contentType: "text/html"},341+ {path: "/.well-known/gordon/ca", contentType: "text/html"},
331- {path: "/ca.crt", contentType: "application/x-x509-ca-cert"},342+ {path: "/.well-known/gordon/ca.crt", contentType: "application/x-x509-ca-cert"},
332 } {343 } {
333 resp, err := ts.Client().Head(ts.URL + tc.path)344 resp, err := ts.Client().Head(ts.URL + tc.path)
334 require.NoError(t, err)345 require.NoError(t, err)
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/service.go +45 -9
@@ -85,6 +85,13 @@ func (s *Service) Load(ctx context.Context) error {
8585 if err != nil {
8686 return fmt.Errorf("load stored certificates: %w", err)
8787 }
88+ state, err := s.deps.Store.LoadState(ctx)
89+ if err != nil {
90+ return fmt.Errorf("load certificate store state: %w", err)
91+ }
92+ if state.ObtainCursor < 0 {
93+ state.ObtainCursor = 0
94+ }
8895
8996 s.mu.Lock()
9097 defer s.mu.Unlock()
@@ -92,7 +99,7 @@ func (s *Service) Load(ctx context.Context) error {
9299 s.certs = make(map[string]*out.StoredCertificate, len(stored))
93100 s.lastErr = make(map[string]string)
94101 s.requiredHosts = make(map[string]struct{})
95- s.obtainCursor = 0
102+ s.obtainCursor = state.ObtainCursor
96103
97104 for i := range stored {
98105 cert := &stored[i]
@@ -191,9 +198,21 @@ func (s *Service) Reconcile(ctx context.Context) error {
191198 batchSize = defaultObtainBatchSize
192199 }
193200
194- missing, pendingCount := s.selectMissingTargetsLocked(targets, batchSize)
201+ previousCursor := s.obtainCursor
202+ missing, pendingCount, nextCursor, cursorChanged := s.selectMissingTargetsLocked(targets, batchSize)
195203 s.mu.Unlock()
196204
205+ if cursorChanged {
206+ if err := s.persistObtainCursor(ctx, nextCursor); err != nil {
207+ return err
208+ }
209+ s.mu.Lock()
210+ if s.obtainCursor == previousCursor {
211+ s.obtainCursor = nextCursor
212+ }
213+ s.mu.Unlock()
214+ }
215+
197216 // Process missing targets without holding s.mu or the store lock so
198217 // GetCertificate/Status are not blocked during network I/O (Obtain).
199218 if remaining := pendingCount - len(missing); remaining > 0 {
@@ -212,10 +231,11 @@ func (s *Service) Reconcile(ctx context.Context) error {
212231 // It rotates the starting point across reconcile runs so one repeatedly failing
213232 // target cannot indefinitely block later targets when batching is enabled.
214233 // Must be called with s.mu held.
215-func (s *Service) selectMissingTargetsLocked(targets []CertificateTarget, batchSize int) ([]CertificateTarget, int) {
234+func (s *Service) selectMissingTargetsLocked(targets []CertificateTarget, batchSize int) ([]CertificateTarget, int, int, bool) {
216235 if batchSize <= 0 {
217236 batchSize = defaultObtainBatchSize
218237 }
238+ previousCursor := s.obtainCursor
219239
220240 allMissing := make([]CertificateTarget, 0, len(targets))
221241 for _, target := range targets {
@@ -226,12 +246,12 @@ func (s *Service) selectMissingTargetsLocked(targets []CertificateTarget, batchS
226246
227247 pendingCount := len(allMissing)
228248 if pendingCount == 0 {
229- s.obtainCursor = 0
230- return nil, 0
249+ nextCursor := 0
250+ return nil, 0, nextCursor, nextCursor != previousCursor
231251 }
232252 if batchSize >= pendingCount {
233- s.obtainCursor = 0
234- return allMissing, pendingCount
253+ nextCursor := 0
254+ return allMissing, pendingCount, nextCursor, nextCursor != previousCursor
235255 }
236256
237257 start := s.obtainCursor % pendingCount
@@ -239,8 +259,24 @@ func (s *Service) selectMissingTargetsLocked(targets []CertificateTarget, batchS
239259 for i := range batchSize {
240260 missing = append(missing, allMissing[(start+i)%pendingCount])
241261 }
242- s.obtainCursor = (start + batchSize) % pendingCount
243- return missing, pendingCount
262+ nextCursor := (start + batchSize) % pendingCount
263+ return missing, pendingCount, nextCursor, nextCursor != previousCursor
264+}
265+
266+func (s *Service) persistObtainCursor(ctx context.Context, obtainCursor int) error {
267+ unlock, err := s.deps.Store.Lock(ctx)
268+ if err != nil {
269+ return fmt.Errorf("acquire store lock: %w", err)
270+ }
271+ saveErr := s.deps.Store.SaveState(ctx, out.CertificateStoreState{ObtainCursor: obtainCursor})
272+ if unlockErr := unlock(); unlockErr != nil {
273+ log := zerowrap.FromCtx(ctx)
274+ log.Warn().Err(unlockErr).Msg("failed to release store lock")
275+ }
276+ if saveErr != nil {
277+ return fmt.Errorf("save certificate store state: %w", saveErr)
278+ }
279+ return nil
244280 }
245281
246282 func populateStoredCertificate(cert *out.StoredCertificate) error {
@@ -85,6 +85,13 @@ func (s *Service) Load(ctx context.Context) error {
85 if err != nil {85 if err != nil {
86 return fmt.Errorf("load stored certificates: %w", err)86 return fmt.Errorf("load stored certificates: %w", err)
87 }87 }
88+ state, err := s.deps.Store.LoadState(ctx)
89+ if err != nil {
90+ return fmt.Errorf("load certificate store state: %w", err)
91+ }
92+ if state.ObtainCursor < 0 {
93+ state.ObtainCursor = 0
94+ }
88 95
89 s.mu.Lock()96 s.mu.Lock()
90 defer s.mu.Unlock()97 defer s.mu.Unlock()
@@ -92,7 +99,7 @@ func (s *Service) Load(ctx context.Context) error {
92 s.certs = make(map[string]*out.StoredCertificate, len(stored))99 s.certs = make(map[string]*out.StoredCertificate, len(stored))
93 s.lastErr = make(map[string]string)100 s.lastErr = make(map[string]string)
94 s.requiredHosts = make(map[string]struct{})101 s.requiredHosts = make(map[string]struct{})
95- s.obtainCursor = 0102+ s.obtainCursor = state.ObtainCursor
96 103
97 for i := range stored {104 for i := range stored {
98 cert := &stored[i]105 cert := &stored[i]
@@ -191,9 +198,21 @@ func (s *Service) Reconcile(ctx context.Context) error {
191 batchSize = defaultObtainBatchSize198 batchSize = defaultObtainBatchSize
192 }199 }
193 200
194- missing, pendingCount := s.selectMissingTargetsLocked(targets, batchSize)201+ previousCursor := s.obtainCursor
202+ missing, pendingCount, nextCursor, cursorChanged := s.selectMissingTargetsLocked(targets, batchSize)
195 s.mu.Unlock()203 s.mu.Unlock()
196 204
205+ if cursorChanged {
206+ if err := s.persistObtainCursor(ctx, nextCursor); err != nil {
207+ return err
208+ }
209+ s.mu.Lock()
210+ if s.obtainCursor == previousCursor {
211+ s.obtainCursor = nextCursor
212+ }
213+ s.mu.Unlock()
214+ }
215+
197 // Process missing targets without holding s.mu or the store lock so216 // Process missing targets without holding s.mu or the store lock so
198 // GetCertificate/Status are not blocked during network I/O (Obtain).217 // GetCertificate/Status are not blocked during network I/O (Obtain).
199 if remaining := pendingCount - len(missing); remaining > 0 {218 if remaining := pendingCount - len(missing); remaining > 0 {
@@ -212,10 +231,11 @@ func (s *Service) Reconcile(ctx context.Context) error {
212 // It rotates the starting point across reconcile runs so one repeatedly failing231 // It rotates the starting point across reconcile runs so one repeatedly failing
213 // target cannot indefinitely block later targets when batching is enabled.232 // target cannot indefinitely block later targets when batching is enabled.
214 // Must be called with s.mu held.233 // Must be called with s.mu held.
215-func (s *Service) selectMissingTargetsLocked(targets []CertificateTarget, batchSize int) ([]CertificateTarget, int) {234+func (s *Service) selectMissingTargetsLocked(targets []CertificateTarget, batchSize int) ([]CertificateTarget, int, int, bool) {
216 if batchSize <= 0 {235 if batchSize <= 0 {
217 batchSize = defaultObtainBatchSize236 batchSize = defaultObtainBatchSize
218 }237 }
238+ previousCursor := s.obtainCursor
219 239
220 allMissing := make([]CertificateTarget, 0, len(targets))240 allMissing := make([]CertificateTarget, 0, len(targets))
221 for _, target := range targets {241 for _, target := range targets {
@@ -226,12 +246,12 @@ func (s *Service) selectMissingTargetsLocked(targets []CertificateTarget, batchS
226 246
227 pendingCount := len(allMissing)247 pendingCount := len(allMissing)
228 if pendingCount == 0 {248 if pendingCount == 0 {
229- s.obtainCursor = 0249+ nextCursor := 0
230- return nil, 0250+ return nil, 0, nextCursor, nextCursor != previousCursor
231 }251 }
232 if batchSize >= pendingCount {252 if batchSize >= pendingCount {
233- s.obtainCursor = 0253+ nextCursor := 0
234- return allMissing, pendingCount254+ return allMissing, pendingCount, nextCursor, nextCursor != previousCursor
235 }255 }
236 256
237 start := s.obtainCursor % pendingCount257 start := s.obtainCursor % pendingCount
@@ -239,8 +259,24 @@ func (s *Service) selectMissingTargetsLocked(targets []CertificateTarget, batchS
239 for i := range batchSize {259 for i := range batchSize {
240 missing = append(missing, allMissing[(start+i)%pendingCount])260 missing = append(missing, allMissing[(start+i)%pendingCount])
241 }261 }
242- s.obtainCursor = (start + batchSize) % pendingCount262+ nextCursor := (start + batchSize) % pendingCount
243- return missing, pendingCount263+ return missing, pendingCount, nextCursor, nextCursor != previousCursor
264+}
265+
266+func (s *Service) persistObtainCursor(ctx context.Context, obtainCursor int) error {
267+ unlock, err := s.deps.Store.Lock(ctx)
268+ if err != nil {
269+ return fmt.Errorf("acquire store lock: %w", err)
270+ }
271+ saveErr := s.deps.Store.SaveState(ctx, out.CertificateStoreState{ObtainCursor: obtainCursor})
272+ if unlockErr := unlock(); unlockErr != nil {
273+ log := zerowrap.FromCtx(ctx)
274+ log.Warn().Err(unlockErr).Msg("failed to release store lock")
275+ }
276+ if saveErr != nil {
277+ return fmt.Errorf("save certificate store state: %w", saveErr)
278+ }
279+ return nil
244 }280 }
245 281
246 func populateStoredCertificate(cert *out.StoredCertificate) error {282 func populateStoredCertificate(cert *out.StoredCertificate) error {
M internal/usecase/publictls/service_test.go +147 -3
@@ -130,9 +130,11 @@ 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+ saveStateErr error
136138 }
137139
138140 func (s *mockCertificateStoreState) All() []out.StoredCertificate {
@@ -143,6 +145,24 @@ func (s *mockCertificateStoreState) All() []out.StoredCertificate {
143145 return certs
144146 }
145147
148+func (s *mockCertificateStoreState) State() out.CertificateStoreState {
149+ s.mu.Lock()
150+ defer s.mu.Unlock()
151+ return s.state
152+}
153+
154+func (s *mockCertificateStoreState) SetState(state out.CertificateStoreState) {
155+ s.mu.Lock()
156+ defer s.mu.Unlock()
157+ s.state = state
158+}
159+
160+func (s *mockCertificateStoreState) SetSaveStateError(err error) {
161+ s.mu.Lock()
162+ defer s.mu.Unlock()
163+ s.saveStateErr = err
164+}
165+
146166 func newMockCertificateStore(t *testing.T, initial ...out.StoredCertificate) (*outmocks.MockCertificateStore, *mockCertificateStoreState) {
147167 t.Helper()
148168 state := &mockCertificateStoreState{certs: append([]out.StoredCertificate(nil), initial...)}
@@ -173,6 +193,22 @@ func newMockCertificateStore(t *testing.T, initial ...out.StoredCertificate) (*o
173193 })
174194 loadAllCall.Maybe()
175195
196+ loadStateCall := store.EXPECT().LoadState(mock.Anything).RunAndReturn(func(context.Context) (out.CertificateStoreState, error) {
197+ return state.State(), nil
198+ })
199+ loadStateCall.Maybe()
200+
201+ saveStateCall := store.EXPECT().SaveState(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, storeState out.CertificateStoreState) error {
202+ state.mu.Lock()
203+ defer state.mu.Unlock()
204+ if state.saveStateErr != nil {
205+ return state.saveStateErr
206+ }
207+ state.state = storeState
208+ return nil
209+ })
210+ saveStateCall.Maybe()
211+
176212 saveCall := store.EXPECT().Save(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, cert out.StoredCertificate) error {
177213 state.mu.Lock()
178214 defer state.mu.Unlock()
@@ -354,6 +390,114 @@ func TestServiceReconcileRotatesBatchAfterFailedObtain(t *testing.T) {
354390 assert.Equal(t, "http01-b.example.com", stored[0].ID)
355391 }
356392
393+func TestServiceReconcileDoesNotAdvanceInMemoryCursorWhenPersistFails(t *testing.T) {
394+ ctx := context.Background()
395+
396+ routes := &fakeRoutes{
397+ routes: []domain.Route{
398+ {Domain: "b.example.com"},
399+ {Domain: "a.example.com"},
400+ },
401+ }
402+ issuer, recorder := newMockPublicCertificateIssuer(t, nil, nil)
403+ store, storeState := newMockCertificateStore(t)
404+
405+ cfg := Config{
406+ Enabled: true,
407+ Email: "admin@example.com",
408+ Challenge: "http-01",
409+ HTTPPort: 8088,
410+ TLSPort: 8443,
411+ ObtainBatchSize: 1,
412+ }
413+ svc := NewService(cfg, ServiceDeps{
414+ Config: cfg,
415+ Routes: routes,
416+ Issuer: issuer,
417+ Store: store,
418+ Challenges: NewHTTP01Challenges(),
419+ Effective: EffectiveChallenge{
420+ ConfiguredMode: domain.ACMEChallengeHTTP01,
421+ Mode: domain.ACMEChallengeHTTP01,
422+ TokenSource: domain.ACMETokenSourceNone,
423+ Reason: "http-01 challenge selected",
424+ },
425+ })
426+
427+ storeState.SetSaveStateError(fmt.Errorf("disk full"))
428+ require.NoError(t, svc.Load(ctx))
429+ err := svc.Reconcile(ctx)
430+ require.Error(t, err)
431+ assert.Contains(t, err.Error(), "save certificate store state")
432+ assert.Empty(t, recorder.Orders(), "obtain should not run when cursor persistence fails")
433+ assert.Equal(t, out.CertificateStoreState{}, storeState.State())
434+
435+ storeState.SetSaveStateError(nil)
436+ require.NoError(t, svc.Reconcile(ctx))
437+
438+ orders := recorder.Orders()
439+ require.Len(t, orders, 1)
440+ assert.Equal(t, "http01-a.example.com", orders[0].ID, "failed cursor persistence should not skip the first target in memory")
441+}
442+
443+func TestServiceReconcilePersistsBatchCursorAcrossRestart(t *testing.T) {
444+ ctx := context.Background()
445+
446+ routes := &fakeRoutes{
447+ routes: []domain.Route{
448+ {Domain: "b.example.com"},
449+ {Domain: "a.example.com"},
450+ },
451+ }
452+ issuer, recorder := newMockPublicCertificateIssuer(t, func(_ context.Context, order out.CertificateOrder) (*out.StoredCertificate, error) {
453+ if order.ID == "http01-a.example.com" {
454+ return nil, fmt.Errorf("acme validation failed")
455+ }
456+ return defaultStoredCert(order)
457+ }, nil)
458+ store, storeState := newMockCertificateStore(t)
459+
460+ cfg := Config{
461+ Enabled: true,
462+ Email: "admin@example.com",
463+ Challenge: "http-01",
464+ HTTPPort: 8088,
465+ TLSPort: 8443,
466+ ObtainBatchSize: 1,
467+ }
468+ deps := ServiceDeps{
469+ Config: cfg,
470+ Routes: routes,
471+ Issuer: issuer,
472+ Store: store,
473+ Challenges: NewHTTP01Challenges(),
474+ Effective: EffectiveChallenge{
475+ ConfiguredMode: domain.ACMEChallengeHTTP01,
476+ Mode: domain.ACMEChallengeHTTP01,
477+ TokenSource: domain.ACMETokenSourceNone,
478+ Reason: "http-01 challenge selected",
479+ },
480+ }
481+
482+ firstSvc := NewService(cfg, deps)
483+ require.NoError(t, firstSvc.Load(ctx))
484+ require.NoError(t, firstSvc.Reconcile(ctx))
485+ assert.Equal(t, out.CertificateStoreState{ObtainCursor: 1}, storeState.State())
486+
487+ secondSvc := NewService(cfg, deps)
488+ require.NoError(t, secondSvc.Load(ctx))
489+ require.NoError(t, secondSvc.Reconcile(ctx))
490+
491+ orders := recorder.Orders()
492+ require.Len(t, orders, 2)
493+ assert.Equal(t, "http01-a.example.com", orders[0].ID)
494+ assert.Equal(t, "http01-b.example.com", orders[1].ID, "restart should resume from the persisted obtain cursor")
495+
496+ stored := storeState.All()
497+ require.Len(t, stored, 1)
498+ assert.Equal(t, "http01-b.example.com", stored[0].ID)
499+}
500+
357501 func TestServiceReconcileObtainsMissingHTTP01Cert(t *testing.T) {
358502 ctx := context.Background()
359503
@@ -130,9 +130,11 @@ 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+ saveStateErr error
136 }138 }
137 139
138 func (s *mockCertificateStoreState) All() []out.StoredCertificate {140 func (s *mockCertificateStoreState) All() []out.StoredCertificate {
@@ -143,6 +145,24 @@ func (s *mockCertificateStoreState) All() []out.StoredCertificate {
143 return certs145 return certs
144 }146 }
145 147
148+func (s *mockCertificateStoreState) State() out.CertificateStoreState {
149+ s.mu.Lock()
150+ defer s.mu.Unlock()
151+ return s.state
152+}
153+
154+func (s *mockCertificateStoreState) SetState(state out.CertificateStoreState) {
155+ s.mu.Lock()
156+ defer s.mu.Unlock()
157+ s.state = state
158+}
159+
160+func (s *mockCertificateStoreState) SetSaveStateError(err error) {
161+ s.mu.Lock()
162+ defer s.mu.Unlock()
163+ s.saveStateErr = err
164+}
165+
146 func newMockCertificateStore(t *testing.T, initial ...out.StoredCertificate) (*outmocks.MockCertificateStore, *mockCertificateStoreState) {166 func newMockCertificateStore(t *testing.T, initial ...out.StoredCertificate) (*outmocks.MockCertificateStore, *mockCertificateStoreState) {
147 t.Helper()167 t.Helper()
148 state := &mockCertificateStoreState{certs: append([]out.StoredCertificate(nil), initial...)}168 state := &mockCertificateStoreState{certs: append([]out.StoredCertificate(nil), initial...)}
@@ -173,6 +193,22 @@ func newMockCertificateStore(t *testing.T, initial ...out.StoredCertificate) (*o
173 })193 })
174 loadAllCall.Maybe()194 loadAllCall.Maybe()
175 195
196+ loadStateCall := store.EXPECT().LoadState(mock.Anything).RunAndReturn(func(context.Context) (out.CertificateStoreState, error) {
197+ return state.State(), nil
198+ })
199+ loadStateCall.Maybe()
200+
201+ saveStateCall := store.EXPECT().SaveState(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, storeState out.CertificateStoreState) error {
202+ state.mu.Lock()
203+ defer state.mu.Unlock()
204+ if state.saveStateErr != nil {
205+ return state.saveStateErr
206+ }
207+ state.state = storeState
208+ return nil
209+ })
210+ saveStateCall.Maybe()
211+
176 saveCall := store.EXPECT().Save(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, cert out.StoredCertificate) error {212 saveCall := store.EXPECT().Save(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, cert out.StoredCertificate) error {
177 state.mu.Lock()213 state.mu.Lock()
178 defer state.mu.Unlock()214 defer state.mu.Unlock()
@@ -354,6 +390,114 @@ func TestServiceReconcileRotatesBatchAfterFailedObtain(t *testing.T) {
354 assert.Equal(t, "http01-b.example.com", stored[0].ID)390 assert.Equal(t, "http01-b.example.com", stored[0].ID)
355 }391 }
356 392
393+func TestServiceReconcileDoesNotAdvanceInMemoryCursorWhenPersistFails(t *testing.T) {
394+ ctx := context.Background()
395+
396+ routes := &fakeRoutes{
397+ routes: []domain.Route{
398+ {Domain: "b.example.com"},
399+ {Domain: "a.example.com"},
400+ },
401+ }
402+ issuer, recorder := newMockPublicCertificateIssuer(t, nil, nil)
403+ store, storeState := newMockCertificateStore(t)
404+
405+ cfg := Config{
406+ Enabled: true,
407+ Email: "admin@example.com",
408+ Challenge: "http-01",
409+ HTTPPort: 8088,
410+ TLSPort: 8443,
411+ ObtainBatchSize: 1,
412+ }
413+ svc := NewService(cfg, ServiceDeps{
414+ Config: cfg,
415+ Routes: routes,
416+ Issuer: issuer,
417+ Store: store,
418+ Challenges: NewHTTP01Challenges(),
419+ Effective: EffectiveChallenge{
420+ ConfiguredMode: domain.ACMEChallengeHTTP01,
421+ Mode: domain.ACMEChallengeHTTP01,
422+ TokenSource: domain.ACMETokenSourceNone,
423+ Reason: "http-01 challenge selected",
424+ },
425+ })
426+
427+ storeState.SetSaveStateError(fmt.Errorf("disk full"))
428+ require.NoError(t, svc.Load(ctx))
429+ err := svc.Reconcile(ctx)
430+ require.Error(t, err)
431+ assert.Contains(t, err.Error(), "save certificate store state")
432+ assert.Empty(t, recorder.Orders(), "obtain should not run when cursor persistence fails")
433+ assert.Equal(t, out.CertificateStoreState{}, storeState.State())
434+
435+ storeState.SetSaveStateError(nil)
436+ require.NoError(t, svc.Reconcile(ctx))
437+
438+ orders := recorder.Orders()
439+ require.Len(t, orders, 1)
440+ assert.Equal(t, "http01-a.example.com", orders[0].ID, "failed cursor persistence should not skip the first target in memory")
441+}
442+
443+func TestServiceReconcilePersistsBatchCursorAcrossRestart(t *testing.T) {
444+ ctx := context.Background()
445+
446+ routes := &fakeRoutes{
447+ routes: []domain.Route{
448+ {Domain: "b.example.com"},
449+ {Domain: "a.example.com"},
450+ },
451+ }
452+ issuer, recorder := newMockPublicCertificateIssuer(t, func(_ context.Context, order out.CertificateOrder) (*out.StoredCertificate, error) {
453+ if order.ID == "http01-a.example.com" {
454+ return nil, fmt.Errorf("acme validation failed")
455+ }
456+ return defaultStoredCert(order)
457+ }, nil)
458+ store, storeState := newMockCertificateStore(t)
459+
460+ cfg := Config{
461+ Enabled: true,
462+ Email: "admin@example.com",
463+ Challenge: "http-01",
464+ HTTPPort: 8088,
465+ TLSPort: 8443,
466+ ObtainBatchSize: 1,
467+ }
468+ deps := ServiceDeps{
469+ Config: cfg,
470+ Routes: routes,
471+ Issuer: issuer,
472+ Store: store,
473+ Challenges: NewHTTP01Challenges(),
474+ Effective: EffectiveChallenge{
475+ ConfiguredMode: domain.ACMEChallengeHTTP01,
476+ Mode: domain.ACMEChallengeHTTP01,
477+ TokenSource: domain.ACMETokenSourceNone,
478+ Reason: "http-01 challenge selected",
479+ },
480+ }
481+
482+ firstSvc := NewService(cfg, deps)
483+ require.NoError(t, firstSvc.Load(ctx))
484+ require.NoError(t, firstSvc.Reconcile(ctx))
485+ assert.Equal(t, out.CertificateStoreState{ObtainCursor: 1}, storeState.State())
486+
487+ secondSvc := NewService(cfg, deps)
488+ require.NoError(t, secondSvc.Load(ctx))
489+ require.NoError(t, secondSvc.Reconcile(ctx))
490+
491+ orders := recorder.Orders()
492+ require.Len(t, orders, 2)
493+ assert.Equal(t, "http01-a.example.com", orders[0].ID)
494+ assert.Equal(t, "http01-b.example.com", orders[1].ID, "restart should resume from the persisted obtain cursor")
495+
496+ stored := storeState.All()
497+ require.Len(t, stored, 1)
498+ assert.Equal(t, "http01-b.example.com", stored[0].ID)
499+}
500+
357 func TestServiceReconcileObtainsMissingHTTP01Cert(t *testing.T) {501 func TestServiceReconcileObtainsMissingHTTP01Cert(t *testing.T) {
358 ctx := context.Background()502 ctx := context.Background()
359 503

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