feat: add optional relay pprof server
송인서 committed
May 2, 2026 at 11:45 UTC
6043dedfec038643048030bd54057d8cfe6711f4
5 files changed
+121
-6
cmd/relay-server/main.go
+8
@@ -47,6 +47,8 @@ type relayServerConfig struct {
47
MaxPort int
48
LandingPageEnabled bool
49
HeadlessShellURL string
50
+ PProfEnabled bool
51
+ PProfAddr string
52
53
ACMEDNSProvider string
54
ENSGaslessEnabled bool
@@ -83,6 +85,8 @@ func runServeCommand(args []string) error {
85
86
utils.BoolFlagEnv(fs, &cfg.LandingPageEnabled, "landing-page-enabled", false, "enable landing page by default when no admin setting has been saved yet", "LANDING_PAGE_ENABLED")
87
utils.StringFlagEnv(fs, &cfg.HeadlessShellURL, "headless-shell-url", "", "headless Chrome CDP WebSocket URL for thumbnail generation (e.g. ws://headless-shell:9222)", "HEADLESS_SHELL_URL")
88
+ utils.BoolFlagEnv(fs, &cfg.PProfEnabled, "pprof-enabled", false, "enable pprof diagnostics HTTP server", "PPROF_ENABLED")
89
+ utils.StringFlagEnv(fs, &cfg.PProfAddr, "pprof-addr", portal.DefaultPProfListenAddr, "pprof diagnostics listen address when enabled", "PPROF_ADDR")
90
91
utils.StringFlagEnv(fs, &cfg.ACMEDNSProvider, "acme-dns-provider", "", "ACME DNS provider for managed DNS-01/A-record sync and ENS gasless DNSSEC/TXT automation (cloudflare|gcloud|route53); leave empty to use manual fullchain.pem/privatekey.pem from IDENTITY_PATH", "ACME_DNS_PROVIDER")
92
utils.BoolFlagEnv(fs, &cfg.ENSGaslessEnabled, "ens-gasless-enabled", false, "enable ENS gasless DNS import automation for the managed DNS zone and lease hostnames", "ENS_GASLESS_ENABLED")
@@ -125,6 +129,8 @@ func runServeCommand(args []string) error {
129
Int("max_port", cfg.MaxPort).
130
Bool("landing_page_enabled", cfg.LandingPageEnabled).
131
Bool("headless_shell_enabled", strings.TrimSpace(cfg.HeadlessShellURL) != "").
132
+ Bool("pprof_enabled", cfg.PProfEnabled).
133
+ Str("pprof_addr", cfg.PProfAddr).
134
Str("acme_dns_provider", cfg.ACMEDNSProvider).
135
Bool("ens_gasless_enabled", cfg.ENSGaslessEnabled).
136
Msg("configured relay server")
@@ -150,6 +156,8 @@ func runServer(ctx context.Context, cfg relayServerConfig) error {
156
TCPEnabled: cfg.TCPEnabled,
157
MinPort: cfg.MinPort,
158
MaxPort: cfg.MaxPort,
159
+ PProfEnabled: cfg.PProfEnabled,
160
+ PProfListenAddr: cfg.PProfAddr,
161
ACME: acme.Config{
162
KeyDir: cfg.IdentityPath,
163
DNSProvider: cfg.ACMEDNSProvider,
docker-compose.yml
+6
@@ -21,6 +21,8 @@ services:
21
# - "${SNI_PORT:-443}:${SNI_PORT:-443}/udp"
22
# - "${MIN_PORT:-40000}-${MAX_PORT:-40009}:${MIN_PORT:-40000}-${MAX_PORT:-40009}/udp"
23
# - "${MIN_PORT:-40000}-${MAX_PORT:-40009}:${MIN_PORT:-40000}-${MAX_PORT:-40009}"
24
+ # Uncomment with PPROF_ENABLED=true and PPROF_ADDR=:6060 to inspect pprof from the host.
25
+ # - "${PPROF_PORT:-6060}:${PPROF_PORT:-6060}"
26
environment:
27
# Public routing, discovery, and relay identity persistence
28
PORTAL_URL: ${PORTAL_URL:-https://localhost:${API_PORT:-4017}}
@@ -47,6 +49,10 @@ services:
49
# Optional: auto-generated thumbnails (requires headless-shell sidecar above)
50
# HEADLESS_SHELL_URL: ${HEADLESS_SHELL_URL:-ws://headless-shell:9222}
51
52
+ # Optional diagnostics; keep loopback unless the pprof port is protected.
53
+ PPROF_ENABLED: ${PPROF_ENABLED:-false}
54
+ PPROF_ADDR: ${PPROF_ADDR:-127.0.0.1:6060}
55
+
56
# TLS/ACME materials
57
ACME_DNS_PROVIDER: ${ACME_DNS_PROVIDER:-}
58
ENS_GASLESS_ENABLED: ${ENS_GASLESS_ENABLED:-false}
docs/src/routes/configuration/+page.md
+7
@@ -58,6 +58,13 @@ The relay server (`relay-server`) reads configuration from environment variables
58
|----------|---------|------|-------------|
59
| `HEADLESS_SHELL_URL` | `""` | string | Headless Chrome CDP WebSocket URL for thumbnail generation (e.g. `ws://headless-shell:9222`) |
60
61
+### Diagnostics
62
+
63
+| Variable | Default | Type | Description |
64
+|----------|---------|------|-------------|
65
+| `PPROF_ENABLED` | `false` | bool | Enable the relay pprof diagnostics HTTP server |
66
+| `PPROF_ADDR` | `127.0.0.1:6060` | string | pprof listen address when enabled; keep it on loopback unless the port is protected |
67
+
68
### Cloudflare
69
70
| Variable | Default | Type | Description |
portal/server.go
+61
-6
@@ -8,6 +8,7 @@ import (
8
"io"
9
"net"
10
"net/http"
11
+ "net/http/pprof"
12
"strings"
13
"sync"
14
"time"
@@ -33,6 +34,7 @@ const (
34
defaultClientHelloWait = 2 * time.Second
35
defaultControlBodyLimit = 4 << 20
36
defaultHopOpenRetryWait = 250 * time.Millisecond
37
+ DefaultPProfListenAddr = "127.0.0.1:6060"
38
)
39
40
type ServerConfig struct {
@@ -51,6 +53,8 @@ type ServerConfig struct {
53
TCPEnabled bool
54
MinPort int
55
MaxPort int
56
+ PProfEnabled bool
57
+ PProfListenAddr string
58
ACME acme.Config
59
}
60
@@ -82,6 +86,9 @@ func normalizeServerConfig(cfg ServerConfig) (ServerConfig, error) {
86
cfg.WireGuardPort = utils.IntOrDefault(cfg.WireGuardPort, overlay.DefaultListenPort)
87
cfg.APIListenAddr = utils.StringOrDefault(cfg.APIListenAddr, fmt.Sprintf(":%d", cfg.APIPort))
88
cfg.SNIListenAddr = utils.StringOrDefault(cfg.SNIListenAddr, fmt.Sprintf(":%d", cfg.SNIPort))
89
+ if cfg.PProfEnabled {
90
+ cfg.PProfListenAddr = utils.StringOrDefault(strings.TrimSpace(cfg.PProfListenAddr), DefaultPProfListenAddr)
91
+ }
92
93
hasPortRange := cfg.MinPort > 0 && cfg.MaxPort > 0
94
if cfg.UDPEnabled || cfg.TCPEnabled {
@@ -110,11 +117,13 @@ type Server struct {
117
acmeManager *acme.Manager
118
proxy proxy
119
113
- apiListener net.Listener
114
- sniListener net.Listener
115
- apiServer *http.Server
116
- apiTLSClose io.Closer
117
- quicBackhaul *quic.Listener
120
+ apiListener net.Listener
121
+ sniListener net.Listener
122
+ apiServer *http.Server
123
+ apiTLSClose io.Closer
124
+ pprofListener net.Listener
125
+ pprofServer *http.Server
126
+ quicBackhaul *quic.Listener
127
128
overlay *overlay.Overlay
129
hopMux *overlay.HopMux
@@ -173,6 +182,8 @@ func (s *Server) Start(ctx context.Context, apiMux *http.ServeMux) error {
182
var sniListener net.Listener
183
var apiServer *http.Server
184
var apiCloser io.Closer
185
+ var pprofListener net.Listener
186
+ var pprofServer *http.Server
187
var hopMux *overlay.HopMux
188
var ov *overlay.Overlay
189
var quicBackhaul *quic.Listener
@@ -190,6 +201,12 @@ func (s *Server) Start(ctx context.Context, apiMux *http.ServeMux) error {
201
if apiServer != nil {
202
_ = apiServer.Close()
203
}
204
+ if pprofServer != nil {
205
+ _ = pprofServer.Close()
206
+ }
207
+ if pprofListener != nil {
208
+ _ = pprofListener.Close()
209
+ }
210
if apiCloser != nil {
211
_ = apiCloser.Close()
212
}
@@ -217,6 +234,22 @@ func (s *Server) Start(ctx context.Context, apiMux *http.ServeMux) error {
234
if err != nil {
235
return err
236
}
237
+ if s.cfg.PProfEnabled {
238
+ pprofListener, err = listenConfig.Listen(serverCtx, "tcp", s.cfg.PProfListenAddr)
239
+ if err != nil {
240
+ return fmt.Errorf("listen pprof: %w", err)
241
+ }
242
+ pprofMux := http.NewServeMux()
243
+ pprofMux.HandleFunc("/debug/pprof/", pprof.Index)
244
+ pprofMux.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline)
245
+ pprofMux.HandleFunc("/debug/pprof/profile", pprof.Profile)
246
+ pprofMux.HandleFunc("/debug/pprof/symbol", pprof.Symbol)
247
+ pprofMux.HandleFunc("/debug/pprof/trace", pprof.Trace)
248
+ pprofServer = &http.Server{
249
+ Handler: pprofMux,
250
+ ReadHeaderTimeout: 10 * time.Second,
251
+ }
252
+ }
253
254
if s.relaySet != nil && strings.TrimSpace(s.identity.WireGuardPrivateKey) != "" {
255
ov, err = s.startOverlay()
@@ -240,6 +273,8 @@ func (s *Server) Start(ctx context.Context, apiMux *http.ServeMux) error {
273
s.sniListener = sniListener
274
s.apiServer = apiServer
275
s.apiTLSClose = apiCloser
276
+ s.pprofListener = pprofListener
277
+ s.pprofServer = pprofServer
278
s.acmeManager = acmeManager
279
s.cancel = cancel
280
s.group = group
@@ -249,6 +284,9 @@ func (s *Server) Start(ctx context.Context, apiMux *http.ServeMux) error {
284
started = true
285
286
group.Go(s.runAPIServer)
287
+ if s.pprofServer != nil {
288
+ group.Go(s.runPProfServer)
289
+ }
290
group.Go(func() error { return s.runPublicIngress(groupCtx) })
291
if s.overlay != nil {
292
group.Go(s.overlay.Serve)
@@ -282,7 +320,11 @@ func (s *Server) Start(ctx context.Context, apiMux *http.ServeMux) error {
320
Bool("wireguard_enabled", s.overlay != nil).
321
Bool("multihop_enabled", s.hopMux != nil).
322
Bool("udp_enabled", s.quicBackhaul != nil).
285
- Bool("tcp_enabled", s.cfg.TCPEnabled)
323
+ Bool("tcp_enabled", s.cfg.TCPEnabled).
324
+ Bool("pprof_enabled", s.pprofServer != nil)
325
+ if s.pprofListener != nil {
326
+ logEvent = logEvent.Str("pprof_addr", utils.HostPortOrLoopback(s.pprofListener.Addr().String()))
327
+ }
328
if s.quicBackhaul != nil {
329
logEvent = logEvent.Str("internal_quic_backhaul_addr", s.quicBackhaul.Addr().String())
330
}
@@ -361,6 +403,11 @@ func (s *Server) Shutdown(ctx context.Context) error {
403
shutdownErr = err
404
}
405
}
406
+ if s.pprofServer != nil {
407
+ if err := s.pprofServer.Shutdown(ctx); err != nil && shutdownErr == nil {
408
+ shutdownErr = err
409
+ }
410
+ }
411
if s.hopMux != nil {
412
if err := s.hopMux.Close(); err != nil && shutdownErr == nil && !errors.Is(err, net.ErrClosed) {
413
shutdownErr = err
@@ -418,6 +465,14 @@ func (s *Server) runAPIServer() error {
465
return err
466
}
467
468
+func (s *Server) runPProfServer() error {
469
+ err := s.pprofServer.Serve(s.pprofListener)
470
+ if err == nil || errors.Is(err, http.ErrServerClosed) || errors.Is(err, net.ErrClosed) {
471
+ return nil
472
+ }
473
+ return err
474
+}
475
+
476
func (s *Server) runPublicIngress(ctx context.Context) error {
477
for {
478
conn, err := s.sniListener.Accept()
portal/server_test.go
+39
@@ -172,6 +172,45 @@ func TestServerStartInitializesLocalACMEAndSigner(t *testing.T) {
172
}
173
}
174
175
+func TestServerStartEnablesPProfOnSeparateHTTPListener(t *testing.T) {
176
+ t.Parallel()
177
+
178
+ server, err := NewServer(ServerConfig{
179
+ PortalURL: "https://localhost:4017",
180
+ IdentityPath: tempIdentityPath(t),
181
+ ACME: acme.Config{KeyDir: t.TempDir()},
182
+ APIListenAddr: "127.0.0.1:0",
183
+ SNIListenAddr: "127.0.0.1:0",
184
+ PProfEnabled: true,
185
+ PProfListenAddr: "127.0.0.1:0",
186
+ })
187
+ if err != nil {
188
+ t.Fatalf("NewServer() error = %v", err)
189
+ }
190
+
191
+ ctx, cancel := context.WithCancel(context.Background())
192
+ defer cancel()
193
+
194
+ if err := server.Start(ctx, nil); err != nil {
195
+ t.Fatalf("Start() error = %v", err)
196
+ }
197
+
198
+ client := newTestClient(t, cancel, server)
199
+ if server.pprofListener == nil {
200
+ t.Fatal("pprofListener = nil, want listener")
201
+ }
202
+
203
+ resp, err := client.Get("http://" + utils.HostPortOrLoopback(server.pprofListener.Addr().String()) + "/debug/pprof/")
204
+ if err != nil {
205
+ t.Fatalf("GET /debug/pprof/ error = %v", err)
206
+ }
207
+ defer resp.Body.Close()
208
+
209
+ if resp.StatusCode != http.StatusOK {
210
+ t.Fatalf("GET /debug/pprof/ status = %d, want %d", resp.StatusCode, http.StatusOK)
211
+ }
212
+}
213
+
214
func TestServerStartDomainReportsCompatibilityInfo(t *testing.T) {
215
t.Parallel()
216