refactor(relay-server): simplify config flags and keyless ACME flow, remove noindex
rabbitprincess committed
Mar 2, 2026 at 10:35 UTC
f632d373736211175922c973e45e6f0c6b0e69a9
11 files changed
+477
-30
AGENTS.md
+3
@@ -79,6 +79,8 @@ 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`) |
83
+| `CLOUDFLARE_TOKEN` | Cloudflare DNS token for ACME DNS-01 auto-issuance when key file is missing |
84
85
### Tunnel Environment Variables
86
@@ -103,6 +105,7 @@ Portal uses environment variables for domain and TLS configuration:
105
- Keyless signer endpoint defaults to relay URL unless explicitly overridden in SDK options.
106
- Certificate chain/root trust are auto-discovered by SDK from signer endpoint when not explicitly provided.
107
- 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
110
See `docs/portal-deploy-guide.md` for full deployment documentation.
111
Dockerfile
+3
-1
@@ -41,7 +41,9 @@ COPY --from=go-builder /src/bin/relay-server /usr/bin/relay-server
41
ENV PORTAL_URL=http://localhost:4017
42
ENV BOOTSTRAP_URIS=http://localhost:4017
43
ENV ADMIN_SECRET_KEY=
44
-ENV NOINDEX=false
44
+ENV SNI_PORT=:443
45
+ENV KEYLESS_KEY_FILE=/etc/portal/keyless/privkey.pem
46
+ENV CLOUDFLARE_TOKEN=
47
ENV TZ=UTC
48
49
EXPOSE 4017
README.md
+11
-1
@@ -29,8 +29,18 @@ docker compose up
29
30
# Access at http://localhost:4017
31
# Admin panel at http://localhost:4017/admin
32
-# Auto-generated admin key shown in logs, or set your own:
32
+# Set your own admin key (recommended):
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,
37
+# relay issues/renews keyless certs via ACME DNS-01.
38
+CLOUDFLARE_TOKEN=your-cloudflare-dns-token docker compose up
39
+```
40
+
41
+```bash
42
+# Run relay binary directly
43
+./bin/relay-server --adminport 4017
44
```
45
46
For production deployment (DNS, TLS, reverse proxy), see [docs/portal-deploy-guide.md](docs/portal-deploy-guide.md).
cmd/relay-server/main.go
+11
-9
@@ -30,14 +30,15 @@ const (
30
var flagPortalURL string
31
32
type relayServerConfig struct {
33
- Port int
33
+ AdminPort int
34
AdminSecretKey string
35
- NoIndex bool
35
LeaseBPS int
36
PortalURL string
37
Bootstraps []string
38
SNIPort string
40
- KeylessKeyFile string
39
+
40
+ KeylessKeyFile string
41
+ CloudflareToken string
42
}
43
44
func main() {
@@ -47,7 +48,6 @@ func main() {
48
49
portalURL := strings.TrimSuffix(strings.TrimSpace(os.Getenv("PORTAL_URL")), "/")
50
if portalURL == "" {
50
- // Prefer explicit scheme for localhost so downstream URL building is unambiguous.
51
portalURL = defaultPortalURL
52
}
53
bootstrapsCSV := strings.TrimSpace(os.Getenv("BOOTSTRAP_URIS"))
@@ -62,15 +62,17 @@ func main() {
62
if keylessKey == "" {
63
keylessKey = defaultKeylessKeyFile
64
}
65
+ adminSecretKey := strings.TrimSpace(os.Getenv("ADMIN_SECRET_KEY"))
66
+ cloudflareToken := strings.TrimSpace(os.Getenv("CLOUDFLARE_TOKEN"))
67
66
- flag.IntVar(&cfg.Port, "port", defaultHTTPPort, "HTTP server port")
67
- flag.StringVar(&cfg.AdminSecretKey, "admin-secret-key", strings.TrimSpace(os.Getenv("ADMIN_SECRET_KEY")), "admin auth secret (env: ADMIN_SECRET_KEY)")
68
- flag.BoolVar(&cfg.NoIndex, "noindex", strings.EqualFold(strings.TrimSpace(os.Getenv("NOINDEX")), "true"), "disallow crawlers (env: NOINDEX)")
68
+ flag.IntVar(&cfg.AdminPort, "adminport", defaultHTTPPort, "Admin/HTTP server port")
69
+ flag.StringVar(&cfg.AdminSecretKey, "admin-secret-key", adminSecretKey, "admin auth secret (env: ADMIN_SECRET_KEY)")
70
flag.IntVar(&cfg.LeaseBPS, "lease-bps", 0, "bytes-per-second limit per lease (0=unlimited)")
71
flag.StringVar(&cfg.PortalURL, "portal-url", portalURL, "portal base URL (env: PORTAL_URL)")
72
flag.StringVar(&bootstrapsCSV, "bootstraps", bootstrapsCSV, "bootstrap URIs, comma-separated (env: BOOTSTRAP_URIS)")
73
flag.StringVar(&cfg.SNIPort, "sni-port", sniPort, "SNI router port (env: SNI_PORT)")
74
flag.StringVar(&cfg.KeylessKeyFile, "keyless-key-file", keylessKey, "PEM private key path for relay keyless signer (env: KEYLESS_KEY_FILE)")
75
+ flag.StringVar(&cfg.CloudflareToken, "cloudflare-token", cloudflareToken, "Cloudflare DNS API token (Zone:Read + DNS:Edit) (env: CLOUDFLARE_TOKEN)")
76
flag.Parse()
77
78
cfg.Bootstraps = parseURLs(bootstrapsCSV)
@@ -89,7 +91,7 @@ func runServer(cfg relayServerConfig) error {
91
Strs("bootstrap_uris", cfg.Bootstraps).
92
Msg("[server] frontend configuration")
93
92
- serv, err := portal.NewRelayServer(ctx, cfg.Bootstraps, cfg.SNIPort, cfg.PortalURL, cfg.KeylessKeyFile)
94
+ serv, err := portal.NewRelayServer(ctx, cfg.Bootstraps, cfg.SNIPort, cfg.PortalURL, cfg.KeylessKeyFile, cfg.CloudflareToken)
95
if err != nil {
96
return fmt.Errorf("create relay server: %w", err)
97
}
@@ -138,7 +140,7 @@ func runServer(cfg relayServerConfig) error {
140
}
141
defer serv.Stop()
142
141
- httpSrv := serveHTTP(fmt.Sprintf(":%d", cfg.Port), serv, admin, frontend, cfg.NoIndex, stop)
143
+ httpSrv := serveHTTP(fmt.Sprintf(":%d", cfg.AdminPort), serv, admin, frontend, stop)
144
145
<-ctx.Done()
146
log.Info().Msg("[server] shutting down...")
cmd/relay-server/serve.go
-9
@@ -28,7 +28,6 @@ func serveHTTP(
28
serv *portal.RelayServer,
29
admin *Admin,
30
frontend *Frontend,
31
- noIndex bool,
31
cancel context.CancelFunc,
32
) *http.Server {
33
if addr == "" {
@@ -43,14 +42,6 @@ func serveHTTP(
42
frontend.ServeAsset(appMux, "/favicon.png", "favicon.png", "image/png")
43
frontend.ServeAsset(appMux, "/favicon.svg", "favicon.svg", "image/svg+xml")
44
46
- if noIndex {
47
- appMux.HandleFunc("/robots.txt", func(w http.ResponseWriter, r *http.Request) {
48
- w.Header().Set("Content-Type", "text/plain")
49
- w.WriteHeader(http.StatusOK)
50
- w.Write([]byte("User-agent: *\nDisallow: /\n"))
51
- })
52
- }
53
-
45
// Portal app assets (JS, CSS, etc.) - served from /app/
46
appMux.HandleFunc("/app/", func(w http.ResponseWriter, r *http.Request) {
47
setCORSHeaders(w)
docker-compose.yml
+7
-7
@@ -6,20 +6,20 @@ services:
6
context: .
7
dockerfile: Dockerfile
8
command:
9
- - "--port"
10
- - "${PORTAL_PORT:-4017}"
9
+ - "--adminport"
10
+ - "${ADMIN_PORT:-4017}"
11
environment:
12
# Core configuration
13
- PORTAL_URL: ${PORTAL_URL:-http://localhost:${PORTAL_PORT:-4017}}
14
- BOOTSTRAP_URIS: ${BOOTSTRAP_URIS:-http://localhost:${PORTAL_PORT:-4017}}
13
+ PORTAL_URL: ${PORTAL_URL:-http://localhost:${ADMIN_PORT:-4017}}
14
+ BOOTSTRAP_URIS: ${BOOTSTRAP_URIS:-http://localhost:${ADMIN_PORT:-4017}}
15
ADMIN_SECRET_KEY: ${ADMIN_SECRET_KEY:-}
16
- NOINDEX: ${NOINDEX:-}
16
18
- # SNI router port (for TLS passthrough)
17
+ # TLS/SNI and keyless configuration
18
SNI_PORT: ${SNI_PORT:-:443}
19
KEYLESS_KEY_FILE: ${KEYLESS_KEY_FILE:-/etc/portal/keyless/privkey.pem}
20
+ CLOUDFLARE_TOKEN: ${CLOUDFLARE_TOKEN:-}
21
ports:
22
- - "${PORTAL_PORT:-4017}:${PORTAL_PORT:-4017}"
22
+ - "${ADMIN_PORT:-4017}:${ADMIN_PORT:-4017}"
23
- "80:80"
24
- "443:443"
25
restart: unless-stopped
docs/portal-deploy-guide.md
+16
-2
@@ -8,7 +8,7 @@ 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 uses a local certificate chain and delegates signing to an external signer API (relay does not hold private key).
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
13
```
14
Client ──TLS──► Relay (SNI Router :443) ──TLS──► Tunnel Backend (TLS mode)
@@ -24,7 +24,8 @@ Client ──TLS──► Relay (SNI Router :443) ──TLS──► Tunnel Back
24
- DNS A/AAAA records pointing to your server:
25
- `example.com -> <server IP>`
26
- `*.example.com -> <server IP>`
27
-- Wildcard TLS certificate and private key for `*.example.com`
27
+- For `self` mode: wildcard TLS certificate and private key for `*.example.com` on the tunnel host.
28
+- For `keyless` mode: either an existing relay signing key at `KEYLESS_KEY_FILE`, or `CLOUDFLARE_TOKEN` for ACME DNS-01 auto issuance.
29
30
## Quick Start
31
@@ -46,6 +47,9 @@ example.com. AAAA 2001:db8::1
47
# Core
48
PORTAL_URL=https://example.com
49
ADMIN_SECRET_KEY=your-secure-key-here
50
+CLOUDFLARE_TOKEN=your-cloudflare-dns-token
51
+# Optional (default: /etc/portal/keyless/privkey.pem)
52
+# KEYLESS_KEY_FILE=/etc/portal/keyless/privkey.pem
53
```
54
55
### 3. Run Portal
@@ -76,6 +80,8 @@ https://myapp.example.com
80
| `BOOTSTRAP_URIS` | (derived) | Relay API URLs |
81
| `ADMIN_SECRET_KEY` | (auto-generated) | Admin authentication key |
82
| `SNI_PORT` | `:443` | SNI router port |
83
+| `KEYLESS_KEY_FILE` | `/etc/portal/keyless/privkey.pem` | Relay keyless signing private key path |
84
+| `CLOUDFLARE_TOKEN` | (empty) | Cloudflare DNS API token used for ACME DNS-01 auto issuance |
85
86
## docker-compose.yml
87
@@ -83,9 +89,15 @@ https://myapp.example.com
89
services:
90
portal:
91
image: ghcr.io/gosuda/portal:1
92
+ command:
93
+ - "--adminport"
94
+ - "4017"
95
environment:
96
PORTAL_URL: ${PORTAL_URL}
97
ADMIN_SECRET_KEY: ${ADMIN_SECRET_KEY}
98
+ SNI_PORT: ${SNI_PORT:-:443}
99
+ KEYLESS_KEY_FILE: ${KEYLESS_KEY_FILE:-/etc/portal/keyless/privkey.pem}
100
+ CLOUDFLARE_TOKEN: ${CLOUDFLARE_TOKEN:-}
101
ports:
102
- "4017:4017"
103
- "443:443"
@@ -140,6 +152,8 @@ portal-tunnel \
152
- Keyless signer endpoint, key id, trust roots, and certificate chain are auto-configured by SDK defaults.
153
- Auto-discovery expects an HTTPS signer endpoint.
154
- External signer API must return TLS signature responses for the requested digest.
155
+- Relay keyless signer key path is configured by `KEYLESS_KEY_FILE`.
156
+- When key file is missing and `CLOUDFLARE_TOKEN` is set, relay auto-issues key/cert using ACME DNS-01.
157
158
Signer API request/response example:
159
go.mod
+9
@@ -3,13 +3,22 @@ module gosuda.org/portal
3
go 1.26.0
4
5
require (
6
+ github.com/go-acme/lego/v4 v4.27.0
7
github.com/gosuda/keyless_tls v0.0.1-0.20260227054723-d699441f3834
8
github.com/rs/zerolog v1.34.0
9
golang.org/x/net v0.50.0
10
)
11
12
require (
13
+ github.com/cenkalti/backoff/v5 v5.0.3 // indirect
14
+ github.com/go-jose/go-jose/v4 v4.1.3 // indirect
15
github.com/mattn/go-colorable v0.1.14 // indirect
16
github.com/mattn/go-isatty v0.0.20 // indirect
17
+ github.com/miekg/dns v1.1.68 // indirect
18
+ golang.org/x/crypto v0.48.0 // indirect
19
+ golang.org/x/mod v0.32.0 // indirect
20
+ golang.org/x/sync v0.19.0 // indirect
21
golang.org/x/sys v0.41.0 // indirect
22
+ golang.org/x/text v0.34.0 // indirect
23
+ golang.org/x/tools v0.41.0 // indirect
24
)
go.sum
+28
@@ -1,5 +1,15 @@
1
+github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
2
+github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
3
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
4
+github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
5
+github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
6
+github.com/go-acme/lego/v4 v4.27.0 h1:cIhWd7Uj4BNFLEF3IpwuMkukVVRs5qjlp4KdUGa75yU=
7
+github.com/go-acme/lego/v4 v4.27.0/go.mod h1:9FfNZHZmg6hf5CWOp4Lzo4gU8aBEvqZvrwdkBboa+4g=
8
+github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
9
+github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
10
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
11
+github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
12
+github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
13
github.com/gosuda/keyless_tls v0.0.1-0.20260227054723-d699441f3834 h1:P+oEVDMGuhFxU3pBMvStOPbDLin8GO9pv7+iA/xaYMs=
14
github.com/gosuda/keyless_tls v0.0.1-0.20260227054723-d699441f3834/go.mod h1:BOhUZgiAAQzxKO3QcC4fCXgd/+lqxgIu1OyIYTqtta8=
15
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
@@ -9,14 +19,32 @@ github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/
19
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
20
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
21
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
22
+github.com/miekg/dns v1.1.68 h1:jsSRkNozw7G/mnmXULynzMNIsgY2dHC8LO6U6Ij2JEA=
23
+github.com/miekg/dns v1.1.68/go.mod h1:fujopn7TB3Pu3JM69XaawiU0wqjpL9/8xGop5UrTPps=
24
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
25
+github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
26
+github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
27
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
28
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
29
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
30
+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
31
+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
32
+golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
33
+golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
34
+golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
35
+golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
36
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
37
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
38
+golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
39
+golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
40
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
41
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
42
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
43
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
44
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
45
+golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
46
+golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
47
+golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
48
+golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
49
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
50
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
portal/keyless/acme.go
new
+382
@@ -0,0 +1,382 @@
1
+package keyless
2
+
3
+import (
4
+ "context"
5
+ "crypto"
6
+ "crypto/ecdsa"
7
+ "crypto/elliptic"
8
+ "crypto/rand"
9
+ "crypto/rsa"
10
+ "crypto/x509"
11
+ "encoding/json"
12
+ "encoding/pem"
13
+ "errors"
14
+ "fmt"
15
+ "net/url"
16
+ "os"
17
+ "path/filepath"
18
+ "strings"
19
+
20
+ "github.com/go-acme/lego/v4/certcrypto"
21
+ "github.com/go-acme/lego/v4/certificate"
22
+ lego "github.com/go-acme/lego/v4/lego"
23
+ "github.com/go-acme/lego/v4/providers/dns/cloudflare"
24
+ "github.com/go-acme/lego/v4/registration"
25
+ "github.com/rs/zerolog/log"
26
+)
27
+
28
+type acmeProvisionConfig struct {
29
+ KeyFile string
30
+ CertFile string
31
+ Email string
32
+ Domains []string
33
+ AccountKeyFile string
34
+ RegistrationFile string
35
+ CloudflareToken string
36
+}
37
+
38
+type acmeUser struct {
39
+ Email string
40
+ Registration *registration.Resource
41
+ Key crypto.PrivateKey
42
+}
43
+
44
+func (u *acmeUser) GetEmail() string {
45
+ if u == nil {
46
+ return ""
47
+ }
48
+ return strings.TrimSpace(u.Email)
49
+}
50
+
51
+func (u *acmeUser) GetRegistration() *registration.Resource {
52
+ if u == nil {
53
+ return nil
54
+ }
55
+ return u.Registration
56
+}
57
+
58
+func (u *acmeUser) GetPrivateKey() crypto.PrivateKey {
59
+ if u == nil {
60
+ return nil
61
+ }
62
+ return u.Key
63
+}
64
+
65
+// EnsureSigningKey provisions a keyless signing key via ACME DNS-01 when missing.
66
+func EnsureSigningKey(ctx context.Context, portalURL, keyFile, cloudflareToken string) (string, error) {
67
+ keyFile = strings.TrimSpace(keyFile)
68
+ if keyFile == "" {
69
+ return "", nil
70
+ }
71
+ if err := ctx.Err(); err != nil {
72
+ return "", fmt.Errorf("keyless provisioning canceled: %w", err)
73
+ }
74
+
75
+ if fileExists(keyFile) {
76
+ return keyFile, nil
77
+ }
78
+ if !hasCloudflareCredentials(cloudflareToken) {
79
+ log.Warn().
80
+ Str("key_file", keyFile).
81
+ Msg("[signer] keyless key file is missing and Cloudflare credentials are not set; signer will stay disabled")
82
+ return keyFile, nil
83
+ }
84
+
85
+ baseDomain := extractBaseDomainForKeyless(portalURL)
86
+ if baseDomain == "" {
87
+ return "", fmt.Errorf("derive base domain from PORTAL_URL for ACME provisioning")
88
+ }
89
+
90
+ cfg, err := buildACMEProvisionConfig(baseDomain, keyFile, cloudflareToken)
91
+ if err != nil {
92
+ return "", err
93
+ }
94
+
95
+ log.Info().
96
+ Strs("domains", cfg.Domains).
97
+ Str("key_file", cfg.KeyFile).
98
+ Str("cert_file", cfg.CertFile).
99
+ Msg("[signer] keyless key is missing; issuing certificate with ACME DNS-01 via Cloudflare")
100
+
101
+ if err := provisionKeylessCertificate(cfg); err != nil {
102
+ return "", err
103
+ }
104
+ return keyFile, nil
105
+}
106
+
107
+func buildACMEProvisionConfig(baseDomain, keyFile, cloudflareToken string) (acmeProvisionConfig, error) {
108
+ keyDir := filepath.Dir(keyFile)
109
+ certFile := filepath.Join(keyDir, "fullchain.pem")
110
+
111
+ accountKeyFile := filepath.Join(keyDir, "acme-account.key")
112
+
113
+ registrationFile := filepath.Join(keyDir, "acme-registration.json")
114
+
115
+ email := "acme@" + baseDomain
116
+
117
+ domains, err := resolveACMEDomains(baseDomain)
118
+ if err != nil {
119
+ return acmeProvisionConfig{}, err
120
+ }
121
+
122
+ return acmeProvisionConfig{
123
+ KeyFile: keyFile,
124
+ CertFile: certFile,
125
+ Email: email,
126
+ Domains: domains,
127
+ AccountKeyFile: accountKeyFile,
128
+ RegistrationFile: registrationFile,
129
+ CloudflareToken: strings.TrimSpace(cloudflareToken),
130
+ }, nil
131
+}
132
+
133
+func resolveACMEDomains(baseDomain string) ([]string, error) {
134
+ domain := strings.ToLower(strings.TrimSpace(baseDomain))
135
+ if domain == "" {
136
+ return nil, errors.New("base domain is required")
137
+ }
138
+ return []string{domain, "*." + domain}, nil
139
+}
140
+
141
+func provisionKeylessCertificate(cfg acmeProvisionConfig) error {
142
+ for _, path := range []string{cfg.KeyFile, cfg.CertFile, cfg.AccountKeyFile, cfg.RegistrationFile} {
143
+ if err := ensureParentDir(path); err != nil {
144
+ return err
145
+ }
146
+ }
147
+
148
+ accountKey, err := loadOrCreateAccountKey(cfg.AccountKeyFile)
149
+ if err != nil {
150
+ return fmt.Errorf("load ACME account key: %w", err)
151
+ }
152
+ accountReg, err := loadRegistration(cfg.RegistrationFile)
153
+ if err != nil {
154
+ return fmt.Errorf("load ACME registration: %w", err)
155
+ }
156
+
157
+ user := &acmeUser{
158
+ Email: cfg.Email,
159
+ Key: accountKey,
160
+ Registration: accountReg,
161
+ }
162
+ clientConfig := lego.NewConfig(user)
163
+ clientConfig.CADirURL = lego.LEDirectoryProduction
164
+ clientConfig.Certificate.KeyType = certcrypto.RSA2048
165
+
166
+ client, err := lego.NewClient(clientConfig)
167
+ if err != nil {
168
+ return fmt.Errorf("create ACME client: %w", err)
169
+ }
170
+
171
+ cfConfig := cloudflare.NewDefaultConfig()
172
+ cfConfig.AuthToken = strings.TrimSpace(cfg.CloudflareToken)
173
+
174
+ provider, err := cloudflare.NewDNSProviderConfig(cfConfig)
175
+ if err != nil {
176
+ return fmt.Errorf("create Cloudflare DNS provider: %w", err)
177
+ }
178
+ if err := client.Challenge.SetDNS01Provider(provider); err != nil {
179
+ return fmt.Errorf("set DNS-01 challenge provider: %w", err)
180
+ }
181
+
182
+ if user.Registration == nil {
183
+ reg, err := client.Registration.Register(registration.RegisterOptions{
184
+ TermsOfServiceAgreed: true,
185
+ })
186
+ if err != nil {
187
+ return fmt.Errorf("register ACME account: %w", err)
188
+ }
189
+ user.Registration = reg
190
+ if err := saveRegistration(cfg.RegistrationFile, reg); err != nil {
191
+ return fmt.Errorf("persist ACME registration: %w", err)
192
+ }
193
+ }
194
+
195
+ obtained, err := client.Certificate.Obtain(certificate.ObtainRequest{
196
+ Domains: cfg.Domains,
197
+ Bundle: true,
198
+ })
199
+ if err != nil {
200
+ return fmt.Errorf("obtain certificate: %w", err)
201
+ }
202
+ if len(obtained.PrivateKey) == 0 {
203
+ return errors.New("ACME response did not include private key")
204
+ }
205
+ if len(obtained.Certificate) == 0 {
206
+ return errors.New("ACME response did not include certificate chain")
207
+ }
208
+
209
+ if err := writeFileAtomic(cfg.KeyFile, obtained.PrivateKey, 0o600); err != nil {
210
+ return fmt.Errorf("write keyless private key: %w", err)
211
+ }
212
+ if err := writeFileAtomic(cfg.CertFile, obtained.Certificate, 0o644); err != nil {
213
+ return fmt.Errorf("write keyless certificate chain: %w", err)
214
+ }
215
+
216
+ log.Info().
217
+ Str("key_file", cfg.KeyFile).
218
+ Str("cert_file", cfg.CertFile).
219
+ Strs("domains", cfg.Domains).
220
+ Msg("[signer] ACME certificate issued and stored")
221
+ return nil
222
+}
223
+
224
+func loadOrCreateAccountKey(path string) (crypto.PrivateKey, error) {
225
+ keyPEM, err := os.ReadFile(path)
226
+ if err == nil {
227
+ key, parseErr := parsePEMPrivateKey(keyPEM)
228
+ if parseErr != nil {
229
+ return nil, parseErr
230
+ }
231
+ return key, nil
232
+ }
233
+ if !errors.Is(err, os.ErrNotExist) {
234
+ return nil, err
235
+ }
236
+
237
+ key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
238
+ if err != nil {
239
+ return nil, fmt.Errorf("generate ACME account key: %w", err)
240
+ }
241
+ pkcs8, err := x509.MarshalPKCS8PrivateKey(key)
242
+ if err != nil {
243
+ return nil, fmt.Errorf("marshal ACME account key: %w", err)
244
+ }
245
+ pemData := pem.EncodeToMemory(&pem.Block{
246
+ Type: "PRIVATE KEY",
247
+ Bytes: pkcs8,
248
+ })
249
+ if err := writeFileAtomic(path, pemData, 0o600); err != nil {
250
+ return nil, fmt.Errorf("persist ACME account key: %w", err)
251
+ }
252
+ return key, nil
253
+}
254
+
255
+func parsePEMPrivateKey(keyPEM []byte) (crypto.PrivateKey, error) {
256
+ block, _ := pem.Decode(keyPEM)
257
+ if block == nil {
258
+ return nil, errors.New("invalid private key PEM")
259
+ }
260
+
261
+ if key, err := x509.ParsePKCS8PrivateKey(block.Bytes); err == nil {
262
+ switch typed := key.(type) {
263
+ case *ecdsa.PrivateKey:
264
+ return typed, nil
265
+ case *rsa.PrivateKey:
266
+ return typed, nil
267
+ }
268
+ }
269
+ if key, err := x509.ParseECPrivateKey(block.Bytes); err == nil {
270
+ return key, nil
271
+ }
272
+ if key, err := x509.ParsePKCS1PrivateKey(block.Bytes); err == nil {
273
+ return key, nil
274
+ }
275
+ return nil, errors.New("unsupported private key type")
276
+}
277
+
278
+func loadRegistration(path string) (*registration.Resource, error) {
279
+ raw, err := os.ReadFile(path)
280
+ if err != nil {
281
+ if errors.Is(err, os.ErrNotExist) {
282
+ return nil, nil
283
+ }
284
+ return nil, err
285
+ }
286
+ var reg registration.Resource
287
+ if err := json.Unmarshal(raw, ®); err != nil {
288
+ return nil, err
289
+ }
290
+ return ®, nil
291
+}
292
+
293
+func saveRegistration(path string, reg *registration.Resource) error {
294
+ if reg == nil {
295
+ return nil
296
+ }
297
+ raw, err := json.MarshalIndent(reg, "", " ")
298
+ if err != nil {
299
+ return err
300
+ }
301
+ return writeFileAtomic(path, raw, 0o600)
302
+}
303
+
304
+func ensureParentDir(path string) error {
305
+ dir := filepath.Dir(path)
306
+ if dir == "" || dir == "." {
307
+ return nil
308
+ }
309
+ if err := os.MkdirAll(dir, 0o700); err != nil {
310
+ return fmt.Errorf("create directory %s: %w", dir, err)
311
+ }
312
+ return nil
313
+}
314
+
315
+func fileExists(path string) bool {
316
+ if strings.TrimSpace(path) == "" {
317
+ return false
318
+ }
319
+ _, err := os.Stat(path)
320
+ return err == nil
321
+}
322
+
323
+func writeFileAtomic(path string, data []byte, mode os.FileMode) error {
324
+ dir := filepath.Dir(path)
325
+ if dir == "" {
326
+ dir = "."
327
+ }
328
+ tmp, err := os.CreateTemp(dir, ".tmp-*")
329
+ if err != nil {
330
+ return err
331
+ }
332
+ tmpName := tmp.Name()
333
+ defer func() {
334
+ _ = os.Remove(tmpName)
335
+ }()
336
+
337
+ if _, err := tmp.Write(data); err != nil {
338
+ _ = tmp.Close()
339
+ return err
340
+ }
341
+ if err := tmp.Chmod(mode); err != nil {
342
+ _ = tmp.Close()
343
+ return err
344
+ }
345
+ if err := tmp.Close(); err != nil {
346
+ return err
347
+ }
348
+ _ = os.Remove(path)
349
+ if err := os.Rename(tmpName, path); err != nil {
350
+ return err
351
+ }
352
+ return os.Chmod(path, mode)
353
+}
354
+
355
+func hasCloudflareCredentials(cloudflareToken string) bool {
356
+ return strings.TrimSpace(cloudflareToken) != ""
357
+}
358
+
359
+func extractBaseDomainForKeyless(portalURL string) string {
360
+ raw := strings.TrimSpace(portalURL)
361
+ if raw == "" {
362
+ return ""
363
+ }
364
+ if !strings.Contains(raw, "://") {
365
+ raw = "https://" + raw
366
+ }
367
+
368
+ parsed, err := url.Parse(raw)
369
+ if err != nil {
370
+ return ""
371
+ }
372
+ if parsed.Hostname() == "" {
373
+ return ""
374
+ }
375
+
376
+ host := strings.TrimPrefix(strings.ToLower(strings.TrimSpace(parsed.Hostname())), "*.")
377
+ parts := strings.Split(host, ".")
378
+ if len(parts) < 2 {
379
+ return ""
380
+ }
381
+ return parts[len(parts)-2] + "." + parts[len(parts)-1]
382
+}
portal/relay.go
+7
-1
@@ -35,6 +35,7 @@ func NewRelayServer(
35
sniPort string,
36
portalURL string,
37
keylessKey string,
38
+ cloudflareToken string,
39
) (*RelayServer, error) {
40
baseDomain := extractBaseDomain(portalURL)
41
if baseDomain == "" {
@@ -50,8 +51,13 @@ func NewRelayServer(
51
stopch: make(chan struct{}),
52
}
53
54
+ keyFile, err := keyless.EnsureSigningKey(ctx, portalURL, keylessKey, cloudflareToken)
55
+ if err != nil {
56
+ return nil, fmt.Errorf("ensure keyless signing key: %w", err)
57
+ }
58
+
59
signer, err := keyless.NewSigner(keyless.Config{
54
- KeyFile: keylessKey,
60
+ KeyFile: keyFile,
61
})
62
if err != nil {
63
return nil, fmt.Errorf("configure keyless signer: %w", err)