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 -}