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, &reg); err != nil {
288 + return nil, err
289 + }
290 + return &reg, 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)