separate main and wildcard domain cert

rabbitprincess committed Mar 2, 2026 at 14:13 UTC f6d40e6120d7bb6ff913d7d5a1f7e3f8ca2a34dc
20 files changed +765 -161
AGENTS.md
+7 -2
@@ -79,7 +79,7 @@ Portal uses environment variables for domain and TLS configuration:
79 | `BOOTSTRAP_URIS` | Relay API URLs (defaults to `PORTAL_URL`) |
80 | `SNI_PORT` | SNI router port (default `443`) |
81 | `ADMIN_SECRET_KEY` | Admin auth key (auto-generated if unset) |
82 -| `KEYLESS_KEY_FILE` | Relay keyless signer private key path (default `/etc/portal/keyless/privkey.pem`) |
82 +| `KEYLESS_DIR` | Relay keyless materials directory (default `/etc/portal/keyless`) |
83 | `CLOUDFLARE_TOKEN` | Cloudflare DNS token for ACME DNS-01 auto-issuance when key file is missing |
84
85 ### Tunnel Environment Variables
@@ -90,6 +90,7 @@ Portal uses environment variables for domain and TLS configuration:
90 | `TLS_MODE` | `no-tls`, `self`, or `keyless` |
91 | `TLS_CERT_FILE` | Self TLS certificate chain path (self mode only) |
92 | `TLS_KEY_FILE` | Self TLS private key path (self mode only) |
93 +| `TLS_CERT_CACHE_FILE` | Keyless cert cache file path (keyless mode only) |
94
95 ### Domain Derivation
96
@@ -105,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.
108 - - Relay signer key comes from `KEYLESS_KEY_FILE`; when missing and `CLOUDFLARE_TOKEN` is set, relay auto-issues via ACME DNS-01.
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.
110
111 See `docs/portal-deploy-guide.md` for full deployment documentation.
112
@@ -120,6 +121,8 @@ Formatting & style:
121 - Run formatting before commits (see Quick Commands).
122 - Import order: stdlib -> external -> internal (blank-line separated).
123 - Naming: packages lowercase single-word; interfaces as behavior verbs; errors use `Err` prefix for sentinels and `Error` suffix for types.
124 +- Do not add meaningless string normalization or utility wrapper functions unless they provide clear, demonstrated value.
125 +- Do not keep backward compatibility when changing code unless explicitly requested by the user.
126 - Context first parameter for public I/O: `func Do(ctx context.Context, ...)`.
127 - CGo disabled: `CGO_ENABLED=0`.
128
@@ -144,6 +147,7 @@ Context & concurrency:
147 - Avoid `time.After` in loops; use `context.WithTimeout` or `time.Ticker`.
148
149 Testing:
150 +- Do not run tests on every execution. Run tests only when explicitly requested, before handoff, or when a change is high-risk.
151 - Use race detector in normal test runs.
152 - Use `t.Context()` in tests where applicable.
153 - Benchmarks should use `for b.Loop() {}`.
@@ -165,3 +169,4 @@ CI/CD:
169
170 Verbalized sampling:
171 - For non-trivial changes: sample multiple intents, explore edge cases, assess coupling, tidy first, and surface tradeoffs.
172 +
Dockerfile
+1 -1
@@ -42,7 +42,7 @@ ENV PORTAL_URL=http://localhost:4017
42 ENV BOOTSTRAP_URIS=http://localhost:4017
43 ENV ADMIN_SECRET_KEY=
44 ENV SNI_PORT=:443
45 -ENV KEYLESS_KEY_FILE=/etc/portal/keyless/privkey.pem
45 +ENV KEYLESS_DIR=/etc/portal/keyless
46 ENV CLOUDFLARE_TOKEN=
47 ENV TZ=UTC
48
README.md
+4 -4
@@ -33,9 +33,9 @@ docker compose up
33 ADMIN_SECRET_KEY=your-secret-key docker compose up
34
35 # Keyless auto-issuance (optional):
36 -# if KEYLESS_KEY_FILE is missing and CLOUDFLARE_TOKEN is set,
36 +# if KEYLESS_DIR is missing and CLOUDFLARE_TOKEN is set,
37 # relay issues keyless certs via ACME DNS-01.
38 -# when KEYLESS_KEY_FILE and sibling fullchain.pem both exist,
38 +# when KEYLESS_DIR/fullchain.pem and KEYLESS_DIR/privatekey.pem both exist,
39 # admin/API listener on --adminport auto-switches to HTTPS.
40 CLOUDFLARE_TOKEN=your-cloudflare-dns-token docker compose up
41 ```
@@ -51,10 +51,10 @@ For production deployment (DNS, TLS, reverse proxy), see [docs/portal-deploy-gui
51
52 ```bash
53 # Windows PowerShell
54 -$env:HOST="localhost:3000"; $env:NAME="myapp"; irm http://localhost:4017/tunnel | iex
54 +$env:APP_HOST="localhost:3000"; $env:APP_NAME="myapp"; irm http://localhost:4017/tunnel | iex
55
56 # macOS/Linux
57 -curl -fsSL http://localhost:4017/tunnel | HOST=localhost:3000 NAME=myapp sh
57 +curl -fsSL http://localhost:4017/tunnel | APP_HOST=localhost:3000 APP_NAME=myapp sh
58 ```
59
60 ### Use Go SDK
cmd/demo-app/main.go
+1 -1
@@ -49,7 +49,7 @@ func main() {
49 flag.BoolVar(&flagHide, "hide", false, "hide this lease from listings")
50 defaultTLSMode := strings.TrimSpace(os.Getenv("TLS_MODE"))
51 if defaultTLSMode == "" {
52 - defaultTLSMode = string(sdk.TLSModeNoTLS)
52 + defaultTLSMode = string(sdk.TLSModeKeyless)
53 }
54 flag.StringVar(&flagTLSMode, "tls-mode", defaultTLSMode, "TLS mode: no-tls, self, or keyless [env: TLS_MODE]")
55 flag.StringVar(&flagTLSCert, "tls-cert-file", os.Getenv("TLS_CERT_FILE"), "certificate chain PEM file for --tls-mode self [env: TLS_CERT_FILE]")
cmd/portal-tunnel/README.md
+6 -3
@@ -53,6 +53,7 @@ Options:
53 --tls-mode TLS mode: no-tls, self, or keyless [default: no-tls] [env: TLS_MODE]
54 --tls-cert-file PEM certificate chain for --tls-mode self [env: TLS_CERT_FILE]
55 --tls-key-file PEM private key for --tls-mode self [env: TLS_KEY_FILE]
56 + --tls-cert-cache-file Certificate cache file path for --tls-mode keyless [env: TLS_CERT_CACHE_FILE]
57 --description Service description metadata [env: APP_DESCRIPTION]
58 --tags Service tags metadata (comma-separated) [env: APP_TAGS]
59 --thumbnail Service thumbnail URL metadata [env: APP_THUMBNAIL]
@@ -67,10 +68,10 @@ Options:
68
69 ```bash
70 # macOS/Linux
70 -curl -fsSL https://portal.example.com/tunnel | HOST=localhost:3000 NAME=myapp sh
71 +curl -fsSL https://portal.example.com/tunnel | APP_HOST=localhost:3000 APP_NAME=myapp sh
72
73 # Windows PowerShell
73 -$env:HOST="localhost:3000"; $env:NAME="myapp"; irm https://portal.example.com/tunnel | iex
74 +$env:APP_HOST="localhost:3000"; $env:APP_NAME="myapp"; irm https://portal.example.com/tunnel | iex
75 ```
76
77 ### Production (TLS)
@@ -80,6 +81,7 @@ export RELAYS=https://portal.example.com
81 export APP_HOST=localhost:3000
82 export APP_NAME=myapp
83 export TLS_MODE=keyless
84 +export TLS_CERT_CACHE_FILE=/var/lib/portal/cache/cert-cache.json
85
86 ./bin/portal-tunnel
87 ```
@@ -104,6 +106,7 @@ export RELAYS=https://portal.example.com
106 export APP_HOST=localhost:3000
107 export APP_NAME=myapp
108 export TLS_MODE=keyless
109 +export TLS_CERT_CACHE_FILE=/var/lib/portal/cache/cert-cache.json
110
111 ./bin/portal-tunnel
112 ```
@@ -136,4 +139,4 @@ Expected signer API contract (`/v1/sign`):
139 --name myapp \
140 --relay https://portal1.example.com,https://portal2.example.com \
141 --tls-mode keyless
139 -```
142 +```
\ No newline at end of file
cmd/portal-tunnel/main.go
+22 -12
@@ -16,21 +16,23 @@ import (
16 "github.com/rs/zerolog"
17 "github.com/rs/zerolog/log"
18
19 + "gosuda.org/portal/portal/acme"
20 "gosuda.org/portal/sdk"
21 )
22
23 var (
23 - flagRelayURLs string
24 - flagHost string
25 - flagName string
26 - flagTLSMode string
27 - flagTLSCertFile string
28 - flagTLSKeyFile string
29 - flagDescription string
30 - flagTags string
31 - flagThumbnail string
32 - flagOwner string
33 - flagHide bool
24 + flagRelayURLs string
25 + flagHost string
26 + flagName string
27 + flagTLSMode string
28 + flagTLSCertFile string
29 + flagTLSKeyFile string
30 + flagTLSCertCacheFile string
31 + flagDescription string
32 + flagTags string
33 + flagThumbnail string
34 + flagOwner string
35 + flagHide bool
36 )
37
38 func main() {
@@ -52,6 +54,7 @@ func main() {
54 flag.StringVar(&flagTLSMode, "tls-mode", defaultTLSMode, "TLS mode: no-tls, self, or keyless [env: TLS_MODE]")
55 flag.StringVar(&flagTLSCertFile, "tls-cert-file", os.Getenv("TLS_CERT_FILE"), "PEM certificate chain for --tls-mode self [env: TLS_CERT_FILE]")
56 flag.StringVar(&flagTLSKeyFile, "tls-key-file", os.Getenv("TLS_KEY_FILE"), "PEM private key for --tls-mode self [env: TLS_KEY_FILE]")
57 + flag.StringVar(&flagTLSCertCacheFile, "tls-cert-cache-file", os.Getenv("TLS_CERT_CACHE_FILE"), "certificate cache file path for --tls-mode keyless [env: TLS_CERT_CACHE_FILE]")
58
59 flag.StringVar(&flagDescription, "description", os.Getenv("APP_DESCRIPTION"), "Service description metadata [env: APP_DESCRIPTION]")
60 flag.StringVar(&flagTags, "tags", os.Getenv("APP_TAGS"), "Service tags metadata (comma-separated) [env: APP_TAGS]")
@@ -130,14 +133,21 @@ func runServiceTunnel(ctx context.Context, relayURLs []string) error {
133 Str("key_file", keyFile).
134 Msg("TLS: Using self-managed local certificate")
135 } else if flagTLSMode == string(sdk.TLSModeKeyless) {
136 + certCacheFile := strings.TrimSpace(flagTLSCertCacheFile)
137 + if certCacheFile == "" {
138 + certCacheFile = acme.DefaultCertCachePath()
139 + }
140 certFile := strings.TrimSpace(flagTLSCertFile)
141 if certFile != "" {
142 log.Warn().
143 Str("cert_file", certFile).
144 Msg("Ignoring --tls-cert-file in keyless mode (SDK auto configuration only)")
145 }
146 + clientOpts = append(clientOpts, sdk.WithCertCacheFile(certCacheFile))
147 clientOpts = append(clientOpts, sdk.WithTLSKeylessDefaults())
140 - log.Info().Msg("TLS: Using keyless remote signer (SDK auto configuration)")
148 + log.Info().
149 + Str("cert_cache_file", certCacheFile).
150 + Msg("TLS: Using keyless remote signer (SDK auto configuration)")
151 } else {
152 return fmt.Errorf("unsupported TLS mode: %s", flagTLSMode)
153 }
cmd/relay-server/frontend/src/components/TunnelCommandModal.tsx
+4 -4
@@ -87,7 +87,7 @@ export function TunnelCommandModal({ trigger }: TunnelCommandModalProps) {
87 if (tlsMode === "self" && tlsKeyFile.trim() !== "") {
88 tlsEnv += `$env:TLS_KEY_FILE="${tlsKeyFile}"; `;
89 }
90 - return `$ProgressPreference = 'SilentlyContinue'; ${tlsEnv}$env:HOST="${hostVal}"; $env:NAME="${nameVal}"; $env:RELAY_URL="${relayUrlVal}"; irm ${currentOrigin}/tunnel?os=windows | iex`;
90 + return `$ProgressPreference = 'SilentlyContinue'; ${tlsEnv}$env:APP_HOST="${hostVal}"; $env:APP_NAME="${nameVal}"; $env:RELAYS="${relayUrlVal}"; irm ${currentOrigin}/tunnel?os=windows | iex`;
91 }
92
93 let tlsEnv = `TLS_MODE=${tlsMode} `;
@@ -97,7 +97,7 @@ export function TunnelCommandModal({ trigger }: TunnelCommandModalProps) {
97 if (tlsMode === "self" && tlsKeyFile.trim() !== "") {
98 tlsEnv += `TLS_KEY_FILE="${tlsKeyFile}" `;
99 }
100 - return `curl -fsSL ${currentOrigin}/tunnel | ${tlsEnv}HOST=${hostVal} NAME=${nameVal} RELAY_URL="${relayUrlVal}" sh`;
100 + return `curl -fsSL ${currentOrigin}/tunnel | ${tlsEnv}APP_HOST=${hostVal} APP_NAME=${nameVal} RELAYS="${relayUrlVal}" sh`;
101 }, [
102 currentOrigin,
103 host,
@@ -172,7 +172,7 @@ export function TunnelCommandModal({ trigger }: TunnelCommandModalProps) {
172 Host
173 </label>
174 <div className="flex items-center rounded-md bg-border">
175 - <span className="px-3 text-sm text-text-muted">HOST=</span>
175 + <span className="px-3 text-sm text-text-muted">APP_HOST=</span>
176 <Input
177 id="host"
178 type="text"
@@ -196,7 +196,7 @@ export function TunnelCommandModal({ trigger }: TunnelCommandModalProps) {
196 Service Name
197 </label>
198 <div className="flex items-center rounded-md bg-border">
199 - <span className="px-3 text-sm text-text-muted">NAME=</span>
199 + <span className="px-3 text-sm text-text-muted">APP_NAME=</span>
200 <Input
201 id="name"
202 type="text"
cmd/relay-server/main.go
+10 -10
@@ -20,10 +20,10 @@ import (
20 )
21
22 const (
23 - defaultAPIPort = 4017
24 - defaultSNIPort = 443
25 - defaultPortalURL = "http://localhost:4017"
26 - defaultKeylessKeyFile = "/etc/portal/keyless/privkey.pem"
23 + defaultAPIPort = 4017
24 + defaultSNIPort = 443
25 + defaultPortalURL = "http://localhost:4017"
26 + defaultKeylessDir = "/etc/portal/keyless"
27 )
28
29 // flagPortalURL is kept for package-level consumers in other files.
@@ -37,7 +37,7 @@ type relayServerConfig struct {
37 Bootstraps []string
38 SNIPort int
39
40 - KeylessKeyFile string
40 + KeylessDir string
41 CloudflareToken string
42 }
43
@@ -55,9 +55,9 @@ func main() {
55 bootstrapsCSV = defaultBootstrapFrom(portalURL)
56 }
57 sniPort := parsePortNumber(os.Getenv("SNI_PORT"), defaultSNIPort, "SNI_PORT")
58 - keylessFile := strings.TrimSpace(os.Getenv("KEYLESS_KEY_FILE"))
59 - if keylessFile == "" {
60 - keylessFile = defaultKeylessKeyFile
58 + keylessDir := strings.TrimSpace(os.Getenv("KEYLESS_DIR"))
59 + if keylessDir == "" {
60 + keylessDir = defaultKeylessDir
61 }
62 adminSecretKey := strings.TrimSpace(os.Getenv("ADMIN_SECRET_KEY"))
63 cloudflareToken := strings.TrimSpace(os.Getenv("CLOUDFLARE_TOKEN"))
@@ -68,7 +68,7 @@ func main() {
68 flag.StringVar(&cfg.PortalURL, "portal-url", portalURL, "portal base URL (env: PORTAL_URL)")
69 flag.StringVar(&bootstrapsCSV, "bootstraps", bootstrapsCSV, "bootstrap URIs, comma-separated (env: BOOTSTRAP_URIS)")
70 flag.IntVar(&cfg.SNIPort, "sni-port", sniPort, "SNI router port number (env: SNI_PORT)")
71 - flag.StringVar(&cfg.KeylessKeyFile, "keyless-key-file", keylessFile, "PEM private key path for relay keyless signer (env: KEYLESS_KEY_FILE)")
71 + flag.StringVar(&cfg.KeylessDir, "keyless-dir", keylessDir, "directory path for relay keyless materials (env: KEYLESS_DIR)")
72 flag.StringVar(&cfg.CloudflareToken, "cloudflare-token", cloudflareToken, "Cloudflare DNS API token (Zone:Read + DNS:Edit) (env: CLOUDFLARE_TOKEN)")
73 flag.Parse()
74
@@ -92,7 +92,7 @@ func runServer(cfg relayServerConfig) error {
92 baseHost := extractBaseDomain(cfg.PortalURL)
93 rootSNI := portalRootHost(cfg.PortalURL)
94 apiUpstreamAddr := loopbackForwardAddr(fmt.Sprintf(":%d", cfg.AdminPort))
95 - serv, err := portal.NewRelayServer(ctx, cfg.Bootstraps, sniListenAddr, baseHost, cfg.KeylessKeyFile, cfg.CloudflareToken)
95 + serv, err := portal.NewRelayServer(ctx, cfg.Bootstraps, sniListenAddr, baseHost, cfg.KeylessDir, cfg.CloudflareToken)
96 if err != nil {
97 return fmt.Errorf("create relay server: %w", err)
98 }
cmd/relay-server/serve.go
+4 -2
@@ -131,8 +131,10 @@ func serveAPI(addr string, serv *portal.RelayServer, admin *Admin, frontend *Fro
131 Handler: handler,
132 TLSNextProto: make(map[string]func(*http.Server, *tls.Conn, http.Handler)),
133 }
134 - acmeManager := serv.GetACMEManager()
135 - tlsCertFile, tlsKeyFile := acmeManager.TLSFiles()
134 + tlsCertFile, tlsKeyFile := "", ""
135 + if acmeManager := serv.GetACMEManager(); acmeManager != nil {
136 + tlsCertFile, tlsKeyFile = acmeManager.TLSFiles()
137 + }
138
139 go func() {
140 var err error
cmd/relay-server/tunnel.go
+17 -17
@@ -32,7 +32,7 @@ case "$ARCH" in
32 esac
33
34 BASE_URL="${BASE_URL:-%s}"
35 -RELAY_URL="${RELAY_URL:-$BASE_URL}"
35 +RELAYS="${RELAYS:-$BASE_URL}"
36 BIN_URL="${BIN_URL:-$BASE_URL/tunnel/bin/$TUNNEL_OS-$TUNNEL_ARCH}"
37
38 TMPDIR="${TMPDIR:-/tmp}"
@@ -45,13 +45,13 @@ echo "Downloading portal-tunnel ($TUNNEL_OS/$TUNNEL_ARCH)..." >&2
45 curl -fsSL "$BIN_URL" -o "$BIN_PATH"
46 chmod +x "$BIN_PATH"
47
48 -set -- "$BIN_PATH" --relay "$RELAY_URL" --host "${HOST:-localhost:3000}"
49 -[ -n "${NAME:-}" ] && set -- "$@" --name "$NAME"
50 -[ -n "${DESCRIPTION:-}" ] && set -- "$@" --description "$DESCRIPTION"
51 -[ -n "${TAGS:-}" ] && set -- "$@" --tags "$TAGS"
52 -[ -n "${THUMBNAIL:-}" ] && set -- "$@" --thumbnail "$THUMBNAIL"
53 -[ -n "${OWNER:-}" ] && set -- "$@" --owner "$OWNER"
54 -if [ "${HIDE:-}" = "1" ] || [ "${HIDE:-}" = "true" ]; then
48 +set -- "$BIN_PATH" --relay "$RELAYS" --host "${APP_HOST:-localhost:3000}"
49 +[ -n "${APP_NAME:-}" ] && set -- "$@" --name "$APP_NAME"
50 +[ -n "${APP_DESCRIPTION:-}" ] && set -- "$@" --description "$APP_DESCRIPTION"
51 +[ -n "${APP_TAGS:-}" ] && set -- "$@" --tags "$APP_TAGS"
52 +[ -n "${APP_THUMBNAIL:-}" ] && set -- "$@" --thumbnail "$APP_THUMBNAIL"
53 +[ -n "${APP_OWNER:-}" ] && set -- "$@" --owner "$APP_OWNER"
54 +if [ "${APP_HIDE:-}" = "1" ] || [ "${APP_HIDE:-}" = "true" ]; then
55 set -- "$@" --hide
56 fi
57 [ -n "${TLS_MODE:-}" ] && set -- "$@" --tls-mode "$TLS_MODE"
@@ -67,7 +67,7 @@ exec "$@"
67 const tunnelPowerShellScriptTemplate = `$ErrorActionPreference = "Stop"
68
69 $BaseUrl = if ($env:BASE_URL) { $env:BASE_URL } else { "%s" }
70 -$RelayUrl = if ($env:RELAY_URL) { $env:RELAY_URL } else { $BaseUrl }
70 +$Relays = if ($env:RELAYS) { $env:RELAYS } else { $BaseUrl }
71
72 $Arch = $env:PROCESSOR_ARCHITECTURE
73 if ($Arch -eq "AMD64") {
@@ -94,15 +94,15 @@ try {
94 exit 1
95 }
96
97 -$ArgsList = @("--relay", $RelayUrl)
97 +$ArgsList = @("--relay", $Relays)
98
99 -if ($env:HOST) { $ArgsList += "--host", $env:HOST } else { $ArgsList += "--host", "localhost:3000" }
100 -if ($env:NAME) { $ArgsList += "--name", $env:NAME }
101 -if ($env:DESCRIPTION) { $ArgsList += "--description", $env:DESCRIPTION }
102 -if ($env:TAGS) { $ArgsList += "--tags", $env:TAGS }
103 -if ($env:THUMBNAIL) { $ArgsList += "--thumbnail", $env:THUMBNAIL }
104 -if ($env:OWNER) { $ArgsList += "--owner", $env:OWNER }
105 -if ($env:HIDE -eq "1" -or $env:HIDE -eq "true") { $ArgsList += "--hide" }
99 +if ($env:APP_HOST) { $ArgsList += "--host", $env:APP_HOST } else { $ArgsList += "--host", "localhost:3000" }
100 +if ($env:APP_NAME) { $ArgsList += "--name", $env:APP_NAME }
101 +if ($env:APP_DESCRIPTION) { $ArgsList += "--description", $env:APP_DESCRIPTION }
102 +if ($env:APP_TAGS) { $ArgsList += "--tags", $env:APP_TAGS }
103 +if ($env:APP_THUMBNAIL) { $ArgsList += "--thumbnail", $env:APP_THUMBNAIL }
104 +if ($env:APP_OWNER) { $ArgsList += "--owner", $env:APP_OWNER }
105 +if ($env:APP_HIDE -eq "1" -or $env:APP_HIDE -eq "true") { $ArgsList += "--hide" }
106 if ($env:TLS_MODE) { $ArgsList += "--tls-mode", $env:TLS_MODE }
107 if ($env:TLS_MODE -eq "self") {
108 if ($env:TLS_CERT_FILE) { $ArgsList += "--tls-cert-file", $env:TLS_CERT_FILE }
docker-compose.yml
+1 -1
@@ -15,7 +15,7 @@ services:
15
16 # TLS/SNI and keyless configurationa
17 SNI_PORT: ${SNI_PORT:-443}
18 - KEYLESS_KEY_FILE: ${KEYLESS_KEY_FILE:-/etc/portal/keyless/privkey.pem}
18 + KEYLESS_DIR: ${KEYLESS_DIR:-/etc/portal/keyless}
19 CLOUDFLARE_TOKEN: ${CLOUDFLARE_TOKEN:-}
20 ports:
21 - "${ADMIN_PORT:-4017}:${ADMIN_PORT:-4017}"
docs/architecture.md
+31
@@ -50,6 +50,37 @@ Portal connects local applications to web users through a secure relay layer wit
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 +
74 +### SNI Routing Logic
75 +
76 +The SNI router (`portal/sni/router.go`) matches routes in this order:
77 +
78 +1. **Exact match**: `foo.example.com` → registered route
79 +2. **Wildcard match**: `*.example.com` matches `foo.example.com`, `bar.example.com`, etc.
80 +3. **No match**: Falls through to `onNoRoute` handler (e.g., apex domain `example.com`)
81 +
82 +Note: `*.example.com` does NOT match `example.com` (apex) - wildcards only match a single DNS label.
83 +
84 ## Protocol Stack
85
86 ```
docs/portal-deploy-guide.md
+9 -9
@@ -8,8 +8,8 @@ Portal uses SNI-based TLS passthrough: the relay routes TLS by SNI to tunnel bac
8
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_KEY_FILE` and can auto-issue key/cert via ACME DNS-01 when `CLOUDFLARE_TOKEN` is set.
12 - When `KEYLESS_KEY_FILE` and sibling `fullchain.pem` exist, relay admin/API on `--adminport` is served over HTTPS automatically.
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.
13
14 ```
15 Client ──TLS──► Relay (SNI Router :443) ──TLS──► Tunnel Backend (TLS mode)
@@ -26,7 +26,7 @@ Client ──TLS──► Relay (SNI Router :443) ──TLS──► Tunnel Back
26 - `example.com -> <server IP>`
27 - `*.example.com -> <server IP>`
28 - For `self` mode: wildcard TLS certificate and private key for `*.example.com` on the tunnel host.
29 -- For `keyless` mode: either an existing relay signing key at `KEYLESS_KEY_FILE`, or `CLOUDFLARE_TOKEN` for ACME DNS-01 auto issuance.
29 +- For `keyless` mode: either an existing relay signing key at `KEYLESS_DIR`, or `CLOUDFLARE_TOKEN` for ACME DNS-01 auto issuance.
30
31 ## Quick Start
32
@@ -49,8 +49,8 @@ example.com. AAAA 2001:db8::1
49 PORTAL_URL=https://example.com
50 ADMIN_SECRET_KEY=your-secure-key-here
51 CLOUDFLARE_TOKEN=your-cloudflare-dns-token
52 -# Optional (default: /etc/portal/keyless/privkey.pem)
53 -# KEYLESS_KEY_FILE=/etc/portal/keyless/privkey.pem
52 +# Optional (default: /etc/portal/keyless)
53 +# KEYLESS_DIR=/etc/portal/keyless
54 ```
55
56 ### 3. Run Portal
@@ -81,7 +81,7 @@ https://myapp.example.com
81 | `BOOTSTRAP_URIS` | (derived) | Relay API URLs |
82 | `ADMIN_SECRET_KEY` | (auto-generated) | Admin authentication key |
83 | `SNI_PORT` | `443` | SNI router port |
84 -| `KEYLESS_KEY_FILE` | `/etc/portal/keyless/privkey.pem` | Relay keyless signing private key path (`fullchain.pem` is expected in same directory for admin/API HTTPS auto-enable) |
84 +| `KEYLESS_DIR` | `/etc/portal/keyless` | Relay keyless materials directory (`wildcard-privatekey.pem` for signer, `fullchain.pem` + `privatekey.pem` for admin/API HTTPS) |
85 | `CLOUDFLARE_TOKEN` | (empty) | Cloudflare DNS API token used for ACME DNS-01 auto issuance |
86
87 ## docker-compose.yml
@@ -97,7 +97,7 @@ services:
97 PORTAL_URL: ${PORTAL_URL}
98 ADMIN_SECRET_KEY: ${ADMIN_SECRET_KEY}
99 SNI_PORT: ${SNI_PORT:-443}
100 - KEYLESS_KEY_FILE: ${KEYLESS_KEY_FILE:-/etc/portal/keyless/privkey.pem}
100 + KEYLESS_DIR: ${KEYLESS_DIR:-/etc/portal/keyless}
101 CLOUDFLARE_TOKEN: ${CLOUDFLARE_TOKEN:-}
102 ports:
103 - "4017:4017"
@@ -153,7 +153,7 @@ portal-tunnel \
153 - Keyless signer endpoint, key id, trust roots, and certificate chain are auto-configured by SDK defaults.
154 - Auto-discovery expects an HTTPS signer endpoint.
155 - External signer API must return TLS signature responses for the requested digest.
156 -- Relay keyless signer key path is configured by `KEYLESS_KEY_FILE`.
156 +- 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.
158
159 Signer API request/response example:
@@ -208,7 +208,7 @@ curl https://example.com/sdk/domain
208 # Expected: {"success":true,"base_domain":"example.com"}
209
210 # Tunnel script
211 -curl -fsSL https://example.com/tunnel | HOST=localhost:3000 NAME=test sh
211 +curl -fsSL https://example.com/tunnel | APP_HOST=localhost:3000 APP_NAME=test sh
212 ```
213
214 ## Architecture
portal/acme/acme.go
+180 -67
@@ -14,7 +14,7 @@ import (
14 "fmt"
15 "os"
16 "path/filepath"
17 - "strings"
17 + "sync"
18
19 "github.com/go-acme/lego/v4/certcrypto"
20 "github.com/go-acme/lego/v4/certificate"
@@ -25,13 +25,24 @@ import (
25 )
26
27 const (
28 - fullChainFileName = "fullchain.pem"
29 - accountKeyFileName = "acme-account.key"
30 - registrationFileName = "acme-registration.json"
31 - defaultACMEEmailPrefix = "acme@"
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@"
35 )
36
37 +type certTarget struct {
38 + Name string
39 + KeyFile string
40 + CertFile string
41 + Domains []string
42 +}
43 +
44 type provisionConfig struct {
45 + TargetName string
46 KeyFile string
47 CertFile string
48 Email string
@@ -43,29 +54,47 @@ type provisionConfig struct {
54
55 type Config struct {
56 BaseDomain string
46 - KeyFile string
57 + KeyDir string
58 CloudflareToken string
59 }
60
50 -type Manager struct {
51 - cfg Config
61 +type AcmeManager struct {
62 + cfg Config
63 + mu sync.RWMutex
64 + stopCh chan struct{}
65 + waitGroup sync.WaitGroup
66 + startOnce sync.Once
67 + stopOnce sync.Once
68 }
69
54 -func NewManager(cfg Config) *Manager {
55 - return &Manager{
70 +func NewManager(cfg Config) *AcmeManager {
71 + return &AcmeManager{
72 cfg: Config{
57 - BaseDomain: strings.ToLower(strings.TrimSpace(cfg.BaseDomain)),
58 - KeyFile: strings.TrimSpace(cfg.KeyFile),
59 - CloudflareToken: strings.TrimSpace(cfg.CloudflareToken),
73 + BaseDomain: cfg.BaseDomain,
74 + KeyDir: cfg.KeyDir,
75 + CloudflareToken: cfg.CloudflareToken,
76 },
77 + stopCh: make(chan struct{}),
78 + }
79 +}
80 +
81 +func (m *AcmeManager) keyDir() string {
82 + if m == nil {
83 + return ""
84 }
85 + return m.cfg.KeyDir
86 }
87
64 -func (m *Manager) keyFile() string {
88 +// SigningKeyFile returns the fixed wildcard key path under configured key directory.
89 +func (m *AcmeManager) SigningKeyFile() string {
90 if m == nil {
91 return ""
92 }
68 - return strings.TrimSpace(m.cfg.KeyFile)
93 + keyDir := m.keyDir()
94 + if keyDir == "" {
95 + return ""
96 + }
97 + return wildcardKeyPath(keyDir)
98 }
99
100 type acmeUser struct {
@@ -78,7 +107,7 @@ func (u *acmeUser) GetEmail() string {
107 if u == nil {
108 return ""
109 }
81 - return strings.TrimSpace(u.Email)
110 + return u.Email
111 }
112
113 func (u *acmeUser) GetRegistration() *registration.Resource {
@@ -96,101 +125,163 @@ func (u *acmeUser) GetPrivateKey() crypto.PrivateKey {
125 }
126
127 // EnsureSigningKey provisions a keyless signing key via ACME DNS-01 when missing.
99 -func (m *Manager) EnsureSigningKey(ctx context.Context) (string, error) {
128 +func (m *AcmeManager) EnsureSigningKey(ctx context.Context) (string, error) {
129 if m == nil {
130 return "", errors.New("acme manager is nil")
131 }
103 - keyFile := m.keyFile()
104 - if keyFile == "" {
132 + configuredKeyDir := m.keyDir()
133 + if configuredKeyDir == "" {
134 return "", nil
135 }
136 if err := ctx.Err(); err != nil {
137 return "", fmt.Errorf("keyless provisioning canceled: %w", err)
138 }
139
111 - if fileExists(keyFile) {
112 - return keyFile, nil
113 - }
114 - if !hasCloudflareToken(m.cfg.CloudflareToken) {
115 - log.Warn().
116 - Str("key_file", keyFile).
117 - Msg("[signer] keyless key file is missing and Cloudflare credentials are not set; signer will stay disabled")
118 - return keyFile, nil
119 - }
120 -
140 baseDomain := m.cfg.BaseDomain
141 if baseDomain == "" {
142 return "", fmt.Errorf("base domain is required for ACME provisioning")
143 }
144
126 - cfg, err := buildProvisionConfig(baseDomain, keyFile, m.cfg.CloudflareToken)
145 + targets, err := buildCertTargets(baseDomain, configuredKeyDir)
146 if err != nil {
147 return "", err
148 }
149 + wildcardTarget, ok := certTargetByName(targets, "wildcard")
150 + if !ok {
151 + return "", errors.New("missing wildcard ACME target")
152 + }
153 + signerKeyFile := wildcardTarget.KeyFile
154
131 - log.Info().
132 - Strs("domains", cfg.Domains).
133 - Str("key_file", cfg.KeyFile).
134 - Str("cert_file", cfg.CertFile).
135 - Msg("[signer] keyless key is missing; issuing certificate with ACME DNS-01 via Cloudflare")
155 + missingTargets := make([]certTarget, 0, len(targets))
156 + for _, target := range targets {
157 + if fileExists(target.KeyFile) && fileExists(target.CertFile) {
158 + continue
159 + }
160 + missingTargets = append(missingTargets, target)
161 + }
162 + if len(missingTargets) == 0 {
163 + return signerKeyFile, nil
164 + }
165 + if !hasCloudflareToken(m.cfg.CloudflareToken) {
166 + if !fileExists(signerKeyFile) {
167 + log.Warn().
168 + Str("key_file", signerKeyFile).
169 + Msg("[signer] keyless key file is missing and Cloudflare credentials are not set; signer will stay disabled")
170 + }
171 + for _, target := range missingTargets {
172 + log.Warn().
173 + Str("target", target.Name).
174 + Str("key_file", target.KeyFile).
175 + Str("cert_file", target.CertFile).
176 + Msg("[signer] ACME target is missing and Cloudflare credentials are not set")
177 + }
178 + return signerKeyFile, nil
179 + }
180
137 - if err := m.provisionCertificate(cfg); err != nil {
138 - return "", err
181 + for _, target := range missingTargets {
182 + cfg, buildErr := buildProvisionConfig(baseDomain, target, m.cfg.CloudflareToken)
183 + if buildErr != nil {
184 + return "", buildErr
185 + }
186 + log.Info().
187 + Str("target", cfg.TargetName).
188 + Strs("domains", cfg.Domains).
189 + Str("key_file", cfg.KeyFile).
190 + Str("cert_file", cfg.CertFile).
191 + Msg("[signer] ACME target is missing; issuing certificate with ACME DNS-01 via Cloudflare")
192 +
193 + if err := m.provisionCertificate(cfg); err != nil {
194 + return "", err
195 + }
196 }
140 - return keyFile, nil
197 + return signerKeyFile, nil
198 }
199
200 // TLSFiles returns fullchain and private key file paths when both exist.
144 -func (m *Manager) TLSFiles() (string, string) {
201 +func (m *AcmeManager) TLSFiles() (string, string) {
202 if m == nil {
203 return "", ""
204 }
148 - keyFile := m.keyFile()
149 - if keyFile == "" {
205 + keyDir := m.keyDir()
206 + if keyDir == "" {
207 return "", ""
208 }
152 - certFile := fullChainPath(keyFile)
153 - if _, err := os.Stat(certFile); err != nil {
154 - return "", ""
209 +
210 + wildcardKeyFile := wildcardKeyPath(keyDir)
211 + mainKeyFile := mainKeyPath(keyDir)
212 + mainCertFile := mainFullChainPath(keyDir)
213 + if fileExists(mainCertFile) && fileExists(mainKeyFile) {
214 + return mainCertFile, mainKeyFile
215 }
156 - if _, err := os.Stat(keyFile); err != nil {
157 - return "", ""
216 +
217 + wildcardCertFile := fullChainPath(keyDir)
218 + if fileExists(wildcardCertFile) && fileExists(wildcardKeyFile) {
219 + return wildcardCertFile, wildcardKeyFile
220 }
159 - return certFile, keyFile
221 +
222 + return "", ""
223 }
224
162 -func buildProvisionConfig(baseDomain, keyFile, cloudflareToken string) (provisionConfig, error) {
163 - keyDir := filepath.Dir(keyFile)
164 - certFile := fullChainPath(keyFile)
225 +func buildProvisionConfig(baseDomain string, target certTarget, cloudflareToken string) (provisionConfig, error) {
226 + keyDir := filepath.Dir(target.KeyFile)
227 accountKeyFile := filepath.Join(keyDir, accountKeyFileName)
228 registrationFile := filepath.Join(keyDir, registrationFileName)
229 email := defaultACMEEmailPrefix + baseDomain
230
169 - domains, err := resolveDomains(baseDomain)
170 - if err != nil {
231 + if target.KeyFile == "" || target.CertFile == "" || len(target.Domains) == 0 {
232 + return provisionConfig{}, errors.New("invalid ACME target")
233 + }
234 + if _, err := resolveDomain(baseDomain); err != nil {
235 return provisionConfig{}, err
236 }
237
238 return provisionConfig{
175 - KeyFile: keyFile,
176 - CertFile: certFile,
239 + TargetName: target.Name,
240 + KeyFile: target.KeyFile,
241 + CertFile: target.CertFile,
242 Email: email,
178 - Domains: domains,
243 + Domains: target.Domains,
244 AccountKeyFile: accountKeyFile,
245 RegistrationFile: registrationFile,
181 - CloudflareToken: strings.TrimSpace(cloudflareToken),
246 + CloudflareToken: cloudflareToken,
247 }, nil
248 }
249
185 -func resolveDomains(baseDomain string) ([]string, error) {
186 - domain := strings.ToLower(strings.TrimSpace(baseDomain))
187 - if domain == "" {
188 - return nil, errors.New("base domain is required")
250 +func resolveDomain(baseDomain string) (string, error) {
251 + if baseDomain == "" {
252 + return "", errors.New("base domain is required")
253 + }
254 + return baseDomain, nil
255 +}
256 +
257 +func buildCertTargets(baseDomain, configuredKeyDir string) ([]certTarget, error) {
258 + base, err := resolveDomain(baseDomain)
259 + if err != nil {
260 + return nil, err
261 + }
262 + keyDir := configuredKeyDir
263 + if keyDir == "" {
264 + return nil, errors.New("key directory is required")
265 }
190 - return []string{domain, "*." + domain}, nil
266 +
267 + return []certTarget{
268 + {
269 + Name: "wildcard",
270 + KeyFile: wildcardKeyPath(keyDir),
271 + CertFile: fullChainPath(keyDir),
272 + // Keep root + wildcard SAN for keyless compatibility.
273 + Domains: []string{base, "*." + base},
274 + },
275 + {
276 + Name: "main",
277 + KeyFile: mainKeyPath(keyDir),
278 + CertFile: mainFullChainPath(keyDir),
279 + Domains: []string{base},
280 + },
281 + }, nil
282 }
283
193 -func (m *Manager) provisionCertificate(cfg provisionConfig) error {
284 +func (m *AcmeManager) provisionCertificate(cfg provisionConfig) error {
285 for _, path := range []string{cfg.KeyFile, cfg.CertFile, cfg.AccountKeyFile, cfg.RegistrationFile} {
286 if err := ensureParentDir(path); err != nil {
287 return err
@@ -221,7 +312,7 @@ func (m *Manager) provisionCertificate(cfg provisionConfig) error {
312 }
313
314 cfConfig := cloudflare.NewDefaultConfig()
224 - cfConfig.AuthToken = strings.TrimSpace(cfg.CloudflareToken)
315 + cfConfig.AuthToken = cfg.CloudflareToken
316
317 provider, err := cloudflare.NewDNSProviderConfig(cfConfig)
318 if err != nil {
@@ -266,6 +357,7 @@ func (m *Manager) provisionCertificate(cfg provisionConfig) error {
357 }
358
359 log.Info().
360 + Str("target", cfg.TargetName).
361 Str("key_file", cfg.KeyFile).
362 Str("cert_file", cfg.CertFile).
363 Strs("domains", cfg.Domains).
@@ -365,7 +457,7 @@ func ensureParentDir(path string) error {
457 }
458
459 func fileExists(path string) bool {
368 - if strings.TrimSpace(path) == "" {
460 + if path == "" {
461 return false
462 }
463 _, err := os.Stat(path)
@@ -405,9 +497,30 @@ func writeFileAtomic(path string, data []byte, mode os.FileMode) error {
497 }
498
499 func hasCloudflareToken(cloudflareToken string) bool {
408 - return strings.TrimSpace(cloudflareToken) != ""
500 + return cloudflareToken != ""
501 +}
502 +
503 +func fullChainPath(keyDir string) string {
504 + return filepath.Join(keyDir, wildcardFullChainFileName)
505 +}
506 +
507 +func wildcardKeyPath(keyDir string) string {
508 + return filepath.Join(keyDir, wildcardKeyFileName)
509 +}
510 +
511 +func mainFullChainPath(keyDir string) string {
512 + return filepath.Join(keyDir, fullChainFileName)
513 }
514
411 -func fullChainPath(keyFile string) string {
412 - return filepath.Join(filepath.Dir(keyFile), fullChainFileName)
515 +func mainKeyPath(keyDir string) string {
516 + return filepath.Join(keyDir, keyFileName)
517 +}
518 +
519 +func certTargetByName(targets []certTarget, name string) (certTarget, bool) {
520 + for _, target := range targets {
521 + if target.Name == name {
522 + return target, true
523 + }
524 + }
525 + return certTarget{}, false
526 }
portal/acme/certcache.go new
+130
@@ -0,0 +1,130 @@
1 +package acme
2 +
3 +import (
4 + "crypto/x509"
5 + "encoding/json"
6 + "encoding/pem"
7 + "errors"
8 + "fmt"
9 + "os"
10 + "path/filepath"
11 + "time"
12 +
13 + "github.com/rs/zerolog/log"
14 +)
15 +
16 +const (
17 + // DefaultCacheTTL is the default time-to-live for cached certificates.
18 + DefaultCacheTTL = 24 * time.Hour
19 +
20 + // ExpiryThreshold is how long before cert expiration to refresh cache.
21 + ExpiryThreshold = 30 * 24 * time.Hour
22 +
23 + // DefaultCacheFileName is the default filename for certificate cache.
24 + DefaultCacheFileName = "portal-cert-cache.json"
25 +)
26 +
27 +// DefaultCertCachePath returns the default path for certificate cache.
28 +// It uses the OS-specific user cache directory if available, otherwise
29 +// falls back to the current directory.
30 +func DefaultCertCachePath() string {
31 + if cacheDir, err := os.UserCacheDir(); err == nil {
32 + return filepath.Join(cacheDir, "portal", DefaultCacheFileName)
33 + }
34 + if homeDir, err := os.UserHomeDir(); err == nil {
35 + return filepath.Join(homeDir, ".cache", "portal", DefaultCacheFileName)
36 + }
37 + return DefaultCacheFileName
38 +}
39 +
40 +// CertCacheEntry represents a cached certificate chain.
41 +type CertCacheEntry struct {
42 + CertPEM []byte `json:"cert_pem"`
43 + RootCAPEM []byte `json:"root_ca_pem"`
44 + FetchedAt int64 `json:"fetched_at"`
45 +}
46 +
47 +// ParseCertificatePEM parses a PEM-encoded certificate.
48 +func ParseCertificatePEM(pemData []byte) (*x509.Certificate, error) {
49 + block, _ := pem.Decode(pemData)
50 + if block == nil {
51 + return nil, errors.New("no PEM block found")
52 + }
53 + return x509.ParseCertificate(block.Bytes)
54 +}
55 +
56 +// LoadCertCache loads a certificate cache from file.
57 +func LoadCertCache(path string) (*CertCacheEntry, error) {
58 + if path == "" {
59 + return nil, nil
60 + }
61 + data, err := os.ReadFile(path)
62 + if err != nil {
63 + if os.IsNotExist(err) {
64 + return nil, nil
65 + }
66 + return nil, fmt.Errorf("read cert cache: %w", err)
67 + }
68 + var entry CertCacheEntry
69 + if err := json.Unmarshal(data, &entry); err != nil {
70 + return nil, fmt.Errorf("parse cert cache: %w", err)
71 + }
72 + return &entry, nil
73 +}
74 +
75 +// SaveCertCache saves a certificate cache to file.
76 +func SaveCertCache(path string, entry *CertCacheEntry) error {
77 + if path == "" || entry == nil {
78 + return nil
79 + }
80 +
81 + // Ensure parent directory exists
82 + dir := filepath.Dir(path)
83 + if dir != "" && dir != "." {
84 + if err := os.MkdirAll(dir, 0700); err != nil {
85 + return fmt.Errorf("create cert cache directory: %w", err)
86 + }
87 + }
88 +
89 + toSave := *entry
90 + toSave.FetchedAt = time.Now().Unix()
91 +
92 + data, err := json.Marshal(&toSave)
93 + if err != nil {
94 + return fmt.Errorf("marshal cert cache: %w", err)
95 + }
96 + if err := writeFileAtomic(path, data, 0o600); err != nil {
97 + return fmt.Errorf("write cert cache: %w", err)
98 + }
99 + return nil
100 +}
101 +
102 +// IsCertCacheFresh checks if a cached certificate is still fresh.
103 +// It checks both the TTL (time since fetch) and the certificate expiration.
104 +func IsCertCacheFresh(entry *CertCacheEntry, ttl time.Duration) bool {
105 + if entry == nil || len(entry.CertPEM) == 0 {
106 + return false
107 + }
108 +
109 + // Check TTL (how long since we fetched)
110 + fetchedAt := time.Unix(entry.FetchedAt, 0)
111 + if time.Since(fetchedAt) >= ttl {
112 + return false
113 + }
114 +
115 + // Check cert expiration (in case ACME renewed on relay side)
116 + cert, err := ParseCertificatePEM(entry.CertPEM)
117 + if err != nil {
118 + return false
119 + }
120 + timeUntilExpiry := time.Until(cert.NotAfter)
121 + if timeUntilExpiry < ExpiryThreshold {
122 + log.Debug().
123 + Time("not_after", cert.NotAfter).
124 + Dur("time_remaining", timeUntilExpiry).
125 + Msg("[certcache] Cached certificate is close to expiration, will refresh")
126 + return false
127 + }
128 +
129 + return true
130 +}
portal/acme/renew.go new
+242
@@ -0,0 +1,242 @@
1 +package acme
2 +
3 +import (
4 + "context"
5 + "errors"
6 + "fmt"
7 + "os"
8 + "time"
9 +
10 + "github.com/go-acme/lego/v4/certcrypto"
11 + "github.com/go-acme/lego/v4/certificate"
12 + lego "github.com/go-acme/lego/v4/lego"
13 + "github.com/go-acme/lego/v4/providers/dns/cloudflare"
14 + "github.com/rs/zerolog/log"
15 +)
16 +
17 +const (
18 + // RenewalCheckInterval is how often to check if renewal is needed.
19 + RenewalCheckInterval = 24 * time.Hour
20 +
21 + // RenewalThreshold is how long before expiration to renew.
22 + RenewalThreshold = 30 * 24 * time.Hour
23 +
24 + // RenewalOperationTimeout bounds a single renewal attempt.
25 + RenewalOperationTimeout = 2 * time.Minute
26 +)
27 +
28 +// Start begins the certificate renewal loop. It checks periodically if the
29 +// certificate needs renewal and renews it automatically.
30 +func (m *AcmeManager) Start(ctx context.Context) {
31 + if m == nil || m.cfg.KeyDir == "" || !hasCloudflareToken(m.cfg.CloudflareToken) {
32 + return
33 + }
34 +
35 + m.startOnce.Do(func() {
36 + m.waitGroup.Add(1)
37 + go m.renewalLoop(ctx)
38 + })
39 +}
40 +
41 +// Stop stops the renewal loop.
42 +func (m *AcmeManager) Stop() {
43 + if m == nil {
44 + return
45 + }
46 + m.stopOnce.Do(func() {
47 + close(m.stopCh)
48 + })
49 + m.waitGroup.Wait()
50 +}
51 +
52 +func (m *AcmeManager) renewalLoop(ctx context.Context) {
53 + defer m.waitGroup.Done()
54 +
55 + ticker := time.NewTicker(RenewalCheckInterval)
56 + defer ticker.Stop()
57 +
58 + for {
59 + select {
60 + case <-m.stopCh:
61 + return
62 + case <-ctx.Done():
63 + return
64 + case <-ticker.C:
65 + if m.shouldRenew() {
66 + renewCtx, cancel := context.WithTimeout(ctx, RenewalOperationTimeout)
67 + err := m.renewCertificate(renewCtx)
68 + cancel()
69 + if err != nil {
70 + log.Error().Err(err).Msg("[acme] certificate renewal failed")
71 + }
72 + }
73 + }
74 + }
75 +}
76 +
77 +// shouldRenew checks if the certificate needs renewal.
78 +func (m *AcmeManager) shouldRenew() bool {
79 + m.mu.RLock()
80 + defer m.mu.RUnlock()
81 +
82 + targets, err := buildCertTargets(m.cfg.BaseDomain, m.cfg.KeyDir)
83 + if err != nil {
84 + log.Debug().Err(err).Msg("[acme] cannot build certificate targets for renewal check")
85 + return false
86 + }
87 +
88 + for _, target := range targets {
89 + needsRenewal, checkErr := certNeedsRenewal(target.CertFile)
90 + if checkErr != nil {
91 + log.Debug().
92 + Err(checkErr).
93 + Str("target", target.Name).
94 + Str("cert_file", target.CertFile).
95 + Msg("[acme] cannot read certificate for renewal check")
96 + continue
97 + }
98 + if needsRenewal {
99 + return true
100 + }
101 + }
102 + return false
103 +}
104 +
105 +func certNeedsRenewal(certFile string) (bool, error) {
106 + certPEM, err := os.ReadFile(certFile)
107 + if err != nil {
108 + return false, err
109 + }
110 +
111 + cert, err := ParseCertificatePEM(certPEM)
112 + if err != nil {
113 + return false, err
114 + }
115 +
116 + timeUntilExpiry := time.Until(cert.NotAfter)
117 + needsRenewal := timeUntilExpiry < RenewalThreshold
118 + if needsRenewal {
119 + log.Info().
120 + Time("not_after", cert.NotAfter).
121 + Dur("time_remaining", timeUntilExpiry).
122 + Str("cert_file", certFile).
123 + Msg("[acme] certificate needs renewal")
124 + } else {
125 + log.Debug().
126 + Time("not_after", cert.NotAfter).
127 + Dur("time_remaining", timeUntilExpiry).
128 + Str("cert_file", certFile).
129 + Msg("[acme] certificate does not need renewal")
130 + }
131 + return needsRenewal, nil
132 +}
133 +
134 +// renewCertificate renews the certificate via ACME.
135 +func (m *AcmeManager) renewCertificate(ctx context.Context) error {
136 + m.mu.Lock()
137 + defer m.mu.Unlock()
138 +
139 + if err := ctx.Err(); err != nil {
140 + return fmt.Errorf("renewal canceled: %w", err)
141 + }
142 +
143 + if m.cfg.BaseDomain == "" {
144 + return errors.New("base domain not configured")
145 + }
146 +
147 + targets, err := buildCertTargets(m.cfg.BaseDomain, m.cfg.KeyDir)
148 + if err != nil {
149 + return fmt.Errorf("build ACME targets: %w", err)
150 + }
151 +
152 + for _, target := range targets {
153 + needsRenewal, checkErr := certNeedsRenewal(target.CertFile)
154 + if checkErr != nil {
155 + continue
156 + }
157 + if !needsRenewal {
158 + continue
159 + }
160 +
161 + cfg, cfgErr := buildProvisionConfig(m.cfg.BaseDomain, target, m.cfg.CloudflareToken)
162 + if cfgErr != nil {
163 + return fmt.Errorf("build provision config: %w", cfgErr)
164 + }
165 +
166 + log.Info().
167 + Str("target", cfg.TargetName).
168 + Strs("domains", cfg.Domains).
169 + Str("cert_file", cfg.CertFile).
170 + Msg("[acme] renewing certificate")
171 +
172 + if err := m.doRenew(cfg); err != nil {
173 + return fmt.Errorf("renew certificate for target %s: %w", cfg.TargetName, err)
174 + }
175 +
176 + log.Info().
177 + Str("target", cfg.TargetName).
178 + Str("cert_file", cfg.CertFile).
179 + Msg("[acme] certificate renewed successfully")
180 + }
181 + return nil
182 +}
183 +
184 +func (m *AcmeManager) doRenew(cfg provisionConfig) error {
185 + accountKey, err := loadOrCreateAccountKey(cfg.AccountKeyFile)
186 + if err != nil {
187 + return fmt.Errorf("load ACME account key: %w", err)
188 + }
189 + accountReg, err := loadRegistration(cfg.RegistrationFile)
190 + if err != nil {
191 + return fmt.Errorf("load ACME registration: %w", err)
192 + }
193 +
194 + user := &acmeUser{
195 + Email: cfg.Email,
196 + Key: accountKey,
197 + Registration: accountReg,
198 + }
199 + clientConfig := lego.NewConfig(user)
200 + clientConfig.CADirURL = lego.LEDirectoryProduction
201 + clientConfig.Certificate.KeyType = certcrypto.RSA2048
202 +
203 + client, err := lego.NewClient(clientConfig)
204 + if err != nil {
205 + return fmt.Errorf("create ACME client: %w", err)
206 + }
207 +
208 + cfConfig := cloudflare.NewDefaultConfig()
209 + cfConfig.AuthToken = cfg.CloudflareToken
210 +
211 + provider, err := cloudflare.NewDNSProviderConfig(cfConfig)
212 + if err != nil {
213 + return fmt.Errorf("create Cloudflare DNS provider: %w", err)
214 + }
215 + if err := client.Challenge.SetDNS01Provider(provider); err != nil {
216 + return fmt.Errorf("set DNS-01 challenge provider: %w", err)
217 + }
218 +
219 + certFile := cfg.CertFile
220 + certPEM, err := os.ReadFile(certFile)
221 + if err != nil {
222 + return fmt.Errorf("read certificate for renewal: %w", err)
223 + }
224 +
225 + renewed, err := client.Certificate.Renew(certificate.Resource{
226 + Domain: cfg.Domains[0],
227 + Certificate: certPEM,
228 + PrivateKey: nil,
229 + }, true, false, "")
230 + if err != nil {
231 + return fmt.Errorf("ACME renew: %w", err)
232 + }
233 + if len(renewed.Certificate) == 0 {
234 + return errors.New("ACME renewal response did not include certificate chain")
235 + }
236 +
237 + if err := writeFileAtomic(cfg.CertFile, renewed.Certificate, 0o644); err != nil {
238 + return fmt.Errorf("write renewed certificate chain: %w", err)
239 + }
240 +
241 + return nil
242 +}
portal/keyless/signer.go
+1 -1
@@ -48,7 +48,7 @@ func NewSigner(cfg Config) (*Signer, error) {
48 if errors.Is(err, os.ErrNotExist) {
49 return nil, nil
50 }
51 - return nil, fmt.Errorf("read KEYLESS_KEY_FILE: %w", err)
51 + return nil, fmt.Errorf("read keyless signing key: %w", err)
52 }
53
54 signingKey, err := ksigner.ParsePrivateKeyPEM(keyPEM)
portal/relay.go
+39 -18
@@ -23,7 +23,7 @@ type RelayServer struct {
23 leaseManager *LeaseManager
24 reverseHub *ReverseHub
25 sniRouter *sni.Router
26 - acmeManager *acme.Manager
26 + acmeManager *acme.AcmeManager
27 keylessSigner *keyless.Signer
28
29 stopch chan struct{}
@@ -36,26 +36,41 @@ func NewRelayServer(
36 address []string,
37 sniPort string,
38 baseHost string,
39 - keylessKey string,
39 + keylessDir string,
40 cloudflareToken string,
41 ) (*RelayServer, error) {
42 server := &RelayServer{
43 - BaseHost: strings.ToLower(strings.TrimSpace(baseHost)),
43 + BaseHost: baseHost,
44 address: address,
45 leaseManager: NewLeaseManager(30 * time.Second),
46 reverseHub: NewReverseHub(),
47 sniRouter: sni.NewRouter(sniPort),
48 - acmeManager: acme.NewManager(acme.Config{
49 - BaseDomain: strings.ToLower(strings.TrimSpace(baseHost)),
50 - KeyFile: keylessKey,
48 + stopch: make(chan struct{}),
49 + }
50 +
51 + keyFile := ""
52 + if keylessDir != "" {
53 + server.acmeManager = acme.NewManager(acme.Config{
54 + BaseDomain: baseHost,
55 + KeyDir: keylessDir,
56 CloudflareToken: cloudflareToken,
52 - }),
53 - stopch: make(chan struct{}),
57 + })
58 + keyFile = server.acmeManager.SigningKeyFile()
59 }
60
56 - keyFile, err := server.acmeManager.EnsureSigningKey(ctx)
57 - if err != nil {
58 - return nil, fmt.Errorf("ensure keyless signing key: %w", err)
61 + shouldEnsureWithACME := keylessDir != "" && cloudflareToken != "" && baseHost != ""
62 + if shouldEnsureWithACME {
63 + var err error
64 + keyFile, err = server.acmeManager.EnsureSigningKey(ctx)
65 + if err != nil {
66 + return nil, fmt.Errorf("ensure keyless signing key: %w", err)
67 + }
68 + } else {
69 + log.Info().
70 + Bool("has_key_dir", keylessDir != "").
71 + Bool("has_cloudflare_token", cloudflareToken != "").
72 + Bool("has_base_domain", baseHost != "").
73 + Msg("[signer] ACME issuance disabled (requires key directory, Cloudflare token, and base domain)")
74 }
75
76 signer, err := keyless.NewSigner(keyless.Config{
@@ -73,15 +88,15 @@ func NewRelayServer(
88
89 server.leaseManager.SetOnLeaseDeleted(server.reverseHub.DropLease)
90 server.reverseHub.SetAuthorizer(func(leaseID, token string) bool {
76 - entry, ok := server.leaseManager.GetLeaseByID(strings.TrimSpace(leaseID))
91 + entry, ok := server.leaseManager.GetLeaseByID(leaseID)
92 if !ok || entry == nil || entry.Lease == nil {
93 return false
94 }
80 - expected := strings.TrimSpace(entry.Lease.ReverseToken)
95 + expected := entry.Lease.ReverseToken
96 if expected == "" {
97 return false
98 }
84 - return subtle.ConstantTimeCompare([]byte(expected), []byte(strings.TrimSpace(token))) == 1
99 + return subtle.ConstantTimeCompare([]byte(expected), []byte(token)) == 1
100 })
101 return server, nil
102 }
@@ -107,7 +122,7 @@ func (g *RelayServer) GetKeylessSigner() *keyless.Signer {
122 }
123
124 // GetACMEManager returns relay ACME manager.
110 -func (g *RelayServer) GetACMEManager() *acme.Manager {
125 +func (g *RelayServer) GetACMEManager() *acme.AcmeManager {
126 return g.acmeManager
127 }
128
@@ -117,12 +132,10 @@ func (g *RelayServer) ConfigurePortalRootFallback(rootSNI, upstreamAddr string)
132 return
133 }
134
120 - rootSNI = strings.ToLower(strings.TrimSpace(rootSNI))
135 if rootSNI == "" {
136 return
137 }
138
125 - upstreamAddr = strings.TrimSpace(upstreamAddr)
139 if upstreamAddr == "" {
140 log.Warn().
141 Msg("[RelayServer] root-domain SNI fallback upstream is empty; fallback disabled")
@@ -130,7 +143,7 @@ func (g *RelayServer) ConfigurePortalRootFallback(rootSNI, upstreamAddr string)
143 }
144
145 g.sniRouter.SetNoRouteHandler(func(clientConn net.Conn, serverName string) bool {
133 - if !strings.EqualFold(strings.TrimSpace(serverName), rootSNI) {
146 + if !strings.EqualFold(serverName, rootSNI) {
147 return false
148 }
149
@@ -164,6 +177,11 @@ func (g *RelayServer) Start() error {
177 }
178 log.Info().Str("addr", g.sniRouter.GetAddr()).Msg("[RelayServer] SNI router started")
179
180 + // Start ACME renewal loop
181 + if g.acmeManager != nil {
182 + g.acmeManager.Start(context.Background())
183 + }
184 +
185 log.Info().Msg("[RelayServer] Started")
186 return nil
187 }
@@ -173,6 +191,9 @@ func (g *RelayServer) Stop() {
191 close(g.stopch)
192 g.leaseManager.Stop()
193 g.sniRouter.Stop()
194 + if g.acmeManager != nil {
195 + g.acmeManager.Stop()
196 + }
197 g.waitgroup.Wait()
198 log.Info().Msg("[RelayServer] Stopped")
199 }
sdk/client.go
+44 -9
@@ -23,6 +23,7 @@ import (
23
24 "github.com/rs/zerolog/log"
25 "gosuda.org/portal/portal"
26 + "gosuda.org/portal/portal/acme"
27 )
28
29 var urlSafeNameRegex = regexp.MustCompile(`^[\p{L}\p{N}_-]+$`)
@@ -162,6 +163,8 @@ func (c *Client) Listen(name string, options ...MetadataOption) (net.Listener, e
163 keylessServerName,
164 c.config.TLSKeylessCertificatePEM,
165 c.config.TLSKeyless.RootCAPEM,
166 + c.config.CertCacheFile,
167 + acme.DefaultCacheTTL,
168 )
169 if err != nil {
170 return nil, fmt.Errorf("prepare keyless materials: %w", err)
@@ -350,20 +353,42 @@ func resolveKeylessMaterials(
353 keylessServerName string,
354 inlineCertPEM []byte,
355 inlineRootCAPEM []byte,
356 + cacheFile string,
357 + cacheTTL time.Duration,
358 ) ([]byte, []byte, error) {
359 certPEM := append([]byte(nil), inlineCertPEM...)
355 - chainFromEndpoint := []byte(nil)
360 + rootCAPEM := append([]byte(nil), inlineRootCAPEM...)
361 +
362 + // If both are explicitly provided, no need for cache or fetch
363 + if len(certPEM) > 0 && len(rootCAPEM) > 0 {
364 + return certPEM, rootCAPEM, nil
365 + }
366
357 - if len(certPEM) == 0 || len(inlineRootCAPEM) == 0 {
358 - autoChain, err := fetchEndpointCertificateChain(ctx, keylessEndpoint, keylessServerName)
359 - if err != nil && len(certPEM) == 0 {
360 - return nil, nil, fmt.Errorf("auto-discover certificate chain from signer endpoint: %w", err)
367 + // Try loading from cache first
368 + if cacheEntry, err := acme.LoadCertCache(cacheFile); err != nil {
369 + log.Debug().Err(err).Msg("[SDK] Failed to load cert cache")
370 + } else if cacheEntry != nil && acme.IsCertCacheFresh(cacheEntry, cacheTTL) {
371 + log.Debug().Str("path", cacheFile).Msg("[SDK] Using cached certificate")
372 + if len(certPEM) == 0 && len(cacheEntry.CertPEM) > 0 {
373 + certPEM = cacheEntry.CertPEM
374 }
362 - if err == nil {
363 - chainFromEndpoint = autoChain
375 + if len(rootCAPEM) == 0 && len(cacheEntry.RootCAPEM) > 0 {
376 + rootCAPEM = cacheEntry.RootCAPEM
377 + }
378 + if len(certPEM) > 0 && len(rootCAPEM) > 0 {
379 + return certPEM, rootCAPEM, nil
380 }
381 }
382
383 + // Fetch from endpoint
384 + chainFromEndpoint, err := fetchEndpointCertificateChain(ctx, keylessEndpoint, keylessServerName)
385 + if err != nil && len(certPEM) == 0 {
386 + return nil, nil, fmt.Errorf("auto-discover certificate chain from signer endpoint: %w", err)
387 + }
388 + if err != nil {
389 + log.Debug().Err(err).Msg("[SDK] Failed to fetch cert from endpoint, using inline materials")
390 + }
391 +
392 if len(certPEM) == 0 {
393 certPEM = chainFromEndpoint
394 }
@@ -371,15 +396,25 @@ func resolveKeylessMaterials(
396 return nil, nil, fmt.Errorf("keyless certificate chain is required")
397 }
398
374 - rootCAPEM := append([]byte(nil), inlineRootCAPEM...)
399 if len(rootCAPEM) == 0 && len(chainFromEndpoint) > 0 {
400 rootCAPEM = append([]byte(nil), chainFromEndpoint...)
401 }
402 if len(rootCAPEM) == 0 {
379 - // Fallback for non-mTLS signer TLS verification.
403 rootCAPEM = append([]byte(nil), certPEM...)
404 }
405
406 + // Save to cache
407 + if cacheFile != "" {
408 + if err := acme.SaveCertCache(cacheFile, &acme.CertCacheEntry{
409 + CertPEM: certPEM,
410 + RootCAPEM: rootCAPEM,
411 + }); err != nil {
412 + log.Debug().Err(err).Msg("[SDK] Failed to save cert cache")
413 + } else {
414 + log.Debug().Str("path", cacheFile).Msg("[SDK] Saved certificate cache")
415 + }
416 + }
417 +
418 return certPEM, rootCAPEM, nil
419 }
420
sdk/types.go
+12
@@ -59,6 +59,10 @@ type ClientConfig struct {
59 // Optional certificate chain and remote signer config used by keyless mode.
60 TLSKeylessCertificatePEM []byte
61 TLSKeyless TLSKeylessConfig
62 +
63 + // CertCacheFile is the path to cache keyless certificates.
64 + // If set, certificates are cached to avoid re-fetching on every startup.
65 + CertCacheFile string
66 }
67
68 type ClientOption func(*ClientConfig)
@@ -148,6 +152,14 @@ func WithTLSKeylessDefaults() ClientOption {
152 }
153 }
154
155 +// WithCertCacheFile sets the path for caching keyless certificates.
156 +// Cached certificates are reused until they expire (default 24 hours).
157 +func WithCertCacheFile(path string) ClientOption {
158 + return func(c *ClientConfig) {
159 + c.CertCacheFile = path
160 + }
161 +}
162 +
163 // MetadataOption configures Metadata
164 type MetadataOption func(*portal.Metadata)
165