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"