Simplify Gordon onboarding routes
- commit
- 90ab417b001c60e840c6daddcc56ec5b88bb6e2e
- author
- brice · · 2026-05-01 10:38 UTC
- parent
- d200400
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 | |||
| 83 | 83 | ||
| 84 | 84 | ## 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 | 88 | ### Firefox / Zen (NSS certificate store) | |
| 89 | 89 | ||
| 90 | 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 | 93 | 2. Check **Trust this CA to identify websites** and confirm. | |
| 94 | 94 | ||
| 95 | 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 | 99 | ```bash | |
| 100 | 100 | # 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 | |
| 102 | 102 | ||
| 103 | 103 | # Debian / Ubuntu | |
| 104 | 104 | sudo cp gordon-ca.pem /usr/local/share/ca-certificates/gordon-ca.crt | |
| @@ -111,7 +111,7 @@ sudo update-ca-trust | |||
| 111 | 111 | ||
| 112 | 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 | 116 | - **iOS:** Download the `.mobileconfig` profile, then install it in **Settings > General > VPN & Device Management**. | |
| 117 | 117 | - **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 Setup | 84 | ## 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 | ```bash | 99 | ```bash | ||
| 100 | # Download the certificate | 100 | # 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 |
| 102 | 102 | ||||
| 103 | # Debian / Ubuntu | 103 | # Debian / Ubuntu | ||
| 104 | sudo cp gordon-ca.pem /usr/local/share/ca-certificates/gordon-ca.crt | 104 | 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 | |||
| 110 | 110 | ||
| 111 | 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 | 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 | 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 | 121 | - `/.well-known/gordon/`, `/.well-known/gordon/ca`, `/.well-known/gordon/ca.crt`, `/.well-known/gordon/ca.mobileconfig` — serve the onboarding page and CA certificate downloads | |
| 122 | - | - `/`, `/ca`, `/ca.crt`, `/ca.mobileconfig` — legacy compatibility aliases for direct HTTP onboarding | |
| 123 | 122 | - `/.well-known/acme-challenge/*` — serves ACME HTTP-01 challenges when public ACME HTTP-01 is enabled, otherwise returns `404 Not Found` | |
| 124 | 123 | - All other paths return `403 Forbidden` | |
| 125 | 124 | ||
| 126 | 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 | 129 | #### HTTP to HTTPS redirect | |
| 131 | 130 | ||
| @@ -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 downloads | 121 | - `/.well-known/gordon/`, `/.well-known/gordon/ca`, `/.well-known/gordon/ca.crt`, `/.well-known/gordon/ca.mobileconfig` — serve the onboarding page and CA certificate downloads | ||
| 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 redirect | 129 | #### 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) { | |||
| 113 | 113 | ||
| 114 | 114 | <div class="platform" id="ios"> | |
| 115 | 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 | 117 | <p>After downloading, go to <strong>Settings → General → VPN & Device Management</strong> to install, then <strong>Settings → General → About → Certificate Trust Settings</strong> to enable full trust.</p> | |
| 118 | 118 | </div> | |
| 119 | 119 | ||
| 120 | 120 | <div class="platform" id="android"> | |
| 121 | 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 | 123 | <p>Go to <strong>Settings → Security → Install a certificate → CA certificate</strong> and select the downloaded file.</p> | |
| 124 | 124 | </div> | |
| 125 | 125 | ||
| 126 | 126 | <div class="platform" id="desktop"> | |
| 127 | 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 | 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 | 130 | </div> | |
| 131 | 131 | ||
| 132 | 132 | <div class="platform" id="firefox"> | |
| 133 | 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 | 135 | <p>Firefox and Zen use their own certificate store (NSS). Go to <strong>Settings → Privacy & Security → Certificates → View Certificates → Authorities → Import</strong> and select the downloaded file. Check <strong>Trust this CA to identify websites</strong>.</p> | |
| 136 | 136 | </div> | |
| 137 | 137 | ||
| @@ -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 & 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 & 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 & 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 & 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) { | |||
| 31 | 31 | h, rootPEM, mobileconfig := newTestHandler() | |
| 32 | 32 | ||
| 33 | 33 | 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) | |
| 40 | 40 | ||
| 41 | 41 | srv := httptest.NewServer(mux) | |
| 42 | 42 | t.Cleanup(srv.Close) | |
| 43 | 43 | 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 | 47 | func getOnboardingBody(t *testing.T, srv *httptest.Server, host string) string { | |
| 48 | 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 | 50 | require.NoError(t, err) | |
| 51 | 51 | req.Host = host | |
| 52 | 52 | ||
| @@ -63,7 +63,7 @@ func getOnboardingBody(t *testing.T, srv *httptest.Server, host string) string { | |||
| 63 | 63 | func TestHandler_CACert(t *testing.T) { | |
| 64 | 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 | 67 | require.NoError(t, err) | |
| 68 | 68 | defer resp.Body.Close() | |
| 69 | 69 | ||
| @@ -78,7 +78,7 @@ func TestHandler_CACert(t *testing.T) { | |||
| 78 | 78 | func TestHandler_Mobileconfig(t *testing.T) { | |
| 79 | 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 | 82 | require.NoError(t, err) | |
| 83 | 83 | defer resp.Body.Close() | |
| 84 | 84 | ||
| @@ -93,7 +93,7 @@ func TestHandler_Mobileconfig(t *testing.T) { | |||
| 93 | 93 | func TestHandler_OnboardingPage(t *testing.T) { | |
| 94 | 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 | 97 | require.NoError(t, err) | |
| 98 | 98 | defer resp.Body.Close() | |
| 99 | 99 | ||
| @@ -103,8 +103,8 @@ func TestHandler_OnboardingPage(t *testing.T) { | |||
| 103 | 103 | assert.Equal(t, http.StatusOK, resp.StatusCode) | |
| 104 | 104 | assert.Equal(t, "text/html; charset=utf-8", resp.Header.Get("Content-Type")) | |
| 105 | 105 | 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") | |
| 108 | 108 | } | |
| 109 | 109 | ||
| 110 | 110 | func TestHandler_OnboardingPage_DefaultHTTPSURLOmitsInternalTLSPort(t *testing.T) { | |
| @@ -164,7 +164,7 @@ func TestHandler_OnboardingPage_GoToSiteLinkIsPlainLink(t *testing.T) { | |||
| 164 | 164 | func TestHandler_OnboardingPage_HEAD(t *testing.T) { | |
| 165 | 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 | 168 | require.NoError(t, err) | |
| 169 | 169 | ||
| 170 | 170 | resp, err := http.DefaultClient.Do(req) | |
| @@ -182,7 +182,7 @@ func TestHandler_OnboardingPage_HEAD(t *testing.T) { | |||
| 182 | 182 | func TestHandler_CACert_HEAD(t *testing.T) { | |
| 183 | 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 | 186 | require.NoError(t, err) | |
| 187 | 187 | ||
| 188 | 188 | resp, err := http.DefaultClient.Do(req) | |
| @@ -200,7 +200,7 @@ func TestHandler_CACert_HEAD(t *testing.T) { | |||
| 200 | 200 | func TestHandler_Mobileconfig_HEAD(t *testing.T) { | |
| 201 | 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 | 204 | require.NoError(t, err) | |
| 205 | 205 | ||
| 206 | 206 | 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, mobileconfig | 43 | 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 = host | 51 | 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 { | |||
| 25 | 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 | 33 | const ( | |
| 29 | 34 | lockFile = ".lock" | |
| 30 | 35 | accountFile = "account.json" | |
| 36 | + | stateFile = "state.json" | |
| 31 | 37 | certDir = "certs" | |
| 32 | 38 | backupSuffix = ".old" | |
| 33 | 39 | tempInfix = ".tmp-" | |
| @@ -97,6 +103,42 @@ func (s *Store) SaveAccount(_ context.Context, account out.ACMEAccount) error { | |||
| 97 | 103 | 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 | 142 | // LoadAll reads all certificate directories under <root>/certs/ and returns | |
| 101 | 143 | // the stored certificates. Directories with unsafe IDs are skipped. | |
| 102 | 144 | 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 nil | 103 | 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 returns | 142 | // 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) { | |||
| 263 | 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 | 289 | func TestStoreLoadAllInvalidChallenge(t *testing.T) { | |
| 267 | 290 | root := t.TempDir() | |
| 268 | 291 | 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 | |||
| 2105 | 2105 | registerOnboardingRoutes(onboardingMux, obHandler) | |
| 2106 | 2106 | // Register onboarding paths host-gated so normal traffic hits | |
| 2107 | 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 | 2108 | httpsMux.Handle("GET /.well-known/gordon/", gordonDomainOnboardingGate(gordonDomain, onboardingMux, proxyHandler)) | |
| 2112 | 2109 | httpsMux.Handle("GET /.well-known/gordon/ca", gordonDomainOnboardingGate(gordonDomain, onboardingMux, proxyHandler)) | |
| 2113 | 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 | 2130 | return registryOut, proxyOut, httpsOut | |
| 2134 | 2131 | } | |
| 2135 | 2132 | ||
| 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. | |
| 2138 | 2135 | func registerOnboardingRoutes(mux *http.ServeMux, ob *onboarding.Handler) { | |
| 2139 | - | mux.HandleFunc("GET /{$}", ob.ServeOnboardingPage) | |
| 2140 | 2136 | mux.HandleFunc("GET /.well-known/gordon/", ob.ServeOnboardingPage) | |
| 2141 | 2137 | mux.HandleFunc("GET /.well-known/gordon/ca", ob.ServeOnboardingPage) | |
| 2142 | 2138 | mux.HandleFunc("GET /.well-known/gordon/ca.crt", ob.ServeCACert) | |
| 2143 | 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 | 2142 | // 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 hits | 2106 | // 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, httpsOut | 2130 | return registryOut, proxyOut, httpsOut | ||
| 2134 | } | 2131 | } | ||
| 2135 | 2132 | ||||
| 2136 | - | // registerOnboardingRoutes registers all CA onboarding HTTP routes on the | 2133 | + | // 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 traffic | 2142 | // 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) { | |||
| 145 | 145 | wantContent string // substring check | |
| 146 | 146 | wantType string // Content-Type substring | |
| 147 | 147 | }{ | |
| 148 | - | {name: "root serves onboarding", path: "/", wantCode: http.StatusOK, wantContent: "Trust CA Certificate", wantType: "text/html"}, | |
| 149 | 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 | 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"}, | |
| 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"}, | |
| 156 | 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 | 159 | for _, tc := range tests { | |
| @@ -201,7 +201,7 @@ func TestCreateHTTPHandlers_TrustedProxyHTTP_DoesNotServeOnboarding(t *testing.T | |||
| 201 | 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 | 205 | t.Parallel() | |
| 206 | 206 | ca := newTestCA(t) | |
| 207 | 207 | cfg := newOnboardingConfig() | |
| @@ -209,7 +209,7 @@ func TestCreateHTTPHandlers_TrustedProxyCACertPath_DoesNotServeOnboardingResourc | |||
| 209 | 209 | ||
| 210 | 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 | 213 | req.RemoteAddr = trustedProxyAddr | |
| 214 | 214 | req.Host = "app.example.com" | |
| 215 | 215 | rec := httptest.NewRecorder() | |
| @@ -229,7 +229,7 @@ func TestCreateHTTPHandlers_ForceHTTPSRedirect_DoesNotBypassDirectOnboarding(t * | |||
| 229 | 229 | ||
| 230 | 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 | 233 | req.RemoteAddr = directAddr | |
| 234 | 234 | req.Host = "o2.bnema.dev" | |
| 235 | 235 | rec := httptest.NewRecorder() | |
| @@ -249,14 +249,16 @@ func TestCreateHTTPHandlers_HTTPSOnboarding_RemainsAvailableOnGordonDomain(t *te | |||
| 249 | 249 | ||
| 250 | 250 | _, _, httpsHandler := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil) | |
| 251 | 251 | ||
| 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) | |
| 257 | 258 | ||
| 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 | + | } | |
| 260 | 262 | } | |
| 261 | 263 | ||
| 262 | 264 | func TestCreateHTTPHandlers_HTTPSOnboarding_DoesNotPreemptAppHostCAPath(t *testing.T) { | |
| @@ -268,7 +270,7 @@ func TestCreateHTTPHandlers_HTTPSOnboarding_DoesNotPreemptAppHostCAPath(t *testi | |||
| 268 | 270 | ||
| 269 | 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 | 274 | req.RemoteAddr = directAddr | |
| 273 | 275 | req.Host = "app.example.com" | |
| 274 | 276 | rec := httptest.NewRecorder() | |
| @@ -276,6 +278,15 @@ func TestCreateHTTPHandlers_HTTPSOnboarding_DoesNotPreemptAppHostCAPath(t *testi | |||
| 276 | 278 | ||
| 277 | 279 | assert.Equal(t, http.StatusNotFound, rec.Code) | |
| 278 | 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 | 292 | func TestCreateHTTPHandlers_HTTPSOnboarding_HEADRoutesRemainAvailable(t *testing.T) { | |
| @@ -292,10 +303,10 @@ func TestCreateHTTPHandlers_HTTPSOnboarding_HEADRoutesRemainAvailable(t *testing | |||
| 292 | 303 | ts := httptest.NewServer(httpsHandler) | |
| 293 | 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 | 307 | req, err := http.NewRequest(http.MethodHead, ts.URL+path, nil) | |
| 297 | 308 | require.NoError(t, err) | |
| 298 | - | req.Host = "gordon.example.com" | |
| 309 | + | req.Host = "Gordon.Example.Com.:8443" | |
| 299 | 310 | resp, err := ts.Client().Do(req) | |
| 300 | 311 | require.NoError(t, err) | |
| 301 | 312 | ||
| @@ -327,8 +338,8 @@ func TestCreateHTTPHandlers_DirectHTTPOnboarding_HEADRoutesRemainAvailable(t *te | |||
| 327 | 338 | path string | |
| 328 | 339 | contentType string | |
| 329 | 340 | }{ | |
| 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"}, | |
| 332 | 343 | } { | |
| 333 | 344 | resp, err := ts.Client().Head(ts.URL + tc.path) | |
| 334 | 345 | require.NoError(t, err) | |
| @@ -145,15 +145,15 @@ func TestCreateHTTPHandlers_DirectHTTPOnboarding_AllPaths(t *testing.T) { | |||||
| 145 | wantContent string // substring check | 145 | wantContent string // substring check | ||
| 146 | wantType string // Content-Type substring | 146 | 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 = trustedProxyAddr | 213 | 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 = directAddr | 233 | 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 = directAddr | 253 | + | 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 = directAddr | 274 | 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 string | 338 | path string | ||
| 328 | contentType string | 339 | 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 { | |||
| 73 | 73 | ||
| 74 | 74 | // LoadAccount is a helper method to define mock.On call | |
| 75 | 75 | // - 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 | 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 | 136 | // LoadAll is a helper method to define mock.On call | |
| 137 | 137 | // - 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 | 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 | 162 | 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 | 225 | // Lock provides a mock function for the type MockCertificateStore | |
| 166 | 226 | func (_mock *MockCertificateStore) Lock(ctx context.Context) (func() error, error) { | |
| 167 | 227 | ret := _mock.Called(ctx) | |
| @@ -197,7 +257,7 @@ type MockCertificateStore_Lock_Call struct { | |||
| 197 | 257 | ||
| 198 | 258 | // Lock is a helper method to define mock.On call | |
| 199 | 259 | // - 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 | 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 | 309 | // Save is a helper method to define mock.On call | |
| 250 | 310 | // - ctx context.Context | |
| 251 | 311 | // - 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 | 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 | 366 | // SaveAccount is a helper method to define mock.On call | |
| 307 | 367 | // - ctx context.Context | |
| 308 | 368 | // - 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 | 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 | 397 | _c.Call.Return(run) | |
| 338 | 398 | 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 | + | } | |
| @@ -73,7 +73,7 @@ type MockCertificateStore_LoadAccount_Call struct { | |||||
| 73 | 73 | ||||
| 74 | // LoadAccount is a helper method to define mock.On call | 74 | // LoadAccount is a helper method to define mock.On call | ||
| 75 | // - ctx context.Context | 75 | // - 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 call | 136 | // LoadAll is a helper method to define mock.On call | ||
| 137 | // - ctx context.Context | 137 | // - 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 _c | 162 | 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 MockCertificateStore | 225 | // 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 call | 258 | // Lock is a helper method to define mock.On call | ||
| 199 | // - ctx context.Context | 259 | // - 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 call | 309 | // Save is a helper method to define mock.On call | ||
| 250 | // - ctx context.Context | 310 | // - ctx context.Context | ||
| 251 | // - cert out.StoredCertificate | 311 | // - 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 call | 366 | // SaveAccount is a helper method to define mock.On call | ||
| 307 | // - ctx context.Context | 367 | // - ctx context.Context | ||
| 308 | // - account out.ACMEAccount | 368 | // - 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 _c | 398 | 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 { | |||
| 39 | 39 | BodyJSON []byte | |
| 40 | 40 | } | |
| 41 | 41 | ||
| 42 | + | // CertificateStoreState represents persisted ACME reconciliation state. | |
| 43 | + | type CertificateStoreState struct { | |
| 44 | + | ObtainCursor int | |
| 45 | + | } | |
| 46 | + | ||
| 42 | 47 | // SecretValue holds a resolved secret value and its source. | |
| 43 | 48 | type SecretValue struct { | |
| 44 | 49 | Value string | |
| @@ -85,6 +90,12 @@ type CertificateStore interface { | |||
| 85 | 90 | // Save persists a certificate. | |
| 86 | 91 | 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 | 99 | // Lock acquires a lock to prevent concurrent certificate operations. | |
| 89 | 100 | Lock(ctx context.Context) (unlock func() error, err error) | |
| 90 | 101 | } | |
| @@ -39,6 +39,11 @@ type ACMEAccount struct { | |||||
| 39 | BodyJSON []byte | 39 | 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 string | 49 | 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) error | 91 | 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 { | |||
| 85 | 85 | if err != nil { | |
| 86 | 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 | 96 | s.mu.Lock() | |
| 90 | 97 | defer s.mu.Unlock() | |
| @@ -92,7 +99,7 @@ func (s *Service) Load(ctx context.Context) error { | |||
| 92 | 99 | s.certs = make(map[string]*out.StoredCertificate, len(stored)) | |
| 93 | 100 | s.lastErr = make(map[string]string) | |
| 94 | 101 | s.requiredHosts = make(map[string]struct{}) | |
| 95 | - | s.obtainCursor = 0 | |
| 102 | + | s.obtainCursor = state.ObtainCursor | |
| 96 | 103 | ||
| 97 | 104 | for i := range stored { | |
| 98 | 105 | cert := &stored[i] | |
| @@ -191,9 +198,21 @@ func (s *Service) Reconcile(ctx context.Context) error { | |||
| 191 | 198 | 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 | 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 | 216 | // Process missing targets without holding s.mu or the store lock so | |
| 198 | 217 | // GetCertificate/Status are not blocked during network I/O (Obtain). | |
| 199 | 218 | if remaining := pendingCount - len(missing); remaining > 0 { | |
| @@ -212,10 +231,11 @@ func (s *Service) Reconcile(ctx context.Context) error { | |||
| 212 | 231 | // It rotates the starting point across reconcile runs so one repeatedly failing | |
| 213 | 232 | // target cannot indefinitely block later targets when batching is enabled. | |
| 214 | 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 | 235 | if batchSize <= 0 { | |
| 217 | 236 | batchSize = defaultObtainBatchSize | |
| 218 | 237 | } | |
| 238 | + | previousCursor := s.obtainCursor | |
| 219 | 239 | ||
| 220 | 240 | allMissing := make([]CertificateTarget, 0, len(targets)) | |
| 221 | 241 | for _, target := range targets { | |
| @@ -226,12 +246,12 @@ func (s *Service) selectMissingTargetsLocked(targets []CertificateTarget, batchS | |||
| 226 | 246 | ||
| 227 | 247 | pendingCount := len(allMissing) | |
| 228 | 248 | if pendingCount == 0 { | |
| 229 | - | s.obtainCursor = 0 | |
| 230 | - | return nil, 0 | |
| 249 | + | nextCursor := 0 | |
| 250 | + | return nil, 0, nextCursor, nextCursor != previousCursor | |
| 231 | 251 | } | |
| 232 | 252 | if batchSize >= pendingCount { | |
| 233 | - | s.obtainCursor = 0 | |
| 234 | - | return allMissing, pendingCount | |
| 253 | + | nextCursor := 0 | |
| 254 | + | return allMissing, pendingCount, nextCursor, nextCursor != previousCursor | |
| 235 | 255 | } | |
| 236 | 256 | ||
| 237 | 257 | start := s.obtainCursor % pendingCount | |
| @@ -239,8 +259,24 @@ func (s *Service) selectMissingTargetsLocked(targets []CertificateTarget, batchS | |||
| 239 | 259 | for i := range batchSize { | |
| 240 | 260 | missing = append(missing, allMissing[(start+i)%pendingCount]) | |
| 241 | 261 | } | |
| 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 | |
| 244 | 280 | } | |
| 245 | 281 | ||
| 246 | 282 | 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 = 0 | 102 | + | 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 = defaultObtainBatchSize | 198 | 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 so | 216 | // 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 failing | 231 | // 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 = defaultObtainBatchSize | 236 | 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 = 0 | 249 | + | nextCursor := 0 |
| 230 | - | return nil, 0 | 250 | + | return nil, 0, nextCursor, nextCursor != previousCursor |
| 231 | } | 251 | } | ||
| 232 | if batchSize >= pendingCount { | 252 | if batchSize >= pendingCount { | ||
| 233 | - | s.obtainCursor = 0 | 253 | + | nextCursor := 0 |
| 234 | - | return allMissing, pendingCount | 254 | + | return allMissing, pendingCount, nextCursor, nextCursor != previousCursor |
| 235 | } | 255 | } | ||
| 236 | 256 | ||||
| 237 | start := s.obtainCursor % pendingCount | 257 | 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) % pendingCount | 262 | + | nextCursor := (start + batchSize) % pendingCount |
| 243 | - | return missing, 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 | |||
| 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 | |||
| 130 | 130 | } | |
| 131 | 131 | ||
| 132 | 132 | 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 | |
| 136 | 138 | } | |
| 137 | 139 | ||
| 138 | 140 | func (s *mockCertificateStoreState) All() []out.StoredCertificate { | |
| @@ -143,6 +145,24 @@ func (s *mockCertificateStoreState) All() []out.StoredCertificate { | |||
| 143 | 145 | 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 | 166 | func newMockCertificateStore(t *testing.T, initial ...out.StoredCertificate) (*outmocks.MockCertificateStore, *mockCertificateStoreState) { | |
| 147 | 167 | t.Helper() | |
| 148 | 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 | 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 | 212 | saveCall := store.EXPECT().Save(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, cert out.StoredCertificate) error { | |
| 177 | 213 | state.mu.Lock() | |
| 178 | 214 | defer state.mu.Unlock() | |
| @@ -354,6 +390,114 @@ func TestServiceReconcileRotatesBatchAfterFailedObtain(t *testing.T) { | |||
| 354 | 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 | 501 | func TestServiceReconcileObtainsMissingHTTP01Cert(t *testing.T) { | |
| 358 | 502 | ctx := context.Background() | |
| 359 | 503 | ||
| @@ -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.Mutex | 133 | + | mu sync.Mutex |
| 134 | - | account *out.ACMEAccount | 134 | + | account *out.ACMEAccount |
| 135 | - | certs []out.StoredCertificate | 135 | + | 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 certs | 145 | 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 | ||||