mirror of
https://github.com/allaunthefox/Research-Stack.git
synced 2026-07-30 18:56:16 +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