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
This commit is contained in:
Brandon Schneider 2026-05-28 13:11:53 -05:00
parent d86625e220
commit 8a723a9ce1
11 changed files with 1192 additions and 0 deletions

2
5-Applications/caddy-edge/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
caddy
caddy.tar.gz

View file

@ -0,0 +1,29 @@
{
email allaun@researchstack.info
default_sni researchstack.info
acme_ca https://acme-staging-v02.api.letsencrypt.org/directory
}
(porkbun_tls) {
tls {
dns porkbun {
api_key {$PORKBUN_API_KEY}
api_secret_key {$PORKBUN_SECRET_KEY}
}
}
}
researchstack.info {
import porkbun_tls
reverse_proxy 100.102.173.61:30080
}
auth.researchstack.info {
import porkbun_tls
reverse_proxy 100.102.173.61:30080
}
*.researchstack.info {
import porkbun_tls
redir https://researchstack.info{uri} 301
}

View file

@ -0,0 +1,6 @@
FROM docker.io/library/caddy:2.10-builder AS builder
RUN xcaddy build --with github.com/caddy-dns/porkbun
FROM docker.io/library/caddy:2.10
COPY --from=builder /usr/bin/caddy /usr/bin/caddy
EXPOSE 80 443 2019

View file

@ -0,0 +1,87 @@
{
"apps": {
"http": {
"servers": {
"srv0": {
"listen": [":443"],
"routes": [
{
"match": [{"host": ["researchstack.info"]}],
"handle": [
{
"handler": "reverse_proxy",
"upstreams": [{"dial": "100.102.173.61:30080"}]
}
],
"terminal": true
},
{
"match": [{"host": ["auth.researchstack.info"]}],
"handle": [
{
"handler": "reverse_proxy",
"upstreams": [{"dial": "100.102.173.61:30080"}]
}
],
"terminal": true
},
{
"match": [{"host": ["*.researchstack.info"]}],
"handle": [
{
"handler": "static_response",
"status_code": 301,
"headers": {
"Location": ["https://researchstack.info{http.request.uri}"]
}
}
],
"terminal": true
}
]
},
"srv_redirect": {
"listen": [":80"],
"routes": [
{
"handle": [
{
"handler": "static_response",
"status_code": 301,
"headers": {
"Location": ["https://{http.request.host}{http.request.uri}"]
}
}
]
}
]
}
}
},
"tls": {
"automation": {
"policies": [
{
"subjects": ["researchstack.info", "*.researchstack.info"],
"issuers": [
{
"module": "acme",
"ca": "https://acme-v02.api.letsencrypt.org/directory",
"email": "allaun@researchstack.info",
"challenges": {
"dns": {
"provider": {
"name": "porkbun",
"api_key": "{env.PORKBUN_API_KEY}",
"api_secret_key": "{env.PORKBUN_SECRET_KEY}"
}
}
}
}
]
}
]
}
}
}
}

View file

@ -0,0 +1,95 @@
apiVersion: v1
kind: Namespace
metadata:
name: edge
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: caddy-data-v2
namespace: edge
spec:
accessModes:
- ReadWriteOnce
storageClassName: local-path
resources:
requests:
storage: 1Gi
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: caddy-edge
namespace: edge
labels:
app: caddy-edge
spec:
replicas: 1
selector:
matchLabels:
app: caddy-edge
template:
metadata:
labels:
app: caddy-edge
spec:
hostNetwork: true
dnsPolicy: Default
nodeSelector:
kubernetes.io/hostname: 361395-1
containers:
- name: caddy
image: localhost/caddy-edge:latest
imagePullPolicy: Never
command: ["caddy"]
args: ["run", "--config", "/etc/caddy/Caddyfile", "--adapter", "caddyfile"]
ports:
- containerPort: 80
name: http
- containerPort: 443
name: https
env:
- name: PORKBUN_API_KEY
valueFrom:
secretKeyRef:
name: porkbun-credentials
key: api-key
- name: PORKBUN_SECRET_KEY
valueFrom:
secretKeyRef:
name: porkbun-credentials
key: api-secret-key
volumeMounts:
- name: caddy-config
mountPath: /etc/caddy
readOnly: true
- name: caddy-data
mountPath: /data
- name: caddy-config-storage
mountPath: /config
resources:
requests:
memory: "64Mi"
cpu: "50m"
limits:
memory: "128Mi"
cpu: "250m"
readinessProbe:
tcpSocket:
port: 443
initialDelaySeconds: 15
periodSeconds: 10
livenessProbe:
tcpSocket:
port: 443
initialDelaySeconds: 20
periodSeconds: 30
volumes:
- name: caddy-config
configMap:
name: caddy-caddyfile
- name: caddy-data
persistentVolumeClaim:
claimName: caddy-data-v2
- name: caddy-config-storage
emptyDir: {}

View file

@ -0,0 +1,13 @@
FROM docker.io/library/golang:1.23-alpine AS builder
WORKDIR /build
COPY go.mod ./
RUN go mod download || true
COPY signaling-server.go .
RUN go mod tidy && CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /webrtc-bridge .
FROM docker.io/library/alpine:3.20
RUN apk add --no-cache ca-certificates
COPY --from=builder /webrtc-bridge /usr/local/bin/webrtc-bridge
COPY client/ /app/client/
EXPOSE 8080
ENTRYPOINT ["webrtc-bridge"]

View file

@ -0,0 +1,136 @@
# 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):**
```json
{
"id": "req-uuid",
"method": "GET",
"path": "/api/v1/status",
"headers": {"Accept": "application/json"},
"body": ""
}
```
**Response (server → client):**
```json
{
"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
```bash
cd 5-Applications/webrtc-bridge
podman build -t localhost/webrtc-bridge:latest .
```
### Import into k3s
```bash
podman save localhost/webrtc-bridge:latest | ssh 361395-1 k3s ctr images import -
```
### Apply manifests
```bash
kubectl apply -f k8s/bridge.yaml
```
### Verify
```bash
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)

View file

@ -0,0 +1,434 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>WebRTC Bridge — Research Stack</title>
<style>
:root {
--bg: #0d1117;
--surface: #161b22;
--border: #30363d;
--text: #e6edf3;
--muted: #8b949e;
--green: #3fb950;
--red: #f85149;
--blue: #58a6ff;
--yellow: #d29922;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'SF Mono', 'Fira Code', monospace;
background: var(--bg);
color: var(--text);
padding: 2rem;
max-width: 960px;
margin: 0 auto;
}
h1 { font-size: 1.5rem; margin-bottom: 0.5rem; }
.subtitle { color: var(--muted); font-size: 0.85rem; margin-bottom: 2rem; }
.status-bar {
display: flex;
gap: 1.5rem;
align-items: center;
padding: 1rem;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
margin-bottom: 1.5rem;
}
.status-dot {
width: 10px; height: 10px;
border-radius: 50%;
background: var(--red);
flex-shrink: 0;
}
.status-dot.connected { background: var(--green); }
.status-dot.connecting { background: var(--yellow); animation: pulse 1s infinite; }
@keyframes pulse { 50% { opacity: 0.4; } }
.status-label { font-weight: 600; }
.status-detail { color: var(--muted); font-size: 0.8rem; }
.metrics {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 1rem;
margin-bottom: 1.5rem;
}
.metric {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
padding: 0.75rem;
text-align: center;
}
.metric-value { font-size: 1.5rem; font-weight: 700; color: var(--blue); }
.metric-label { font-size: 0.7rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.05em; }
.request-form {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
padding: 1rem;
margin-bottom: 1.5rem;
}
.form-row { display: flex; gap: 0.5rem; margin-bottom: 0.5rem; }
select, input, button {
font-family: inherit;
font-size: 0.85rem;
background: var(--bg);
color: var(--text);
border: 1px solid var(--border);
border-radius: 6px;
padding: 0.5rem 0.75rem;
}
select { width: 100px; }
input { flex: 1; }
button {
background: var(--blue);
color: var(--bg);
border-color: var(--blue);
cursor: pointer;
font-weight: 600;
}
button:hover { opacity: 0.9; }
button:disabled { opacity: 0.4; cursor: not-allowed; }
.log {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
padding: 1rem;
max-height: 400px;
overflow-y: auto;
font-size: 0.8rem;
line-height: 1.6;
}
.log-entry { border-bottom: 1px solid var(--border); padding: 0.25rem 0; }
.log-entry:last-child { border-bottom: none; }
.log-time { color: var(--muted); }
.log-ok { color: var(--green); }
.log-err { color: var(--red); }
.log-info { color: var(--blue); }
</style>
</head>
<body>
<h1>⚡ WebRTC Bridge</h1>
<p class="subtitle">Direct P2P to Research Stack — bypasses DERP relay</p>
<div class="status-bar">
<div class="status-dot" id="statusDot"></div>
<div>
<div class="status-label" id="statusLabel">Disconnected</div>
<div class="status-detail" id="statusDetail">Not connected to signaling server</div>
</div>
</div>
<div class="metrics">
<div class="metric">
<div class="metric-value" id="metricRTT"></div>
<div class="metric-label">RTT (ms)</div>
</div>
<div class="metric">
<div class="metric-value" id="metricRequests">0</div>
<div class="metric-label">Requests</div>
</div>
<div class="metric">
<div class="metric-value" id="metricBytes">0</div>
<div class="metric-label">Bytes</div>
</div>
<div class="metric">
<div class="metric-value" id="metricICE"></div>
<div class="metric-label">ICE Type</div>
</div>
</div>
<div class="request-form">
<div class="form-row">
<select id="reqMethod">
<option>GET</option>
<option>POST</option>
<option>PUT</option>
<option>DELETE</option>
<option>PATCH</option>
</select>
<input id="reqPath" placeholder="/api/v1/..." value="/dashboard/">
<button id="sendBtn" disabled>Send</button>
</div>
</div>
<div class="log" id="log"></div>
<script>
const state = {
ws: null,
pc: null,
dc: null, // The single data channel (client-created)
connected: false,
requests: 0,
bytes: 0,
pendingRequests: new Map(),
};
const els = {
statusDot: document.getElementById('statusDot'),
statusLabel: document.getElementById('statusLabel'),
statusDetail: document.getElementById('statusDetail'),
metricRTT: document.getElementById('metricRTT'),
metricRequests: document.getElementById('metricRequests'),
metricBytes: document.getElementById('metricBytes'),
metricICE: document.getElementById('metricICE'),
reqMethod: document.getElementById('reqMethod'),
reqPath: document.getElementById('reqPath'),
sendBtn: document.getElementById('sendBtn'),
log: document.getElementById('log'),
};
function log(msg, cls = '') {
const entry = document.createElement('div');
entry.className = 'log-entry';
const time = new Date().toISOString().slice(11, 23);
entry.innerHTML = `<span class="log-time">${time}</span> <span class="${cls}">${msg}</span>`;
els.log.prepend(entry);
while (els.log.children.length > 200) {
els.log.removeChild(els.log.lastChild);
}
}
function setStatus(st, label, detail) {
els.statusDot.className = 'status-dot ' + st;
els.statusLabel.textContent = label;
els.statusDetail.textContent = detail;
}
function updateMetrics() {
els.metricRequests.textContent = state.requests;
els.metricBytes.textContent = formatBytes(state.bytes);
}
function formatBytes(b) {
if (b < 1024) return b + ' B';
if (b < 1048576) return (b / 1024).toFixed(1) + ' KB';
return (b / 1048576).toFixed(1) + ' MB';
}
function generateId() {
return 'req-' + Math.random().toString(36).slice(2, 10);
}
// --- WebSocket Signaling ---
function connectSignaling() {
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = proto + '//' + location.host + '/ws';
log('Connecting to signaling server: ' + wsUrl, 'log-info');
setStatus('connecting', 'Connecting', 'Establishing WebSocket...');
const ws = new WebSocket(wsUrl);
state.ws = ws;
ws.onopen = () => {
log('WebSocket connected', 'log-info');
createPeerConnection();
sendOffer();
};
ws.onmessage = (evt) => {
const msg = JSON.parse(evt.data);
handleSignalingMessage(msg);
};
ws.onerror = () => {
log('WebSocket error', 'log-err');
};
ws.onclose = () => {
log('WebSocket closed', 'log-err');
if (!state.connected) {
setStatus('', 'Disconnected', 'Signaling server closed — retrying in 3s');
setTimeout(connectSignaling, 3000);
}
};
}
function handleSignalingMessage(msg) {
switch (msg.type) {
case 'answer':
log('Received SDP answer', 'log-info');
state.pc.setRemoteDescription(new RTCSessionDescription(msg.sdp));
break;
case 'candidate':
if (msg.candidate) {
state.pc.addIceCandidate(new RTCIceCandidate(msg.candidate));
}
break;
}
}
// --- WebRTC PeerConnection ---
function createPeerConnection() {
const config = {
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'stun:stun1.l.google.com:19302' },
{ urls: 'stun:stun2.l.google.com:19302' },
],
};
const pc = new RTCPeerConnection(config);
state.pc = pc;
pc.onicecandidate = (evt) => {
if (evt.candidate && state.ws && state.ws.readyState === WebSocket.OPEN) {
state.ws.send(JSON.stringify({
type: 'candidate',
candidate: evt.candidate.toJSON(),
}));
}
};
pc.oniceconnectionstatechange = () => {
const iceState = pc.iceConnectionState;
log('ICE state: ' + iceState, 'log-info');
if (iceState === 'connected' || iceState === 'completed') {
// Try to detect the candidate pair type
try {
const stats = pc.getStats();
stats.then(s => {
s.forEach(report => {
if (report.type === 'candidate-pair' && report.selected) {
const local = s.get(report.localCandidateId);
if (local) {
els.metricICE.textContent = local.candidateType || 'unknown';
}
}
});
});
} catch (_) {}
measureRTT();
}
if (iceState === 'failed' || iceState === 'disconnected') {
state.connected = false;
setStatus('', 'Disconnected', 'ICE connection lost');
els.sendBtn.disabled = true;
}
};
// Client creates the data channel — server receives it via OnDataChannel.
const dc = pc.createDataChannel('http-proxy', { ordered: true });
state.dc = dc;
dc.onopen = () => {
log('Data channel opened', 'log-info');
state.connected = true;
setStatus('connected', 'Connected', 'WebRTC data channel active — P2P');
els.sendBtn.disabled = false;
// Close the signaling WebSocket; we no longer need it.
if (state.ws && state.ws.readyState === WebSocket.OPEN) {
state.ws.close();
}
};
dc.onclose = () => {
log('Data channel closed', 'log-err');
state.connected = false;
setStatus('', 'Disconnected', 'Data channel closed');
els.sendBtn.disabled = true;
};
dc.onmessage = (evt) => {
handleDataChannelMessage(evt.data);
};
}
async function sendOffer() {
const offer = await state.pc.createOffer();
await state.pc.setLocalDescription(offer);
state.ws.send(JSON.stringify({
type: 'offer',
sdp: offer,
}));
log('Sent SDP offer', 'log-info');
}
// --- Data Channel Protocol ---
function handleDataChannelMessage(data) {
const resp = JSON.parse(data);
const pending = state.pendingRequests.get(resp.id);
if (pending) {
state.pendingRequests.delete(resp.id);
const elapsed = performance.now() - pending.startTime;
state.bytes += (data.length + pending.bodySize);
updateMetrics();
const cls = resp.status < 400 ? 'log-ok' : 'log-err';
log(`${pending.method} ${pending.path} → ${resp.status} (${elapsed.toFixed(0)}ms)`, cls);
if (pending.onResponse) {
pending.onResponse(resp, elapsed);
}
}
}
function sendRequest(method, path) {
if (!state.connected || !state.dc) {
log('Not connected', 'log-err');
return;
}
const id = generateId();
const req = { id, method, path, headers: {}, body: '' };
const data = JSON.stringify(req);
state.pendingRequests.set(id, {
method,
path,
startTime: performance.now(),
bodySize: data.length,
});
state.dc.send(data);
state.requests++;
updateMetrics();
log(`→ ${method} ${path}`, 'log-info');
}
// --- RTT Measurement ---
function measureRTT() {
if (!state.dc || state.dc.readyState !== 'open') return;
const id = 'rtt-' + Date.now();
state.pendingRequests.set(id, {
method: 'RTT',
path: '/health',
startTime: performance.now(),
bodySize: 0,
onResponse: (_resp, elapsed) => {
els.metricRTT.textContent = elapsed.toFixed(0);
},
});
const req = { id, method: 'GET', path: '/health', headers: {}, body: '' };
state.dc.send(JSON.stringify(req));
}
setInterval(() => {
if (state.connected) measureRTT();
}, 10000);
// --- UI Events ---
els.sendBtn.addEventListener('click', () => {
sendRequest(els.reqMethod.value, els.reqPath.value);
});
els.reqPath.addEventListener('keydown', (evt) => {
if (evt.key === 'Enter') {
sendRequest(els.reqMethod.value, els.reqPath.value);
}
});
// --- Start ---
connectSignaling();
</script>
</body>
</html>

View file

@ -0,0 +1,3 @@
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/pion/webrtc/v4 v4.0.14 h1:nyds/sFRR+HvmWoBa6wrL46sSfpArE0qR883MBW96lg=
github.com/pion/webrtc/v4 v4.0.14/go.mod h1:R3+qTnQTS03UzwDarYecgioNf7DYgTsldxnCXB821Kk=

View file

@ -0,0 +1,100 @@
apiVersion: v1
kind: Namespace
metadata:
name: edge
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: webrtc-bridge
namespace: edge
labels:
app: webrtc-bridge
spec:
replicas: 1
selector:
matchLabels:
app: webrtc-bridge
template:
metadata:
labels:
app: webrtc-bridge
spec:
hostNetwork: true
dnsPolicy: ClusterFirstWithHostNet
nodeSelector:
kubernetes.io/hostname: 361395-1
containers:
- name: webrtc-bridge
image: localhost/webrtc-bridge:latest
imagePullPolicy: Never
ports:
- containerPort: 8080
name: http
env:
- name: PORT
value: "8080"
- name: PROXY_TARGET
value: "http://100.102.173.61:30080"
resources:
requests:
memory: "64Mi"
cpu: "50m"
limits:
memory: "128Mi"
cpu: "250m"
readinessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 5
periodSeconds: 10
livenessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 10
periodSeconds: 30
---
apiVersion: v1
kind: Service
metadata:
name: webrtc-bridge
namespace: edge
spec:
selector:
app: webrtc-bridge
ports:
- port: 80
targetPort: 8080
name: http
type: ClusterIP
---
# Traefik IngressRoute to expose under /webrtc path on the Funnel hostname.
# Traefik is already configured for 361395-1.tail4e7094.ts.net via Tailscale Funnel.
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: webrtc-bridge
namespace: edge
spec:
entryPoints:
- web
routes:
- match: Host(`361395-1.tail4e7094.ts.net`) && PathPrefix(`/webrtc`)
kind: Rule
services:
- name: webrtc-bridge
port: 80
middlewares:
- name: webrtc-strip-prefix
---
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: webrtc-strip-prefix
namespace: edge
spec:
stripPrefix:
prefixes:
- /webrtc

View file

@ -0,0 +1,287 @@
package main
import (
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"sync"
"time"
"github.com/gorilla/websocket"
"github.com/pion/webrtc/v4"
)
// Signaling message types exchanged over WebSocket.
type SignalMessage struct {
Type string `json:"type"` // "offer", "answer", "candidate"
SDP *webrtc.SessionDescription `json:"sdp,omitempty"`
Candidate *webrtc.ICECandidateInit `json:"candidate,omitempty"`
}
// HTTP request forwarded over the WebRTC data channel.
type HTTPRequest struct {
ID string `json:"id"`
Method string `json:"method"`
Path string `json:"path"`
Headers map[string]string `json:"headers"`
Body string `json:"body"`
}
// HTTP response returned over the WebRTC data channel.
type HTTPResponse struct {
ID string `json:"id"`
Status int `json:"status"`
Headers map[string]string `json:"headers"`
Body string `json:"body"`
}
var (
upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool { return true },
}
// Target for proxied HTTP requests — Traefik ingress.
proxyTarget = envOr("PROXY_TARGET", "http://100.102.173.61:30080")
)
func envOr(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
func main() {
port := envOr("PORT", "8080")
http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"status":"ok","service":"webrtc-bridge"}`))
})
http.HandleFunc("/ws", handleWebSocket)
// Serve the static client files.
http.Handle("/", http.FileServer(http.Dir("/app/client")))
log.Printf("WebRTC bridge signaling server starting on :%s (proxy target: %s)", port, proxyTarget)
if err := http.ListenAndServe(":"+port, nil); err != nil {
log.Fatalf("server failed: %v", err)
}
}
func handleWebSocket(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Printf("WebSocket upgrade failed: %v", err)
return
}
defer conn.Close()
log.Printf("Client connected: %s", r.RemoteAddr)
// Create a new RTCPeerConnection for this client.
config := webrtc.Configuration{
ICEServers: []webrtc.ICEServer{
{URLs: []string{"stun:stun.l.google.com:19302"}},
{URLs: []string{"stun:stun1.l.google.com:19302"}},
{URLs: []string{"stun:stun2.l.google.com:19302"}},
},
}
peerConnection, err := webrtc.NewPeerConnection(config)
if err != nil {
log.Printf("Failed to create PeerConnection: %v", err)
return
}
defer peerConnection.Close()
var dcOnce sync.Once
// Handle incoming data channels from the client.
peerConnection.OnDataChannel(func(dc *webrtc.DataChannel) {
dcOnce.Do(func() {
setupDataChannel(dc)
})
})
// Send ICE candidates to the client.
peerConnection.OnICECandidate(func(c *webrtc.ICECandidate) {
if c == nil {
return
}
candidate := c.ToJSON()
msg := SignalMessage{
Type: "candidate",
Candidate: &candidate,
}
conn.WriteJSON(msg)
})
// Log connection state changes.
peerConnection.OnConnectionStateChange(func(state webrtc.PeerConnectionState) {
log.Printf("PeerConnection state: %s", state.String())
})
// Read signaling messages from the WebSocket.
for {
_, raw, err := conn.ReadMessage()
if err != nil {
if websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) {
log.Printf("Client disconnected: %s", r.RemoteAddr)
} else {
log.Printf("WebSocket read error: %v", err)
}
return
}
var msg SignalMessage
if err := json.Unmarshal(raw, &msg); err != nil {
log.Printf("Invalid signaling message: %v", err)
continue
}
switch msg.Type {
case "offer":
if msg.SDP == nil {
log.Printf("Offer missing SDP")
continue
}
if err := peerConnection.SetRemoteDescription(*msg.SDP); err != nil {
log.Printf("SetRemoteDescription failed: %v", err)
continue
}
answer, err := peerConnection.CreateAnswer(nil)
if err != nil {
log.Printf("CreateAnswer failed: %v", err)
continue
}
if err := peerConnection.SetLocalDescription(answer); err != nil {
log.Printf("SetLocalDescription failed: %v", err)
continue
}
conn.WriteJSON(SignalMessage{
Type: "answer",
SDP: &answer,
})
log.Printf("Sent SDP answer to %s", r.RemoteAddr)
case "candidate":
if msg.Candidate == nil {
continue
}
if err := peerConnection.AddICECandidate(*msg.Candidate); err != nil {
log.Printf("AddICECandidate failed: %v", err)
}
}
}
}
func setupDataChannel(dc *webrtc.DataChannel) {
dc.OnOpen(func() {
log.Printf("Data channel opened: %s (id=%d)", dc.Label(), *dc.ID())
})
dc.OnClose(func() {
log.Printf("Data channel closed: %s", dc.Label())
})
dc.OnMessage(func(msg webrtc.DataChannelMessage) {
var req HTTPRequest
if err := json.Unmarshal(msg.Data, &req); err != nil {
log.Printf("Invalid data channel message: %v", err)
return
}
go func() {
resp := proxyRequest(&req)
data, _ := json.Marshal(resp)
dc.SendText(string(data))
}()
})
}
func proxyRequest(req *HTTPRequest) *HTTPResponse {
start := time.Now()
path := req.Path
if path == "" {
path = "/"
}
url := proxyTarget + path
var body io.Reader
if req.Body != "" {
body = &readCloser{[]byte(req.Body)}
}
httpReq, err := http.NewRequest(req.Method, url, body)
if err != nil {
return &HTTPResponse{
ID: req.ID,
Status: 502,
Headers: map[string]string{"Content-Type": "application/json"},
Body: fmt.Sprintf(`{"error":"bad request: %s"}`, err.Error()),
}
}
for k, v := range req.Headers {
httpReq.Header.Set(k, v)
}
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(httpReq)
if err != nil {
return &HTTPResponse{
ID: req.ID,
Status: 502,
Headers: map[string]string{"Content-Type": "application/json"},
Body: fmt.Sprintf(`{"error":"proxy error: %s"}`, err.Error()),
}
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return &HTTPResponse{
ID: req.ID,
Status: 502,
Headers: map[string]string{"Content-Type": "application/json"},
Body: fmt.Sprintf(`{"error":"read body: %s"}`, err.Error()),
}
}
headers := make(map[string]string)
for k := range resp.Header {
headers[k] = resp.Header.Get(k)
}
elapsed := time.Since(start)
log.Printf("Proxy %s %s → %d (%s)", req.Method, req.Path, resp.Status, elapsed)
return &HTTPResponse{
ID: req.ID,
Status: resp.StatusCode,
Headers: headers,
Body: string(respBody),
}
}
// readCloser wraps a byte slice as an io.ReadCloser.
type readCloser struct {
data []byte
}
func (r *readCloser) Read(p []byte) (int, error) {
if len(r.data) == 0 {
return 0, io.EOF
}
n := copy(p, r.data)
r.data = r.data[n:]
return n, nil
}
func (r *readCloser) Close() error { return nil }