refactor(relay): split ACME package and simplify TLS wiring

rabbitprincess committed Mar 2, 2026 at 11:32 UTC 0df7278229d02124c33bc35c9134f4265da0d259
9 files changed +158 -60
.gitignore
+1
@@ -13,6 +13,7 @@ chat
13
14 cmd/relay-server/dist/app
15 cmd/relay-server/dist/wasm
16 +data
17
18 # Test binary, built with `go test -c`
19 *.test
AGENTS.md
+1 -1
@@ -77,7 +77,7 @@ Portal uses environment variables for domain and TLS configuration:
77 |----------|-------------|
78 | `PORTAL_URL` | Base URL (e.g., `https://portal.example.com`) |
79 | `BOOTSTRAP_URIS` | Relay API URLs (defaults to `PORTAL_URL`) |
80 -| `SNI_PORT` | SNI router port (default `:443`) |
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 |
README.md
+3 -1
@@ -34,7 +34,9 @@ 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.
37 +# relay issues keyless certs via ACME DNS-01.
38 +# when KEYLESS_KEY_FILE and sibling fullchain.pem both exist,
39 +# admin/API listener on --adminport auto-switches to HTTPS.
40 CLOUDFLARE_TOKEN=your-cloudflare-dns-token docker compose up
41 ```
42
cmd/relay-server/main.go
+34 -17
@@ -7,6 +7,7 @@ import (
7 "net"
8 "os"
9 "os/signal"
10 + "strconv"
11 "strings"
12 "syscall"
13 "time"
@@ -20,9 +21,9 @@ import (
21 )
22
23 const (
23 - defaultHTTPPort = 4017
24 + defaultAPIPort = 4017
25 + defaultSNIPort = 443
26 defaultPortalURL = "http://localhost:4017"
25 - defaultSNIPort = ":443"
27 defaultKeylessKeyFile = "/etc/portal/keyless/privkey.pem"
28 )
29
@@ -35,7 +36,7 @@ type relayServerConfig struct {
36 LeaseBPS int
37 PortalURL string
38 Bootstraps []string
38 - SNIPort string
39 + SNIPort int
40
41 KeylessKeyFile string
42 CloudflareToken string
@@ -54,24 +55,21 @@ func main() {
55 if bootstrapsCSV == "" {
56 bootstrapsCSV = defaultBootstrapFrom(portalURL)
57 }
57 - sniPort := strings.TrimSpace(os.Getenv("SNI_PORT"))
58 - if sniPort == "" {
59 - sniPort = defaultSNIPort
60 - }
61 - keylessKey := strings.TrimSpace(os.Getenv("KEYLESS_KEY_FILE"))
62 - if keylessKey == "" {
63 - keylessKey = defaultKeylessKeyFile
58 + sniPort := parsePortNumber(os.Getenv("SNI_PORT"), defaultSNIPort, "SNI_PORT")
59 + keylessFile := strings.TrimSpace(os.Getenv("KEYLESS_KEY_FILE"))
60 + if keylessFile == "" {
61 + keylessFile = defaultKeylessKeyFile
62 }
63 adminSecretKey := strings.TrimSpace(os.Getenv("ADMIN_SECRET_KEY"))
64 cloudflareToken := strings.TrimSpace(os.Getenv("CLOUDFLARE_TOKEN"))
65
68 - flag.IntVar(&cfg.AdminPort, "adminport", defaultHTTPPort, "Admin/HTTP server port")
66 + flag.IntVar(&cfg.AdminPort, "adminport", defaultAPIPort, "Admin/HTTP server port")
67 flag.StringVar(&cfg.AdminSecretKey, "admin-secret-key", adminSecretKey, "admin auth secret (env: ADMIN_SECRET_KEY)")
68 flag.IntVar(&cfg.LeaseBPS, "lease-bps", 0, "bytes-per-second limit per lease (0=unlimited)")
69 flag.StringVar(&cfg.PortalURL, "portal-url", portalURL, "portal base URL (env: PORTAL_URL)")
70 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)")
71 + flag.IntVar(&cfg.SNIPort, "sni-port", sniPort, "SNI router port number (env: SNI_PORT)")
72 + flag.StringVar(&cfg.KeylessKeyFile, "keyless-key-file", keylessFile, "PEM private key path for relay keyless signer (env: KEYLESS_KEY_FILE)")
73 flag.StringVar(&cfg.CloudflareToken, "cloudflare-token", cloudflareToken, "Cloudflare DNS API token (Zone:Read + DNS:Edit) (env: CLOUDFLARE_TOKEN)")
74 flag.Parse()
75
@@ -85,13 +83,14 @@ func main() {
83 func runServer(cfg relayServerConfig) error {
84 ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
85 defer stop()
86 + sniListenAddr := fmt.Sprintf(":%d", cfg.SNIPort)
87
88 log.Info().
89 Str("portal_base_url", cfg.PortalURL).
90 Strs("bootstrap_uris", cfg.Bootstraps).
91 Msg("[server] frontend configuration")
92
94 - serv, err := portal.NewRelayServer(ctx, cfg.Bootstraps, cfg.SNIPort, cfg.PortalURL, cfg.KeylessKeyFile, cfg.CloudflareToken)
93 + serv, err := portal.NewRelayServer(ctx, cfg.Bootstraps, sniListenAddr, cfg.PortalURL, cfg.KeylessKeyFile, cfg.CloudflareToken)
94 if err != nil {
95 return fmt.Errorf("create relay server: %w", err)
96 }
@@ -140,15 +139,15 @@ func runServer(cfg relayServerConfig) error {
139 }
140 defer serv.Stop()
141
143 - httpSrv := serveHTTP(fmt.Sprintf(":%d", cfg.AdminPort), serv, admin, frontend, stop)
142 + apiServ := serveAPI(fmt.Sprintf(":%d", cfg.AdminPort), serv, admin, frontend, stop)
143
144 <-ctx.Done()
145 log.Info().Msg("[server] shutting down...")
146
147 shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
148 defer cancel()
150 - if httpSrv != nil {
151 - if err := httpSrv.Shutdown(shutdownCtx); err != nil {
149 + if apiServ != nil {
150 + if err := apiServ.Shutdown(shutdownCtx); err != nil {
151 log.Error().Err(err).Msg("[server] http server shutdown error")
152 }
153 }
@@ -156,3 +155,21 @@ func runServer(cfg relayServerConfig) error {
155 log.Info().Msg("[server] shutdown complete")
156 return nil
157 }
158 +
159 +func parsePortNumber(raw string, fallback int, source string) int {
160 + value := strings.TrimSpace(raw)
161 + if value == "" {
162 + return fallback
163 + }
164 + value = strings.TrimPrefix(value, ":")
165 + port, err := strconv.Atoi(value)
166 + if err != nil || port < 1 || port > 65535 {
167 + log.Warn().
168 + Str("source", source).
169 + Str("value", raw).
170 + Int("fallback_port", fallback).
171 + Msg("[server] invalid port value; using fallback")
172 + return fallback
173 + }
174 + return port
175 +}
cmd/relay-server/serve.go
+17 -10
@@ -22,14 +22,8 @@ import (
22 //go:embed dist/*
23 var distFS embed.FS
24
25 -// serveHTTP builds the HTTP mux and returns the server.
26 -func serveHTTP(
27 - addr string,
28 - serv *portal.RelayServer,
29 - admin *Admin,
30 - frontend *Frontend,
31 - cancel context.CancelFunc,
32 -) *http.Server {
25 +// serveAPI builds the admin/API mux and returns the server.
26 +func serveAPI(addr string, serv *portal.RelayServer, admin *Admin, frontend *Frontend, cancel context.CancelFunc) *http.Server {
27 if addr == "" {
28 addr = ":0"
29 }
@@ -128,10 +122,23 @@ func serveHTTP(
122 Addr: addr,
123 Handler: handler,
124 }
125 + acmeManager := serv.GetACMEManager()
126 + tlsCertFile, tlsKeyFile := acmeManager.TLSFiles()
127
128 go func() {
133 - log.Info().Msgf("[server] http: %s", addr)
134 - if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
129 + var err error
130 + if tlsCertFile != "" && tlsKeyFile != "" {
131 + log.Info().
132 + Str("addr", addr).
133 + Str("cert_file", tlsCertFile).
134 + Str("key_file", tlsKeyFile).
135 + Msg("[server] admin https enabled")
136 + err = srv.ListenAndServeTLS(tlsCertFile, tlsKeyFile)
137 + } else {
138 + log.Info().Msgf("[server] http: %s", addr)
139 + err = srv.ListenAndServe()
140 + }
141 + if err != nil && err != http.ErrServerClosed {
142 log.Error().Err(err).Msg("[server] http error")
143 cancel()
144 }
docker-compose.yml
+4 -3
@@ -1,7 +1,6 @@
1 services:
2 portal:
3 image: ghcr.io/gosuda/portal:1
4 - pull_policy: always
4 build:
5 context: .
6 dockerfile: Dockerfile
@@ -14,12 +13,14 @@ services:
13 BOOTSTRAP_URIS: ${BOOTSTRAP_URIS:-http://localhost:${ADMIN_PORT:-4017}}
14 ADMIN_SECRET_KEY: ${ADMIN_SECRET_KEY:-}
15
17 - # TLS/SNI and keyless configuration
18 - SNI_PORT: ${SNI_PORT:-:443}
16 + # TLS/SNI and keyless configurationa
17 + SNI_PORT: ${SNI_PORT:-443}
18 KEYLESS_KEY_FILE: ${KEYLESS_KEY_FILE:-/etc/portal/keyless/privkey.pem}
19 CLOUDFLARE_TOKEN: ${CLOUDFLARE_TOKEN:-}
20 ports:
21 - "${ADMIN_PORT:-4017}:${ADMIN_PORT:-4017}"
22 - "80:80"
23 - "443:443"
24 + volumes:
25 + - ./data/keyless:/etc/portal/keyless
26 restart: unless-stopped
docs/portal-deploy-guide.md
+4 -3
@@ -9,6 +9,7 @@ Portal uses SNI-based TLS passthrough: the relay routes TLS by SNI to tunnel bac
9 TLS certificate mode:
10 - `self`: tunnel uses locally managed certificate and key files.
11 - `keyless`: tunnel delegates TLS signing to relay keyless signer (`/v1/sign`). Relay uses `KEYLESS_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.
13
14 ```
15 Client ──TLS──► Relay (SNI Router :443) ──TLS──► Tunnel Backend (TLS mode)
@@ -79,8 +80,8 @@ https://myapp.example.com
80 | `PORTAL_URL` | `http://localhost:4017` | Base URL (e.g., `https://example.com`) |
81 | `BOOTSTRAP_URIS` | (derived) | Relay API URLs |
82 | `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 |
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) |
85 | `CLOUDFLARE_TOKEN` | (empty) | Cloudflare DNS API token used for ACME DNS-01 auto issuance |
86
87 ## docker-compose.yml
@@ -95,7 +96,7 @@ services:
96 environment:
97 PORTAL_URL: ${PORTAL_URL}
98 ADMIN_SECRET_KEY: ${ADMIN_SECRET_KEY}
98 - SNI_PORT: ${SNI_PORT:-:443}
99 + SNI_PORT: ${SNI_PORT:-443}
100 KEYLESS_KEY_FILE: ${KEYLESS_KEY_FILE:-/etc/portal/keyless/privkey.pem}
101 CLOUDFLARE_TOKEN: ${CLOUDFLARE_TOKEN:-}
102 ports:
portal/acme/acme.go renamed
+80 -23
@@ -1,4 +1,4 @@
1 -package keyless
1 +package acme
2
3 import (
4 "context"
@@ -25,7 +25,14 @@ import (
25 "github.com/rs/zerolog/log"
26 )
27
28 -type acmeProvisionConfig struct {
28 +const (
29 + fullChainFileName = "fullchain.pem"
30 + accountKeyFileName = "acme-account.key"
31 + registrationFileName = "acme-registration.json"
32 + defaultACMEEmailPrefix = "acme@"
33 +)
34 +
35 +type provisionConfig struct {
36 KeyFile string
37 CertFile string
38 Email string
@@ -35,6 +42,33 @@ type acmeProvisionConfig struct {
42 CloudflareToken string
43 }
44
45 +type Config struct {
46 + PortalURL string
47 + KeyFile string
48 + CloudflareToken string
49 +}
50 +
51 +type Manager struct {
52 + cfg Config
53 +}
54 +
55 +func NewManager(cfg Config) *Manager {
56 + return &Manager{
57 + cfg: Config{
58 + PortalURL: strings.TrimSpace(cfg.PortalURL),
59 + KeyFile: strings.TrimSpace(cfg.KeyFile),
60 + CloudflareToken: strings.TrimSpace(cfg.CloudflareToken),
61 + },
62 + }
63 +}
64 +
65 +func (m *Manager) keyFile() string {
66 + if m == nil {
67 + return ""
68 + }
69 + return strings.TrimSpace(m.cfg.KeyFile)
70 +}
71 +
72 type acmeUser struct {
73 Email string
74 Registration *registration.Resource
@@ -63,8 +97,11 @@ func (u *acmeUser) GetPrivateKey() crypto.PrivateKey {
97 }
98
99 // 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)
100 +func (m *Manager) EnsureSigningKey(ctx context.Context) (string, error) {
101 + if m == nil {
102 + return "", errors.New("acme manager is nil")
103 + }
104 + keyFile := m.keyFile()
105 if keyFile == "" {
106 return "", nil
107 }
@@ -75,19 +112,19 @@ func EnsureSigningKey(ctx context.Context, portalURL, keyFile, cloudflareToken s
112 if fileExists(keyFile) {
113 return keyFile, nil
114 }
78 - if !hasCloudflareCredentials(cloudflareToken) {
115 + if !hasCloudflareToken(m.cfg.CloudflareToken) {
116 log.Warn().
117 Str("key_file", keyFile).
118 Msg("[signer] keyless key file is missing and Cloudflare credentials are not set; signer will stay disabled")
119 return keyFile, nil
120 }
121
85 - baseDomain := extractBaseDomainForKeyless(portalURL)
122 + baseDomain := extractBaseDomain(m.cfg.PortalURL)
123 if baseDomain == "" {
124 return "", fmt.Errorf("derive base domain from PORTAL_URL for ACME provisioning")
125 }
126
90 - cfg, err := buildACMEProvisionConfig(baseDomain, keyFile, cloudflareToken)
127 + cfg, err := buildProvisionConfig(baseDomain, keyFile, m.cfg.CloudflareToken)
128 if err != nil {
129 return "", err
130 }
@@ -98,28 +135,44 @@ func EnsureSigningKey(ctx context.Context, portalURL, keyFile, cloudflareToken s
135 Str("cert_file", cfg.CertFile).
136 Msg("[signer] keyless key is missing; issuing certificate with ACME DNS-01 via Cloudflare")
137
101 - if err := provisionKeylessCertificate(cfg); err != nil {
138 + if err := m.provisionCertificate(cfg); err != nil {
139 return "", err
140 }
141 return keyFile, nil
142 }
143
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")
144 +// TLSFiles returns fullchain and private key file paths when both exist.
145 +func (m *Manager) TLSFiles() (string, string) {
146 + if m == nil {
147 + return "", ""
148 + }
149 + keyFile := m.keyFile()
150 + if keyFile == "" {
151 + return "", ""
152 + }
153 + certFile := fullChainPath(keyFile)
154 + if _, err := os.Stat(certFile); err != nil {
155 + return "", ""
156 + }
157 + if _, err := os.Stat(keyFile); err != nil {
158 + return "", ""
159 + }
160 + return certFile, keyFile
161 +}
162
115 - email := "acme@" + baseDomain
163 +func buildProvisionConfig(baseDomain, keyFile, cloudflareToken string) (provisionConfig, error) {
164 + keyDir := filepath.Dir(keyFile)
165 + certFile := fullChainPath(keyFile)
166 + accountKeyFile := filepath.Join(keyDir, accountKeyFileName)
167 + registrationFile := filepath.Join(keyDir, registrationFileName)
168 + email := defaultACMEEmailPrefix + baseDomain
169
117 - domains, err := resolveACMEDomains(baseDomain)
170 + domains, err := resolveDomains(baseDomain)
171 if err != nil {
119 - return acmeProvisionConfig{}, err
172 + return provisionConfig{}, err
173 }
174
122 - return acmeProvisionConfig{
175 + return provisionConfig{
176 KeyFile: keyFile,
177 CertFile: certFile,
178 Email: email,
@@ -130,7 +183,7 @@ func buildACMEProvisionConfig(baseDomain, keyFile, cloudflareToken string) (acme
183 }, nil
184 }
185
133 -func resolveACMEDomains(baseDomain string) ([]string, error) {
186 +func resolveDomains(baseDomain string) ([]string, error) {
187 domain := strings.ToLower(strings.TrimSpace(baseDomain))
188 if domain == "" {
189 return nil, errors.New("base domain is required")
@@ -138,7 +191,7 @@ func resolveACMEDomains(baseDomain string) ([]string, error) {
191 return []string{domain, "*." + domain}, nil
192 }
193
141 -func provisionKeylessCertificate(cfg acmeProvisionConfig) error {
194 +func (m *Manager) provisionCertificate(cfg provisionConfig) error {
195 for _, path := range []string{cfg.KeyFile, cfg.CertFile, cfg.AccountKeyFile, cfg.RegistrationFile} {
196 if err := ensureParentDir(path); err != nil {
197 return err
@@ -352,11 +405,11 @@ func writeFileAtomic(path string, data []byte, mode os.FileMode) error {
405 return os.Chmod(path, mode)
406 }
407
355 -func hasCloudflareCredentials(cloudflareToken string) bool {
408 +func hasCloudflareToken(cloudflareToken string) bool {
409 return strings.TrimSpace(cloudflareToken) != ""
410 }
411
359 -func extractBaseDomainForKeyless(portalURL string) string {
412 +func extractBaseDomain(portalURL string) string {
413 raw := strings.TrimSpace(portalURL)
414 if raw == "" {
415 return ""
@@ -380,3 +433,7 @@ func extractBaseDomainForKeyless(portalURL string) string {
433 }
434 return parts[len(parts)-2] + "." + parts[len(parts)-1]
435 }
436 +
437 +func fullChainPath(keyFile string) string {
438 + return filepath.Join(filepath.Dir(keyFile), fullChainFileName)
439 +}
portal/relay.go
+14 -2
@@ -11,6 +11,7 @@ import (
11
12 "github.com/rs/zerolog/log"
13
14 + "gosuda.org/portal/portal/acme"
15 "gosuda.org/portal/portal/keyless"
16 "gosuda.org/portal/portal/sni"
17 )
@@ -22,6 +23,7 @@ type RelayServer struct {
23 leaseManager *LeaseManager
24 reverseHub *ReverseHub
25 sniRouter *sni.Router
26 + acmeManager *acme.Manager
27 keylessSigner *keyless.Signer
28
29 stopch chan struct{}
@@ -48,10 +50,15 @@ func NewRelayServer(
50 leaseManager: NewLeaseManager(30 * time.Second),
51 reverseHub: NewReverseHub(),
52 sniRouter: sni.NewRouter(sniPort),
51 - stopch: make(chan struct{}),
53 + acmeManager: acme.NewManager(acme.Config{
54 + PortalURL: portalURL,
55 + KeyFile: keylessKey,
56 + CloudflareToken: cloudflareToken,
57 + }),
58 + stopch: make(chan struct{}),
59 }
60
54 - keyFile, err := keyless.EnsureSigningKey(ctx, portalURL, keylessKey, cloudflareToken)
61 + keyFile, err := server.acmeManager.EnsureSigningKey(ctx)
62 if err != nil {
63 return nil, fmt.Errorf("ensure keyless signing key: %w", err)
64 }
@@ -126,6 +133,11 @@ func (g *RelayServer) GetKeylessSigner() *keyless.Signer {
133 return g.keylessSigner
134 }
135
136 +// GetACMEManager returns relay ACME manager.
137 +func (g *RelayServer) GetACMEManager() *acme.Manager {
138 + return g.acmeManager
139 +}
140 +
141 // Start starts the relay server.
142 func (g *RelayServer) Start() error {
143 g.leaseManager.Start()