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