mirror of
https://github.com/allaunthefox/Research-Stack.git
synced 2026-07-31 03:05:21 +00:00
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:
parent
ed98817257
commit
683bde4af6
11 changed files with 1192 additions and 0 deletions
2
5-Applications/caddy-edge/.gitignore
vendored
Normal file
2
5-Applications/caddy-edge/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
caddy
|
||||
caddy.tar.gz
|
||||
29
5-Applications/caddy-edge/Caddyfile
Normal file
29
5-Applications/caddy-edge/Caddyfile
Normal 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
|
||||
}
|
||||
6
5-Applications/caddy-edge/Containerfile
Normal file
6
5-Applications/caddy-edge/Containerfile
Normal 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
|
||||
87
5-Applications/caddy-edge/caddy.json
Normal file
87
5-Applications/caddy-edge/caddy.json
Normal 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}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
95
5-Applications/caddy-edge/k8s/caddy.yaml
Normal file
95
5-Applications/caddy-edge/k8s/caddy.yaml
Normal 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: {}
|
||||
13
5-Applications/webrtc-bridge/Containerfile
Normal file
13
5-Applications/webrtc-bridge/Containerfile
Normal 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"]
|
||||
136
5-Applications/webrtc-bridge/README.md
Normal file
136
5-Applications/webrtc-bridge/README.md
Normal 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)
|
||||
434
5-Applications/webrtc-bridge/client/index.html
Normal file
434
5-Applications/webrtc-bridge/client/index.html
Normal 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>
|
||||
3
5-Applications/webrtc-bridge/go.sum
Normal file
3
5-Applications/webrtc-bridge/go.sum
Normal 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=
|
||||
100
5-Applications/webrtc-bridge/k8s/bridge.yaml
Normal file
100
5-Applications/webrtc-bridge/k8s/bridge.yaml
Normal 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
|
||||
287
5-Applications/webrtc-bridge/signaling-server.go
Normal file
287
5-Applications/webrtc-bridge/signaling-server.go
Normal 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 }
|
||||
Loading…
Add table
Reference in a new issue