remove main/wildcard cert split (HTTP/1.1 only)
rabbitprincess committed
Mar 2, 2026 at 16:51 UTC
d05abb75a0acdd9a37698240395f62ef4d879198
5 files changed
+28
-80
AGENTS.md
+1
-1
@@ -106,7 +106,7 @@ Portal uses environment variables for domain and TLS configuration:
106
- Keyless signer endpoint defaults to relay URL unless explicitly overridden in SDK options.
107
- Certificate chain/root trust are auto-discovered by SDK from signer endpoint when not explicitly provided.
108
- Auto-discovery requires an HTTPS signer endpoint.
109
- - Relay signer key comes from `KEYLESS_DIR/wildcard-privatekey.pem`; when missing and `CLOUDFLARE_TOKEN` is set, relay auto-issues via ACME DNS-01.
109
+ - Relay signer key comes from `KEYLESS_DIR/privatekey.pem`; when missing and `CLOUDFLARE_TOKEN` is set, relay auto-issues via ACME DNS-01.
110
111
See `docs/portal-deploy-guide.md` for full deployment documentation.
112
README.md
+4
-2
@@ -35,8 +35,10 @@ ADMIN_SECRET_KEY=your-secret-key docker compose up
35
# Keyless auto-issuance (optional):
36
# if KEYLESS_DIR is missing and CLOUDFLARE_TOKEN is set,
37
# relay issues keyless certs via ACME DNS-01.
38
-# when KEYLESS_DIR/fullchain.pem and KEYLESS_DIR/privatekey.pem both exist,
39
-# admin/API listener on --adminport auto-switches to HTTPS.
38
+# relay uses one unified cert/key pair:
39
+# KEYLESS_DIR/fullchain.pem
40
+# KEYLESS_DIR/privatekey.pem
41
+# when both files exist, admin/API listener on --adminport auto-switches to HTTPS (HTTP/1.1 only).
42
CLOUDFLARE_TOKEN=your-cloudflare-dns-token docker compose up
43
```
44
docs/architecture.md
+1
-22
@@ -46,31 +46,10 @@ Portal connects local applications to web users through a secure relay layer wit
46
47
## Security
48
49
-- **E2EE**: TLS passthrough with keyless wildcard certificates - relay routes TLS by SNI without termination
49
+- **E2EE**: TLS passthrough with keyless certificate (`*.example.com` + `example.com` SAN) - relay routes TLS by SNI without termination
50
- **Tokens**: Per-lease reverse connection tokens
51
- **SNI Routing**: TLS passthrough without decryption
52
53
-## Certificate Strategy
54
-
55
-Portal issues **two separate certificates** for each domain:
56
-
57
-1. **Wildcard certificate**: `*.example.com` - for subdomain routes (e.g., `foo.example.com`)
58
-2. **Main certificate**: `example.com` - for the apex/root domain
59
-
60
-### Why Two Certificates?
61
-
62
-This separation is necessary due to **HTTP/2 connection coalescing**:
63
-
64
-1. Browser connects to `foo.example.com` (SNI: `foo.example.com`)
65
-2. If the certificate also includes `example.com` as a SAN, the browser may reuse this connection for `example.com` requests
66
-3. However, the TLS handshake has already negotiated SNI as `foo.example.com`
67
-4. The SNI router will only match the `foo.example.com` route, not `example.com`
68
-
69
-By keeping certificates separate:
70
-- Browser creates a new connection for `example.com`
71
-- SNI is properly negotiated as `example.com`
72
-- The `onNoRoute` handler can serve the apex domain correctly (e.g., admin UI or redirect)
73
-
53
### SNI Routing Logic
54
55
The SNI router (`portal/sni/router.go`) matches routes in this order:
docs/portal-deploy-guide.md
+5
-3
@@ -9,7 +9,9 @@ Portal uses SNI-based TLS passthrough: the relay routes TLS by SNI to tunnel bac
9
TLS certificate mode:
10
- `self`: tunnel uses locally managed certificate and key files.
11
- `keyless`: tunnel delegates TLS signing to relay keyless signer (`/v1/sign`). Relay uses `KEYLESS_DIR` and can auto-issue key/cert via ACME DNS-01 when `CLOUDFLARE_TOKEN` is set.
12
- When `KEYLESS_DIR/fullchain.pem` and `KEYLESS_DIR/privatekey.pem` exist, relay admin/API on `--adminport` is served over HTTPS automatically.
12
+ Relay uses one unified cert/key pair at `KEYLESS_DIR/fullchain.pem` and `KEYLESS_DIR/privatekey.pem`.
13
+ The certificate covers both `*.example.com` and `example.com` (SAN), and relay admin/API on `--adminport` is served over HTTPS automatically.
14
+ Admin/API HTTPS is served as HTTP/1.1 only.
15
16
```
17
Client ──TLS──► Relay (SNI Router :443) ──TLS──► Tunnel Backend (TLS mode)
@@ -81,7 +83,7 @@ https://myapp.example.com
83
| `BOOTSTRAP_URIS` | (derived) | Relay API URLs |
84
| `ADMIN_SECRET_KEY` | (auto-generated) | Admin authentication key |
85
| `SNI_PORT` | `443` | SNI router port |
84
-| `KEYLESS_DIR` | `/etc/portal/keyless` | Relay keyless materials directory (`wildcard-privatekey.pem` for signer, `fullchain.pem` + `privatekey.pem` for admin/API HTTPS) |
86
+| `KEYLESS_DIR` | `/etc/portal/keyless` | Relay keyless materials directory (`fullchain.pem` + `privatekey.pem` used for signer and admin/API HTTPS, HTTP/1.1 only) |
87
| `CLOUDFLARE_TOKEN` | (empty) | Cloudflare DNS API token used for ACME DNS-01 auto issuance |
88
89
## docker-compose.yml
@@ -154,7 +156,7 @@ portal-tunnel \
156
- Auto-discovery expects an HTTPS signer endpoint.
157
- External signer API must return TLS signature responses for the requested digest.
158
- Relay keyless signer key path is configured by `KEYLESS_DIR`.
157
-- When key file is missing and `CLOUDFLARE_TOKEN` is set, relay auto-issues key/cert using ACME DNS-01.
159
+- When key/cert files are missing and `CLOUDFLARE_TOKEN` is set, relay auto-issues a unified certificate (`*.example.com` + `example.com`) via ACME DNS-01.
160
161
Signer API request/response example:
162
portal/acme/acme.go
+17
-52
@@ -25,13 +25,11 @@ import (
25
)
26
27
const (
28
- fullChainFileName = "fullchain.pem"
29
- keyFileName = "privatekey.pem"
30
- wildcardFullChainFileName = "wildcard-fullchain.pem"
31
- wildcardKeyFileName = "wildcard-privatekey.pem"
32
- accountKeyFileName = "acme-account.key"
33
- registrationFileName = "acme-registration.json"
34
- defaultACMEEmailPrefix = "acme@"
28
+ fullChainFileName = "fullchain.pem"
29
+ keyFileName = "privatekey.pem"
30
+ accountKeyFileName = "acme-account.key"
31
+ registrationFileName = "acme-registration.json"
32
+ defaultACMEEmailPrefix = "acme@"
33
)
34
35
type certTarget struct {
@@ -85,7 +83,7 @@ func (m *AcmeManager) keyDir() string {
83
return m.cfg.KeyDir
84
}
85
88
-// SigningKeyFile returns the fixed wildcard key path under configured key directory.
86
+// SigningKeyFile returns the unified signer key path under configured key directory.
87
func (m *AcmeManager) SigningKeyFile() string {
88
if m == nil {
89
return ""
@@ -94,7 +92,7 @@ func (m *AcmeManager) SigningKeyFile() string {
92
if keyDir == "" {
93
return ""
94
}
97
- return wildcardKeyPath(keyDir)
95
+ return keyPath(keyDir)
96
}
97
98
type acmeUser struct {
@@ -146,11 +144,7 @@ func (m *AcmeManager) EnsureSigningKey(ctx context.Context) (string, error) {
144
if err != nil {
145
return "", err
146
}
149
- wildcardTarget, ok := certTargetByName(targets, "wildcard")
150
- if !ok {
151
- return "", errors.New("missing wildcard ACME target")
152
- }
153
- signerKeyFile := wildcardTarget.KeyFile
147
+ signerKeyFile := keyPath(configuredKeyDir)
148
149
missingTargets := make([]certTarget, 0, len(targets))
150
for _, target := range targets {
@@ -197,7 +191,7 @@ func (m *AcmeManager) EnsureSigningKey(ctx context.Context) (string, error) {
191
return signerKeyFile, nil
192
}
193
200
-// TLSFiles returns fullchain and private key file paths when both exist.
194
+// TLSFiles returns the unified fullchain and private key file paths when both exist.
195
func (m *AcmeManager) TLSFiles() (string, string) {
196
if m == nil {
197
return "", ""
@@ -207,16 +201,10 @@ func (m *AcmeManager) TLSFiles() (string, string) {
201
return "", ""
202
}
203
210
- wildcardKeyFile := wildcardKeyPath(keyDir)
211
- wildcardCertFile := fullChainPath(keyDir)
212
- if fileExists(wildcardCertFile) && fileExists(wildcardKeyFile) {
213
- return wildcardCertFile, wildcardKeyFile
214
- }
215
-
216
- mainKeyFile := mainKeyPath(keyDir)
217
- mainCertFile := mainFullChainPath(keyDir)
218
- if fileExists(mainCertFile) && fileExists(mainKeyFile) {
219
- return mainCertFile, mainKeyFile
204
+ keyFile := keyPath(keyDir)
205
+ certFile := fullChainPath(keyDir)
206
+ if fileExists(certFile) && fileExists(keyFile) {
207
+ return certFile, keyFile
208
}
209
210
return "", ""
@@ -266,16 +254,10 @@ func buildCertTargets(baseDomain, configuredKeyDir string) ([]certTarget, error)
254
255
return []certTarget{
256
{
269
- Name: "wildcard",
270
- KeyFile: wildcardKeyPath(keyDir),
257
+ Name: "unified",
258
+ KeyFile: keyPath(keyDir),
259
CertFile: fullChainPath(keyDir),
272
- Domains: []string{"*." + base},
273
- },
274
- {
275
- Name: "main",
276
- KeyFile: mainKeyPath(keyDir),
277
- CertFile: mainFullChainPath(keyDir),
278
- Domains: []string{base},
260
+ Domains: []string{"*." + base, base},
261
},
262
}, nil
263
}
@@ -500,26 +482,9 @@ func hasCloudflareToken(cloudflareToken string) bool {
482
}
483
484
func fullChainPath(keyDir string) string {
503
- return filepath.Join(keyDir, wildcardFullChainFileName)
504
-}
505
-
506
-func wildcardKeyPath(keyDir string) string {
507
- return filepath.Join(keyDir, wildcardKeyFileName)
508
-}
509
-
510
-func mainFullChainPath(keyDir string) string {
485
return filepath.Join(keyDir, fullChainFileName)
486
}
487
514
-func mainKeyPath(keyDir string) string {
488
+func keyPath(keyDir string) string {
489
return filepath.Join(keyDir, keyFileName)
490
}
517
-
518
-func certTargetByName(targets []certTarget, name string) (certTarget, bool) {
519
- for _, target := range targets {
520
- if target.Name == name {
521
- return target, true
522
- }
523
- }
524
- return certTarget{}, false
525
-}