feat(relay): add optional auto-generated thumbnail screenshots for tunnel apps

Hee Sung Son committed Apr 7, 2026 at 20:09 UTC ec8e6b18f3c9cd4563dfa88fa8305a187ba4a069
15 files changed +464 -10
.env.example
+5
@@ -48,3 +48,8 @@ LANDING_PAGE_ENABLED=false
48 # Optionally restrict which proxy source ranges may supply those headers; leave empty for default private/loopback proxy ranges.
49 TRUST_PROXY_HEADERS=false
50 TRUSTED_PROXY_CIDRS=
51 +
52 +# Optional: auto-generated thumbnail screenshots for tunnel apps without a thumbnail.
53 +# Requires the headless-shell sidecar (chromedp/headless-shell) in docker-compose.
54 +# Leave empty or remove to disable. See docs/deployment.md section 8.
55 +# HEADLESS_SHELL_URL=ws://headless-shell:9222
README.md
+1
@@ -15,6 +15,7 @@
15 - **Relay discovery and pools**: Use discovered relays as a pool, with multi-relay access and failover
16 - **No login, no API keys**: Authenticate ownership using SIWE, with ENS-based identity support
17 - **Raw TCP/UDP + TCP port routing**: Native TCP reverse sessions, optional UDP, and dedicated TCP ports for non-TLS services
18 +- **Auto-generated thumbnails**: Optional headless screenshot sidecar generates dashboard card previews for tunnel apps
19
20 ## Comparison
21
cmd/relay-server/main.go
+5
@@ -48,6 +48,8 @@ type relayServerConfig struct {
48 AdminSettingsPath string
49 KeylessDir string
50
51 + HeadlessShellURL string
52 +
53 ACMEDNSProvider string
54 ENSGaslessEnabled bool
55 CloudflareToken string
@@ -80,6 +82,8 @@ func runServeCommand(args []string) error {
82 utils.BoolFlagEnv(fs, &cfg.TrustProxyHeaders, "trust-proxy-headers", false, "trust X-Forwarded-* and X-Real-IP headers from trusted proxies", "TRUST_PROXY_HEADERS")
83 utils.StringFlagEnv(fs, &cfg.TrustedProxyCIDRs, "trusted-proxy-cidrs", "", "trusted proxy CIDR allowlist for forwarded headers, comma-separated; defaults to private/loopback proxy ranges when trust-proxy-headers is enabled", "TRUSTED_PROXY_CIDRS")
84
85 + 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")
86 +
87 utils.StringFlagEnv(fs, &cfg.KeylessDir, "keyless-dir", "./.portal-certs", "directory path for relay keyless materials", "KEYLESS_DIR")
88 utils.StringFlagEnv(fs, &cfg.AdminSettingsPath, "admin-settings-path", "admin_settings.json", "admin settings file path", "ADMIN_SETTINGS_PATH")
89 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 KEYLESS_DIR", "ACME_DNS_PROVIDER")
@@ -159,6 +163,7 @@ func runServer(ctx context.Context, cfg relayServerConfig) error {
163 MaxPort: cfg.MaxPort,
164 UDPEnabled: cfg.UDPEnabled,
165 TCPEnabled: cfg.TCPEnabled,
166 + HeadlessShellURL: cfg.HeadlessShellURL,
167 })
168 if err != nil {
169 return fmt.Errorf("create relay server: %w", err)
docker-compose.yml
+11
@@ -1,9 +1,17 @@
1 services:
2 + # Optional: uncomment to enable auto-generated thumbnails for tunnel apps.
3 + # See docs/deployment.md section 8 for details.
4 + # headless-shell:
5 + # image: chromedp/headless-shell:stable
6 + # restart: unless-stopped
7 +
8 portal:
9 image: ghcr.io/gosuda/portal:latest
10 build:
11 context: .
12 dockerfile: Dockerfile
13 + # depends_on:
14 + # - headless-shell
15 stop_grace_period: 30s
16 ports:
17 - "${API_PORT:-4017}:${API_PORT:-4017}"
@@ -35,6 +43,9 @@ services:
43 TRUST_PROXY_HEADERS: ${TRUST_PROXY_HEADERS:-false}
44 TRUSTED_PROXY_CIDRS: ${TRUSTED_PROXY_CIDRS:-}
45
46 + # Optional: auto-generated thumbnails (requires headless-shell sidecar above)
47 + # HEADLESS_SHELL_URL: ${HEADLESS_SHELL_URL:-ws://headless-shell:9222}
48 +
49 # TLS/ACME and keyless materials
50 KEYLESS_DIR: ${KEYLESS_DIR:-/portal-certs}
51 ACME_DNS_PROVIDER: ${ACME_DNS_PROVIDER:-}
docs/deployment.md
+96 -9
@@ -403,11 +403,98 @@ sudo sysctl -w net.core.wmem_max=7500000
403
404 To persist this across reboots, add the values to `/etc/sysctl.conf` or a file in `/etc/sysctl.d/`.
405
406 -## 6. Auto-Update
406 +## 6. Optional Thumbnail Screenshots
407 +
408 +Portal can automatically generate thumbnail screenshots for tunnel apps that don't provide their own. When a tunnel app registers without a `thumbnail` in its metadata, the relay captures a screenshot of the app's public page and serves it as a card background on the dashboard.
409 +
410 +This feature is **disabled by default** and entirely optional. Without it, apps without a thumbnail simply show a gradient background.
411 +
412 +### 6.1 When to enable
413 +
414 +Enable this feature when:
415 +
416 +- You want richer visual previews on the relay dashboard
417 +- Most of your tunnel apps don't set a custom thumbnail in their metadata
418 +
419 +Skip this feature when:
420 +
421 +- You want the smallest possible deployment footprint
422 +- Tunnel apps already provide their own thumbnails
423 +- You're running on resource-constrained servers
424 +
425 +### 6.2 How it works
426 +
427 +The relay uses a headless Chromium sidecar (`chromedp/headless-shell`, ~200 MB) to render tunnel app pages and capture screenshots. When a tunnel app registers:
428 +
429 +1. If the app has no thumbnail and `HEADLESS_SHELL_URL` is configured, the relay queues a screenshot job.
430 +2. A single background worker connects to the headless Chromium via Chrome DevTools Protocol (CDP).
431 +3. The worker navigates to the app's public HTTPS URL, waits for the page to load, and captures a 1280×720 screenshot.
432 +4. The screenshot is JPEG-encoded and cached in memory (max 256 KB per image).
433 +5. On the next dashboard page load, the cached thumbnail is injected into the app's card.
434 +
435 +Screenshots are evicted when the lease expires or the app disconnects.
436 +
437 +### 6.3 Enable thumbnail screenshots
438 +
439 +**Step 1**: Uncomment the headless-shell service in `docker-compose.yml`:
440 +
441 +```yaml
442 +services:
443 + headless-shell:
444 + image: chromedp/headless-shell:stable
445 + restart: unless-stopped
446 +```
447 +
448 +**Step 2**: Uncomment the `depends_on` in the portal service:
449 +
450 +```yaml
451 + portal:
452 + depends_on:
453 + - headless-shell
454 +```
455 +
456 +**Step 3**: Uncomment and set `HEADLESS_SHELL_URL` in the portal environment:
457 +
458 +```yaml
459 + environment:
460 + HEADLESS_SHELL_URL: ${HEADLESS_SHELL_URL:-ws://headless-shell:9222}
461 +```
462 +
463 +Or set it in `.env`:
464 +
465 +```bash
466 +HEADLESS_SHELL_URL=ws://headless-shell:9222
467 +```
468 +
469 +**Step 4**: Restart the stack:
470 +
471 +```bash
472 +docker compose up -d
473 +```
474 +
475 +### 6.4 Verify
476 +
477 +After a tunnel app connects, check the relay logs for:
478 +
479 +```
480 +INF thumbnail captured hostname=myapp.portal.example.com size=36209
481 +```
482 +
483 +The thumbnail is then served at `/thumbnail/<hostname>` and displayed on the dashboard card.
484 +
485 +### 6.5 Disable
486 +
487 +Remove or comment out `HEADLESS_SHELL_URL` from `.env` or the docker-compose environment. The headless-shell container can also be removed. Without this variable, the feature is completely inactive with zero overhead.
488 +
489 +| Variable | Default | Description |
490 +|---|---|---|
491 +| `HEADLESS_SHELL_URL` | _(empty, disabled)_ | CDP WebSocket URL for headless Chromium sidecar (e.g. `ws://headless-shell:9222`) |
492 +
493 +## 7. Auto-Update
494
495 Automatically redeploy when a new `ghcr.io/gosuda/portal:latest` image is pushed.
496
410 -### 6.1 Deploy script
497 +### 7.1 Deploy script
498
499 Create `deploy_portal.sh` in your project directory:
500
@@ -421,7 +508,7 @@ docker compose pull
508 docker compose up -d
509 ```
510
424 -### 6.2 Watcher script
511 +### 7.2 Watcher script
512
513 The repository includes `watch_and_deploy.sh`, which polls the remote image digest and runs the deploy script on change.
514
@@ -433,7 +520,7 @@ Environment variables:
520 | `DEPLOY_SCRIPT` | `deploy_portal.sh` | Path to deploy script |
521 | `DIGEST_FILE` | `.portal_image_digest` | File storing the last known digest |
522
436 -### 6.3 Register as systemd service
523 +### 7.3 Register as systemd service
524
525 Set `WorkingDirectory` and `ExecStart` to the directory where `watch_and_deploy.sh` and `deploy_portal.sh` are located:
526
@@ -469,7 +556,7 @@ Adjust `User` to match your environment. Ensure the user belongs to the `docker`
556 sudo usermod -aG docker opc
557 ```
558
472 -### 6.4 Verify and monitor
559 +### 7.4 Verify and monitor
560
561 ```bash
562 sudo systemctl status portal-watcher
@@ -477,9 +564,9 @@ sudo journalctl -u portal-watcher -f
564 sudo journalctl -u portal-watcher --since today
565 ```
566
480 -## 7. Troubleshooting
567 +## 8. Troubleshooting
568
482 -### 7.1 Ports blocked
569 +### 8.1 Ports blocked
570
571 Required inbound ports:
572
@@ -502,11 +589,11 @@ sudo ufw allow 40000:40009/tcp
589 sudo ufw status
590 ```
591
505 -### 7.2 QUIC UDP buffer warnings
592 +### 8.2 QUIC UDP buffer warnings
593
594 If relay logs show `failed to sufficiently increase receive buffer size`, apply the sysctl settings from section 5.5.
595
509 -### 7.3 Docker DNS resolution fails
596 +### 8.3 Docker DNS resolution fails
597
598 If logs show `discover bootstraps failed`, `sync dns records`, or `lookup <host> on 127.0.0.11:53: write: operation not permitted`, Docker is usually using the wrong host resolver config.
599
docs/examples/nginx-proxy-multi-service/.env.example
+5
@@ -51,3 +51,8 @@ LANDING_PAGE_ENABLED=false
51 # Optionally restrict which proxy source ranges may supply those headers; leave empty for default private/loopback proxy ranges.
52 TRUST_PROXY_HEADERS=true
53 TRUSTED_PROXY_CIDRS=127.0.0.0/8
54 +
55 +# Optional: auto-generated thumbnail screenshots for tunnel apps without a thumbnail.
56 +# Requires the headless-shell sidecar (chromedp/headless-shell) in docker-compose.
57 +# Leave empty or remove to disable. See docs/deployment.md section 8.
58 +# HEADLESS_SHELL_URL=ws://headless-shell:9222
docs/examples/nginx-proxy-multi-service/docker-compose.yaml
+10
@@ -50,6 +50,13 @@ services:
50 - app-a-network
51 - app-b-network
52
53 + # ─── headless-shell (optional thumbnail screenshot sidecar) ─────────────────
54 + # Uncomment to enable auto-generated thumbnails for tunnel apps.
55 + # See docs/deployment.md section 8 for details.
56 + # headless-shell:
57 + # image: chromedp/headless-shell:stable
58 + # restart: unless-stopped
59 +
60 # ─── portal relay ───────────────────────────────────────────────────────────
61 # NAT-traversal relay server.
62 # TCP (4017, 4443) is reached by nginx via host.docker.internal.
@@ -59,6 +66,8 @@ services:
66 portal:
67 image: ghcr.io/gosuda/portal:latest
68 container_name: portal
69 + # depends_on:
70 + # - headless-shell
71 ports:
72 - "${API_PORT:-4017}:${API_PORT:-4017}/tcp"
73 - "${SNI_PORT:-4443}:${SNI_PORT:-4443}/tcp"
@@ -81,6 +90,7 @@ services:
90 TCP_ENABLED: ${TCP_ENABLED:-false}
91 ADMIN_SECRET_KEY: ${ADMIN_SECRET_KEY:-}
92 LANDING_PAGE_ENABLED: ${LANDING_PAGE_ENABLED:-false}
93 + # HEADLESS_SHELL_URL: ${HEADLESS_SHELL_URL:-ws://headless-shell:9222}
94 TRUST_PROXY_HEADERS: ${TRUST_PROXY_HEADERS:-true}
95 TRUSTED_PROXY_CIDRS: ${TRUSTED_PROXY_CIDRS:-127.0.0.0/8}
96 KEYLESS_DIR: ${KEYLESS_DIR:-/portal-certs}
docs/examples/nginx-proxy/.env.example
+5
@@ -51,3 +51,8 @@ LANDING_PAGE_ENABLED=false
51 # Optionally restrict which proxy source ranges may supply those headers; leave empty for default private/loopback proxy ranges.
52 TRUST_PROXY_HEADERS=true
53 TRUSTED_PROXY_CIDRS=127.0.0.0/8
54 +
55 +# Optional: auto-generated thumbnail screenshots for tunnel apps without a thumbnail.
56 +# Requires the headless-shell sidecar (chromedp/headless-shell) in docker-compose.
57 +# Leave empty or remove to disable. See docs/deployment.md section 8.
58 +# HEADLESS_SHELL_URL=ws://headless-shell:9222
docs/examples/nginx-proxy/docker-compose.yaml
+10
@@ -43,6 +43,13 @@ services:
43 - portal
44 restart: unless-stopped
45
46 + # ─── headless-shell (optional thumbnail screenshot sidecar) ─────────────────
47 + # Uncomment to enable auto-generated thumbnails for tunnel apps.
48 + # See docs/deployment.md section 8 for details.
49 + # headless-shell:
50 + # image: chromedp/headless-shell:stable
51 + # restart: unless-stopped
52 +
53 # ─── portal relay ───────────────────────────────────────────────────────────
54 # NAT-traversal relay server.
55 # TCP (4017, 4443) is reached by nginx via 127.0.0.1.
@@ -52,6 +59,8 @@ services:
59 portal:
60 image: ghcr.io/gosuda/portal:latest
61 container_name: portal
62 + # depends_on:
63 + # - headless-shell
64 ports:
65 - "${API_PORT:-4017}:${API_PORT:-4017}/tcp"
66 - "${SNI_PORT:-4443}:${SNI_PORT:-4443}/tcp"
@@ -79,6 +88,7 @@ services:
88
89 ADMIN_SECRET_KEY: ${ADMIN_SECRET_KEY:-}
90 LANDING_PAGE_ENABLED: ${LANDING_PAGE_ENABLED:-false}
91 + # HEADLESS_SHELL_URL: ${HEADLESS_SHELL_URL:-ws://headless-shell:9222}
92 TRUST_PROXY_HEADERS: ${TRUST_PROXY_HEADERS:-true}
93 TRUSTED_PROXY_CIDRS: ${TRUSTED_PROXY_CIDRS:-127.0.0.0/8}
94
go.mod
+6
@@ -12,6 +12,7 @@ require (
12 github.com/decred/dcrd/dcrec/secp256k1/v4 v4.1.0
13 github.com/go-acme/lego/v4 v4.32.0
14 github.com/go-jose/go-jose/v4 v4.1.4
15 + github.com/go-rod/rod v0.116.2
16 github.com/gosuda/keyless_tls v0.0.1-0.20260304212324-7733f8366abc
17 github.com/quic-go/quic-go v0.59.0
18 github.com/rs/zerolog v1.34.0
@@ -54,6 +55,11 @@ require (
55 github.com/mattn/go-isatty v0.0.20 // indirect
56 github.com/miekg/dns v1.1.72 // indirect
57 github.com/relvacode/iso8601 v1.1.1-0.20210511065120-b30b151cc433 // indirect
58 + github.com/ysmood/fetchup v0.2.3 // indirect
59 + github.com/ysmood/goob v0.4.0 // indirect
60 + github.com/ysmood/got v0.40.0 // indirect
61 + github.com/ysmood/gson v0.7.3 // indirect
62 + github.com/ysmood/leakless v0.9.0 // indirect
63 go.opentelemetry.io/auto/sdk v1.2.1 // indirect
64 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect
65 go.opentelemetry.io/otel v1.39.0 // indirect
go.sum
+16
@@ -64,6 +64,8 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
64 github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
65 github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
66 github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
67 +github.com/go-rod/rod v0.116.2 h1:A5t2Ky2A+5eD/ZJQr1EfsQSe5rms5Xof/qj296e+ZqA=
68 +github.com/go-rod/rod v0.116.2/go.mod h1:H+CMO9SCNc2TJ2WfrG+pKhITz57uGNYU43qYHh438Mg=
69 github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
70 github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
71 github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
@@ -105,6 +107,20 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
107 github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
108 github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
109 github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
110 +github.com/ysmood/fetchup v0.2.3 h1:ulX+SonA0Vma5zUFXtv52Kzip/xe7aj4vqT5AJwQ+ZQ=
111 +github.com/ysmood/fetchup v0.2.3/go.mod h1:xhibcRKziSvol0H1/pj33dnKrYyI2ebIvz5cOOkYGns=
112 +github.com/ysmood/goob v0.4.0 h1:HsxXhyLBeGzWXnqVKtmT9qM7EuVs/XOgkX7T6r1o1AQ=
113 +github.com/ysmood/goob v0.4.0/go.mod h1:u6yx7ZhS4Exf2MwciFr6nIM8knHQIE22lFpWHnfql18=
114 +github.com/ysmood/gop v0.2.0 h1:+tFrG0TWPxT6p9ZaZs+VY+opCvHU8/3Fk6BaNv6kqKg=
115 +github.com/ysmood/gop v0.2.0/go.mod h1:rr5z2z27oGEbyB787hpEcx4ab8cCiPnKxn0SUHt6xzk=
116 +github.com/ysmood/got v0.40.0 h1:ZQk1B55zIvS7zflRrkGfPDrPG3d7+JOza1ZkNxcc74Q=
117 +github.com/ysmood/got v0.40.0/go.mod h1:W7DdpuX6skL3NszLmAsC5hT7JAhuLZhByVzHTq874Qg=
118 +github.com/ysmood/gotrace v0.6.0 h1:SyI1d4jclswLhg7SWTL6os3L1WOKeNn/ZtzVQF8QmdY=
119 +github.com/ysmood/gotrace v0.6.0/go.mod h1:TzhIG7nHDry5//eYZDYcTzuJLYQIkykJzCRIo4/dzQM=
120 +github.com/ysmood/gson v0.7.3 h1:QFkWbTH8MxyUTKPkVWAENJhxqdBa4lYTQWqZCiLG6kE=
121 +github.com/ysmood/gson v0.7.3/go.mod h1:3Kzs5zDl21g5F/BlLTNcuAGAYLKt2lV5G8D1zF3RNmg=
122 +github.com/ysmood/leakless v0.9.0 h1:qxCG5VirSBvmi3uynXFkcnLMzkphdh3xx5FtrORwDCU=
123 +github.com/ysmood/leakless v0.9.0/go.mod h1:R8iAXPRaG97QJwqxs74RdwzcRHT1SWCGTNqY8q0JvMQ=
124 go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
125 go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
126 go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 h1:q4XOmH/0opmeuJtPsbFNivyl7bCt7yRBbeEm2sC/XtQ=
portal/api_server.go
+32
@@ -127,11 +127,38 @@ func (s *Server) apiHandler(base *http.ServeMux, keylessSignerHandler http.Handl
127 }
128 keylessSignerHandler.ServeHTTP(w, r)
129 default:
130 + if strings.HasPrefix(r.URL.Path, types.PathThumbnailPrefix) {
131 + s.serveThumbnail(w, r)
132 + return
133 + }
134 base.ServeHTTP(w, r)
135 }
136 })
137 }
138
139 +func (s *Server) serveThumbnail(w http.ResponseWriter, r *http.Request) {
140 + if !utils.RequireMethod(w, r, http.MethodGet) {
141 + return
142 + }
143 + hostname := strings.TrimPrefix(r.URL.Path, types.PathThumbnailPrefix)
144 + hostname = strings.TrimSpace(strings.ToLower(hostname))
145 + if hostname == "" {
146 + http.NotFound(w, r)
147 + return
148 + }
149 +
150 + data, contentType, ok := s.thumbnails.Get(hostname)
151 + if !ok {
152 + http.NotFound(w, r)
153 + return
154 + }
155 +
156 + w.Header().Set("Content-Type", contentType)
157 + w.Header().Set("Cache-Control", "public, max-age=300")
158 + w.WriteHeader(http.StatusOK)
159 + _, _ = w.Write(data)
160 +}
161 +
162 func (s *Server) handleRoot(w http.ResponseWriter, _ *http.Request) {
163 utils.WriteAPIData(w, http.StatusOK, map[string]any{
164 "service": "portal-relay",
@@ -608,6 +635,11 @@ func (s *Server) registerLease(req types.RegisterChallengeRequest, clientIP, rep
635 return types.RegisterResponse{}, err
636 }
637
638 + // Trigger thumbnail generation for apps that don't provide one
639 + if record.Metadata.Thumbnail == "" && s.thumbnails != nil {
640 + s.thumbnails.TriggerAsync(hostname)
641 + }
642 +
643 resp := types.RegisterResponse{
644 Identity: record.Copy(),
645 Hostname: hostname,
portal/server.go
+12
@@ -51,6 +51,7 @@ type ServerConfig struct {
51 MaxPort int
52 UDPEnabled bool
53 TCPEnabled bool
54 + HeadlessShellURL string
55 }
56
57 type Server struct {
@@ -69,6 +70,7 @@ type Server struct {
70 cfg ServerConfig
71 trustedProxyCIDRs []*net.IPNet
72 relaySet *discovery.RelaySet
73 + thumbnails *thumbnailService
74 shutdownOnce sync.Once
75 }
76
@@ -150,6 +152,7 @@ func NewServer(cfg ServerConfig) (*Server, error) {
152 tcpPorts: tcpPorts,
153 identity: identity,
154 trustedProxyCIDRs: trustedProxyCIDRs,
155 + thumbnails: newThumbnailService(cfg.HeadlessShellURL),
156 }
157
158 if cfg.DiscoveryEnabled {
@@ -300,6 +303,9 @@ func (s *Server) Shutdown(ctx context.Context) error {
303 if s.acmeManager != nil {
304 s.acmeManager.Stop()
305 }
306 + if s.thumbnails != nil {
307 + s.thumbnails.Close()
308 + }
309 })
310 return shutdownErr
311 }
@@ -327,6 +333,9 @@ func (s *Server) LeaseSnapshots() []types.Lease {
333 if snap.Ready == 0 && since >= 3*time.Minute {
334 continue
335 }
336 + if snap.Metadata.Thumbnail == "" && s.thumbnails != nil && s.thumbnails.Has(snap.Hostname) {
337 + snap.Lease.Metadata.Thumbnail = types.PathThumbnailPrefix + snap.Hostname
338 + }
339 out = append(out, snap.Lease)
340 }
341 return out
@@ -467,6 +476,9 @@ func (s *Server) runLeaseJanitor(ctx context.Context, interval time.Duration) er
476 Str("address", lease.Address).
477 Msg("delete expired lease ens gasless txt")
478 }
479 + if s.thumbnails != nil {
480 + s.thumbnails.Remove(lease.Hostname)
481 + }
482 lease.Close()
483 }
484 }
portal/thumbnail.go new
+248
@@ -0,0 +1,248 @@
1 +package portal
2 +
3 +import (
4 + "encoding/json"
5 + "fmt"
6 + "io"
7 + "net/http"
8 + "net/url"
9 + "strings"
10 + "sync"
11 + "time"
12 +
13 + "github.com/go-rod/rod"
14 + "github.com/go-rod/rod/lib/proto"
15 + "github.com/rs/zerolog/log"
16 +)
17 +
18 +const (
19 + thumbnailViewportWidth = 1280
20 + thumbnailViewportHeight = 720
21 + thumbnailJPEGQuality = 80
22 + thumbnailMaxBytes = 256 << 10 // 256KB
23 + thumbnailCooldown = 30 * time.Second
24 + thumbnailPageTimeout = 15 * time.Second
25 + thumbnailQueueSize = 32
26 + thumbnailContentType = "image/jpeg"
27 +)
28 +
29 +type thumbEntry struct {
30 + data []byte
31 + fetchedAt time.Time
32 +}
33 +
34 +type thumbnailService struct {
35 + mu sync.RWMutex
36 + cache map[string]*thumbEntry
37 + pending map[string]bool
38 + queue chan string
39 + headlessShellURL string
40 + done chan struct{}
41 +}
42 +
43 +func newThumbnailService(headlessShellURL string) *thumbnailService {
44 + headlessShellURL = strings.TrimSpace(headlessShellURL)
45 + if headlessShellURL == "" {
46 + return nil
47 + }
48 + ts := &thumbnailService{
49 + cache: make(map[string]*thumbEntry),
50 + pending: make(map[string]bool),
51 + queue: make(chan string, thumbnailQueueSize),
52 + headlessShellURL: headlessShellURL,
53 + done: make(chan struct{}),
54 + }
55 + go ts.worker()
56 + return ts
57 +}
58 +
59 +func (ts *thumbnailService) worker() {
60 + for hostname := range ts.queue {
61 + ts.capture(hostname)
62 + ts.mu.Lock()
63 + delete(ts.pending, hostname)
64 + ts.mu.Unlock()
65 + }
66 + close(ts.done)
67 +}
68 +
69 +func (ts *thumbnailService) Get(hostname string) ([]byte, string, bool) {
70 + if ts == nil {
71 + return nil, "", false
72 + }
73 + ts.mu.RLock()
74 + entry, ok := ts.cache[hostname]
75 + ts.mu.RUnlock()
76 + if !ok || len(entry.data) == 0 {
77 + return nil, "", false
78 + }
79 + return entry.data, thumbnailContentType, true
80 +}
81 +
82 +func (ts *thumbnailService) Has(hostname string) bool {
83 + if ts == nil {
84 + return false
85 + }
86 + ts.mu.RLock()
87 + entry, ok := ts.cache[hostname]
88 + ts.mu.RUnlock()
89 + return ok && len(entry.data) > 0
90 +}
91 +
92 +func (ts *thumbnailService) TriggerAsync(hostname string) {
93 + if ts == nil || hostname == "" {
94 + return
95 + }
96 +
97 + ts.mu.Lock()
98 + defer ts.mu.Unlock()
99 +
100 + if entry, ok := ts.cache[hostname]; ok {
101 + if len(entry.data) > 0 || time.Since(entry.fetchedAt) < thumbnailCooldown {
102 + return
103 + }
104 + }
105 + if ts.pending[hostname] {
106 + return
107 + }
108 +
109 + ts.pending[hostname] = true
110 + select {
111 + case ts.queue <- hostname:
112 + default:
113 + delete(ts.pending, hostname)
114 + }
115 +}
116 +
117 +func (ts *thumbnailService) capture(hostname string) {
118 + data, err := ts.screenshot(hostname)
119 + if err != nil {
120 + log.Warn().Err(err).Str("hostname", hostname).Msg("thumbnail capture failed")
121 + ts.cacheResult(hostname, nil)
122 + return
123 + }
124 + if len(data) > thumbnailMaxBytes {
125 + log.Warn().Str("hostname", hostname).Int("size", len(data)).Msg("thumbnail too large, discarding")
126 + ts.cacheResult(hostname, nil)
127 + return
128 + }
129 +
130 + ts.cacheResult(hostname, data)
131 + log.Info().Str("hostname", hostname).Int("size", len(data)).Msg("thumbnail captured")
132 +}
133 +
134 +func (ts *thumbnailService) cacheResult(hostname string, data []byte) {
135 + ts.mu.Lock()
136 + ts.cache[hostname] = &thumbEntry{data: data, fetchedAt: time.Now()}
137 + ts.mu.Unlock()
138 +}
139 +
140 +func (ts *thumbnailService) resolveCDPWebSocketURL() (string, error) {
141 + parsed, err := url.Parse(ts.headlessShellURL)
142 + if err != nil {
143 + return "", fmt.Errorf("parse headless shell URL: %w", err)
144 + }
145 +
146 + req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("http://%s/json/version", parsed.Host), nil)
147 + if err != nil {
148 + return "", fmt.Errorf("build /json/version request: %w", err)
149 + }
150 + req.Host = "127.0.0.1" // headless-shell rejects non-IP Host headers
151 +
152 + resp, err := (&http.Client{Timeout: 5 * time.Second}).Do(req)
153 + if err != nil {
154 + return "", fmt.Errorf("query /json/version: %w", err)
155 + }
156 + defer resp.Body.Close()
157 +
158 + body, err := io.ReadAll(io.LimitReader(resp.Body, 4096))
159 + if err != nil {
160 + return "", fmt.Errorf("read /json/version: %w", err)
161 + }
162 + if resp.StatusCode != http.StatusOK {
163 + return "", fmt.Errorf("/json/version status %d: %s", resp.StatusCode, body)
164 + }
165 +
166 + var info struct {
167 + WebSocketDebuggerURL string `json:"webSocketDebuggerUrl"`
168 + }
169 + if err := json.Unmarshal(body, &info); err != nil {
170 + return "", fmt.Errorf("decode /json/version: %w", err)
171 + }
172 + if info.WebSocketDebuggerURL == "" {
173 + return "", fmt.Errorf("/json/version: empty webSocketDebuggerUrl")
174 + }
175 +
176 + wsURL, err := url.Parse(info.WebSocketDebuggerURL)
177 + if err != nil {
178 + return "", fmt.Errorf("parse debugger URL: %w", err)
179 + }
180 + wsURL.Host = parsed.Host // headless-shell returns 0.0.0.0 internally
181 + return wsURL.String(), nil
182 +}
183 +
184 +func (ts *thumbnailService) screenshot(hostname string) ([]byte, error) {
185 + cdpURL, err := ts.resolveCDPWebSocketURL()
186 + if err != nil {
187 + return nil, err
188 + }
189 +
190 + browser := rod.New().ControlURL(cdpURL)
191 + if err := browser.Connect(); err != nil {
192 + return nil, err
193 + }
194 +
195 + incognito, err := browser.Incognito()
196 + if err != nil {
197 + return nil, err
198 + }
199 + defer incognito.Close()
200 +
201 + page, err := incognito.Page(proto.TargetCreateTarget{URL: "about:blank"})
202 + if err != nil {
203 + return nil, err
204 + }
205 + defer page.Close()
206 +
207 + _ = page.SetViewport(&proto.EmulationSetDeviceMetricsOverride{
208 + Width: thumbnailViewportWidth,
209 + Height: thumbnailViewportHeight,
210 + })
211 + _ = browser.IgnoreCertErrors(true)
212 +
213 + if err := page.Navigate("https://" + hostname); err != nil {
214 + return nil, err
215 + }
216 + if err := page.Timeout(thumbnailPageTimeout).WaitLoad(); err != nil {
217 + return nil, err
218 + }
219 + time.Sleep(1 * time.Second)
220 +
221 + quality := thumbnailJPEGQuality
222 + return page.Screenshot(false, &proto.PageCaptureScreenshot{
223 + Format: proto.PageCaptureScreenshotFormatJpeg,
224 + Quality: &quality,
225 + })
226 +}
227 +
228 +func (ts *thumbnailService) Remove(hostname string) {
229 + if ts == nil {
230 + return
231 + }
232 + ts.mu.Lock()
233 + delete(ts.cache, hostname)
234 + delete(ts.pending, hostname)
235 + ts.mu.Unlock()
236 +}
237 +
238 +func (ts *thumbnailService) Close() {
239 + if ts == nil {
240 + return
241 + }
242 + close(ts.queue)
243 + <-ts.done
244 + ts.mu.Lock()
245 + ts.cache = make(map[string]*thumbEntry)
246 + ts.pending = make(map[string]bool)
247 + ts.mu.Unlock()
248 +}
types/paths.go
+2 -1
@@ -24,7 +24,8 @@ const (
24 PathInstallPowerShell = "/install.ps1"
25 PathInstallBinPrefix = "/install/bin/"
26
27 - PathTunnelStatus = "/tunnel/status"
27 + PathTunnelStatus = "/tunnel/status"
28 + PathThumbnailPrefix = "/thumbnail/"
29
30 PathSDKPrefix = "/sdk/"
31 PathSDKDomain = "/sdk/domain"