Research-Stack/5-Applications/webrtc-bridge/README.md
Brandon Schneider 683bde4af6 feat(infra): WebRTC bridge + Caddy edge config + Tailscale Funnel
WebRTC bridge (5-Applications/webrtc-bridge/):
- Go signaling server with pion/webrtc v4 + gorilla/websocket
- Browser client with dark UI, RTT/ICE metrics, request builder
- HTTP proxy over WebRTC data channel to Traefik
- k3s deployment on 361395-1 with hostNetwork
- Traefik IngressRoute at /webrtc with stripPrefix middleware
- Bypasses Tailscale DERP relay latency (~129ms → direct P2P)

Caddy edge config (5-Applications/caddy-edge/):
- Caddyfile with Porkbun DNS-01 challenge
- JSON config with explicit TLS connection policies
- k3s deployment on 361395-1 with hostNetwork
- Note: TLS handshake fails in Caddy 2.10.2 (internal error)
  despite certs being loaded. Using Tailscale Funnel instead.

Infrastructure fixes:
- Tailscale Funnel enabled on 361395-1 → Traefik
- Traefik ingress for 361395-1.tail4e7094.ts.net → Homer
- Funnel hostname: https://361395-1.tail4e7094.ts.net
2026-05-28 13:11:53 -05:00

4.6 KiB

WebRTC Bridge

Bypass Tailscale DERP relay latency for the Research Stack k3s cluster.

Problem

Tailscale Funnel on node 361395-1 exposes Traefik at https://361395-1.tail4e7094.ts.net, but Funnel traffic traverses DERP relays, adding ~129 ms of latency per hop. For interactive workloads (dashboards, shell, streaming) this is unacceptable.

Solution

A lightweight WebRTC bridge that establishes a direct peer-to-peer data channel between the browser client and the cluster node, bypassing DERP entirely. After a one-time signaling exchange over WebSocket, all HTTP traffic flows over the WebRTC data channel with sub-10 ms latency when both peers have direct connectivity.

Architecture

┌──────────────┐   WebSocket (signaling)   ┌───────────────────┐
│              │ ◄──────────────────────── │                   │
│   Browser    │   SDP offer/answer        │  Signaling Server │
│   Client     │   ICE candidates          │  (Go, port 8080)  │
│              │                           │  on 361395-1      │
│              │   WebRTC Data Channel     │                   │
│              │ ◄════════════════════════► │  HTTP proxy       │
│              │   (peer-to-peer)          │  → Traefik:30080  │
└──────────────┘                           └───────────────────┘
       │                                           │
       │  Direct P2P (UDP)                         │
       │  STUN for NAT traversal                   │
       │  No DERP relay!                           │
       ▼                                           ▼
  Client NAT                              Tailscale mesh
  (STUN resolves)                         (direct route)

Components

Component Language Port Description
signaling-server.go Go 8080 WebSocket signaling + static file server
client/index.html HTML/JS Browser-side WebRTC client
Containerfile Multi-stage Go build
k8s/bridge.yaml YAML k3s deployment manifests

Signaling Flow

  1. Client opens WebSocket to wss://361395-1.tail4e7094.ts.net/webrtc/ws
  2. Client creates RTCPeerConnection with public STUN servers
  3. Client creates SDP offer, sends via WebSocket
  4. Server receives offer, creates answer, sends back
  5. ICE candidates exchanged over WebSocket
  6. WebRTC data channel opens — signaling WebSocket may close
  7. Client sends HTTP requests as JSON over the data channel
  8. Server proxies requests to Traefik, returns responses

Data Channel Protocol

Messages on the data channel are JSON-encoded HTTP request/response pairs:

Request (client → server):

{
  "id": "req-uuid",
  "method": "GET",
  "path": "/api/v1/status",
  "headers": {"Accept": "application/json"},
  "body": ""
}

Response (server → client):

{
  "id": "req-uuid",
  "status": 200,
  "headers": {"Content-Type": "application/json"},
  "body": "{\"ok\": true}"
}

NAT Traversal

  • Uses public STUN servers (stun:stun.l.google.com:19302, stun:stun1.l.google.com:19302)
  • The server runs on 361395-1 with hostNetwork: true, so its Tailscale IP 100.110.163.82 is directly reachable
  • If STUN fails to resolve a direct path, the connection falls back to Tailscale's own NAT traversal (which is better than DERP for most cases)
  • A TURN server can be added later if needed

Deployment

Build the container image

cd 5-Applications/webrtc-bridge
podman build -t localhost/webrtc-bridge:latest .

Import into k3s

podman save localhost/webrtc-bridge:latest | ssh 361395-1 k3s ctr images import -

Apply manifests

kubectl apply -f k8s/bridge.yaml

Verify

kubectl -n edge get pods -l app=webrtc-bridge
curl -s https://361395-1.tail4e7094.ts.net/webrtc/health

Access

Open https://361395-1.tail4e7094.ts.net/webrtc/ in a browser. The client will automatically connect via the signaling server, establish a WebRTC data channel, and begin proxying HTTP requests.

Future Work

  • Add TURN server for symmetric NAT scenarios
  • Multiplex multiple HTTP requests over a single data channel
  • Add connection quality metrics (RTT, packet loss)
  • Support WebSocket proxying through the data channel
  • Add authentication (Tailscale identity headers)