support manual relay certificates and simplify acme manager

rabbitprincess committed Apr 1, 2026 at 23:45 UTC 3a18116d53f1187e5c8aa36a8a959b2b9a8b142a
16 files changed +629 -231
.env.example
+6 -3
@@ -15,9 +15,12 @@ UDP_PORT_COUNT=0
15
16 # TLS/ACME and keyless materials
17 KEYLESS_DIR=/portal-certs
18 -# Supported values: cloudflare, route53
19 -ACME_DNS_PROVIDER=cloudflare
20 -# Optional ENS gasless DNS import automation. When enabled, Portal also manages DNSSEC for the zone.
18 +# Leave empty to use manual fullchain.pem/privatekey.pem from KEYLESS_DIR.
19 +# Set this when Portal should manage ACME DNS-01/renewal and/or ENS gasless DNSSEC/TXT automation.
20 +# Supported managed values: cloudflare, route53
21 +ACME_DNS_PROVIDER=
22 +# Optional ENS gasless DNS import automation. When enabled, Portal uses ACME_DNS_PROVIDER
23 +# for DNSSEC and ENS TXT automation, even when certificate files are managed manually.
24 ENS_GASLESS_ENABLED=false
25
26 # Cloudflare API token (required when ACME_DNS_PROVIDER=cloudflare)
README.md
+7 -1
@@ -53,8 +53,14 @@ cd portal && cp .env.example .env
53 docker compose up
54 ```
55
56 -The Docker setup persists both the relay identity JSON and relay certificates under `./.portal-certs`. Keep that directory on persistent storage if you want a stable relay address and ACME state across restarts.
56 +The Docker setup persists both the relay identity JSON and relay certificates under `./.portal-certs`. Keep that directory on persistent storage if you want a stable relay address and certificate state across restarts.
57
58 +For public domains, you can either:
59 +
60 +- place `fullchain.pem` and `privatekey.pem` in `./.portal-certs` and leave `ACME_DNS_PROVIDER` empty, or
61 +- set `ACME_DNS_PROVIDER=cloudflare|route53` and let Portal manage DNS-01 + renewal
62 +
63 +If you want Portal-managed ENS TXT/DNSSEC while keeping manual certificate files, place the certs in `./.portal-certs`, set `ACME_DNS_PROVIDER`, and enable `ENS_GASLESS_ENABLED=true`.
64 For deployment to a public domain, see [docs/deployment.md](docs/deployment.md).
65
66 ### Expose Local Service via Tunnel
cmd/relay-server/main.go
+2 -1
@@ -80,7 +80,7 @@ func runServeCommand(args []string) error {
80
81 utils.StringFlagEnv(fs, &cfg.KeylessDir, "keyless-dir", "./.portal-certs", "directory path for relay keyless materials", "KEYLESS_DIR")
82 utils.StringFlagEnv(fs, &cfg.AdminSettingsPath, "admin-settings-path", "admin_settings.json", "admin settings file path", "ADMIN_SETTINGS_PATH")
83 - utils.StringFlagEnv(fs, &cfg.ACMEDNSProvider, "acme-dns-provider", "cloudflare", "ACME DNS provider for DNS-01 and A-record sync (cloudflare|route53)", "ACME_DNS_PROVIDER")
83 + utils.StringFlagEnv(fs, &cfg.ACMEDNSProvider, "acme-dns-provider", "", "ACME DNS provider for managed DNS-01/A-record sync and ENS gasless DNSSEC/TXT automation (cloudflare|route53); leave empty to use manual fullchain.pem/privatekey.pem from KEYLESS_DIR", "ACME_DNS_PROVIDER")
84 utils.BoolFlagEnv(fs, &cfg.ENSGaslessEnabled, "ens-gasless-enabled", false, "enable ENS gasless DNS import automation for the managed DNS zone and lease hostnames", "ENS_GASLESS_ENABLED")
85 utils.StringFlagEnv(fs, &cfg.CloudflareToken, "cloudflare-token", "", "Cloudflare DNS API token (required when acme-dns-provider=cloudflare)", "CLOUDFLARE_TOKEN")
86 utils.StringFlagEnv(fs, &cfg.AWSAccessKeyID, "aws-access-key-id", "", "AWS access key ID for Route53 static credentials; uses the default AWS credential chain when omitted", "AWS_ACCESS_KEY_ID")
@@ -109,6 +109,7 @@ func runServeCommand(args []string) error {
109 Str("admin_settings_path", cfg.AdminSettingsPath).
110 Bool("landing_page_enabled", cfg.LandingPageEnabled).
111 Bool("discovery_enabled", cfg.DiscoveryEnabled).
112 + Str("acme_dns_provider", cfg.ACMEDNSProvider).
113 Bool("ens_gasless_enabled", cfg.ENSGaslessEnabled).
114 Bool("wireguard_enabled", strings.TrimSpace(cfg.WireGuardPrivateKey) != "").
115 Bool("udp_enabled", cfg.UDPPortCount > 0).
docker-compose.yml
+4 -1
@@ -37,7 +37,8 @@ services:
37
38 # TLS/ACME and keyless materials
39 KEYLESS_DIR: ${KEYLESS_DIR:-/portal-certs}
40 - ACME_DNS_PROVIDER: ${ACME_DNS_PROVIDER:-cloudflare}
40 + ACME_DNS_PROVIDER: ${ACME_DNS_PROVIDER:-}
41 + ENS_GASLESS_ENABLED: ${ENS_GASLESS_ENABLED:-false}
42 CLOUDFLARE_TOKEN: ${CLOUDFLARE_TOKEN:-}
43 AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID:-}
44 AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY:-}
@@ -45,6 +46,8 @@ services:
46 AWS_REGION: ${AWS_REGION:-}
47 AWS_DEFAULT_REGION: ${AWS_DEFAULT_REGION:-}
48 AWS_HOSTED_ZONE_ID: ${AWS_HOSTED_ZONE_ID:-}
49 + AWS_DNSSEC_KMS_KEY_ARN: ${AWS_DNSSEC_KMS_KEY_ARN:-}
50 + DNSSEC_KSK_NAME: ${DNSSEC_KSK_NAME:-}
51 volumes:
52 - ./.portal-certs:${KEYLESS_DIR:-/portal-certs}
53 restart: unless-stopped
docs/architecture.md
+8 -4
@@ -63,11 +63,13 @@ UDP client
63
64 ### Operational Constraints
65
66 -- For non-localhost deployments, ACME management supports only `cloudflare` and `route53`.
66 +- For non-localhost deployments, relay TLS can run from manual certificate files in `KEYLESS_DIR` or from managed ACME.
67 +- When managed ACME is enabled, supported DNS providers are only `cloudflare` and `route53`.
68 +- ENS gasless automation reuses `ACME_DNS_PROVIDER` for DNSSEC and ENS TXT sync.
69 - Relay, tunnel, and demo-app identities are persisted as JSON at `IDENTITY_PATH` / `--identity-path`. Missing files are generated automatically and stored with `name`, `address`, `public_key`, and `private_key`.
68 -- Non-localhost ACME keeps both root and wildcard DNS A records in sync.
70 +- Managed non-localhost ACME keeps both root and wildcard DNS A records in sync.
71 - Relay certificate material lives under `KEYLESS_DIR` as `fullchain.pem` and `privatekey.pem`.
70 -- Localhost uses the development certificate path instead of DNS-provider-managed ACME.
72 +- Localhost uses the development certificate path instead of public managed/manual certificate setup.
73
74 ## Connection Model
75
@@ -348,10 +350,12 @@ Relay-local frontend asset filenames stay in `cmd/relay-server`, not `types/`.
350 - Relay admin/API TLS uses the certificate in `KEYLESS_DIR`
351 - `fullchain.pem`
352 - `privatekey.pem`
351 -- For non-localhost deployments, ACME DNS-01 currently supports `cloudflare` and `route53`, and keeps:
353 +- For non-localhost deployments, Portal can either use those files directly or manage them through ACME.
354 +- When ACME is enabled, DNS-01 currently supports `cloudflare` and `route53`, and keeps:
355 - root host A record
356 - wildcard host A record
357 - relay certificate renewal
358 +- When manual certificate files are present and valid, Portal uses them instead of provisioning a new certificate, but can still use `ACME_DNS_PROVIDER` for ENS gasless DNSSEC/TXT automation.
359 - SDK/tunnel fetches the relay certificate chain from the relay root host, verifies that the leaf covers tenant hostnames, and builds a tenant-side `tls.Config` with a remote signer backed by `/v1/sign`
360 - During tenant TLS handshake, the SDK/tunnel endpoint acts as the TLS server and derives tenant session keys locally; the relay only signs handshake digests and does not receive tenant TLS traffic secrets
361 - Relay control-plane TLS and reverse-session setup still terminate on the relay's admin/API listener and are not protected by the tenant keyless TLS path
docs/deployment.md
+84 -21
@@ -9,24 +9,43 @@ You need:
9 - A public domain, for example `example.com`
10 - A public Linux server with a static public IPv4
11 - Docker and Docker Compose
12 -- A supported DNS provider account for ACME DNS-01 automation: `cloudflare` or `route53`
12 +- Optional for managed ACME DNS-01 automation or Portal-managed ENS TXT sync: a supported DNS provider account for `cloudflare` or `route53`
13 - Open inbound ports:
14 - `443/tcp`
15 - `4017/tcp`
16 - optional for UDP transport:
17 - `4017/udp`
18 - - `50000+/udp` (see section 4)
18 + - `50000+/udp` (see section 5)
19
20 -## 2. DNS Provider Setup
20 +## 2. Certificate and DNS Mode
21
22 -### 2.1 Choose ACME DNS provider
22 +Choose one of these modes:
23 +
24 +- Manual certificate mode
25 + - Leave `ACME_DNS_PROVIDER` empty.
26 + - Place `fullchain.pem` and `privatekey.pem` in `KEYLESS_DIR`.
27 + - Portal uses the files as-is and does not modify DNS or renew the certificate.
28 +- Manual certificate + gasless mode
29 + - Place `fullchain.pem` and `privatekey.pem` in `KEYLESS_DIR`.
30 + - Set `ACME_DNS_PROVIDER`.
31 + - Portal keeps the manual certificate files, skips ACME certificate issuance, and still uses the provider for DNSSEC + ENS TXT automation.
32 +- Managed ACME mode
33 + - Set `ACME_DNS_PROVIDER` to `cloudflare` or `route53`.
34 + - Portal manages root/wildcard A records and certificate renewal.
35 + - If ENS gasless is enabled, Portal also manages DNSSEC.
36 +
37 +If you only need a relay and do not need Portal-managed DNS or automatic renewal, manual certificate mode is the simplest option.
38 +
39 +## 3. Managed ACME Provider Setup
40 +
41 +### 3.1 Choose ACME DNS provider
42
43 Set `ACME_DNS_PROVIDER` to one of:
44
45 - `cloudflare`
46 - `route53`
47
29 -### 2.2 Cloudflare setup
48 +### 3.2 Cloudflare setup
49
50 #### Add domain to Cloudflare
51
@@ -60,7 +79,7 @@ Required permissions:
79
80 - `Zone:Read`
81 - `DNS:Edit`
63 -- optional when `ENS_GASLESS_ENABLED=true`:
82 +- optional when `ENS_GASLESS_ENABLED=true` and `ACME_DNS_PROVIDER=cloudflare`:
83 - `Zone Settings:Edit`
84
85 Scope:
@@ -69,7 +88,7 @@ Scope:
88
89 Save the token for `CLOUDFLARE_TOKEN`.
90
72 -### 2.3 Route53 setup
91 +### 3.3 Route53 setup
92
93 Create or select a public hosted zone that covers your relay host.
94
@@ -97,18 +116,20 @@ Equivalent relay flags:
116 - `--aws-region`
117 - `--aws-hosted-zone-id`
118
100 -When `ENS_GASLESS_ENABLED=true` and the hosted zone does not already have an active Route53 key-signing key (KSK), also provide:
119 +When `ENS_GASLESS_ENABLED=true` and `ACME_DNS_PROVIDER=route53` and the hosted zone does not already have an active Route53 key-signing key (KSK), also provide:
120
121 - `AWS_DNSSEC_KMS_KEY_ARN`
122 - optional `DNSSEC_KSK_NAME`
123
105 -### 2.4 Optional ENS Gasless Automation
124 +### 3.4 Optional ENS Gasless Automation
125
107 -Portal can optionally enable ENS gasless DNS import for the managed zone and lease hostnames.
126 +Portal can optionally enable ENS gasless DNS import for the base domain and lease hostnames.
127
128 - This is not required for normal Portal deployment.
129 - Enable it only when you specifically need ENS gasless DNS import.
111 -- Portal manages DNSSEC as part of this mode.
130 +- ENS gasless automation requires `ACME_DNS_PROVIDER`.
131 +- Portal uses that provider for both DNSSEC automation and ENS TXT create/delete.
132 +- If valid manual certificate files already exist in `KEYLESS_DIR`, Portal keeps using them and does not force ACME certificate issuance just because `ACME_DNS_PROVIDER` is set.
133 - Cloudflare can enable zone signing directly, but some registrars still require publishing the returned DS record.
134 - Route53 requires a compatible KMS key ARN when no active KSK already exists, and the registrar may still require the DS record.
135 - New lease hostnames such as `app.portal.example.com` are published automatically when they register and are cleaned up on unregister or expiry.
@@ -118,11 +139,51 @@ Portal can optionally enable ENS gasless DNS import for the managed zone and lea
139 - This enables offchain gasless DNSSEC usage in ENS-aware clients. It does not perform an onchain ENS claim transaction.
140 - Keep `ENS_GASLESS_ENABLED=false` unless you intend to use ENS gasless DNS import.
141
121 -## 3. Run Relay Server
142 +## 4. Run Relay Server
143
123 -### 3.1 Create `.env` at repository root
144 +### 4.1 Create `.env` at repository root
145
125 -Example:
146 +Manual certificate example:
147 +
148 +```bash
149 +PORTAL_URL=https://example.com
150 +BOOTSTRAPS=
151 +DISCOVERY=true
152 +IDENTITY_PATH=/portal-certs/identity.json
153 +WIREGUARD_ENDPOINT=
154 +SNI_PORT=443
155 +ADMIN_SECRET_KEY=your-admin-secret
156 +KEYLESS_DIR=/portal-certs
157 +ACME_DNS_PROVIDER=
158 +ENS_GASLESS_ENABLED=false
159 +```
160 +
161 +Place these files in `KEYLESS_DIR` before startup:
162 +
163 +```text
164 +/portal-certs/fullchain.pem
165 +/portal-certs/privatekey.pem
166 +```
167 +
168 +Manual certificate + gasless example:
169 +
170 +```bash
171 +PORTAL_URL=https://example.com
172 +BOOTSTRAPS=
173 +DISCOVERY=true
174 +IDENTITY_PATH=/portal-certs/identity.json
175 +WIREGUARD_ENDPOINT=
176 +SNI_PORT=443
177 +ADMIN_SECRET_KEY=your-admin-secret
178 +KEYLESS_DIR=/portal-certs
179 +ACME_DNS_PROVIDER=cloudflare
180 +CLOUDFLARE_TOKEN=cf_xxxxxxxxxxxxxxxxx
181 +ENS_GASLESS_ENABLED=true
182 +```
183 +
184 +In this mode, Portal keeps the manual certificate files but still manages DNSSEC and `ENS1 ...` TXT records through Cloudflare.
185 +
186 +Managed Cloudflare example:
187
188 ```bash
189 PORTAL_URL=https://example.com
@@ -165,7 +226,7 @@ Notes:
226 - Set `WIREGUARD_ENDPOINT` explicitly only when relay-peer discovery UDP is exposed on a different address than `PORTAL_URL`
227 - `IDENTITY_PATH` stores the relay identity JSON inside the container
228 - `KEYLESS_DIR` stores relay certificate material inside the container
168 -- The Docker Compose stack stores both the relay identity JSON and ACME state under `./.portal-certs` on the host
229 +- The Docker Compose stack stores relay identity JSON and certificate state under `./.portal-certs` on the host
230
231 If the relay sits behind a reverse proxy or ingress and you want admin/auth and lease IP tracking to use the original client IP, set:
232
@@ -175,7 +236,7 @@ TRUST_PROXY_HEADERS=true
236
237 If your proxy source addresses are public or you want a stricter allowlist, also set `TRUSTED_PROXY_CIDRS`.
238
178 -### 3.2 Start Relay
239 +### 4.2 Start Relay
240
241 When using the published Docker image, create the bind-mount directory first and make it writable by UID `65532` (`nonroot` in the distroless image):
242
@@ -185,17 +246,19 @@ sudo chown 65532:65532 ./.portal-certs
246 chmod 755 ./.portal-certs
247 ```
248
249 +If you use manual certificate mode, make sure `fullchain.pem` and `privatekey.pem` already exist in `./.portal-certs` before startup.
250 +
251 Then start the stack:
252
253 ```bash
254 docker compose up -d
255 ```
256
194 -## 4. Optional UDP Setup
257 +## 5. Optional UDP Setup
258
259 UDP transport is disabled by default.
260
198 -### 4.1 Open UDP ports on your VM or host
261 +### 5.1 Open UDP ports on your VM or host
262
263 Open these UDP ports in your cloud security group or firewall:
264
@@ -209,7 +272,7 @@ sudo ufw allow 4017/udp
272 sudo ufw allow 50000:50009/udp
273 ```
274
212 -### 4.2 Expose UDP ports in Docker
275 +### 5.2 Expose UDP ports in Docker
276
277 If you use `network_mode: host`, the container uses host UDP ports directly.
278
@@ -221,7 +284,7 @@ ports:
284 - "50000-50009:50000-50009/udp"
285 ```
286
224 -### 4.3 Configure `UDP_PORT_COUNT`
287 +### 5.3 Configure `UDP_PORT_COUNT`
288
289 Set `UDP_PORT_COUNT` in `.env` to the number of UDP leases you want to support.
290
@@ -237,7 +300,7 @@ That allocates lease UDP ports `50000-50009`.
300 |---|---|---|
301 | `UDP_PORT_COUNT` | `0` | Number of UDP ports to allocate, starting at port 50000 |
302
240 -### 4.4 Enable UDP in the admin panel
303 +### 5.4 Enable UDP in the admin panel
304
305 After the relay starts, open `/admin`, enable UDP transport, and optionally set a max concurrent UDP lease limit.
306
docs/examples/nginx-proxy-multi-service/docker-compose.yaml
+12 -2
@@ -14,7 +14,8 @@
14 # Prerequisites:
15 # 1. Copy .env.example to .env and set all required values.
16 # 2. Place TLS certificates in ./certs/:
17 -# - Portal: managed by portal itself (ACME via KEYLESS_DIR)
17 +# - Portal root-domain certs: either managed by portal (set ACME_DNS_PROVIDER)
18 +# or mounted manually into ./.portal-certs as fullchain.pem/privatekey.pem
19 # - Other services: app_a_fullchain.pem, app_a_privkey.pem, etc.
20 # 3. Create the .portal-certs directory with correct ownership (UID 65532 = nonroot in distroless):
21 # mkdir -p ./.portal-certs
@@ -79,8 +80,17 @@ services:
80 TRUST_PROXY_HEADERS: ${TRUST_PROXY_HEADERS:-true}
81 TRUSTED_PROXY_CIDRS: ${TRUSTED_PROXY_CIDRS:-127.0.0.0/8}
82 KEYLESS_DIR: ${KEYLESS_DIR:-/portal-certs}
83 + ACME_DNS_PROVIDER: ${ACME_DNS_PROVIDER:-}
84 + ENS_GASLESS_ENABLED: ${ENS_GASLESS_ENABLED:-false}
85 CLOUDFLARE_TOKEN: ${CLOUDFLARE_TOKEN:-}
83 - ACME_DNS_PROVIDER: ${ACME_DNS_PROVIDER:-cloudflare}
86 + AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID:-}
87 + AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY:-}
88 + AWS_SESSION_TOKEN: ${AWS_SESSION_TOKEN:-}
89 + AWS_REGION: ${AWS_REGION:-}
90 + AWS_DEFAULT_REGION: ${AWS_DEFAULT_REGION:-}
91 + AWS_HOSTED_ZONE_ID: ${AWS_HOSTED_ZONE_ID:-}
92 + AWS_DNSSEC_KMS_KEY_ARN: ${AWS_DNSSEC_KMS_KEY_ARN:-}
93 + DNSSEC_KSK_NAME: ${DNSSEC_KSK_NAME:-}
94 volumes:
95 - ./.portal-certs:${KEYLESS_DIR:-/portal-certs}
96 restart: unless-stopped
docs/examples/nginx-proxy/docker-compose.yaml
+12 -2
@@ -13,9 +13,10 @@
13 #
14 # Prerequisites:
15 # 1. Copy .env.example to .env and set all required values.
16 -# 2. Place your TLS certificate files in ./certs/:
16 +# 2. Place your root-domain TLS certificate files in ./certs/:
17 # ./certs/fullchain.pem
18 # ./certs/privkey.pem
19 +# Portal can instead use ACME or manual fullchain.pem/privatekey.pem in ./.portal-certs.
20 # 3. Create the .portal-certs directory with correct ownership (UID 65532 = nonroot in distroless):
21 # mkdir -p ./.portal-certs
22 # sudo chown 65532:65532 ./.portal-certs
@@ -77,8 +78,17 @@ services:
78 TRUSTED_PROXY_CIDRS: ${TRUSTED_PROXY_CIDRS:-127.0.0.0/8}
79
80 KEYLESS_DIR: ${KEYLESS_DIR:-/portal-certs}
81 + ACME_DNS_PROVIDER: ${ACME_DNS_PROVIDER:-}
82 + ENS_GASLESS_ENABLED: ${ENS_GASLESS_ENABLED:-false}
83 CLOUDFLARE_TOKEN: ${CLOUDFLARE_TOKEN:-}
81 - ACME_DNS_PROVIDER: ${ACME_DNS_PROVIDER:-cloudflare}
84 + AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID:-}
85 + AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY:-}
86 + AWS_SESSION_TOKEN: ${AWS_SESSION_TOKEN:-}
87 + AWS_REGION: ${AWS_REGION:-}
88 + AWS_DEFAULT_REGION: ${AWS_DEFAULT_REGION:-}
89 + AWS_HOSTED_ZONE_ID: ${AWS_HOSTED_ZONE_ID:-}
90 + AWS_DNSSEC_KMS_KEY_ARN: ${AWS_DNSSEC_KMS_KEY_ARN:-}
91 + DNSSEC_KSK_NAME: ${DNSSEC_KSK_NAME:-}
92 volumes:
93 - ./.portal-certs:${KEYLESS_DIR:-/portal-certs}
94 restart: unless-stopped
portal/acme/acme.go
+175 -104
@@ -60,7 +60,6 @@ type Manager struct {
60 stopCh chan struct{}
61 cfg Config
62 wg sync.WaitGroup
63 - mu sync.RWMutex
63 dns DNSProvider
64 startOnce sync.Once
65 stopOnce sync.Once
@@ -68,16 +67,6 @@ type Manager struct {
67 ensLogOnce sync.Once
68 }
69
71 -type provisionConfig struct {
72 - KeyFile string
73 - CertFile string
74 - AccountKeyFile string
75 - RegistrationFile string
76 - Email string
77 - DNSProvider DNSProvider
78 - Domains []string
79 -}
80 -
70 type acmeUser struct {
71 Key crypto.PrivateKey
72 Registration *registration.Resource
@@ -121,26 +110,22 @@ func NewManager(cfg Config) (*Manager, error) {
110 }, nil
111 }
112
124 - dns, err := NewDNSProvider(DNSProviderConfig{
125 - Type: cfg.DNSProvider,
126 - CloudflareToken: cfg.CloudflareToken,
127 - AWSAccessKeyID: cfg.AWSAccessKeyID,
128 - AWSSecretAccessKey: cfg.AWSSecretAccessKey,
129 - AWSSessionToken: cfg.AWSSessionToken,
130 - AWSRegion: cfg.AWSRegion,
131 - AWSHostedZoneID: cfg.AWSHostedZoneID,
132 - AWSKMSKeyARN: cfg.AWSKMSKeyARN,
133 - DNSSECKSKName: cfg.DNSSECKSKName,
134 - })
113 + manager := &Manager{
114 + cfg: cfg,
115 + stopCh: make(chan struct{}),
116 + }
117 +
118 + acmeDNS, err := NewDNSProvider(cfg.DNSProvider, cfg)
119 if err != nil {
120 return nil, fmt.Errorf("create acme dns provider: %w", err)
121 }
122 + manager.dns = acmeDNS
123
139 - return &Manager{
140 - cfg: cfg,
141 - stopCh: make(chan struct{}),
142 - dns: dns,
143 - }, nil
124 + if cfg.ENSGaslessEnabled && manager.dns == nil {
125 + return nil, errors.New("ens gasless automation requires ACME_DNS_PROVIDER")
126 + }
127 +
128 + return manager, nil
129 }
130
131 func (m *Manager) EnsureCertificate(ctx context.Context) (string, string, error) {
@@ -154,12 +139,25 @@ func (m *Manager) EnsureCertificate(ctx context.Context) (string, string, error)
139 }
140 return m.TLSFiles()
141 }
142 + certFile, keyFile, manual, err := m.manualCertificateOverride()
143 + if err != nil {
144 + return "", "", err
145 + }
146 + if manual {
147 + if err := m.syncENSGasless(ctx); err != nil {
148 + return "", "", err
149 + }
150 + return certFile, keyFile, nil
151 + }
152 + if !m.managedACME() {
153 + return m.ensureManualCertificate()
154 + }
155
156 if err := m.syncDNS(ctx); err != nil {
157 return "", "", fmt.Errorf("ensure dns records: %w", err)
158 }
159
162 - certFile, keyFile, err := m.TLSFiles()
160 + certFile, keyFile, err = m.TLSFiles()
161 if err == nil {
162 covered, err := certCoversDomains(certFile, certificateDomains(m.cfg.BaseDomain))
163 if err == nil && covered {
@@ -191,7 +189,7 @@ func (m *Manager) EnsureTLSMaterial(ctx context.Context) ([]byte, []byte, error)
189 }
190
191 func (m *Manager) Start(ctx context.Context) {
194 - if m == nil || utils.IsLocalRelayHost(m.cfg.BaseDomain) {
192 + if m == nil || utils.IsLocalRelayHost(m.cfg.BaseDomain) || (!m.cfg.ENSGaslessEnabled && !m.managedACME()) {
193 return
194 }
195
@@ -223,18 +221,76 @@ func (m *Manager) TLSFiles() (string, string, error) {
221 return certFile, keyFile, nil
222 }
223
226 -func (m *Manager) provision(ctx context.Context) error {
227 - cfg := provisionConfig{
228 - KeyFile: filepath.Join(m.cfg.KeyDir, keyFileName),
229 - CertFile: filepath.Join(m.cfg.KeyDir, fullChainFileName),
230 - AccountKeyFile: filepath.Join(m.cfg.KeyDir, accountKeyFileName),
231 - RegistrationFile: filepath.Join(m.cfg.KeyDir, registrationFileName),
232 - Email: defaultACMEEmailPrefix + m.cfg.BaseDomain,
233 - DNSProvider: m.dns,
234 - Domains: certificateDomains(m.cfg.BaseDomain),
224 +func (m *Manager) managedACME() bool {
225 + return m != nil && m.dns != nil
226 +}
227 +
228 +func (m *Manager) ensureManualCertificate() (string, string, error) {
229 + certFile, keyFile, err := m.TLSFiles()
230 + if err != nil {
231 + return "", "", fmt.Errorf("manual certificate mode requires %s and %s in %s or configure ACME_DNS_PROVIDER", fullChainFileName, keyFileName, m.cfg.KeyDir)
232 + }
233 +
234 + covered, err := m.manualCertificateCovered(certFile)
235 + if err != nil {
236 + return "", "", err
237 }
238 + if !covered {
239 + return "", "", fmt.Errorf("manual relay certificate must cover %s and *.%s", m.cfg.BaseDomain, m.cfg.BaseDomain)
240 + }
241 + return certFile, keyFile, nil
242 +}
243
237 - for _, path := range []string{cfg.KeyFile, cfg.CertFile, cfg.AccountKeyFile, cfg.RegistrationFile} {
244 +func (m *Manager) manualCertificateOverride() (string, string, bool, error) {
245 + if m == nil || utils.IsLocalRelayHost(m.cfg.BaseDomain) {
246 + return "", "", false, nil
247 + }
248 + certFile := filepath.Join(m.cfg.KeyDir, fullChainFileName)
249 + keyFile := filepath.Join(m.cfg.KeyDir, keyFileName)
250 + if !fileExists(certFile) || !fileExists(keyFile) {
251 + return "", "", false, nil
252 + }
253 + var err error
254 + covered, err := m.manualCertificateCovered(certFile)
255 + if err != nil {
256 + return "", "", false, err
257 + }
258 + hasACMEState := m.hasACMEState()
259 + if !covered {
260 + if !hasACMEState {
261 + return "", "", false, fmt.Errorf("manual relay certificate must cover %s and *.%s", m.cfg.BaseDomain, m.cfg.BaseDomain)
262 + }
263 + return "", "", false, nil
264 + }
265 + if hasACMEState {
266 + return "", "", false, nil
267 + }
268 + return certFile, keyFile, true, nil
269 +}
270 +
271 +func (m *Manager) manualCertificateCovered(certFile string) (bool, error) {
272 + covered, err := certCoversDomains(certFile, certificateDomains(m.cfg.BaseDomain))
273 + if err != nil {
274 + return false, fmt.Errorf("validate relay certificate: %w", err)
275 + }
276 + return covered, nil
277 +}
278 +
279 +func (m *Manager) hasACMEState() bool {
280 + if m == nil {
281 + return false
282 + }
283 + return fileExists(filepath.Join(m.cfg.KeyDir, accountKeyFileName)) || fileExists(filepath.Join(m.cfg.KeyDir, registrationFileName))
284 +}
285 +
286 +func (m *Manager) provision(ctx context.Context) error {
287 + keyFile := filepath.Join(m.cfg.KeyDir, keyFileName)
288 + certFile := filepath.Join(m.cfg.KeyDir, fullChainFileName)
289 + accountKeyFile := filepath.Join(m.cfg.KeyDir, accountKeyFileName)
290 + registrationFile := filepath.Join(m.cfg.KeyDir, registrationFileName)
291 + domains := certificateDomains(m.cfg.BaseDomain)
292 +
293 + for _, path := range []string{keyFile, certFile, accountKeyFile, registrationFile} {
294 if err := ensureParentDir(path); err != nil {
295 return err
296 }
@@ -243,13 +299,13 @@ func (m *Manager) provision(ctx context.Context) error {
299 return fmt.Errorf("acme provisioning canceled: %w", err)
300 }
301
246 - client, _, err := newClient(ctx, cfg)
302 + client, err := newClient(ctx, defaultACMEEmailPrefix+m.cfg.BaseDomain, accountKeyFile, registrationFile, m.dns)
303 if err != nil {
304 return err
305 }
306
307 obtained, err := client.Certificate.Obtain(certificate.ObtainRequest{
252 - Domains: cfg.Domains,
308 + Domains: domains,
309 Bundle: true,
310 })
311 if err != nil {
@@ -259,10 +315,10 @@ func (m *Manager) provision(ctx context.Context) error {
315 return errors.New("acme obtain response missing certificate or private key")
316 }
317
262 - if err := writeFileAtomic(cfg.CertFile, obtained.Certificate, 0o644); err != nil {
318 + if err := writeFileAtomic(certFile, obtained.Certificate, 0o644); err != nil {
319 return fmt.Errorf("write certificate chain: %w", err)
320 }
265 - if err := writeFileAtomic(cfg.KeyFile, obtained.PrivateKey, 0o600); err != nil {
321 + if err := writeFileAtomic(keyFile, obtained.PrivateKey, 0o600); err != nil {
322 return fmt.Errorf("write private key: %w", err)
323 }
324 return nil
@@ -290,11 +346,12 @@ func (m *Manager) maintenanceLoop(ctx context.Context) {
346 log.Warn().Err(err).Str("base_domain", m.cfg.BaseDomain).Msg("sync dns records")
347 }
348 case <-renewTicker.C:
293 - if !m.shouldRenew() {
349 + _, _, manual, err := m.manualCertificateOverride()
350 + if err != nil || manual || !m.managedACME() || !m.shouldRenew() {
351 continue
352 }
353 renewCtx, cancel := context.WithTimeout(ctx, defaultSyncTimeout)
297 - err := m.provision(renewCtx)
354 + err = m.provision(renewCtx)
355 cancel()
356 if err != nil {
357 log.Warn().Err(err).Str("base_domain", m.cfg.BaseDomain).Msg("renew acme certificate")
@@ -307,37 +364,15 @@ func (m *Manager) syncDNS(ctx context.Context) error {
364 if m == nil || utils.IsLocalRelayHost(m.cfg.BaseDomain) {
365 return nil
366 }
310 - if m.dns == nil {
311 - return errors.New("acme dns provider is required")
367 + if err := m.syncENSGasless(ctx); err != nil {
368 + return err
369 }
313 - if m.cfg.ENSGaslessEnabled {
314 - status, err := m.dns.EnsureDNSSEC(ctx, m.cfg.BaseDomain)
315 - if err != nil {
316 - return fmt.Errorf("ensure dnssec: %w", err)
317 - }
318 - m.dnssecLogOnce.Do(func() {
319 - event := log.Info().
320 - Str("provider", m.dns.Name()).
321 - Str("base_domain", m.cfg.BaseDomain).
322 - Str("state", strings.TrimSpace(status.State))
323 - if strings.TrimSpace(status.DSRecord) != "" {
324 - event = event.Str("ds_record", strings.TrimSpace(status.DSRecord))
325 - }
326 - if strings.TrimSpace(status.Message) != "" {
327 - event = event.Str("message", strings.TrimSpace(status.Message))
328 - }
329 - event.Msg("dnssec configured")
330 - })
331 - if err := m.SyncENSGaslessHostname(ctx, m.cfg.BaseDomain, m.cfg.ENSGaslessAddress); err != nil {
332 - return fmt.Errorf("ensure ens gasless txt: %w", err)
333 - }
334 - m.ensLogOnce.Do(func() {
335 - log.Info().
336 - Str("provider", m.dns.Name()).
337 - Str("base_domain", m.cfg.BaseDomain).
338 - Str("address", m.cfg.ENSGaslessAddress).
339 - Msg("ens gasless dns import configured")
340 - })
370 + _, _, manual, err := m.manualCertificateOverride()
371 + if err != nil {
372 + return err
373 + }
374 + if manual || !m.managedACME() {
375 + return nil
376 }
377
378 publicIP, err := utils.ResolvePublicIPv4(ctx)
@@ -348,12 +383,51 @@ func (m *Manager) syncDNS(ctx context.Context) error {
383 return m.dns.EnsureARecords(ctx, m.cfg.BaseDomain, publicIP)
384 }
385
386 +func (m *Manager) syncENSGasless(ctx context.Context) error {
387 + if m == nil || !m.cfg.ENSGaslessEnabled || utils.IsLocalRelayHost(m.cfg.BaseDomain) {
388 + return nil
389 + }
390 + if m.dns == nil {
391 + return errors.New("ACME_DNS_PROVIDER is required")
392 + }
393 +
394 + status, err := m.dns.EnsureDNSSEC(ctx, m.cfg.BaseDomain)
395 + if err != nil {
396 + return fmt.Errorf("ensure dnssec: %w", err)
397 + }
398 + m.dnssecLogOnce.Do(func() {
399 + event := log.Info().
400 + Str("provider", m.dns.Name()).
401 + Str("base_domain", m.cfg.BaseDomain).
402 + Str("state", strings.TrimSpace(status.State))
403 + if strings.TrimSpace(status.DSRecord) != "" {
404 + event = event.Str("ds_record", strings.TrimSpace(status.DSRecord))
405 + }
406 + if strings.TrimSpace(status.Message) != "" {
407 + event = event.Str("message", strings.TrimSpace(status.Message))
408 + }
409 + event.Msg("dnssec configured")
410 + })
411 +
412 + if err := m.SyncENSGaslessHostname(ctx, m.cfg.BaseDomain, m.cfg.ENSGaslessAddress); err != nil {
413 + return fmt.Errorf("ensure ens gasless txt: %w", err)
414 + }
415 + m.ensLogOnce.Do(func() {
416 + log.Info().
417 + Str("provider", m.dns.Name()).
418 + Str("base_domain", m.cfg.BaseDomain).
419 + Str("address", m.cfg.ENSGaslessAddress).
420 + Msg("ens gasless dns import configured")
421 + })
422 + return nil
423 +}
424 +
425 func (m *Manager) SyncENSGaslessHostname(ctx context.Context, hostname, address string) error {
426 if m == nil || !m.cfg.ENSGaslessEnabled || utils.IsLocalRelayHost(m.cfg.BaseDomain) {
427 return nil
428 }
429 if m.dns == nil {
356 - return errors.New("acme dns provider is required")
430 + return errors.New("ACME_DNS_PROVIDER is required")
431 }
432
433 hostname = utils.NormalizeHostname(hostname)
@@ -376,7 +450,7 @@ func (m *Manager) DeleteENSGaslessHostname(ctx context.Context, hostname string)
450 return nil
451 }
452 if m.dns == nil {
379 - return errors.New("acme dns provider is required")
453 + return errors.New("ACME_DNS_PROVIDER is required")
454 }
455
456 hostname = utils.NormalizeHostname(hostname)
@@ -399,9 +473,6 @@ func hostnameMatchesBaseDomain(hostname, baseDomain string) bool {
473 }
474
475 func (m *Manager) shouldRenew() bool {
402 - m.mu.RLock()
403 - defer m.mu.RUnlock()
404 -
476 certFile := filepath.Join(m.cfg.KeyDir, fullChainFileName)
477 needsRenewal, err := certNeedsRenewal(certFile, certificateDomains(m.cfg.BaseDomain))
478 return err == nil && needsRenewal
@@ -423,11 +494,7 @@ func certNeedsRenewal(certFile string, domains []string) (bool, error) {
494 if time.Until(cert.NotAfter) < 30*24*time.Hour {
495 return true, nil
496 }
426 - covered, err := certCoversDomains(certFile, domains)
427 - if err != nil {
428 - return false, err
429 - }
430 - return !covered, nil
497 + return !certificateCoversDomains(cert, domains), nil
498 }
499
500 func certCoversDomains(certFile string, domains []string) (bool, error) {
@@ -439,36 +506,40 @@ func certCoversDomains(certFile string, domains []string) (bool, error) {
506 if err != nil {
507 return false, err
508 }
509 + return certificateCoversDomains(cert, domains), nil
510 +}
511 +
512 +func certificateCoversDomains(cert *x509.Certificate, domains []string) bool {
513 for _, domain := range domains {
514 if wildcardDomain, ok := strings.CutPrefix(domain, "*."); ok {
515 if !certificateCoversHostname(cert, "probe."+wildcardDomain) {
445 - return false, nil
516 + return false
517 }
518 continue
519 }
520 if !certificateCoversHostname(cert, domain) {
450 - return false, nil
521 + return false
522 }
523 }
453 - return true, nil
524 + return true
525 }
526
527 func certificateCoversHostname(cert *x509.Certificate, hostname string) bool {
528 return cert != nil && cert.VerifyHostname(hostname) == nil
529 }
530
460 -func newClient(ctx context.Context, cfg provisionConfig) (*lego.Client, *acmeUser, error) {
461 - accountKey, err := loadOrCreateAccountKey(cfg.AccountKeyFile)
531 +func newClient(ctx context.Context, email, accountKeyFile, registrationFile string, dnsProvider DNSProvider) (*lego.Client, error) {
532 + accountKey, err := loadOrCreateAccountKey(accountKeyFile)
533 if err != nil {
463 - return nil, nil, fmt.Errorf("load acme account key: %w", err)
534 + return nil, fmt.Errorf("load acme account key: %w", err)
535 }
465 - accountReg, err := loadRegistration(cfg.RegistrationFile)
536 + accountReg, err := loadRegistration(registrationFile)
537 if err != nil {
467 - return nil, nil, fmt.Errorf("load acme registration: %w", err)
538 + return nil, fmt.Errorf("load acme registration: %w", err)
539 }
540
541 user := &acmeUser{
471 - Email: cfg.Email,
542 + Email: email,
543 Key: accountKey,
544 Registration: accountReg,
545 }
@@ -479,32 +550,32 @@ func newClient(ctx context.Context, cfg provisionConfig) (*lego.Client, *acmeUse
550
551 client, err := lego.NewClient(clientConfig)
552 if err != nil {
482 - return nil, nil, fmt.Errorf("create acme client: %w", err)
553 + return nil, fmt.Errorf("create acme client: %w", err)
554 }
555
485 - if cfg.DNSProvider == nil {
486 - return nil, nil, errors.New("acme dns provider is required")
556 + if dnsProvider == nil {
557 + return nil, errors.New("ACME_DNS_PROVIDER is required")
558 }
488 - challengeProvider, err := cfg.DNSProvider.ChallengeProvider(ctx)
559 + challengeProvider, err := dnsProvider.ChallengeProvider(ctx)
560 if err != nil {
490 - return nil, nil, fmt.Errorf("create dns challenge provider: %w", err)
561 + return nil, fmt.Errorf("create dns challenge provider: %w", err)
562 }
563 if err := client.Challenge.SetDNS01Provider(challengeProvider); err != nil {
493 - return nil, nil, fmt.Errorf("set dns01 provider: %w", err)
564 + return nil, fmt.Errorf("set dns01 provider: %w", err)
565 }
566
567 if user.Registration == nil {
568 reg, err := client.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: true})
569 if err != nil {
499 - return nil, nil, fmt.Errorf("register acme account: %w", err)
570 + return nil, fmt.Errorf("register acme account: %w", err)
571 }
572 user.Registration = reg
502 - if err := saveRegistration(cfg.RegistrationFile, reg); err != nil {
503 - return nil, nil, fmt.Errorf("persist acme registration: %w", err)
573 + if err := saveRegistration(registrationFile, reg); err != nil {
574 + return nil, fmt.Errorf("persist acme registration: %w", err)
575 }
576 }
577
507 - return client, user, nil
578 + return client, nil
579 }
580
581 func (u *acmeUser) GetEmail() string { return u.Email }
portal/acme/acme_test.go
+169
@@ -2,7 +2,18 @@ package acme
2
3 import (
4 "context"
5 + "crypto/ecdsa"
6 + "crypto/elliptic"
7 + "crypto/rand"
8 + "crypto/x509"
9 + "crypto/x509/pkix"
10 + "encoding/pem"
11 + "math/big"
12 + "os"
13 + "path/filepath"
14 + "strings"
15 "testing"
16 + "time"
17 )
18
19 func TestEnsureCertificateGeneratesLocalDevelopmentMaterial(t *testing.T) {
@@ -37,3 +48,161 @@ func TestEnsureCertificateGeneratesLocalDevelopmentMaterial(t *testing.T) {
48 t.Fatal("certCoversDomains() = false, want true")
49 }
50 }
51 +
52 +func TestEnsureTLSMaterialUsesManualCertificateWithoutDNSProvider(t *testing.T) {
53 + t.Parallel()
54 +
55 + keyDir := t.TempDir()
56 + if err := writeManualRelayCertificate(t, keyDir, "portal.example.com"); err != nil {
57 + t.Fatalf("writeManualRelayCertificate() error = %v", err)
58 + }
59 +
60 + manager, err := NewManager(Config{
61 + BaseDomain: "portal.example.com",
62 + KeyDir: keyDir,
63 + })
64 + if err != nil {
65 + t.Fatalf("NewManager() error = %v", err)
66 + }
67 +
68 + certPEM, keyPEM, err := manager.EnsureTLSMaterial(context.Background())
69 + if err != nil {
70 + t.Fatalf("EnsureTLSMaterial() error = %v", err)
71 + }
72 + if len(certPEM) == 0 || len(keyPEM) == 0 {
73 + t.Fatalf("EnsureTLSMaterial() returned empty PEM material")
74 + }
75 +}
76 +
77 +func TestEnsureTLSMaterialRequiresManualCertificateWhenProviderUnset(t *testing.T) {
78 + t.Parallel()
79 +
80 + manager, err := NewManager(Config{
81 + BaseDomain: "portal.example.com",
82 + KeyDir: t.TempDir(),
83 + })
84 + if err != nil {
85 + t.Fatalf("NewManager() error = %v", err)
86 + }
87 +
88 + _, _, err = manager.EnsureTLSMaterial(context.Background())
89 + if err == nil {
90 + t.Fatal("EnsureTLSMaterial() error = nil, want missing manual certificate error")
91 + }
92 + if got := err.Error(); got == "" || !containsAll(got, "manual certificate mode requires", "fullchain.pem", "privatekey.pem") {
93 + t.Fatalf("EnsureTLSMaterial() error = %q, want manual certificate guidance", got)
94 + }
95 +}
96 +
97 +func TestNewManagerRejectsENSGaslessWithoutDNSProvider(t *testing.T) {
98 + t.Parallel()
99 +
100 + _, err := NewManager(Config{
101 + BaseDomain: "portal.example.com",
102 + KeyDir: t.TempDir(),
103 + ENSGaslessEnabled: true,
104 + ENSGaslessAddress: "0x1234567890123456789012345678901234567890",
105 + })
106 + if err == nil {
107 + t.Fatal("NewManager() error = nil, want ENS gasless provider error")
108 + }
109 + if got := err.Error(); got != "ens gasless automation requires ACME_DNS_PROVIDER" {
110 + t.Fatalf("NewManager() error = %q, want ENS gasless provider guidance", got)
111 + }
112 +}
113 +
114 +func TestNewManagerAllowsENSGaslessWithACMEDNSProvider(t *testing.T) {
115 + t.Parallel()
116 +
117 + manager, err := NewManager(Config{
118 + BaseDomain: "portal.example.com",
119 + KeyDir: t.TempDir(),
120 + DNSProvider: TypeRoute53,
121 + ENSGaslessEnabled: true,
122 + ENSGaslessAddress: "0x1234567890123456789012345678901234567890",
123 + })
124 + if err != nil {
125 + t.Fatalf("NewManager() error = %v", err)
126 + }
127 + if manager == nil {
128 + t.Fatal("NewManager() = nil, want manager")
129 + }
130 + if manager.dns == nil {
131 + t.Fatal("manager.dns = nil, want dns provider")
132 + }
133 +}
134 +
135 +func TestEnsureTLSMaterialUsesManualCertificateWithDNSProvider(t *testing.T) {
136 + t.Parallel()
137 +
138 + keyDir := t.TempDir()
139 + if err := writeManualRelayCertificate(t, keyDir, "portal.example.com"); err != nil {
140 + t.Fatalf("writeManualRelayCertificate() error = %v", err)
141 + }
142 +
143 + manager, err := NewManager(Config{
144 + BaseDomain: "portal.example.com",
145 + KeyDir: keyDir,
146 + DNSProvider: TypeRoute53,
147 + })
148 + if err != nil {
149 + t.Fatalf("NewManager() error = %v", err)
150 + }
151 +
152 + certPEM, keyPEM, err := manager.EnsureTLSMaterial(context.Background())
153 + if err != nil {
154 + t.Fatalf("EnsureTLSMaterial() error = %v", err)
155 + }
156 + if len(certPEM) == 0 || len(keyPEM) == 0 {
157 + t.Fatalf("EnsureTLSMaterial() returned empty PEM material")
158 + }
159 +}
160 +
161 +func writeManualRelayCertificate(t *testing.T, keyDir, baseDomain string) error {
162 + t.Helper()
163 +
164 + privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
165 + if err != nil {
166 + return err
167 + }
168 +
169 + now := time.Now().UTC()
170 + template := &x509.Certificate{
171 + SerialNumber: big.NewInt(now.UnixNano()),
172 + Subject: pkix.Name{
173 + CommonName: baseDomain,
174 + },
175 + NotBefore: now.Add(-time.Hour),
176 + NotAfter: now.Add(90 * 24 * time.Hour),
177 + DNSNames: []string{baseDomain, "*." + baseDomain},
178 + BasicConstraintsValid: true,
179 + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
180 + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
181 + }
182 +
183 + der, err := x509.CreateCertificate(rand.Reader, template, template, privateKey.Public(), privateKey)
184 + if err != nil {
185 + return err
186 + }
187 + keyDER, err := x509.MarshalECPrivateKey(privateKey)
188 + if err != nil {
189 + return err
190 + }
191 +
192 + certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
193 + keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
194 +
195 + if err := os.WriteFile(filepath.Join(keyDir, fullChainFileName), certPEM, 0o644); err != nil {
196 + return err
197 + }
198 + return os.WriteFile(filepath.Join(keyDir, keyFileName), keyPEM, 0o600)
199 +}
200 +
201 +func containsAll(text string, parts ...string) bool {
202 + for _, part := range parts {
203 + if !strings.Contains(text, part) {
204 + return false
205 + }
206 + }
207 + return true
208 +}
portal/acme/provider.go
+5 -15
@@ -17,18 +17,6 @@ const (
17 TypeRoute53 = "route53"
18 )
19
20 -type DNSProviderConfig struct {
21 - Type string
22 - CloudflareToken string
23 - AWSAccessKeyID string
24 - AWSSecretAccessKey string
25 - AWSSessionToken string
26 - AWSRegion string
27 - AWSHostedZoneID string
28 - AWSKMSKeyARN string
29 - DNSSECKSKName string
30 -}
31 -
20 type DNSProvider interface {
21 Name() string
22 ChallengeProvider(ctx context.Context) (challenge.Provider, error)
@@ -38,8 +26,10 @@ type DNSProvider interface {
26 EnsureDNSSEC(ctx context.Context, baseDomain string) (types.DNSSECStatus, error)
27 }
28
41 -func NewDNSProvider(cfg DNSProviderConfig) (DNSProvider, error) {
42 - switch strings.ToLower(strings.TrimSpace(cfg.Type)) {
29 +func NewDNSProvider(providerType string, cfg Config) (DNSProvider, error) {
30 + switch strings.ToLower(strings.TrimSpace(providerType)) {
31 + case "":
32 + return nil, nil
33 case TypeCloudflare:
34 return cloudflare.New(cfg.CloudflareToken), nil
35 case TypeRoute53:
@@ -53,6 +43,6 @@ func NewDNSProvider(cfg DNSProviderConfig) (DNSProvider, error) {
43 DNSSECKSKName: cfg.DNSSECKSKName,
44 }), nil
45 default:
56 - return nil, fmt.Errorf("unsupported acme dns provider: %q", cfg.Type)
46 + return nil, fmt.Errorf("unsupported acme dns provider: %q", providerType)
47 }
48 }
portal/api_server.go
+2 -2
@@ -359,7 +359,7 @@ func (s *Server) handleUnregister(w http.ResponseWriter, r *http.Request) {
359 }
360 deleteCtx, cancel := context.WithTimeout(context.Background(), defaultClaimTimeout)
361 defer cancel()
362 - if err := s.deleteLeaseENSGasless(deleteCtx, record); err != nil {
362 + if err := s.acmeManager.DeleteENSGaslessHostname(deleteCtx, record.Hostname); err != nil {
363 log.Warn().
364 Err(err).
365 Str("hostname", record.Hostname).
@@ -602,7 +602,7 @@ func (s *Server) registerLease(req types.RegisterChallengeRequest, clientIP, rep
602 }
603 syncCtx, cancel := context.WithTimeout(context.Background(), defaultClaimTimeout)
604 defer cancel()
605 - if err := s.syncLeaseENSGasless(syncCtx, record); err != nil {
605 + if err := s.acmeManager.SyncENSGaslessHostname(syncCtx, record.Hostname, record.Address); err != nil {
606 _, _ = s.registry.Unregister(record.Copy())
607 record.Close()
608 return types.RegisterResponse{}, err
portal/lease.go
+6 -36
@@ -52,24 +52,6 @@ func (r *leaseRegistry) CloseAll() []*leaseRecord {
52 return out
53 }
54
55 -func (r *leaseRegistry) RunJanitor(ctx context.Context, interval time.Duration, onExpired func(*leaseRecord)) error {
56 - if interval <= 0 {
57 - return errors.New("janitor interval must be positive")
58 - }
59 -
60 - ticker := time.NewTicker(interval)
61 - defer ticker.Stop()
62 -
63 - for {
64 - select {
65 - case <-ctx.Done():
66 - return nil
67 - case <-ticker.C:
68 - r.cleanupExpired(time.Now(), onExpired)
69 - }
70 - }
71 -}
72 -
55 func (r *leaseRegistry) Lookup(host string) (*leaseRecord, bool) {
56 host = utils.NormalizeHostname(host)
57 if host == "" {
@@ -246,24 +228,7 @@ func (r *leaseRegistry) Touch(identity types.Identity, clientIP string, now time
228 return record
229 }
230
249 -func (r *leaseRegistry) cleanupExpired(now time.Time, onExpired func(*leaseRecord)) {
250 - expiredLeases := r.removeExpired(now)
251 - r.mu.Lock()
252 - for challengeID, challenge := range r.registerChallenges {
253 - if challenge == nil || challenge.Expired(now) {
254 - delete(r.registerChallenges, challengeID)
255 - }
256 - }
257 - r.mu.Unlock()
258 - for _, lease := range expiredLeases {
259 - if onExpired != nil {
260 - onExpired(lease)
261 - }
262 - lease.Close()
263 - }
264 -}
265 -
266 -func (r *leaseRegistry) removeExpired(now time.Time) []*leaseRecord {
231 +func (r *leaseRegistry) cleanupExpired(now time.Time) []*leaseRecord {
232 r.mu.Lock()
233 defer r.mu.Unlock()
234
@@ -276,6 +241,11 @@ func (r *leaseRegistry) removeExpired(now time.Time) []*leaseRecord {
241 r.policy.ForgetIdentity(key)
242 }
243 }
244 + for challengeID, challenge := range r.registerChallenges {
245 + if challenge == nil || challenge.Expired(now) {
246 + delete(r.registerChallenges, challengeID)
247 + }
248 + }
249 return expired
250 }
251
portal/lease_test.go
+7 -7
@@ -165,24 +165,24 @@ func TestLeaseRegistryCleanupExpiredClosesBroker(t *testing.T) {
165 t.Fatalf("Register() error = %v", err)
166 }
167
168 - for _, lease := range registry.removeExpired(time.Now()) {
168 + for _, lease := range registry.cleanupExpired(time.Now()) {
169 lease.Close()
170 }
171
172 if _, ok := registry.Lookup("expired.example.com"); ok {
173 - t.Fatal("Lookup() after removeExpired() = true, want false")
173 + t.Fatal("Lookup() after cleanupExpired() = true, want false")
174 }
175 if _, err := record.stream.Claim(context.Background()); !errors.Is(err, net.ErrClosed) {
176 - t.Fatalf("Claim() after removeExpired() error = %v, want %v", err, net.ErrClosed)
176 + t.Fatalf("Claim() after cleanupExpired() error = %v, want %v", err, net.ErrClosed)
177 }
178 }
179
180 -func TestLeaseRegistryRunJanitorRejectsNonPositiveInterval(t *testing.T) {
180 +func TestServerRunLeaseJanitorRejectsNonPositiveInterval(t *testing.T) {
181 t.Parallel()
182
183 - registry := newLeaseRegistry(policy.NewRuntime())
184 - err := registry.RunJanitor(context.Background(), 0, nil)
183 + server := &Server{registry: newLeaseRegistry(policy.NewRuntime())}
184 + err := server.runLeaseJanitor(context.Background(), 0)
185 if err == nil {
186 - t.Fatal("RunJanitor() error = nil, want validation error")
186 + t.Fatal("runLeaseJanitor() error = nil, want validation error")
187 }
188 }
portal/server.go
+32 -32
@@ -227,7 +227,7 @@ func (s *Server) Start(ctx context.Context, apiMux *http.ServeMux) error {
227 group.Go(s.overlay.Serve)
228 }
229 group.Go(func() error { return s.runSNIListener(groupCtx) })
230 - group.Go(func() error { return s.registry.RunJanitor(groupCtx, 5*time.Second, s.cleanupExpiredLeaseDNS) })
230 + group.Go(func() error { return s.runLeaseJanitor(groupCtx, 5*time.Second) })
231 if s.cfg.DiscoveryEnabled {
232 group.Go(func() error { return s.runRelayDiscoveryLoop(groupCtx) })
233 }
@@ -252,8 +252,7 @@ func (s *Server) Start(ctx context.Context, apiMux *http.ServeMux) error {
252 Str("acme_dns_provider", s.cfg.ACME.DNSProvider).
253 Bool("discovery_enabled", s.cfg.DiscoveryEnabled).
254 Bool("wireguard_enabled", s.wgConfig.PrivateKey != "").
255 - Bool("udp_enabled", s.cfg.UDPPortCount > 0).
256 - Bool("acme_enabled", !strings.HasSuffix(s.identity.Name, "localhost") && s.identity.Name != "127.0.0.1" && s.identity.Name != "::1")
255 + Bool("udp_enabled", s.cfg.UDPPortCount > 0)
256 if s.quicTunnel != nil {
257 logEvent = logEvent.Str("internal_quic_tunnel_addr", s.quicTunnel.Addr().String())
258 }
@@ -429,35 +428,6 @@ func (s *Server) prepareAPITLS(ctx context.Context) (keyless.TLSMaterialConfig,
428 return apiTLS, manager, nil
429 }
430
432 -func (s *Server) syncLeaseENSGasless(ctx context.Context, lease *leaseRecord) error {
433 - if s == nil || s.acmeManager == nil || lease == nil {
434 - return nil
435 - }
436 - return s.acmeManager.SyncENSGaslessHostname(ctx, lease.Hostname, lease.Address)
437 -}
438 -
439 -func (s *Server) deleteLeaseENSGasless(ctx context.Context, lease *leaseRecord) error {
440 - if s == nil || s.acmeManager == nil || lease == nil {
441 - return nil
442 - }
443 - return s.acmeManager.DeleteENSGaslessHostname(ctx, lease.Hostname)
444 -}
445 -
446 -func (s *Server) cleanupExpiredLeaseDNS(lease *leaseRecord) {
447 - if s == nil || lease == nil {
448 - return
449 - }
450 - ctx, cancel := context.WithTimeout(context.Background(), defaultClaimTimeout)
451 - defer cancel()
452 - if err := s.deleteLeaseENSGasless(ctx, lease); err != nil {
453 - log.Warn().
454 - Err(err).
455 - Str("hostname", lease.Hostname).
456 - Str("address", lease.Address).
457 - Msg("delete expired lease ens gasless txt")
458 - }
459 -}
460 -
431 func (s *Server) runSNIListener(ctx context.Context) error {
432 for {
433 conn, err := s.sniListener.Accept()
@@ -520,6 +490,36 @@ func (s *Server) runSNIListener(ctx context.Context) error {
490 }
491 }
492
493 +func (s *Server) runLeaseJanitor(ctx context.Context, interval time.Duration) error {
494 + if interval <= 0 {
495 + return errors.New("janitor interval must be positive")
496 + }
497 +
498 + ticker := time.NewTicker(interval)
499 + defer ticker.Stop()
500 +
501 + for {
502 + select {
503 + case <-ctx.Done():
504 + return nil
505 + case <-ticker.C:
506 + for _, lease := range s.registry.cleanupExpired(time.Now()) {
507 + deleteCtx, cancel := context.WithTimeout(context.Background(), defaultClaimTimeout)
508 + err := s.acmeManager.DeleteENSGaslessHostname(deleteCtx, lease.Hostname)
509 + cancel()
510 + if err != nil {
511 + log.Warn().
512 + Err(err).
513 + Str("hostname", lease.Hostname).
514 + Str("address", lease.Address).
515 + Msg("delete expired lease ens gasless txt")
516 + }
517 + lease.Close()
518 + }
519 + }
520 + }
521 +}
522 +
523 func (s *Server) startQUICTunnelListener(apiTLS keyless.TLSMaterialConfig) error {
524 if len(apiTLS.KeyPEM) == 0 {
525 return fmt.Errorf("quic tunnel requires api tls key")
portal/server_test.go
+98
@@ -2,11 +2,19 @@ package portal
2
3 import (
4 "context"
5 + "crypto/ecdsa"
6 + "crypto/elliptic"
7 + "crypto/rand"
8 "crypto/tls"
9 + "crypto/x509"
10 + "crypto/x509/pkix"
11 "encoding/json"
12 + "encoding/pem"
13 "io"
14 + "math/big"
15 "net"
16 "net/http"
17 + "os"
18 "path/filepath"
19 "reflect"
20 "sort"
@@ -62,6 +70,48 @@ func tempIdentityPath(t *testing.T) string {
70 return filepath.Join(t.TempDir(), "relay_identity.json")
71 }
72
73 +func writeManualRelayCertificate(t *testing.T, keyDir, baseDomain string) {
74 + t.Helper()
75 +
76 + privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
77 + if err != nil {
78 + t.Fatalf("GenerateKey() error = %v", err)
79 + }
80 +
81 + now := time.Now().UTC()
82 + template := &x509.Certificate{
83 + SerialNumber: big.NewInt(now.UnixNano()),
84 + Subject: pkix.Name{
85 + CommonName: baseDomain,
86 + },
87 + NotBefore: now.Add(-time.Hour),
88 + NotAfter: now.Add(90 * 24 * time.Hour),
89 + DNSNames: []string{baseDomain, "*." + baseDomain},
90 + BasicConstraintsValid: true,
91 + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
92 + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
93 + }
94 +
95 + der, err := x509.CreateCertificate(rand.Reader, template, template, privateKey.Public(), privateKey)
96 + if err != nil {
97 + t.Fatalf("CreateCertificate() error = %v", err)
98 + }
99 + keyDER, err := x509.MarshalECPrivateKey(privateKey)
100 + if err != nil {
101 + t.Fatalf("MarshalECPrivateKey() error = %v", err)
102 + }
103 +
104 + certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
105 + keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
106 +
107 + if err := os.WriteFile(filepath.Join(keyDir, "fullchain.pem"), certPEM, 0o644); err != nil {
108 + t.Fatalf("WriteFile(cert) error = %v", err)
109 + }
110 + if err := os.WriteFile(filepath.Join(keyDir, "privatekey.pem"), keyPEM, 0o600); err != nil {
111 + t.Fatalf("WriteFile(key) error = %v", err)
112 + }
113 +}
114 +
115 func TestNewServerGeneratesWireGuardWhenDiscoveryEnabled(t *testing.T) {
116 t.Parallel()
117
@@ -151,6 +201,54 @@ func TestServerStartInitializesLocalACMEAndSigner(t *testing.T) {
201 }
202 }
203
204 +func TestServerStartUsesManualCertificateWithoutACMEProvider(t *testing.T) {
205 + t.Parallel()
206 +
207 + keyDir := t.TempDir()
208 + writeManualRelayCertificate(t, keyDir, "portal.example.com")
209 +
210 + server, err := NewServer(ServerConfig{
211 + PortalURL: "https://portal.example.com",
212 + IdentityPath: tempIdentityPath(t),
213 + ACME: acme.Config{KeyDir: keyDir},
214 + APIListenAddr: "127.0.0.1:0",
215 + SNIListenAddr: "127.0.0.1:0",
216 + })
217 + if err != nil {
218 + t.Fatalf("NewServer() error = %v", err)
219 + }
220 +
221 + ctx, cancel := context.WithCancel(context.Background())
222 + defer cancel()
223 +
224 + if err := server.Start(ctx, nil); err != nil {
225 + t.Fatalf("Start() error = %v", err)
226 + }
227 +
228 + client := &http.Client{
229 + Transport: &http.Transport{
230 + TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
231 + },
232 + }
233 + t.Cleanup(func() {
234 + client.CloseIdleConnections()
235 + cancel()
236 + if err := server.Wait(); err != nil {
237 + t.Fatalf("Wait() error = %v", err)
238 + }
239 + })
240 +
241 + healthResp, err := client.Get("https://" + utils.HostPortOrLoopback(server.apiListener.Addr().String()) + types.PathHealthz)
242 + if err != nil {
243 + t.Fatalf("GET /healthz error = %v", err)
244 + }
245 + defer healthResp.Body.Close()
246 +
247 + if healthResp.StatusCode != http.StatusOK {
248 + t.Fatalf("GET /healthz status = %d, want %d", healthResp.StatusCode, http.StatusOK)
249 + }
250 +}
251 +
252 func TestServerStartDiscoveryIncludesIdentityAndOmitsSignerFields(t *testing.T) {
253 t.Parallel()
254