Merge pull request #183 from bnema/feature/acme-http-onboarding-hardening
Harden ACME HTTP onboarding flow
17 files · +922 -187
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/reference.md +2 -0
| @@ -40,6 +40,7 @@ access_token_ttl = "15m" # Ephemeral access token lifetime ( | |||
| 40 | 40 | enabled = false # Enable public ACME certificates (requires server.tls_port > 0) | |
| 41 | 41 | email = "" # ACME account email when enabled | |
| 42 | 42 | challenge = "auto" # "auto", "http-01", or "cloudflare-dns-01" | |
| 43 | + | obtain_batch_size = 1 # New certificate orders per reconcile run | |
| 43 | 44 | ||
| 44 | 45 | # ============================================================================= | |
| 45 | 46 | # API (applies to both Registry and Admin endpoints) | |
| @@ -206,6 +207,7 @@ keep_last = 3 # Keep N newest tags per repository | |||
| 206 | 207 | | `tls.acme.enabled` | `false` | Enable public ACME certificates (requires `server.tls_port > 0`) | | |
| 207 | 208 | | `tls.acme.email` | `""` | ACME account email when enabled | | |
| 208 | 209 | | `tls.acme.challenge` | `"auto"` | ACME challenge mode: `auto`, `http-01`, or `cloudflare-dns-01` | | |
| 210 | + | | `tls.acme.obtain_batch_size` | `1` | Maximum new ACME certificate orders per reconcile run | | |
| 209 | 211 | | `auth.enabled` | `true` | Enable authentication; when `false`, run local-only mode (loopback-only `/v2/*`, `/admin/*` disabled) | | |
| 210 | 212 | | `auth.secrets_backend` | `"unsafe"` | Secrets storage | | |
| 211 | 213 | | `auth.token_expiry` | `"720h"` | 30 days | | |
| @@ -40,6 +40,7 @@ access_token_ttl = "15m" # Ephemeral access token lifetime ( | |||||
| 40 | enabled = false # Enable public ACME certificates (requires server.tls_port > 0) | 40 | enabled = false # Enable public ACME certificates (requires server.tls_port > 0) | ||
| 41 | email = "" # ACME account email when enabled | 41 | email = "" # ACME account email when enabled | ||
| 42 | challenge = "auto" # "auto", "http-01", or "cloudflare-dns-01" | 42 | challenge = "auto" # "auto", "http-01", or "cloudflare-dns-01" | ||
| 43 | + | obtain_batch_size = 1 # New certificate orders per reconcile run | |||
| 43 | 44 | ||||
| 44 | # ============================================================================= | 45 | # ============================================================================= | ||
| 45 | # API (applies to both Registry and Admin endpoints) | 46 | # API (applies to both Registry and Admin endpoints) | ||
| @@ -206,6 +207,7 @@ keep_last = 3 # Keep N newest tags per repository | |||||
| 206 | | `tls.acme.enabled` | `false` | Enable public ACME certificates (requires `server.tls_port > 0`) | | 207 | | `tls.acme.enabled` | `false` | Enable public ACME certificates (requires `server.tls_port > 0`) | | ||
| 207 | | `tls.acme.email` | `""` | ACME account email when enabled | | 208 | | `tls.acme.email` | `""` | ACME account email when enabled | | ||
| 208 | | `tls.acme.challenge` | `"auto"` | ACME challenge mode: `auto`, `http-01`, or `cloudflare-dns-01` | | 209 | | `tls.acme.challenge` | `"auto"` | ACME challenge mode: `auto`, `http-01`, or `cloudflare-dns-01` | | ||
| 210 | + | | `tls.acme.obtain_batch_size` | `1` | Maximum new ACME certificate orders per reconcile run | | |||
| 209 | | `auth.enabled` | `true` | Enable authentication; when `false`, run local-only mode (loopback-only `/v2/*`, `/admin/*` disabled) | | 211 | | `auth.enabled` | `true` | Enable authentication; when `false`, run local-only mode (loopback-only `/v2/*`, `/admin/*` disabled) | | ||
| 210 | | `auth.secrets_backend` | `"unsafe"` | Secrets storage | | 212 | | `auth.secrets_backend` | `"unsafe"` | Secrets storage | | ||
| 211 | | `auth.token_expiry` | `"720h"` | 30 days | | 213 | | `auth.token_expiry` | `"720h"` | 30 days | | ||
M docs/config/server.md +13 -4
| @@ -69,7 +69,7 @@ With TLS enabled: | |||
| 69 | 69 | - Gordon runs an internal CA (root + intermediate) stored in `{data_dir}/pki/` | |
| 70 | 70 | - Leaf certificates are issued on-demand per domain (SNI-based) and cached in memory | |
| 71 | 71 | - The intermediate CA auto-renews before expiry | |
| 72 | - | - An onboarding page at `https://<gordon-host>:<tls_port>/ca` lets clients download the root CA certificate | |
| 72 | + | - An onboarding page at `https://<gordon-host>:<tls_port>/.well-known/gordon/` lets clients download the root CA certificate | |
| 73 | 73 | - The root CA is stable across restarts (generated once, persisted to disk) | |
| 74 | 74 | ||
| 75 | 75 | #### Custom certificates | |
| @@ -95,7 +95,8 @@ tls_port = 8443 | |||
| 95 | 95 | [tls.acme] | |
| 96 | 96 | enabled = true | |
| 97 | 97 | email = "admin@example.com" | |
| 98 | - | challenge = "auto" # auto, http-01, or cloudflare-dns-01 | |
| 98 | + | challenge = "auto" # auto, http-01, or cloudflare-dns-01 | |
| 99 | + | obtain_batch_size = 1 # maximum new certificate orders per reconcile run | |
| 99 | 100 | ``` | |
| 100 | 101 | ||
| 101 | 102 | Challenge behavior: | |
| @@ -103,19 +104,27 @@ Challenge behavior: | |||
| 103 | 104 | - `cloudflare-dns-01` uses a Cloudflare API token from `pass`, `GORDON_CLOUDFLARE_API_TOKEN_FILE`, or `GORDON_CLOUDFLARE_API_TOKEN`. | |
| 104 | 105 | - `auto` selects Cloudflare DNS-01 when a token is available, otherwise falls back to HTTP-01. | |
| 105 | 106 | ||
| 107 | + | HTTP-01 requires public access to port 80 for each hostname being validated. If you use Cloudflare in DNS-only/gray-cloud mode, your firewall/NAT must allow direct public traffic to Gordon's HTTP proxy port. A firewall rule that only allows Cloudflare source IPs on port 80 is compatible with orange-cloud proxying, but it blocks gray-cloud HTTP-01 validation; use DNS-01 or temporarily open port 80 for direct validation. | |
| 108 | + | ||
| 109 | + | Gordon limits new ACME certificate orders to `obtain_batch_size` per reconcile run (default `1`) so enabling ACME on an existing multi-route server does not burst through every route and hit Let's Encrypt rate limits. Later reloads, restarts, or other explicit reconcile runs continue issuing remaining certificates. | |
| 110 | + | ||
| 111 | + | If the initial ACME reconcile fails (e.g. due to a transient network error or misconfiguration), Gordon logs the failure but still starts the renewal loop for already cached certificates. Operators should fix the underlying error and restart/reload Gordon to trigger a new obtain attempt for missing certificates. | |
| 112 | + | ||
| 113 | + | For Cloudflare Full/Strict, Cloudflare terminates browser TLS at the edge and connects to Gordon over HTTPS. A public ACME certificate served by Gordon is the preferred origin certificate because Cloudflare Strict can validate it without custom origin trust. Cloudflare Flexible mode (HTTPS at the edge, HTTP to Gordon) is not end-to-end HTTPS. | |
| 114 | + | ||
| 106 | 115 | Static certificates have priority, then public ACME certificates, then Gordon's internal CA fallback. Gordon uses the `go-acme/lego` ACME client, including its DNS-provider support for Cloudflare DNS-01. | |
| 107 | 116 | ||
| 108 | 117 | #### Direct HTTP Onboarding | |
| 109 | 118 | ||
| 110 | 119 | When `tls_port` is non-zero, direct HTTP clients (those not arriving through a trusted proxy) are restricted to CA onboarding paths only: | |
| 111 | 120 | ||
| 112 | - | - `/`, `/ca`, `/ca.crt`, `/ca.mobileconfig` — serve the onboarding page and CA certificate 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 | |
| 113 | 122 | - `/.well-known/acme-challenge/*` — serves ACME HTTP-01 challenges when public ACME HTTP-01 is enabled, otherwise returns `404 Not Found` | |
| 114 | 123 | - All other paths return `403 Forbidden` | |
| 115 | 124 | ||
| 116 | 125 | This lets new clients discover and trust the internal CA over plain HTTP without exposing the full application. Trusted proxy traffic (e.g. from Cloudflare) continues through the normal HTTP proxy path unaffected. | |
| 117 | 126 | ||
| 118 | - | The onboarding page is also available on HTTPS at `/ca`. | |
| 127 | + | On HTTPS, onboarding paths are served only on `server.gordon_domain`; app hosts can use their own routes without Gordon intercepting them. If `gordon_domain` is empty, HTTPS onboarding paths are not served at all to avoid intercepting app hosts. | |
| 119 | 128 | ||
| 120 | 129 | #### HTTP to HTTPS redirect | |
| 121 | 130 | ||
| @@ -69,7 +69,7 @@ With TLS enabled: | |||||
| 69 | - Gordon runs an internal CA (root + intermediate) stored in `{data_dir}/pki/` | 69 | - Gordon runs an internal CA (root + intermediate) stored in `{data_dir}/pki/` | ||
| 70 | - Leaf certificates are issued on-demand per domain (SNI-based) and cached in memory | 70 | - Leaf certificates are issued on-demand per domain (SNI-based) and cached in memory | ||
| 71 | - The intermediate CA auto-renews before expiry | 71 | - The intermediate CA auto-renews before expiry | ||
| 72 | - | - An onboarding page at `https://<gordon-host>:<tls_port>/ca` lets clients download the root CA certificate | 72 | + | - An onboarding page at `https://<gordon-host>:<tls_port>/.well-known/gordon/` lets clients download the root CA certificate |
| 73 | - The root CA is stable across restarts (generated once, persisted to disk) | 73 | - The root CA is stable across restarts (generated once, persisted to disk) | ||
| 74 | 74 | ||||
| 75 | #### Custom certificates | 75 | #### Custom certificates | ||
| @@ -95,7 +95,8 @@ tls_port = 8443 | |||||
| 95 | [tls.acme] | 95 | [tls.acme] | ||
| 96 | enabled = true | 96 | enabled = true | ||
| 97 | email = "admin@example.com" | 97 | email = "admin@example.com" | ||
| 98 | - | challenge = "auto" # auto, http-01, or cloudflare-dns-01 | 98 | + | challenge = "auto" # auto, http-01, or cloudflare-dns-01 |
| 99 | + | obtain_batch_size = 1 # maximum new certificate orders per reconcile run | |||
| 99 | ``` | 100 | ``` | ||
| 100 | 101 | ||||
| 101 | Challenge behavior: | 102 | Challenge behavior: | ||
| @@ -103,19 +104,27 @@ Challenge behavior: | |||||
| 103 | - `cloudflare-dns-01` uses a Cloudflare API token from `pass`, `GORDON_CLOUDFLARE_API_TOKEN_FILE`, or `GORDON_CLOUDFLARE_API_TOKEN`. | 104 | - `cloudflare-dns-01` uses a Cloudflare API token from `pass`, `GORDON_CLOUDFLARE_API_TOKEN_FILE`, or `GORDON_CLOUDFLARE_API_TOKEN`. | ||
| 104 | - `auto` selects Cloudflare DNS-01 when a token is available, otherwise falls back to HTTP-01. | 105 | - `auto` selects Cloudflare DNS-01 when a token is available, otherwise falls back to HTTP-01. | ||
| 105 | 106 | ||||
| 107 | + | HTTP-01 requires public access to port 80 for each hostname being validated. If you use Cloudflare in DNS-only/gray-cloud mode, your firewall/NAT must allow direct public traffic to Gordon's HTTP proxy port. A firewall rule that only allows Cloudflare source IPs on port 80 is compatible with orange-cloud proxying, but it blocks gray-cloud HTTP-01 validation; use DNS-01 or temporarily open port 80 for direct validation. | |||
| 108 | + | ||||
| 109 | + | Gordon limits new ACME certificate orders to `obtain_batch_size` per reconcile run (default `1`) so enabling ACME on an existing multi-route server does not burst through every route and hit Let's Encrypt rate limits. Later reloads, restarts, or other explicit reconcile runs continue issuing remaining certificates. | |||
| 110 | + | ||||
| 111 | + | If the initial ACME reconcile fails (e.g. due to a transient network error or misconfiguration), Gordon logs the failure but still starts the renewal loop for already cached certificates. Operators should fix the underlying error and restart/reload Gordon to trigger a new obtain attempt for missing certificates. | |||
| 112 | + | ||||
| 113 | + | For Cloudflare Full/Strict, Cloudflare terminates browser TLS at the edge and connects to Gordon over HTTPS. A public ACME certificate served by Gordon is the preferred origin certificate because Cloudflare Strict can validate it without custom origin trust. Cloudflare Flexible mode (HTTPS at the edge, HTTP to Gordon) is not end-to-end HTTPS. | |||
| 114 | + | ||||
| 106 | Static certificates have priority, then public ACME certificates, then Gordon's internal CA fallback. Gordon uses the `go-acme/lego` ACME client, including its DNS-provider support for Cloudflare DNS-01. | 115 | Static certificates have priority, then public ACME certificates, then Gordon's internal CA fallback. Gordon uses the `go-acme/lego` ACME client, including its DNS-provider support for Cloudflare DNS-01. | ||
| 107 | 116 | ||||
| 108 | #### Direct HTTP Onboarding | 117 | #### Direct HTTP Onboarding | ||
| 109 | 118 | ||||
| 110 | When `tls_port` is non-zero, direct HTTP clients (those not arriving through a trusted proxy) are restricted to CA onboarding paths only: | 119 | When `tls_port` is non-zero, direct HTTP clients (those not arriving through a trusted proxy) are restricted to CA onboarding paths only: | ||
| 111 | 120 | ||||
| 112 | - | - `/`, `/ca`, `/ca.crt`, `/ca.mobileconfig` — serve the onboarding page and CA certificate 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 |
| 113 | - `/.well-known/acme-challenge/*` — serves ACME HTTP-01 challenges when public ACME HTTP-01 is enabled, otherwise returns `404 Not Found` | 122 | - `/.well-known/acme-challenge/*` — serves ACME HTTP-01 challenges when public ACME HTTP-01 is enabled, otherwise returns `404 Not Found` | ||
| 114 | - All other paths return `403 Forbidden` | 123 | - All other paths return `403 Forbidden` | ||
| 115 | 124 | ||||
| 116 | This lets new clients discover and trust the internal CA over plain HTTP without exposing the full application. Trusted proxy traffic (e.g. from Cloudflare) continues through the normal HTTP proxy path unaffected. | 125 | This lets new clients discover and trust the internal CA over plain HTTP without exposing the full application. Trusted proxy traffic (e.g. from Cloudflare) continues through the normal HTTP proxy path unaffected. | ||
| 117 | 126 | ||||
| 118 | - | The onboarding page is also available on HTTPS at `/ca`. | 127 | + | On HTTPS, onboarding paths are served only on `server.gordon_domain`; app hosts can use their own routes without Gordon intercepting them. If `gordon_domain` is empty, HTTPS onboarding paths are not served at all to avoid intercepting app hosts. |
| 119 | 128 | ||||
| 120 | #### HTTP to HTTPS redirect | 129 | #### HTTP to HTTPS redirect | ||
| 121 | 130 | ||||
M internal/adapters/in/http/onboarding/handler.go +4 -4
| @@ -113,25 +113,25 @@ func (h *Handler) ServeOnboardingPage(w http.ResponseWriter, r *http.Request) { | |||
| 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 +18 -16
| @@ -31,22 +31,24 @@ 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/{$}", h.ServeOnboardingPage) | |
| 35 | + | mux.HandleFunc("HEAD /.well-known/gordon/{$}", h.ServeOnboardingPage) | |
| 36 | + | mux.HandleFunc("GET /.well-known/gordon/ca", h.ServeOnboardingPage) | |
| 37 | + | mux.HandleFunc("HEAD /.well-known/gordon/ca", h.ServeOnboardingPage) | |
| 38 | + | mux.HandleFunc("GET /.well-known/gordon/ca.crt", h.ServeCACert) | |
| 39 | + | mux.HandleFunc("HEAD /.well-known/gordon/ca.crt", h.ServeCACert) | |
| 40 | + | mux.HandleFunc("GET /.well-known/gordon/ca.mobileconfig", h.ServeMobileconfig) | |
| 41 | + | mux.HandleFunc("HEAD /.well-known/gordon/ca.mobileconfig", h.ServeMobileconfig) | |
| 40 | 42 | ||
| 41 | 43 | srv := httptest.NewServer(mux) | |
| 42 | 44 | t.Cleanup(srv.Close) | |
| 43 | 45 | return srv, rootPEM, mobileconfig | |
| 44 | 46 | } | |
| 45 | 47 | ||
| 46 | - | // getOnboardingBody issues a GET /ca with a custom Host header and returns the body. | |
| 48 | + | // getOnboardingBody issues a GET /.well-known/gordon/ca with a custom Host header and returns the body. | |
| 47 | 49 | func getOnboardingBody(t *testing.T, srv *httptest.Server, host string) string { | |
| 48 | 50 | t.Helper() | |
| 49 | - | req, err := http.NewRequest(http.MethodGet, srv.URL+"/ca", nil) | |
| 51 | + | req, err := http.NewRequest(http.MethodGet, srv.URL+"/.well-known/gordon/ca", nil) | |
| 50 | 52 | require.NoError(t, err) | |
| 51 | 53 | req.Host = host | |
| 52 | 54 | ||
| @@ -63,7 +65,7 @@ func getOnboardingBody(t *testing.T, srv *httptest.Server, host string) string { | |||
| 63 | 65 | func TestHandler_CACert(t *testing.T) { | |
| 64 | 66 | srv, rootPEM, _ := newTestServer(t) | |
| 65 | 67 | ||
| 66 | - | resp, err := http.Get(srv.URL + "/ca.crt") | |
| 68 | + | resp, err := http.Get(srv.URL + "/.well-known/gordon/ca.crt") | |
| 67 | 69 | require.NoError(t, err) | |
| 68 | 70 | defer resp.Body.Close() | |
| 69 | 71 | ||
| @@ -78,7 +80,7 @@ func TestHandler_CACert(t *testing.T) { | |||
| 78 | 80 | func TestHandler_Mobileconfig(t *testing.T) { | |
| 79 | 81 | srv, _, _ := newTestServer(t) | |
| 80 | 82 | ||
| 81 | - | resp, err := http.Get(srv.URL + "/ca.mobileconfig") | |
| 83 | + | resp, err := http.Get(srv.URL + "/.well-known/gordon/ca.mobileconfig") | |
| 82 | 84 | require.NoError(t, err) | |
| 83 | 85 | defer resp.Body.Close() | |
| 84 | 86 | ||
| @@ -93,7 +95,7 @@ func TestHandler_Mobileconfig(t *testing.T) { | |||
| 93 | 95 | func TestHandler_OnboardingPage(t *testing.T) { | |
| 94 | 96 | srv, _, _ := newTestServer(t) | |
| 95 | 97 | ||
| 96 | - | resp, err := http.Get(srv.URL + "/ca") | |
| 98 | + | resp, err := http.Get(srv.URL + "/.well-known/gordon/") | |
| 97 | 99 | require.NoError(t, err) | |
| 98 | 100 | defer resp.Body.Close() | |
| 99 | 101 | ||
| @@ -103,8 +105,8 @@ func TestHandler_OnboardingPage(t *testing.T) { | |||
| 103 | 105 | assert.Equal(t, http.StatusOK, resp.StatusCode) | |
| 104 | 106 | assert.Equal(t, "text/html; charset=utf-8", resp.Header.Get("Content-Type")) | |
| 105 | 107 | assert.Contains(t, string(body), "Gordon") | |
| 106 | - | assert.Contains(t, string(body), "/ca.crt") | |
| 107 | - | assert.Contains(t, string(body), "/ca.mobileconfig") | |
| 108 | + | assert.Contains(t, string(body), "/.well-known/gordon/ca.crt") | |
| 109 | + | assert.Contains(t, string(body), "/.well-known/gordon/ca.mobileconfig") | |
| 108 | 110 | } | |
| 109 | 111 | ||
| 110 | 112 | func TestHandler_OnboardingPage_DefaultHTTPSURLOmitsInternalTLSPort(t *testing.T) { | |
| @@ -164,7 +166,7 @@ func TestHandler_OnboardingPage_GoToSiteLinkIsPlainLink(t *testing.T) { | |||
| 164 | 166 | func TestHandler_OnboardingPage_HEAD(t *testing.T) { | |
| 165 | 167 | srv, _, _ := newTestServer(t) | |
| 166 | 168 | ||
| 167 | - | req, err := http.NewRequest(http.MethodHead, srv.URL+"/ca", nil) | |
| 169 | + | req, err := http.NewRequest(http.MethodHead, srv.URL+"/.well-known/gordon/ca", nil) | |
| 168 | 170 | require.NoError(t, err) | |
| 169 | 171 | ||
| 170 | 172 | resp, err := http.DefaultClient.Do(req) | |
| @@ -182,7 +184,7 @@ func TestHandler_OnboardingPage_HEAD(t *testing.T) { | |||
| 182 | 184 | func TestHandler_CACert_HEAD(t *testing.T) { | |
| 183 | 185 | srv, _, _ := newTestServer(t) | |
| 184 | 186 | ||
| 185 | - | req, err := http.NewRequest(http.MethodHead, srv.URL+"/ca.crt", nil) | |
| 187 | + | req, err := http.NewRequest(http.MethodHead, srv.URL+"/.well-known/gordon/ca.crt", nil) | |
| 186 | 188 | require.NoError(t, err) | |
| 187 | 189 | ||
| 188 | 190 | resp, err := http.DefaultClient.Do(req) | |
| @@ -200,7 +202,7 @@ func TestHandler_CACert_HEAD(t *testing.T) { | |||
| 200 | 202 | func TestHandler_Mobileconfig_HEAD(t *testing.T) { | |
| 201 | 203 | srv, _, _ := newTestServer(t) | |
| 202 | 204 | ||
| 203 | - | req, err := http.NewRequest(http.MethodHead, srv.URL+"/ca.mobileconfig", nil) | |
| 205 | + | req, err := http.NewRequest(http.MethodHead, srv.URL+"/.well-known/gordon/ca.mobileconfig", nil) | |
| 204 | 206 | require.NoError(t, err) | |
| 205 | 207 | ||
| 206 | 208 | resp, err := http.DefaultClient.Do(req) | |
| @@ -31,22 +31,24 @@ func newTestServer(t *testing.T) (*httptest.Server, []byte, []byte) { | |||||
| 31 | h, rootPEM, mobileconfig := newTestHandler() | 31 | h, rootPEM, mobileconfig := newTestHandler() | ||
| 32 | 32 | ||||
| 33 | mux := http.NewServeMux() | 33 | mux := http.NewServeMux() | ||
| 34 | - | mux.HandleFunc("GET /ca", h.ServeOnboardingPage) | 34 | + | mux.HandleFunc("GET /.well-known/gordon/{$}", h.ServeOnboardingPage) |
| 35 | - | mux.HandleFunc("HEAD /ca", h.ServeOnboardingPage) | 35 | + | mux.HandleFunc("HEAD /.well-known/gordon/{$}", h.ServeOnboardingPage) |
| 36 | - | mux.HandleFunc("GET /ca.crt", h.ServeCACert) | 36 | + | mux.HandleFunc("GET /.well-known/gordon/ca", h.ServeOnboardingPage) |
| 37 | - | mux.HandleFunc("HEAD /ca.crt", h.ServeCACert) | 37 | + | mux.HandleFunc("HEAD /.well-known/gordon/ca", h.ServeOnboardingPage) |
| 38 | - | mux.HandleFunc("GET /ca.mobileconfig", h.ServeMobileconfig) | 38 | + | mux.HandleFunc("GET /.well-known/gordon/ca.crt", h.ServeCACert) |
| 39 | - | mux.HandleFunc("HEAD /ca.mobileconfig", h.ServeMobileconfig) | 39 | + | mux.HandleFunc("HEAD /.well-known/gordon/ca.crt", h.ServeCACert) |
| 40 | + | mux.HandleFunc("GET /.well-known/gordon/ca.mobileconfig", h.ServeMobileconfig) | |||
| 41 | + | mux.HandleFunc("HEAD /.well-known/gordon/ca.mobileconfig", h.ServeMobileconfig) | |||
| 40 | 42 | ||||
| 41 | srv := httptest.NewServer(mux) | 43 | srv := httptest.NewServer(mux) | ||
| 42 | t.Cleanup(srv.Close) | 44 | t.Cleanup(srv.Close) | ||
| 43 | return srv, rootPEM, mobileconfig | 45 | return srv, rootPEM, mobileconfig | ||
| 44 | } | 46 | } | ||
| 45 | 47 | ||||
| 46 | - | // getOnboardingBody issues a GET /ca with a custom Host header and returns the body. | 48 | + | // getOnboardingBody issues a GET /.well-known/gordon/ca with a custom Host header and returns the body. |
| 47 | func getOnboardingBody(t *testing.T, srv *httptest.Server, host string) string { | 49 | func getOnboardingBody(t *testing.T, srv *httptest.Server, host string) string { | ||
| 48 | t.Helper() | 50 | t.Helper() | ||
| 49 | - | req, err := http.NewRequest(http.MethodGet, srv.URL+"/ca", nil) | 51 | + | req, err := http.NewRequest(http.MethodGet, srv.URL+"/.well-known/gordon/ca", nil) |
| 50 | require.NoError(t, err) | 52 | require.NoError(t, err) | ||
| 51 | req.Host = host | 53 | req.Host = host | ||
| 52 | 54 | ||||
| @@ -63,7 +65,7 @@ func getOnboardingBody(t *testing.T, srv *httptest.Server, host string) string { | |||||
| 63 | func TestHandler_CACert(t *testing.T) { | 65 | func TestHandler_CACert(t *testing.T) { | ||
| 64 | srv, rootPEM, _ := newTestServer(t) | 66 | srv, rootPEM, _ := newTestServer(t) | ||
| 65 | 67 | ||||
| 66 | - | resp, err := http.Get(srv.URL + "/ca.crt") | 68 | + | resp, err := http.Get(srv.URL + "/.well-known/gordon/ca.crt") |
| 67 | require.NoError(t, err) | 69 | require.NoError(t, err) | ||
| 68 | defer resp.Body.Close() | 70 | defer resp.Body.Close() | ||
| 69 | 71 | ||||
| @@ -78,7 +80,7 @@ func TestHandler_CACert(t *testing.T) { | |||||
| 78 | func TestHandler_Mobileconfig(t *testing.T) { | 80 | func TestHandler_Mobileconfig(t *testing.T) { | ||
| 79 | srv, _, _ := newTestServer(t) | 81 | srv, _, _ := newTestServer(t) | ||
| 80 | 82 | ||||
| 81 | - | resp, err := http.Get(srv.URL + "/ca.mobileconfig") | 83 | + | resp, err := http.Get(srv.URL + "/.well-known/gordon/ca.mobileconfig") |
| 82 | require.NoError(t, err) | 84 | require.NoError(t, err) | ||
| 83 | defer resp.Body.Close() | 85 | defer resp.Body.Close() | ||
| 84 | 86 | ||||
| @@ -93,7 +95,7 @@ func TestHandler_Mobileconfig(t *testing.T) { | |||||
| 93 | func TestHandler_OnboardingPage(t *testing.T) { | 95 | func TestHandler_OnboardingPage(t *testing.T) { | ||
| 94 | srv, _, _ := newTestServer(t) | 96 | srv, _, _ := newTestServer(t) | ||
| 95 | 97 | ||||
| 96 | - | resp, err := http.Get(srv.URL + "/ca") | 98 | + | resp, err := http.Get(srv.URL + "/.well-known/gordon/") |
| 97 | require.NoError(t, err) | 99 | require.NoError(t, err) | ||
| 98 | defer resp.Body.Close() | 100 | defer resp.Body.Close() | ||
| 99 | 101 | ||||
| @@ -103,8 +105,8 @@ func TestHandler_OnboardingPage(t *testing.T) { | |||||
| 103 | assert.Equal(t, http.StatusOK, resp.StatusCode) | 105 | assert.Equal(t, http.StatusOK, resp.StatusCode) | ||
| 104 | assert.Equal(t, "text/html; charset=utf-8", resp.Header.Get("Content-Type")) | 106 | assert.Equal(t, "text/html; charset=utf-8", resp.Header.Get("Content-Type")) | ||
| 105 | assert.Contains(t, string(body), "Gordon") | 107 | assert.Contains(t, string(body), "Gordon") | ||
| 106 | - | assert.Contains(t, string(body), "/ca.crt") | 108 | + | assert.Contains(t, string(body), "/.well-known/gordon/ca.crt") |
| 107 | - | assert.Contains(t, string(body), "/ca.mobileconfig") | 109 | + | assert.Contains(t, string(body), "/.well-known/gordon/ca.mobileconfig") |
| 108 | } | 110 | } | ||
| 109 | 111 | ||||
| 110 | func TestHandler_OnboardingPage_DefaultHTTPSURLOmitsInternalTLSPort(t *testing.T) { | 112 | func TestHandler_OnboardingPage_DefaultHTTPSURLOmitsInternalTLSPort(t *testing.T) { | ||
| @@ -164,7 +166,7 @@ func TestHandler_OnboardingPage_GoToSiteLinkIsPlainLink(t *testing.T) { | |||||
| 164 | func TestHandler_OnboardingPage_HEAD(t *testing.T) { | 166 | func TestHandler_OnboardingPage_HEAD(t *testing.T) { | ||
| 165 | srv, _, _ := newTestServer(t) | 167 | srv, _, _ := newTestServer(t) | ||
| 166 | 168 | ||||
| 167 | - | req, err := http.NewRequest(http.MethodHead, srv.URL+"/ca", nil) | 169 | + | req, err := http.NewRequest(http.MethodHead, srv.URL+"/.well-known/gordon/ca", nil) |
| 168 | require.NoError(t, err) | 170 | require.NoError(t, err) | ||
| 169 | 171 | ||||
| 170 | resp, err := http.DefaultClient.Do(req) | 172 | resp, err := http.DefaultClient.Do(req) | ||
| @@ -182,7 +184,7 @@ func TestHandler_OnboardingPage_HEAD(t *testing.T) { | |||||
| 182 | func TestHandler_CACert_HEAD(t *testing.T) { | 184 | func TestHandler_CACert_HEAD(t *testing.T) { | ||
| 183 | srv, _, _ := newTestServer(t) | 185 | srv, _, _ := newTestServer(t) | ||
| 184 | 186 | ||||
| 185 | - | req, err := http.NewRequest(http.MethodHead, srv.URL+"/ca.crt", nil) | 187 | + | req, err := http.NewRequest(http.MethodHead, srv.URL+"/.well-known/gordon/ca.crt", nil) |
| 186 | require.NoError(t, err) | 188 | require.NoError(t, err) | ||
| 187 | 189 | ||||
| 188 | resp, err := http.DefaultClient.Do(req) | 190 | resp, err := http.DefaultClient.Do(req) | ||
| @@ -200,7 +202,7 @@ func TestHandler_CACert_HEAD(t *testing.T) { | |||||
| 200 | func TestHandler_Mobileconfig_HEAD(t *testing.T) { | 202 | func TestHandler_Mobileconfig_HEAD(t *testing.T) { | ||
| 201 | srv, _, _ := newTestServer(t) | 203 | srv, _, _ := newTestServer(t) | ||
| 202 | 204 | ||||
| 203 | - | req, err := http.NewRequest(http.MethodHead, srv.URL+"/ca.mobileconfig", nil) | 205 | + | req, err := http.NewRequest(http.MethodHead, srv.URL+"/.well-known/gordon/ca.mobileconfig", nil) |
| 204 | require.NoError(t, err) | 206 | require.NoError(t, err) | ||
| 205 | 207 | ||||
| 206 | resp, err := http.DefaultClient.Do(req) | 208 | resp, err := http.DefaultClient.Do(req) | ||
M internal/adapters/out/acmestore/store.go +42 -0
| @@ -25,9 +25,15 @@ type certMetadata struct { | |||
| 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/acme_config_test.go +1 -0
| @@ -14,4 +14,5 @@ func TestLoadConfig_ACMEDefaults(t *testing.T) { | |||
| 14 | 14 | assert.False(t, v.GetBool("tls.acme.enabled")) | |
| 15 | 15 | assert.Equal(t, "", v.GetString("tls.acme.email")) | |
| 16 | 16 | assert.Equal(t, "auto", v.GetString("tls.acme.challenge")) | |
| 17 | + | assert.Equal(t, 1, v.GetInt("tls.acme.obtain_batch_size")) | |
| 17 | 18 | } | |
| @@ -14,4 +14,5 @@ func TestLoadConfig_ACMEDefaults(t *testing.T) { | |||||
| 14 | assert.False(t, v.GetBool("tls.acme.enabled")) | 14 | assert.False(t, v.GetBool("tls.acme.enabled")) | ||
| 15 | assert.Equal(t, "", v.GetString("tls.acme.email")) | 15 | assert.Equal(t, "", v.GetString("tls.acme.email")) | ||
| 16 | assert.Equal(t, "auto", v.GetString("tls.acme.challenge")) | 16 | assert.Equal(t, "auto", v.GetString("tls.acme.challenge")) | ||
| 17 | + | assert.Equal(t, 1, v.GetInt("tls.acme.obtain_batch_size")) | |||
| 17 | } | 18 | } | ||
M internal/app/kernel.go +3 -3
| @@ -61,9 +61,9 @@ func newKernel(configPath string, initLog kernelLoggerInit) (*Kernel, error) { | |||
| 61 | 61 | ||
| 62 | 62 | // Prefer full service wiring so local CLI can execute the same operations | |
| 63 | 63 | // as remote mode without going through HTTP admin endpoints. | |
| 64 | - | // Use StartPublicTLS: false to prevent ACME Reconcile and renewal loop | |
| 65 | - | // side effects during read-only CLI commands (e.g. gordon tls status). | |
| 66 | - | if svc, fullErr := createServicesWithOptions(ctx, v, cfg, log, serviceOptions{StartPublicTLS: false}); fullErr == nil { | |
| 64 | + | // ACME Reconcile and renewal loop are only started from runServers, | |
| 65 | + | // so there are no side effects for read-only CLI commands. | |
| 66 | + | if svc, fullErr := createServicesWithOptions(ctx, v, cfg, log); fullErr == nil { | |
| 67 | 67 | // Wrap cleanup to stop public TLS service (with its renewal loop) | |
| 68 | 68 | // before the logger is cleaned up. | |
| 69 | 69 | wrappedCleanup := func() { | |
| @@ -61,9 +61,9 @@ func newKernel(configPath string, initLog kernelLoggerInit) (*Kernel, error) { | |||||
| 61 | 61 | ||||
| 62 | // Prefer full service wiring so local CLI can execute the same operations | 62 | // Prefer full service wiring so local CLI can execute the same operations | ||
| 63 | // as remote mode without going through HTTP admin endpoints. | 63 | // as remote mode without going through HTTP admin endpoints. | ||
| 64 | - | // Use StartPublicTLS: false to prevent ACME Reconcile and renewal loop | 64 | + | // ACME Reconcile and renewal loop are only started from runServers, |
| 65 | - | // side effects during read-only CLI commands (e.g. gordon tls status). | 65 | + | // so there are no side effects for read-only CLI commands. |
| 66 | - | if svc, fullErr := createServicesWithOptions(ctx, v, cfg, log, serviceOptions{StartPublicTLS: false}); fullErr == nil { | 66 | + | if svc, fullErr := createServicesWithOptions(ctx, v, cfg, log); fullErr == nil { |
| 67 | // Wrap cleanup to stop public TLS service (with its renewal loop) | 67 | // Wrap cleanup to stop public TLS service (with its renewal loop) | ||
| 68 | // before the logger is cleaned up. | 68 | // before the logger is cleaned up. | ||
| 69 | wrappedCleanup := func() { | 69 | wrappedCleanup := func() { | ||
M internal/app/run.go +109 -59
| @@ -198,9 +198,10 @@ type Config struct { | |||
| 198 | 198 | ||
| 199 | 199 | TLS struct { | |
| 200 | 200 | ACME struct { | |
| 201 | - | Enabled bool `mapstructure:"enabled"` | |
| 202 | - | Email string `mapstructure:"email"` | |
| 203 | - | Challenge string `mapstructure:"challenge"` | |
| 201 | + | Enabled bool `mapstructure:"enabled"` | |
| 202 | + | Email string `mapstructure:"email"` | |
| 203 | + | Challenge string `mapstructure:"challenge"` | |
| 204 | + | ObtainBatchSize int `mapstructure:"obtain_batch_size"` | |
| 204 | 205 | } `mapstructure:"acme"` | |
| 205 | 206 | } `mapstructure:"tls"` | |
| 206 | 207 | } | |
| @@ -239,6 +240,7 @@ type services struct { | |||
| 239 | 240 | pkiSvc *pkiusecase.Service | |
| 240 | 241 | reloadCoordinator *reloadCoordinator | |
| 241 | 242 | publicTLSSvc in.PublicTLSService | |
| 243 | + | publicTLSRuntime publicTLSRuntime | |
| 242 | 244 | registryHandler interface { | |
| 243 | 245 | UpdateBlobLimits(maxBlobChunkSize, maxBlobSize int64) | |
| 244 | 246 | } | |
| @@ -416,40 +418,31 @@ func initAccessLog(cfg Config, log zerowrap.Logger) (*accesslog.Writer, error) { | |||
| 416 | 418 | return writer, nil | |
| 417 | 419 | } | |
| 418 | 420 | ||
| 419 | - | // serviceOptions controls optional behavior for createServicesWithOptions. | |
| 420 | - | type serviceOptions struct { | |
| 421 | - | // StartPublicTLS controls whether ACME Reconcile and renewal loop are started. | |
| 422 | - | // Server runtime sets true; Kernel/local CLI sets false (read-only status). | |
| 423 | - | StartPublicTLS bool | |
| 424 | - | } | |
| 425 | - | ||
| 426 | 421 | // serviceInit holds the shared context for service initialization helpers. | |
| 427 | 422 | type serviceInit struct { | |
| 428 | - | ctx context.Context | |
| 429 | - | v *viper.Viper | |
| 430 | - | cfg Config | |
| 431 | - | log zerowrap.Logger | |
| 432 | - | svc *services | |
| 433 | - | startPublicTLS bool | |
| 423 | + | ctx context.Context | |
| 424 | + | v *viper.Viper | |
| 425 | + | cfg Config | |
| 426 | + | log zerowrap.Logger | |
| 427 | + | svc *services | |
| 434 | 428 | } | |
| 435 | 429 | ||
| 436 | 430 | // createServices creates all the application services for server runtime. | |
| 437 | - | // It calls createServicesWithOptions with StartPublicTLS: true. | |
| 431 | + | // Public ACME reconciliation is started later, after the HTTP listener is bound. | |
| 438 | 432 | func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowrap.Logger) (_ *services, retErr error) { | |
| 439 | - | return createServicesWithOptions(ctx, v, cfg, log, serviceOptions{StartPublicTLS: true}) | |
| 433 | + | return createServicesWithOptions(ctx, v, cfg, log) | |
| 440 | 434 | } | |
| 441 | 435 | ||
| 442 | - | // createServicesWithOptions creates all the application services with the given options. | |
| 443 | - | // When StartPublicTLS is false, ACME Reconcile and renewal loop are skipped so that | |
| 444 | - | // read-only CLI commands (e.g. gordon tls status) do not perform ACME side effects. | |
| 445 | - | func createServicesWithOptions(ctx context.Context, v *viper.Viper, cfg Config, log zerowrap.Logger, opts serviceOptions) (_ *services, retErr error) { | |
| 436 | + | // createServicesWithOptions creates all the application services. | |
| 437 | + | // ACME Reconcile and renewal loop are started later from runServers, | |
| 438 | + | // after HTTP listeners are bound. | |
| 439 | + | func createServicesWithOptions(ctx context.Context, v *viper.Viper, cfg Config, log zerowrap.Logger) (_ *services, retErr error) { | |
| 446 | 440 | si := &serviceInit{ | |
| 447 | - | ctx: ctx, | |
| 448 | - | v: v, | |
| 449 | - | cfg: cfg, | |
| 450 | - | log: log, | |
| 451 | - | svc: &services{}, | |
| 452 | - | startPublicTLS: opts.StartPublicTLS, | |
| 441 | + | ctx: ctx, | |
| 442 | + | v: v, | |
| 443 | + | cfg: cfg, | |
| 444 | + | log: log, | |
| 445 | + | svc: &services{}, | |
| 453 | 446 | } | |
| 454 | 447 | defer func() { | |
| 455 | 448 | if retErr != nil { | |
| @@ -555,12 +548,13 @@ func (si *serviceInit) initPublicTLS() error { | |||
| 555 | 548 | log := si.log | |
| 556 | 549 | ||
| 557 | 550 | publicTLSCfg := publictls.Config{ | |
| 558 | - | Enabled: si.cfg.TLS.ACME.Enabled, | |
| 559 | - | Email: si.cfg.TLS.ACME.Email, | |
| 560 | - | Challenge: si.cfg.TLS.ACME.Challenge, | |
| 561 | - | HTTPPort: si.cfg.Server.Port, | |
| 562 | - | TLSPort: si.cfg.Server.TLSPort, | |
| 563 | - | DataDir: resolveDataDir(si.cfg.Server.DataDir), | |
| 551 | + | Enabled: si.cfg.TLS.ACME.Enabled, | |
| 552 | + | Email: si.cfg.TLS.ACME.Email, | |
| 553 | + | Challenge: si.cfg.TLS.ACME.Challenge, | |
| 554 | + | HTTPPort: si.cfg.Server.Port, | |
| 555 | + | TLSPort: si.cfg.Server.TLSPort, | |
| 556 | + | DataDir: resolveDataDir(si.cfg.Server.DataDir), | |
| 557 | + | ObtainBatchSize: si.cfg.TLS.ACME.ObtainBatchSize, | |
| 564 | 558 | } | |
| 565 | 559 | ||
| 566 | 560 | tokenResolver := secrets.NewPublicTLSResolver(secrets.PublicTLSResolverConfig{}) | |
| @@ -606,27 +600,34 @@ func (si *serviceInit) initPublicTLS() error { | |||
| 606 | 600 | log.Warn().Err(err).Msg("failed to load ACME certificates, continuing") | |
| 607 | 601 | } | |
| 608 | 602 | ||
| 609 | - | if si.startPublicTLS { | |
| 610 | - | if err := svc.Reconcile(ctx); err != nil { | |
| 611 | - | log.Warn().Err(err).Msg("failed to reconcile ACME certificates, continuing") | |
| 612 | - | } | |
| 613 | - | svc.StartRenewalLoop(ctx, time.Hour) | |
| 614 | - | log.Info(). | |
| 615 | - | Str("email", si.cfg.TLS.ACME.Email). | |
| 616 | - | Str("challenge", string(effective.Mode)). | |
| 617 | - | Str("reason", effective.Reason). | |
| 618 | - | Msg("public ACME TLS initialized") | |
| 619 | - | } else { | |
| 620 | - | log.Info(). | |
| 621 | - | Str("email", si.cfg.TLS.ACME.Email). | |
| 622 | - | Str("challenge", string(effective.Mode)). | |
| 623 | - | Msg("public ACME TLS initialized (read-only, no renewal loop)") | |
| 624 | - | } | |
| 603 | + | log.Info(). | |
| 604 | + | Str("email", si.cfg.TLS.ACME.Email). | |
| 605 | + | Str("challenge", string(effective.Mode)). | |
| 606 | + | Msg("public ACME TLS initialized (runtime start deferred)") | |
| 625 | 607 | ||
| 626 | 608 | si.svc.publicTLSSvc = svc | |
| 609 | + | si.svc.publicTLSRuntime = svc | |
| 627 | 610 | return nil | |
| 628 | 611 | } | |
| 629 | 612 | ||
| 613 | + | // publicTLSRuntime is the subset of in.PublicTLSService needed at server runtime | |
| 614 | + | // after the HTTP listener is bound: reconcile missing certs and start the renewal | |
| 615 | + | // loop. It is nil when ACME is disabled. | |
| 616 | + | type publicTLSRuntime interface { | |
| 617 | + | Reconcile(context.Context) error | |
| 618 | + | StartRenewalLoop(context.Context, time.Duration) <-chan struct{} | |
| 619 | + | } | |
| 620 | + | ||
| 621 | + | func startPublicTLSRuntime(ctx context.Context, svc publicTLSRuntime, log zerowrap.Logger) error { | |
| 622 | + | if svc == nil { | |
| 623 | + | return nil | |
| 624 | + | } | |
| 625 | + | reconcileErr := svc.Reconcile(ctx) | |
| 626 | + | svc.StartRenewalLoop(ctx, time.Hour) | |
| 627 | + | log.Info().Msg("public ACME TLS runtime started") | |
| 628 | + | return reconcileErr | |
| 629 | + | } | |
| 630 | + | ||
| 630 | 631 | // initSecrets creates the domain secret store, env loader, and secret service. | |
| 631 | 632 | func (si *serviceInit) initSecrets() error { | |
| 632 | 633 | envDir, backend, passStore, domainSecretStore, err := createDomainSecretStore(si.cfg, si.log) | |
| @@ -2098,12 +2099,20 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger, accessWr | |||
| 2098 | 2099 | } | |
| 2099 | 2100 | ||
| 2100 | 2101 | httpsMux := http.NewServeMux() | |
| 2101 | - | if obHandler != nil { | |
| 2102 | - | httpsMux.HandleFunc("GET /ca", obHandler.ServeOnboardingPage) | |
| 2103 | - | httpsMux.HandleFunc("GET /ca.crt", obHandler.ServeCACert) | |
| 2104 | - | httpsMux.HandleFunc("GET /ca.mobileconfig", obHandler.ServeMobileconfig) | |
| 2102 | + | if obHandler != nil && cfg.Server.GordonDomain != "" { | |
| 2103 | + | gordonDomain := strings.TrimSuffix(strings.ToLower(strings.TrimSpace(cfg.Server.GordonDomain)), ".") | |
| 2104 | + | onboardingMux := http.NewServeMux() | |
| 2105 | + | registerOnboardingRoutes(onboardingMux, obHandler) | |
| 2106 | + | // Register onboarding paths host-gated so normal traffic hits | |
| 2107 | + | // proxyHandler directly through the catch-all / pattern. | |
| 2108 | + | httpsMux.Handle("GET /.well-known/gordon/", gordonDomainOnboardingGate(gordonDomain, onboardingMux, proxyHandler)) | |
| 2109 | + | httpsMux.Handle("GET /.well-known/gordon/ca", gordonDomainOnboardingGate(gordonDomain, onboardingMux, proxyHandler)) | |
| 2110 | + | httpsMux.Handle("GET /.well-known/gordon/ca.crt", gordonDomainOnboardingGate(gordonDomain, onboardingMux, proxyHandler)) | |
| 2111 | + | httpsMux.Handle("GET /.well-known/gordon/ca.mobileconfig", gordonDomainOnboardingGate(gordonDomain, onboardingMux, proxyHandler)) | |
| 2112 | + | httpsMux.Handle("/", proxyHandler) | |
| 2113 | + | } else { | |
| 2114 | + | httpsMux.Handle("/", proxyHandler) | |
| 2105 | 2115 | } | |
| 2106 | - | httpsMux.Handle("/", proxyHandler) | |
| 2107 | 2116 | ||
| 2108 | 2117 | httpsHandler := otelhttp.NewHandler(middleware.Chain(httpsProxyMiddlewares...)(httpsMux), "gordon.proxy.tls") | |
| 2109 | 2118 | ||
| @@ -2121,6 +2130,15 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger, accessWr | |||
| 2121 | 2130 | return registryOut, proxyOut, httpsOut | |
| 2122 | 2131 | } | |
| 2123 | 2132 | ||
| 2133 | + | // registerOnboardingRoutes registers CA onboarding well-known HTTP routes on | |
| 2134 | + | // the given mux. Both direct-HTTP and Gordon-domain HTTPS onboarding use this. | |
| 2135 | + | func registerOnboardingRoutes(mux *http.ServeMux, ob *onboarding.Handler) { | |
| 2136 | + | mux.HandleFunc("GET /.well-known/gordon/", ob.ServeOnboardingPage) | |
| 2137 | + | mux.HandleFunc("GET /.well-known/gordon/ca", ob.ServeOnboardingPage) | |
| 2138 | + | mux.HandleFunc("GET /.well-known/gordon/ca.crt", ob.ServeCACert) | |
| 2139 | + | mux.HandleFunc("GET /.well-known/gordon/ca.mobileconfig", ob.ServeMobileconfig) | |
| 2140 | + | } | |
| 2141 | + | ||
| 2124 | 2142 | // directHTTPOnboardingGate returns an http.Handler that splits HTTP traffic | |
| 2125 | 2143 | // by source IP. Trusted proxy IPs flow through to the normal proxy chain. | |
| 2126 | 2144 | // Direct clients are served the CA onboarding flow on allowed paths and | |
| @@ -2129,10 +2147,7 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger, accessWr | |||
| 2129 | 2147 | func directHTTPOnboardingGate(ob *onboarding.Handler, proxyNets []*net.IPNet, proxyChain http.Handler, log zerowrap.Logger) http.Handler { | |
| 2130 | 2148 | // Build a small mux for direct-client onboarding paths. | |
| 2131 | 2149 | onboardingMux := http.NewServeMux() | |
| 2132 | - | onboardingMux.HandleFunc("GET /{$}", ob.ServeOnboardingPage) | |
| 2133 | - | onboardingMux.HandleFunc("GET /ca", ob.ServeOnboardingPage) | |
| 2134 | - | onboardingMux.HandleFunc("GET /ca.crt", ob.ServeCACert) | |
| 2135 | - | onboardingMux.HandleFunc("GET /ca.mobileconfig", ob.ServeMobileconfig) | |
| 2150 | + | registerOnboardingRoutes(onboardingMux, ob) | |
| 2136 | 2151 | ||
| 2137 | 2152 | // Reserve ACME challenge path for future use. | |
| 2138 | 2153 | onboardingMux.HandleFunc("/.well-known/acme-challenge/", func(w http.ResponseWriter, _ *http.Request) { | |
| @@ -2159,6 +2174,32 @@ func directHTTPOnboardingGate(ob *onboarding.Handler, proxyNets []*net.IPNet, pr | |||
| 2159 | 2174 | }) | |
| 2160 | 2175 | } | |
| 2161 | 2176 | ||
| 2177 | + | // canonicalHostsEqual compares two hosts after normalising both: stripping | |
| 2178 | + | // port, trimming spaces, lowercasing, and removing trailing dot. | |
| 2179 | + | func canonicalHostsEqual(host, expected string) bool { | |
| 2180 | + | host = strings.TrimSpace(host) | |
| 2181 | + | if h, _, err := net.SplitHostPort(host); err == nil { | |
| 2182 | + | host = h | |
| 2183 | + | } | |
| 2184 | + | host = strings.TrimSuffix(strings.ToLower(host), ".") | |
| 2185 | + | expected = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(expected)), ".") | |
| 2186 | + | return host == expected | |
| 2187 | + | } | |
| 2188 | + | ||
| 2189 | + | // gordonDomainOnboardingGate returns a handler that serves onboarding routes | |
| 2190 | + | // only when the request host matches gordonDomain. For mismatched hosts it | |
| 2191 | + | // delegates to proxyHandler. gordonDomain must already be canonicalised | |
| 2192 | + | // (trimmed, lowered, trailing dot removed). | |
| 2193 | + | func gordonDomainOnboardingGate(gordonDomain string, onboardingMux, proxyHandler http.Handler) http.Handler { | |
| 2194 | + | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 2195 | + | if gordonDomain != "" && canonicalHostsEqual(r.Host, gordonDomain) { | |
| 2196 | + | onboardingMux.ServeHTTP(w, r) | |
| 2197 | + | return | |
| 2198 | + | } | |
| 2199 | + | proxyHandler.ServeHTTP(w, r) | |
| 2200 | + | }) | |
| 2201 | + | } | |
| 2202 | + | ||
| 2162 | 2203 | // directHTTPForbidden responds with 403 for non-onboarding HTTP paths. | |
| 2163 | 2204 | // HEAD requests get an empty body per HTTP semantics. | |
| 2164 | 2205 | func directHTTPForbidden(w http.ResponseWriter, r *http.Request) { | |
| @@ -2481,6 +2522,8 @@ func runServers(ctx context.Context, v *viper.Viper, cfg Config, svc *services, | |||
| 2481 | 2522 | } | |
| 2482 | 2523 | logEvent.Msg("Gordon is running") | |
| 2483 | 2524 | ||
| 2525 | + | startPublicTLSRuntimeWithWarning(ctx, svc.publicTLSRuntime, log) | |
| 2526 | + | ||
| 2484 | 2527 | schedulerCleanup, err := startOptionalSchedulers(ctx, cfg, svc, log, v) | |
| 2485 | 2528 | if err != nil { | |
| 2486 | 2529 | return err | |
| @@ -2498,6 +2541,12 @@ func runServers(ctx context.Context, v *viper.Viper, cfg Config, svc *services, | |||
| 2498 | 2541 | return nil | |
| 2499 | 2542 | } | |
| 2500 | 2543 | ||
| 2544 | + | func startPublicTLSRuntimeWithWarning(ctx context.Context, svc publicTLSRuntime, log zerowrap.Logger) { | |
| 2545 | + | if err := startPublicTLSRuntime(ctx, svc, log); err != nil { | |
| 2546 | + | log.Warn().Err(err).Msg("initial public ACME reconcile failed, continuing with renewal loop") | |
| 2547 | + | } | |
| 2548 | + | } | |
| 2549 | + | ||
| 2501 | 2550 | func waitForServerReady(ready <-chan struct{}, errChan <-chan error) error { | |
| 2502 | 2551 | select { | |
| 2503 | 2552 | case <-ready: | |
| @@ -3219,6 +3268,7 @@ func loadConfig(v *viper.Viper, configPath string) error { | |||
| 3219 | 3268 | v.SetDefault("tls.acme.enabled", false) | |
| 3220 | 3269 | v.SetDefault("tls.acme.email", "") | |
| 3221 | 3270 | v.SetDefault("tls.acme.challenge", "auto") | |
| 3271 | + | v.SetDefault("tls.acme.obtain_batch_size", 1) | |
| 3222 | 3272 | v.SetDefault("server.force_https_redirect", false) | |
| 3223 | 3273 | v.SetDefault("server.data_dir", DefaultDataDir()) | |
| 3224 | 3274 | v.SetDefault("server.runtime", "auto") | |
| @@ -198,9 +198,10 @@ type Config struct { | |||||
| 198 | 198 | ||||
| 199 | TLS struct { | 199 | TLS struct { | ||
| 200 | ACME struct { | 200 | ACME struct { | ||
| 201 | - | Enabled bool `mapstructure:"enabled"` | 201 | + | Enabled bool `mapstructure:"enabled"` |
| 202 | - | Email string `mapstructure:"email"` | 202 | + | Email string `mapstructure:"email"` |
| 203 | - | Challenge string `mapstructure:"challenge"` | 203 | + | Challenge string `mapstructure:"challenge"` |
| 204 | + | ObtainBatchSize int `mapstructure:"obtain_batch_size"` | |||
| 204 | } `mapstructure:"acme"` | 205 | } `mapstructure:"acme"` | ||
| 205 | } `mapstructure:"tls"` | 206 | } `mapstructure:"tls"` | ||
| 206 | } | 207 | } | ||
| @@ -239,6 +240,7 @@ type services struct { | |||||
| 239 | pkiSvc *pkiusecase.Service | 240 | pkiSvc *pkiusecase.Service | ||
| 240 | reloadCoordinator *reloadCoordinator | 241 | reloadCoordinator *reloadCoordinator | ||
| 241 | publicTLSSvc in.PublicTLSService | 242 | publicTLSSvc in.PublicTLSService | ||
| 243 | + | publicTLSRuntime publicTLSRuntime | |||
| 242 | registryHandler interface { | 244 | registryHandler interface { | ||
| 243 | UpdateBlobLimits(maxBlobChunkSize, maxBlobSize int64) | 245 | UpdateBlobLimits(maxBlobChunkSize, maxBlobSize int64) | ||
| 244 | } | 246 | } | ||
| @@ -416,40 +418,31 @@ func initAccessLog(cfg Config, log zerowrap.Logger) (*accesslog.Writer, error) { | |||||
| 416 | return writer, nil | 418 | return writer, nil | ||
| 417 | } | 419 | } | ||
| 418 | 420 | ||||
| 419 | - | // serviceOptions controls optional behavior for createServicesWithOptions. | |||
| 420 | - | type serviceOptions struct { | |||
| 421 | - | // StartPublicTLS controls whether ACME Reconcile and renewal loop are started. | |||
| 422 | - | // Server runtime sets true; Kernel/local CLI sets false (read-only status). | |||
| 423 | - | StartPublicTLS bool | |||
| 424 | - | } | |||
| 425 | - | ||||
| 426 | // serviceInit holds the shared context for service initialization helpers. | 421 | // serviceInit holds the shared context for service initialization helpers. | ||
| 427 | type serviceInit struct { | 422 | type serviceInit struct { | ||
| 428 | - | ctx context.Context | 423 | + | ctx context.Context |
| 429 | - | v *viper.Viper | 424 | + | v *viper.Viper |
| 430 | - | cfg Config | 425 | + | cfg Config |
| 431 | - | log zerowrap.Logger | 426 | + | log zerowrap.Logger |
| 432 | - | svc *services | 427 | + | svc *services |
| 433 | - | startPublicTLS bool | |||
| 434 | } | 428 | } | ||
| 435 | 429 | ||||
| 436 | // createServices creates all the application services for server runtime. | 430 | // createServices creates all the application services for server runtime. | ||
| 437 | - | // It calls createServicesWithOptions with StartPublicTLS: true. | 431 | + | // Public ACME reconciliation is started later, after the HTTP listener is bound. |
| 438 | func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowrap.Logger) (_ *services, retErr error) { | 432 | func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowrap.Logger) (_ *services, retErr error) { | ||
| 439 | - | return createServicesWithOptions(ctx, v, cfg, log, serviceOptions{StartPublicTLS: true}) | 433 | + | return createServicesWithOptions(ctx, v, cfg, log) |
| 440 | } | 434 | } | ||
| 441 | 435 | ||||
| 442 | - | // createServicesWithOptions creates all the application services with the given options. | 436 | + | // createServicesWithOptions creates all the application services. |
| 443 | - | // When StartPublicTLS is false, ACME Reconcile and renewal loop are skipped so that | 437 | + | // ACME Reconcile and renewal loop are started later from runServers, |
| 444 | - | // read-only CLI commands (e.g. gordon tls status) do not perform ACME side effects. | 438 | + | // after HTTP listeners are bound. |
| 445 | - | func createServicesWithOptions(ctx context.Context, v *viper.Viper, cfg Config, log zerowrap.Logger, opts serviceOptions) (_ *services, retErr error) { | 439 | + | func createServicesWithOptions(ctx context.Context, v *viper.Viper, cfg Config, log zerowrap.Logger) (_ *services, retErr error) { |
| 446 | si := &serviceInit{ | 440 | si := &serviceInit{ | ||
| 447 | - | ctx: ctx, | 441 | + | ctx: ctx, |
| 448 | - | v: v, | 442 | + | v: v, |
| 449 | - | cfg: cfg, | 443 | + | cfg: cfg, |
| 450 | - | log: log, | 444 | + | log: log, |
| 451 | - | svc: &services{}, | 445 | + | svc: &services{}, |
| 452 | - | startPublicTLS: opts.StartPublicTLS, | |||
| 453 | } | 446 | } | ||
| 454 | defer func() { | 447 | defer func() { | ||
| 455 | if retErr != nil { | 448 | if retErr != nil { | ||
| @@ -555,12 +548,13 @@ func (si *serviceInit) initPublicTLS() error { | |||||
| 555 | log := si.log | 548 | log := si.log | ||
| 556 | 549 | ||||
| 557 | publicTLSCfg := publictls.Config{ | 550 | publicTLSCfg := publictls.Config{ | ||
| 558 | - | Enabled: si.cfg.TLS.ACME.Enabled, | 551 | + | Enabled: si.cfg.TLS.ACME.Enabled, |
| 559 | - | Email: si.cfg.TLS.ACME.Email, | 552 | + | Email: si.cfg.TLS.ACME.Email, |
| 560 | - | Challenge: si.cfg.TLS.ACME.Challenge, | 553 | + | Challenge: si.cfg.TLS.ACME.Challenge, |
| 561 | - | HTTPPort: si.cfg.Server.Port, | 554 | + | HTTPPort: si.cfg.Server.Port, |
| 562 | - | TLSPort: si.cfg.Server.TLSPort, | 555 | + | TLSPort: si.cfg.Server.TLSPort, |
| 563 | - | DataDir: resolveDataDir(si.cfg.Server.DataDir), | 556 | + | DataDir: resolveDataDir(si.cfg.Server.DataDir), |
| 557 | + | ObtainBatchSize: si.cfg.TLS.ACME.ObtainBatchSize, | |||
| 564 | } | 558 | } | ||
| 565 | 559 | ||||
| 566 | tokenResolver := secrets.NewPublicTLSResolver(secrets.PublicTLSResolverConfig{}) | 560 | tokenResolver := secrets.NewPublicTLSResolver(secrets.PublicTLSResolverConfig{}) | ||
| @@ -606,27 +600,34 @@ func (si *serviceInit) initPublicTLS() error { | |||||
| 606 | log.Warn().Err(err).Msg("failed to load ACME certificates, continuing") | 600 | log.Warn().Err(err).Msg("failed to load ACME certificates, continuing") | ||
| 607 | } | 601 | } | ||
| 608 | 602 | ||||
| 609 | - | if si.startPublicTLS { | 603 | + | log.Info(). |
| 610 | - | if err := svc.Reconcile(ctx); err != nil { | 604 | + | Str("email", si.cfg.TLS.ACME.Email). |
| 611 | - | log.Warn().Err(err).Msg("failed to reconcile ACME certificates, continuing") | 605 | + | Str("challenge", string(effective.Mode)). |
| 612 | - | } | 606 | + | Msg("public ACME TLS initialized (runtime start deferred)") |
| 613 | - | svc.StartRenewalLoop(ctx, time.Hour) | |||
| 614 | - | log.Info(). | |||
| 615 | - | Str("email", si.cfg.TLS.ACME.Email). | |||
| 616 | - | Str("challenge", string(effective.Mode)). | |||
| 617 | - | Str("reason", effective.Reason). | |||
| 618 | - | Msg("public ACME TLS initialized") | |||
| 619 | - | } else { | |||
| 620 | - | log.Info(). | |||
| 621 | - | Str("email", si.cfg.TLS.ACME.Email). | |||
| 622 | - | Str("challenge", string(effective.Mode)). | |||
| 623 | - | Msg("public ACME TLS initialized (read-only, no renewal loop)") | |||
| 624 | - | } | |||
| 625 | 607 | ||||
| 626 | si.svc.publicTLSSvc = svc | 608 | si.svc.publicTLSSvc = svc | ||
| 609 | + | si.svc.publicTLSRuntime = svc | |||
| 627 | return nil | 610 | return nil | ||
| 628 | } | 611 | } | ||
| 629 | 612 | ||||
| 613 | + | // publicTLSRuntime is the subset of in.PublicTLSService needed at server runtime | |||
| 614 | + | // after the HTTP listener is bound: reconcile missing certs and start the renewal | |||
| 615 | + | // loop. It is nil when ACME is disabled. | |||
| 616 | + | type publicTLSRuntime interface { | |||
| 617 | + | Reconcile(context.Context) error | |||
| 618 | + | StartRenewalLoop(context.Context, time.Duration) <-chan struct{} | |||
| 619 | + | } | |||
| 620 | + | ||||
| 621 | + | func startPublicTLSRuntime(ctx context.Context, svc publicTLSRuntime, log zerowrap.Logger) error { | |||
| 622 | + | if svc == nil { | |||
| 623 | + | return nil | |||
| 624 | + | } | |||
| 625 | + | reconcileErr := svc.Reconcile(ctx) | |||
| 626 | + | svc.StartRenewalLoop(ctx, time.Hour) | |||
| 627 | + | log.Info().Msg("public ACME TLS runtime started") | |||
| 628 | + | return reconcileErr | |||
| 629 | + | } | |||
| 630 | + | ||||
| 630 | // initSecrets creates the domain secret store, env loader, and secret service. | 631 | // initSecrets creates the domain secret store, env loader, and secret service. | ||
| 631 | func (si *serviceInit) initSecrets() error { | 632 | func (si *serviceInit) initSecrets() error { | ||
| 632 | envDir, backend, passStore, domainSecretStore, err := createDomainSecretStore(si.cfg, si.log) | 633 | envDir, backend, passStore, domainSecretStore, err := createDomainSecretStore(si.cfg, si.log) | ||
| @@ -2098,12 +2099,20 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger, accessWr | |||||
| 2098 | } | 2099 | } | ||
| 2099 | 2100 | ||||
| 2100 | httpsMux := http.NewServeMux() | 2101 | httpsMux := http.NewServeMux() | ||
| 2101 | - | if obHandler != nil { | 2102 | + | if obHandler != nil && cfg.Server.GordonDomain != "" { |
| 2102 | - | httpsMux.HandleFunc("GET /ca", obHandler.ServeOnboardingPage) | 2103 | + | gordonDomain := strings.TrimSuffix(strings.ToLower(strings.TrimSpace(cfg.Server.GordonDomain)), ".") |
| 2103 | - | httpsMux.HandleFunc("GET /ca.crt", obHandler.ServeCACert) | 2104 | + | onboardingMux := http.NewServeMux() |
| 2104 | - | httpsMux.HandleFunc("GET /ca.mobileconfig", obHandler.ServeMobileconfig) | 2105 | + | registerOnboardingRoutes(onboardingMux, obHandler) |
| 2106 | + | // Register onboarding paths host-gated so normal traffic hits | |||
| 2107 | + | // proxyHandler directly through the catch-all / pattern. | |||
| 2108 | + | httpsMux.Handle("GET /.well-known/gordon/", gordonDomainOnboardingGate(gordonDomain, onboardingMux, proxyHandler)) | |||
| 2109 | + | httpsMux.Handle("GET /.well-known/gordon/ca", gordonDomainOnboardingGate(gordonDomain, onboardingMux, proxyHandler)) | |||
| 2110 | + | httpsMux.Handle("GET /.well-known/gordon/ca.crt", gordonDomainOnboardingGate(gordonDomain, onboardingMux, proxyHandler)) | |||
| 2111 | + | httpsMux.Handle("GET /.well-known/gordon/ca.mobileconfig", gordonDomainOnboardingGate(gordonDomain, onboardingMux, proxyHandler)) | |||
| 2112 | + | httpsMux.Handle("/", proxyHandler) | |||
| 2113 | + | } else { | |||
| 2114 | + | httpsMux.Handle("/", proxyHandler) | |||
| 2105 | } | 2115 | } | ||
| 2106 | - | httpsMux.Handle("/", proxyHandler) | |||
| 2107 | 2116 | ||||
| 2108 | httpsHandler := otelhttp.NewHandler(middleware.Chain(httpsProxyMiddlewares...)(httpsMux), "gordon.proxy.tls") | 2117 | httpsHandler := otelhttp.NewHandler(middleware.Chain(httpsProxyMiddlewares...)(httpsMux), "gordon.proxy.tls") | ||
| 2109 | 2118 | ||||
| @@ -2121,6 +2130,15 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger, accessWr | |||||
| 2121 | return registryOut, proxyOut, httpsOut | 2130 | return registryOut, proxyOut, httpsOut | ||
| 2122 | } | 2131 | } | ||
| 2123 | 2132 | ||||
| 2133 | + | // registerOnboardingRoutes registers CA onboarding well-known HTTP routes on | |||
| 2134 | + | // the given mux. Both direct-HTTP and Gordon-domain HTTPS onboarding use this. | |||
| 2135 | + | func registerOnboardingRoutes(mux *http.ServeMux, ob *onboarding.Handler) { | |||
| 2136 | + | mux.HandleFunc("GET /.well-known/gordon/", ob.ServeOnboardingPage) | |||
| 2137 | + | mux.HandleFunc("GET /.well-known/gordon/ca", ob.ServeOnboardingPage) | |||
| 2138 | + | mux.HandleFunc("GET /.well-known/gordon/ca.crt", ob.ServeCACert) | |||
| 2139 | + | mux.HandleFunc("GET /.well-known/gordon/ca.mobileconfig", ob.ServeMobileconfig) | |||
| 2140 | + | } | |||
| 2141 | + | ||||
| 2124 | // directHTTPOnboardingGate returns an http.Handler that splits HTTP traffic | 2142 | // directHTTPOnboardingGate returns an http.Handler that splits HTTP traffic | ||
| 2125 | // by source IP. Trusted proxy IPs flow through to the normal proxy chain. | 2143 | // by source IP. Trusted proxy IPs flow through to the normal proxy chain. | ||
| 2126 | // Direct clients are served the CA onboarding flow on allowed paths and | 2144 | // Direct clients are served the CA onboarding flow on allowed paths and | ||
| @@ -2129,10 +2147,7 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger, accessWr | |||||
| 2129 | func directHTTPOnboardingGate(ob *onboarding.Handler, proxyNets []*net.IPNet, proxyChain http.Handler, log zerowrap.Logger) http.Handler { | 2147 | func directHTTPOnboardingGate(ob *onboarding.Handler, proxyNets []*net.IPNet, proxyChain http.Handler, log zerowrap.Logger) http.Handler { | ||
| 2130 | // Build a small mux for direct-client onboarding paths. | 2148 | // Build a small mux for direct-client onboarding paths. | ||
| 2131 | onboardingMux := http.NewServeMux() | 2149 | onboardingMux := http.NewServeMux() | ||
| 2132 | - | onboardingMux.HandleFunc("GET /{$}", ob.ServeOnboardingPage) | 2150 | + | registerOnboardingRoutes(onboardingMux, ob) |
| 2133 | - | onboardingMux.HandleFunc("GET /ca", ob.ServeOnboardingPage) | |||
| 2134 | - | onboardingMux.HandleFunc("GET /ca.crt", ob.ServeCACert) | |||
| 2135 | - | onboardingMux.HandleFunc("GET /ca.mobileconfig", ob.ServeMobileconfig) | |||
| 2136 | 2151 | ||||
| 2137 | // Reserve ACME challenge path for future use. | 2152 | // Reserve ACME challenge path for future use. | ||
| 2138 | onboardingMux.HandleFunc("/.well-known/acme-challenge/", func(w http.ResponseWriter, _ *http.Request) { | 2153 | onboardingMux.HandleFunc("/.well-known/acme-challenge/", func(w http.ResponseWriter, _ *http.Request) { | ||
| @@ -2159,6 +2174,32 @@ func directHTTPOnboardingGate(ob *onboarding.Handler, proxyNets []*net.IPNet, pr | |||||
| 2159 | }) | 2174 | }) | ||
| 2160 | } | 2175 | } | ||
| 2161 | 2176 | ||||
| 2177 | + | // canonicalHostsEqual compares two hosts after normalising both: stripping | |||
| 2178 | + | // port, trimming spaces, lowercasing, and removing trailing dot. | |||
| 2179 | + | func canonicalHostsEqual(host, expected string) bool { | |||
| 2180 | + | host = strings.TrimSpace(host) | |||
| 2181 | + | if h, _, err := net.SplitHostPort(host); err == nil { | |||
| 2182 | + | host = h | |||
| 2183 | + | } | |||
| 2184 | + | host = strings.TrimSuffix(strings.ToLower(host), ".") | |||
| 2185 | + | expected = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(expected)), ".") | |||
| 2186 | + | return host == expected | |||
| 2187 | + | } | |||
| 2188 | + | ||||
| 2189 | + | // gordonDomainOnboardingGate returns a handler that serves onboarding routes | |||
| 2190 | + | // only when the request host matches gordonDomain. For mismatched hosts it | |||
| 2191 | + | // delegates to proxyHandler. gordonDomain must already be canonicalised | |||
| 2192 | + | // (trimmed, lowered, trailing dot removed). | |||
| 2193 | + | func gordonDomainOnboardingGate(gordonDomain string, onboardingMux, proxyHandler http.Handler) http.Handler { | |||
| 2194 | + | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 2195 | + | if gordonDomain != "" && canonicalHostsEqual(r.Host, gordonDomain) { | |||
| 2196 | + | onboardingMux.ServeHTTP(w, r) | |||
| 2197 | + | return | |||
| 2198 | + | } | |||
| 2199 | + | proxyHandler.ServeHTTP(w, r) | |||
| 2200 | + | }) | |||
| 2201 | + | } | |||
| 2202 | + | ||||
| 2162 | // directHTTPForbidden responds with 403 for non-onboarding HTTP paths. | 2203 | // directHTTPForbidden responds with 403 for non-onboarding HTTP paths. | ||
| 2163 | // HEAD requests get an empty body per HTTP semantics. | 2204 | // HEAD requests get an empty body per HTTP semantics. | ||
| 2164 | func directHTTPForbidden(w http.ResponseWriter, r *http.Request) { | 2205 | func directHTTPForbidden(w http.ResponseWriter, r *http.Request) { | ||
| @@ -2481,6 +2522,8 @@ func runServers(ctx context.Context, v *viper.Viper, cfg Config, svc *services, | |||||
| 2481 | } | 2522 | } | ||
| 2482 | logEvent.Msg("Gordon is running") | 2523 | logEvent.Msg("Gordon is running") | ||
| 2483 | 2524 | ||||
| 2525 | + | startPublicTLSRuntimeWithWarning(ctx, svc.publicTLSRuntime, log) | |||
| 2526 | + | ||||
| 2484 | schedulerCleanup, err := startOptionalSchedulers(ctx, cfg, svc, log, v) | 2527 | schedulerCleanup, err := startOptionalSchedulers(ctx, cfg, svc, log, v) | ||
| 2485 | if err != nil { | 2528 | if err != nil { | ||
| 2486 | return err | 2529 | return err | ||
| @@ -2498,6 +2541,12 @@ func runServers(ctx context.Context, v *viper.Viper, cfg Config, svc *services, | |||||
| 2498 | return nil | 2541 | return nil | ||
| 2499 | } | 2542 | } | ||
| 2500 | 2543 | ||||
| 2544 | + | func startPublicTLSRuntimeWithWarning(ctx context.Context, svc publicTLSRuntime, log zerowrap.Logger) { | |||
| 2545 | + | if err := startPublicTLSRuntime(ctx, svc, log); err != nil { | |||
| 2546 | + | log.Warn().Err(err).Msg("initial public ACME reconcile failed, continuing with renewal loop") | |||
| 2547 | + | } | |||
| 2548 | + | } | |||
| 2549 | + | ||||
| 2501 | func waitForServerReady(ready <-chan struct{}, errChan <-chan error) error { | 2550 | func waitForServerReady(ready <-chan struct{}, errChan <-chan error) error { | ||
| 2502 | select { | 2551 | select { | ||
| 2503 | case <-ready: | 2552 | case <-ready: | ||
| @@ -3219,6 +3268,7 @@ func loadConfig(v *viper.Viper, configPath string) error { | |||||
| 3219 | v.SetDefault("tls.acme.enabled", false) | 3268 | v.SetDefault("tls.acme.enabled", false) | ||
| 3220 | v.SetDefault("tls.acme.email", "") | 3269 | v.SetDefault("tls.acme.email", "") | ||
| 3221 | v.SetDefault("tls.acme.challenge", "auto") | 3270 | v.SetDefault("tls.acme.challenge", "auto") | ||
| 3271 | + | v.SetDefault("tls.acme.obtain_batch_size", 1) | |||
| 3222 | v.SetDefault("server.force_https_redirect", false) | 3272 | v.SetDefault("server.force_https_redirect", false) | ||
| 3223 | v.SetDefault("server.data_dir", DefaultDataDir()) | 3273 | v.SetDefault("server.data_dir", DefaultDataDir()) | ||
| 3224 | v.SetDefault("server.runtime", "auto") | 3274 | v.SetDefault("server.runtime", "auto") | ||
M internal/app/run_http_handlers_test.go +152 -65
| @@ -2,6 +2,7 @@ package app | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "errors" | |
| 5 | 6 | "io" | |
| 6 | 7 | "net/http" | |
| 7 | 8 | "net/http/httptest" | |
| @@ -9,16 +10,30 @@ import ( | |||
| 9 | 10 | "path/filepath" | |
| 10 | 11 | "sync" | |
| 11 | 12 | "testing" | |
| 13 | + | "time" | |
| 12 | 14 | ||
| 13 | 15 | "github.com/bnema/zerowrap" | |
| 14 | 16 | "github.com/stretchr/testify/assert" | |
| 17 | + | "github.com/stretchr/testify/mock" | |
| 15 | 18 | "github.com/stretchr/testify/require" | |
| 16 | 19 | ||
| 17 | 20 | adminhttp "github.com/bnema/gordon/internal/adapters/in/http/admin" | |
| 18 | 21 | pkiadapter "github.com/bnema/gordon/internal/adapters/out/pki" | |
| 22 | + | inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks" | |
| 19 | 23 | out "github.com/bnema/gordon/internal/boundaries/out" | |
| 24 | + | proxyusecase "github.com/bnema/gordon/internal/usecase/proxy" | |
| 20 | 25 | ) | |
| 21 | 26 | ||
| 27 | + | func newNotFoundProxyService(t *testing.T) *proxyusecase.Service { | |
| 28 | + | t.Helper() | |
| 29 | + | configSvc := inmocks.NewMockConfigService(t) | |
| 30 | + | configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{}).Maybe() | |
| 31 | + | configSvc.EXPECT().GetRoutes(mock.Anything).Return(nil).Maybe() | |
| 32 | + | containerSvc := inmocks.NewMockContainerService(t) | |
| 33 | + | containerSvc.EXPECT().Get(mock.Anything, mock.Anything).Return(nil, false).Maybe() | |
| 34 | + | return proxyusecase.NewService(nil, containerSvc, configSvc, proxyusecase.Config{}) | |
| 35 | + | } | |
| 36 | + | ||
| 22 | 37 | // testAccessLogWriter is a thread-safe mock AccessLogWriter for tests. | |
| 23 | 38 | type testAccessLogWriter struct { | |
| 24 | 39 | mu sync.Mutex | |
| @@ -120,60 +135,50 @@ const directAddr = "203.0.113.10:12345" | |||
| 120 | 135 | // trustedProxyAddr is an IP inside the 173.245.48.0/20 range. | |
| 121 | 136 | const trustedProxyAddr = "173.245.48.5:12345" | |
| 122 | 137 | ||
| 123 | - | func TestCreateHTTPHandlers_DirectHTTPRoot_ServesOnboarding(t *testing.T) { | |
| 124 | - | t.Parallel() | |
| 125 | - | ca := newTestCA(t) | |
| 126 | - | cfg := newOnboardingConfig() | |
| 127 | - | svc := &services{caAdapter: ca} | |
| 128 | - | ||
| 129 | - | _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil) | |
| 130 | - | ||
| 131 | - | req := httptest.NewRequest(http.MethodGet, "/", nil) | |
| 132 | - | req.RemoteAddr = directAddr | |
| 133 | - | req.Host = "o2.bnema.dev" | |
| 134 | - | rec := httptest.NewRecorder() | |
| 135 | - | httpHandler.ServeHTTP(rec, req) | |
| 136 | - | ||
| 137 | - | assert.Equal(t, http.StatusOK, rec.Code) | |
| 138 | - | assert.Contains(t, rec.Header().Get("Content-Type"), "text/html") | |
| 139 | - | assert.Contains(t, rec.Body.String(), "Trust CA Certificate") | |
| 140 | - | } | |
| 141 | - | ||
| 142 | - | func TestCreateHTTPHandlers_DirectHTTPCertPath_ServesCACert(t *testing.T) { | |
| 143 | - | t.Parallel() | |
| 144 | - | ca := newTestCA(t) | |
| 145 | - | cfg := newOnboardingConfig() | |
| 146 | - | svc := &services{caAdapter: ca} | |
| 147 | - | ||
| 148 | - | _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil) | |
| 149 | - | ||
| 150 | - | req := httptest.NewRequest(http.MethodGet, "/ca.crt", nil) | |
| 151 | - | req.RemoteAddr = directAddr | |
| 152 | - | req.Host = "o2.bnema.dev" | |
| 153 | - | rec := httptest.NewRecorder() | |
| 154 | - | httpHandler.ServeHTTP(rec, req) | |
| 155 | - | ||
| 156 | - | assert.Equal(t, http.StatusOK, rec.Code) | |
| 157 | - | assert.Contains(t, rec.Header().Get("Content-Type"), "application/x-x509-ca-cert") | |
| 158 | - | assert.Contains(t, rec.Body.String(), "BEGIN CERTIFICATE") | |
| 159 | - | } | |
| 160 | - | ||
| 161 | - | func TestCreateHTTPHandlers_DirectHTTPUnknownPath_ReturnsForbidden(t *testing.T) { | |
| 138 | + | func TestCreateHTTPHandlers_DirectHTTPOnboarding_AllPaths(t *testing.T) { | |
| 162 | 139 | t.Parallel() | |
| 163 | - | ca := newTestCA(t) | |
| 164 | - | cfg := newOnboardingConfig() | |
| 165 | - | svc := &services{caAdapter: ca} | |
| 166 | 140 | ||
| 167 | - | _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil) | |
| 168 | - | ||
| 169 | - | req := httptest.NewRequest(http.MethodGet, "/web", nil) | |
| 170 | - | req.RemoteAddr = directAddr | |
| 171 | - | req.Host = "o2.bnema.dev" | |
| 172 | - | rec := httptest.NewRecorder() | |
| 173 | - | httpHandler.ServeHTTP(rec, req) | |
| 141 | + | tests := []struct { | |
| 142 | + | name string | |
| 143 | + | path string | |
| 144 | + | wantCode int | |
| 145 | + | wantContent string // substring check | |
| 146 | + | wantType string // Content-Type substring | |
| 147 | + | }{ | |
| 148 | + | {name: ".well-known/gordon/ serves onboarding", path: "/.well-known/gordon/", wantCode: http.StatusOK, wantContent: "Trust CA Certificate", wantType: "text/html"}, | |
| 149 | + | {name: ".well-known/gordon/ca serves onboarding", path: "/.well-known/gordon/ca", wantCode: http.StatusOK, wantContent: "Trust CA Certificate", wantType: "text/html"}, | |
| 150 | + | {name: ".well-known/gordon/ca.crt serves CA cert", path: "/.well-known/gordon/ca.crt", wantCode: http.StatusOK, wantContent: "BEGIN CERTIFICATE", wantType: "application/x-x509-ca-cert"}, | |
| 151 | + | {name: ".well-known/gordon/ca.mobileconfig serves mobileconfig", path: "/.well-known/gordon/ca.mobileconfig", wantCode: http.StatusOK, wantContent: "<?xml", wantType: "application/x-apple-aspen-config"}, | |
| 152 | + | {name: "root returns forbidden", path: "/", wantCode: http.StatusForbidden, wantContent: "Only certificate onboarding is available", wantType: "text/plain"}, | |
| 153 | + | {name: "former /ca alias returns forbidden", path: "/ca", wantCode: http.StatusForbidden, wantContent: "Only certificate onboarding is available", wantType: "text/plain"}, | |
| 154 | + | {name: "former /ca.crt alias returns forbidden", path: "/ca.crt", wantCode: http.StatusForbidden, wantContent: "Only certificate onboarding is available", wantType: "text/plain"}, | |
| 155 | + | {name: "former /ca.mobileconfig alias returns forbidden", path: "/ca.mobileconfig", wantCode: http.StatusForbidden, wantContent: "Only certificate onboarding is available", wantType: "text/plain"}, | |
| 156 | + | {name: "unknown path returns forbidden", path: "/web", wantCode: http.StatusForbidden, wantContent: "Only certificate onboarding is available", wantType: "text/plain"}, | |
| 157 | + | } | |
| 174 | 158 | ||
| 175 | - | assert.Equal(t, http.StatusForbidden, rec.Code) | |
| 176 | - | assert.Contains(t, rec.Header().Get("Content-Type"), "text/plain") | |
| 159 | + | for _, tc := range tests { | |
| 160 | + | t.Run(tc.name, func(t *testing.T) { | |
| 161 | + | ca := newTestCA(t) | |
| 162 | + | cfg := newOnboardingConfig() | |
| 163 | + | svc := &services{caAdapter: ca} | |
| 164 | + | ||
| 165 | + | _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil) | |
| 166 | + | ||
| 167 | + | req := httptest.NewRequest(http.MethodGet, tc.path, nil) | |
| 168 | + | req.RemoteAddr = directAddr | |
| 169 | + | req.Host = "o2.bnema.dev" | |
| 170 | + | rec := httptest.NewRecorder() | |
| 171 | + | httpHandler.ServeHTTP(rec, req) | |
| 172 | + | ||
| 173 | + | assert.Equal(t, tc.wantCode, rec.Code, "%s: status code", tc.name) | |
| 174 | + | if tc.wantType != "" { | |
| 175 | + | assert.Contains(t, rec.Header().Get("Content-Type"), tc.wantType, "%s: content type", tc.name) | |
| 176 | + | } | |
| 177 | + | if tc.wantContent != "" { | |
| 178 | + | assert.Contains(t, rec.Body.String(), tc.wantContent, "%s: body content", tc.name) | |
| 179 | + | } | |
| 180 | + | }) | |
| 181 | + | } | |
| 177 | 182 | } | |
| 178 | 183 | ||
| 179 | 184 | func TestCreateHTTPHandlers_TrustedProxyHTTP_DoesNotServeOnboarding(t *testing.T) { | |
| @@ -196,7 +201,7 @@ func TestCreateHTTPHandlers_TrustedProxyHTTP_DoesNotServeOnboarding(t *testing.T | |||
| 196 | 201 | assert.NotContains(t, rec.Body.String(), "Trust CA Certificate") | |
| 197 | 202 | } | |
| 198 | 203 | ||
| 199 | - | func TestCreateHTTPHandlers_TrustedProxyCACertPath_DoesNotServeOnboardingResource(t *testing.T) { | |
| 204 | + | func TestCreateHTTPHandlers_TrustedProxyWellKnownCACertPath_DoesNotServeOnboardingResource(t *testing.T) { | |
| 200 | 205 | t.Parallel() | |
| 201 | 206 | ca := newTestCA(t) | |
| 202 | 207 | cfg := newOnboardingConfig() | |
| @@ -204,7 +209,7 @@ func TestCreateHTTPHandlers_TrustedProxyCACertPath_DoesNotServeOnboardingResourc | |||
| 204 | 209 | ||
| 205 | 210 | _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil) | |
| 206 | 211 | ||
| 207 | - | req := httptest.NewRequest(http.MethodGet, "/ca.crt", nil) | |
| 212 | + | req := httptest.NewRequest(http.MethodGet, "/.well-known/gordon/ca.crt", nil) | |
| 208 | 213 | req.RemoteAddr = trustedProxyAddr | |
| 209 | 214 | req.Host = "app.example.com" | |
| 210 | 215 | rec := httptest.NewRecorder() | |
| @@ -224,7 +229,7 @@ func TestCreateHTTPHandlers_ForceHTTPSRedirect_DoesNotBypassDirectOnboarding(t * | |||
| 224 | 229 | ||
| 225 | 230 | _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil) | |
| 226 | 231 | ||
| 227 | - | req := httptest.NewRequest(http.MethodGet, "/", nil) | |
| 232 | + | req := httptest.NewRequest(http.MethodGet, "/.well-known/gordon/", nil) | |
| 228 | 233 | req.RemoteAddr = directAddr | |
| 229 | 234 | req.Host = "o2.bnema.dev" | |
| 230 | 235 | rec := httptest.NewRecorder() | |
| @@ -235,29 +240,61 @@ func TestCreateHTTPHandlers_ForceHTTPSRedirect_DoesNotBypassDirectOnboarding(t * | |||
| 235 | 240 | assert.Contains(t, rec.Body.String(), "Trust CA Certificate") | |
| 236 | 241 | } | |
| 237 | 242 | ||
| 238 | - | func TestCreateHTTPHandlers_HTTPSOnboarding_RemainsAvailable(t *testing.T) { | |
| 243 | + | func TestCreateHTTPHandlers_HTTPSOnboarding_RemainsAvailableOnGordonDomain(t *testing.T) { | |
| 239 | 244 | t.Parallel() | |
| 240 | 245 | ca := newTestCA(t) | |
| 241 | 246 | cfg := newOnboardingConfig() | |
| 242 | - | svc := &services{caAdapter: ca} | |
| 247 | + | cfg.Server.GordonDomain = "gordon.example.com" | |
| 248 | + | svc := &services{caAdapter: ca, proxySvc: newNotFoundProxyService(t)} | |
| 243 | 249 | ||
| 244 | 250 | _, _, httpsHandler := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil) | |
| 245 | 251 | ||
| 246 | - | req := httptest.NewRequest(http.MethodGet, "/ca", nil) | |
| 252 | + | for _, host := range []string{"gordon.example.com", "Gordon.Example.Com.:8443"} { | |
| 253 | + | req := httptest.NewRequest(http.MethodGet, "/.well-known/gordon/ca", nil) | |
| 254 | + | req.RemoteAddr = directAddr | |
| 255 | + | req.Host = host | |
| 256 | + | rec := httptest.NewRecorder() | |
| 257 | + | httpsHandler.ServeHTTP(rec, req) | |
| 258 | + | ||
| 259 | + | assert.Equal(t, http.StatusOK, rec.Code, "host %s", host) | |
| 260 | + | assert.Contains(t, rec.Body.String(), "Trust CA Certificate", "host %s", host) | |
| 261 | + | } | |
| 262 | + | } | |
| 263 | + | ||
| 264 | + | func TestCreateHTTPHandlers_HTTPSOnboarding_DoesNotPreemptAppHostCAPath(t *testing.T) { | |
| 265 | + | t.Parallel() | |
| 266 | + | ca := newTestCA(t) | |
| 267 | + | cfg := newOnboardingConfig() | |
| 268 | + | cfg.Server.GordonDomain = "gordon.example.com" | |
| 269 | + | svc := &services{caAdapter: ca, proxySvc: newNotFoundProxyService(t)} | |
| 270 | + | ||
| 271 | + | _, _, httpsHandler := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil) | |
| 272 | + | ||
| 273 | + | req := httptest.NewRequest(http.MethodGet, "/.well-known/gordon/ca", nil) | |
| 247 | 274 | req.RemoteAddr = directAddr | |
| 248 | - | req.Host = "o2.bnema.dev" | |
| 275 | + | req.Host = "app.example.com" | |
| 249 | 276 | rec := httptest.NewRecorder() | |
| 250 | 277 | httpsHandler.ServeHTTP(rec, req) | |
| 251 | 278 | ||
| 252 | - | assert.Equal(t, http.StatusOK, rec.Code) | |
| 253 | - | assert.Contains(t, rec.Body.String(), "Trust CA Certificate") | |
| 279 | + | assert.Equal(t, http.StatusNotFound, rec.Code) | |
| 280 | + | assert.NotContains(t, rec.Body.String(), "Trust CA Certificate") | |
| 281 | + | ||
| 282 | + | normalizedReq := httptest.NewRequest(http.MethodGet, "/.well-known/gordon/ca", nil) | |
| 283 | + | normalizedReq.RemoteAddr = directAddr | |
| 284 | + | normalizedReq.Host = "Gordon.Example.Com.:8443" | |
| 285 | + | normalizedRec := httptest.NewRecorder() | |
| 286 | + | httpsHandler.ServeHTTP(normalizedRec, normalizedReq) | |
| 287 | + | ||
| 288 | + | assert.Equal(t, http.StatusOK, normalizedRec.Code) | |
| 289 | + | assert.Contains(t, normalizedRec.Body.String(), "Trust CA Certificate") | |
| 254 | 290 | } | |
| 255 | 291 | ||
| 256 | 292 | func TestCreateHTTPHandlers_HTTPSOnboarding_HEADRoutesRemainAvailable(t *testing.T) { | |
| 257 | 293 | t.Parallel() | |
| 258 | 294 | ca := newTestCA(t) | |
| 259 | 295 | cfg := newOnboardingConfig() | |
| 260 | - | svc := &services{caAdapter: ca} | |
| 296 | + | cfg.Server.GordonDomain = "gordon.example.com" | |
| 297 | + | svc := &services{caAdapter: ca, proxySvc: newNotFoundProxyService(t)} | |
| 261 | 298 | ||
| 262 | 299 | _, _, httpsHandler := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil) | |
| 263 | 300 | ||
| @@ -266,8 +303,11 @@ func TestCreateHTTPHandlers_HTTPSOnboarding_HEADRoutesRemainAvailable(t *testing | |||
| 266 | 303 | ts := httptest.NewServer(httpsHandler) | |
| 267 | 304 | defer ts.Close() | |
| 268 | 305 | ||
| 269 | - | for _, path := range []string{"/ca", "/ca.crt", "/ca.mobileconfig"} { | |
| 270 | - | resp, err := ts.Client().Head(ts.URL + path) | |
| 306 | + | for _, path := range []string{"/.well-known/gordon/ca", "/.well-known/gordon/ca.crt", "/.well-known/gordon/ca.mobileconfig"} { | |
| 307 | + | req, err := http.NewRequest(http.MethodHead, ts.URL+path, nil) | |
| 308 | + | require.NoError(t, err) | |
| 309 | + | req.Host = "Gordon.Example.Com.:8443" | |
| 310 | + | resp, err := ts.Client().Do(req) | |
| 271 | 311 | require.NoError(t, err) | |
| 272 | 312 | ||
| 273 | 313 | body, err := io.ReadAll(resp.Body) | |
| @@ -298,8 +338,8 @@ func TestCreateHTTPHandlers_DirectHTTPOnboarding_HEADRoutesRemainAvailable(t *te | |||
| 298 | 338 | path string | |
| 299 | 339 | contentType string | |
| 300 | 340 | }{ | |
| 301 | - | {path: "/ca", contentType: "text/html"}, | |
| 302 | - | {path: "/ca.crt", contentType: "application/x-x509-ca-cert"}, | |
| 341 | + | {path: "/.well-known/gordon/ca", contentType: "text/html"}, | |
| 342 | + | {path: "/.well-known/gordon/ca.crt", contentType: "application/x-x509-ca-cert"}, | |
| 303 | 343 | } { | |
| 304 | 344 | resp, err := ts.Client().Head(ts.URL + tc.path) | |
| 305 | 345 | require.NoError(t, err) | |
| @@ -376,6 +416,53 @@ func TestCreateHTTPHandlers_TLSDisabled_DoesNotServeHTTPOnboarding(t *testing.T) | |||
| 376 | 416 | assert.NotContains(t, rec.Body.String(), "Trust CA Certificate") | |
| 377 | 417 | } | |
| 378 | 418 | ||
| 419 | + | type recordingPublicTLSRuntime struct { | |
| 420 | + | reconcileCalls int | |
| 421 | + | loopCalls int | |
| 422 | + | reconcileErr error | |
| 423 | + | } | |
| 424 | + | ||
| 425 | + | func (r *recordingPublicTLSRuntime) Reconcile(context.Context) error { | |
| 426 | + | r.reconcileCalls++ | |
| 427 | + | return r.reconcileErr | |
| 428 | + | } | |
| 429 | + | ||
| 430 | + | func (r *recordingPublicTLSRuntime) StartRenewalLoop(context.Context, time.Duration) <-chan struct{} { | |
| 431 | + | r.loopCalls++ | |
| 432 | + | done := make(chan struct{}) | |
| 433 | + | close(done) | |
| 434 | + | return done | |
| 435 | + | } | |
| 436 | + | ||
| 437 | + | func TestStartPublicTLSRuntime_ReconcilesAndStartsRenewalLoop(t *testing.T) { | |
| 438 | + | t.Parallel() | |
| 439 | + | ||
| 440 | + | runtime := &recordingPublicTLSRuntime{} | |
| 441 | + | err := startPublicTLSRuntime(context.Background(), runtime, zerowrap.Default()) | |
| 442 | + | ||
| 443 | + | require.NoError(t, err) | |
| 444 | + | assert.Equal(t, 1, runtime.reconcileCalls) | |
| 445 | + | assert.Equal(t, 1, runtime.loopCalls) | |
| 446 | + | } | |
| 447 | + | ||
| 448 | + | func TestStartPublicTLSRuntime_NilSvc(t *testing.T) { | |
| 449 | + | t.Parallel() | |
| 450 | + | ||
| 451 | + | err := startPublicTLSRuntime(context.Background(), nil, zerowrap.Default()) | |
| 452 | + | require.NoError(t, err) | |
| 453 | + | } | |
| 454 | + | ||
| 455 | + | func TestStartPublicTLSRuntime_StartsRenewalLoopWhenReconcileFails(t *testing.T) { | |
| 456 | + | t.Parallel() | |
| 457 | + | ||
| 458 | + | runtime := &recordingPublicTLSRuntime{reconcileErr: errors.New("boom")} | |
| 459 | + | err := startPublicTLSRuntime(context.Background(), runtime, zerowrap.Default()) | |
| 460 | + | ||
| 461 | + | require.Error(t, err) | |
| 462 | + | assert.Equal(t, 1, runtime.reconcileCalls) | |
| 463 | + | assert.Equal(t, 1, runtime.loopCalls) | |
| 464 | + | } | |
| 465 | + | ||
| 379 | 466 | func TestCreateHTTPHandlers_TLSConfiguredWithoutCA_FailsStartup(t *testing.T) { | |
| 380 | 467 | t.Parallel() | |
| 381 | 468 | // Verify that initPKI returns an error when TLS is enabled but CA init fails. | |
| @@ -2,6 +2,7 @@ package app | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | + | "errors" | |||
| 5 | "io" | 6 | "io" | ||
| 6 | "net/http" | 7 | "net/http" | ||
| 7 | "net/http/httptest" | 8 | "net/http/httptest" | ||
| @@ -9,16 +10,30 @@ import ( | |||||
| 9 | "path/filepath" | 10 | "path/filepath" | ||
| 10 | "sync" | 11 | "sync" | ||
| 11 | "testing" | 12 | "testing" | ||
| 13 | + | "time" | |||
| 12 | 14 | ||||
| 13 | "github.com/bnema/zerowrap" | 15 | "github.com/bnema/zerowrap" | ||
| 14 | "github.com/stretchr/testify/assert" | 16 | "github.com/stretchr/testify/assert" | ||
| 17 | + | "github.com/stretchr/testify/mock" | |||
| 15 | "github.com/stretchr/testify/require" | 18 | "github.com/stretchr/testify/require" | ||
| 16 | 19 | ||||
| 17 | adminhttp "github.com/bnema/gordon/internal/adapters/in/http/admin" | 20 | adminhttp "github.com/bnema/gordon/internal/adapters/in/http/admin" | ||
| 18 | pkiadapter "github.com/bnema/gordon/internal/adapters/out/pki" | 21 | pkiadapter "github.com/bnema/gordon/internal/adapters/out/pki" | ||
| 22 | + | inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks" | |||
| 19 | out "github.com/bnema/gordon/internal/boundaries/out" | 23 | out "github.com/bnema/gordon/internal/boundaries/out" | ||
| 24 | + | proxyusecase "github.com/bnema/gordon/internal/usecase/proxy" | |||
| 20 | ) | 25 | ) | ||
| 21 | 26 | ||||
| 27 | + | func newNotFoundProxyService(t *testing.T) *proxyusecase.Service { | |||
| 28 | + | t.Helper() | |||
| 29 | + | configSvc := inmocks.NewMockConfigService(t) | |||
| 30 | + | configSvc.EXPECT().GetExternalRoutes().Return(map[string]string{}).Maybe() | |||
| 31 | + | configSvc.EXPECT().GetRoutes(mock.Anything).Return(nil).Maybe() | |||
| 32 | + | containerSvc := inmocks.NewMockContainerService(t) | |||
| 33 | + | containerSvc.EXPECT().Get(mock.Anything, mock.Anything).Return(nil, false).Maybe() | |||
| 34 | + | return proxyusecase.NewService(nil, containerSvc, configSvc, proxyusecase.Config{}) | |||
| 35 | + | } | |||
| 36 | + | ||||
| 22 | // testAccessLogWriter is a thread-safe mock AccessLogWriter for tests. | 37 | // testAccessLogWriter is a thread-safe mock AccessLogWriter for tests. | ||
| 23 | type testAccessLogWriter struct { | 38 | type testAccessLogWriter struct { | ||
| 24 | mu sync.Mutex | 39 | mu sync.Mutex | ||
| @@ -120,60 +135,50 @@ const directAddr = "203.0.113.10:12345" | |||||
| 120 | // trustedProxyAddr is an IP inside the 173.245.48.0/20 range. | 135 | // trustedProxyAddr is an IP inside the 173.245.48.0/20 range. | ||
| 121 | const trustedProxyAddr = "173.245.48.5:12345" | 136 | const trustedProxyAddr = "173.245.48.5:12345" | ||
| 122 | 137 | ||||
| 123 | - | func TestCreateHTTPHandlers_DirectHTTPRoot_ServesOnboarding(t *testing.T) { | 138 | + | func TestCreateHTTPHandlers_DirectHTTPOnboarding_AllPaths(t *testing.T) { |
| 124 | - | t.Parallel() | |||
| 125 | - | ca := newTestCA(t) | |||
| 126 | - | cfg := newOnboardingConfig() | |||
| 127 | - | svc := &services{caAdapter: ca} | |||
| 128 | - | ||||
| 129 | - | _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil) | |||
| 130 | - | ||||
| 131 | - | req := httptest.NewRequest(http.MethodGet, "/", nil) | |||
| 132 | - | req.RemoteAddr = directAddr | |||
| 133 | - | req.Host = "o2.bnema.dev" | |||
| 134 | - | rec := httptest.NewRecorder() | |||
| 135 | - | httpHandler.ServeHTTP(rec, req) | |||
| 136 | - | ||||
| 137 | - | assert.Equal(t, http.StatusOK, rec.Code) | |||
| 138 | - | assert.Contains(t, rec.Header().Get("Content-Type"), "text/html") | |||
| 139 | - | assert.Contains(t, rec.Body.String(), "Trust CA Certificate") | |||
| 140 | - | } | |||
| 141 | - | ||||
| 142 | - | func TestCreateHTTPHandlers_DirectHTTPCertPath_ServesCACert(t *testing.T) { | |||
| 143 | - | t.Parallel() | |||
| 144 | - | ca := newTestCA(t) | |||
| 145 | - | cfg := newOnboardingConfig() | |||
| 146 | - | svc := &services{caAdapter: ca} | |||
| 147 | - | ||||
| 148 | - | _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil) | |||
| 149 | - | ||||
| 150 | - | req := httptest.NewRequest(http.MethodGet, "/ca.crt", nil) | |||
| 151 | - | req.RemoteAddr = directAddr | |||
| 152 | - | req.Host = "o2.bnema.dev" | |||
| 153 | - | rec := httptest.NewRecorder() | |||
| 154 | - | httpHandler.ServeHTTP(rec, req) | |||
| 155 | - | ||||
| 156 | - | assert.Equal(t, http.StatusOK, rec.Code) | |||
| 157 | - | assert.Contains(t, rec.Header().Get("Content-Type"), "application/x-x509-ca-cert") | |||
| 158 | - | assert.Contains(t, rec.Body.String(), "BEGIN CERTIFICATE") | |||
| 159 | - | } | |||
| 160 | - | ||||
| 161 | - | func TestCreateHTTPHandlers_DirectHTTPUnknownPath_ReturnsForbidden(t *testing.T) { | |||
| 162 | t.Parallel() | 139 | t.Parallel() | ||
| 163 | - | ca := newTestCA(t) | |||
| 164 | - | cfg := newOnboardingConfig() | |||
| 165 | - | svc := &services{caAdapter: ca} | |||
| 166 | 140 | ||||
| 167 | - | _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil) | 141 | + | tests := []struct { |
| 168 | - | 142 | + | name string | |
| 169 | - | req := httptest.NewRequest(http.MethodGet, "/web", nil) | 143 | + | path string |
| 170 | - | req.RemoteAddr = directAddr | 144 | + | wantCode int |
| 171 | - | req.Host = "o2.bnema.dev" | 145 | + | wantContent string // substring check |
| 172 | - | rec := httptest.NewRecorder() | 146 | + | wantType string // Content-Type substring |
| 173 | - | httpHandler.ServeHTTP(rec, req) | 147 | + | }{ |
| 148 | + | {name: ".well-known/gordon/ serves onboarding", path: "/.well-known/gordon/", wantCode: http.StatusOK, wantContent: "Trust CA Certificate", wantType: "text/html"}, | |||
| 149 | + | {name: ".well-known/gordon/ca serves onboarding", path: "/.well-known/gordon/ca", wantCode: http.StatusOK, wantContent: "Trust CA Certificate", wantType: "text/html"}, | |||
| 150 | + | {name: ".well-known/gordon/ca.crt serves CA cert", path: "/.well-known/gordon/ca.crt", wantCode: http.StatusOK, wantContent: "BEGIN CERTIFICATE", wantType: "application/x-x509-ca-cert"}, | |||
| 151 | + | {name: ".well-known/gordon/ca.mobileconfig serves mobileconfig", path: "/.well-known/gordon/ca.mobileconfig", wantCode: http.StatusOK, wantContent: "<?xml", wantType: "application/x-apple-aspen-config"}, | |||
| 152 | + | {name: "root returns forbidden", path: "/", wantCode: http.StatusForbidden, wantContent: "Only certificate onboarding is available", wantType: "text/plain"}, | |||
| 153 | + | {name: "former /ca alias returns forbidden", path: "/ca", wantCode: http.StatusForbidden, wantContent: "Only certificate onboarding is available", wantType: "text/plain"}, | |||
| 154 | + | {name: "former /ca.crt alias returns forbidden", path: "/ca.crt", wantCode: http.StatusForbidden, wantContent: "Only certificate onboarding is available", wantType: "text/plain"}, | |||
| 155 | + | {name: "former /ca.mobileconfig alias returns forbidden", path: "/ca.mobileconfig", wantCode: http.StatusForbidden, wantContent: "Only certificate onboarding is available", wantType: "text/plain"}, | |||
| 156 | + | {name: "unknown path returns forbidden", path: "/web", wantCode: http.StatusForbidden, wantContent: "Only certificate onboarding is available", wantType: "text/plain"}, | |||
| 157 | + | } | |||
| 174 | 158 | ||||
| 175 | - | assert.Equal(t, http.StatusForbidden, rec.Code) | 159 | + | for _, tc := range tests { |
| 176 | - | assert.Contains(t, rec.Header().Get("Content-Type"), "text/plain") | 160 | + | t.Run(tc.name, func(t *testing.T) { |
| 161 | + | ca := newTestCA(t) | |||
| 162 | + | cfg := newOnboardingConfig() | |||
| 163 | + | svc := &services{caAdapter: ca} | |||
| 164 | + | ||||
| 165 | + | _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil) | |||
| 166 | + | ||||
| 167 | + | req := httptest.NewRequest(http.MethodGet, tc.path, nil) | |||
| 168 | + | req.RemoteAddr = directAddr | |||
| 169 | + | req.Host = "o2.bnema.dev" | |||
| 170 | + | rec := httptest.NewRecorder() | |||
| 171 | + | httpHandler.ServeHTTP(rec, req) | |||
| 172 | + | ||||
| 173 | + | assert.Equal(t, tc.wantCode, rec.Code, "%s: status code", tc.name) | |||
| 174 | + | if tc.wantType != "" { | |||
| 175 | + | assert.Contains(t, rec.Header().Get("Content-Type"), tc.wantType, "%s: content type", tc.name) | |||
| 176 | + | } | |||
| 177 | + | if tc.wantContent != "" { | |||
| 178 | + | assert.Contains(t, rec.Body.String(), tc.wantContent, "%s: body content", tc.name) | |||
| 179 | + | } | |||
| 180 | + | }) | |||
| 181 | + | } | |||
| 177 | } | 182 | } | ||
| 178 | 183 | ||||
| 179 | func TestCreateHTTPHandlers_TrustedProxyHTTP_DoesNotServeOnboarding(t *testing.T) { | 184 | func TestCreateHTTPHandlers_TrustedProxyHTTP_DoesNotServeOnboarding(t *testing.T) { | ||
| @@ -196,7 +201,7 @@ func TestCreateHTTPHandlers_TrustedProxyHTTP_DoesNotServeOnboarding(t *testing.T | |||||
| 196 | assert.NotContains(t, rec.Body.String(), "Trust CA Certificate") | 201 | assert.NotContains(t, rec.Body.String(), "Trust CA Certificate") | ||
| 197 | } | 202 | } | ||
| 198 | 203 | ||||
| 199 | - | func TestCreateHTTPHandlers_TrustedProxyCACertPath_DoesNotServeOnboardingResource(t *testing.T) { | 204 | + | func TestCreateHTTPHandlers_TrustedProxyWellKnownCACertPath_DoesNotServeOnboardingResource(t *testing.T) { |
| 200 | t.Parallel() | 205 | t.Parallel() | ||
| 201 | ca := newTestCA(t) | 206 | ca := newTestCA(t) | ||
| 202 | cfg := newOnboardingConfig() | 207 | cfg := newOnboardingConfig() | ||
| @@ -204,7 +209,7 @@ func TestCreateHTTPHandlers_TrustedProxyCACertPath_DoesNotServeOnboardingResourc | |||||
| 204 | 209 | ||||
| 205 | _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil) | 210 | _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil) | ||
| 206 | 211 | ||||
| 207 | - | req := httptest.NewRequest(http.MethodGet, "/ca.crt", nil) | 212 | + | req := httptest.NewRequest(http.MethodGet, "/.well-known/gordon/ca.crt", nil) |
| 208 | req.RemoteAddr = trustedProxyAddr | 213 | req.RemoteAddr = trustedProxyAddr | ||
| 209 | req.Host = "app.example.com" | 214 | req.Host = "app.example.com" | ||
| 210 | rec := httptest.NewRecorder() | 215 | rec := httptest.NewRecorder() | ||
| @@ -224,7 +229,7 @@ func TestCreateHTTPHandlers_ForceHTTPSRedirect_DoesNotBypassDirectOnboarding(t * | |||||
| 224 | 229 | ||||
| 225 | _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil) | 230 | _, httpHandler, _ := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil) | ||
| 226 | 231 | ||||
| 227 | - | req := httptest.NewRequest(http.MethodGet, "/", nil) | 232 | + | req := httptest.NewRequest(http.MethodGet, "/.well-known/gordon/", nil) |
| 228 | req.RemoteAddr = directAddr | 233 | req.RemoteAddr = directAddr | ||
| 229 | req.Host = "o2.bnema.dev" | 234 | req.Host = "o2.bnema.dev" | ||
| 230 | rec := httptest.NewRecorder() | 235 | rec := httptest.NewRecorder() | ||
| @@ -235,29 +240,61 @@ func TestCreateHTTPHandlers_ForceHTTPSRedirect_DoesNotBypassDirectOnboarding(t * | |||||
| 235 | assert.Contains(t, rec.Body.String(), "Trust CA Certificate") | 240 | assert.Contains(t, rec.Body.String(), "Trust CA Certificate") | ||
| 236 | } | 241 | } | ||
| 237 | 242 | ||||
| 238 | - | func TestCreateHTTPHandlers_HTTPSOnboarding_RemainsAvailable(t *testing.T) { | 243 | + | func TestCreateHTTPHandlers_HTTPSOnboarding_RemainsAvailableOnGordonDomain(t *testing.T) { |
| 239 | t.Parallel() | 244 | t.Parallel() | ||
| 240 | ca := newTestCA(t) | 245 | ca := newTestCA(t) | ||
| 241 | cfg := newOnboardingConfig() | 246 | cfg := newOnboardingConfig() | ||
| 242 | - | svc := &services{caAdapter: ca} | 247 | + | cfg.Server.GordonDomain = "gordon.example.com" |
| 248 | + | svc := &services{caAdapter: ca, proxySvc: newNotFoundProxyService(t)} | |||
| 243 | 249 | ||||
| 244 | _, _, httpsHandler := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil) | 250 | _, _, httpsHandler := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil) | ||
| 245 | 251 | ||||
| 246 | - | req := httptest.NewRequest(http.MethodGet, "/ca", nil) | 252 | + | for _, host := range []string{"gordon.example.com", "Gordon.Example.Com.:8443"} { |
| 253 | + | req := httptest.NewRequest(http.MethodGet, "/.well-known/gordon/ca", nil) | |||
| 254 | + | req.RemoteAddr = directAddr | |||
| 255 | + | req.Host = host | |||
| 256 | + | rec := httptest.NewRecorder() | |||
| 257 | + | httpsHandler.ServeHTTP(rec, req) | |||
| 258 | + | ||||
| 259 | + | assert.Equal(t, http.StatusOK, rec.Code, "host %s", host) | |||
| 260 | + | assert.Contains(t, rec.Body.String(), "Trust CA Certificate", "host %s", host) | |||
| 261 | + | } | |||
| 262 | + | } | |||
| 263 | + | ||||
| 264 | + | func TestCreateHTTPHandlers_HTTPSOnboarding_DoesNotPreemptAppHostCAPath(t *testing.T) { | |||
| 265 | + | t.Parallel() | |||
| 266 | + | ca := newTestCA(t) | |||
| 267 | + | cfg := newOnboardingConfig() | |||
| 268 | + | cfg.Server.GordonDomain = "gordon.example.com" | |||
| 269 | + | svc := &services{caAdapter: ca, proxySvc: newNotFoundProxyService(t)} | |||
| 270 | + | ||||
| 271 | + | _, _, httpsHandler := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil) | |||
| 272 | + | ||||
| 273 | + | req := httptest.NewRequest(http.MethodGet, "/.well-known/gordon/ca", nil) | |||
| 247 | req.RemoteAddr = directAddr | 274 | req.RemoteAddr = directAddr | ||
| 248 | - | req.Host = "o2.bnema.dev" | 275 | + | req.Host = "app.example.com" |
| 249 | rec := httptest.NewRecorder() | 276 | rec := httptest.NewRecorder() | ||
| 250 | httpsHandler.ServeHTTP(rec, req) | 277 | httpsHandler.ServeHTTP(rec, req) | ||
| 251 | 278 | ||||
| 252 | - | assert.Equal(t, http.StatusOK, rec.Code) | 279 | + | assert.Equal(t, http.StatusNotFound, rec.Code) |
| 253 | - | assert.Contains(t, rec.Body.String(), "Trust CA Certificate") | 280 | + | assert.NotContains(t, rec.Body.String(), "Trust CA Certificate") |
| 281 | + | ||||
| 282 | + | normalizedReq := httptest.NewRequest(http.MethodGet, "/.well-known/gordon/ca", nil) | |||
| 283 | + | normalizedReq.RemoteAddr = directAddr | |||
| 284 | + | normalizedReq.Host = "Gordon.Example.Com.:8443" | |||
| 285 | + | normalizedRec := httptest.NewRecorder() | |||
| 286 | + | httpsHandler.ServeHTTP(normalizedRec, normalizedReq) | |||
| 287 | + | ||||
| 288 | + | assert.Equal(t, http.StatusOK, normalizedRec.Code) | |||
| 289 | + | assert.Contains(t, normalizedRec.Body.String(), "Trust CA Certificate") | |||
| 254 | } | 290 | } | ||
| 255 | 291 | ||||
| 256 | func TestCreateHTTPHandlers_HTTPSOnboarding_HEADRoutesRemainAvailable(t *testing.T) { | 292 | func TestCreateHTTPHandlers_HTTPSOnboarding_HEADRoutesRemainAvailable(t *testing.T) { | ||
| 257 | t.Parallel() | 293 | t.Parallel() | ||
| 258 | ca := newTestCA(t) | 294 | ca := newTestCA(t) | ||
| 259 | cfg := newOnboardingConfig() | 295 | cfg := newOnboardingConfig() | ||
| 260 | - | svc := &services{caAdapter: ca} | 296 | + | cfg.Server.GordonDomain = "gordon.example.com" |
| 297 | + | svc := &services{caAdapter: ca, proxySvc: newNotFoundProxyService(t)} | |||
| 261 | 298 | ||||
| 262 | _, _, httpsHandler := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil) | 299 | _, _, httpsHandler := createHTTPHandlers(svc, cfg, zerowrap.Default(), nil) | ||
| 263 | 300 | ||||
| @@ -266,8 +303,11 @@ func TestCreateHTTPHandlers_HTTPSOnboarding_HEADRoutesRemainAvailable(t *testing | |||||
| 266 | ts := httptest.NewServer(httpsHandler) | 303 | ts := httptest.NewServer(httpsHandler) | ||
| 267 | defer ts.Close() | 304 | defer ts.Close() | ||
| 268 | 305 | ||||
| 269 | - | for _, path := range []string{"/ca", "/ca.crt", "/ca.mobileconfig"} { | 306 | + | for _, path := range []string{"/.well-known/gordon/ca", "/.well-known/gordon/ca.crt", "/.well-known/gordon/ca.mobileconfig"} { |
| 270 | - | resp, err := ts.Client().Head(ts.URL + path) | 307 | + | req, err := http.NewRequest(http.MethodHead, ts.URL+path, nil) |
| 308 | + | require.NoError(t, err) | |||
| 309 | + | req.Host = "Gordon.Example.Com.:8443" | |||
| 310 | + | resp, err := ts.Client().Do(req) | |||
| 271 | require.NoError(t, err) | 311 | require.NoError(t, err) | ||
| 272 | 312 | ||||
| 273 | body, err := io.ReadAll(resp.Body) | 313 | body, err := io.ReadAll(resp.Body) | ||
| @@ -298,8 +338,8 @@ func TestCreateHTTPHandlers_DirectHTTPOnboarding_HEADRoutesRemainAvailable(t *te | |||||
| 298 | path string | 338 | path string | ||
| 299 | contentType string | 339 | contentType string | ||
| 300 | }{ | 340 | }{ | ||
| 301 | - | {path: "/ca", contentType: "text/html"}, | 341 | + | {path: "/.well-known/gordon/ca", contentType: "text/html"}, |
| 302 | - | {path: "/ca.crt", contentType: "application/x-x509-ca-cert"}, | 342 | + | {path: "/.well-known/gordon/ca.crt", contentType: "application/x-x509-ca-cert"}, |
| 303 | } { | 343 | } { | ||
| 304 | resp, err := ts.Client().Head(ts.URL + tc.path) | 344 | resp, err := ts.Client().Head(ts.URL + tc.path) | ||
| 305 | require.NoError(t, err) | 345 | require.NoError(t, err) | ||
| @@ -376,6 +416,53 @@ func TestCreateHTTPHandlers_TLSDisabled_DoesNotServeHTTPOnboarding(t *testing.T) | |||||
| 376 | assert.NotContains(t, rec.Body.String(), "Trust CA Certificate") | 416 | assert.NotContains(t, rec.Body.String(), "Trust CA Certificate") | ||
| 377 | } | 417 | } | ||
| 378 | 418 | ||||
| 419 | + | type recordingPublicTLSRuntime struct { | |||
| 420 | + | reconcileCalls int | |||
| 421 | + | loopCalls int | |||
| 422 | + | reconcileErr error | |||
| 423 | + | } | |||
| 424 | + | ||||
| 425 | + | func (r *recordingPublicTLSRuntime) Reconcile(context.Context) error { | |||
| 426 | + | r.reconcileCalls++ | |||
| 427 | + | return r.reconcileErr | |||
| 428 | + | } | |||
| 429 | + | ||||
| 430 | + | func (r *recordingPublicTLSRuntime) StartRenewalLoop(context.Context, time.Duration) <-chan struct{} { | |||
| 431 | + | r.loopCalls++ | |||
| 432 | + | done := make(chan struct{}) | |||
| 433 | + | close(done) | |||
| 434 | + | return done | |||
| 435 | + | } | |||
| 436 | + | ||||
| 437 | + | func TestStartPublicTLSRuntime_ReconcilesAndStartsRenewalLoop(t *testing.T) { | |||
| 438 | + | t.Parallel() | |||
| 439 | + | ||||
| 440 | + | runtime := &recordingPublicTLSRuntime{} | |||
| 441 | + | err := startPublicTLSRuntime(context.Background(), runtime, zerowrap.Default()) | |||
| 442 | + | ||||
| 443 | + | require.NoError(t, err) | |||
| 444 | + | assert.Equal(t, 1, runtime.reconcileCalls) | |||
| 445 | + | assert.Equal(t, 1, runtime.loopCalls) | |||
| 446 | + | } | |||
| 447 | + | ||||
| 448 | + | func TestStartPublicTLSRuntime_NilSvc(t *testing.T) { | |||
| 449 | + | t.Parallel() | |||
| 450 | + | ||||
| 451 | + | err := startPublicTLSRuntime(context.Background(), nil, zerowrap.Default()) | |||
| 452 | + | require.NoError(t, err) | |||
| 453 | + | } | |||
| 454 | + | ||||
| 455 | + | func TestStartPublicTLSRuntime_StartsRenewalLoopWhenReconcileFails(t *testing.T) { | |||
| 456 | + | t.Parallel() | |||
| 457 | + | ||||
| 458 | + | runtime := &recordingPublicTLSRuntime{reconcileErr: errors.New("boom")} | |||
| 459 | + | err := startPublicTLSRuntime(context.Background(), runtime, zerowrap.Default()) | |||
| 460 | + | ||||
| 461 | + | require.Error(t, err) | |||
| 462 | + | assert.Equal(t, 1, runtime.reconcileCalls) | |||
| 463 | + | assert.Equal(t, 1, runtime.loopCalls) | |||
| 464 | + | } | |||
| 465 | + | ||||
| 379 | func TestCreateHTTPHandlers_TLSConfiguredWithoutCA_FailsStartup(t *testing.T) { | 466 | func TestCreateHTTPHandlers_TLSConfiguredWithoutCA_FailsStartup(t *testing.T) { | ||
| 380 | t.Parallel() | 467 | t.Parallel() | ||
| 381 | // Verify that initPKI returns an error when TLS is enabled but CA init fails. | 468 | // Verify that initPKI returns an error when TLS is enabled but CA init fails. | ||
M internal/boundaries/out/mocks/mock_certificate_store.go +122 -5
| @@ -73,7 +73,7 @@ type MockCertificateStore_LoadAccount_Call struct { | |||
| 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/config.go +7 -6
| @@ -10,12 +10,13 @@ import ( | |||
| 10 | 10 | ||
| 11 | 11 | // Config holds the resolved configuration for public ACME TLS. | |
| 12 | 12 | type Config struct { | |
| 13 | - | Enabled bool | |
| 14 | - | Email string | |
| 15 | - | Challenge string | |
| 16 | - | HTTPPort int | |
| 17 | - | TLSPort int | |
| 18 | - | DataDir string | |
| 13 | + | Enabled bool | |
| 14 | + | Email string | |
| 15 | + | Challenge string | |
| 16 | + | HTTPPort int | |
| 17 | + | TLSPort int | |
| 18 | + | DataDir string | |
| 19 | + | ObtainBatchSize int | |
| 19 | 20 | } | |
| 20 | 21 | ||
| 21 | 22 | // EffectiveChallenge represents the resolved ACME challenge configuration. | |
| @@ -10,12 +10,13 @@ import ( | |||||
| 10 | 10 | ||||
| 11 | // Config holds the resolved configuration for public ACME TLS. | 11 | // Config holds the resolved configuration for public ACME TLS. | ||
| 12 | type Config struct { | 12 | type Config struct { | ||
| 13 | - | Enabled bool | 13 | + | Enabled bool |
| 14 | - | Email string | 14 | + | Email string |
| 15 | - | Challenge string | 15 | + | Challenge string |
| 16 | - | HTTPPort int | 16 | + | HTTPPort int |
| 17 | - | TLSPort int | 17 | + | TLSPort int |
| 18 | - | DataDir string | 18 | + | DataDir string |
| 19 | + | ObtainBatchSize int | |||
| 19 | } | 20 | } | ||
| 20 | 21 | ||||
| 21 | // EffectiveChallenge represents the resolved ACME challenge configuration. | 22 | // EffectiveChallenge represents the resolved ACME challenge configuration. | ||
M internal/usecase/publictls/integration_test.go +5 -4
| @@ -40,10 +40,11 @@ func TestPublicTLSIntegration_DNS01WildcardStatusAndCertificateLookup(t *testing | |||
| 40 | 40 | store, _ := newMockCertificateStore(t) | |
| 41 | 41 | ||
| 42 | 42 | cfg := Config{ | |
| 43 | - | Enabled: true, | |
| 44 | - | Email: "admin@example.com", | |
| 45 | - | Challenge: "cloudflare-dns-01", | |
| 46 | - | TLSPort: 8443, | |
| 43 | + | Enabled: true, | |
| 44 | + | Email: "admin@example.com", | |
| 45 | + | Challenge: "cloudflare-dns-01", | |
| 46 | + | TLSPort: 8443, | |
| 47 | + | ObtainBatchSize: 10, | |
| 47 | 48 | } | |
| 48 | 49 | ||
| 49 | 50 | svc := NewService(cfg, ServiceDeps{ | |
| @@ -40,10 +40,11 @@ func TestPublicTLSIntegration_DNS01WildcardStatusAndCertificateLookup(t *testing | |||||
| 40 | store, _ := newMockCertificateStore(t) | 40 | store, _ := newMockCertificateStore(t) | ||
| 41 | 41 | ||||
| 42 | cfg := Config{ | 42 | cfg := Config{ | ||
| 43 | - | Enabled: true, | 43 | + | Enabled: true, |
| 44 | - | Email: "admin@example.com", | 44 | + | Email: "admin@example.com", |
| 45 | - | Challenge: "cloudflare-dns-01", | 45 | + | Challenge: "cloudflare-dns-01", |
| 46 | - | TLSPort: 8443, | 46 | + | TLSPort: 8443, |
| 47 | + | ObtainBatchSize: 10, | |||
| 47 | } | 48 | } | ||
| 48 | 49 | ||||
| 49 | svc := NewService(cfg, ServiceDeps{ | 50 | svc := NewService(cfg, ServiceDeps{ | ||
M internal/usecase/publictls/service.go +95 -14
| @@ -33,15 +33,18 @@ type ServiceDeps struct { | |||
| 33 | 33 | Log zerowrap.Logger | |
| 34 | 34 | } | |
| 35 | 35 | ||
| 36 | + | const defaultObtainBatchSize = 1 | |
| 37 | + | ||
| 36 | 38 | // Service manages public TLS certificates via ACME. | |
| 37 | 39 | type Service struct { | |
| 38 | - | mu sync.RWMutex | |
| 39 | - | cfg Config | |
| 40 | - | deps ServiceDeps | |
| 41 | - | log zerowrap.Logger | |
| 42 | - | certs map[string]*out.StoredCertificate // indexed by cert ID | |
| 43 | - | lastErr map[string]string // indexed by cert ID | |
| 44 | - | routeErr map[string]string // indexed by host | |
| 40 | + | mu sync.RWMutex | |
| 41 | + | cfg Config | |
| 42 | + | deps ServiceDeps | |
| 43 | + | log zerowrap.Logger | |
| 44 | + | certs map[string]*out.StoredCertificate // indexed by cert ID | |
| 45 | + | lastErr map[string]string // indexed by cert ID | |
| 46 | + | routeErr map[string]string // indexed by host | |
| 47 | + | obtainCursor int // next missing target index for batched obtains | |
| 45 | 48 | ||
| 46 | 49 | // requiredHosts is the set of hosts that must be covered by ACME certs. | |
| 47 | 50 | requiredHosts map[string]struct{} | |
| @@ -82,6 +85,14 @@ func (s *Service) Load(ctx context.Context) error { | |||
| 82 | 85 | if err != nil { | |
| 83 | 86 | return fmt.Errorf("load stored certificates: %w", err) | |
| 84 | 87 | } | |
| 88 | + | state, err := s.deps.Store.LoadState(ctx) | |
| 89 | + | if err != nil { | |
| 90 | + | s.log.Warn().Err(err).Msg("failed to load certificate store state; using in-memory ACME obtain cursor") | |
| 91 | + | state = out.CertificateStoreState{} | |
| 92 | + | } | |
| 93 | + | if state.ObtainCursor < 0 { | |
| 94 | + | state.ObtainCursor = 0 | |
| 95 | + | } | |
| 85 | 96 | ||
| 86 | 97 | s.mu.Lock() | |
| 87 | 98 | defer s.mu.Unlock() | |
| @@ -89,6 +100,7 @@ func (s *Service) Load(ctx context.Context) error { | |||
| 89 | 100 | s.certs = make(map[string]*out.StoredCertificate, len(stored)) | |
| 90 | 101 | s.lastErr = make(map[string]string) | |
| 91 | 102 | s.requiredHosts = make(map[string]struct{}) | |
| 103 | + | s.obtainCursor = state.ObtainCursor | |
| 92 | 104 | ||
| 93 | 105 | for i := range stored { | |
| 94 | 106 | cert := &stored[i] | |
| @@ -182,20 +194,89 @@ func (s *Service) Reconcile(ctx context.Context) error { | |||
| 182 | 194 | s.mu.Lock() | |
| 183 | 195 | s.requiredHosts = required | |
| 184 | 196 | ||
| 185 | - | missing := make([]CertificateTarget, 0) | |
| 186 | - | for _, target := range targets { | |
| 187 | - | if s.certificateExistsLocked(target) { | |
| 188 | - | continue | |
| 189 | - | } | |
| 190 | - | missing = append(missing, target) | |
| 197 | + | batchSize := s.cfg.ObtainBatchSize | |
| 198 | + | if batchSize <= 0 { | |
| 199 | + | batchSize = defaultObtainBatchSize | |
| 191 | 200 | } | |
| 201 | + | ||
| 202 | + | previousCursor := s.obtainCursor | |
| 203 | + | missing, pendingCount, nextCursor, cursorChanged := s.selectMissingTargetsLocked(targets, batchSize) | |
| 192 | 204 | s.mu.Unlock() | |
| 193 | 205 | ||
| 206 | + | if cursorChanged { | |
| 207 | + | if err := s.persistObtainCursor(ctx, nextCursor); err != nil { | |
| 208 | + | log.Warn().Err(err).Int("obtain_cursor", nextCursor).Msg("failed to persist ACME obtain cursor; continuing with in-memory cursor") | |
| 209 | + | } | |
| 210 | + | s.mu.Lock() | |
| 211 | + | if s.obtainCursor == previousCursor { | |
| 212 | + | s.obtainCursor = nextCursor | |
| 213 | + | } | |
| 214 | + | s.mu.Unlock() | |
| 215 | + | } | |
| 216 | + | ||
| 194 | 217 | // Process missing targets without holding s.mu or the store lock so | |
| 195 | 218 | // GetCertificate/Status are not blocked during network I/O (Obtain). | |
| 219 | + | if remaining := pendingCount - len(missing); remaining > 0 { | |
| 220 | + | log.Info(). | |
| 221 | + | Int("obtain_batch_size", len(missing)). | |
| 222 | + | Int("remaining_count", remaining). | |
| 223 | + | Msg("ACME obtain batch limit reached; remaining certificates will be reconciled later") | |
| 224 | + | } | |
| 196 | 225 | s.reconcileMissingTargets(ctx, missing, required) | |
| 197 | 226 | ||
| 198 | - | log.Debug().Int("missing_count", len(missing)).Msg("reconciled missing targets") | |
| 227 | + | log.Debug().Int("missing_count", len(missing)).Int("pending_count", pendingCount).Msg("reconciled missing targets") | |
| 228 | + | return nil | |
| 229 | + | } | |
| 230 | + | ||
| 231 | + | // selectMissingTargetsLocked returns up to batchSize missing certificate targets. | |
| 232 | + | // It rotates the starting point across reconcile runs so one repeatedly failing | |
| 233 | + | // target cannot indefinitely block later targets when batching is enabled. | |
| 234 | + | // Must be called with s.mu held. | |
| 235 | + | func (s *Service) selectMissingTargetsLocked(targets []CertificateTarget, batchSize int) ([]CertificateTarget, int, int, bool) { | |
| 236 | + | if batchSize <= 0 { | |
| 237 | + | batchSize = defaultObtainBatchSize | |
| 238 | + | } | |
| 239 | + | previousCursor := s.obtainCursor | |
| 240 | + | ||
| 241 | + | allMissing := make([]CertificateTarget, 0, len(targets)) | |
| 242 | + | for _, target := range targets { | |
| 243 | + | if !s.certificateExistsLocked(target) { | |
| 244 | + | allMissing = append(allMissing, target) | |
| 245 | + | } | |
| 246 | + | } | |
| 247 | + | ||
| 248 | + | pendingCount := len(allMissing) | |
| 249 | + | if pendingCount == 0 { | |
| 250 | + | nextCursor := 0 | |
| 251 | + | return nil, 0, nextCursor, nextCursor != previousCursor | |
| 252 | + | } | |
| 253 | + | if batchSize >= pendingCount { | |
| 254 | + | nextCursor := 0 | |
| 255 | + | return allMissing, pendingCount, nextCursor, nextCursor != previousCursor | |
| 256 | + | } | |
| 257 | + | ||
| 258 | + | start := s.obtainCursor % pendingCount | |
| 259 | + | missing := make([]CertificateTarget, 0, batchSize) | |
| 260 | + | for i := range batchSize { | |
| 261 | + | missing = append(missing, allMissing[(start+i)%pendingCount]) | |
| 262 | + | } | |
| 263 | + | nextCursor := (start + batchSize) % pendingCount | |
| 264 | + | return missing, pendingCount, nextCursor, nextCursor != previousCursor | |
| 265 | + | } | |
| 266 | + | ||
| 267 | + | func (s *Service) persistObtainCursor(ctx context.Context, obtainCursor int) error { | |
| 268 | + | unlock, err := s.deps.Store.Lock(ctx) | |
| 269 | + | if err != nil { | |
| 270 | + | return fmt.Errorf("acquire store lock: %w", err) | |
| 271 | + | } | |
| 272 | + | saveErr := s.deps.Store.SaveState(ctx, out.CertificateStoreState{ObtainCursor: obtainCursor}) | |
| 273 | + | if unlockErr := unlock(); unlockErr != nil { | |
| 274 | + | log := zerowrap.FromCtx(ctx) | |
| 275 | + | log.Warn().Err(unlockErr).Msg("failed to release store lock") | |
| 276 | + | } | |
| 277 | + | if saveErr != nil { | |
| 278 | + | return fmt.Errorf("save certificate store state: %w", saveErr) | |
| 279 | + | } | |
| 199 | 280 | return nil | |
| 200 | 281 | } | |
| 201 | 282 | ||
| @@ -33,15 +33,18 @@ type ServiceDeps struct { | |||||
| 33 | Log zerowrap.Logger | 33 | Log zerowrap.Logger | ||
| 34 | } | 34 | } | ||
| 35 | 35 | ||||
| 36 | + | const defaultObtainBatchSize = 1 | |||
| 37 | + | ||||
| 36 | // Service manages public TLS certificates via ACME. | 38 | // Service manages public TLS certificates via ACME. | ||
| 37 | type Service struct { | 39 | type Service struct { | ||
| 38 | - | mu sync.RWMutex | 40 | + | mu sync.RWMutex |
| 39 | - | cfg Config | 41 | + | cfg Config |
| 40 | - | deps ServiceDeps | 42 | + | deps ServiceDeps |
| 41 | - | log zerowrap.Logger | 43 | + | log zerowrap.Logger |
| 42 | - | certs map[string]*out.StoredCertificate // indexed by cert ID | 44 | + | certs map[string]*out.StoredCertificate // indexed by cert ID |
| 43 | - | lastErr map[string]string // indexed by cert ID | 45 | + | lastErr map[string]string // indexed by cert ID |
| 44 | - | routeErr map[string]string // indexed by host | 46 | + | routeErr map[string]string // indexed by host |
| 47 | + | obtainCursor int // next missing target index for batched obtains | |||
| 45 | 48 | ||||
| 46 | // requiredHosts is the set of hosts that must be covered by ACME certs. | 49 | // requiredHosts is the set of hosts that must be covered by ACME certs. | ||
| 47 | requiredHosts map[string]struct{} | 50 | requiredHosts map[string]struct{} | ||
| @@ -82,6 +85,14 @@ func (s *Service) Load(ctx context.Context) error { | |||||
| 82 | if err != nil { | 85 | if err != nil { | ||
| 83 | return fmt.Errorf("load stored certificates: %w", err) | 86 | return fmt.Errorf("load stored certificates: %w", err) | ||
| 84 | } | 87 | } | ||
| 88 | + | state, err := s.deps.Store.LoadState(ctx) | |||
| 89 | + | if err != nil { | |||
| 90 | + | s.log.Warn().Err(err).Msg("failed to load certificate store state; using in-memory ACME obtain cursor") | |||
| 91 | + | state = out.CertificateStoreState{} | |||
| 92 | + | } | |||
| 93 | + | if state.ObtainCursor < 0 { | |||
| 94 | + | state.ObtainCursor = 0 | |||
| 95 | + | } | |||
| 85 | 96 | ||||
| 86 | s.mu.Lock() | 97 | s.mu.Lock() | ||
| 87 | defer s.mu.Unlock() | 98 | defer s.mu.Unlock() | ||
| @@ -89,6 +100,7 @@ func (s *Service) Load(ctx context.Context) error { | |||||
| 89 | s.certs = make(map[string]*out.StoredCertificate, len(stored)) | 100 | s.certs = make(map[string]*out.StoredCertificate, len(stored)) | ||
| 90 | s.lastErr = make(map[string]string) | 101 | s.lastErr = make(map[string]string) | ||
| 91 | s.requiredHosts = make(map[string]struct{}) | 102 | s.requiredHosts = make(map[string]struct{}) | ||
| 103 | + | s.obtainCursor = state.ObtainCursor | |||
| 92 | 104 | ||||
| 93 | for i := range stored { | 105 | for i := range stored { | ||
| 94 | cert := &stored[i] | 106 | cert := &stored[i] | ||
| @@ -182,20 +194,89 @@ func (s *Service) Reconcile(ctx context.Context) error { | |||||
| 182 | s.mu.Lock() | 194 | s.mu.Lock() | ||
| 183 | s.requiredHosts = required | 195 | s.requiredHosts = required | ||
| 184 | 196 | ||||
| 185 | - | missing := make([]CertificateTarget, 0) | 197 | + | batchSize := s.cfg.ObtainBatchSize |
| 186 | - | for _, target := range targets { | 198 | + | if batchSize <= 0 { |
| 187 | - | if s.certificateExistsLocked(target) { | 199 | + | batchSize = defaultObtainBatchSize |
| 188 | - | continue | |||
| 189 | - | } | |||
| 190 | - | missing = append(missing, target) | |||
| 191 | } | 200 | } | ||
| 201 | + | ||||
| 202 | + | previousCursor := s.obtainCursor | |||
| 203 | + | missing, pendingCount, nextCursor, cursorChanged := s.selectMissingTargetsLocked(targets, batchSize) | |||
| 192 | s.mu.Unlock() | 204 | s.mu.Unlock() | ||
| 193 | 205 | ||||
| 206 | + | if cursorChanged { | |||
| 207 | + | if err := s.persistObtainCursor(ctx, nextCursor); err != nil { | |||
| 208 | + | log.Warn().Err(err).Int("obtain_cursor", nextCursor).Msg("failed to persist ACME obtain cursor; continuing with in-memory cursor") | |||
| 209 | + | } | |||
| 210 | + | s.mu.Lock() | |||
| 211 | + | if s.obtainCursor == previousCursor { | |||
| 212 | + | s.obtainCursor = nextCursor | |||
| 213 | + | } | |||
| 214 | + | s.mu.Unlock() | |||
| 215 | + | } | |||
| 216 | + | ||||
| 194 | // Process missing targets without holding s.mu or the store lock so | 217 | // Process missing targets without holding s.mu or the store lock so | ||
| 195 | // GetCertificate/Status are not blocked during network I/O (Obtain). | 218 | // GetCertificate/Status are not blocked during network I/O (Obtain). | ||
| 219 | + | if remaining := pendingCount - len(missing); remaining > 0 { | |||
| 220 | + | log.Info(). | |||
| 221 | + | Int("obtain_batch_size", len(missing)). | |||
| 222 | + | Int("remaining_count", remaining). | |||
| 223 | + | Msg("ACME obtain batch limit reached; remaining certificates will be reconciled later") | |||
| 224 | + | } | |||
| 196 | s.reconcileMissingTargets(ctx, missing, required) | 225 | s.reconcileMissingTargets(ctx, missing, required) | ||
| 197 | 226 | ||||
| 198 | - | log.Debug().Int("missing_count", len(missing)).Msg("reconciled missing targets") | 227 | + | log.Debug().Int("missing_count", len(missing)).Int("pending_count", pendingCount).Msg("reconciled missing targets") |
| 228 | + | return nil | |||
| 229 | + | } | |||
| 230 | + | ||||
| 231 | + | // selectMissingTargetsLocked returns up to batchSize missing certificate targets. | |||
| 232 | + | // It rotates the starting point across reconcile runs so one repeatedly failing | |||
| 233 | + | // target cannot indefinitely block later targets when batching is enabled. | |||
| 234 | + | // Must be called with s.mu held. | |||
| 235 | + | func (s *Service) selectMissingTargetsLocked(targets []CertificateTarget, batchSize int) ([]CertificateTarget, int, int, bool) { | |||
| 236 | + | if batchSize <= 0 { | |||
| 237 | + | batchSize = defaultObtainBatchSize | |||
| 238 | + | } | |||
| 239 | + | previousCursor := s.obtainCursor | |||
| 240 | + | ||||
| 241 | + | allMissing := make([]CertificateTarget, 0, len(targets)) | |||
| 242 | + | for _, target := range targets { | |||
| 243 | + | if !s.certificateExistsLocked(target) { | |||
| 244 | + | allMissing = append(allMissing, target) | |||
| 245 | + | } | |||
| 246 | + | } | |||
| 247 | + | ||||
| 248 | + | pendingCount := len(allMissing) | |||
| 249 | + | if pendingCount == 0 { | |||
| 250 | + | nextCursor := 0 | |||
| 251 | + | return nil, 0, nextCursor, nextCursor != previousCursor | |||
| 252 | + | } | |||
| 253 | + | if batchSize >= pendingCount { | |||
| 254 | + | nextCursor := 0 | |||
| 255 | + | return allMissing, pendingCount, nextCursor, nextCursor != previousCursor | |||
| 256 | + | } | |||
| 257 | + | ||||
| 258 | + | start := s.obtainCursor % pendingCount | |||
| 259 | + | missing := make([]CertificateTarget, 0, batchSize) | |||
| 260 | + | for i := range batchSize { | |||
| 261 | + | missing = append(missing, allMissing[(start+i)%pendingCount]) | |||
| 262 | + | } | |||
| 263 | + | nextCursor := (start + batchSize) % pendingCount | |||
| 264 | + | return missing, pendingCount, nextCursor, nextCursor != previousCursor | |||
| 265 | + | } | |||
| 266 | + | ||||
| 267 | + | func (s *Service) persistObtainCursor(ctx context.Context, obtainCursor int) error { | |||
| 268 | + | unlock, err := s.deps.Store.Lock(ctx) | |||
| 269 | + | if err != nil { | |||
| 270 | + | return fmt.Errorf("acquire store lock: %w", err) | |||
| 271 | + | } | |||
| 272 | + | saveErr := s.deps.Store.SaveState(ctx, out.CertificateStoreState{ObtainCursor: obtainCursor}) | |||
| 273 | + | if unlockErr := unlock(); unlockErr != nil { | |||
| 274 | + | log := zerowrap.FromCtx(ctx) | |||
| 275 | + | log.Warn().Err(unlockErr).Msg("failed to release store lock") | |||
| 276 | + | } | |||
| 277 | + | if saveErr != nil { | |||
| 278 | + | return fmt.Errorf("save certificate store state: %w", saveErr) | |||
| 279 | + | } | |||
| 199 | return nil | 280 | return nil | ||
| 200 | } | 281 | } | ||
| 201 | 282 | ||||
M internal/usecase/publictls/service_test.go +311 -3
| @@ -130,9 +130,12 @@ func defaultStoredCert(order out.CertificateOrder) (*out.StoredCertificate, erro | |||
| 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 | + | loadStateErr error | |
| 138 | + | saveStateErr error | |
| 136 | 139 | } | |
| 137 | 140 | ||
| 138 | 141 | func (s *mockCertificateStoreState) All() []out.StoredCertificate { | |
| @@ -143,6 +146,30 @@ func (s *mockCertificateStoreState) All() []out.StoredCertificate { | |||
| 143 | 146 | return certs | |
| 144 | 147 | } | |
| 145 | 148 | ||
| 149 | + | func (s *mockCertificateStoreState) State() out.CertificateStoreState { | |
| 150 | + | s.mu.Lock() | |
| 151 | + | defer s.mu.Unlock() | |
| 152 | + | return s.state | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | func (s *mockCertificateStoreState) SetState(state out.CertificateStoreState) { | |
| 156 | + | s.mu.Lock() | |
| 157 | + | defer s.mu.Unlock() | |
| 158 | + | s.state = state | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | func (s *mockCertificateStoreState) SetLoadStateError(err error) { | |
| 162 | + | s.mu.Lock() | |
| 163 | + | defer s.mu.Unlock() | |
| 164 | + | s.loadStateErr = err | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | func (s *mockCertificateStoreState) SetSaveStateError(err error) { | |
| 168 | + | s.mu.Lock() | |
| 169 | + | defer s.mu.Unlock() | |
| 170 | + | s.saveStateErr = err | |
| 171 | + | } | |
| 172 | + | ||
| 146 | 173 | func newMockCertificateStore(t *testing.T, initial ...out.StoredCertificate) (*outmocks.MockCertificateStore, *mockCertificateStoreState) { | |
| 147 | 174 | t.Helper() | |
| 148 | 175 | state := &mockCertificateStoreState{certs: append([]out.StoredCertificate(nil), initial...)} | |
| @@ -173,6 +200,27 @@ func newMockCertificateStore(t *testing.T, initial ...out.StoredCertificate) (*o | |||
| 173 | 200 | }) | |
| 174 | 201 | loadAllCall.Maybe() | |
| 175 | 202 | ||
| 203 | + | loadStateCall := store.EXPECT().LoadState(mock.Anything).RunAndReturn(func(context.Context) (out.CertificateStoreState, error) { | |
| 204 | + | state.mu.Lock() | |
| 205 | + | defer state.mu.Unlock() | |
| 206 | + | if state.loadStateErr != nil { | |
| 207 | + | return out.CertificateStoreState{}, state.loadStateErr | |
| 208 | + | } | |
| 209 | + | return state.state, nil | |
| 210 | + | }) | |
| 211 | + | loadStateCall.Maybe() | |
| 212 | + | ||
| 213 | + | saveStateCall := store.EXPECT().SaveState(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, storeState out.CertificateStoreState) error { | |
| 214 | + | state.mu.Lock() | |
| 215 | + | defer state.mu.Unlock() | |
| 216 | + | if state.saveStateErr != nil { | |
| 217 | + | return state.saveStateErr | |
| 218 | + | } | |
| 219 | + | state.state = storeState | |
| 220 | + | return nil | |
| 221 | + | }) | |
| 222 | + | saveStateCall.Maybe() | |
| 223 | + | ||
| 176 | 224 | saveCall := store.EXPECT().Save(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, cert out.StoredCertificate) error { | |
| 177 | 225 | state.mu.Lock() | |
| 178 | 226 | defer state.mu.Unlock() | |
| @@ -251,6 +299,266 @@ func generateTestCertPEM(names []string) (certPEM []byte, keyPEM []byte, err err | |||
| 251 | 299 | // Tests | |
| 252 | 300 | // --------------------------------------------------------------------------- | |
| 253 | 301 | ||
| 302 | + | func TestServiceReconcileLimitsMissingObtainsPerRun(t *testing.T) { | |
| 303 | + | ctx := context.Background() | |
| 304 | + | ||
| 305 | + | routes := &fakeRoutes{ | |
| 306 | + | routes: []domain.Route{ | |
| 307 | + | {Domain: "one.example.com"}, | |
| 308 | + | {Domain: "two.example.com"}, | |
| 309 | + | {Domain: "three.example.com"}, | |
| 310 | + | }, | |
| 311 | + | } | |
| 312 | + | issuer, recorder := newMockPublicCertificateIssuer(t, nil, nil) | |
| 313 | + | store, storeState := newMockCertificateStore(t) | |
| 314 | + | ||
| 315 | + | cfg := Config{ | |
| 316 | + | Enabled: true, | |
| 317 | + | Email: "admin@example.com", | |
| 318 | + | Challenge: "http-01", | |
| 319 | + | HTTPPort: 8088, | |
| 320 | + | TLSPort: 8443, | |
| 321 | + | ObtainBatchSize: 2, | |
| 322 | + | } | |
| 323 | + | ||
| 324 | + | svc := NewService(cfg, ServiceDeps{ | |
| 325 | + | Config: cfg, | |
| 326 | + | Routes: routes, | |
| 327 | + | Issuer: issuer, | |
| 328 | + | Store: store, | |
| 329 | + | Challenges: NewHTTP01Challenges(), | |
| 330 | + | Effective: EffectiveChallenge{ | |
| 331 | + | ConfiguredMode: domain.ACMEChallengeHTTP01, | |
| 332 | + | Mode: domain.ACMEChallengeHTTP01, | |
| 333 | + | TokenSource: domain.ACMETokenSourceNone, | |
| 334 | + | Reason: "http-01 challenge selected", | |
| 335 | + | }, | |
| 336 | + | }) | |
| 337 | + | ||
| 338 | + | require.NoError(t, svc.Load(ctx)) | |
| 339 | + | require.NoError(t, svc.Reconcile(ctx)) | |
| 340 | + | ||
| 341 | + | orders := recorder.Orders() | |
| 342 | + | require.Len(t, orders, 2, "first reconcile should respect configured obtain batch size") | |
| 343 | + | assert.Len(t, storeState.All(), 2) | |
| 344 | + | ||
| 345 | + | require.NoError(t, svc.Reconcile(ctx)) | |
| 346 | + | orders = recorder.Orders() | |
| 347 | + | require.Len(t, orders, 3, "second reconcile should continue remaining missing targets") | |
| 348 | + | assert.Len(t, storeState.All(), 3) | |
| 349 | + | } | |
| 350 | + | ||
| 351 | + | func TestServiceReconcileRotatesBatchAfterFailedObtain(t *testing.T) { | |
| 352 | + | ctx := context.Background() | |
| 353 | + | ||
| 354 | + | routes := &fakeRoutes{ | |
| 355 | + | routes: []domain.Route{ | |
| 356 | + | {Domain: "b.example.com"}, | |
| 357 | + | {Domain: "a.example.com"}, | |
| 358 | + | }, | |
| 359 | + | } | |
| 360 | + | issuer, recorder := newMockPublicCertificateIssuer(t, func(_ context.Context, order out.CertificateOrder) (*out.StoredCertificate, error) { | |
| 361 | + | if order.ID == "http01-a.example.com" { | |
| 362 | + | return nil, fmt.Errorf("acme validation failed") | |
| 363 | + | } | |
| 364 | + | return defaultStoredCert(order) | |
| 365 | + | }, nil) | |
| 366 | + | store, storeState := newMockCertificateStore(t) | |
| 367 | + | ||
| 368 | + | cfg := Config{ | |
| 369 | + | Enabled: true, | |
| 370 | + | Email: "admin@example.com", | |
| 371 | + | Challenge: "http-01", | |
| 372 | + | HTTPPort: 8088, | |
| 373 | + | TLSPort: 8443, | |
| 374 | + | ObtainBatchSize: 1, | |
| 375 | + | } | |
| 376 | + | ||
| 377 | + | svc := NewService(cfg, ServiceDeps{ | |
| 378 | + | Config: cfg, | |
| 379 | + | Routes: routes, | |
| 380 | + | Issuer: issuer, | |
| 381 | + | Store: store, | |
| 382 | + | Challenges: NewHTTP01Challenges(), | |
| 383 | + | Effective: EffectiveChallenge{ | |
| 384 | + | ConfiguredMode: domain.ACMEChallengeHTTP01, | |
| 385 | + | Mode: domain.ACMEChallengeHTTP01, | |
| 386 | + | TokenSource: domain.ACMETokenSourceNone, | |
| 387 | + | Reason: "http-01 challenge selected", | |
| 388 | + | }, | |
| 389 | + | }) | |
| 390 | + | ||
| 391 | + | require.NoError(t, svc.Load(ctx)) | |
| 392 | + | require.NoError(t, svc.Reconcile(ctx)) | |
| 393 | + | require.NoError(t, svc.Reconcile(ctx)) | |
| 394 | + | ||
| 395 | + | orders := recorder.Orders() | |
| 396 | + | require.Len(t, orders, 2) | |
| 397 | + | assert.Equal(t, "http01-a.example.com", orders[0].ID) | |
| 398 | + | assert.Equal(t, "http01-b.example.com", orders[1].ID, "second reconcile should rotate past the failed first target") | |
| 399 | + | ||
| 400 | + | stored := storeState.All() | |
| 401 | + | require.Len(t, stored, 1) | |
| 402 | + | assert.Equal(t, "http01-b.example.com", stored[0].ID) | |
| 403 | + | } | |
| 404 | + | ||
| 405 | + | func TestServiceLoadUsesZeroCursorWhenLoadStateFails(t *testing.T) { | |
| 406 | + | ctx := context.Background() | |
| 407 | + | ||
| 408 | + | routes := &fakeRoutes{ | |
| 409 | + | routes: []domain.Route{ | |
| 410 | + | {Domain: "b.example.com"}, | |
| 411 | + | {Domain: "a.example.com"}, | |
| 412 | + | }, | |
| 413 | + | } | |
| 414 | + | issuer, recorder := newMockPublicCertificateIssuer(t, nil, nil) | |
| 415 | + | store, storeState := newMockCertificateStore(t) | |
| 416 | + | storeState.SetState(out.CertificateStoreState{ObtainCursor: 1}) | |
| 417 | + | storeState.SetLoadStateError(fmt.Errorf("state unreadable")) | |
| 418 | + | ||
| 419 | + | cfg := Config{ | |
| 420 | + | Enabled: true, | |
| 421 | + | Email: "admin@example.com", | |
| 422 | + | Challenge: "http-01", | |
| 423 | + | HTTPPort: 8088, | |
| 424 | + | TLSPort: 8443, | |
| 425 | + | ObtainBatchSize: 1, | |
| 426 | + | } | |
| 427 | + | svc := NewService(cfg, ServiceDeps{ | |
| 428 | + | Config: cfg, | |
| 429 | + | Routes: routes, | |
| 430 | + | Issuer: issuer, | |
| 431 | + | Store: store, | |
| 432 | + | Challenges: NewHTTP01Challenges(), | |
| 433 | + | Effective: EffectiveChallenge{ | |
| 434 | + | ConfiguredMode: domain.ACMEChallengeHTTP01, | |
| 435 | + | Mode: domain.ACMEChallengeHTTP01, | |
| 436 | + | TokenSource: domain.ACMETokenSourceNone, | |
| 437 | + | Reason: "http-01 challenge selected", | |
| 438 | + | }, | |
| 439 | + | }) | |
| 440 | + | ||
| 441 | + | require.NoError(t, svc.Load(ctx)) | |
| 442 | + | require.NoError(t, svc.Reconcile(ctx)) | |
| 443 | + | ||
| 444 | + | orders := recorder.Orders() | |
| 445 | + | require.Len(t, orders, 1) | |
| 446 | + | assert.Equal(t, "http01-a.example.com", orders[0].ID, "LoadState failure should fall back to the zero cursor") | |
| 447 | + | } | |
| 448 | + | ||
| 449 | + | func TestServiceReconcileUsesInMemoryCursorWhenPersistFails(t *testing.T) { | |
| 450 | + | ctx := context.Background() | |
| 451 | + | ||
| 452 | + | routes := &fakeRoutes{ | |
| 453 | + | routes: []domain.Route{ | |
| 454 | + | {Domain: "b.example.com"}, | |
| 455 | + | {Domain: "a.example.com"}, | |
| 456 | + | }, | |
| 457 | + | } | |
| 458 | + | issuer, recorder := newMockPublicCertificateIssuer(t, func(_ context.Context, order out.CertificateOrder) (*out.StoredCertificate, error) { | |
| 459 | + | if order.ID == "http01-a.example.com" { | |
| 460 | + | return nil, fmt.Errorf("acme validation failed") | |
| 461 | + | } | |
| 462 | + | return defaultStoredCert(order) | |
| 463 | + | }, nil) | |
| 464 | + | store, storeState := newMockCertificateStore(t) | |
| 465 | + | ||
| 466 | + | cfg := Config{ | |
| 467 | + | Enabled: true, | |
| 468 | + | Email: "admin@example.com", | |
| 469 | + | Challenge: "http-01", | |
| 470 | + | HTTPPort: 8088, | |
| 471 | + | TLSPort: 8443, | |
| 472 | + | ObtainBatchSize: 1, | |
| 473 | + | } | |
| 474 | + | svc := NewService(cfg, ServiceDeps{ | |
| 475 | + | Config: cfg, | |
| 476 | + | Routes: routes, | |
| 477 | + | Issuer: issuer, | |
| 478 | + | Store: store, | |
| 479 | + | Challenges: NewHTTP01Challenges(), | |
| 480 | + | Effective: EffectiveChallenge{ | |
| 481 | + | ConfiguredMode: domain.ACMEChallengeHTTP01, | |
| 482 | + | Mode: domain.ACMEChallengeHTTP01, | |
| 483 | + | TokenSource: domain.ACMETokenSourceNone, | |
| 484 | + | Reason: "http-01 challenge selected", | |
| 485 | + | }, | |
| 486 | + | }) | |
| 487 | + | ||
| 488 | + | storeState.SetSaveStateError(fmt.Errorf("disk full")) | |
| 489 | + | require.NoError(t, svc.Load(ctx)) | |
| 490 | + | require.NoError(t, svc.Reconcile(ctx)) | |
| 491 | + | require.NoError(t, svc.Reconcile(ctx)) | |
| 492 | + | assert.Equal(t, out.CertificateStoreState{}, storeState.State(), "failed SaveState should leave persisted state unchanged") | |
| 493 | + | ||
| 494 | + | orders := recorder.Orders() | |
| 495 | + | require.Len(t, orders, 2) | |
| 496 | + | assert.Equal(t, "http01-a.example.com", orders[0].ID) | |
| 497 | + | assert.Equal(t, "http01-b.example.com", orders[1].ID, "SaveState failure should still advance the in-memory cursor") | |
| 498 | + | ||
| 499 | + | stored := storeState.All() | |
| 500 | + | require.Len(t, stored, 1) | |
| 501 | + | assert.Equal(t, "http01-b.example.com", stored[0].ID) | |
| 502 | + | } | |
| 503 | + | ||
| 504 | + | func TestServiceReconcilePersistsBatchCursorAcrossRestart(t *testing.T) { | |
| 505 | + | ctx := context.Background() | |
| 506 | + | ||
| 507 | + | routes := &fakeRoutes{ | |
| 508 | + | routes: []domain.Route{ | |
| 509 | + | {Domain: "b.example.com"}, | |
| 510 | + | {Domain: "a.example.com"}, | |
| 511 | + | }, | |
| 512 | + | } | |
| 513 | + | issuer, recorder := newMockPublicCertificateIssuer(t, func(_ context.Context, order out.CertificateOrder) (*out.StoredCertificate, error) { | |
| 514 | + | if order.ID == "http01-a.example.com" { | |
| 515 | + | return nil, fmt.Errorf("acme validation failed") | |
| 516 | + | } | |
| 517 | + | return defaultStoredCert(order) | |
| 518 | + | }, nil) | |
| 519 | + | store, storeState := newMockCertificateStore(t) | |
| 520 | + | ||
| 521 | + | cfg := Config{ | |
| 522 | + | Enabled: true, | |
| 523 | + | Email: "admin@example.com", | |
| 524 | + | Challenge: "http-01", | |
| 525 | + | HTTPPort: 8088, | |
| 526 | + | TLSPort: 8443, | |
| 527 | + | ObtainBatchSize: 1, | |
| 528 | + | } | |
| 529 | + | deps := ServiceDeps{ | |
| 530 | + | Config: cfg, | |
| 531 | + | Routes: routes, | |
| 532 | + | Issuer: issuer, | |
| 533 | + | Store: store, | |
| 534 | + | Challenges: NewHTTP01Challenges(), | |
| 535 | + | Effective: EffectiveChallenge{ | |
| 536 | + | ConfiguredMode: domain.ACMEChallengeHTTP01, | |
| 537 | + | Mode: domain.ACMEChallengeHTTP01, | |
| 538 | + | TokenSource: domain.ACMETokenSourceNone, | |
| 539 | + | Reason: "http-01 challenge selected", | |
| 540 | + | }, | |
| 541 | + | } | |
| 542 | + | ||
| 543 | + | firstSvc := NewService(cfg, deps) | |
| 544 | + | require.NoError(t, firstSvc.Load(ctx)) | |
| 545 | + | require.NoError(t, firstSvc.Reconcile(ctx)) | |
| 546 | + | assert.Equal(t, out.CertificateStoreState{ObtainCursor: 1}, storeState.State()) | |
| 547 | + | ||
| 548 | + | secondSvc := NewService(cfg, deps) | |
| 549 | + | require.NoError(t, secondSvc.Load(ctx)) | |
| 550 | + | require.NoError(t, secondSvc.Reconcile(ctx)) | |
| 551 | + | ||
| 552 | + | orders := recorder.Orders() | |
| 553 | + | require.Len(t, orders, 2) | |
| 554 | + | assert.Equal(t, "http01-a.example.com", orders[0].ID) | |
| 555 | + | assert.Equal(t, "http01-b.example.com", orders[1].ID, "restart should resume from the persisted obtain cursor") | |
| 556 | + | ||
| 557 | + | stored := storeState.All() | |
| 558 | + | require.Len(t, stored, 1) | |
| 559 | + | assert.Equal(t, "http01-b.example.com", stored[0].ID) | |
| 560 | + | } | |
| 561 | + | ||
| 254 | 562 | func TestServiceReconcileObtainsMissingHTTP01Cert(t *testing.T) { | |
| 255 | 563 | ctx := context.Background() | |
| 256 | 564 | ||
| @@ -130,9 +130,12 @@ func defaultStoredCert(order out.CertificateOrder) (*out.StoredCertificate, erro | |||||
| 130 | } | 130 | } | ||
| 131 | 131 | ||||
| 132 | type mockCertificateStoreState struct { | 132 | type mockCertificateStoreState struct { | ||
| 133 | - | mu sync.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 | + | loadStateErr error | |||
| 138 | + | saveStateErr error | |||
| 136 | } | 139 | } | ||
| 137 | 140 | ||||
| 138 | func (s *mockCertificateStoreState) All() []out.StoredCertificate { | 141 | func (s *mockCertificateStoreState) All() []out.StoredCertificate { | ||
| @@ -143,6 +146,30 @@ func (s *mockCertificateStoreState) All() []out.StoredCertificate { | |||||
| 143 | return certs | 146 | return certs | ||
| 144 | } | 147 | } | ||
| 145 | 148 | ||||
| 149 | + | func (s *mockCertificateStoreState) State() out.CertificateStoreState { | |||
| 150 | + | s.mu.Lock() | |||
| 151 | + | defer s.mu.Unlock() | |||
| 152 | + | return s.state | |||
| 153 | + | } | |||
| 154 | + | ||||
| 155 | + | func (s *mockCertificateStoreState) SetState(state out.CertificateStoreState) { | |||
| 156 | + | s.mu.Lock() | |||
| 157 | + | defer s.mu.Unlock() | |||
| 158 | + | s.state = state | |||
| 159 | + | } | |||
| 160 | + | ||||
| 161 | + | func (s *mockCertificateStoreState) SetLoadStateError(err error) { | |||
| 162 | + | s.mu.Lock() | |||
| 163 | + | defer s.mu.Unlock() | |||
| 164 | + | s.loadStateErr = err | |||
| 165 | + | } | |||
| 166 | + | ||||
| 167 | + | func (s *mockCertificateStoreState) SetSaveStateError(err error) { | |||
| 168 | + | s.mu.Lock() | |||
| 169 | + | defer s.mu.Unlock() | |||
| 170 | + | s.saveStateErr = err | |||
| 171 | + | } | |||
| 172 | + | ||||
| 146 | func newMockCertificateStore(t *testing.T, initial ...out.StoredCertificate) (*outmocks.MockCertificateStore, *mockCertificateStoreState) { | 173 | func newMockCertificateStore(t *testing.T, initial ...out.StoredCertificate) (*outmocks.MockCertificateStore, *mockCertificateStoreState) { | ||
| 147 | t.Helper() | 174 | t.Helper() | ||
| 148 | state := &mockCertificateStoreState{certs: append([]out.StoredCertificate(nil), initial...)} | 175 | state := &mockCertificateStoreState{certs: append([]out.StoredCertificate(nil), initial...)} | ||
| @@ -173,6 +200,27 @@ func newMockCertificateStore(t *testing.T, initial ...out.StoredCertificate) (*o | |||||
| 173 | }) | 200 | }) | ||
| 174 | loadAllCall.Maybe() | 201 | loadAllCall.Maybe() | ||
| 175 | 202 | ||||
| 203 | + | loadStateCall := store.EXPECT().LoadState(mock.Anything).RunAndReturn(func(context.Context) (out.CertificateStoreState, error) { | |||
| 204 | + | state.mu.Lock() | |||
| 205 | + | defer state.mu.Unlock() | |||
| 206 | + | if state.loadStateErr != nil { | |||
| 207 | + | return out.CertificateStoreState{}, state.loadStateErr | |||
| 208 | + | } | |||
| 209 | + | return state.state, nil | |||
| 210 | + | }) | |||
| 211 | + | loadStateCall.Maybe() | |||
| 212 | + | ||||
| 213 | + | saveStateCall := store.EXPECT().SaveState(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, storeState out.CertificateStoreState) error { | |||
| 214 | + | state.mu.Lock() | |||
| 215 | + | defer state.mu.Unlock() | |||
| 216 | + | if state.saveStateErr != nil { | |||
| 217 | + | return state.saveStateErr | |||
| 218 | + | } | |||
| 219 | + | state.state = storeState | |||
| 220 | + | return nil | |||
| 221 | + | }) | |||
| 222 | + | saveStateCall.Maybe() | |||
| 223 | + | ||||
| 176 | saveCall := store.EXPECT().Save(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, cert out.StoredCertificate) error { | 224 | saveCall := store.EXPECT().Save(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, cert out.StoredCertificate) error { | ||
| 177 | state.mu.Lock() | 225 | state.mu.Lock() | ||
| 178 | defer state.mu.Unlock() | 226 | defer state.mu.Unlock() | ||
| @@ -251,6 +299,266 @@ func generateTestCertPEM(names []string) (certPEM []byte, keyPEM []byte, err err | |||||
| 251 | // Tests | 299 | // Tests | ||
| 252 | // --------------------------------------------------------------------------- | 300 | // --------------------------------------------------------------------------- | ||
| 253 | 301 | ||||
| 302 | + | func TestServiceReconcileLimitsMissingObtainsPerRun(t *testing.T) { | |||
| 303 | + | ctx := context.Background() | |||
| 304 | + | ||||
| 305 | + | routes := &fakeRoutes{ | |||
| 306 | + | routes: []domain.Route{ | |||
| 307 | + | {Domain: "one.example.com"}, | |||
| 308 | + | {Domain: "two.example.com"}, | |||
| 309 | + | {Domain: "three.example.com"}, | |||
| 310 | + | }, | |||
| 311 | + | } | |||
| 312 | + | issuer, recorder := newMockPublicCertificateIssuer(t, nil, nil) | |||
| 313 | + | store, storeState := newMockCertificateStore(t) | |||
| 314 | + | ||||
| 315 | + | cfg := Config{ | |||
| 316 | + | Enabled: true, | |||
| 317 | + | Email: "admin@example.com", | |||
| 318 | + | Challenge: "http-01", | |||
| 319 | + | HTTPPort: 8088, | |||
| 320 | + | TLSPort: 8443, | |||
| 321 | + | ObtainBatchSize: 2, | |||
| 322 | + | } | |||
| 323 | + | ||||
| 324 | + | svc := NewService(cfg, ServiceDeps{ | |||
| 325 | + | Config: cfg, | |||
| 326 | + | Routes: routes, | |||
| 327 | + | Issuer: issuer, | |||
| 328 | + | Store: store, | |||
| 329 | + | Challenges: NewHTTP01Challenges(), | |||
| 330 | + | Effective: EffectiveChallenge{ | |||
| 331 | + | ConfiguredMode: domain.ACMEChallengeHTTP01, | |||
| 332 | + | Mode: domain.ACMEChallengeHTTP01, | |||
| 333 | + | TokenSource: domain.ACMETokenSourceNone, | |||
| 334 | + | Reason: "http-01 challenge selected", | |||
| 335 | + | }, | |||
| 336 | + | }) | |||
| 337 | + | ||||
| 338 | + | require.NoError(t, svc.Load(ctx)) | |||
| 339 | + | require.NoError(t, svc.Reconcile(ctx)) | |||
| 340 | + | ||||
| 341 | + | orders := recorder.Orders() | |||
| 342 | + | require.Len(t, orders, 2, "first reconcile should respect configured obtain batch size") | |||
| 343 | + | assert.Len(t, storeState.All(), 2) | |||
| 344 | + | ||||
| 345 | + | require.NoError(t, svc.Reconcile(ctx)) | |||
| 346 | + | orders = recorder.Orders() | |||
| 347 | + | require.Len(t, orders, 3, "second reconcile should continue remaining missing targets") | |||
| 348 | + | assert.Len(t, storeState.All(), 3) | |||
| 349 | + | } | |||
| 350 | + | ||||
| 351 | + | func TestServiceReconcileRotatesBatchAfterFailedObtain(t *testing.T) { | |||
| 352 | + | ctx := context.Background() | |||
| 353 | + | ||||
| 354 | + | routes := &fakeRoutes{ | |||
| 355 | + | routes: []domain.Route{ | |||
| 356 | + | {Domain: "b.example.com"}, | |||
| 357 | + | {Domain: "a.example.com"}, | |||
| 358 | + | }, | |||
| 359 | + | } | |||
| 360 | + | issuer, recorder := newMockPublicCertificateIssuer(t, func(_ context.Context, order out.CertificateOrder) (*out.StoredCertificate, error) { | |||
| 361 | + | if order.ID == "http01-a.example.com" { | |||
| 362 | + | return nil, fmt.Errorf("acme validation failed") | |||
| 363 | + | } | |||
| 364 | + | return defaultStoredCert(order) | |||
| 365 | + | }, nil) | |||
| 366 | + | store, storeState := newMockCertificateStore(t) | |||
| 367 | + | ||||
| 368 | + | cfg := Config{ | |||
| 369 | + | Enabled: true, | |||
| 370 | + | Email: "admin@example.com", | |||
| 371 | + | Challenge: "http-01", | |||
| 372 | + | HTTPPort: 8088, | |||
| 373 | + | TLSPort: 8443, | |||
| 374 | + | ObtainBatchSize: 1, | |||
| 375 | + | } | |||
| 376 | + | ||||
| 377 | + | svc := NewService(cfg, ServiceDeps{ | |||
| 378 | + | Config: cfg, | |||
| 379 | + | Routes: routes, | |||
| 380 | + | Issuer: issuer, | |||
| 381 | + | Store: store, | |||
| 382 | + | Challenges: NewHTTP01Challenges(), | |||
| 383 | + | Effective: EffectiveChallenge{ | |||
| 384 | + | ConfiguredMode: domain.ACMEChallengeHTTP01, | |||
| 385 | + | Mode: domain.ACMEChallengeHTTP01, | |||
| 386 | + | TokenSource: domain.ACMETokenSourceNone, | |||
| 387 | + | Reason: "http-01 challenge selected", | |||
| 388 | + | }, | |||
| 389 | + | }) | |||
| 390 | + | ||||
| 391 | + | require.NoError(t, svc.Load(ctx)) | |||
| 392 | + | require.NoError(t, svc.Reconcile(ctx)) | |||
| 393 | + | require.NoError(t, svc.Reconcile(ctx)) | |||
| 394 | + | ||||
| 395 | + | orders := recorder.Orders() | |||
| 396 | + | require.Len(t, orders, 2) | |||
| 397 | + | assert.Equal(t, "http01-a.example.com", orders[0].ID) | |||
| 398 | + | assert.Equal(t, "http01-b.example.com", orders[1].ID, "second reconcile should rotate past the failed first target") | |||
| 399 | + | ||||
| 400 | + | stored := storeState.All() | |||
| 401 | + | require.Len(t, stored, 1) | |||
| 402 | + | assert.Equal(t, "http01-b.example.com", stored[0].ID) | |||
| 403 | + | } | |||
| 404 | + | ||||
| 405 | + | func TestServiceLoadUsesZeroCursorWhenLoadStateFails(t *testing.T) { | |||
| 406 | + | ctx := context.Background() | |||
| 407 | + | ||||
| 408 | + | routes := &fakeRoutes{ | |||
| 409 | + | routes: []domain.Route{ | |||
| 410 | + | {Domain: "b.example.com"}, | |||
| 411 | + | {Domain: "a.example.com"}, | |||
| 412 | + | }, | |||
| 413 | + | } | |||
| 414 | + | issuer, recorder := newMockPublicCertificateIssuer(t, nil, nil) | |||
| 415 | + | store, storeState := newMockCertificateStore(t) | |||
| 416 | + | storeState.SetState(out.CertificateStoreState{ObtainCursor: 1}) | |||
| 417 | + | storeState.SetLoadStateError(fmt.Errorf("state unreadable")) | |||
| 418 | + | ||||
| 419 | + | cfg := Config{ | |||
| 420 | + | Enabled: true, | |||
| 421 | + | Email: "admin@example.com", | |||
| 422 | + | Challenge: "http-01", | |||
| 423 | + | HTTPPort: 8088, | |||
| 424 | + | TLSPort: 8443, | |||
| 425 | + | ObtainBatchSize: 1, | |||
| 426 | + | } | |||
| 427 | + | svc := NewService(cfg, ServiceDeps{ | |||
| 428 | + | Config: cfg, | |||
| 429 | + | Routes: routes, | |||
| 430 | + | Issuer: issuer, | |||
| 431 | + | Store: store, | |||
| 432 | + | Challenges: NewHTTP01Challenges(), | |||
| 433 | + | Effective: EffectiveChallenge{ | |||
| 434 | + | ConfiguredMode: domain.ACMEChallengeHTTP01, | |||
| 435 | + | Mode: domain.ACMEChallengeHTTP01, | |||
| 436 | + | TokenSource: domain.ACMETokenSourceNone, | |||
| 437 | + | Reason: "http-01 challenge selected", | |||
| 438 | + | }, | |||
| 439 | + | }) | |||
| 440 | + | ||||
| 441 | + | require.NoError(t, svc.Load(ctx)) | |||
| 442 | + | require.NoError(t, svc.Reconcile(ctx)) | |||
| 443 | + | ||||
| 444 | + | orders := recorder.Orders() | |||
| 445 | + | require.Len(t, orders, 1) | |||
| 446 | + | assert.Equal(t, "http01-a.example.com", orders[0].ID, "LoadState failure should fall back to the zero cursor") | |||
| 447 | + | } | |||
| 448 | + | ||||
| 449 | + | func TestServiceReconcileUsesInMemoryCursorWhenPersistFails(t *testing.T) { | |||
| 450 | + | ctx := context.Background() | |||
| 451 | + | ||||
| 452 | + | routes := &fakeRoutes{ | |||
| 453 | + | routes: []domain.Route{ | |||
| 454 | + | {Domain: "b.example.com"}, | |||
| 455 | + | {Domain: "a.example.com"}, | |||
| 456 | + | }, | |||
| 457 | + | } | |||
| 458 | + | issuer, recorder := newMockPublicCertificateIssuer(t, func(_ context.Context, order out.CertificateOrder) (*out.StoredCertificate, error) { | |||
| 459 | + | if order.ID == "http01-a.example.com" { | |||
| 460 | + | return nil, fmt.Errorf("acme validation failed") | |||
| 461 | + | } | |||
| 462 | + | return defaultStoredCert(order) | |||
| 463 | + | }, nil) | |||
| 464 | + | store, storeState := newMockCertificateStore(t) | |||
| 465 | + | ||||
| 466 | + | cfg := Config{ | |||
| 467 | + | Enabled: true, | |||
| 468 | + | Email: "admin@example.com", | |||
| 469 | + | Challenge: "http-01", | |||
| 470 | + | HTTPPort: 8088, | |||
| 471 | + | TLSPort: 8443, | |||
| 472 | + | ObtainBatchSize: 1, | |||
| 473 | + | } | |||
| 474 | + | svc := NewService(cfg, ServiceDeps{ | |||
| 475 | + | Config: cfg, | |||
| 476 | + | Routes: routes, | |||
| 477 | + | Issuer: issuer, | |||
| 478 | + | Store: store, | |||
| 479 | + | Challenges: NewHTTP01Challenges(), | |||
| 480 | + | Effective: EffectiveChallenge{ | |||
| 481 | + | ConfiguredMode: domain.ACMEChallengeHTTP01, | |||
| 482 | + | Mode: domain.ACMEChallengeHTTP01, | |||
| 483 | + | TokenSource: domain.ACMETokenSourceNone, | |||
| 484 | + | Reason: "http-01 challenge selected", | |||
| 485 | + | }, | |||
| 486 | + | }) | |||
| 487 | + | ||||
| 488 | + | storeState.SetSaveStateError(fmt.Errorf("disk full")) | |||
| 489 | + | require.NoError(t, svc.Load(ctx)) | |||
| 490 | + | require.NoError(t, svc.Reconcile(ctx)) | |||
| 491 | + | require.NoError(t, svc.Reconcile(ctx)) | |||
| 492 | + | assert.Equal(t, out.CertificateStoreState{}, storeState.State(), "failed SaveState should leave persisted state unchanged") | |||
| 493 | + | ||||
| 494 | + | orders := recorder.Orders() | |||
| 495 | + | require.Len(t, orders, 2) | |||
| 496 | + | assert.Equal(t, "http01-a.example.com", orders[0].ID) | |||
| 497 | + | assert.Equal(t, "http01-b.example.com", orders[1].ID, "SaveState failure should still advance the in-memory cursor") | |||
| 498 | + | ||||
| 499 | + | stored := storeState.All() | |||
| 500 | + | require.Len(t, stored, 1) | |||
| 501 | + | assert.Equal(t, "http01-b.example.com", stored[0].ID) | |||
| 502 | + | } | |||
| 503 | + | ||||
| 504 | + | func TestServiceReconcilePersistsBatchCursorAcrossRestart(t *testing.T) { | |||
| 505 | + | ctx := context.Background() | |||
| 506 | + | ||||
| 507 | + | routes := &fakeRoutes{ | |||
| 508 | + | routes: []domain.Route{ | |||
| 509 | + | {Domain: "b.example.com"}, | |||
| 510 | + | {Domain: "a.example.com"}, | |||
| 511 | + | }, | |||
| 512 | + | } | |||
| 513 | + | issuer, recorder := newMockPublicCertificateIssuer(t, func(_ context.Context, order out.CertificateOrder) (*out.StoredCertificate, error) { | |||
| 514 | + | if order.ID == "http01-a.example.com" { | |||
| 515 | + | return nil, fmt.Errorf("acme validation failed") | |||
| 516 | + | } | |||
| 517 | + | return defaultStoredCert(order) | |||
| 518 | + | }, nil) | |||
| 519 | + | store, storeState := newMockCertificateStore(t) | |||
| 520 | + | ||||
| 521 | + | cfg := Config{ | |||
| 522 | + | Enabled: true, | |||
| 523 | + | Email: "admin@example.com", | |||
| 524 | + | Challenge: "http-01", | |||
| 525 | + | HTTPPort: 8088, | |||
| 526 | + | TLSPort: 8443, | |||
| 527 | + | ObtainBatchSize: 1, | |||
| 528 | + | } | |||
| 529 | + | deps := ServiceDeps{ | |||
| 530 | + | Config: cfg, | |||
| 531 | + | Routes: routes, | |||
| 532 | + | Issuer: issuer, | |||
| 533 | + | Store: store, | |||
| 534 | + | Challenges: NewHTTP01Challenges(), | |||
| 535 | + | Effective: EffectiveChallenge{ | |||
| 536 | + | ConfiguredMode: domain.ACMEChallengeHTTP01, | |||
| 537 | + | Mode: domain.ACMEChallengeHTTP01, | |||
| 538 | + | TokenSource: domain.ACMETokenSourceNone, | |||
| 539 | + | Reason: "http-01 challenge selected", | |||
| 540 | + | }, | |||
| 541 | + | } | |||
| 542 | + | ||||
| 543 | + | firstSvc := NewService(cfg, deps) | |||
| 544 | + | require.NoError(t, firstSvc.Load(ctx)) | |||
| 545 | + | require.NoError(t, firstSvc.Reconcile(ctx)) | |||
| 546 | + | assert.Equal(t, out.CertificateStoreState{ObtainCursor: 1}, storeState.State()) | |||
| 547 | + | ||||
| 548 | + | secondSvc := NewService(cfg, deps) | |||
| 549 | + | require.NoError(t, secondSvc.Load(ctx)) | |||
| 550 | + | require.NoError(t, secondSvc.Reconcile(ctx)) | |||
| 551 | + | ||||
| 552 | + | orders := recorder.Orders() | |||
| 553 | + | require.Len(t, orders, 2) | |||
| 554 | + | assert.Equal(t, "http01-a.example.com", orders[0].ID) | |||
| 555 | + | assert.Equal(t, "http01-b.example.com", orders[1].ID, "restart should resume from the persisted obtain cursor") | |||
| 556 | + | ||||
| 557 | + | stored := storeState.All() | |||
| 558 | + | require.Len(t, stored, 1) | |||
| 559 | + | assert.Equal(t, "http01-b.example.com", stored[0].ID) | |||
| 560 | + | } | |||
| 561 | + | ||||
| 254 | func TestServiceReconcileObtainsMissingHTTP01Cert(t *testing.T) { | 562 | func TestServiceReconcileObtainsMissingHTTP01Cert(t *testing.T) { | ||
| 255 | ctx := context.Background() | 563 | ctx := context.Background() | ||
| 256 | 564 | ||||