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()