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