diff --git a/5-Applications/caddy-edge/.gitignore b/5-Applications/caddy-edge/.gitignore new file mode 100644 index 00000000..90f42c63 --- /dev/null +++ b/5-Applications/caddy-edge/.gitignore @@ -0,0 +1,2 @@ +caddy +caddy.tar.gz diff --git a/5-Applications/caddy-edge/Caddyfile b/5-Applications/caddy-edge/Caddyfile new file mode 100644 index 00000000..37166426 --- /dev/null +++ b/5-Applications/caddy-edge/Caddyfile @@ -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 +} diff --git a/5-Applications/caddy-edge/Containerfile b/5-Applications/caddy-edge/Containerfile new file mode 100644 index 00000000..5d9617d0 --- /dev/null +++ b/5-Applications/caddy-edge/Containerfile @@ -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 diff --git a/5-Applications/caddy-edge/caddy.json b/5-Applications/caddy-edge/caddy.json new file mode 100644 index 00000000..f141cf31 --- /dev/null +++ b/5-Applications/caddy-edge/caddy.json @@ -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}" + } + } + } + } + ] + } + ] + } + } + } +} diff --git a/5-Applications/caddy-edge/k8s/caddy.yaml b/5-Applications/caddy-edge/k8s/caddy.yaml new file mode 100644 index 00000000..b1d24818 --- /dev/null +++ b/5-Applications/caddy-edge/k8s/caddy.yaml @@ -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: {} diff --git a/5-Applications/webrtc-bridge/Containerfile b/5-Applications/webrtc-bridge/Containerfile new file mode 100644 index 00000000..41886d85 --- /dev/null +++ b/5-Applications/webrtc-bridge/Containerfile @@ -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"] diff --git a/5-Applications/webrtc-bridge/README.md b/5-Applications/webrtc-bridge/README.md new file mode 100644 index 00000000..469955c2 --- /dev/null +++ b/5-Applications/webrtc-bridge/README.md @@ -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) diff --git a/5-Applications/webrtc-bridge/client/index.html b/5-Applications/webrtc-bridge/client/index.html new file mode 100644 index 00000000..311dd7f9 --- /dev/null +++ b/5-Applications/webrtc-bridge/client/index.html @@ -0,0 +1,434 @@ + + +
+ + +Direct P2P to Research Stack — bypasses DERP relay
+ + + +