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