diff --git a/.env.example b/.env.example index 4ed5a8d..08123c8 100644 --- a/.env.example +++ b/.env.example @@ -8,7 +8,7 @@ NODE_ENV=development # Database (SQLite file path) DATABASE_URL=./data/keylessh.db -# External TCP Bridge (optional, for production scaling) +# External TCP Bridge (optional, for production SSH scaling) # BRIDGE_URL=wss://your-bridge.azurecontainerapps.io # Note: Bridge authenticates using JWT verified against JWKS (same tidecloak.json) @@ -16,6 +16,23 @@ DATABASE_URL=./data/keylessh.db # If not set, tcp-bridge falls back to data/tidecloak.json # CLIENT_ADAPTER='{"realm":"...","auth-server-url":"...","resource":"...","jwk":{...}}' +# ============================================ +# Signal Server (VM deployment — see signal-server/) +# ============================================ +# The signal server handles P2P signaling, HTTP relay, and gateway portal. +# Deploy it on a VM alongside coturn using: signal-server/deploy.sh +# +# API_SECRET: Shared secret for gateway registration authentication (timing-safe) +# API_SECRET=your-shared-secret-here +# +# STUN/TURN configuration for WebRTC P2P upgrade: +# ICE_SERVERS: Comma-separated STUN server URLs +# ICE_SERVERS=stun:relay.example.com:3478 +# TURN_SERVER: TURN relay fallback URL +# TURN_SERVER=turn:relay.example.com:3478 +# TURN_SECRET: Shared secret for TURN REST API ephemeral credentials (HMAC-SHA256) +# TURN_SECRET=your-turn-secret-here + # Auth server override (optional, for testing) # AUTH_SERVER_OVERRIDE_URL=http://localhost:8080 diff --git a/README.md b/README.md index a3f507a..faf92f5 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,24 @@ The result: enterprise-grade SSH access control without any private keys to mana - **Programmable policy encforcement** with Forseti contracts for SSH access - **Simple, static, trustless SSH account access** (e.g., only `ssh:root` role holders can SSH as root) - **Admin UX**: servers, users, roles, policy templates, change requests (access, roles, policies), sessions, logs -- **Optional external bastion** (`tcp-bridge`) for scalable WS↔TCP tunneling +- **Optional external bastion** (`bridges/tcp-bridge`) for scalable WS↔TCP tunneling +- **NAT-traversing HTTP gateway** (`bridges/punchd-bridge`) with WebRTC P2P upgrade + +## Project Structure + +``` +keylessh/ +├── client/ # React UI (xterm.js, SSH client, SFTP browser) +├── server/ # Express API + WebSocket bridge + SQLite +├── shared/ # Shared types + schema +├── signal-server/ # P2P signaling + HTTP relay for punchd-bridge +├── bridges/ +│ ├── tcp-bridge/ # Stateless WS↔TCP forwarder (optional) +│ └── punchd-bridge/ # NAT-traversing HTTP reverse proxy gateway +│ └── gateway/ # Gateway source code +├── docs/ # Architecture, deployment, developer guides +└── script/ # TideCloak setup scripts +``` ## Documentation @@ -43,6 +60,11 @@ The result: enterprise-grade SSH access control without any private keys to mana - Deployment: [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md) - Developer guide: [docs/DEVELOPERS.md](docs/DEVELOPERS.md) +### Component docs + +- **Punc'd Bridge** — NAT-traversing HTTP reverse proxy that lets you expose local web apps through a public signal server without port forwarding. Starts with HTTP relay over WebSocket, then upgrades to peer-to-peer WebRTC DataChannels. See [bridges/punchd-bridge/docs/ARCHITECTURE.md](bridges/punchd-bridge/docs/ARCHITECTURE.md) for the full connection lifecycle, PlantUML diagrams, and multi-backend routing. +- **Signal Server** — Public signaling hub that brokers WebSocket connections between gateways and clients. Handles gateway registration, ICE candidate exchange, HTTP request relay, and TURN credential provisioning. Deployed alongside a coturn sidecar for STUN/TURN. See [signal-server/deploy.sh](signal-server/deploy.sh) for the automated VM deployment script. + ## Quickstart (Local Dev) ### 1. Clone and start TideCloak diff --git a/bridges/punchd-bridge/.gitignore b/bridges/punchd-bridge/.gitignore new file mode 100644 index 0000000..b2ba89f --- /dev/null +++ b/bridges/punchd-bridge/.gitignore @@ -0,0 +1,12 @@ +node_modules +dist +.DS_Store +*.tar.gz +data/ +*.db +*.db-wal +*.db-shm +.env +.env.local +.env.*.local +.stun-deploy.env diff --git a/bridges/punchd-bridge/README.md b/bridges/punchd-bridge/README.md new file mode 100644 index 0000000..7e3d553 --- /dev/null +++ b/bridges/punchd-bridge/README.md @@ -0,0 +1,104 @@ +# Punc'd + +NAT-traversing authenticated reverse proxy gateway. Access private web applications from anywhere through hole-punched WebRTC DataChannels. + +## How it works + +``` +Browser → Signal Server (relay) → Gateway → Backend App + │ ↕ coturn │ + └──── WebRTC DataChannel (P2P) ────┘ + (after hole punch) +``` + +The system has three components: + +1. **Signal Server** (public) — signaling hub, HTTP relay, portal & admin dashboard. Run by the infrastructure operator. Lives in the main repo as `signal-server/`. +2. **coturn** (public, sidecar) — STUN/TURN server for NAT traversal and relay fallback. Runs alongside the signal server with `--network host`. +3. **Gateway** (private) — authenticating reverse proxy that registers with the signal server. Run by anyone who wants to expose a local app. + +The signal server and gateway can be run by **different operators**. Clients connect through the signal server's HTTP relay, then upgrade to peer-to-peer WebRTC DataChannels via NAT hole punching (using coturn for STUN binding and TURN relay fallback). A Service Worker transparently routes browser fetches through the DataChannel. + +## Quick start + +```bash +./start.sh +``` + +On first run this will: +1. Start TideCloak (Docker) and walk you through realm initialization +2. Prompt for `API_SECRET` and `TURN_SECRET` (get these from the signal server operator) +3. Install dependencies, build, and start the gateway + +On subsequent runs, TideCloak and secrets are reused automatically. + +### Options + +```bash +./start.sh --skip-tc # skip TideCloak, gateway only +API_SECRET=xxx TURN_SECRET=yyy ./start.sh # pass secrets directly +SIGNAL_SERVER_URL=wss://signal.example.com:9090 ./start.sh # custom signal server +BACKEND_URL=http://localhost:4000 ./start.sh # custom backend +``` + +### Gateway only (without TideCloak) + +```bash +script/gateway/start.sh +``` + +Prompts for secrets on first run and saves them to `script/gateway/.env`. + +### Docker Compose (all services) + +```bash +docker compose up --build +``` + +This starts coturn, the signal server, and the gateway together. Set `TURN_SECRET`, `API_SECRET`, and `EXTERNAL_IP` in your environment or a `.env` file. + +## Secrets + +| Secret | Generated by | Shared with | Purpose | +|--------|-------------|-------------|---------| +| `API_SECRET` | Signal server operator | Gateway operators | Authenticates gateway registration (timing-safe) | +| `TURN_SECRET` | Signal server operator | Gateway operators | Generates ephemeral TURN credentials (HMAC-SHA256) | + +**Secret flow:** +1. Signal server operator deploys and generates secrets (or sets them manually) +2. Signal server operator shares `API_SECRET` + `TURN_SECRET` with gateway operators +3. Gateway operator sets them as env vars or pastes them when `./start.sh` prompts + +The start scripts load secrets in this order: +1. Environment variables (if already set) +2. `script/gateway/.env` (saved from a previous run) +3. `signal-server/.env` (only if you run both locally) +4. Prompts you to paste them (saves to `script/gateway/.env` for next time) + +## Environment variables + +See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md#configuration-reference) for the full configuration reference. + +### Key variables + +| Variable | Component | Description | +|----------|-----------|-------------| +| `SIGNAL_SERVER_URL` | Gateway | WebSocket URL of the signal server | +| `BACKEND_URL` | Gateway | Backend to proxy to | +| `BACKENDS` | Gateway | Multiple backends: `"App=http://host:3000,Auth=http://host:8080;noauth"` | +| `API_SECRET` | Both | Shared secret for gateway registration | +| `TURN_SECRET` | Both + coturn | Shared secret for TURN credentials (HMAC-SHA256) | +| `EXTERNAL_IP` | coturn | Public IP for TURN relay addresses | +| `TIDECLOAK_CONFIG_B64` | Both | Base64 TideCloak config for authentication | +| `TC_INTERNAL_URL` | Gateway | Internal TideCloak URL when `KC_HOSTNAME` is public | +| `GATEWAY_DISPLAY_NAME` | Gateway | Name shown in the portal | +| `GATEWAY_DESCRIPTION` | Gateway | Description shown in the portal | + +## Documentation + +- [Architecture & Protocol Details](docs/ARCHITECTURE.md) — full system docs with diagrams +- [PlantUML sources](docs/diagrams/) — editable sequence diagrams + +## License + +Proprietary diff --git a/bridges/punchd-bridge/admin.sh b/bridges/punchd-bridge/admin.sh new file mode 100755 index 0000000..ece84b7 --- /dev/null +++ b/bridges/punchd-bridge/admin.sh @@ -0,0 +1,135 @@ +#!/usr/bin/env bash +# TideCloak admin helper — interactive admin session. +# +# Temporarily restarts TideCloak with KC_HOSTNAME=localhost so the admin +# console works natively (no proxy issues). When you're done, it restarts +# with the public KC_HOSTNAME and re-signs IdP settings. +# +# Usage: +# ./admin.sh # interactive admin session (localhost → public) +# ./admin.sh --resign # just re-sign IdP settings (no admin session) + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")" && pwd)" +TC_PORT="${TC_PORT:-8080}" +TC_CONTAINER="${TC_CONTAINER:-mytidecloak}" +REALM_NAME="${NEW_REALM_NAME:-keylessh}" +CLIENT_NAME="${CLIENT_NAME:-myclient}" +STUN_SERVER_URL="${STUN_SERVER_URL:-wss://tidestun.codesyo.com:9090}" +TC_PUBLIC_URL="${TC_PUBLIC_URL:-$(echo "$STUN_SERVER_URL" | sed 's|^wss://|https://|;s|^ws://|http://|;s|:[0-9]*$||')}" + +wait_for_tc() { + echo -n " Waiting for TideCloak..." + for i in $(seq 1 30); do + if curl -s -f --connect-timeout 3 "http://localhost:${TC_PORT}" > /dev/null 2>&1; then + echo " ready" + return + fi + echo -n "." + sleep 3 + done + echo " timeout!" + exit 1 +} + +get_admin_token() { + curl -s -f -X POST "http://localhost:${TC_PORT}/realms/master/protocol/openid-connect/token" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "username=admin" -d "password=password" \ + -d "grant_type=password" -d "client_id=admin-cli" | jq -r '.access_token' +} + +restart_tc() { + local kc_hostname="$1" + docker rm -f "$TC_CONTAINER" > /dev/null 2>&1 || true + docker run \ + --name "$TC_CONTAINER" \ + -d \ + -v "${REPO_ROOT}/script/tidecloak":/opt/keycloak/data/h2 \ + -p "${TC_PORT}:8080" \ + -e KC_BOOTSTRAP_ADMIN_USERNAME=admin \ + -e KC_BOOTSTRAP_ADMIN_PASSWORD=password \ + -e "KC_HOSTNAME=${kc_hostname}" \ + -e SYSTEM_HOME_ORK=https://sork1.tideprotocol.com \ + -e USER_HOME_ORK=https://sork1.tideprotocol.com \ + -e THRESHOLD_T=3 \ + -e THRESHOLD_N=5 \ + -e PAYER_PUBLIC=20000011d6a0e8212d682657147d864b82d10e92776c15ead43dcfdc100ebf4dcfe6a8 \ + tideorg/tidecloak-stg-dev:latest + wait_for_tc +} + +resign_idp_settings() { + echo " Signing IdP settings..." + local token + token="$(get_admin_token)" + curl -s -f -X POST "http://localhost:${TC_PORT}/admin/realms/${REALM_NAME}/vendorResources/sign-idp-settings" \ + -H "Authorization: Bearer $token" > /dev/null 2>&1 + echo " IdP settings signed with current KC_HOSTNAME" +} + +# ── --resign: just re-sign without interactive session ────────── +if [ "${1:-}" = "--resign" ]; then + echo "[Admin] Re-signing IdP settings with public URL: ${TC_PUBLIC_URL}" + echo "" + + echo " Step 1/2: Restarting TideCloak with KC_HOSTNAME=${TC_PUBLIC_URL}" + restart_tc "$TC_PUBLIC_URL" + + echo " Step 2/2: Signing IdP settings..." + resign_idp_settings + + echo "" + echo "Done! IdP settings signed with ${TC_PUBLIC_URL}." + echo "Restart the gateway to pick up the new KC_HOSTNAME." + exit 0 +fi + +# ── Interactive admin session ─────────────────────────────────── +echo "" +echo "=== TideCloak Admin Session ===" +echo "" +echo "This will:" +echo " 1. Restart TideCloak with KC_HOSTNAME=localhost (admin console works)" +echo " 2. Wait for you to do admin work" +echo " 3. Restart with KC_HOSTNAME=${TC_PUBLIC_URL} (for Tide SDK)" +echo " 4. Re-sign IdP settings" +echo "" +read -rp "Press Enter to start (Ctrl+C to cancel)..." + +# Step 1: Restart with localhost +echo "" +echo "[Step 1/4] Restarting TideCloak with KC_HOSTNAME=http://localhost:${TC_PORT}" +restart_tc "http://localhost:${TC_PORT}" + +echo "" +echo "============================================" +echo " Admin Console Ready!" +echo "" +echo " URL: http://localhost:${TC_PORT}/admin/master/console/" +echo " Login: admin / password" +echo " Realm: ${REALM_NAME}" +echo "" +echo " Do your admin work now (create token drafts, etc.)" +echo "============================================" +echo "" +read -rp "Press Enter when done with admin work..." + +# Step 2: Restart with public URL +echo "" +echo "[Step 2/4] Restarting TideCloak with KC_HOSTNAME=${TC_PUBLIC_URL}" +restart_tc "$TC_PUBLIC_URL" + +# Step 3: Re-sign IdP settings +echo "[Step 3/4] Re-signing IdP settings with public URL" +resign_idp_settings + +# Step 4: Done +echo "" +echo "[Step 4/4] Done!" +echo "" +echo " KC_HOSTNAME is now: ${TC_PUBLIC_URL}" +echo " IdP settings signed with public URL." +echo " Restart the gateway to resume normal operation." +echo "" diff --git a/bridges/punchd-bridge/docker-compose.yml b/bridges/punchd-bridge/docker-compose.yml new file mode 100644 index 0000000..7cc6baf --- /dev/null +++ b/bridges/punchd-bridge/docker-compose.yml @@ -0,0 +1,36 @@ +version: "3.8" + +services: + coturn: + image: coturn/coturn:latest + network_mode: host + command: + - "--listening-port=3478" + - "--external-ip=${EXTERNAL_IP:-}" + - "--use-auth-secret" + - "--static-auth-secret=${TURN_SECRET:-}" + - "--realm=keylessh" + - "--min-port=49152" + - "--max-port=65535" + - "--fingerprint" + - "--no-multicast-peers" + - "--no-cli" + - "--log-file=stdout" + - "--verbose" + restart: unless-stopped + + gateway: + build: ./gateway + environment: + - LISTEN_PORT=7891 + - HEALTH_PORT=7892 + - BACKEND_URL=${BACKEND_URL:-http://host.docker.internal:3000} + - STUN_SERVER_URL=${STUN_SERVER_URL:-} + - TIDECLOAK_CONFIG_B64=${TIDECLOAK_CONFIG_B64:-} + - ICE_SERVERS=${ICE_SERVERS:-} + - TURN_SERVER=${TURN_SERVER:-} + - TURN_SECRET=${TURN_SECRET:-} + - AUTH_SERVER_PUBLIC_URL=${AUTH_SERVER_PUBLIC_URL:-} + - STRIP_AUTH_HEADER=${STRIP_AUTH_HEADER:-false} + - API_SECRET=${API_SECRET:-} + restart: unless-stopped diff --git a/bridges/punchd-bridge/docs/ARCHITECTURE.md b/bridges/punchd-bridge/docs/ARCHITECTURE.md new file mode 100644 index 0000000..8e10123 --- /dev/null +++ b/bridges/punchd-bridge/docs/ARCHITECTURE.md @@ -0,0 +1,612 @@ +# Punc'd Architecture + +Punc'd's signal server and gateway work together to provide authenticated, NAT-traversing access to backend web applications. The signal server acts as a public signaling hub and HTTP relay; a coturn sidecar handles STUN/TURN protocol traffic. The gateway is a local proxy that registers with the signal server and serves traffic from remote clients — first via HTTP relay, then upgraded to peer-to-peer WebRTC DataChannels. + +> PlantUML sources are in [docs/diagrams/](diagrams/) if you need to regenerate or edit the SVGs. + +## System Overview + +![System Overview](diagrams/system-overview.svg) + +**Components:** + +| Component | Role | Operator | +|-----------|------|----------| +| **Signal Server** | Signaling (WebSocket), HTTP relay, portal, admin dashboard | Infrastructure operator (public) | +| **coturn** | STUN Binding, TURN Allocate/Relay (RFC 5389/5766) | Infrastructure operator (sidecar, `--network host`) | +| **Gateway** | Authenticating reverse proxy, WebRTC peer handler | Anyone exposing a local app (private network) | + +## Connection Lifecycle + +### Phase 1 — Portal Selection + +The user visits the signal server's public URL. If no `gateway_relay` cookie is set, the signal server serves the portal page (`portal.html`). The user picks which gateway (and optionally which backend) to connect through. Selecting a gateway sets the `gateway_relay` cookie for session affinity and redirects to `/__b//` if a specific backend was chosen. + +### Phase 2 — HTTP Relay + +All subsequent requests are tunneled through the gateway's signaling WebSocket until WebRTC takes over. + +![HTTP Relay](diagrams/http-relay.svg) + +**Relay gateway selection order:** +1. `gateway_relay` cookie (session affinity from portal selection) +2. Realm-based routing (match `/realms//` in URL to gateway metadata) +3. Load-balance: gateway with fewest paired clients + +**Relay limits:** +- Request body: 10 MB max (413 if exceeded) +- Pending requests: 5,000 max (503 if exceeded) +- Relay timeout: 30 s (504 if exceeded) +- Pending requests are cleaned up when a gateway disconnects + +The HTML response is rewritten by the gateway proxy to: +- Replace `localhost:PORT` URLs with `/__b/` paths (cross-backend routing) +- Prepend `/__b/` to absolute paths in `href`, `src`, `action`, `formaction` attributes +- Inject a fetch/XHR patch script so JS requests with absolute paths get the prefix too +- Inject `webrtc-upgrade.js` before `` (triggers Phase 3) +- Inject a floating "Switch" button (when multiple backends are configured) linking to `/portal` + +### Phase 3 — WebRTC Upgrade & NAT Traversal (Hole Punching) + +The injected `webrtc-upgrade.js` script upgrades the connection from HTTP relay to a direct peer-to-peer DataChannel. This is the "hole punching" flow. + +#### Gateway Registration & Client Pairing + +![Gateway Registration](diagrams/gateway-registration.svg) + +The gateway registers via WebSocket on startup (API secret validated with timing-safe comparison). When a client registers (from `webrtc-upgrade.js`), the signal server pairs it with a gateway — either an explicitly selected gateway (via `targetGatewayId` from the portal) or the gateway with the fewest paired clients. + +**Registry limits:** max 100 gateways, max 10,000 clients. Connections exceeding per-IP limits (20) are rejected. Messages are rate-limited to 100/s per connection. + +#### ICE Candidate Exchange & STUN Binding + +This is where the actual hole punching happens. Both peers gather ICE candidates by probing their network interfaces and sending STUN Binding Requests to coturn. + +![Hole Punching](diagrams/hole-punching.svg) + +**How hole punching works:** + +1. Both peers (browser and gateway) send STUN Binding Requests to coturn on port 3478 +2. coturn responds with each peer's **server-reflexive address** — the public IP:port that the NAT assigned +3. These addresses are exchanged via the signaling channel as ICE candidates +4. Both peers attempt to send packets directly to each other's reflexive addresses +5. When peer A sends a packet to peer B's reflexive address, A's NAT creates a mapping. If B simultaneously sends to A's address, B's NAT also creates a mapping. The packets "punch through" both NATs. +6. Once a bidirectional path is established, the DataChannel opens over this direct path + +#### TURN Fallback + +If direct P2P fails (symmetric NAT, restrictive firewall), the ICE agent falls back to TURN relay through coturn. + +![TURN Fallback](diagrams/turn-fallback.svg) + +### Phase 4 — Service Worker Takes Over + +Once the DataChannel is open, a Service Worker is registered to transparently route sub-resource fetches through the DataChannel instead of HTTP relay. + +![Service Worker DataChannel](diagrams/service-worker-datachannel.svg) + +**Why a session token is needed:** HttpOnly cookies are inaccessible to JavaScript. When requests go through the normal HTTP relay, the browser automatically attaches cookies. But DataChannel messages are pure JavaScript — no cookie jar. So `webrtc-upgrade.js` fetches the JWT once via HTTP relay (`GET /auth/session-token`) and manually injects it into every DataChannel request's cookie header. + +**Service Worker routing logic:** +- Navigation requests (page loads) always use relay — new pages need their own DataChannel +- Requests to localhost targeting `/realms/*` or `/resources/*` are rewritten to same-origin (catches TideCloak SDK requests that use internal URLs) +- Non-same-origin requests are ignored +- Gateway-internal paths (`/js/`, `/auth/`, `/login`, `/webrtc-config`, `/_idp/`, `/realms/`, `/resources/`, `/portal`, `/health`) skip DataChannel and go through relay +- **DC readiness gate:** The SW only intercepts requests from clients that have signaled `dc_ready` (tracked in a `dcClients` set). If the requesting client has no active DataChannel, the SW does not call `event.respondWith()` — the browser handles the request natively with proper cookie handling and caching. This prevents stale data when navigating back before the DataChannel reconnects. +- Sub-resource requests without a `/__b/` prefix get the prefix prepended from the requesting page's URL +- DataChannel requests time out after 10 seconds, falling back to relay +- The page-side DataChannel handler times out after 15 seconds, also falling back to relay + +**Large response chunking:** Responses over 200KB are split into 150KB chunks to stay within the SCTP message size limit (~256KB): + +``` +Gateway sends: + { type:"http_response_start", id, statusCode, headers, totalChunks: 3 } + { type:"http_response_chunk", id, index: 0, data: "" } + { type:"http_response_chunk", id, index: 1, data: "" } + { type:"http_response_chunk", id, index: 2, data: "" } + +Browser reassembles all chunks, then resolves the pending fetch. +``` + +### Complete Lifecycle + +End-to-end view: portal selection → authentication → HTTP relay → WebRTC upgrade → Service Worker takeover. + +![Complete Lifecycle](diagrams/complete-lifecycle.svg) + +## Authentication Flow + +### Gateway OIDC Login (through relay) + +TideCloak traffic (`/realms/*`, `/resources/*`) is reverse-proxied through the gateway so the browser never needs direct access to the TideCloak server. The gateway maintains a server-side cookie jar (`tc_sess` cookie → stored TideCloak cookies) with per-session LRU eviction (1-hour TTL, max 10,000 sessions) to avoid relying on the signal relay to forward `Set-Cookie` headers. + +**Backend cookie jar:** The gateway also maintains a server-side cookie jar for backend `Set-Cookie` headers, keyed by JWT subject (`sub` claim). When backend responses include `Set-Cookie` headers, the gateway stores them. On DataChannel requests (marked with `x-dc-request: 1` by the peer handler), the gateway injects the stored cookies into the proxied request. This is necessary because DataChannel responses bypass the browser's native cookie handling — the Service Worker cannot set `Set-Cookie` headers on constructed `Response` objects (forbidden header). The backend cookie jar uses the same LRU eviction strategy (7-day TTL, max 10,000 entries). Cookies are stored for all authenticated responses so the jar is seeded from the initial HTTP relay page load. + +When `KC_HOSTNAME` is a public URL but TideCloak runs locally, `TC_INTERNAL_URL` tells the gateway where to actually send proxied requests and server-side token exchanges. Browser-facing auth URLs use `AUTH_SERVER_PUBLIC_URL` if set, otherwise relative paths (`/realms/...`) so auth traffic routes through the gateway. + +The gateway also handles an `/_idp/` prefix: TideCloak URLs are rewritten to `{publicOrigin}/_idp/...` so the Tide SDK enclave iframe can reach TideCloak through the relay. The gateway strips this prefix before proxying. + +**Open redirect prevention:** The `/auth/login?redirect=` parameter and post-callback redirect are sanitized — only relative paths starting with `/` are accepted. Protocol URLs (`https://`, `javascript:`), protocol-relative URLs (`//`), and non-path values are rejected and replaced with `/`. + +![OIDC Login](diagrams/oidc-login.svg) + +**Auth endpoints on the gateway:** +- `/auth/login?redirect=` — initiates OIDC redirect to TideCloak (redirect param sanitized) +- `/auth/callback?code=&state=` — exchanges code for tokens, sets `gateway_access` and `gateway_refresh` cookies +- `/auth/session-token` — returns the JWT from the HttpOnly cookie (for DataChannel auth, `Cache-Control: no-store`) +- `/auth/logout` — clears cookies and redirects to TideCloak logout +- `/realms/*`, `/resources/*` — reverse-proxied to TideCloak (public, no auth, 30 s timeout) + +**Token validation:** +1. `gateway_access` cookie (HttpOnly, `Lax`) +2. `Authorization: Bearer ` header +3. If expired, transparent refresh via `gateway_refresh` cookie (`Strict`) +4. Proxied requests include `x-forwarded-user` header with the subject claim + +### Portal/Admin TideCloak Auth + +The portal and admin pages use `@tidecloak/js` (bundled as `tidecloak.js`) for front-channel OIDC via `IAMService`: + +```javascript +IAMService.initIAM(config) // check-sso with silent iframe + .then(authenticated => { + if (!authenticated) IAMService.doLogin(); // redirect to TC + else start(); // load portal data + }); +``` + +The signal server's `/auth-config` endpoint serves the TideCloak client config (minus the JWK) as a JavaScript global. + +## Multi-Backend Routing + +The gateway supports multiple backend services behind a single endpoint using path-based routing. + +![Multi-Backend Routing](diagrams/multi-backend-routing.svg) + +### Configuration + +```bash +BACKENDS="App1=http://localhost:3000,MediaBox=http://localhost:8080" +``` + +If `BACKENDS` is not set, `BACKEND_URL` is used as a single backend named after `GATEWAY_DISPLAY_NAME` (or "Default"). + +#### Per-Backend `noauth` Flag + +Append `;noauth` to a backend URL to skip gateway-side JWT validation for that backend. Requests are proxied directly without requiring a TideCloak session — useful for backends that handle their own authentication (e.g. an auth server). + +```bash +BACKENDS="App=http://localhost:3000,AuthServer=http://localhost:8080;noauth" +``` + +- **Auth backends** (no `;noauth`): Requests without a valid JWT are redirected to the TideCloak login flow. The gateway sets `x-forwarded-user` from the verified token. +- **No-auth backends** (`;noauth`): Requests are forwarded as-is. No JWT extraction, validation, refresh, or login redirect. No `x-forwarded-user` header is set. + +### Path Prefix System + +``` +Request URL Backend URL forwarded to backend +────────────────────────────── ─────────────── ──────────────────────── +GET /__b/App1/api/data App1 GET /api/data +GET /__b/MediaBox/dashboard MediaBox GET /dashboard +GET /anything-else First backend GET /anything-else +``` + +Backend resolution order: +1. `/__b/` path prefix (highest priority) +2. `x-gateway-backend` header (set by signal relay) +3. First (default) backend + +### HTML Rewriting + +The gateway rewrites HTML responses (up to 50 MB) to maintain correct routing: + +| What | Before | After | +|------|--------|-------| +| Links | `href="/about"` | `href="/__b/App1/about"` | +| Scripts | `src="/main.js"` | `src="/__b/App1/main.js"` | +| Localhost refs | `http://localhost:3000/api` | `/__b/App1/api` | +| JS fetch/XHR | `fetch("/api/data")` | `fetch("/__b/App1/api/data")` (via injected patch) | +| Redirects | `Location: /dashboard` | `Location: /__b/App1/dashboard` | + +The injected fetch/XHR patch skips gateway-internal paths: `/js/`, `/auth/`, `/login`, `/webrtc-config`, `/realms/`, `/resources/`, `/portal`, `/health`. + +The Service Worker also rewrites URLs — if the page is at `/__b/App1/dashboard` and it fetches `/api/data`, the SW prepends `/__b/App1`. + +## TURN Protocol Details + +### coturn + +STUN/TURN protocol handling is delegated to [coturn](https://github.com/coturn/coturn), running as a Docker sidecar with `--network host`. coturn handles: + +- STUN Binding Requests/Responses (RFC 5389) for NAT discovery +- TURN Allocate/Refresh/Send/CreatePermission/ChannelBind (RFC 5766) for relay fallback +- Ephemeral credential validation via HMAC-SHA256 shared secret + +The signaling server and gateway only handle WebSocket signaling; all UDP/TCP STUN/TURN traffic goes directly to coturn. + +### TURN REST API Credentials (HMAC-SHA256) + +Both the gateway and coturn share a `TURN_SECRET`. The gateway generates short-lived credentials for clients: + +``` +username = String(Math.floor(Date.now() / 1000) + 3600) // expires in 1 hour +password = Base64(HMAC-SHA256(TURN_SECRET, username)) +``` + +coturn validates by recomputing the HMAC-SHA256 and checking the expiry timestamp. The `--auth-secret-algorithm=sha256` flag tells coturn to use SHA-256 instead of the legacy SHA-1 default. + +### STUN Message Format (RFC 5389) + +``` + 0 1 2 3 + 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 ++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +|0 0| STUN Message Type | Message Length | ++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +| Magic Cookie (0x2112A442) | ++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +| | +| Transaction ID (96 bits) | +| | ++-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ +``` + +### Supported TURN Methods + +| Method | Code | Auth Required | +|--------|------|---------------| +| Binding | 0x001 | No | +| Allocate | 0x003 | Yes (TURN) | +| Refresh | 0x004 | Yes (TURN) | +| Send | 0x006 | No (indication) | +| Data | 0x007 | No (indication) | +| CreatePermission | 0x008 | Yes (TURN) | +| ChannelBind | 0x009 | Yes (TURN) | + +### TURN Data Relay + +Two transport modes within an allocation: + +| Mode | Overhead | Description | +|------|----------|-------------| +| Send/Data indication | 36+ bytes | Full STUN header per packet | +| ChannelData | 4 bytes | `channelNumber:2 + length:2` header only | + +ChannelBind maps a 16-bit channel number (0x4000-0x7FFF) to a specific peer IP:port, enabling the low-overhead ChannelData framing. A channel binding implicitly installs a permission. + +### Packet-Type Detection + +The first byte of each UDP/TCP packet determines the protocol: + +``` +Bits 7-6 = 00 → STUN message (RFC 5389) +Bits 7-6 = 01 → ChannelData (TURN, channel numbers 0x4000-0x7FFF) +``` + +## Security + +### Security Headers + +Both servers set on every response: +- `X-Content-Type-Options: nosniff` +- `Referrer-Policy: strict-origin-when-cross-origin` + +The signal server sets `X-Frame-Options: DENY`. The gateway sets `X-Frame-Options: SAMEORIGIN` (Tide SDK enclave uses iframes). + +### Request Validation + +- **HTTP method whitelist:** Only `GET`, `POST`, `PUT`, `PATCH`, `DELETE`, `HEAD`, `OPTIONS` are allowed. Other methods return 405. Applied at all three request entry points (relay, DataChannel, backend proxy). +- **Open redirect prevention:** Auth redirect parameters only accept relative paths starting with `/`. +- **Body size limits:** 10 MB for relay requests, 64 KB for API POST bodies, 50 MB for proxied response buffering. + +### Authentication & Secrets + +- **API_SECRET:** Gateway registration with the signal server uses timing-safe comparison (`crypto.timingSafeEqual`). +- **TURN_SECRET:** Shared secret for HMAC-SHA256 ephemeral TURN credentials. +- **Gateway ID:** Generated with `crypto.randomBytes(8).toString("hex")` (16 hex chars) for cryptographic uniqueness. + +### Rate Limiting & Capacity + +| Resource | Limit | Behavior when exceeded | +|----------|-------|----------------------| +| WebSocket connections per IP | 20 | Connection rejected (close 1013) | +| Messages per connection per second | 100 | Connection closed (close 1008) | +| WebSocket message size | 1 MB | Message rejected | +| Registered gateways | 100 | Registration rejected | +| Registered clients | 10,000 | Registration rejected | +| Pending relay requests | 5,000 | HTTP 503 | +| WebRTC peer connections per gateway | 200 | New offers rejected | +| Admin WebSocket subscribers | 50 | Subscription rejected | +| TC cookie jar sessions | 10,000 | LRU eviction | + +### Proxy Timeouts + +All proxy requests (TideCloak, backend, relay loopback, DataChannel loopback) have a 30-second timeout. On timeout, the request is destroyed and a 504 response is returned. + +### Connection Resilience + +**Gateway → signal server:** The gateway reconnects to the signal server with exponential backoff: 1 s → 2 s → 4 s → 8 s → ... → 30 s max, with ±20% jitter. The delay resets to 1 s on successful connection. When a gateway disconnects, all its pending relay requests are rejected immediately (502) rather than waiting for the 30 s timeout. + +**Browser → DataChannel:** The `webrtc-upgrade.js` script automatically reconnects when the DataChannel or signaling WebSocket drops. On disconnect: +1. Cleans up the PeerConnection and DataChannel +2. Rejects all pending DataChannel requests (falling back to relay) +3. Notifies the Service Worker (`dc_closed`) so it stops intercepting +4. Schedules reconnect with exponential backoff: 5 s × 1.5^n, max 60 s +5. On reconnect: fetches a fresh session token, generates a new client ID, reconnects signaling +6. Backoff resets to 0 on successful DataChannel open + +ICE failure (`iceConnectionState === "failed"`) also triggers cleanup and reconnect. + +## Port Summary + +| Component | Port | Protocol | Purpose | +|-----------|------|----------|---------| +| coturn | 3478 | UDP + TCP | STUN Binding, TURN Allocate/Refresh/Send/ChannelBind | +| Signal server | 9090 | HTTP/WS (or HTTPS/WSS) | Signaling, portal, admin, HTTP relay, health check | +| coturn | 49152-65535 | UDP | TURN relay sockets (one per allocation) | +| Gateway | 7891 | HTTP or HTTPS | Proxy server (auth + backend routing) | +| Gateway | 7892 | HTTP | Health check endpoint | + +## Configuration Reference + +### coturn + +coturn is configured via CLI flags in `docker-compose.yml` or `deploy.sh`. Key settings: + +| Flag | Default | Description | +|------|---------|-------------| +| `--listening-port` | 3478 | STUN/TURN protocol port | +| `--external-ip` | (required) | Public IP for TURN relay addresses | +| `--static-auth-secret` | (required) | Shared secret for TURN credentials | +| `--auth-secret-algorithm` | sha256 | HMAC algorithm for credential validation | +| `--realm` | keylessh | TURN authentication realm | +| `--min-port` / `--max-port` | 49152-65535 | TURN relay port range | + +See [turnserver.conf](../signal-server/turnserver.conf) for the full configuration template. + +### Signal Server + +| Variable | Default | Description | +|----------|---------|-------------| +| `SIGNAL_PORT` | 9090 | Signaling + HTTP port | +| `API_SECRET` | (none) | Shared secret for gateway registration (timing-safe validated) | +| `TIDECLOAK_CONFIG_B64` | (none) | Base64 TideCloak adapter config (enables admin JWT auth) | +| `TLS_CERT_PATH` | (none) | TLS cert for signaling port (enables HTTPS/WSS) | +| `TLS_KEY_PATH` | (none) | TLS key for signaling port | + +### Gateway + +| Variable | Default | Description | +|----------|---------|-------------| +| `STUN_SERVER_URL` | (required) | WebSocket URL of the signal server signaling port | +| `LISTEN_PORT` | 7891 | Gateway HTTP/HTTPS port | +| `HEALTH_PORT` | 7892 | Health check port | +| `BACKENDS` | (none) | `"Name=http://host:port,Auth=http://host2:port;noauth"` | +| `BACKEND_URL` | (none) | Single backend URL (fallback if `BACKENDS` not set) | +| `GATEWAY_ID` | random (`gw-<16hex>`) | Unique gateway identifier (cryptographic random) | +| `GATEWAY_DISPLAY_NAME` | GATEWAY_ID | Display name in portal | +| `GATEWAY_DESCRIPTION` | (none) | Description shown in portal | +| `ICE_SERVERS` | derived from `STUN_SERVER_URL` | STUN server for WebRTC, e.g. `stun:host:3478` | +| `TURN_SERVER` | (none) | TURN server URL, e.g. `turn:host:3478` | +| `TURN_SECRET` | (none) | Shared secret for TURN credentials (HMAC-SHA256) | +| `API_SECRET` | (none) | Shared secret for signal server registration | +| `TIDECLOAK_CONFIG_B64` | (none) | Base64 TideCloak adapter config | +| `TIDECLOAK_CONFIG_PATH` | `/data/tidecloak.json` | File path to TideCloak config (fallback if B64 not set) | +| `AUTH_SERVER_PUBLIC_URL` | (none) | Public TideCloak URL for browser redirects | +| `TC_INTERNAL_URL` | config `auth-server-url` | Internal TideCloak URL for proxying and token exchange | +| `HTTPS` | true | Generate self-signed TLS for gateway (`false` to disable) | +| `TLS_HOSTNAME` | localhost | Hostname for the self-signed certificate | +| `STRIP_AUTH_HEADER` | false | Remove `Authorization` header before proxying to backend | + +## Signal Server API Routes + +The signal server (port 9090) serves these HTTP endpoints in addition to the WebSocket signaling and HTTP relay: + +| Path | Method | Auth | Description | +|------|--------|------|-------------| +| `/health` | GET | None | Health check with registry stats | +| `/portal` | GET | None | Portal page (clears `gateway_relay` cookie) | +| `/` | GET | None | Portal if no `gateway_relay` cookie, else relay to gateway | +| `/admin` | GET | None | Admin dashboard page | +| `/api/gateways` | GET | User JWT (if configured) | List registered gateways for portal | +| `/api/admin/stats` | GET | Admin JWT | Detailed stats (gateways, clients, connections) | +| `/api/select-gateway` | POST | None | Set `gateway_relay` cookie (body: `{gatewayId, backend?}`, max 64 KB) | +| `/api/select` | GET | None | Set cookie + redirect (`?gateway=&backend=`) | +| `/api/clear-selection` | POST | None | Clear `gateway_relay` cookie | +| `/auth-config` | GET | None | TideCloak client config as JS global | +| `/silent-check-sso.html` | GET | None | TideCloak silent SSO iframe | +| `/static/*` | GET | None | Static assets | + +All other requests are passed to the HTTP relay handler. + +## Signaling Message Reference + +### WebSocket Messages (signal server signaling) + +| Type | Direction | Fields | Description | +|------|-----------|--------|-------------| +| `register` | Gateway/client → server | `id`, `role`, `secret`?, `addresses`?, `metadata`?, `targetGatewayId`? | Register in the registry | +| `registered` | server → gateway/client | `role`, `id` | Registration confirmed | +| `paired` | server → both | `gateway:{id, addresses}` or `client:{id, reflexiveAddress}` | Pairing established | +| `candidate` | bidirectional | `fromId`, `targetId`, `candidate:{candidate, mid}` | ICE candidate forwarding | +| `sdp_offer` | client → gateway (via server) | `fromId`, `targetId`, `sdp`, `sdpType` | SDP offer | +| `sdp_answer` | gateway → client (via server) | `fromId`, `targetId`, `sdp`, `sdpType` | SDP answer | +| `http_request` | server → gateway | `id`, `method`, `url`, `headers`, `body` (base64) | Relay HTTP request | +| `http_response` | gateway → server | `id`, `statusCode`, `headers`, `body` (base64) | Relay HTTP response | +| `client_status` | gateway → server | `clientId`, `connectionType` (`relay`/`p2p`/`turn`) | Connection type update (ownership verified) | +| `subscribe_stats` | admin → server | `token`? | Subscribe to live stats (max 50 subscribers) | +| `stats_update` | server → admin | `gateways[]`, `clients[]` | Live stats (every 3s) | +| `admin_action` | admin → server | `token`, `action` (`drain_gateway`/`disconnect_client`), `targetId` | Admin action | +| `admin_result` | server → admin | `action`, `targetId`, `success` | Admin action result | +| `error` | server → gateway/client | `message` | Error notification | + +**Registration metadata** (gateway only): `{ displayName?, description?, backends?: [{name}], realm? }` + +**Client `targetGatewayId`**: If set on a `register` message with `role: "client"`, the server pairs the client with that specific gateway instead of load-balancing. + +### DataChannel Messages (gateway <-> browser) + +| Type | Direction | Fields | Description | +|------|-----------|--------|-------------| +| `http_request` | browser → gateway | `id`, `method`, `url`, `headers`, `body` (base64) | HTTP request over DC | +| `http_response` | gateway → browser | `id`, `statusCode`, `headers`, `body` (base64) | Response (< 200KB) | +| `http_response_start` | gateway → browser | `id`, `statusCode`, `headers`, `totalChunks` | Chunked response start | +| `http_response_chunk` | gateway → browser | `id`, `index`, `data` (base64) | Chunked response part | + +### Service Worker Messages (page <-> SW) + +| Type | Direction | Fields | Description | +|------|-----------|--------|-------------| +| `dc_ready` | page → SW | (none) | DataChannel is open | +| `dc_closed` | page → SW | (none) | DataChannel closed | +| `dc_fetch` | SW → page | `url`, `method`, `headers`, `body` | Route fetch via DC | + +## Secrets & Operator Model + +The signal server and gateway can be run by **different operators**. The signal server operator generates secrets and shares them with gateway operators. + +| Secret | Generated by | Given to | Purpose | +|--------|-------------|----------|---------| +| `API_SECRET` | Signal server operator | Gateway operators | Authenticates gateway registration (timing-safe validated) | +| `TURN_SECRET` | Signal server operator | Gateway operators | Generates ephemeral TURN relay credentials (HMAC-SHA256) | + +**Typical flow:** +1. Signal server operator deploys → secrets auto-generated by `signal-server/deploy.sh` +2. Signal server operator gives `API_SECRET` + `TURN_SECRET` to gateway operators +3. Gateway operator sets them as env vars or pastes when prompted by `./start.sh` or `script/gateway/start.sh` + +Secrets are saved to `script/gateway/.env` on first run so you only enter them once. + +## Timeouts + +| What | Duration | Behavior on timeout | +|------|----------|-------------------| +| HTTP relay (signal → gateway response) | 30 s | 504 to client | +| TideCloak proxy request | 30 s | 504 to client | +| Backend proxy request | 30 s | 504 to client | +| DataChannel loopback request | 30 s | 504 over DataChannel | +| Relay loopback request | 30 s | 504 over WebSocket | +| DataChannel fetch (page-side) | 15 s | Falls back to relay | +| Service Worker fetch (SW-side) | 10 s | Falls back to relay | +| Gateway WebSocket reconnect | 1–30 s | Exponential backoff with ±20% jitter | +| TURN allocation | 600 s (default) | Allocation + permissions + channels deleted | +| TURN permission | 300 s | Permission expired | +| TURN channel binding | 600 s | Channel expired | +| TideCloak cookie jar session | 3600 s | Session evicted (LRU) | + +## Local Development + +### Start Signal Server + +```bash +cd signal-server +npm install +npm run build +npm start +``` + +On first deploy, `deploy.sh` generates and prints `API_SECRET` and `TURN_SECRET`. + +### Start Gateway (connecting to an existing signal server) + +```bash +cd gateway +npm install + +# Required: signal server URL and secrets (from the signal server operator) +export STUN_SERVER_URL=wss://signal.example.com:9090 +export API_SECRET= +export TURN_SECRET= + +# Required: at least one backend +export BACKEND_URL=http://localhost:3000 +# Or multiple backends (append ;noauth to skip gateway JWT validation): +export BACKENDS="App=http://localhost:3000,Auth=http://localhost:8080;noauth" + +# Optional: display name in portal +export GATEWAY_DISPLAY_NAME="My Local Gateway" +export GATEWAY_DESCRIPTION="Development environment" + +# Optional: internal TideCloak URL (if KC_HOSTNAME is a public URL) +export TC_INTERNAL_URL=http://localhost:8080 + +npm run build +npm start +``` + +Or use the convenience scripts: + +```bash +# Full setup (TideCloak + gateway): +./start.sh + +# Gateway only (prompts for secrets on first run): +script/gateway/start.sh +``` + +### Running both locally (Docker Compose) + +If you run both components yourself, `docker-compose.yml` wires them together (including coturn): + +```bash +docker compose up --build +``` + +## Troubleshooting + +### WebRTC upgrade not happening + +- Check browser console for `[WebRTC]` log messages +- Verify `/webrtc-config` returns valid STUN/TURN URLs +- Ensure coturn port 3478 is reachable (UDP and TCP) +- Check that `TURN_SECRET` matches between gateway and coturn +- Verify `ICE_SERVERS` is set on the gateway (derived from `STUN_SERVER_URL` if not explicit) + +### TURN allocation failing + +- Verify credentials: `TURN_SECRET` must be identical on gateway and coturn +- Ensure coturn is running with `--auth-secret-algorithm=sha256` +- Check that coturn's UDP port range (49152-65535) is accessible +- Look for `401 Unauthorized` in coturn logs (credential mismatch) +- Ensure `EXTERNAL_IP` is set correctly on coturn (required for TURN relay addresses) +- Test credentials manually: + ```bash + USER="$(date -d '+1 hour' +%s)" + PASS=$(echo -n "$USER" | openssl dgst -sha256 -hmac "$TURN_SECRET" -binary | base64) + turnutils_uclient -u "$USER" -w "$PASS" -p 3478 + ``` + +### HTTP relay timeout (504) + +- The signal server waits 30 seconds for a gateway response +- Check that the gateway is connected (admin dashboard shows gateway status) +- Check gateway logs for errors proxying to the backend +- Pending requests are cleaned up when a gateway disconnects (502 instead of 504) + +### Service Worker not intercepting + +- The `Service-Worker-Allowed: /` header must be present on `/js/sw.js` +- Check `navigator.serviceWorker.controller` is not null +- Page navigations are intentionally not intercepted (only sub-resources) + +### Auth redirect loop + +- Ensure `TIDECLOAK_CONFIG_B64` or `TIDECLOAK_CONFIG_PATH` is set on the gateway +- Check that the TideCloak client has the gateway's origin in its valid redirect URIs +- Verify the `gateway_access` and `gateway_refresh` cookies are being set +- If `KC_HOSTNAME` is a public URL, set `TC_INTERNAL_URL=http://localhost:8080` so server-side token exchange reaches TideCloak + +### TideCloak proxy issues + +- Check that `/realms/*` and `/resources/*` requests are reaching the gateway +- If using `TC_INTERNAL_URL`, ensure the gateway can reach TideCloak at that address +- The Service Worker rewrites localhost TideCloak URLs to same-origin — check for `[SW] Rewriting localhost request:` in console + +### Rate limiting / connection issues + +- If gateway registration fails, verify `API_SECRET` matches (timing-safe comparison — no timing side-channel) +- If connections are rejected, check per-IP limits (max 20 WebSocket connections per IP) +- If messages are dropped, check rate limits (max 100 messages/s per connection) +- Registry limits: max 100 gateways, max 10,000 clients diff --git a/bridges/punchd-bridge/docs/diagrams/01-system-overview.puml b/bridges/punchd-bridge/docs/diagrams/01-system-overview.puml new file mode 100644 index 0000000..e41b188 --- /dev/null +++ b/bridges/punchd-bridge/docs/diagrams/01-system-overview.puml @@ -0,0 +1,73 @@ +@startuml system-overview +!theme plain +!pragma layout smetana +title Punc'd — System Overview + +skinparam { + backgroundColor #0f172a + defaultFontColor #f8fafc + defaultFontName "Segoe UI" + defaultFontSize 13 + ArrowColor #94a3b8 + ArrowFontColor #94a3b8 + NoteBackgroundColor #1e293b + NoteBorderColor #334155 + NoteFontColor #f8fafc + ComponentBackgroundColor #1e293b + ComponentBorderColor #334155 + ComponentFontColor #f8fafc + DatabaseBackgroundColor #1e293b + DatabaseBorderColor #334155 + DatabaseFontColor #f8fafc + CloudBackgroundColor #1e293b + CloudBorderColor #334155 + CloudFontColor #f8fafc + RectangleBorderColor #334155 + RectangleBackgroundColor #1e293b + RectangleFontColor #f8fafc + ActorBorderColor #3b82f6 + ActorBackgroundColor #1e293b + ActorFontColor #f8fafc +} + +actor "Browser" as browser #3b82f6 + +cloud "Internet" as inet { +} + +package "Signal Server (public)" as stun { + component "Signaling\n(WS port 9090)" as sig + component "Portal & Admin\n(HTTP port 9090)" as portal + database "In-memory\nRegistry" as reg +} + +package "coturn (sidecar)" as coturnpkg { + component "STUN/TURN\n(UDP+TCP port 3478)" as stunproto + component "TURN Relay\n(UDP 49152-65535)" as relay +} + +package "Private Network" as priv { + component "Gateway\n(port 7891)" as gw + component "Backend App 1\n(port 3000)" as app1 + component "Backend App 2\n(port 8080)" as app2 +} + +cloud "TideCloak" as tc { + component "OIDC / Auth" as oidc +} + +browser -down-> portal : "1. Pick Gateway\n(HTTP)" +browser -down-> sig : "2. HTTP relay\n(WebSocket tunnel)" +browser -down-> stunproto : "3. STUN Binding\n(NAT discovery)" +browser <-down-> relay : "5. TURN relay\n(NAT fallback)" +browser <-right-> gw : "4. P2P DataChannel\n(hole-punched)" + +sig <-right-> gw : "Persistent WS\n(registration +\nrelay + signaling)" +sig -down-> reg + +gw -down-> app1 : "Proxy" +gw -down-> app2 : "Proxy" +gw -left-> oidc : "OIDC\ncode exchange" +browser -right-> oidc : "Login\n(via Gateway proxy)" + +@enduml diff --git a/bridges/punchd-bridge/docs/diagrams/02-gateway-registration.puml b/bridges/punchd-bridge/docs/diagrams/02-gateway-registration.puml new file mode 100644 index 0000000..4ae64d2 --- /dev/null +++ b/bridges/punchd-bridge/docs/diagrams/02-gateway-registration.puml @@ -0,0 +1,67 @@ +@startuml gateway-registration +!theme plain +title Gateway Registration & Client Pairing + +skinparam { + backgroundColor #0f172a + defaultFontColor #f8fafc + defaultFontName "Segoe UI" + defaultFontSize 12 + SequenceLifeLineBorderColor #334155 + SequenceLifeLineBackgroundColor #1e293b + ParticipantBackgroundColor #1e293b + ParticipantBorderColor #3b82f6 + ParticipantFontColor #f8fafc + ArrowColor #94a3b8 + ArrowFontColor #94a3b8 + NoteBackgroundColor #1e293b + NoteBorderColor #334155 + NoteFontColor #f8fafc + SequenceGroupBackgroundColor #1e293b + SequenceGroupBorderColor #334155 + SequenceGroupFontColor #f8fafc + SequenceDividerBackgroundColor #334155 + SequenceDividerFontColor #f8fafc +} + +participant "Gateway" as gw #1e293b +participant "Signal Server\n(signaling)" as stun #1e293b +participant "Browser" as browser #1e293b + +== Gateway Registration == + +gw -> stun : WebSocket connect +activate stun + +gw -> stun : register\n{ role: "gateway", id: "gw-a1b2c3...",\n secret: "...",\n addresses: ["10.0.0.5:7891"],\n metadata: { displayName, backends } } + +stun -> stun : Timing-safe validate\nAPI_SECRET → add to registry\n(max 100 gateways) + +stun --> gw : registered\n{ role: "gateway", id: "gw-a1b2c3..." } + +note over gw, stun + Gateway keeps WebSocket open. + Reconnects with exponential backoff + (1 s → 2 s → 4 s → ... → 30 s max, ±20 % jitter). + Resets to 1 s on successful connection. +end note + +== Client Connects == + +browser -> stun : WebSocket connect +activate browser + +stun -> stun : Per-IP connection limit\n(max 20 per IP) + +browser -> stun : register\n{ role: "client", id: "client-abc" } + +stun -> stun : Add to registry (max 10 000)\nFind least-loaded gateway\nPair client with gateway + +stun --> browser : paired\n{ gateway: { id: "gw-a1b2c3...",\n addresses: ["10.0.0.5:7891"] } } + +stun --> gw : paired\n{ client: { id: "client-abc",\n reflexiveAddress: "1.2.3.4" } } + +deactivate stun +deactivate browser + +@enduml diff --git a/bridges/punchd-bridge/docs/diagrams/03-http-relay.puml b/bridges/punchd-bridge/docs/diagrams/03-http-relay.puml new file mode 100644 index 0000000..6ca0a22 --- /dev/null +++ b/bridges/punchd-bridge/docs/diagrams/03-http-relay.puml @@ -0,0 +1,69 @@ +@startuml http-relay +!theme plain +title HTTP Relay — Request Tunneling via WebSocket + +skinparam { + backgroundColor #0f172a + defaultFontColor #f8fafc + defaultFontName "Segoe UI" + defaultFontSize 12 + SequenceLifeLineBorderColor #334155 + SequenceLifeLineBackgroundColor #1e293b + ParticipantBackgroundColor #1e293b + ParticipantBorderColor #3b82f6 + ParticipantFontColor #f8fafc + ArrowColor #94a3b8 + ArrowFontColor #94a3b8 + NoteBackgroundColor #1e293b + NoteBorderColor #334155 + NoteFontColor #f8fafc + SequenceGroupBackgroundColor #1e293b + SequenceGroupBorderColor #334155 + SequenceDividerBackgroundColor #334155 + SequenceDividerFontColor #f8fafc +} + +participant "Browser" as browser #1e293b +participant "Signal Server\n(HTTP + WS)" as stun #1e293b +participant "Gateway" as gw #1e293b +participant "Backend App" as backend #1e293b + +== Portal Selection == + +browser -> stun : GET /portal +stun --> browser : portal.html + +browser -> stun : GET /api/gateways +stun --> browser : { gateways: [{ id, displayName, backends }] } + +browser -> stun : GET /api/select?gateway=gw-x&backend=App1 +stun --> browser : 302 /__b/App1/\nSet-Cookie: gateway_relay=gw-x + +== HTTP Relay (every request before WebRTC) == + +browser -> stun : GET /__b/App1/dashboard\nCookie: gateway_relay=gw-x +activate stun + +stun -> stun : Cookie lookup → gw-x\nBody limit: 10 MB + +stun -> gw : WS: http_request\n{ id: "uuid-1", method: "GET",\n url: "/__b/App1/dashboard",\n headers: {...}, body: "" } +activate gw + +gw -> gw : HTTP to 127.0.0.1:7891\nValidate method (whitelist)\n30 s request timeout + +gw -> gw : Strip /__b/App1 prefix\nCheck JWT → not auth'd\n→ 302 /auth/login + +gw --> stun : WS: http_response\n{ id: "uuid-1", statusCode: 302,\n headers: {Location: "/auth/login"},\n body: "" } +deactivate gw + +stun --> browser : HTTP 302 /auth/login +deactivate stun + +note over stun + 30 s relay timeout → HTTP 504 + Max 5 000 pending requests + Session affinity via gateway_relay cookie + Pending requests cleaned up on gateway disconnect +end note + +@enduml diff --git a/bridges/punchd-bridge/docs/diagrams/04-oidc-login.puml b/bridges/punchd-bridge/docs/diagrams/04-oidc-login.puml new file mode 100644 index 0000000..e74f072 --- /dev/null +++ b/bridges/punchd-bridge/docs/diagrams/04-oidc-login.puml @@ -0,0 +1,80 @@ +@startuml oidc-login +!theme plain +title OIDC Login Flow (through HTTP Relay) + +skinparam { + backgroundColor #0f172a + defaultFontColor #f8fafc + defaultFontName "Segoe UI" + defaultFontSize 12 + SequenceLifeLineBorderColor #334155 + SequenceLifeLineBackgroundColor #1e293b + ParticipantBackgroundColor #1e293b + ParticipantBorderColor #3b82f6 + ParticipantFontColor #f8fafc + ArrowColor #94a3b8 + ArrowFontColor #94a3b8 + NoteBackgroundColor #1e293b + NoteBorderColor #334155 + NoteFontColor #f8fafc + SequenceDividerBackgroundColor #334155 + SequenceDividerFontColor #f8fafc +} + +participant "Browser" as browser #1e293b +participant "Signal Server\n(relay)" as stun #1e293b +participant "Gateway" as gw #1e293b +participant "TideCloak" as tc #1e293b + +== Redirect to Login == + +browser -> stun : GET /auth/login?redirect=/dashboard +stun -> gw : WS: http_request (relay) +gw -> gw : Sanitize redirect param\n(reject non-relative URLs)\nBuild TideCloak auth URL\nwith state={nonce, redirect} +gw --> stun : WS: http_response 302\nLocation: /realms/.../openid-connect/auth +stun --> browser : HTTP 302 + +== TideCloak Login (proxied through Gateway) == + +browser -> stun : GET /realms/.../auth?client_id=... +stun -> gw : WS: http_request (relay) +gw -> tc : Proxy: GET /realms/.../auth\n(30 s timeout) +tc --> gw : Login page HTML +gw -> gw : Rewrite TC URLs → /_idp/...\nStore TC cookies server-side +gw --> stun : WS: http_response 200 +stun --> browser : Login page + +browser -> browser : User enters credentials + +browser -> stun : POST /realms/.../login-actions/authenticate +stun -> gw : WS: http_request (relay) +gw -> tc : Proxy: POST authenticate +tc --> gw : 302 /auth/callback?code=xyz +gw --> stun : WS: http_response 302 +stun --> browser : HTTP 302 + +== Code Exchange == + +browser -> stun : GET /auth/callback?code=xyz +stun -> gw : WS: http_request (relay) +activate gw + +gw -> tc : POST /realms/.../token\n{ grant_type: authorization_code,\n code: xyz } +tc --> gw : { access_token, refresh_token } + +gw -> gw : Set cookies:\n gateway_access = JWT (HttpOnly)\n gateway_refresh = token (HttpOnly, Strict)\nSanitize redirect from state + +gw --> stun : WS: http_response 302\nLocation: /dashboard\nSet-Cookie: gateway_access=...\nSet-Cookie: gateway_refresh=... +deactivate gw + +stun --> browser : HTTP 302 + cookies + +note over browser, gw + All subsequent requests include gateway_access cookie. + Gateway validates JWT on every request. + Transparent refresh via gateway_refresh when expired. + Session token endpoint returns JWT for DataChannel auth + (Cache-Control: no-store). +end note + +@enduml diff --git a/bridges/punchd-bridge/docs/diagrams/05-hole-punching.puml b/bridges/punchd-bridge/docs/diagrams/05-hole-punching.puml new file mode 100644 index 0000000..a103228 --- /dev/null +++ b/bridges/punchd-bridge/docs/diagrams/05-hole-punching.puml @@ -0,0 +1,121 @@ +@startuml hole-punching +!theme plain +title NAT Traversal — Hole Punching via STUN + ICE + +skinparam { + backgroundColor #0f172a + defaultFontColor #f8fafc + defaultFontName "Segoe UI" + defaultFontSize 12 + SequenceLifeLineBorderColor #334155 + SequenceLifeLineBackgroundColor #1e293b + ParticipantBackgroundColor #1e293b + ParticipantBorderColor #3b82f6 + ParticipantFontColor #f8fafc + ArrowColor #94a3b8 + ArrowFontColor #94a3b8 + NoteBackgroundColor #1e293b + NoteBorderColor #334155 + NoteFontColor #f8fafc + SequenceGroupBackgroundColor #1e293b + SequenceGroupBorderColor #334155 + SequenceDividerBackgroundColor #334155 + SequenceDividerFontColor #f8fafc +} + +participant "Browser\n(behind NAT A)" as browser #1e293b +participant "NAT A" as natA #1e293b +participant "Signal Server\n(signaling WS)" as sig #1e293b +participant "coturn\n(port 3478)" as stun #1e293b +participant "NAT B" as natB #1e293b +participant "Gateway\n(behind NAT B)" as gw #1e293b + +== 1. Fetch TURN Credentials == + +browser -> sig : GET /webrtc-config (via relay) +sig --> browser : { stunServer, turnServer,\n turnUsername, turnPassword } + +note over sig + Credentials use HMAC-SHA256: + user = unix_expiry + pass = Base64(HMAC-SHA256(secret, user)) +end note + +== 2. SDP Offer/Answer Exchange == + +browser -> browser : new RTCPeerConnection({\n iceServers: [stun, turn] }) +browser -> browser : createDataChannel("http-tunnel") +browser -> browser : createOffer() → SDP + +browser -> sig : sdp_offer { sdp, fromId, targetId } +sig -> gw : forward sdp_offer + +gw -> gw : setRemoteDescription(offer)\n→ generates SDP answer\n(max 200 peers) + +gw -> sig : sdp_answer { sdp, fromId } +sig -> browser : forward sdp_answer +browser -> browser : setRemoteDescription(answer) + +== 3. STUN Binding — Discover Public Address == + +browser -> natA : STUN Binding Request +natA -> stun : (NAT assigns 5.6.7.8:54321) +stun -> stun : Read source: 5.6.7.8:54321 + +stun --> natA : Binding Success\nXOR-MAPPED-ADDRESS:\n 5.6.7.8:54321 +natA --> browser : (server-reflexive address) + +note over browser + Browser now knows: + host candidate: 192.168.1.5:54321 + srflx candidate: 5.6.7.8:54321 +end note + +gw -> natB : STUN Binding Request +natB -> stun : (NAT assigns 9.8.7.6:12345) +stun --> natB : Binding Success\nXOR-MAPPED-ADDRESS:\n 9.8.7.6:12345 +natB --> gw : (server-reflexive address) + +== 4. ICE Candidate Exchange == + +browser -> sig : candidate { srflx: 5.6.7.8:54321 } +sig -> gw : forward candidate + +gw -> sig : candidate { srflx: 9.8.7.6:12345 } +sig -> browser : forward candidate + +== 5. ICE Connectivity Checks (Hole Punching) == + +note over browser, gw #334155 + Both peers simultaneously send STUN binding + requests to each other's reflexive addresses. + This "punches holes" in both NATs. +end note + +browser -> natA : packet → 9.8.7.6:12345 +natA -> natB : (creates NAT mapping for return traffic) + +gw -> natB : packet → 5.6.7.8:54321 +natB -> natA : (creates NAT mapping for return traffic) + +natA --> browser : return traffic now flows +natB --> gw : return traffic now flows + +note over browser, gw #334155 + Bidirectional path established! + NAT mappings allow direct communication. +end note + +== 6. DataChannel Opens == + +browser <-> gw : **P2P DataChannel OPEN** + +gw -> sig : client_status\n{ clientId, connectionType: "p2p" } + +note over sig + Ownership verified: only the + paired gateway can update a client's + connection status. +end note + +@enduml diff --git a/bridges/punchd-bridge/docs/diagrams/06-turn-fallback.puml b/bridges/punchd-bridge/docs/diagrams/06-turn-fallback.puml new file mode 100644 index 0000000..eb6b4da --- /dev/null +++ b/bridges/punchd-bridge/docs/diagrams/06-turn-fallback.puml @@ -0,0 +1,100 @@ +@startuml turn-fallback +!theme plain +title TURN Relay Fallback (when P2P fails) + +skinparam { + backgroundColor #0f172a + defaultFontColor #f8fafc + defaultFontName "Segoe UI" + defaultFontSize 12 + SequenceLifeLineBorderColor #334155 + SequenceLifeLineBackgroundColor #1e293b + ParticipantBackgroundColor #1e293b + ParticipantBorderColor #3b82f6 + ParticipantFontColor #f8fafc + ArrowColor #94a3b8 + ArrowFontColor #94a3b8 + NoteBackgroundColor #1e293b + NoteBorderColor #334155 + NoteFontColor #f8fafc + SequenceGroupBackgroundColor #1e293b + SequenceGroupBorderColor #334155 + SequenceDividerBackgroundColor #334155 + SequenceDividerFontColor #f8fafc +} + +participant "Browser" as browser #1e293b +participant "coturn\n(port 3478)" as stun #1e293b +participant "Relay Socket\n(port 54000)" as relay #1e293b +participant "Signal Server\n(signaling)" as sig #1e293b +participant "Gateway" as gw #1e293b + +note over browser, gw #334155 + P2P connectivity checks failed + (symmetric NAT or firewall blocking). + ICE agent falls back to TURN. +end note + +== 1. TURN Allocate == + +browser -> stun : Allocate Request\n(no credentials) + +stun --> browser : 401 Unauthorized\nREALM: "keylessh"\nNONCE: "abc123" + +browser -> stun : Allocate Request\nUsername: "1708000000"\nRealm: "keylessh"\nNonce: "abc123"\nMESSAGE-INTEGRITY + +stun -> stun : Validate:\n1. timestamp > now? (not expired)\n2. password = HMAC-SHA256(secret, username)\n3. key = MD5(user:realm:pass)\n4. verify MESSAGE-INTEGRITY + +stun -> relay : Bind UDP socket\non random port 49152-65535 +activate relay + +stun --> browser : Allocate Success\nXOR-RELAYED-ADDRESS: 5.6.7.8:54000\nXOR-MAPPED-ADDRESS: (reflexive)\nLIFETIME: 600 + +== 2. Create Permission == + +browser -> stun : CreatePermission\n{ peer: Gateway's IP address } +stun -> stun : Install permission\n(expires in 300s) +stun --> browser : CreatePermission Success + +== 3. Channel Bind (optional, reduces overhead) == + +browser -> stun : ChannelBind\n{ channel: 0x4001,\n peer: Gateway IP:port } +stun -> stun : Map channel 0x4001 → peer +stun --> browser : ChannelBind Success + +== 4. ICE Candidate Exchange == + +browser -> sig : candidate { type: "relay",\n address: 5.6.7.8:54000 } +sig -> gw : forward candidate + +note over browser, gw + Gateway also gets TURN credentials + (HMAC-SHA256) and may allocate its own + relay, or connect directly to the + browser's relay address. +end note + +== 5. Data Relay == + +browser -> stun : ChannelData\n[0x4001 | length | payload] +note right: 4-byte overhead only + +stun -> relay : extract payload +relay -> gw : UDP: payload + +gw -> relay : UDP: response +relay -> stun : received from peer +stun -> stun : lookup channel by peer +stun --> browser : ChannelData\n[0x4001 | length | response] + +deactivate relay + +note over browser, gw #334155 + DataChannel established via TURN relay. + Higher latency than P2P but works through + any NAT/firewall configuration. +end note + +gw -> sig : client_status\n{ clientId, connectionType: "turn" } + +@enduml diff --git a/bridges/punchd-bridge/docs/diagrams/07-service-worker-datachannel.puml b/bridges/punchd-bridge/docs/diagrams/07-service-worker-datachannel.puml new file mode 100644 index 0000000..2c89629 --- /dev/null +++ b/bridges/punchd-bridge/docs/diagrams/07-service-worker-datachannel.puml @@ -0,0 +1,101 @@ +@startuml service-worker-datachannel +!theme plain +title Service Worker DataChannel Tunneling + +skinparam { + backgroundColor #0f172a + defaultFontColor #f8fafc + defaultFontName "Segoe UI" + defaultFontSize 12 + SequenceLifeLineBorderColor #334155 + SequenceLifeLineBackgroundColor #1e293b + ParticipantBackgroundColor #1e293b + ParticipantBorderColor #3b82f6 + ParticipantFontColor #f8fafc + ArrowColor #94a3b8 + ArrowFontColor #94a3b8 + NoteBackgroundColor #1e293b + NoteBorderColor #334155 + NoteFontColor #f8fafc + SequenceGroupBackgroundColor #1e293b + SequenceGroupBorderColor #334155 + SequenceDividerBackgroundColor #334155 + SequenceDividerFontColor #f8fafc +} + +participant "Web App\n(page JS)" as app #1e293b +participant "Service\nWorker" as sw #1e293b +participant "webrtc-\nupgrade.js" as rtc #1e293b +participant "DataChannel" as dc #1e293b +participant "Gateway\n(peer handler)" as gw #1e293b +participant "Backend" as backend #1e293b + +== Setup (after DataChannel opens) == + +rtc -> rtc : GET /auth/session-token\n(via HTTP relay, reads HttpOnly cookie) +rtc -> rtc : Store JWT for injection + +rtc -> sw : register /js/sw.js\n(scope: "/") +rtc -> sw : postMessage({ type: "dc_ready" }) +sw -> sw : Add client to dcClients set + +== Fetch Interception == + +app -> sw : fetch("/api/data") +activate sw + +sw -> sw : Decision:\n navigate? → NO\n cross-origin? → NO\n Gateway path? → NO\n dcClients.has(clientId)? → YES\n → Route via DataChannel + +sw -> sw : Rewrite URL prefix:\npage at /__b/App1/...\n→ /__b/App1/api/data + +sw -> rtc : postMessage(\n { type: "dc_fetch",\n url: "/__b/App1/api/data",\n method: "GET",\n headers: {...} },\n [MessagePort]) +activate rtc + +rtc -> rtc : Inject session JWT\ninto cookie header + +rtc -> dc : send(JSON.stringify({\n type: "http_request",\n id: "uuid-1",\n method: "GET",\n url: "/__b/App1/api/data",\n headers: {\n cookie: "gateway_access="\n },\n body: "" })) +activate dc + +dc -> gw : DataChannel message +activate gw + +gw -> gw : Validate HTTP method\nHTTP to 127.0.0.1:7891\n(30 s timeout) +gw -> gw : Strip /__b/App1 prefix\nVerify JWT → OK\nInject backend cookie jar\n(keyed by JWT sub) + +gw -> backend : GET /api/data\n(with stored backend cookies) +backend --> gw : 200 { data: [...] }\n+ Set-Cookie (if any) +gw -> gw : Store Set-Cookie\nin backend cookie jar + +alt Response < 200KB + gw -> dc : http_response\n{ id, statusCode: 200,\n headers, body: base64 } +else Response > 200KB (chunked) + gw -> dc : http_response_start\n{ id, statusCode, headers,\n totalChunks: 3 } + gw -> dc : http_response_chunk\n{ id, index: 0, data: "..." } + gw -> dc : http_response_chunk\n{ id, index: 1, data: "..." } + gw -> dc : http_response_chunk\n{ id, index: 2, data: "..." } +end + +deactivate gw + +dc -> rtc : onmessage +deactivate dc + +rtc -> rtc : Decode base64\n(or reassemble chunks) + +rtc -> sw : port.postMessage({\n statusCode: 200,\n headers: {...},\n body: base64 }) +deactivate rtc + +sw -> sw : new Response(\n bodyBytes,\n { status, headers }) + +sw --> app : Response object +deactivate sw + +note over app, gw #334155 + Page navigations bypass SW → use HTTP relay. + Sub-resource fetches → DataChannel (only if dc_ready). + No dc_ready → browser handles natively (proper cookies). + 10 s timeout → fallback to HTTP relay. + Backend Set-Cookie stored server-side (SW can't set them). +end note + +@enduml diff --git a/bridges/punchd-bridge/docs/diagrams/08-multi-backend-routing.puml b/bridges/punchd-bridge/docs/diagrams/08-multi-backend-routing.puml new file mode 100644 index 0000000..80bfc88 --- /dev/null +++ b/bridges/punchd-bridge/docs/diagrams/08-multi-backend-routing.puml @@ -0,0 +1,83 @@ +@startuml multi-backend-routing +!theme plain +title Multi-Backend Path-Based Routing + +skinparam { + backgroundColor #0f172a + defaultFontColor #f8fafc + defaultFontName "Segoe UI" + defaultFontSize 12 + SequenceLifeLineBorderColor #334155 + SequenceLifeLineBackgroundColor #1e293b + ParticipantBackgroundColor #1e293b + ParticipantBorderColor #3b82f6 + ParticipantFontColor #f8fafc + ArrowColor #94a3b8 + ArrowFontColor #94a3b8 + NoteBackgroundColor #1e293b + NoteBorderColor #334155 + NoteFontColor #f8fafc + SequenceGroupBackgroundColor #1e293b + SequenceGroupBorderColor #334155 + SequenceDividerBackgroundColor #334155 + SequenceDividerFontColor #f8fafc +} + +participant "Browser" as browser #1e293b +participant "Gateway Proxy" as gw #1e293b +participant "App1\nlocalhost:3000" as app1 #1e293b +participant "MediaBox\nlocalhost:8080" as app2 #1e293b + +== Path Prefix Routing == + +browser -> gw : GET /__b/App1/api/users +activate gw +gw -> gw : Validate method (whitelist)\nDetect prefix: /__b/App1\nStrip → /api/users\nBackend: App1 +gw -> app1 : GET /api/users\n(30 s timeout) +app1 --> gw : 200 { users: [...] } +gw --> browser : 200 { users: [...] } +deactivate gw + +||| + +browser -> gw : GET /__b/MediaBox/files +activate gw +gw -> gw : Detect prefix: /__b/MediaBox\nStrip → /files\nBackend: MediaBox +gw -> app2 : GET /files\n(30 s timeout) +app2 --> gw : 200 (file listing) +gw --> browser : 200 (file listing) +deactivate gw + +== HTML Rewriting == + +browser -> gw : GET /__b/App1/ +activate gw +gw -> app1 : GET / +app1 --> gw : 200 text/html\n(max 50 MB buffered) + +gw -> gw : **Rewrite HTML:**\n1. href="/about" → href="/__b/App1/about"\n2. src="/main.js" → src="/__b/App1/main.js"\n3. http://localhost:3000/api → /__b/App1/api\n4. Inject fetch/XHR prefix patch\n5. Inject webrtc-upgrade.js\n6. Inject backend "Switch" button + +gw --> browser : 200 (rewritten HTML) +deactivate gw + +== Redirect Rewriting == + +browser -> gw : POST /__b/App1/form +activate gw +gw -> app1 : POST /form +app1 --> gw : 302 Location: /dashboard + +gw -> gw : Rewrite redirect:\n/dashboard → /__b/App1/dashboard + +gw --> browser : 302 /__b/App1/dashboard +deactivate gw + +== Backend Resolution Priority == + +note over browser, gw #334155 + 1. **/__b//** path prefix (highest priority) + 2. **x-gateway-backend** header + 3. **Default** (first configured backend) +end note + +@enduml diff --git a/bridges/punchd-bridge/docs/diagrams/09-complete-lifecycle.puml b/bridges/punchd-bridge/docs/diagrams/09-complete-lifecycle.puml new file mode 100644 index 0000000..eabd3e1 --- /dev/null +++ b/bridges/punchd-bridge/docs/diagrams/09-complete-lifecycle.puml @@ -0,0 +1,131 @@ +@startuml complete-lifecycle +!theme plain +title Complete Connection Lifecycle — Portal to P2P + +skinparam { + backgroundColor #0f172a + defaultFontColor #f8fafc + defaultFontName "Segoe UI" + defaultFontSize 12 + SequenceLifeLineBorderColor #334155 + SequenceLifeLineBackgroundColor #1e293b + ParticipantBackgroundColor #1e293b + ParticipantBorderColor #3b82f6 + ParticipantFontColor #f8fafc + ArrowColor #94a3b8 + ArrowFontColor #94a3b8 + NoteBackgroundColor #1e293b + NoteBorderColor #334155 + NoteFontColor #f8fafc + SequenceGroupBackgroundColor #1e293b + SequenceGroupBorderColor #334155 + SequenceDividerBackgroundColor #334155 + SequenceDividerFontColor #f8fafc +} + +actor "User" as user #3b82f6 +participant "Browser" as browser #1e293b +participant "Signal Server" as stun #1e293b +participant "coturn" as coturn #1e293b +participant "Gateway" as gw #1e293b +participant "TideCloak" as tc #1e293b +participant "Backend" as backend #1e293b + +== Phase 1: Portal == + +user -> browser : Visit signal-server URL +browser -> stun : GET /portal +stun --> browser : portal.html +browser -> stun : GET /api/gateways +stun --> browser : [{ id: "gw-x", displayName: "My App", backends: [...] }] +user -> browser : Click "Connect" on App1 +browser -> stun : GET /api/select?gateway=gw-x&backend=App1 +stun --> browser : 302 /__b/App1/ + cookies + +== Phase 2: HTTP Relay + Auth == + +browser -> stun : GET /__b/App1/ (Cookie: gateway_relay=gw-x) +stun -> gw : WS: http_request (relay) +gw --> stun : WS: http_response 302 → /auth/login +stun --> browser : 302 /auth/login + +browser -> stun : GET /auth/login → relay → Gateway → 302 TideCloak +browser -> stun : GET /realms/... → relay → Gateway → proxy to TideCloak +browser <-> tc : Login (proxied through Gateway) +browser -> stun : GET /auth/callback?code=xyz → relay → Gateway +gw -> tc : Exchange code for tokens +gw --> stun : 302 + Set-Cookie: gateway_access, gateway_refresh +stun --> browser : 302 /__b/App1/ + auth cookies + +browser -> stun : GET /__b/App1/ (with JWT cookie) +stun -> gw : WS: http_request (relay) +gw -> gw : Validate method + JWT +gw -> backend : GET / (authenticated, 30 s timeout) +backend --> gw : 200 HTML +gw -> gw : Rewrite HTML + inject webrtc-upgrade.js +gw --> stun : WS: http_response +stun --> browser : 200 (app page + upgrade script) + +== Phase 3: WebRTC Upgrade (async, background) == + +browser -> browser : webrtc-upgrade.js executes +browser -> stun : GET /auth/session-token (via relay) +stun --> browser : { token: "" } + +browser -> stun : WS: register { role: "client",\n token, targetGatewayId } +stun --> browser : paired { gateway: "gw-x" } +stun --> gw : paired { client: "client-abc" } + +browser -> browser : **Browser creates:**\nnew RTCPeerConnection\n+ createDataChannel("http-tunnel")\n+ createOffer() +browser -> stun : sdp_offer { sdp, fromId, targetId } +stun -> gw : forward sdp_offer + +gw -> gw : **Gateway creates:**\nnew PeerConnection\nsetRemoteDescription(offer)\n→ auto-generates answer +gw -> stun : sdp_answer { sdp, fromId } +stun -> browser : forward sdp_answer +browser -> browser : setRemoteDescription(answer) + +browser <-> coturn : STUN Binding (port 3478)\n→ discover reflexive addresses +browser <-> stun : ICE candidates ←→ forwarded ←→ Gateway +browser <-> gw : **ICE connectivity check → hole punched!** + +gw -> gw : onDataChannel("http-tunnel")\n(receives browser's DC) +browser <-> gw : **DataChannel OPEN** + +== Phase 4: Service Worker Takeover == + +browser -> browser : Register Service Worker +browser -> browser : SW.postMessage("dc_ready") + +note over browser, gw #334155 + From this point: + - Page navigations → HTTP relay (cookie-based auth) + - Sub-resource fetches → DataChannel (only if dc_ready) + - No dc_ready → browser handles natively + - 10 s DC timeout → fallback to relay +end note + +user -> browser : Click link / API call +browser -> gw : **DataChannel: http_request** +gw -> gw : Inject backend cookie jar\n(keyed by JWT sub) +gw -> backend : Proxy (authenticated, 30 s timeout) +backend --> gw : Response + Set-Cookie +gw -> gw : Store Set-Cookie\nin backend cookie jar +gw -> browser : **DataChannel: http_response** + +== Phase 5: Reconnection (if DC drops) == + +browser -> browser : DataChannel closes or\nICE fails or signaling drops +browser -> browser : cleanupPeer() → reject pending\nSW.postMessage("dc_closed") +browser -> browser : Exponential backoff:\n5 s × 1.5^n (max 60 s) + +browser -> stun : GET /auth/session-token\n(fresh token) +browser -> stun : WS: register { new clientId } +stun --> browser : paired → repeat Phase 3 + +note over browser, gw #334155 + Reconnect resets backoff on success. + During reconnect: all requests use HTTP relay. +end note + +@enduml diff --git a/bridges/punchd-bridge/docs/diagrams/complete-lifecycle.svg b/bridges/punchd-bridge/docs/diagrams/complete-lifecycle.svg new file mode 100644 index 0000000..75b50b5 --- /dev/null +++ b/bridges/punchd-bridge/docs/diagrams/complete-lifecycle.svg @@ -0,0 +1 @@ +Complete Connection Lifecycle — Portal to P2PUserUserBrowserBrowserSignal ServerSignal ServercoturncoturnGatewayGatewayTideCloakTideCloakBackendBackendPhase 1: PortalVisit signal-server URLGET /portalportal.htmlGET /api/gateways[{ id: "gw-x", displayName: "My App", backends: [...] }]Click "Connect" on App1GET /api/select?gateway=gw-x&backend=App1302 /__b/App1/ + cookiesPhase 2: HTTP Relay + AuthGET /__b/App1/ (Cookie: gateway_relay=gw-x)WS: http_request (relay)WS: http_response 302 → /auth/login302 /auth/loginGET /auth/login → relay → Gateway → 302 TideCloakGET /realms/... → relay → Gateway → proxy to TideCloakLogin (proxied through Gateway)GET /auth/callback?code=xyz → relay → GatewayExchange code for tokens302 + Set-Cookie: gateway_access, gateway_refresh302 /__b/App1/ + auth cookiesGET /__b/App1/ (with JWT cookie)WS: http_request (relay)Validate method + JWTGET / (authenticated, 30 s timeout)200 HTMLRewrite HTML + inject webrtc-upgrade.jsWS: http_response200 (app page + upgrade script)Phase 3: WebRTC Upgrade (async, background)webrtc-upgrade.js executesGET /auth/session-token (via relay){ token: "<JWT>" }WS: register { role: "client",token, targetGatewayId }paired { gateway: "gw-x" }paired { client: "client-abc" }Browser creates:new RTCPeerConnection+ createDataChannel("http-tunnel")+ createOffer()sdp_offer { sdp, fromId, targetId }forward sdp_offerGateway creates:new PeerConnectionsetRemoteDescription(offer)→ auto-generates answersdp_answer { sdp, fromId }forward sdp_answersetRemoteDescription(answer)STUN Binding (port 3478)→ discover reflexive addressesICE candidates ←→ forwarded ←→ GatewayICE connectivity check → hole punched!onDataChannel("http-tunnel")(receives browser's DC)DataChannel OPENPhase 4: Service Worker TakeoverRegister Service WorkerSW.postMessage("dc_ready")From this point:- Page navigations → HTTP relay (cookie-based auth)- Sub-resource fetches → DataChannel (only if dc_ready)- No dc_ready → browser handles natively- 10 s DC timeout → fallback to relayClick link / API callDataChannel: http_requestInject backend cookie jar(keyed by JWT sub)Proxy (authenticated, 30 s timeout)Response + Set-CookieStore Set-Cookiein backend cookie jarDataChannel: http_responsePhase 5: Reconnection (if DC drops)DataChannel closes orICE fails or signaling dropscleanupPeer() → reject pendingSW.postMessage("dc_closed")Exponential backoff:5 s × 1.5^n (max 60 s)GET /auth/session-token(fresh token)WS: register { new clientId }paired → repeat Phase 3Reconnect resets backoff on success.During reconnect: all requests use HTTP relay. \ No newline at end of file diff --git a/bridges/punchd-bridge/docs/diagrams/gateway-registration.svg b/bridges/punchd-bridge/docs/diagrams/gateway-registration.svg new file mode 100644 index 0000000..38370e6 --- /dev/null +++ b/bridges/punchd-bridge/docs/diagrams/gateway-registration.svg @@ -0,0 +1 @@ +Gateway Registration & Client PairingGatewayGatewaySignal Server(signaling)Signal Server(signaling)BrowserBrowserGateway RegistrationWebSocket connectregister{ role: "gateway", id: "gw-a1b2c3...",secret: "...",addresses: ["10.0.0.5:7891"],metadata: { displayName, backends } }Timing-safe validateAPI_SECRET → add to registry(max 100 gateways)registered{ role: "gateway", id: "gw-a1b2c3..." }Gateway keeps WebSocket open.Reconnects with exponential backoff(1 s → 2 s → 4 s → ... → 30 s max, ±20 % jitter).Resets to 1 s on successful connection.Client ConnectsWebSocket connectPer-IP connection limit(max 20 per IP)register{ role: "client", id: "client-abc" }Add to registry (max 10 000)Find least-loaded gatewayPair client with gatewaypaired{ gateway: { id: "gw-a1b2c3...",addresses: ["10.0.0.5:7891"] } }paired{ client: { id: "client-abc",reflexiveAddress: "1.2.3.4" } } \ No newline at end of file diff --git a/bridges/punchd-bridge/docs/diagrams/hole-punching.svg b/bridges/punchd-bridge/docs/diagrams/hole-punching.svg new file mode 100644 index 0000000..8e2517a --- /dev/null +++ b/bridges/punchd-bridge/docs/diagrams/hole-punching.svg @@ -0,0 +1 @@ +NAT Traversal — Hole Punching via STUN + ICEBrowser(behind NAT A)Browser(behind NAT A)NAT ANAT ASignal Server(signaling WS)Signal Server(signaling WS)coturn(port 3478)coturn(port 3478)NAT BNAT BGateway(behind NAT B)Gateway(behind NAT B)1. Fetch TURN CredentialsGET /webrtc-config (via relay){ stunServer, turnServer,turnUsername, turnPassword }Credentials use HMAC-SHA256:user = unix_expirypass = Base64(HMAC-SHA256(secret, user))2. SDP Offer/Answer Exchangenew RTCPeerConnection({iceServers: [stun, turn] })createDataChannel("http-tunnel")createOffer() → SDPsdp_offer { sdp, fromId, targetId }forward sdp_offersetRemoteDescription(offer)→ generates SDP answer(max 200 peers)sdp_answer { sdp, fromId }forward sdp_answersetRemoteDescription(answer)3. STUN Binding — Discover Public AddressSTUN Binding Request(NAT assigns 5.6.7.8:54321)Read source: 5.6.7.8:54321Binding SuccessXOR-MAPPED-ADDRESS:5.6.7.8:54321(server-reflexive address)Browser now knows:host candidate: 192.168.1.5:54321srflx candidate: 5.6.7.8:54321STUN Binding Request(NAT assigns 9.8.7.6:12345)Binding SuccessXOR-MAPPED-ADDRESS:9.8.7.6:12345(server-reflexive address)4. ICE Candidate Exchangecandidate { srflx: 5.6.7.8:54321 }forward candidatecandidate { srflx: 9.8.7.6:12345 }forward candidate5. ICE Connectivity Checks (Hole Punching)Both peers simultaneously send STUN bindingrequests to each other's reflexive addresses.This "punches holes" in both NATs.packet → 9.8.7.6:12345(creates NAT mapping for return traffic)packet → 5.6.7.8:54321(creates NAT mapping for return traffic)return traffic now flowsreturn traffic now flowsBidirectional path established!NAT mappings allow direct communication.6. DataChannel OpensP2P DataChannel OPENclient_status{ clientId, connectionType: "p2p" }Ownership verified: only thepaired gateway can update a client'sconnection status. \ No newline at end of file diff --git a/bridges/punchd-bridge/docs/diagrams/http-relay.svg b/bridges/punchd-bridge/docs/diagrams/http-relay.svg new file mode 100644 index 0000000..74d3769 --- /dev/null +++ b/bridges/punchd-bridge/docs/diagrams/http-relay.svg @@ -0,0 +1 @@ +HTTP Relay — Request Tunneling via WebSocketBrowserBrowserSignal Server(HTTP + WS)Signal Server(HTTP + WS)GatewayGatewayBackend AppBackend AppPortal SelectionGET /portalportal.htmlGET /api/gateways{ gateways: [{ id, displayName, backends }] }GET /api/select?gateway=gw-x&backend=App1302 /__b/App1/Set-Cookie: gateway_relay=gw-xHTTP Relay (every request before WebRTC)GET /__b/App1/dashboardCookie: gateway_relay=gw-xCookie lookup → gw-xBody limit: 10 MBWS: http_request{ id: "uuid-1", method: "GET",url: "/__b/App1/dashboard",headers: {...}, body: "" }HTTP to 127.0.0.1:7891Validate method (whitelist)30 s request timeoutStrip /__b/App1 prefixCheck JWT → not auth'd→ 302 /auth/loginWS: http_response{ id: "uuid-1", statusCode: 302,headers: {Location: "/auth/login"},body: "" }HTTP 302 /auth/login30 s relay timeout → HTTP 504Max 5 000 pending requestsSession affinity via gateway_relay cookiePending requests cleaned up on gateway disconnect \ No newline at end of file diff --git a/bridges/punchd-bridge/docs/diagrams/multi-backend-routing.svg b/bridges/punchd-bridge/docs/diagrams/multi-backend-routing.svg new file mode 100644 index 0000000..766705a --- /dev/null +++ b/bridges/punchd-bridge/docs/diagrams/multi-backend-routing.svg @@ -0,0 +1 @@ +Multi-Backend Path-Based RoutingBrowserBrowserGateway ProxyGateway ProxyApp1localhost:3000App1localhost:3000MediaBoxlocalhost:8080MediaBoxlocalhost:8080Path Prefix RoutingGET /__b/App1/api/usersValidate method (whitelist)Detect prefix: /__b/App1Strip → /api/usersBackend: App1GET /api/users(30 s timeout)200 { users: [...] }200 { users: [...] }GET /__b/MediaBox/filesDetect prefix: /__b/MediaBoxStrip → /filesBackend: MediaBoxGET /files(30 s timeout)200 (file listing)200 (file listing)HTML RewritingGET /__b/App1/GET /200 text/html(max 50 MB buffered)Rewrite HTML:1. href="/about" → href="/__b/App1/about"2. src="/main.js" → src="/__b/App1/main.js"3. http://localhost:3000/api → /__b/App1/api4. Inject fetch/XHR prefix patch5. Inject webrtc-upgrade.js6. Inject backend "Switch" button200 (rewritten HTML)Redirect RewritingPOST /__b/App1/formPOST /form302 Location: /dashboardRewrite redirect:/dashboard → /__b/App1/dashboard302 /__b/App1/dashboardBackend Resolution Priority1./__b/<name>/path prefix (highest priority)2.x-gateway-backendheader3.Default(first configured backend) \ No newline at end of file diff --git a/bridges/punchd-bridge/docs/diagrams/oidc-login.svg b/bridges/punchd-bridge/docs/diagrams/oidc-login.svg new file mode 100644 index 0000000..d320632 --- /dev/null +++ b/bridges/punchd-bridge/docs/diagrams/oidc-login.svg @@ -0,0 +1 @@ +OIDC Login Flow (through HTTP Relay)BrowserBrowserSignal Server(relay)Signal Server(relay)GatewayGatewayTideCloakTideCloakRedirect to LoginGET /auth/login?redirect=/dashboardWS: http_request (relay)Sanitize redirect param(reject non-relative URLs)Build TideCloak auth URLwith state={nonce, redirect}WS: http_response 302Location: /realms/.../openid-connect/authHTTP 302TideCloak Login (proxied through Gateway)GET /realms/.../auth?client_id=...WS: http_request (relay)Proxy: GET /realms/.../auth(30 s timeout)Login page HTMLRewrite TC URLs → /_idp/...Store TC cookies server-sideWS: http_response 200Login pageUser enters credentialsPOST /realms/.../login-actions/authenticateWS: http_request (relay)Proxy: POST authenticate302 /auth/callback?code=xyzWS: http_response 302HTTP 302Code ExchangeGET /auth/callback?code=xyzWS: http_request (relay)POST /realms/.../token{ grant_type: authorization_code,code: xyz }{ access_token, refresh_token }Set cookies:gateway_access = JWT (HttpOnly)gateway_refresh = token (HttpOnly, Strict)Sanitize redirect from stateWS: http_response 302Location: /dashboardSet-Cookie: gateway_access=...Set-Cookie: gateway_refresh=...HTTP 302 + cookiesAll subsequent requests include gateway_access cookie.Gateway validates JWT on every request.Transparent refresh via gateway_refresh when expired.Session token endpoint returns JWT for DataChannel auth(Cache-Control: no-store). \ No newline at end of file diff --git a/bridges/punchd-bridge/docs/diagrams/service-worker-datachannel.svg b/bridges/punchd-bridge/docs/diagrams/service-worker-datachannel.svg new file mode 100644 index 0000000..59e3a5b --- /dev/null +++ b/bridges/punchd-bridge/docs/diagrams/service-worker-datachannel.svg @@ -0,0 +1 @@ +Service Worker DataChannel TunnelingWeb App(page JS)Web App(page JS)ServiceWorkerServiceWorkerwebrtc-upgrade.jswebrtc-upgrade.jsDataChannelDataChannelGateway(peer handler)Gateway(peer handler)BackendBackendSetup (after DataChannel opens)GET /auth/session-token(via HTTP relay, reads HttpOnly cookie)Store JWT for injectionregister /js/sw.js(scope: "/")postMessage({ type: "dc_ready" })Add client to dcClients setFetch Interceptionfetch("/api/data")Decision:navigate? → NOcross-origin? → NOGateway path? → NOdcClients.has(clientId)? → YES→ Route via DataChannelRewrite URL prefix:page at /__b/App1/...→ /__b/App1/api/datapostMessage({ type: "dc_fetch",url: "/__b/App1/api/data",method: "GET",headers: {...} },[MessagePort])Inject session JWTinto cookie headersend(JSON.stringify({type: "http_request",id: "uuid-1",method: "GET",url: "/__b/App1/api/data",headers: {cookie: "gateway_access=<jwt>"},body: "" }))DataChannel messageValidate HTTP methodHTTP to 127.0.0.1:7891(30 s timeout)Strip /__b/App1 prefixVerify JWT → OKInject backend cookie jar(keyed by JWT sub)GET /api/data(with stored backend cookies)200 { data: [...] }+ Set-Cookie (if any)Store Set-Cookiein backend cookie jaralt[Response < 200KB]http_response{ id, statusCode: 200,headers, body: base64 }[Response > 200KB (chunked)]http_response_start{ id, statusCode, headers,totalChunks: 3 }http_response_chunk{ id, index: 0, data: "..." }http_response_chunk{ id, index: 1, data: "..." }http_response_chunk{ id, index: 2, data: "..." }onmessageDecode base64(or reassemble chunks)port.postMessage({statusCode: 200,headers: {...},body: base64 })new Response(bodyBytes,{ status, headers })Response objectPage navigations bypass SW → use HTTP relay.Sub-resource fetches → DataChannel (only if dc_ready).No dc_ready → browser handles natively (proper cookies).10 s timeout → fallback to HTTP relay.Backend Set-Cookie stored server-side (SW can't set them). \ No newline at end of file diff --git a/bridges/punchd-bridge/docs/diagrams/system-overview.svg b/bridges/punchd-bridge/docs/diagrams/system-overview.svg new file mode 100644 index 0000000..2ee13c3 --- /dev/null +++ b/bridges/punchd-bridge/docs/diagrams/system-overview.svg @@ -0,0 +1 @@ +Punc'd — System OverviewSignal Server (public)coturn (sidecar)Private NetworkTideCloakBrowserInternetSignaling(WS port 9090)Portal & Admin(HTTP port 9090)In-memoryRegistrySTUN/TURN(UDP+TCP port 3478)TURN Relay(UDP 49152-65535)Gateway(port 7891)Backend App 1(port 3000)Backend App 2(port 8080)OIDC / Auth1. Pick Gateway(HTTP)2. HTTP relay(WebSocket tunnel)3. STUN Binding(NAT discovery)5. TURN relay(NAT fallback)4. P2P DataChannel(hole-punched)Persistent WS(registration +relay + signaling)ProxyProxyOIDCcode exchangeLogin(via Gateway proxy) \ No newline at end of file diff --git a/bridges/punchd-bridge/docs/diagrams/turn-fallback.svg b/bridges/punchd-bridge/docs/diagrams/turn-fallback.svg new file mode 100644 index 0000000..538ac04 --- /dev/null +++ b/bridges/punchd-bridge/docs/diagrams/turn-fallback.svg @@ -0,0 +1 @@ +TURN Relay Fallback (when P2P fails)BrowserBrowsercoturn(port 3478)coturn(port 3478)Relay Socket(port 54000)Relay Socket(port 54000)Signal Server(signaling)Signal Server(signaling)GatewayGatewayP2P connectivity checks failed(symmetric NAT or firewall blocking).ICE agent falls back to TURN.1. TURN AllocateAllocate Request(no credentials)401 UnauthorizedREALM: "keylessh"NONCE: "abc123"Allocate RequestUsername: "1708000000"Realm: "keylessh"Nonce: "abc123"MESSAGE-INTEGRITYValidate:1. timestamp > now? (not expired)2. password = HMAC-SHA256(secret, username)3. key = MD5(user:realm:pass)4. verify MESSAGE-INTEGRITYBind UDP socketon random port 49152-65535Allocate SuccessXOR-RELAYED-ADDRESS: 5.6.7.8:54000XOR-MAPPED-ADDRESS: (reflexive)LIFETIME: 6002. Create PermissionCreatePermission{ peer: Gateway's IP address }Install permission(expires in 300s)CreatePermission Success3. Channel Bind (optional, reduces overhead)ChannelBind{ channel: 0x4001,peer: Gateway IP:port }Map channel 0x4001 → peerChannelBind Success4. ICE Candidate Exchangecandidate { type: "relay",address: 5.6.7.8:54000 }forward candidateGateway also gets TURN credentials(HMAC-SHA256) and may allocate its ownrelay, or connect directly to thebrowser's relay address.5. Data RelayChannelData[0x4001 | length | payload]4-byte overhead onlyextract payloadUDP: payloadUDP: responsereceived from peerlookup channel by peerChannelData[0x4001 | length | response]DataChannel established via TURN relay.Higher latency than P2P but works throughany NAT/firewall configuration.client_status{ clientId, connectionType: "turn" } \ No newline at end of file diff --git a/bridges/punchd-bridge/gateway/Dockerfile b/bridges/punchd-bridge/gateway/Dockerfile new file mode 100644 index 0000000..de48202 --- /dev/null +++ b/bridges/punchd-bridge/gateway/Dockerfile @@ -0,0 +1,47 @@ +############### +# Build stage # +############### +FROM node:24-bookworm-slim AS builder +WORKDIR /app + +# Install deps (incl dev), build, then prune to prod-only +COPY package*.json ./ +RUN npm ci + +COPY tsconfig.json ./ +COPY src ./src +COPY public ./public +RUN npm run build + +# Keep only production deps for the runtime image +RUN npm prune --omit=dev && npm cache clean --force + +#################### +# Production stage # +#################### +# Distroless = minimal attack surface (no shell, no package manager) +FROM gcr.io/distroless/nodejs24-debian12:nonroot AS production +WORKDIR /app + +ENV NODE_ENV=production +ENV LISTEN_PORT=7891 +ENV HEALTH_PORT=7892 + +# App + production dependencies +COPY --from=builder /app/package*.json ./ +COPY --from=builder /app/node_modules ./node_modules +COPY --from=builder /app/dist ./dist +COPY --from=builder /app/public ./public + +EXPOSE 7891/tcp +EXPOSE 7892/tcp + +# Healthcheck hits the health port +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD ["/nodejs/bin/node", "-e", "require('http').get({host:'127.0.0.1',port:process.env.HEALTH_PORT||7892,path:'/health',timeout:2000},r=>process.exit(r.statusCode===200?0:1)).on('error',()=>process.exit(1))"] + +# distroless:nonroot already runs as non-root +USER nonroot + +# distroless node image has ENTRYPOINT ["node"], so this runs: node dist/index.js +CMD ["dist/index.js"] diff --git a/bridges/punchd-bridge/gateway/azure/container-app.yaml b/bridges/punchd-bridge/gateway/azure/container-app.yaml new file mode 100644 index 0000000..7fb40e8 --- /dev/null +++ b/bridges/punchd-bridge/gateway/azure/container-app.yaml @@ -0,0 +1,72 @@ +# Azure Container Apps configuration for gateway +# Deploy with: az containerapp create --yaml container-app.yaml + +name: keylessh-gateway +type: Microsoft.App/containerApps +location: eastus +properties: + managedEnvironmentId: /subscriptions//resourceGroups//providers/Microsoft.App/managedEnvironments/ + + configuration: + # Internal ingress — only reachable within the Container Apps environment + ingress: + external: false + targetPort: 7891 + transport: http + + # Secrets + secrets: + - name: tidecloak-config + value: + - name: turn-secret + value: + + template: + containers: + - name: gateway + image: .azurecr.io/keylessh-gateway:latest + resources: + cpu: 0.25 + memory: 0.5Gi + env: + - name: TIDECLOAK_CONFIG_B64 + secretRef: tidecloak-config + - name: TURN_SECRET + secretRef: turn-secret + - name: LISTEN_PORT + value: "7891" + - name: HEALTH_PORT + value: "7892" + - name: BACKEND_URL + value: "" + - name: STUN_SERVER_URL + value: "" + - name: ICE_SERVERS + value: "" + - name: TURN_SERVER + value: "" + - name: GATEWAY_DISPLAY_NAME + value: "Azure Gateway" + probes: + - type: liveness + httpGet: + path: /health + port: 7892 + initialDelaySeconds: 5 + periodSeconds: 30 + - type: readiness + httpGet: + path: /health + port: 7892 + initialDelaySeconds: 3 + periodSeconds: 10 + + # Auto-scaling configuration + scale: + minReplicas: 0 # Scale to zero when no traffic + maxReplicas: 100 + rules: + - name: http-connections + http: + metadata: + concurrentRequests: "10" diff --git a/bridges/punchd-bridge/gateway/azure/deploy.sh b/bridges/punchd-bridge/gateway/azure/deploy.sh new file mode 100755 index 0000000..2ef48c1 --- /dev/null +++ b/bridges/punchd-bridge/gateway/azure/deploy.sh @@ -0,0 +1,156 @@ +#!/bin/bash +set -e + +# Configuration - update these values +RESOURCE_GROUP="keylessh-rg" +LOCATION="eastus" +ENVIRONMENT_NAME="keylessh-env" +ACR_NAME="keylesshacr" +APP_NAME="keylessh-gateway" + +# Auto-load STUN deployment config if available +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +STUN_ENV="${REPO_ROOT}/.stun-deploy.env" +if [ -f "$STUN_ENV" ]; then + echo "Loading STUN config from $STUN_ENV" + set -a; source "$STUN_ENV"; set +a +fi + +# Required +BACKEND_URL="${BACKEND_URL:-}" +STUN_SERVER_URL="${STUN_SERVER_URL:-}" + +# Optional +ICE_SERVERS="${ICE_SERVERS:-}" +TURN_SERVER="${TURN_SERVER:-}" +TURN_SECRET="${TURN_SECRET:-}" +GATEWAY_ID="${GATEWAY_ID:-}" +GATEWAY_DISPLAY_NAME="${GATEWAY_DISPLAY_NAME:-}" +GATEWAY_DESCRIPTION="${GATEWAY_DESCRIPTION:-}" +AUTH_SERVER_PUBLIC_URL="${AUTH_SERVER_PUBLIC_URL:-}" +STRIP_AUTH_HEADER="${STRIP_AUTH_HEADER:-false}" + +# Path to tidecloak.json config (required for JWT verification) +TIDECLOAK_CONFIG="${TIDECLOAK_CONFIG:-${REPO_ROOT}/gateway/data/tidecloak.json}" + +echo "=== Punc'd Gateway Deployment ===" +echo "Resource Group: $RESOURCE_GROUP" +echo "Location: $LOCATION" +echo "Container App: $APP_NAME" +echo "" + +# Check if logged in +if ! az account show &> /dev/null; then + echo "Please login to Azure first: az login" + exit 1 +fi + +if [ -z "$BACKEND_URL" ]; then + echo "Error: BACKEND_URL is required" + echo "Usage: BACKEND_URL=http://app:3000 STUN_SERVER_URL=ws://stun:9090 ./deploy.sh" + exit 1 +fi + +if [ -z "$STUN_SERVER_URL" ]; then + echo "Error: STUN_SERVER_URL is required" + echo "Usage: BACKEND_URL=http://app:3000 STUN_SERVER_URL=ws://stun:9090 ./deploy.sh" + exit 1 +fi + +# Check if tidecloak.json exists +if [ ! -f "$TIDECLOAK_CONFIG" ]; then + echo "Error: TideCloak config not found at $TIDECLOAK_CONFIG" + echo "Please ensure tidecloak.json exists with JWKS configuration" + exit 1 +fi + +# Read and base64 encode the config for storage as a secret +TIDECLOAK_CONFIG_B64=$(base64 -w0 "$TIDECLOAK_CONFIG") + +# Create resource group if not exists +echo "Creating resource group..." +az group create --name $RESOURCE_GROUP --location $LOCATION --output none 2>/dev/null || true + +# Create Azure Container Registry if not exists +echo "Creating container registry..." +az acr create \ + --resource-group $RESOURCE_GROUP \ + --name $ACR_NAME \ + --sku Basic \ + --admin-enabled true \ + --output none 2>/dev/null || true + +# Build and push image +echo "Building and pushing Docker image..." +az acr build \ + --registry $ACR_NAME \ + --image $APP_NAME:latest \ + --file Dockerfile \ + . + +# Create Container Apps environment if not exists +echo "Creating Container Apps environment..." +az containerapp env create \ + --name $ENVIRONMENT_NAME \ + --resource-group $RESOURCE_GROUP \ + --location $LOCATION \ + --output none 2>/dev/null || true + +# Get ACR credentials +ACR_SERVER=$(az acr show --name $ACR_NAME --query loginServer -o tsv) +ACR_USERNAME=$(az acr credential show --name $ACR_NAME --query username -o tsv) +ACR_PASSWORD=$(az acr credential show --name $ACR_NAME --query "passwords[0].value" -o tsv) + +# Build env vars string — always include required vars +ENV_VARS="TIDECLOAK_CONFIG_B64=secretref:tidecloak-config" +ENV_VARS="$ENV_VARS BACKEND_URL=$BACKEND_URL" +ENV_VARS="$ENV_VARS STUN_SERVER_URL=$STUN_SERVER_URL" +ENV_VARS="$ENV_VARS STRIP_AUTH_HEADER=$STRIP_AUTH_HEADER" + +# Append optional vars only if set +[ -n "$ICE_SERVERS" ] && ENV_VARS="$ENV_VARS ICE_SERVERS=$ICE_SERVERS" +[ -n "$TURN_SERVER" ] && ENV_VARS="$ENV_VARS TURN_SERVER=$TURN_SERVER" +[ -n "$TURN_SECRET" ] && ENV_VARS="$ENV_VARS TURN_SECRET=$TURN_SECRET" +[ -n "$GATEWAY_ID" ] && ENV_VARS="$ENV_VARS GATEWAY_ID=$GATEWAY_ID" +[ -n "$GATEWAY_DISPLAY_NAME" ] && ENV_VARS="$ENV_VARS GATEWAY_DISPLAY_NAME=$GATEWAY_DISPLAY_NAME" +[ -n "$GATEWAY_DESCRIPTION" ] && ENV_VARS="$ENV_VARS GATEWAY_DESCRIPTION=$GATEWAY_DESCRIPTION" +[ -n "$AUTH_SERVER_PUBLIC_URL" ] && ENV_VARS="$ENV_VARS AUTH_SERVER_PUBLIC_URL=$AUTH_SERVER_PUBLIC_URL" + +# Deploy Container App (internal ingress — not publicly accessible) +echo "Deploying Container App (internal)..." +az containerapp create \ + --name $APP_NAME \ + --resource-group $RESOURCE_GROUP \ + --environment $ENVIRONMENT_NAME \ + --image "$ACR_SERVER/$APP_NAME:latest" \ + --registry-server $ACR_SERVER \ + --registry-username $ACR_USERNAME \ + --registry-password $ACR_PASSWORD \ + --target-port 7891 \ + --ingress internal \ + --min-replicas 0 \ + --max-replicas 100 \ + --cpu 0.25 \ + --memory 0.5Gi \ + --secrets "tidecloak-config=$TIDECLOAK_CONFIG_B64" \ + --env-vars $ENV_VARS \ + --scale-rule-name http-connections \ + --scale-rule-type http \ + --scale-rule-http-concurrency 10 + +# Get the internal URL +GATEWAY_URL=$(az containerapp show \ + --name $APP_NAME \ + --resource-group $RESOURCE_GROUP \ + --query "properties.configuration.ingress.fqdn" -o tsv) + +echo "" +echo "=== Deployment Complete ===" +echo "" +echo "Gateway Internal URL: http://$GATEWAY_URL (not publicly accessible)" +echo "Health Check: http://$GATEWAY_URL:7892/health" +echo "STUN Server: $STUN_SERVER_URL" +echo "" +echo "Clients reach this gateway through the STUN/TURN server." +echo "The gateway will scale from 0 to 100 instances based on HTTP connections." diff --git a/bridges/punchd-bridge/gateway/package-lock.json b/bridges/punchd-bridge/gateway/package-lock.json new file mode 100644 index 0000000..5715cbd --- /dev/null +++ b/bridges/punchd-bridge/gateway/package-lock.json @@ -0,0 +1,1333 @@ +{ + "name": "keylessh-gateway", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "keylessh-gateway", + "version": "1.0.0", + "dependencies": { + "jose": "^6.1.3", + "node-datachannel": "^0.32.1", + "selfsigned": "^5.5.0", + "ws": "^8.18.0" + }, + "devDependencies": { + "@types/node": "^20.19.0", + "@types/ws": "^8.5.13", + "tsx": "^4.20.5", + "typescript": "^5.6.3" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@noble/hashes": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.4.0.tgz", + "integrity": "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@peculiar/asn1-cms": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.6.1.tgz", + "integrity": "sha512-vdG4fBF6Lkirkcl53q6eOdn3XYKt+kJTG59edgRZORlg/3atWWEReRCx5rYE1ZzTTX6vLK5zDMjHh7vbrcXGtw==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.1", + "@peculiar/asn1-x509-attr": "^2.6.1", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-csr": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.6.1.tgz", + "integrity": "sha512-WRWnKfIocHyzFYQTka8O/tXCiBquAPSrRjXbOkHbO4qdmS6loffCEGs+rby6WxxGdJCuunnhS2duHURhjyio6w==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.1", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-ecc": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.6.1.tgz", + "integrity": "sha512-+Vqw8WFxrtDIN5ehUdvlN2m73exS2JVG0UAyfVB31gIfor3zWEAQPD+K9ydCxaj3MLen9k0JhKpu9LqviuCE1g==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.1", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-pfx": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.6.1.tgz", + "integrity": "sha512-nB5jVQy3MAAWvq0KY0R2JUZG8bO/bTLpnwyOzXyEh/e54ynGTatAR+csOnXkkVD9AFZ2uL8Z7EV918+qB1qDvw==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.6.1", + "@peculiar/asn1-pkcs8": "^2.6.1", + "@peculiar/asn1-rsa": "^2.6.1", + "@peculiar/asn1-schema": "^2.6.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-pkcs8": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.6.1.tgz", + "integrity": "sha512-JB5iQ9Izn5yGMw3ZG4Nw3Xn/hb/G38GYF3lf7WmJb8JZUydhVGEjK/ZlFSWhnlB7K/4oqEs8HnfFIKklhR58Tw==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.1", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-pkcs9": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.6.1.tgz", + "integrity": "sha512-5EV8nZoMSxeWmcxWmmcolg22ojZRgJg+Y9MX2fnE2bGRo5KQLqV5IL9kdSQDZxlHz95tHvIq9F//bvL1OeNILw==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.6.1", + "@peculiar/asn1-pfx": "^2.6.1", + "@peculiar/asn1-pkcs8": "^2.6.1", + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.1", + "@peculiar/asn1-x509-attr": "^2.6.1", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-rsa": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.6.1.tgz", + "integrity": "sha512-1nVMEh46SElUt5CB3RUTV4EG/z7iYc7EoaDY5ECwganibQPkZ/Y2eMsTKB/LeyrUJ+W/tKoD9WUqIy8vB+CEdA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.1", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-schema": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.6.0.tgz", + "integrity": "sha512-xNLYLBFTBKkCzEZIw842BxytQQATQv+lDTCEMZ8C196iJcJJMBUZxrhSTxLaohMyKK8QlzRNTRkUmanucnDSqg==", + "license": "MIT", + "dependencies": { + "asn1js": "^3.0.6", + "pvtsutils": "^1.3.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-x509": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.6.1.tgz", + "integrity": "sha512-O9jT5F1A2+t3r7C4VT7LYGXqkGLK7Kj1xFpz7U0isPrubwU5PbDoyYtx6MiGst29yq7pXN5vZbQFKRCP+lLZlA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.6.0", + "asn1js": "^3.0.6", + "pvtsutils": "^1.3.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-x509-attr": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.6.1.tgz", + "integrity": "sha512-tlW6cxoHwgcQghnJwv3YS+9OO1737zgPogZ+CgWRUK4roEwIPzRH4JEiG770xe5HX2ATfCpmX60gurfWIF9dcQ==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.1", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/x509": { + "version": "1.14.3", + "resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-1.14.3.tgz", + "integrity": "sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.6.0", + "@peculiar/asn1-csr": "^2.6.0", + "@peculiar/asn1-ecc": "^2.6.0", + "@peculiar/asn1-pkcs9": "^2.6.0", + "@peculiar/asn1-rsa": "^2.6.0", + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.0", + "pvtsutils": "^1.3.6", + "reflect-metadata": "^0.2.2", + "tslib": "^2.8.1", + "tsyringe": "^4.10.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@types/node": { + "version": "20.19.33", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.33.tgz", + "integrity": "sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/asn1js": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.7.tgz", + "integrity": "sha512-uLvq6KJu04qoQM6gvBfKFjlh6Gl0vOKQuR5cJMDHQkmwfMOQeN3F3SHCv9SNYSL+CRoHvOGFfllDlVz03GQjvQ==", + "license": "BSD-3-Clause", + "dependencies": { + "pvtsutils": "^1.3.6", + "pvutils": "^1.1.3", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/bytestreamjs": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/bytestreamjs/-/bytestreamjs-2.0.1.tgz", + "integrity": "sha512-U1Z/ob71V/bXfVABvNr/Kumf5VyeQRBEm6Txb0PQ6S7V5GpBM3w4Cbqz/xPDicR5tN0uvDifng8C+5qECeGwyQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-tsconfig": { + "version": "4.13.6", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz", + "integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, + "node_modules/jose": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz", + "integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, + "node_modules/node-abi": { + "version": "3.87.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.87.0.tgz", + "integrity": "sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-datachannel": { + "version": "0.32.1", + "resolved": "https://registry.npmjs.org/node-datachannel/-/node-datachannel-0.32.1.tgz", + "integrity": "sha512-r4UdtA0lCsz6XrG84pJ6lntAyw/MHpmBOhEkg5UQcmWTEpANqCPkMos6rj/QZDdq3GBUsdI/wst5acwWUiibCA==", + "hasInstallScript": true, + "license": "MPL 2.0", + "dependencies": { + "prebuild-install": "^7.1.3" + }, + "engines": { + "node": ">=18.20.0" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/pkijs": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/pkijs/-/pkijs-3.3.3.tgz", + "integrity": "sha512-+KD8hJtqQMYoTuL1bbGOqxb4z+nZkTAwVdNtWwe8Tc2xNbEmdJYIYoc6Qt0uF55e6YW6KuTHw1DjQ18gMhzepw==", + "license": "BSD-3-Clause", + "dependencies": { + "@noble/hashes": "1.4.0", + "asn1js": "^3.0.6", + "bytestreamjs": "^2.0.1", + "pvtsutils": "^1.3.6", + "pvutils": "^1.1.3", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/pump": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", + "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/pvtsutils": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz", + "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/pvutils": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.5.tgz", + "integrity": "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==", + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/reflect-metadata": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", + "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", + "license": "Apache-2.0" + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/selfsigned": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/selfsigned/-/selfsigned-5.5.0.tgz", + "integrity": "sha512-ftnu3TW4+3eBfLRFnDEkzGxSF/10BJBkaLJuBHZX0kiPS7bRdlpZGu6YGt4KngMkdTwJE6MbjavFpqHvqVt+Ew==", + "license": "MIT", + "dependencies": { + "@peculiar/x509": "^1.14.2", + "pkijs": "^3.3.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tsyringe": { + "version": "4.10.0", + "resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.10.0.tgz", + "integrity": "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==", + "license": "MIT", + "dependencies": { + "tslib": "^1.9.3" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/tsyringe/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "license": "0BSD" + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/bridges/punchd-bridge/gateway/package.json b/bridges/punchd-bridge/gateway/package.json new file mode 100644 index 0000000..e864d0f --- /dev/null +++ b/bridges/punchd-bridge/gateway/package.json @@ -0,0 +1,24 @@ +{ + "name": "keylessh-gateway", + "version": "1.0.0", + "type": "module", + "description": "HTTP/HTTPS reverse proxy with TideCloak JWT authentication", + "main": "dist/index.js", + "scripts": { + "build": "tsc", + "start": "node dist/index.js", + "dev": "tsx watch src/index.ts" + }, + "dependencies": { + "jose": "^6.1.3", + "node-datachannel": "^0.32.1", + "selfsigned": "^5.5.0", + "ws": "^8.18.0" + }, + "devDependencies": { + "@types/node": "^20.19.0", + "@types/ws": "^8.5.13", + "tsx": "^4.20.5", + "typescript": "^5.6.3" + } +} diff --git a/bridges/punchd-bridge/gateway/public/js/sw.js b/bridges/punchd-bridge/gateway/public/js/sw.js new file mode 100644 index 0000000..0e82624 --- /dev/null +++ b/bridges/punchd-bridge/gateway/public/js/sw.js @@ -0,0 +1,299 @@ +/** + * Service Worker for WebRTC DataChannel HTTP tunneling. + * + * Intercepts same-origin sub-resource requests and routes them through + * the page's WebRTC DataChannel when available. Navigation requests + * always use the network (relay) since they load new pages that need + * to establish their own DataChannel. + * + * The page signals DC readiness via postMessage({ type: "dc_ready" }). + * Only clients that have signaled are used for DataChannel routing. + * + * Also handles path-based backend routing: if the requesting page is + * under /__b//, prefixless absolute paths are rewritten to + * include the prefix. + */ + +// Clients that have signaled an active DataChannel +var dcClients = new Set(); + +// Callbacks waiting for a specific client's DC to become ready +var dcWaiters = new Map(); // clientId → [resolve, ...] + +self.addEventListener("install", function () { + self.skipWaiting(); +}); + +self.addEventListener("activate", function (event) { + event.waitUntil( + self.clients.claim().then(function () { + // After claiming all clients, ask each if it has an active DataChannel. + // Clients with an active DC will respond with dc_ready, rebuilding + // dcClients after a SW update (skipWaiting clears the old SW's state). + return self.clients.matchAll({ type: "window" }).then(function (allClients) { + allClients.forEach(function (client) { + client.postMessage({ type: "dc_check" }); + }); + }); + }) + ); +}); + +// Listen for DC ready/closed signals from pages +self.addEventListener("message", function (event) { + var clientId = event.source && event.source.id; + if (!clientId) return; + if (event.data && event.data.type === "dc_ready") { + dcClients.add(clientId); + // Wake up any fetch handlers waiting for this client's DC + var waiters = dcWaiters.get(clientId); + if (waiters) { + waiters.forEach(function (resolve) { resolve(true); }); + dcWaiters.delete(clientId); + } + } else if (event.data && event.data.type === "dc_closed") { + dcClients.delete(clientId); + } +}); + +/** Wait for a client's DataChannel to become ready, with timeout. */ +function waitForDc(clientId, timeoutMs) { + if (dcClients.has(clientId)) return Promise.resolve(true); + return new Promise(function (resolve) { + var timer = setTimeout(function () { resolve(false); }, timeoutMs); + var wrapped = function (ready) { + clearTimeout(timer); + resolve(ready); + }; + var list = dcWaiters.get(clientId); + if (!list) { + list = []; + dcWaiters.set(clientId, list); + } + list.push(wrapped); + }); +} + +/** Gateway-internal paths — skip DataChannel, go through relay. */ +var GATEWAY_PATHS = /^\/(js\/|auth\/|login|webrtc-config|_idp\/|realms\/|resources\/|portal|health)/; + +function extractPrefix(pathname) { + var m = pathname.match(/^\/__b\/[^/]+/); + return m ? m[0] : null; +} + +function stripPrefix(pathname) { + var m = pathname.match(/^\/__b\/[^/]+(\/.*)/); + return m ? m[1] : pathname; +} + +self.addEventListener("fetch", function (event) { + // Navigation requests (page loads) always use relay — new pages + // need to establish their own DataChannel + if (event.request.mode === "navigate") return; + + var url = new URL(event.request.url); + + // Intercept requests to localhost (any port) that target TideCloak + // paths (/realms/*, /resources/*). The SDK/adapter may construct + // absolute URLs using the TideCloak's internal localhost address. + // Rewrite them to same-origin so they route through the gateway proxy. + if ( + url.origin !== self.location.origin && + (url.hostname === "localhost" || url.hostname === "127.0.0.1") && + (url.pathname.startsWith("/realms/") || url.pathname.startsWith("/resources/")) + ) { + console.log("[SW] Rewriting localhost request:", event.request.url); + var rewrittenUrl = self.location.origin + url.pathname + url.search; + event.respondWith( + fetch(new Request(rewrittenUrl, { + method: event.request.method, + headers: event.request.headers, + body: event.request.method !== "GET" && event.request.method !== "HEAD" + ? event.request.body + : undefined, + credentials: "same-origin", + redirect: event.request.redirect, + })) + ); + return; + } + + if (url.origin !== self.location.origin) return; + + // Skip gateway-internal paths (strip prefix first for matching) + if (GATEWAY_PATHS.test(stripPrefix(url.pathname))) return; + + // If DC is already active, route through it immediately. + // If not yet ready, wait up to 8s for it — this prevents the burst + // of sub-resource requests from flooding the STUN relay while + // WebRTC is still connecting. Falls back to network on timeout. + if (!event.clientId) return; + + if (dcClients.has(event.clientId)) { + event.respondWith(rewriteAndHandle(event)); + return; + } + + event.respondWith( + waitForDc(event.clientId, 8000).then(function (ready) { + if (ready) { + return rewriteAndHandle(event); + } + // DC didn't connect in time — fall back to network (relay) + return fetch(event.request); + }) + ); +}); + +async function rewriteAndHandle(event) { + var request = event.request; + var url = new URL(request.url); + + // Prepend /__b/ prefix from requesting client if needed + if (!url.pathname.startsWith("/__b/") && event.clientId) { + try { + var client = await self.clients.get(event.clientId); + if (client) { + var prefix = extractPrefix(new URL(client.url).pathname); + if (prefix && !GATEWAY_PATHS.test(url.pathname)) { + var newUrl = new URL(request.url); + newUrl.pathname = prefix + newUrl.pathname; + request = new Request(newUrl.toString(), request); + } + } + } catch (e) { + // proceed with original + } + } + + return handleViaDataChannel(event.clientId, request); +} + +async function handleViaDataChannel(clientId, request) { + var fallbackRequest = request.clone(); + + try { + var client = await self.clients.get(clientId); + if (!client) { + dcClients.delete(clientId); + return fetch(fallbackRequest); + } + + // Read request body + var body = ""; + if (request.method !== "GET" && request.method !== "HEAD") { + var buf = await request.arrayBuffer(); + if (buf.byteLength > 0) { + body = btoa(String.fromCharCode.apply(null, new Uint8Array(buf))); + } + } + + var mc = new MessageChannel(); + var headers = {}; + for (var pair of request.headers) { + headers[pair[0]] = pair[1]; + } + + client.postMessage( + { + type: "dc_fetch", + url: new URL(request.url).pathname + new URL(request.url).search, + method: request.method, + headers: headers, + body: body, + }, + [mc.port2] + ); + + return new Promise(function (resolve) { + var timer = setTimeout(function () { + resolve(fetch(fallbackRequest)); + }, 10000); + + mc.port1.onmessage = function (e) { + clearTimeout(timer); + if (e.data.error) { + resolve(fetch(fallbackRequest)); + return; + } + + var responseHeaders = new Headers(); + for (var key in e.data.headers || {}) { + try { + var val = e.data.headers[key]; + if (Array.isArray(val)) { + val.forEach(function (v) { responseHeaders.append(key, v); }); + } else { + responseHeaders.set(key, val); + } + } catch (err) { + // skip forbidden headers + } + } + + if (e.data.streaming) { + // Live streaming response (SSE, NDJSON) — return a ReadableStream + // so the browser can consume data progressively. + var stream = new ReadableStream({ + start: function (controller) { + mc.port1.onmessage = function (ev) { + if (ev.data.type === "chunk" && ev.data.data) { + try { + if (ev.data.data instanceof ArrayBuffer) { + controller.enqueue(new Uint8Array(ev.data.data)); + } else { + var raw = atob(ev.data.data); + var bytes = new Uint8Array(raw.length); + for (var i = 0; i < raw.length; i++) { + bytes[i] = raw.charCodeAt(i); + } + controller.enqueue(bytes); + } + } catch (err) { + // Stream may have been cancelled + } + } else if (ev.data.type === "end") { + try { controller.close(); } catch (err) {} + } + }; + }, + cancel: function () { + mc.port1.onmessage = null; + }, + }); + resolve( + new Response(stream, { + status: e.data.statusCode, + headers: responseHeaders, + }) + ); + return; + } + + var bodyBytes; + if (e.data.binaryBody instanceof ArrayBuffer) { + bodyBytes = new Uint8Array(e.data.binaryBody); + } else { + bodyBytes = Uint8Array.from(atob(e.data.body || ""), function (c) { + return c.charCodeAt(0); + }); + } + + console.log("[SW] DC response:", e.data.statusCode, + "body:", bodyBytes.length, "bytes", + "content-range:", responseHeaders.get("content-range"), + "via:", e.data.binaryBody ? "ArrayBuffer" : "base64"); + + resolve( + new Response(bodyBytes, { + status: e.data.statusCode, + headers: responseHeaders, + }) + ); + }; + }); + } catch (e) { + return fetch(fallbackRequest); + } +} diff --git a/bridges/punchd-bridge/gateway/public/js/webrtc-upgrade.js b/bridges/punchd-bridge/gateway/public/js/webrtc-upgrade.js new file mode 100644 index 0000000..ee1548b --- /dev/null +++ b/bridges/punchd-bridge/gateway/public/js/webrtc-upgrade.js @@ -0,0 +1,823 @@ +/** + * WebRTC DataChannel upgrade script. + * + * After the page loads (via HTTP relay), this script attempts to + * establish a direct WebRTC DataChannel to the gateway. If successful, + * it registers a Service Worker that routes subsequent HTTP requests + * through the DataChannel for lower latency. + * + * Falls back gracefully — if WebRTC fails, HTTP relay continues working. + * Automatically reconnects when the DataChannel or signaling drops. + */ + +(function () { + "use strict"; + + // Detect /__b/ prefix from current page URL so gateway-internal + // fetches (session-token, webrtc-config) route through the STUN relay + const BACKEND_PREFIX = (function () { + const m = location.pathname.match(/^\/__b\/[^/]+/); + return m ? m[0] : ""; + })(); + const CONFIG_ENDPOINT = BACKEND_PREFIX + "/webrtc-config"; + const NativeWebSocket = window.WebSocket; + const RECONNECT_DELAY = 5000; + const MAX_RECONNECT_DELAY = 60000; + + let signalingWs = null; + let peerConnection = null; + let dataChannel = null; + let clientId = "client-" + Math.random().toString(36).slice(2, 10); + let pairedGatewayId = null; + let config = null; + let sessionToken = null; + let tokenRefreshTimer = null; + let reconnectAttempts = 0; + let reconnectTimer = null; + let swRegistered = false; + let dcReadySignaled = false; + + // Pending requests waiting for DataChannel responses + const pendingRequests = new Map(); + // In-flight chunked responses being reassembled + const chunkedResponses = new Map(); + // MessagePorts for streaming responses (SSE, NDJSON) back to SW + const streamingPorts = new Map(); + // Active WebSocket connections tunneled through DataChannel + const dcWebSockets = new Map(); + + async function init() { + try { + const res = await fetch(CONFIG_ENDPOINT); + if (!res.ok) { + console.log("[WebRTC] Config not available, skipping upgrade"); + return; + } + config = await res.json(); + console.log("[WebRTC] Config loaded:", config); + + await fetchSessionToken(); + connectSignaling(); + } catch (err) { + console.log("[WebRTC] Upgrade not available:", err.message); + } + } + + /** Clean up peer connection and DataChannel without triggering reconnect. */ + function cleanupPeer() { + if (dataChannel) { + try { dataChannel.onclose = null; dataChannel.onerror = null; dataChannel.close(); } catch {} + dataChannel = null; + } + if (peerConnection) { + try { peerConnection.onicecandidate = null; peerConnection.onconnectionstatechange = null; peerConnection.close(); } catch {} + peerConnection = null; + } + pairedGatewayId = null; + if (tokenRefreshTimer) { clearInterval(tokenRefreshTimer); tokenRefreshTimer = null; } + // Reject pending requests so they fall back to relay + for (const [id, entry] of pendingRequests) { + entry.resolve({ statusCode: 502, headers: {}, body: "" }); + } + pendingRequests.clear(); + chunkedResponses.clear(); + // End any in-flight streaming responses so SW promises don't hang + for (var [id, port] of streamingPorts) { + port.postMessage({ type: "end" }); + } + streamingPorts.clear(); + // Close all DataChannel-tunneled WebSocket connections + for (const [id, ws] of dcWebSockets) { + ws._fireClose(1001, "DataChannel closed"); + } + dcWebSockets.clear(); + // Tell SW this client no longer has DataChannel + dcReadySignaled = false; + if (navigator.serviceWorker.controller) { + navigator.serviceWorker.controller.postMessage({ type: "dc_closed" }); + } + } + + /** Schedule a reconnection attempt with exponential backoff. */ + function scheduleReconnect() { + if (reconnectTimer) return; + const delay = Math.min(RECONNECT_DELAY * Math.pow(1.5, reconnectAttempts), MAX_RECONNECT_DELAY); + reconnectAttempts++; + console.log(`[WebRTC] Reconnecting in ${Math.round(delay / 1000)}s (attempt ${reconnectAttempts})...`); + reconnectTimer = setTimeout(() => { + reconnectTimer = null; + reconnect(); + }, delay); + } + + /** Reconnect: refresh token, new client ID, clean state, reconnect signaling. */ + async function reconnect() { + cleanupPeer(); + if (signalingWs) { + try { signalingWs.onclose = null; signalingWs.close(); } catch {} + signalingWs = null; + } + // Get a fresh session token (the old one may have expired) + await fetchSessionToken(); + if (!sessionToken) { + console.log("[WebRTC] No session token — skipping reconnect"); + return; + } + // New client ID so the signal server doesn't confuse with old state + clientId = "client-" + Math.random().toString(36).slice(2, 10); + connectSignaling(); + } + + function connectSignaling() { + if (!config?.signalingUrl) return; + + console.log("[WebRTC] Connecting to signaling:", config.signalingUrl); + signalingWs = new NativeWebSocket(config.signalingUrl); + + signalingWs.onopen = () => { + console.log("[WebRTC] Signaling connected"); + if (!sessionToken) { + console.log("[WebRTC] No session token — cannot authenticate with signal server"); + signalingWs.close(); + return; + } + const registerMsg = { + type: "register", + role: "client", + id: clientId, + token: sessionToken, + }; + if (config.targetGatewayId) { + registerMsg.targetGatewayId = config.targetGatewayId; + console.log("[WebRTC] Targeting gateway:", config.targetGatewayId); + } + signalingWs.send(JSON.stringify(registerMsg)); + }; + + signalingWs.onmessage = (event) => { + let msg; + try { + msg = JSON.parse(event.data); + } catch { + return; + } + handleSignalingMessage(msg); + }; + + signalingWs.onclose = () => { + console.log("[WebRTC] Signaling disconnected"); + cleanupPeer(); + scheduleReconnect(); + }; + + signalingWs.onerror = () => { + console.log("[WebRTC] Signaling error"); + }; + } + + function handleSignalingMessage(msg) { + switch (msg.type) { + case "registered": + console.log("[WebRTC] Registered as client:", msg.id); + break; + + case "paired": + pairedGatewayId = msg.gateway?.id; + console.log("[WebRTC] Paired with gateway:", pairedGatewayId); + startWebRTC(); + break; + + case "sdp_answer": + if (peerConnection && msg.sdp) { + console.log("[WebRTC] Received SDP answer"); + peerConnection + .setRemoteDescription( + new RTCSessionDescription({ type: msg.sdpType || "answer", sdp: msg.sdp }) + ) + .catch((err) => console.error("[WebRTC] setRemoteDescription error:", err)); + } + break; + + case "candidate": + if (peerConnection && msg.candidate) { + const c = msg.candidate; + console.log("[WebRTC] Remote ICE candidate:", c.candidate); + peerConnection + .addIceCandidate( + new RTCIceCandidate({ candidate: c.candidate, sdpMid: c.mid }) + ) + .catch((err) => console.error("[WebRTC] addIceCandidate error:", err)); + } + break; + + case "error": + console.error("[WebRTC] Signaling error:", msg.message); + // Retry pairing if gateway was temporarily unavailable + if (msg.message && msg.message.indexOf("No gateway") !== -1 && !pairedGatewayId) { + var retryDelay = Math.min(3000 * Math.pow(1.5, reconnectAttempts), 30000); + reconnectAttempts++; + console.log("[WebRTC] Will retry pairing in " + Math.round(retryDelay / 1000) + "s..."); + setTimeout(function () { + if (signalingWs && signalingWs.readyState === WebSocket.OPEN && !pairedGatewayId) { + console.log("[WebRTC] Retrying registration/pairing..."); + var registerMsg = { + type: "register", + role: "client", + id: clientId, + token: sessionToken, + }; + if (config.targetGatewayId) { + registerMsg.targetGatewayId = config.targetGatewayId; + } + signalingWs.send(JSON.stringify(registerMsg)); + } + }, retryDelay); + } + break; + } + } + + function startWebRTC() { + if (!pairedGatewayId) return; + + // Save gateway ID before cleanup (cleanupPeer resets pairedGatewayId) + const targetGateway = pairedGatewayId; + cleanupPeer(); + pairedGatewayId = targetGateway; + + console.log("[WebRTC] Starting WebRTC handshake with gateway:", pairedGatewayId); + + const iceServers = []; + if (config.stunServer) { + iceServers.push({ urls: config.stunServer }); + } + if (config.turnServer) { + iceServers.push({ + urls: config.turnServer, + username: config.turnUsername || "", + credential: config.turnPassword || "", + }); + } + console.log("[WebRTC] ICE servers:", JSON.stringify(iceServers)); + + peerConnection = new RTCPeerConnection({ + iceServers: iceServers.length > 0 ? iceServers : undefined, + }); + + dataChannel = peerConnection.createDataChannel("http-tunnel", { + ordered: true, + }); + dataChannel.binaryType = "arraybuffer"; + + dataChannel.onopen = async () => { + console.log("[WebRTC] DataChannel OPEN — direct connection established!"); + reconnectAttempts = 0; // Reset backoff on success + // Refresh session token before DC requests start (token may have expired since page load) + await fetchSessionToken(); + // Refresh token every 4 minutes to stay ahead of 5-minute expiry + if (tokenRefreshTimer) clearInterval(tokenRefreshTimer); + tokenRefreshTimer = setInterval(fetchSessionToken, 4 * 60 * 1000); + installWebSocketShim(); + await registerServiceWorker(); + + // Only signal dc_ready if we have a valid session token — without it, + // DC requests would 401 and fall back to relay anyway (wasted round-trip) + if (!sessionToken) { + console.warn("[WebRTC] No session token — DC routing deferred until token acquired"); + return; + } + + // Wait for SW to claim this page (clients.claim() may still be pending) + if (!navigator.serviceWorker.controller) { + await navigator.serviceWorker.ready; + if (!navigator.serviceWorker.controller) { + await new Promise(function (resolve) { + navigator.serviceWorker.addEventListener("controllerchange", resolve, { once: true }); + setTimeout(resolve, 3000); // don't wait forever + }); + } + } + signalDcReady(); + }; + + dataChannel.onmessage = (event) => { + // Binary message — could be a streaming chunk OR a JSON control message + // sent as binary (to avoid SCTP PPID confusion when interleaving). + // JSON messages start with '{'; chunk data starts with a UUID (hex digit). + if (typeof event.data !== "string") { + const buf = new Uint8Array(event.data); + if (buf.length === 0) return; + + // Check if this is a JSON control message sent as binary + // (0x7B = '{' — JSON objects always start with this) + if (buf[0] === 0x7B) { + try { + const msg = JSON.parse(new TextDecoder().decode(buf)); + handleDcMessage(msg); + } catch { + console.error("[WebRTC] Failed to parse binary-JSON DC message"); + } + return; + } + + // Binary streaming chunk: 36-byte requestId prefix + raw bytes + if (buf.length < 36) return; + const requestId = new TextDecoder().decode(buf.subarray(0, 36)); + const entry = chunkedResponses.get(requestId); + if (!entry || !entry.streaming) return; + if (entry.live) { + // Live stream (SSE/NDJSON) — forward chunk to SW immediately + const port = streamingPorts.get(requestId); + if (port) { + const chunkBytes = buf.slice(36).buffer; + port.postMessage({ type: "chunk", data: chunkBytes }, [chunkBytes]); + } + } else { + // Finite response (video, etc) — buffer chunk on page side + entry.chunks.push(buf.slice(36)); + } + return; + } + + // Text message = JSON control message + try { + const msg = JSON.parse(event.data); + handleDcMessage(msg); + } catch { + console.error("[WebRTC] Failed to parse DataChannel message"); + } + }; + + function handleDcMessage(msg) { + if (msg.type === "http_response" && msg.id) { + // Single buffered response + const pending = pendingRequests.get(msg.id); + if (pending) { + pendingRequests.delete(msg.id); + pending.resolve(msg); + } + } else if (msg.type === "http_response_start" && msg.id) { + if (msg.streaming) { + var isLive = !!msg.live; + if (isLive) { + // Live stream (SSE, NDJSON) — resolve immediately with ReadableStream. + // Client consumes data progressively as it arrives. + const pending = pendingRequests.get(msg.id); + if (pending) { + pendingRequests.delete(msg.id); + pending.resolve({ + statusCode: msg.statusCode, + headers: msg.headers, + streaming: true, + }); + } + } + // live=true: forward chunks to SW via ReadableStream + // live=false: buffer chunks page-side, deliver complete Response on end + // (Chrome's media pipeline doesn't handle ReadableStream 206 from SW) + chunkedResponses.set(msg.id, { + streaming: true, + live: isLive, + statusCode: msg.statusCode, + headers: msg.headers, + chunks: isLive ? undefined : [], + }); + } else { + // Size-chunked reassembly (large buffered responses) + chunkedResponses.set(msg.id, { + statusCode: msg.statusCode, + headers: msg.headers, + totalChunks: msg.totalChunks, + received: 0, + chunks: new Array(msg.totalChunks), + }); + } + } else if (msg.type === "http_response_chunk" && msg.id) { + const entry = chunkedResponses.get(msg.id); + if (!entry) return; + + if (entry.streaming) { + // Forward chunk to SW via the streaming port + const port = streamingPorts.get(msg.id); + if (port) { + port.postMessage({ type: "chunk", data: msg.data }); + } + } else { + // Size-chunked reassembly + entry.chunks[msg.index] = msg.data; + entry.received++; + console.log(`[WebRTC] Chunk ${entry.received}/${entry.totalChunks} received for ${msg.id}`); + if (entry.received === entry.totalChunks) { + console.log(`[WebRTC] All chunks received for ${msg.id}, reassembling`); + chunkedResponses.delete(msg.id); + const pending = pendingRequests.get(msg.id); + if (pending) { + pendingRequests.delete(msg.id); + pending.resolve({ + statusCode: entry.statusCode, + headers: entry.headers, + body: entry.chunks.join(""), + }); + } + } + } + } else if (msg.type === "http_response_end" && msg.id) { + const entry = chunkedResponses.get(msg.id); + chunkedResponses.delete(msg.id); + + if (entry && !entry.live && entry.chunks) { + // Buffered finite response — concatenate chunks and encode as base64. + // Uses the same proven path as small responses (body field). + const totalLength = entry.chunks.reduce((sum, c) => sum + c.length, 0); + const merged = new Uint8Array(totalLength); + let offset = 0; + for (const chunk of entry.chunks) { + merged.set(chunk, offset); + offset += chunk.length; + } + console.log(`[WebRTC] Buffered response complete: ${msg.id} (${totalLength} bytes)`); + const pending = pendingRequests.get(msg.id); + if (pending) { + pendingRequests.delete(msg.id); + pending.resolve({ + statusCode: entry.statusCode, + headers: entry.headers, + binaryBody: merged.buffer, + }); + } + } else { + // Live stream — close the ReadableStream + const port = streamingPorts.get(msg.id); + if (port) { + port.postMessage({ type: "end" }); + streamingPorts.delete(msg.id); + } + } + } else if (msg.type === "ws_opened" && msg.id) { + const ws = dcWebSockets.get(msg.id); + if (ws) ws._fireOpen(msg.protocol); + } else if (msg.type === "ws_message" && msg.id) { + const ws = dcWebSockets.get(msg.id); + if (ws) ws._fireMessage(msg.data, msg.binary); + } else if (msg.type === "ws_close" && msg.id) { + const ws = dcWebSockets.get(msg.id); + if (ws) ws._fireClose(msg.code, msg.reason); + } else if (msg.type === "ws_error" && msg.id) { + const ws = dcWebSockets.get(msg.id); + if (ws) ws._fireError(msg.message); + } + } + + dataChannel.onclose = () => { + console.log("[WebRTC] DataChannel closed"); + cleanupPeer(); + // Only reconnect if signaling is still open (otherwise signaling.onclose handles it) + if (signalingWs && signalingWs.readyState === WebSocket.OPEN) { + scheduleReconnect(); + } + }; + + dataChannel.onerror = (err) => { + console.error("[WebRTC] DataChannel error:", err); + }; + + // ICE candidate handling + peerConnection.onicecandidate = (event) => { + if (event.candidate) { + console.log("[WebRTC] Local ICE candidate:", event.candidate.candidate); + if (signalingWs?.readyState === WebSocket.OPEN) { + signalingWs.send( + JSON.stringify({ + type: "candidate", + fromId: clientId, + targetId: pairedGatewayId, + candidate: { + candidate: event.candidate.candidate, + mid: event.candidate.sdpMid, + }, + }) + ); + } + } else { + console.log("[WebRTC] ICE gathering complete (null candidate)"); + } + }; + + peerConnection.oniceconnectionstatechange = () => { + console.log("[WebRTC] ICE connection state:", peerConnection.iceConnectionState); + if (peerConnection.iceConnectionState === "failed") { + console.log("[WebRTC] ICE failed — closing peer"); + cleanupPeer(); + scheduleReconnect(); + } + }; + + peerConnection.onconnectionstatechange = () => { + console.log("[WebRTC] Connection state:", peerConnection.connectionState); + }; + + // Create and send SDP offer + peerConnection + .createOffer() + .then((offer) => peerConnection.setLocalDescription(offer)) + .then(() => { + signalingWs.send( + JSON.stringify({ + type: "sdp_offer", + fromId: clientId, + targetId: pairedGatewayId, + sdp: peerConnection.localDescription.sdp, + sdpType: peerConnection.localDescription.type, + }) + ); + console.log("[WebRTC] SDP offer sent"); + }) + .catch((err) => { + console.error("[WebRTC] Failed to create offer:", err); + }); + } + + async function registerServiceWorker() { + if (swRegistered || !("serviceWorker" in navigator)) { + return; + } + + try { + await navigator.serviceWorker.register("/js/sw.js", { scope: "/", updateViaCache: "none" }); + console.log("[WebRTC] Service Worker registered"); + swRegistered = true; + + // When a new SW takes control mid-session (e.g., after SW update), + // re-signal dc_ready so the new SW knows this client has an active DC. + navigator.serviceWorker.addEventListener("controllerchange", function () { + console.log("[WebRTC] New Service Worker took control"); + // New SW needs to be told about our DC — reset flag and re-signal + dcReadySignaled = false; + if (sessionToken && dataChannel && dataChannel.readyState === "open") { + signalDcReady(); + } + }); + + navigator.serviceWorker.addEventListener("message", (event) => { + if (event.data?.type === "dc_fetch") { + handleSwFetch(event.data, event.ports[0]); + } else if (event.data?.type === "dc_check") { + // New SW is asking if we have an active DC — re-signal readiness + dcReadySignaled = false; + if (sessionToken && dataChannel && dataChannel.readyState === "open") { + signalDcReady(); + } + } + }); + navigator.serviceWorker.startMessages(); + } catch (err) { + console.error("[WebRTC] Service Worker registration failed:", err); + } + } + + function signalDcReady() { + if (dcReadySignaled) return; + if (!navigator.serviceWorker.controller) return; + if (!dataChannel || dataChannel.readyState !== "open") return; + navigator.serviceWorker.controller.postMessage({ type: "dc_ready" }); + dcReadySignaled = true; + window.__dcReady = true; + window.dispatchEvent(new CustomEvent("dc-ready")); + console.log("[WebRTC] Signaled dc_ready to Service Worker"); + } + + async function fetchSessionToken() { + try { + const res = await fetch(BACKEND_PREFIX + "/auth/session-token", { + headers: { "X-Requested-With": "XMLHttpRequest" }, + }); + if (!res.ok) { + console.log("[WebRTC] No session token available"); + sessionToken = null; + return; + } + const data = await res.json(); + sessionToken = data.token; + console.log("[WebRTC] Session token acquired"); + // If dc_ready was deferred because we had no token, signal now + // Only when SW is already registered (avoid signaling old SW before update) + if (!dcReadySignaled && swRegistered && dataChannel && dataChannel.readyState === "open") { + signalDcReady(); + } + } catch (err) { + console.log("[WebRTC] Failed to fetch session token:", err.message); + sessionToken = null; + } + } + + function handleSwFetch(request, responsePort) { + if (!dataChannel || dataChannel.readyState !== "open") { + responsePort.postMessage({ error: "DataChannel not open" }); + return; + } + + const requestId = crypto.randomUUID(); + + // Inject session cookie that the SW can't read (HttpOnly). + // Merge with any existing cookies from the request (non-HttpOnly cookies + // the browser may have set). The gateway's backend cookie jar handles + // HttpOnly cookies server-side for DataChannel sessions. + const headers = { ...request.headers }; + if (sessionToken) { + const existing = headers.cookie || ""; + headers.cookie = existing + ? `${existing}; gateway_access=${sessionToken}` + : `gateway_access=${sessionToken}`; + } + + dataChannel.send( + JSON.stringify({ + type: "http_request", + id: requestId, + method: request.method, + url: request.url, + headers: headers, + body: request.body || "", + }) + ); + + const timeout = setTimeout(() => { + pendingRequests.delete(requestId); + streamingPorts.delete(requestId); + responsePort.postMessage({ error: "Timeout" }); + }, 15000); + + pendingRequests.set(requestId, { + resolve: (msg) => { + clearTimeout(timeout); + if (msg.streaming) { + // Live streaming response (SSE, NDJSON) — keep the port open for chunks + streamingPorts.set(requestId, responsePort); + responsePort.postMessage({ + statusCode: msg.statusCode, + headers: msg.headers, + streaming: true, + }); + } else if (msg.binaryBody) { + // Transfer raw ArrayBuffer — avoids base64 encode/decode overhead + responsePort.postMessage({ + statusCode: msg.statusCode, + headers: msg.headers, + binaryBody: msg.binaryBody, + }, [msg.binaryBody]); + } else { + responsePort.postMessage({ + statusCode: msg.statusCode, + headers: msg.headers, + body: msg.body, + }); + } + }, + }); + } + + // --- WebSocket shim: tunnels same-origin WS connections through DataChannel --- + + function bufToBase64(bytes) { + let binary = ""; + for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]); + return btoa(binary); + } + + class DCWebSocket { + constructor(url, protocols) { + this._id = crypto.randomUUID(); + this._listeners = {}; + this.readyState = 0; // CONNECTING + this.protocol = ""; + this.extensions = ""; + this.bufferedAmount = 0; + this.binaryType = "blob"; + this.onopen = null; + this.onmessage = null; + this.onclose = null; + this.onerror = null; + + const parsed = new URL(url, window.location.origin); + this.url = parsed.href; + + // Prepend /__b/ prefix from current page if needed + let wsPath = parsed.pathname + parsed.search; + if (!wsPath.startsWith("/__b/")) { + const prefixMatch = window.location.pathname.match(/^\/__b\/[^/]+/); + if (prefixMatch) wsPath = prefixMatch[0] + wsPath; + } + + dcWebSockets.set(this._id, this); + + const headers = {}; + if (sessionToken) { + headers.cookie = "gateway_access=" + sessionToken; + } + + dataChannel.send(JSON.stringify({ + type: "ws_open", + id: this._id, + url: wsPath, + protocols: Array.isArray(protocols) ? protocols : protocols ? [protocols] : [], + headers: headers, + })); + } + + addEventListener(type, fn) { + if (!this._listeners[type]) this._listeners[type] = []; + if (this._listeners[type].indexOf(fn) === -1) this._listeners[type].push(fn); + } + + removeEventListener(type, fn) { + if (!this._listeners[type]) return; + this._listeners[type] = this._listeners[type].filter(function (f) { return f !== fn; }); + } + + _dispatch(type, event) { + if (typeof this["on" + type] === "function") this["on" + type](event); + const listeners = this._listeners[type]; + if (listeners) listeners.forEach(function (fn) { fn(event); }); + } + + send(data) { + if (this.readyState !== 1) throw new DOMException("WebSocket not open", "InvalidStateError"); + if (typeof data === "string") { + dataChannel.send(JSON.stringify({ type: "ws_message", id: this._id, data: data, binary: false })); + } else if (data instanceof ArrayBuffer) { + dataChannel.send(JSON.stringify({ type: "ws_message", id: this._id, data: bufToBase64(new Uint8Array(data)), binary: true })); + } else if (ArrayBuffer.isView(data)) { + dataChannel.send(JSON.stringify({ type: "ws_message", id: this._id, data: bufToBase64(new Uint8Array(data.buffer, data.byteOffset, data.byteLength)), binary: true })); + } else if (data instanceof Blob) { + const wsId = this._id; + const ws = this; + data.arrayBuffer().then(function (buf) { + if (ws.readyState !== 1) return; + dataChannel.send(JSON.stringify({ type: "ws_message", id: wsId, data: bufToBase64(new Uint8Array(buf)), binary: true })); + }); + } + } + + close(code, reason) { + if (this.readyState >= 2) return; + this.readyState = 2; // CLOSING + if (dataChannel && dataChannel.readyState === "open") { + dataChannel.send(JSON.stringify({ type: "ws_close", id: this._id, code: code || 1000, reason: reason || "" })); + } + } + + _fireOpen(protocol) { + this.readyState = 1; + this.protocol = protocol || ""; + this._dispatch("open", new Event("open")); + } + + _fireMessage(data, binary) { + let payload; + if (binary) { + const raw = atob(data); + const bytes = new Uint8Array(raw.length); + for (let i = 0; i < raw.length; i++) bytes[i] = raw.charCodeAt(i); + payload = this.binaryType === "arraybuffer" ? bytes.buffer : new Blob([bytes]); + } else { + payload = data; + } + this._dispatch("message", new MessageEvent("message", { data: payload })); + } + + _fireClose(code, reason) { + if (this.readyState === 3) return; + this.readyState = 3; + dcWebSockets.delete(this._id); + this._dispatch("close", new CloseEvent("close", { code: code || 1000, reason: reason || "", wasClean: code !== 1006 })); + } + + _fireError(message) { + dcWebSockets.delete(this._id); + this._dispatch("error", new Event("error")); + this._fireClose(1006, message || "Connection failed"); + } + } + + function installWebSocketShim() { + window.WebSocket = function (url, protocols) { + const parsed = new URL(url, window.location.origin); + if (parsed.origin !== window.location.origin || !dataChannel || dataChannel.readyState !== "open") { + return new NativeWebSocket(url, protocols); + } + return new DCWebSocket(url, protocols); + }; + window.WebSocket.CONNECTING = 0; + window.WebSocket.OPEN = 1; + window.WebSocket.CLOSING = 2; + window.WebSocket.CLOSED = 3; + window.WebSocket.prototype = NativeWebSocket.prototype; + } + + // Start upgrade after page load + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", init); + } else { + init(); + } +})(); diff --git a/bridges/punchd-bridge/gateway/src/auth/oidc.ts b/bridges/punchd-bridge/gateway/src/auth/oidc.ts new file mode 100644 index 0000000..b32fda4 --- /dev/null +++ b/bridges/punchd-bridge/gateway/src/auth/oidc.ts @@ -0,0 +1,201 @@ +/** + * Server-side OIDC flow for TideCloak authentication. + * + * Handles authorization URL construction, code exchange, token refresh, + * and logout URL construction. + */ + +import { randomBytes } from "crypto"; +import { request as httpRequest } from "http"; +import { request as httpsRequest } from "https"; +import type { TidecloakConfig } from "../config.js"; + +export interface OidcEndpoints { + authorization: string; + token: string; + logout: string; +} + +export interface TokenResponse { + access_token: string; + refresh_token?: string; + expires_in: number; + refresh_expires_in?: number; + token_type: string; +} + +/** + * Derive OIDC endpoints from TideCloak config. + * @param baseUrlOverride — Optional public URL for browser-facing endpoints + * (e.g. ngrok URL). Server-side endpoints always use config auth-server-url. + */ +export function getOidcEndpoints( + config: TidecloakConfig, + baseUrlOverride?: string +): OidcEndpoints { + const base = (baseUrlOverride ?? config["auth-server-url"]).replace( + /\/$/, + "" + ); + const realmPath = `${base}/realms/${config.realm}/protocol/openid-connect`; + + return { + authorization: `${realmPath}/auth`, + token: `${realmPath}/token`, + logout: `${realmPath}/logout`, + }; +} + +/** + * Build the authorization redirect URL. + * State encodes the original URL the user was trying to access. + */ +export function buildAuthUrl( + endpoints: OidcEndpoints, + clientId: string, + redirectUri: string, + originalUrl: string +): { url: string; state: string } { + const state = Buffer.from( + JSON.stringify({ + nonce: randomBytes(16).toString("hex"), + redirect: originalUrl || "/", + }) + ).toString("base64url"); + + const params = new URLSearchParams({ + client_id: clientId, + redirect_uri: redirectUri, + response_type: "code", + scope: "openid", + state, + }); + + return { + url: `${endpoints.authorization}?${params}`, + state, + }; +} + +/** + * Exchange authorization code for tokens. + */ +export async function exchangeCode( + endpoints: OidcEndpoints, + clientId: string, + code: string, + redirectUri: string +): Promise { + const body = new URLSearchParams({ + grant_type: "authorization_code", + client_id: clientId, + code, + redirect_uri: redirectUri, + }); + + return postTokenRequest(endpoints.token, body, "Token exchange"); +} + +/** + * Refresh an access token using a refresh token. + */ +export async function refreshAccessToken( + endpoints: OidcEndpoints, + clientId: string, + refreshToken: string +): Promise { + const body = new URLSearchParams({ + grant_type: "refresh_token", + client_id: clientId, + refresh_token: refreshToken, + }); + + return postTokenRequest(endpoints.token, body, "Token refresh"); +} + +/** + * POST to a token endpoint using http.request (not fetch). + * + * Uses http.request instead of fetch for reliability — Node.js fetch + * (undici) can have DNS resolution issues in some environments. + * The hostname is kept as-is (not replaced with 127.0.0.1) because + * on WSL2 with Docker Desktop, "localhost" routes through a special + * forwarding layer that 127.0.0.1 bypasses. + */ +function postTokenRequest( + tokenUrl: string, + params: URLSearchParams, + label: string +): Promise { + return new Promise((resolve, reject) => { + const url = new URL(tokenUrl); + const isHttps = url.protocol === "https:"; + const makeReq = isHttps ? httpsRequest : httpRequest; + const postBody = params.toString(); + + const req = makeReq( + { + hostname: url.hostname, + port: url.port || (isHttps ? 443 : 80), + path: url.pathname + url.search, + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + "Content-Length": Buffer.byteLength(postBody).toString(), + }, + }, + (res) => { + const chunks: Buffer[] = []; + res.on("data", (chunk: Buffer) => chunks.push(chunk)); + res.on("end", () => { + const text = Buffer.concat(chunks).toString("utf-8"); + if (!res.statusCode || res.statusCode >= 400) { + console.error(`[OIDC] ${label} failed (${res.statusCode}): ${text}`); + reject(new Error(`${label} failed (${res.statusCode}): ${text}`)); + return; + } + try { + resolve(JSON.parse(text) as TokenResponse); + } catch (e) { + console.error(`[OIDC] ${label} response not JSON: ${text.slice(0, 200)}`); + reject(new Error(`${label} response not JSON`)); + } + }); + } + ); + + req.on("error", (err) => { + console.error(`[OIDC] ${label} network error: ${err.message}`); + reject(new Error(`${label} network error: ${err.message}`)); + }); + + req.end(postBody); + }); +} + +/** + * Build TideCloak logout URL. + */ +export function buildLogoutUrl( + endpoints: OidcEndpoints, + clientId: string, + postLogoutRedirectUri: string +): string { + const params = new URLSearchParams({ + client_id: clientId, + post_logout_redirect_uri: postLogoutRedirectUri, + }); + + return `${endpoints.logout}?${params}`; +} + +/** + * Parse the state parameter from the callback. + */ +export function parseState(state: string): { nonce: string; redirect: string } { + try { + return JSON.parse(Buffer.from(state, "base64url").toString("utf-8")); + } catch { + return { nonce: "", redirect: "/" }; + } +} diff --git a/bridges/punchd-bridge/gateway/src/auth/tidecloak.ts b/bridges/punchd-bridge/gateway/src/auth/tidecloak.ts new file mode 100644 index 0000000..d98c1a5 --- /dev/null +++ b/bridges/punchd-bridge/gateway/src/auth/tidecloak.ts @@ -0,0 +1,55 @@ +/** + * TideCloak JWT verification using local JWKS. + * Adapted from tcp-bridge pattern. + */ + +import { jwtVerify, createLocalJWKSet, JWTPayload } from "jose"; +import type { TidecloakConfig } from "../config.js"; + +export interface TidecloakAuth { + verifyToken(token: string): Promise; +} + +export function createTidecloakAuth( + config: TidecloakConfig, + extraIssuers?: string[] +): TidecloakAuth { + const JWKS = createLocalJWKSet(config.jwk); + + const baseUrl = config["auth-server-url"].replace(/\/$/, ""); + const primaryIssuer = `${baseUrl}/realms/${config.realm}`; + + // Accept tokens from both the local and public TideCloak URLs + const validIssuers = [primaryIssuer]; + if (extraIssuers) { + for (const base of extraIssuers) { + const url = base.replace(/\/$/, ""); + validIssuers.push(`${url}/realms/${config.realm}`); + } + } + + console.log("[Gateway] TideCloak JWKS loaded successfully"); + console.log(`[Gateway] Valid issuers: ${validIssuers.join(", ")}`); + + return { + async verifyToken(token: string): Promise { + try { + const { payload } = await jwtVerify(token, JWKS, { + issuer: validIssuers, + }); + + if (payload.azp !== config.resource) { + console.log( + `[Gateway] AZP mismatch: expected ${config.resource}, got ${payload.azp}` + ); + return null; + } + + return payload; + } catch (err) { + console.log("[Gateway] JWT verification failed:", err); + return null; + } + }, + }; +} diff --git a/bridges/punchd-bridge/gateway/src/config.ts b/bridges/punchd-bridge/gateway/src/config.ts new file mode 100644 index 0000000..406a93b --- /dev/null +++ b/bridges/punchd-bridge/gateway/src/config.ts @@ -0,0 +1,237 @@ +/** + * Gateway configuration loaded from environment variables and TideCloak config. + * + * The gateway is local-facing — it runs on the internal/private network. + * Remote clients reach it through the public STUN/TURN server. + */ + +import { readFileSync } from "fs"; +import { randomBytes } from "crypto"; +import { join, dirname } from "path"; +import { fileURLToPath } from "url"; +import { networkInterfaces } from "os"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +export interface BackendEntry { + name: string; + url: string; + /** Skip gateway JWT validation — backend handles its own auth */ + noAuth?: boolean; + /** Strip Authorization header before proxying to this backend */ + stripAuth?: boolean; +} + +export interface ServerConfig { + listenPort: number; + healthPort: number; + backendUrl: string; + backends: BackendEntry[]; + stunServerUrl: string; + gatewayId: string; + stripAuthHeader: boolean; + /** Public-facing TideCloak URL for browser redirects (overrides config auth-server-url) */ + authServerPublicUrl?: string; + /** ICE servers for WebRTC NAT traversal, e.g. ["stun:relay.example.com:3478"] */ + iceServers: string[]; + /** TURN server URL for WebRTC relay fallback, e.g. "turn:relay.example.com:3478" */ + turnServer?: string; + /** Shared secret for TURN REST API ephemeral credentials (same as TURN server's TURN_SECRET) */ + turnSecret: string; + /** Shared secret for STUN server API authentication (API_SECRET) */ + apiSecret: string; + /** Display name shown in the portal (GATEWAY_DISPLAY_NAME) */ + displayName?: string; + /** Description shown in the portal (GATEWAY_DESCRIPTION) */ + description?: string; + /** Enable HTTPS with a self-signed certificate */ + https: boolean; + /** Hostname for the self-signed certificate */ + tlsHostname: string; + /** Internal TideCloak URL for proxying and server-side requests (TC_INTERNAL_URL). + * Defaults to tidecloak config auth-server-url. Use when KC_HOSTNAME differs from + * the internal address (e.g. KC_HOSTNAME=public URL, TC_INTERNAL_URL=http://localhost:8080) */ + tcInternalUrl?: string; +} + +export interface TidecloakConfig { + realm: string; + "auth-server-url": string; + resource: string; + "public-client"?: boolean; + jwk: { + keys: Array<{ + kid: string; + kty: string; + alg: string; + use: string; + crv: string; + x: string; + }>; + }; + [key: string]: unknown; // Allow extra fields (vendorId, homeOrkUrl, etc.) +} + +export function loadConfig(): ServerConfig { + const stunServerUrl = process.env.STUN_SERVER_URL; + if (!stunServerUrl) { + console.error("[Gateway] STUN_SERVER_URL is required"); + process.exit(1); + } + + // Parse BACKENDS env var: "Name=http://host:port,Other=http://host2:port2" + // Falls back to BACKEND_URL for backwards compat + const backends = parseBackends(); + const backendUrl = backends[0]?.url || ""; + + if (!backendUrl) { + console.error("[Gateway] BACKENDS or BACKEND_URL is required"); + process.exit(1); + } + + return { + listenPort: parseInt(process.env.LISTEN_PORT || "7891", 10), + healthPort: parseInt(process.env.HEALTH_PORT || "7892", 10), + backendUrl, + backends, + stunServerUrl, + gatewayId: process.env.GATEWAY_ID || `gateway-${randomBytes(8).toString("hex")}`, + stripAuthHeader: process.env.STRIP_AUTH_HEADER === "true", + authServerPublicUrl: process.env.AUTH_SERVER_PUBLIC_URL || undefined, + iceServers: process.env.ICE_SERVERS + ? process.env.ICE_SERVERS.split(",") + : deriveIceServers(stunServerUrl), + turnServer: process.env.TURN_SERVER || undefined, + turnSecret: warnIfEmpty("TURN_SECRET"), + apiSecret: requireSecret("API_SECRET"), + displayName: process.env.GATEWAY_DISPLAY_NAME || undefined, + description: process.env.GATEWAY_DESCRIPTION || undefined, + https: process.env.HTTPS !== "false", + tlsHostname: process.env.TLS_HOSTNAME || "localhost", + tcInternalUrl: process.env.TC_INTERNAL_URL || undefined, + }; +} + +function parseBackends(): BackendEntry[] { + const backendsEnv = process.env.BACKENDS; + if (backendsEnv) { + return backendsEnv.split(",").map((entry) => { + const eq = entry.indexOf("="); + if (eq < 0) return { name: "Default", url: entry.trim() }; + let rawUrl = entry.slice(eq + 1).trim(); + let noAuth = false; + let stripAuth = false; + // Parse suffix flags: ;noauth, ;stripauth (order-independent, repeatable) + let changed = true; + while (changed) { + changed = false; + const lower = rawUrl.toLowerCase(); + if (lower.endsWith(";noauth")) { + noAuth = true; + rawUrl = rawUrl.slice(0, -";noauth".length).trim(); + changed = true; + } else if (lower.endsWith(";stripauth")) { + stripAuth = true; + rawUrl = rawUrl.slice(0, -";stripauth".length).trim(); + changed = true; + } + } + return { name: entry.slice(0, eq).trim(), url: rawUrl, noAuth: noAuth || undefined, stripAuth: stripAuth || undefined }; + }).filter((b) => b.url); + } + + const backendUrl = process.env.BACKEND_URL; + if (backendUrl) { + const name = process.env.GATEWAY_DISPLAY_NAME || "Default"; + return [{ name, url: backendUrl }]; + } + + return []; +} + +/** + * Load TideCloak config from file or base64 env var. + */ +export function loadTidecloakConfig(): TidecloakConfig { + const configB64 = process.env.TIDECLOAK_CONFIG_B64; + + let configData: string; + + if (configB64) { + configData = Buffer.from(configB64, "base64").toString("utf-8"); + console.log("[Gateway] Loading JWKS from TIDECLOAK_CONFIG_B64"); + } else { + const configPath = resolveTidecloakPath(); + configData = readFileSync(configPath, "utf-8"); + console.log(`[Gateway] Loading JWKS from ${configPath}`); + } + + const config = JSON.parse(configData) as TidecloakConfig; + + if (!config.jwk?.keys?.length) { + console.error("[Gateway] No JWKS keys found in config"); + process.exit(1); + } + + return config; +} + +/** + * Derive STUN ICE server address from the signaling WebSocket URL. + * ws://host:9090 → stun:host:3478 + * If host is localhost/127.0.0.1, auto-detect LAN IP so browsers on the + * same network can reach the STUN server. + */ +function deriveIceServers(wsUrl: string): string[] { + try { + const url = new URL(wsUrl); + let host = url.hostname; + if (host === "localhost" || host === "127.0.0.1") { + host = detectLanIp(); + } + return [`stun:${host}:3478`]; + } catch { + return []; + } +} + +function detectLanIp(): string { + const ifaces = networkInterfaces(); + for (const addrs of Object.values(ifaces)) { + if (!addrs) continue; + for (const addr of addrs) { + if (addr.family === "IPv4" && !addr.internal) { + return addr.address; + } + } + } + return "127.0.0.1"; +} + +function requireSecret(envVar: string): string { + const value = process.env[envVar] || ""; + if (!value) { + console.error(`[Gateway] ${envVar} is required (cannot be empty)`); + process.exit(1); + } + return value; +} + +function warnIfEmpty(envVar: string): string { + const value = process.env[envVar] || ""; + if (!value) { + console.warn(`[Gateway] WARNING: ${envVar} is empty — TURN credentials will be disabled`); + } + return value; +} + +function resolveTidecloakPath(): string { + if (process.env.TIDECLOAK_CONFIG_PATH) { + return process.env.TIDECLOAK_CONFIG_PATH; + } + + // Resolve relative to project root (parent of src/ or dist/) + const projectRoot = join(__dirname, ".."); + return join(projectRoot, "data", "tidecloak.json"); +} diff --git a/bridges/punchd-bridge/gateway/src/health.ts b/bridges/punchd-bridge/gateway/src/health.ts new file mode 100644 index 0000000..fb87c8a --- /dev/null +++ b/bridges/punchd-bridge/gateway/src/health.ts @@ -0,0 +1,26 @@ +/** + * HTTP health check endpoint. + */ + +import { createServer, Server } from "http"; + +export function createHealthServer( + port: number, + getStats: () => Record +): Server { + const server = createServer((req, res) => { + if (req.url === "/health") { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ status: "ok", ...getStats() })); + return; + } + res.writeHead(404); + res.end("Not found"); + }); + + server.listen(port, () => { + console.log(`[Health] http://localhost:${port}/health`); + }); + + return server; +} diff --git a/bridges/punchd-bridge/gateway/src/index.ts b/bridges/punchd-bridge/gateway/src/index.ts new file mode 100644 index 0000000..3e5d30d --- /dev/null +++ b/bridges/punchd-bridge/gateway/src/index.ts @@ -0,0 +1,123 @@ +/** + * KeyleSSH Gateway - HTTP/HTTPS Auth Gateway (local-facing) + * + * Runs on the internal/private network. Not exposed to the internet. + * Remote clients reach this gateway through the public STUN/TURN server. + * + * 1. Registers with the public STUN server as a gateway instance + * 2. Receives HTTP traffic from clients (via STUN/TURN relay or direct after NAT traversal) + * 3. Serves login page for TideCloak authentication (server-side OIDC) + * 4. Validates TideCloak JWT (cookie or Authorization header) + * 5. Proxies authorized requests to the local backend + */ + +import { hostname } from "os"; +import { loadConfig, loadTidecloakConfig } from "./config.js"; +import { createTidecloakAuth } from "./auth/tidecloak.js"; +import { createProxy } from "./proxy/http-proxy.js"; +import { createHealthServer } from "./health.js"; +import { registerWithStun } from "./registration/stun-client.js"; +import { generateSelfSignedCert } from "./tls/self-signed.js"; + +async function main() { + // ── Configuration ──────────────────────────────────────────────── + + const config = loadConfig(); + const tcConfig = loadTidecloakConfig(); + + const auth = createTidecloakAuth(tcConfig); + + // ── TLS ───────────────────────────────────────────────────────── + + const tls = config.https + ? await generateSelfSignedCert(config.tlsHostname) + : undefined; + + // ── HTTP/HTTPS Proxy ──────────────────────────────────────────── + + const { server: proxyServer, getStats } = createProxy({ + listenPort: config.listenPort, + backendUrl: config.backendUrl, + backends: config.backends, + auth, + stripAuthHeader: config.stripAuthHeader, + tcConfig, + authServerPublicUrl: config.authServerPublicUrl, + iceServers: config.iceServers, + turnServer: config.turnServer, + turnSecret: config.turnSecret, + tls, + tcInternalUrl: config.tcInternalUrl, + gatewayId: config.gatewayId, + }); + + // ── Health Check ───────────────────────────────────────────────── + + const healthServer = createHealthServer(config.healthPort, () => ({ + gatewayId: config.gatewayId, + ...getStats(), + })); + + // ── STUN Registration ──────────────────────────────────────────── + + const stunReg = registerWithStun({ + stunServerUrl: config.stunServerUrl, + gatewayId: config.gatewayId, + listenPort: config.listenPort, + useTls: !!tls, + iceServers: config.iceServers, + turnServer: config.turnServer, + turnSecret: config.turnSecret, + apiSecret: config.apiSecret, + metadata: { + displayName: config.displayName, + description: config.description, + backends: config.backends.map((b) => ({ name: b.name })), + realm: tcConfig.realm, + }, + addresses: [`${getLocalAddress()}:${config.listenPort}`], + onPaired(client) { + console.log( + `[Gateway] Client ${client.id} paired (reflexive: ${client.reflexiveAddress})` + ); + }, + }); + + function getLocalAddress(): string { + return process.env.GATEWAY_ADDRESS || hostname(); + } + + // ── Startup banner ─────────────────────────────────────────────── + + const scheme = config.https ? "https" : "http"; + console.log(`[Gateway] KeyleSSH Gateway (local-facing)`); + console.log(`[Gateway] Login: ${scheme}://localhost:${config.listenPort}/login`); + console.log(`[Gateway] Proxy: ${scheme}://localhost:${config.listenPort}`); + console.log(`[Gateway] Health: http://localhost:${config.healthPort}/health`); + if (config.backends.length > 1) { + for (const b of config.backends) { + console.log(`[Gateway] Backend: ${b.name} → ${b.url}`); + } + } else { + console.log(`[Gateway] Backend: ${config.backendUrl}`); + } + console.log(`[Gateway] STUN Server: ${config.stunServerUrl}`); + console.log(`[Gateway] Gateway ID: ${config.gatewayId}`); + + // ── Graceful shutdown ──────────────────────────────────────────── + + process.on("SIGTERM", () => { + console.log("[Gateway] Shutting down..."); + stunReg.close(); + proxyServer.close(); + healthServer.close(() => { + console.log("[Gateway] Shutdown complete"); + process.exit(0); + }); + }); +} + +main().catch((err) => { + console.error("[Gateway] Fatal:", err); + process.exit(1); +}); diff --git a/bridges/punchd-bridge/gateway/src/proxy/http-proxy.ts b/bridges/punchd-bridge/gateway/src/proxy/http-proxy.ts new file mode 100644 index 0000000..b2c1fcb --- /dev/null +++ b/bridges/punchd-bridge/gateway/src/proxy/http-proxy.ts @@ -0,0 +1,1302 @@ +/** + * HTTP auth gateway with server-side OIDC login flow. + * + * Public routes (no auth): /auth/*, /health + * Protected routes: everything else → validate JWT → proxy to backend + * + * Auth is extracted from: + * 1. `gateway_access` httpOnly cookie (browser sessions) + * 2. `Authorization: Bearer ` header (API/programmatic access) + * + * When the access token expires, the gateway transparently refreshes + * using the refresh token cookie before proxying. + */ + +import { + createServer, + Server, + IncomingMessage, + ServerResponse, + request as httpRequest, +} from "http"; +import { + createServer as createHttpsServer, + Server as HttpsServer, + request as httpsRequest, +} from "https"; +import { createHmac, randomBytes } from "crypto"; +import { readFileSync, realpathSync } from "fs"; +import { join, resolve } from "path"; +import type { TidecloakAuth } from "../auth/tidecloak.js"; +import type { TidecloakConfig } from "../config.js"; +import { + getOidcEndpoints, + buildAuthUrl, + exchangeCode, + refreshAccessToken, + buildLogoutUrl, + parseState, + type OidcEndpoints, +} from "../auth/oidc.js"; + +export interface ProxyOptions { + listenPort: number; + backendUrl: string; + backends?: { name: string; url: string; noAuth?: boolean; stripAuth?: boolean }[]; + auth: TidecloakAuth; + stripAuthHeader: boolean; + tcConfig: TidecloakConfig; + /** Public URL for TideCloak (browser-facing). Defaults to config auth-server-url. */ + authServerPublicUrl?: string; + /** ICE servers for WebRTC, e.g. ["stun:relay.example.com:3478"] */ + iceServers?: string[]; + /** TURN server URL, e.g. "turn:relay.example.com:3478" */ + turnServer?: string; + /** Shared secret for TURN REST API ephemeral credentials */ + turnSecret?: string; + /** TLS key + cert for HTTPS. If provided, server uses HTTPS. */ + tls?: { key: string; cert: string }; + /** Internal TideCloak URL for proxying (when KC_HOSTNAME is a public URL) */ + tcInternalUrl?: string; + /** Gateway ID for dest: role enforcement */ + gatewayId?: string; +} + +export interface ProxyStats { + totalRequests: number; + authorizedRequests: number; + rejectedRequests: number; +} + +// ── Cookie helpers ─────────────────────────────────────────────── + +function parseCookies(header: string | undefined): Record { + if (!header) return {}; + const cookies: Record = {}; + for (const pair of header.split(";")) { + const eq = pair.indexOf("="); + if (eq < 0) continue; + cookies[pair.slice(0, eq).trim()] = pair.slice(eq + 1).trim(); + } + return cookies; +} + +let _useSecureCookies = false; + +function buildCookieHeader( + name: string, + value: string, + maxAge: number, + sameSite: "Lax" | "Strict" | "None" = "Lax" +): string { + // SameSite=None requires the Secure flag (browser requirement) + const needsSecure = sameSite === "None" || _useSecureCookies; + const secure = needsSecure ? "; Secure" : ""; + return `${name}=${value}; HttpOnly; Path=/; Max-Age=${maxAge}; SameSite=${sameSite}${secure}`; +} + +function clearCookieHeader(name: string): string { + const secure = _useSecureCookies ? "; Secure" : ""; + return `${name}=; HttpOnly; Path=/; Max-Age=0; SameSite=Lax${secure}`; +} + +// ── Static file serving ────────────────────────────────────────── + +const PUBLIC_DIR = resolve( + import.meta.dirname ?? join(process.cwd(), "src", "proxy"), + "..", + "..", + "public" +); + +function serveFile( + res: ServerResponse, + filename: string, + contentType: string +): void { + try { + const resolved = resolve(PUBLIC_DIR, filename); + // Prevent path traversal and symlink escape — real path must be inside PUBLIC_DIR + const realPath = realpathSync(resolved); + if (!realPath.startsWith(PUBLIC_DIR + "/")) { + res.writeHead(403, { "Content-Type": "text/plain" }); + res.end("Forbidden"); + return; + } + const content = readFileSync(realPath, "utf-8"); + res.writeHead(200, { "Content-Type": contentType }); + res.end(content); + } catch { + res.writeHead(404, { "Content-Type": "text/plain" }); + res.end("Not found"); + } +} + +// ── Redirect helper ────────────────────────────────────────────── + +function redirect(res: ServerResponse, location: string, status = 302): void { + res.writeHead(status, { Location: location }); + res.end(); +} + +// ── Open redirect prevention ──────────────────────────────────── + +function sanitizeRedirect(url: string): string { + if (!url || typeof url !== "string") return "/"; + const trimmed = url.trim(); + if (trimmed.startsWith("//") || /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(trimmed)) return "/"; + if (!trimmed.startsWith("/")) return "/"; + return trimmed; +} + +// ── HTTP method validation ────────────────────────────────────── + +const ALLOWED_METHODS = new Set(["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"]); + +// ── Request type detection ─────────────────────────────────────── + +function isBrowserRequest(req: IncomingMessage): boolean { + const accept = req.headers.accept || ""; + return accept.includes("text/html"); +} + +function getCallbackUrl(req: IncomingMessage, isTls: boolean): string { + const proto = isTls ? "https" : "http"; + const host = req.headers.host || `localhost`; + return `${proto}://${host}/auth/callback`; +} + +// ── Redirect rewriting ────────────────────────────────────── + +/** + * Rewrite `Location` headers that point to localhost or the TideCloak + * origin. localhost:PORT refs become /__b/ paths (path-based + * backend routing), keeping DataChannel and remote connections working. + */ +function rewriteRedirects( + headers: Record, + tcOrigin: string, + portMap?: Map, + replacement?: string +): void { + if (!headers.location || typeof headers.location !== "string") return; + + // Rewrite TideCloak origin → replacement origin (or relative path) + if (tcOrigin && headers.location.startsWith(tcOrigin)) { + headers.location = (replacement || "") + (headers.location.slice(tcOrigin.length) || "/"); + return; // Don't apply localhost regex — URL is already rewritten + } + // Rewrite localhost:PORT → /__b/ (known backend) or strip (unknown) + headers.location = headers.location.replace( + /^https?:\/\/localhost(:\d+)?/, + (_match: string, portGroup?: string) => { + if (portGroup && portMap) { + const port = portGroup.slice(1); + const name = portMap.get(port); + if (name) return `/__b/${encodeURIComponent(name)}`; + } + return replacement || ""; + } + ); +} + +// Regex matching http(s)://localhost:PORT — used to rewrite backend +// cross-references in HTML so they stay within the DataChannel. +const LOCALHOST_URL_RE = /https?:\/\/localhost(:\d+)?/g; + +// ── Main proxy factory ─────────────────────────────────────────── + +export function createProxy(options: ProxyOptions): { + server: Server | HttpsServer; + getStats: () => ProxyStats; +} { + const stats: ProxyStats = { + totalRequests: 0, + authorizedRequests: 0, + rejectedRequests: 0, + }; + + // Build backend lookup map (name → URL) + const backendMap = new Map(); + if (options.backends?.length) { + for (const b of options.backends) { + backendMap.set(b.name, new URL(b.url)); + } + } + const defaultBackendUrl = new URL(options.backendUrl); + + // No-auth backends: skip gateway JWT validation (backend handles its own auth) + const noAuthBackends = new Set(); + // Strip-auth backends: remove Authorization header before proxying + const stripAuthBackends = new Set(); + if (options.backends?.length) { + for (const b of options.backends) { + if (b.noAuth) { + noAuthBackends.add(b.name); + console.log(`[Proxy] Backend "${b.name}" — auth disabled (noauth)`); + } + if (b.stripAuth) { + stripAuthBackends.add(b.name); + console.log(`[Proxy] Backend "${b.name}" — auth header stripped (stripauth)`); + } + } + } + + // Reverse map: "localhost:PORT" → backend name (for cross-backend routing) + const portToBackend = new Map(); + for (const [name, url] of backendMap) { + if (url.hostname === "localhost" || url.hostname === "127.0.0.1") { + portToBackend.set(url.port || (url.protocol === "https:" ? "443" : "80"), name); + } + } + + /** + * Rewrite all localhost:PORT URLs in HTML to /__b/ paths. + * This keeps links, form actions, and JS references within the + * DataChannel and routes them to the correct backend. + */ + function rewriteLocalhostInHtml(html: string): string { + return html.replace(LOCALHOST_URL_RE, (_match: string, portGroup?: string) => { + if (portGroup) { + const port = portGroup.slice(1); + const name = portToBackend.get(port); + if (name) return `/__b/${encodeURIComponent(name)}`; + } + return ""; + }); + } + + /** + * Prepend /__b/ prefix to absolute paths in HTML attributes + * (href="/...", src="/...", action="/...") so links stay within + * the correct backend namespace. Skips protocol-relative (//) + * and already-prefixed (/__b/) paths. + */ + function prependPrefix(html: string, prefix: string): string { + return html.replace( + /((?:href|src|action|formaction)\s*=\s*["'])(\/(?!\/|__b\/))/gi, + `$1${prefix}$2` + ); + } + + function resolveBackend(req: IncomingMessage, activeBackend?: string): URL { + // 1. Path-based /__b/ prefix (highest priority) + if (activeBackend) { + const found = backendMap.get(activeBackend); + if (found) return found; + } + // 2. x-gateway-backend header (set by STUN relay from /__b/ prefix in URL) + const headerBackend = req.headers["x-gateway-backend"] as string | undefined; + if (headerBackend) { + const found = backendMap.get(headerBackend); + if (found) return found; + } + return defaultBackendUrl; + } + + // ── TideCloak cookie jar ────────────────────────────────────── + // The STUN relay may not forward Set-Cookie headers to the browser, + // so the gateway stores TC cookies server-side. A lightweight `tc_sess` + // cookie on the browser maps to the stored TC cookies. + interface TcSession { cookies: Map; lastAccess: number; } + const tcCookieJar = new Map(); + const TC_SESS_MAX_AGE = 3600; // 1 hour + const TC_SESS_MAX_ENTRIES = 10000; + + /** Get or create a TC session ID from the browser's tc_sess cookie. */ + function getTcSessionId(req: IncomingMessage): { id: string; isNew: boolean } { + const cookies = parseCookies(req.headers.cookie); + const candidateId = cookies["tc_sess"]; + // Only accept existing session IDs that are in the jar (ignore client-supplied unknown IDs) + if (candidateId && tcCookieJar.has(candidateId)) { + const existing = tcCookieJar.get(candidateId)!; + existing.lastAccess = Date.now(); + return { id: candidateId, isNew: false }; + } + // Always generate a new server-side ID — never trust a client-supplied value + const id = randomBytes(16).toString("hex"); + tcCookieJar.set(id, { cookies: new Map(), lastAccess: Date.now() }); + return { id, isNew: true }; + } + + /** Store TC's Set-Cookie values in the jar, return gateway's tc_sess cookie. */ + function storeTcCookies( + sessionId: string, + setCookieHeaders: string | string[] | undefined + ): void { + if (!setCookieHeaders) return; + const session = tcCookieJar.get(sessionId); + if (!session) return; + session.lastAccess = Date.now(); + const headers = Array.isArray(setCookieHeaders) ? setCookieHeaders : [setCookieHeaders]; + for (const h of headers) { + const eq = h.indexOf("="); + if (eq < 0) continue; + const name = h.slice(0, eq).trim(); + // Extract just the value (up to first ';') + const rest = h.slice(eq + 1); + const semi = rest.indexOf(";"); + const value = semi >= 0 ? rest.slice(0, semi) : rest; + // Ignore clearing (Max-Age=0 or empty value) + if (!value || /Max-Age=0/i.test(h)) { + session.cookies.delete(name); + } else { + session.cookies.set(name, value); + } + } + } + + /** Build a Cookie header from the jar for proxied requests to TC. */ + function getTcCookieHeader(sessionId: string): string { + const session = tcCookieJar.get(sessionId); + if (!session || session.cookies.size === 0) return ""; + session.lastAccess = Date.now(); + return Array.from(session.cookies.entries()).map(([k, v]) => `${k}=${v}`).join("; "); + } + + // Periodically evict stale sessions (every 10 min) + setInterval(() => { + const now = Date.now(); + const maxAge = TC_SESS_MAX_AGE * 1000; + // Evict expired entries + for (const [id, session] of tcCookieJar) { + if (now - session.lastAccess > maxAge) { + tcCookieJar.delete(id); + } + } + // If still over limit, evict oldest (LRU) + if (tcCookieJar.size > TC_SESS_MAX_ENTRIES) { + const sorted = [...tcCookieJar.entries()].sort((a, b) => a[1].lastAccess - b[1].lastAccess); + const toRemove = sorted.slice(0, tcCookieJar.size - TC_SESS_MAX_ENTRIES); + for (const [id] of toRemove) { + tcCookieJar.delete(id); + } + } + }, 600_000).unref(); + + // ── Backend cookie jar ───────────────────────────────────────── + // DataChannel requests (WebRTC) bypass the browser's cookie handling: + // - Set-Cookie is a forbidden header in SW's new Response() + // - HttpOnly cookies can't be read from JS + // So the gateway stores backend cookies server-side, keyed by JWT sub. + // Key format: "userId:backendName" to prevent cross-backend cookie leakage + interface BackendSession { cookies: Map; lastAccess: number; } + const backendCookieJar = new Map(); + const BACKEND_SESS_MAX_AGE = 7 * 24 * 3600; // 7 days (match backend session) + const BACKEND_SESS_MAX_ENTRIES = 10000; + + /** Store backend Set-Cookie values in the jar for DataChannel sessions. */ + function storeBackendCookies( + userId: string, + setCookieHeaders: string | string[] | undefined + ): void { + if (!setCookieHeaders || !userId) return; + let session = backendCookieJar.get(userId); + if (!session) { + session = { cookies: new Map(), lastAccess: Date.now() }; + backendCookieJar.set(userId, session); + } + session.lastAccess = Date.now(); + const headers = Array.isArray(setCookieHeaders) ? setCookieHeaders : [setCookieHeaders]; + for (const h of headers) { + const eq = h.indexOf("="); + if (eq < 0) continue; + const name = h.slice(0, eq).trim(); + const rest = h.slice(eq + 1); + const semi = rest.indexOf(";"); + const value = semi >= 0 ? rest.slice(0, semi) : rest; + if (!value || /Max-Age=0/i.test(h)) { + session.cookies.delete(name); + } else { + session.cookies.set(name, value); + } + } + } + + /** Build a Cookie header from the backend jar. */ + function getBackendCookieHeader(userId: string): string { + const session = backendCookieJar.get(userId); + if (!session || session.cookies.size === 0) return ""; + session.lastAccess = Date.now(); + return Array.from(session.cookies.entries()).map(([k, v]) => `${k}=${v}`).join("; "); + } + + // Evict stale backend sessions (piggyback on TC timer interval) + setInterval(() => { + const now = Date.now(); + const maxAge = BACKEND_SESS_MAX_AGE * 1000; + for (const [id, session] of backendCookieJar) { + if (now - session.lastAccess > maxAge) { + backendCookieJar.delete(id); + } + } + if (backendCookieJar.size > BACKEND_SESS_MAX_ENTRIES) { + const sorted = [...backendCookieJar.entries()].sort((a, b) => a[1].lastAccess - b[1].lastAccess); + const toRemove = sorted.slice(0, backendCookieJar.size - BACKEND_SESS_MAX_ENTRIES); + for (const [id] of toRemove) { + backendCookieJar.delete(id); + } + } + }, 600_000).unref(); + + // TideCloak internal URL for reverse-proxying and server-side requests. + // When KC_HOSTNAME is a public URL, TC_INTERNAL_URL points to the actual + // TideCloak instance (e.g. http://localhost:8080). + const tcInternalUrl = options.tcInternalUrl || options.tcConfig["auth-server-url"]; + const tcProxyUrl = new URL(tcInternalUrl); + const tcProxyIsHttps = tcProxyUrl.protocol === "https:"; + const makeTcRequest = tcProxyIsHttps ? httpsRequest : httpRequest; + + // KC_HOSTNAME-based public URL (from adapter config). TideCloak generates + // redirects and URLs using this, so we need to rewrite it too. + const tcPublicOrigin = options.tcConfig["auth-server-url"] + ? new URL(options.tcConfig["auth-server-url"]).origin + : null; + + console.log(`[Proxy] TideCloak internal URL: ${tcInternalUrl}`); + console.log(`[Proxy] TideCloak public origin: ${tcPublicOrigin}`); + console.log(`[Proxy] TideCloak config auth-server-url: ${options.tcConfig["auth-server-url"]}`); + if (!options.tcInternalUrl && !tcInternalUrl.includes("localhost")) { + console.warn(`[Proxy] WARNING: TC_INTERNAL_URL not set — token exchange will use public URL: ${tcInternalUrl}`); + console.warn(`[Proxy] Set TC_INTERNAL_URL=http://localhost:8080 if TideCloak runs locally`); + } + + // Browser-facing endpoints use public URL if explicitly set; + // otherwise derived per-request from Host header (see getBrowserEndpoints) + const fixedBrowserEndpoints: OidcEndpoints | null = options.authServerPublicUrl + ? getOidcEndpoints(options.tcConfig, options.authServerPublicUrl) + : null; + // Server-side endpoints (token exchange, refresh) always use internal URL + const serverEndpoints: OidcEndpoints = getOidcEndpoints(options.tcConfig, tcInternalUrl); + const clientId = options.tcConfig.resource; + const isTls = !!options.tls; + _useSecureCookies = isTls; + + /** Get browser-facing OIDC endpoints. + * Uses authServerPublicUrl if explicitly set, otherwise returns relative + * paths (/realms/...) so auth traffic stays on the gateway origin. + * The /realms/* proxy forwards these to the real TideCloak server. */ + function getBrowserEndpoints(_req: IncomingMessage): OidcEndpoints { + if (fixedBrowserEndpoints) return fixedBrowserEndpoints; + // Use relative paths so auth URLs route through the gateway's TideCloak proxy + return getOidcEndpoints(options.tcConfig, ""); + } + + const requestHandler = async (req: IncomingMessage, res: ServerResponse) => { + // ── Security headers ────────────────────────────────────────── + res.setHeader("X-Content-Type-Options", "nosniff"); + res.setHeader("X-Frame-Options", "SAMEORIGIN"); + res.setHeader("Referrer-Policy", "strict-origin-when-cross-origin"); + res.setHeader( + "Content-Security-Policy", + "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; connect-src 'self' wss: ws:; img-src 'self' data: blob:; media-src 'self' blob:; worker-src 'self' blob:; frame-ancestors 'self'" + ); + if (isTls) { + res.setHeader("Strict-Transport-Security", "max-age=31536000; includeSubDomains"); + } + + let url = req.url || "/"; + let path = url.split("?")[0]; + let backendPrefix = ""; // e.g. "/__b/MediaBox" + let activeBackend = ""; // e.g. "MediaBox" + + // ── Path-based backend routing ────────────────────── + // Strip /__b// prefix so all routes work normally. + // The backend is determined by path, not cookies. + if (path.startsWith("/__b/")) { + const rest = path.slice("/__b/".length); + const slashIdx = rest.indexOf("/"); + const encodedName = slashIdx >= 0 ? rest.slice(0, slashIdx) : rest; + const name = decodeURIComponent(encodedName); + if (backendMap.has(name)) { + activeBackend = name; + backendPrefix = `/__b/${encodedName}`; + const stripped = slashIdx >= 0 ? rest.slice(slashIdx) : "/"; + const query = url.includes("?") ? url.slice(url.indexOf("?")) : ""; + url = stripped + query; + path = stripped; + req.url = url; + } + } + + // ── TideCloak /_idp prefix stripping ───────────────── + // The proxy rewrites TC's localhost URLs to {publicOrigin}/_idp/… + // so the Tide SDK enclave iframe can reach TC through the relay. + // Strip the /_idp prefix here so the request hits the /realms/* + // or /resources/* handler below. + if (path.startsWith("/_idp/")) { + url = url.slice("/_idp".length); + path = path.slice("/_idp".length); + req.url = url; + } + + // ── Public routes ──────────────────────────────────── + + // Favicon — return empty 204 to avoid falling through to backend role check + if (path === "/favicon.ico") { + res.writeHead(204); + res.end(); + return; + } + + // Static JS files + if (path.startsWith("/js/") && path.endsWith(".js")) { + // Allow SW to control root scope even though it lives under /js/ + if (path === "/js/sw.js") { + res.setHeader("Service-Worker-Allowed", "/"); + res.setHeader("Cache-Control", "no-cache"); + } + serveFile(res, path.slice(1), "application/javascript; charset=utf-8"); + return; + } + + // WebRTC config — tells the browser how to connect for P2P upgrade + // TURN credentials require valid JWT to prevent bandwidth abuse + if (path === "/webrtc-config") { + const proto = isTls ? "https" : "http"; + const host = req.headers.host || "localhost"; + const wsProto = isTls ? "wss" : "ws"; + const webrtcConfig: Record = { + signalingUrl: `${wsProto}://${host}`, + stunServer: options.iceServers?.[0] + ? `stun:${options.iceServers[0].replace("stun:", "")}` + : null, + }; + if (options.turnServer && options.turnSecret) { + // Only serve TURN credentials to authenticated users + const wrtcCookies = parseCookies(req.headers.cookie); + const wrtcToken = wrtcCookies["gateway_access"]; + const wrtcPayload = wrtcToken ? await options.auth.verifyToken(wrtcToken) : null; + if (wrtcPayload) { + const expiry = Math.floor(Date.now() / 1000) + 3600; + const turnUsername = `${expiry}`; + const turnPassword = createHmac("sha1", options.turnSecret) + .update(turnUsername) + .digest("base64"); + webrtcConfig.turnServer = options.turnServer; + webrtcConfig.turnUsername = turnUsername; + webrtcConfig.turnPassword = turnPassword; + } + } + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify(webrtcConfig)); + return; + } + + // OIDC: initiate login + if (path === "/auth/login") { + const params = new URLSearchParams(url.split("?")[1] || ""); + const originalUrl = sanitizeRedirect(params.get("redirect") || "/"); + const callbackUrl = getCallbackUrl(req, isTls); + const { url: authUrl, state } = buildAuthUrl( + getBrowserEndpoints(req), + clientId, + callbackUrl, + originalUrl + ); + // Store state nonce in a short-lived cookie for CSRF validation on callback + const parsedState = parseState(state); + res.writeHead(302, { + Location: authUrl, + "Set-Cookie": `oidc_nonce=${parsedState.nonce}; HttpOnly; Path=/auth/callback; Max-Age=600; SameSite=Lax${isTls ? "; Secure" : ""}`, + }); + res.end(); + return; + } + + // OIDC: callback from TideCloak + if (path === "/auth/callback") { + const params = new URLSearchParams(url.split("?")[1] || ""); + const code = params.get("code"); + const stateParam = params.get("state") || ""; + const error = params.get("error"); + const errorDesc = params.get("error_description"); + + if (error) { + console.log(`[Gateway] Auth error from TideCloak: ${error} — ${errorDesc || "no description"}`); + redirect(res, `/auth/login?error=${encodeURIComponent(error)}`); + return; + } + + if (!code) { + console.log("[Gateway] Auth callback missing code parameter"); + redirect(res, `/auth/login?error=no_code`); + return; + } + + // CSRF validation: compare state nonce against oidc_nonce cookie + const callbackCookies = parseCookies(req.headers.cookie); + const state = parseState(stateParam); + const expectedNonce = callbackCookies["oidc_nonce"]; + if (!expectedNonce || expectedNonce !== state.nonce) { + console.log("[Gateway] OIDC CSRF check failed: nonce mismatch"); + redirect(res, `/auth/login?error=csrf_failed`); + return; + } + + try { + const callbackUrl = getCallbackUrl(req, isTls); + console.log(`[Gateway] Token exchange:`); + console.log(`[Gateway] endpoint: ${serverEndpoints.token}`); + console.log(`[Gateway] client_id: ${clientId}`); + console.log(`[Gateway] redirect_uri: ${callbackUrl}`); + console.log(`[Gateway] code: ${code.slice(0, 8)}...`); + const tokens = await exchangeCode( + serverEndpoints, + clientId, + code, + callbackUrl + ); + console.log(`[Gateway] Token exchange succeeded (expires_in=${tokens.expires_in})`); + + const cookies: string[] = [ + buildCookieHeader( + "gateway_access", + tokens.access_token, + tokens.expires_in + ), + ]; + + if (tokens.refresh_token) { + cookies.push( + buildCookieHeader( + "gateway_refresh", + tokens.refresh_token, + tokens.refresh_expires_in || 1800, + "Strict" + ) + ); + } + + // Clear the one-time CSRF nonce cookie + cookies.push("oidc_nonce=; HttpOnly; Path=/auth/callback; Max-Age=0"); + + const safeRedirect = sanitizeRedirect(state.redirect || "/"); + console.log(`[Gateway] Auth complete, redirecting to: ${safeRedirect}`); + res.writeHead(302, { + Location: safeRedirect, + "Set-Cookie": cookies, + }); + res.end(); + } catch (err) { + console.error("[Gateway] Token exchange failed:", err); + redirect(res, `/auth/login?error=token_exchange`); + } + return; + } + + // Session token — returns JWT from HttpOnly cookie so the page + // can include it in WebRTC DataChannel requests (SW can't read cookies). + // Requires X-Requested-With header to prevent simple cross-origin requests + // (XSS in a proxied backend can still call this, but it blocks CSRF from + // external origins since custom headers trigger a CORS preflight). + if (path === "/auth/session-token") { + if (!req.headers["x-requested-with"]) { + res.writeHead(403, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "Missing X-Requested-With header" })); + return; + } + const cookies = parseCookies(req.headers.cookie); + let accessToken = cookies["gateway_access"]; + // Also accept Authorization: Bearer token (relay flow has no cookies) + if (!accessToken) { + const authHeader = req.headers.authorization; + if (authHeader?.startsWith("Bearer ")) { + accessToken = authHeader.slice(7); + } + } + if (!accessToken) { + res.writeHead(401, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "No session" })); + return; + } + let payload = await options.auth.verifyToken(accessToken); + + // If access token expired, try refreshing with refresh token + const setCookies: string[] = []; + if (!payload && cookies["gateway_refresh"]) { + try { + const tokens = await refreshAccessToken( + serverEndpoints, + clientId, + cookies["gateway_refresh"] + ); + payload = await options.auth.verifyToken(tokens.access_token); + if (payload) { + accessToken = tokens.access_token; + setCookies.push( + buildCookieHeader("gateway_access", tokens.access_token, tokens.expires_in) + ); + if (tokens.refresh_token) { + setCookies.push( + buildCookieHeader("gateway_refresh", tokens.refresh_token, tokens.refresh_expires_in || 1800, "Strict") + ); + } + } + } catch (err) { + console.log("[Gateway] Session token refresh failed:", err); + } + } + + if (!payload) { + res.writeHead(401, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "Invalid session" })); + return; + } + + const headers: Record = { + "Content-Type": "application/json", + "Cache-Control": "no-store", + "Pragma": "no-cache", + }; + if (setCookies.length > 0) { + headers["Set-Cookie"] = setCookies; + } + res.writeHead(200, headers); + res.end(JSON.stringify({ token: accessToken })); + return; + } + + // OIDC: logout + if (path === "/auth/logout") { + // Clear backend cookie jar for this user before logout + const cookies = parseCookies(req.headers.cookie); + const logoutToken = cookies["gateway_access"]; + if (logoutToken) { + const logoutPayload = await options.auth.verifyToken(logoutToken); + if (logoutPayload?.sub) { + // Clear all backend-scoped entries for this user + const prefix = `${logoutPayload.sub}:`; + for (const key of backendCookieJar.keys()) { + if (key.startsWith(prefix)) backendCookieJar.delete(key); + } + } + } + + const callbackUrl = getCallbackUrl(req, isTls); + const proto = callbackUrl.split("/auth/callback")[0]; + const logoutUrl = buildLogoutUrl( + getBrowserEndpoints(req), + clientId, + `${proto}/auth/login` + ); + + // Clear tc_sess from jar and cookie + const logoutCookies = parseCookies(req.headers.cookie); + if (logoutCookies["tc_sess"]) { + tcCookieJar.delete(logoutCookies["tc_sess"]); + } + + res.writeHead(302, { + Location: logoutUrl, + "Set-Cookie": [ + clearCookieHeader("gateway_access"), + clearCookieHeader("gateway_refresh"), + clearCookieHeader("tc_sess"), + ], + }); + res.end(); + return; + } + + // ── Reverse-proxy TideCloak (/realms/*, /resources/*) ── + // Public — TideCloak handles its own auth on these paths. + // This keeps the browser on the gateway origin so DataChannel + // and remote access don't break on auth redirects. + // + // Cookie jar: TC's cookies are stored server-side and injected + // into proxied requests. This avoids relying on the STUN relay + // to forward Set-Cookie headers to the browser. + // + // Note: CORS is NOT handled here — the STUN relay is the final + // hop to the browser and adds CORS headers there. Adding CORS at + // both levels causes duplicate Access-Control-Allow-Origin headers + // which makes the browser reject the response entirely. + if (path.startsWith("/realms/") || path.startsWith("/resources/") || path.startsWith("/admin")) { + const publicProto = isTls ? "https" : "http"; + const publicHost = req.headers.host || "localhost"; + const publicBase = `${publicProto}://${publicHost}/_idp`; + + // Get or create a server-side TC session for cookie jar + const tcSess = getTcSessionId(req); + + const tcProxyHeaders = { ...req.headers }; + tcProxyHeaders.host = tcProxyUrl.host; + // Strip forwarded headers so TideCloak sees plain HTTP localhost + // and doesn't redirect to KC_HOSTNAME based on protocol mismatch + delete tcProxyHeaders["x-forwarded-proto"]; + delete tcProxyHeaders["x-forwarded-host"]; + delete tcProxyHeaders["x-forwarded-for"]; + delete tcProxyHeaders["x-forwarded-port"]; + // Request uncompressed so we can rewrite URLs in the response + delete tcProxyHeaders["accept-encoding"]; + + // Inject stored TC cookies into the proxied request + const jarCookies = getTcCookieHeader(tcSess.id); + if (jarCookies) { + // Merge with any existing cookies from the browser + const existing = tcProxyHeaders.cookie || ""; + tcProxyHeaders.cookie = existing ? `${existing}; ${jarCookies}` : jarCookies; + } + + const tcProxyReq = makeTcRequest( + { + hostname: tcProxyUrl.hostname, + port: tcProxyUrl.port || (tcProxyIsHttps ? 443 : 80), + path: url, + method: req.method, + headers: tcProxyHeaders, + }, + (tcProxyRes) => { + const headers = { ...tcProxyRes.headers }; + rewriteRedirects(headers, tcProxyUrl.origin, undefined, publicBase); + + // Remove any encoding header since we'll serve uncompressed + delete headers["content-encoding"]; + delete headers["transfer-encoding"]; + + // Strip CSP so rewritten cross-origin URLs aren't blocked + delete headers["content-security-policy"]; + delete headers["content-security-policy-report-only"]; + + // Store TC's cookies server-side instead of forwarding to browser + const rawSC = headers["set-cookie"]; + storeTcCookies(tcSess.id, rawSC); + // Replace TC's Set-Cookie with our tc_sess cookie. + // SameSite=None so cross-site iframes (Tide SDK enclave on + // sork1.tideprotocol.com) can send it back for tidevouchers. + if (rawSC || tcSess.isNew) { + headers["set-cookie"] = [ + buildCookieHeader("tc_sess", tcSess.id, TC_SESS_MAX_AGE, "None"), + ]; + } + + const contentType = (headers["content-type"] || "") as string; + const isText = contentType.includes("text/") || + contentType.includes("application/javascript") || + contentType.includes("application/json"); + if (isText) { + const chunks: Buffer[] = []; + let totalSize = 0; + const MAX_RESPONSE = 50 * 1024 * 1024; // 50 MB + tcProxyRes.on("data", (chunk: Buffer) => { + totalSize += chunk.length; + if (totalSize > MAX_RESPONSE) { + tcProxyRes.destroy(); + if (!res.headersSent) { + res.writeHead(502, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "Response too large" })); + } + return; + } + chunks.push(chunk); + }); + tcProxyRes.on("end", () => { + if (res.headersSent) return; + let body = Buffer.concat(chunks).toString("utf-8"); + // Rewrite TC internal URLs → public gateway base + body = body.replaceAll(tcProxyUrl.origin, publicBase); + body = body.replaceAll( + tcProxyUrl.origin.replaceAll("/", "\\/"), + publicBase.replaceAll("/", "\\/") + ); + body = body.replaceAll( + encodeURIComponent(tcProxyUrl.origin), + encodeURIComponent(publicBase) + ); + // Rewrite KC_HOSTNAME public URLs in body so admin console + // auth flow stays on the local gateway instead of going through + // the STUN relay (different domain = broken cookies). + // Note: NOT done for Location headers — only body content. + if (tcPublicOrigin && tcPublicOrigin !== tcProxyUrl.origin) { + body = body.replaceAll(tcPublicOrigin, publicBase); + body = body.replaceAll( + tcPublicOrigin.replaceAll("/", "\\/"), + publicBase.replaceAll("/", "\\/") + ); + body = body.replaceAll( + encodeURIComponent(tcPublicOrigin), + encodeURIComponent(publicBase) + ); + } + + delete headers["content-length"]; + res.writeHead(tcProxyRes.statusCode || 502, headers); + res.end(body); + }); + } else { + res.writeHead(tcProxyRes.statusCode || 502, headers); + tcProxyRes.pipe(res); + } + } + ); + + tcProxyReq.setTimeout(30000, () => { + tcProxyReq.destroy(); + if (!res.headersSent) { + res.writeHead(504, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "Auth server timeout" })); + } + }); + + tcProxyReq.on("error", (err) => { + console.error("[Proxy] TideCloak error:", err.message); + if (!res.headersSent) { + res.writeHead(502, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "Auth server unavailable" })); + } + }); + + req.pipe(tcProxyReq); + return; + } + + // ── Protected routes ───────────────────────────────── + + stats.totalRequests++; + + // Check if this backend skips gateway-side JWT validation + const isNoAuth = activeBackend + ? noAuthBackends.has(activeBackend) + : false; // default backend always requires JWT + + let payload: any = null; + + if (isNoAuth) { + // Backend handles its own auth — skip JWT validation + stats.authorizedRequests++; + } else { + // Extract JWT: cookie first, then Authorization header + const cookies = parseCookies(req.headers.cookie); + let token = cookies["gateway_access"] || null; + + if (!token) { + const authHeader = req.headers.authorization; + if (authHeader?.startsWith("Bearer ")) { + token = authHeader.slice(7); + } + } + + // Validate JWT + payload = token ? await options.auth.verifyToken(token) : null; + + // If access token expired, try refreshing with refresh token + if (!payload && cookies["gateway_refresh"]) { + try { + const tokens = await refreshAccessToken( + serverEndpoints, + clientId, + cookies["gateway_refresh"] + ); + + payload = await options.auth.verifyToken(tokens.access_token); + + if (payload) { + // Set updated cookies on the response + token = tokens.access_token; + const refreshCookies: string[] = [ + buildCookieHeader( + "gateway_access", + tokens.access_token, + tokens.expires_in + ), + ]; + if (tokens.refresh_token) { + refreshCookies.push( + buildCookieHeader( + "gateway_refresh", + tokens.refresh_token, + tokens.refresh_expires_in || 1800, + "Strict" + ) + ); + } + // Store cookies to set on the proxied response + (res as any).__refreshCookies = refreshCookies; + } + } catch (err) { + console.log("[Gateway] Token refresh failed:", err); + } + } + + // No valid token — redirect browser or 401 for API + if (!payload) { + stats.rejectedRequests++; + + if (isBrowserRequest(req)) { + const fullUrl = backendPrefix + url; + const redirectTarget = encodeURIComponent(fullUrl); + // Redirect to TideCloak SSO — handles both fresh sessions + // and expired ones (refresh failed or no refresh token) + redirect(res, `/auth/login?redirect=${redirectTarget}`); + } else { + res.writeHead(401, { "Content-Type": "application/json" }); + res.end( + JSON.stringify({ error: "Missing or invalid authorization" }) + ); + } + return; + } + + stats.authorizedRequests++; + + // ── dest: role enforcement ───────────────────────── + // Users must have an explicit dest:: role + // to access any gateway backend. No matching role = 403. + if (options.gatewayId && payload) { + // dest:: — split on first and second ':' + const realmRoles: string[] = (payload as any)?.realm_access?.roles ?? []; + const clientId = options.tcConfig.resource; + const clientRoles: string[] = (payload as any)?.resource_access?.[clientId]?.roles ?? []; + const allRoles = [...realmRoles, ...clientRoles]; + + const backend = activeBackend || options.backends?.[0]?.name || "Default"; + const gwIdLower = options.gatewayId!.toLowerCase(); + const backendLower = backend.toLowerCase(); + const hasAccess = allRoles.some((r: string) => { + if (!/^dest:/i.test(r)) return false; + // Split "dest::" on first two colons + const firstColon = r.indexOf(":"); + const secondColon = r.indexOf(":", firstColon + 1); + if (secondColon < 0) return false; + const gwId = r.slice(firstColon + 1, secondColon); + const bk = r.slice(secondColon + 1); + return gwId.toLowerCase() === gwIdLower && bk.toLowerCase() === backendLower; + }); + if (!hasAccess) { + const destRoles = allRoles.filter((r: string) => /^dest:/i.test(r)); + console.log(`[Gateway] dest role denied: gwId=${options.gatewayId} backend="${backend}" clientId="${clientId}" destRoles=${JSON.stringify(destRoles)} allRolesCount=${allRoles.length}`); + stats.rejectedRequests++; + res.writeHead(403, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "Forbidden: no dest role for this backend" })); + return; + } + } + } + + // ── Proxy to backend ───────────────────────────────── + + // DC auth trace (opt-in via DEBUG_DC=true) + if (process.env.DEBUG_DC && req.headers["x-dc-request"]) { + console.log(`[Gateway] DC request: url=${req.url} authed=${!!payload} backend=${activeBackend || "default"}`); + } + + // Validate HTTP method + if (!ALLOWED_METHODS.has((req.method || "").toUpperCase())) { + res.writeHead(405, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "Method not allowed" })); + return; + } + + const proxyHeaders = { ...req.headers }; + delete proxyHeaders.host; + + // Remove auth header: globally (STRIP_AUTH_HEADER) or per-backend (;stripauth) + if (options.stripAuthHeader || stripAuthBackends.has(activeBackend || "")) { + delete proxyHeaders.authorization; + } + + // ── Sanitize client-spoofable headers ───────────────── + // Strip forwarded headers before setting them from trusted sources. + // Prevents clients from injecting identities or spoofing IPs. + // x-gateway-backend is stripped later (line ~998) before proxying — + // it's needed by resolveBackend() above, and is already JWT-gated. + delete proxyHeaders["x-forwarded-user"]; + delete proxyHeaders["x-forwarded-for"]; + delete proxyHeaders["x-forwarded-proto"]; + delete proxyHeaders["x-forwarded-host"]; + delete proxyHeaders["x-forwarded-port"]; + + if (payload) { + proxyHeaders["x-forwarded-user"] = payload.sub || "unknown"; + } + proxyHeaders["x-forwarded-for"] = + req.socket.remoteAddress || "unknown"; + + const targetBackend = resolveBackend(req, activeBackend); + const targetIsHttps = targetBackend.protocol === "https:"; + const makeBackendReq = targetIsHttps ? httpsRequest : httpRequest; + + // DataChannel requests: inject stored backend cookies (browser can't + // attach HttpOnly cookies through the SW/DataChannel path) + const isDcRequest = !!proxyHeaders["x-dc-request"]; + delete proxyHeaders["x-dc-request"]; // don't leak to backend + delete proxyHeaders["x-gateway-backend"]; // don't leak routing header to backend + // Request uncompressed responses so we can rewrite HTML (URL prefixing, script injection) + delete proxyHeaders["accept-encoding"]; + const backendKey = activeBackend || options.backends?.[0]?.name || "default"; + if (isDcRequest && payload?.sub) { + const jarCookies = getBackendCookieHeader(`${payload.sub}:${backendKey}`); + if (jarCookies) { + const existing = (proxyHeaders.cookie as string) || ""; + proxyHeaders.cookie = existing ? `${existing}; ${jarCookies}` : jarCookies; + } + } + + const proxyReq = makeBackendReq( + { + hostname: targetBackend.hostname, + port: targetBackend.port || (targetIsHttps ? 443 : 80), + path: req.url, + method: req.method, + headers: proxyHeaders, + }, + (proxyRes) => { + const headers = { ...proxyRes.headers }; + + // Backend cookie jar: store Set-Cookie values server-side so that + // DataChannel requests (where the browser can't set cookies) still + // get the right session cookies. Store for ALL authenticated requests + // — the initial page load (direct HTTP) seeds the jar before DC + // takes over, preventing session mismatch. + const cookieJarUser = payload?.sub || ""; + if (cookieJarUser && headers["set-cookie"]) { + storeBackendCookies(`${cookieJarUser}:${backendKey}`, headers["set-cookie"] as string | string[]); + } + + // Rewrite redirects: TideCloak → relative, localhost:PORT → /__b/ + rewriteRedirects(headers, tcProxyUrl.origin, portToBackend); + + // Prepend /__b/ prefix to relative redirects so backend + // redirects stay within the correct path namespace + if (backendPrefix && headers.location && typeof headers.location === "string") { + const loc = headers.location; + if (loc.startsWith("/") && !loc.startsWith("/__b/")) { + headers.location = backendPrefix + loc; + } + } + + // Append refresh cookies if token was refreshed + const refreshCookies = (res as any).__refreshCookies as + | string[] + | undefined; + if (refreshCookies) { + const existing = headers["set-cookie"] || []; + const existingArr = Array.isArray(existing) + ? existing + : existing ? [existing as string] : []; + headers["set-cookie"] = [...existingArr, ...refreshCookies]; + } + + // Buffer HTML to rewrite URLs and inject scripts + const contentType = (headers["content-type"] || "") as string; + if (contentType.includes("text/html")) { + const chunks: Buffer[] = []; + let totalSize = 0; + const MAX_RESPONSE = 50 * 1024 * 1024; // 50 MB + proxyRes.on("data", (chunk: Buffer) => { + totalSize += chunk.length; + if (totalSize > MAX_RESPONSE) { + proxyRes.destroy(); + if (!res.headersSent) { + res.writeHead(502, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "Response too large" })); + } + return; + } + chunks.push(chunk); + }); + proxyRes.on("end", () => { + if (res.headersSent) return; + let html = Buffer.concat(chunks).toString("utf-8"); + // Rewrite localhost:PORT refs → /__b/ + html = rewriteLocalhostInHtml(html); + // Prepend /__b/ to absolute paths in HTML attributes + if (backendPrefix) { + html = prependPrefix(html, backendPrefix); + // Inject fetch/XHR interceptor so JS-initiated requests + // with absolute paths (e.g. fetch("/api/data")) get the + // /__b/ prefix prepended automatically. + // Gateway-internal paths (/auth/*, /js/*, /realms/*, etc.) are + // skipped — they work without the prefix. + // Escape backendPrefix for safe JS string interpolation (prevents XSS if name contains quotes/backslashes) + const safePrefix = backendPrefix.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/(function(){` + + `var P="${safePrefix}";` + + `var W=/^\\/(js\\/|auth\\/|login|webrtc-config|realms\\/|resources\\/|portal|health)/;` + + `function n(u){return typeof u==="string"&&u[0]==="/"&&u.indexOf("/__b/")!==0&&!W.test(u)}` + + `var F=window.fetch;window.fetch=function(u,i){` + + `if(n(u))u=P+u;` + + `else if(u instanceof Request){var r=new URL(u.url);if(r.origin===location.origin&&n(r.pathname)){r.pathname=P+r.pathname;u=new Request(r,u)}}` + + `return F.call(this,u,i)};` + + `var O=XMLHttpRequest.prototype.open;XMLHttpRequest.prototype.open=function(m,u){` + + `if(n(u))arguments[1]=P+u;` + + `return O.apply(this,arguments)};` + + // Intercept element.src setter (video, audio, img, source, script, iframe) + `["HTMLMediaElement","HTMLSourceElement","HTMLImageElement","HTMLScriptElement","HTMLIFrameElement"].forEach(function(c){` + + `var E=window[c];if(!E)return;` + + `var d=Object.getOwnPropertyDescriptor(E.prototype,"src");if(!d||!d.set)return;` + + `Object.defineProperty(E.prototype,"src",{get:d.get,set:function(v){d.set.call(this,n(v)?P+v:v)},configurable:true})` + + `});` + + // Intercept setAttribute for src/href + `var SA=Element.prototype.setAttribute;Element.prototype.setAttribute=function(a,v){` + + `if((a==="src"||a==="href")&&typeof v==="string"&&n(v))v=P+v;` + + `return SA.call(this,a,v)};` + + `})()`; + if (html.includes("")) { + html = html.replace("", `${patchScript}`); + } else { + html = patchScript + html; + } + } + // Inject WebRTC upgrade script + if (options.iceServers?.length) { + const script = ``; + if (html.includes("")) { + html = html.replace("", `${script}\n`); + } else { + html += script; + } + } + delete headers["content-length"]; + res.writeHead(proxyRes.statusCode || 502, headers); + res.end(html); + }); + } else { + res.writeHead(proxyRes.statusCode || 502, headers); + proxyRes.pipe(res); + } + } + ); + + proxyReq.setTimeout(30000, () => { + proxyReq.destroy(); + if (!res.headersSent) { + res.writeHead(504, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "Backend timeout" })); + } + }); + + proxyReq.on("error", (err) => { + console.error("[Proxy] Backend error:", err.message); + if (!res.headersSent) { + res.writeHead(502, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "Backend unavailable" })); + } + }); + + req.pipe(proxyReq); + }; + + const server = options.tls + ? createHttpsServer({ key: options.tls.key, cert: options.tls.cert }, requestHandler) + : createServer(requestHandler); + + const scheme = isTls ? "https" : "http"; + server.listen(options.listenPort, () => { + console.log(`[Proxy] Listening on ${scheme}://localhost:${options.listenPort}`); + if (options.backends && options.backends.length > 1) { + for (const b of options.backends) { + console.log(`[Proxy] Backend: ${b.name} → ${b.url}`); + } + } else { + console.log(`[Proxy] Backend: ${options.backendUrl}`); + } + console.log(`[Proxy] Login: ${scheme}://localhost:${options.listenPort}/auth/login`); + }); + + return { + server, + getStats: () => ({ ...stats }), + }; +} diff --git a/bridges/punchd-bridge/gateway/src/registration/stun-client.ts b/bridges/punchd-bridge/gateway/src/registration/stun-client.ts new file mode 100644 index 0000000..8dd8537 --- /dev/null +++ b/bridges/punchd-bridge/gateway/src/registration/stun-client.ts @@ -0,0 +1,432 @@ +/** + * WebSocket client that registers with the STUN signaling server + * and handles pairing/candidate messages. + * + * Also handles HTTP relay: the STUN server tunnels HTTP requests + * from remote clients through this WebSocket connection. + */ + +import WebSocket from "ws"; +import { request as httpRequest } from "http"; +import { request as httpsRequest } from "https"; +import { createPeerHandler, type PeerHandler } from "../webrtc/peer-handler.js"; + +export interface StunRegistrationOptions { + stunServerUrl: string; + gatewayId: string; + addresses: string[]; + /** Gateway listen port — used for local HTTP relay requests */ + listenPort: number; + /** ICE servers for WebRTC, e.g. ["stun:relay.example.com:3478"] */ + iceServers?: string[]; + /** TURN server URL, e.g. "turn:host:3478" */ + turnServer?: string; + /** Shared secret for TURN REST API ephemeral credentials */ + turnSecret?: string; + /** Whether the gateway's HTTP server uses TLS */ + useTls?: boolean; + /** Shared secret for STUN server API authentication */ + apiSecret?: string; + /** Metadata for portal display and realm-based routing */ + metadata?: { displayName?: string; description?: string; backends?: { name: string }[]; realm?: string }; + onPaired?: (client: { id: string; reflexiveAddress: string | null }) => void; + onCandidate?: (fromId: string, candidate: unknown) => void; +} + +export interface StunRegistration { + close: () => void; +} + +export function registerWithStun( + options: StunRegistrationOptions +): StunRegistration { + let ws: WebSocket | null = null; + let reconnectTimer: ReturnType | null = null; + let pingTimer: ReturnType | null = null; + let closed = false; + let peerHandler: PeerHandler | null = null; + let reconnectDelay = 1000; // Exponential backoff: 1s → 30s max + let pongReceived = true; + + function connect() { + if (closed) return; + + console.log(`[STUN-Reg] Connecting to ${options.stunServerUrl}...`); + ws = new WebSocket(options.stunServerUrl); + + ws.on("open", () => { + console.log("[STUN-Reg] Connected to STUN server"); + reconnectDelay = 1000; // Reset backoff on successful connection + + // Start client-side ping heartbeat to detect dead connections early + pongReceived = true; + if (pingTimer) clearInterval(pingTimer); + pingTimer = setInterval(() => { + if (!pongReceived) { + console.warn("[STUN-Reg] No pong received — connection dead, reconnecting"); + ws?.terminate(); + return; + } + pongReceived = false; + ws?.ping(); + }, 30_000); + + // Initialize WebRTC peer handler + if (options.iceServers?.length) { + peerHandler?.cleanup(); + peerHandler = createPeerHandler({ + iceServers: options.iceServers, + turnServer: options.turnServer, + turnSecret: options.turnSecret, + listenPort: options.listenPort, + useTls: options.useTls, + gatewayId: options.gatewayId, + sendSignaling: safeSend, + }); + console.log("[STUN-Reg] WebRTC peer handler ready"); + } + + // Register as gateway + safeSend({ + type: "register", + role: "gateway", + id: options.gatewayId, + secret: options.apiSecret || undefined, + addresses: options.addresses, + metadata: options.metadata, + }); + }); + + ws.on("pong", () => { pongReceived = true; }); + + ws.on("message", (data) => { + let msg: Record; + try { + msg = JSON.parse(data.toString()); + } catch { + return; + } + + switch (msg.type) { + case "registered": + console.log(`[STUN-Reg] Registered as gateway: ${options.gatewayId}`); + break; + + case "paired": + if (msg.client && typeof msg.client === "object") { + const client = msg.client as { + id: string; + reflexiveAddress: string | null; + }; + console.log(`[STUN-Reg] Paired with client: ${client.id}`); + options.onPaired?.(client); + } + break; + + case "candidate": + if (msg.fromId && msg.candidate) { + // Forward to WebRTC peer handler if the candidate has mid (WebRTC ICE) + const cand = msg.candidate as { candidate?: string; mid?: string }; + if (peerHandler && cand.candidate !== undefined && cand.mid !== undefined) { + peerHandler.handleCandidate(msg.fromId as string, cand.candidate, cand.mid); + } + options.onCandidate?.(msg.fromId as string, msg.candidate); + } + break; + + case "sdp_offer": + if (peerHandler && msg.fromId && msg.sdp) { + peerHandler.handleSdpOffer(msg.fromId as string, msg.sdp as string); + } + break; + + case "http_request": + handleHttpRequest(msg); + break; + + case "http_request_abort": + handleHttpRequestAbort(msg); + break; + + case "error": + console.error(`[STUN-Reg] Error: ${msg.message}`); + break; + } + }); + + ws.on("close", () => { + console.log("[STUN-Reg] Disconnected from STUN server"); + if (pingTimer) { clearInterval(pingTimer); pingTimer = null; } + scheduleReconnect(); + }); + + ws.on("error", (err) => { + console.error("[STUN-Reg] WebSocket error:", err.message); + }); + } + + /** + * Handle an HTTP request tunneled from the STUN server. + * Makes a local request to the gateway's own HTTP server, collects the + * response, and sends it back over WebSocket. + */ + const ALLOWED_METHODS = new Set(["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"]); + + // Track in-flight requests so the signal server can abort them (e.g. client disconnected) + const pendingRequests = new Map(); + + /** Content types that should be streamed chunk-by-chunk instead of buffered */ + function isStreamingResponse(res: import("http").IncomingMessage): boolean { + const ct = (res.headers["content-type"] || "").toLowerCase(); + return ct.includes("text/event-stream") + || ct.includes("application/x-ndjson") + || (ct.includes("text/plain") && res.headers["transfer-encoding"] === "chunked"); + } + + function handleHttpRequestAbort(msg: Record): void { + const req = pendingRequests.get(msg.id as string); + if (req) { + req.destroy(); + pendingRequests.delete(msg.id as string); + } + } + + function handleHttpRequest(msg: Record): void { + const requestId = msg.id as string; + const method = (msg.method as string) || "GET"; + const url = (msg.url as string) || "/"; + const headers = (msg.headers as Record) || {}; + const bodyB64 = (msg.body as string) || ""; + + // Validate URL path — must start with / and contain no CRLF (header injection) + if (!url.startsWith("/") || /[\r\n]/.test(url)) { + safeSend({ + type: "http_response", + id: requestId, + statusCode: 400, + headers: { "content-type": "application/json" }, + body: Buffer.from(JSON.stringify({ error: "Invalid URL" })).toString("base64"), + }); + return; + } + + // Validate HTTP method + if (!ALLOWED_METHODS.has(method.toUpperCase())) { + safeSend({ + type: "http_response", + id: requestId, + statusCode: 405, + headers: { "content-type": "application/json" }, + body: Buffer.from(JSON.stringify({ error: "Method not allowed" })).toString("base64"), + }); + return; + } + + console.log(`[STUN-Reg] Relay: ${method} ${url} (id: ${requestId})`); + + // Limit decoded body size to 10MB to prevent OOM + const MAX_BODY_SIZE = 10 * 1024 * 1024; + if (bodyB64 && bodyB64.length > MAX_BODY_SIZE * 1.37) { // base64 overhead ~37% + safeSend({ + type: "http_response", + id: requestId, + statusCode: 413, + headers: { "content-type": "application/json" }, + body: Buffer.from(JSON.stringify({ error: "Request body too large" })).toString("base64"), + }); + return; + } + const bodyBuf = bodyB64 ? Buffer.from(bodyB64, "base64") : undefined; + + // Sanitize relay headers — strip internal routing headers that only + // the gateway should set, to prevent the STUN server from injecting them + const sanitizedHeaders = { ...headers }; + for (const h of ["x-forwarded-user", "x-forwarded-for", "x-forwarded-proto", + "x-forwarded-host", "x-forwarded-port", "x-dc-request", + "x-gateway-backend"]) { + delete sanitizedHeaders[h]; + } + + // Make local request to the gateway's own HTTP server + const makeReq = options.useTls ? httpsRequest : httpRequest; + const req = makeReq( + { + hostname: "127.0.0.1", + port: options.listenPort, + path: url, + method, + headers: sanitizedHeaders as Record, + rejectUnauthorized: false, // self-signed cert + }, + (res) => { + // Normalize response headers + const responseHeaders: Record = {}; + for (const [key, value] of Object.entries(res.headers)) { + if (value !== undefined) { + responseHeaders[key] = value as string | string[]; + } + } + + if (isStreamingResponse(res)) { + // Streaming mode: forward chunks as they arrive (SSE, NDJSON, etc.) + console.log(`[STUN-Reg] Streaming relay: ${res.statusCode} for ${url}`); + safeSend({ + type: "http_response_start", + id: requestId, + statusCode: res.statusCode || 200, + headers: responseHeaders, + }); + + res.on("data", (chunk: Buffer) => { + safeSend({ + type: "http_response_chunk", + id: requestId, + data: chunk.toString("base64"), + }); + }); + + res.on("end", () => { + pendingRequests.delete(requestId); + safeSend({ type: "http_response_end", id: requestId }); + }); + } else { + // Buffered mode: collect full response then send + const chunks: Buffer[] = []; + let totalResponseSize = 0; + const MAX_RESPONSE = 50 * 1024 * 1024; // 50MB + let aborted = false; + res.on("data", (chunk: Buffer) => { + totalResponseSize += chunk.length; + if (totalResponseSize > MAX_RESPONSE) { + if (!aborted) { + aborted = true; + req.destroy(); + pendingRequests.delete(requestId); + safeSend({ + type: "http_response", id: requestId, statusCode: 502, + headers: { "content-type": "application/json" }, + body: Buffer.from(JSON.stringify({ error: "Response too large" })).toString("base64"), + }); + } + return; + } + chunks.push(chunk); + }); + res.on("end", () => { + if (aborted) return; + pendingRequests.delete(requestId); + const responseBody = Buffer.concat(chunks); + + // If response body > 512KB, send as chunked to stay under WS maxPayload. + // Base64 of 512KB ≈ 700KB which fits in a 1MB WS frame with headroom. + const MAX_SINGLE_WS = 512 * 1024; + if (responseBody.length > MAX_SINGLE_WS) { + console.log(`[STUN-Reg] Relay response (chunked): ${res.statusCode} for ${url} (${responseBody.length} bytes)`); + safeSend({ + type: "http_response_start", + id: requestId, + statusCode: res.statusCode || 200, + headers: responseHeaders, + }); + const CHUNK_SIZE = 256 * 1024; + for (let i = 0; i < responseBody.length; i += CHUNK_SIZE) { + safeSend({ + type: "http_response_chunk", + id: requestId, + data: responseBody.subarray(i, Math.min(i + CHUNK_SIZE, responseBody.length)).toString("base64"), + }); + } + safeSend({ type: "http_response_end", id: requestId }); + } else { + const bodyB64 = responseBody.toString("base64"); + console.log(`[STUN-Reg] Relay response: ${res.statusCode} for ${url} (${bodyB64.length} bytes b64)`); + safeSend({ + type: "http_response", + id: requestId, + statusCode: res.statusCode || 500, + headers: responseHeaders, + body: bodyB64, + }); + } + }); + } + + res.on("error", () => { + pendingRequests.delete(requestId); + }); + } + ); + + pendingRequests.set(requestId, req); + + // Timeout only applies to non-streaming (initial response). Streaming responses + // clear the timeout once headers arrive (handled by node http client). + req.setTimeout(30000, () => { + req.destroy(); + pendingRequests.delete(requestId); + safeSend({ + type: "http_response", + id: requestId, + statusCode: 504, + headers: { "content-type": "application/json" }, + body: Buffer.from(JSON.stringify({ error: "Gateway timeout" })).toString("base64"), + }); + }); + + req.on("error", (err) => { + pendingRequests.delete(requestId); + console.error(`[STUN-Reg] Relay request failed: ${err.message}`); + safeSend({ + type: "http_response", + id: requestId, + statusCode: 502, + headers: { "content-type": "application/json" }, + body: Buffer.from( + JSON.stringify({ error: "Gateway internal error" }) + ).toString("base64"), + }); + }); + + if (bodyBuf && bodyBuf.length > 0) { + req.end(bodyBuf); + } else { + req.end(); + } + } + + function scheduleReconnect() { + if (closed) return; + if (reconnectTimer) clearTimeout(reconnectTimer); + // Exponential backoff with 20% jitter: 1s → 2s → 4s → ... → 30s max + const jitter = 1 + (Math.random() - 0.5) * 0.4; // 0.8x–1.2x + const delay = Math.min(reconnectDelay * jitter, 30000); + reconnectTimer = setTimeout(() => { + console.log(`[STUN-Reg] Reconnecting (delay: ${Math.round(delay)}ms)...`); + connect(); + }, delay); + reconnectDelay = Math.min(reconnectDelay * 2, 30000); + } + + function safeSend(data: unknown) { + if (ws?.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify(data)); + } + } + + // Start initial connection + connect(); + + return { + close() { + closed = true; + if (reconnectTimer) clearTimeout(reconnectTimer); + if (pingTimer) { clearInterval(pingTimer); pingTimer = null; } + peerHandler?.cleanup(); + peerHandler = null; + if (ws) { + ws.close(); + ws = null; + } + }, + }; +} diff --git a/bridges/punchd-bridge/gateway/src/tls/self-signed.ts b/bridges/punchd-bridge/gateway/src/tls/self-signed.ts new file mode 100644 index 0000000..8ff307c --- /dev/null +++ b/bridges/punchd-bridge/gateway/src/tls/self-signed.ts @@ -0,0 +1,35 @@ +/** + * Generate a self-signed TLS certificate for the gateway HTTPS server. + * The cert is created in-memory on startup — no files written to disk. + */ + +import { generate } from "selfsigned"; + +export interface TlsCert { + key: string; + cert: string; +} + +export async function generateSelfSignedCert(hostname = "localhost"): Promise { + const now = new Date(); + const expiry = new Date(now); + expiry.setFullYear(expiry.getFullYear() + 1); + + const attrs = [{ name: "commonName", value: hostname }]; + const pems = await generate(attrs, { + keySize: 2048, + algorithm: "sha256", + notBeforeDate: now, + notAfterDate: expiry, + extensions: [ + { name: "subjectAltName", altNames: [ + { type: 2, value: hostname }, + { type: 2, value: "localhost" }, + { type: 7, ip: "127.0.0.1" }, + ]}, + ], + }); + + console.log(`[TLS] Generated self-signed certificate for ${hostname}`); + return { key: pems.private, cert: pems.cert }; +} diff --git a/bridges/punchd-bridge/gateway/src/webrtc/peer-handler.ts b/bridges/punchd-bridge/gateway/src/webrtc/peer-handler.ts new file mode 100644 index 0000000..8c59d54 --- /dev/null +++ b/bridges/punchd-bridge/gateway/src/webrtc/peer-handler.ts @@ -0,0 +1,509 @@ +/** + * WebRTC peer connection handler. + * + * Manages WebRTC DataChannel connections from browser clients. + * When a client sends an SDP offer (via the signaling server), + * the gateway creates a PeerConnection, establishes a DataChannel, + * and tunnels HTTP requests/responses over it — same format + * as the WebSocket-based HTTP relay. + */ + +import { createHmac } from "crypto"; +import { PeerConnection, DataChannel } from "node-datachannel"; +import { request as httpRequest } from "http"; +import { request as httpsRequest } from "https"; +import WebSocket from "ws"; + +export interface PeerHandlerOptions { + /** STUN server for ICE, e.g. "stun:relay.example.com:3478" */ + iceServers: string[]; + /** TURN server URL, e.g. "turn:host:3478" */ + turnServer?: string; + /** Shared secret for TURN REST API ephemeral credentials */ + turnSecret?: string; + /** Gateway's HTTP port for local requests */ + listenPort: number; + /** Whether the gateway is using HTTPS (self-signed) */ + useTls?: boolean; + /** Send signaling messages (SDP answers, ICE candidates) back via WebSocket */ + sendSignaling: (msg: unknown) => void; + /** Gateway ID — used as fromId in signaling messages */ + gatewayId: string; +} + +export interface PeerHandler { + handleSdpOffer: (clientId: string, sdp: string) => void; + handleCandidate: (clientId: string, candidate: string, mid: string) => void; + cleanup: () => void; +} + +const MAX_PEERS = 200; +const ALLOWED_METHODS = new Set(["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"]); + +export function createPeerHandler(options: PeerHandlerOptions): PeerHandler { + const peers = new Map(); + + function handleSdpOffer(clientId: string, sdp: string): void { + // Clean up existing peer if reconnecting + const existing = peers.get(clientId); + if (existing) { + existing.close(); + peers.delete(clientId); + } + + // Reject new peers if at capacity (reconnects already cleaned up above) + if (peers.size >= MAX_PEERS) { + console.warn(`[WebRTC] Peer limit reached (${MAX_PEERS}), rejecting ${clientId}`); + return; + } + + console.log(`[WebRTC] Creating peer for client: ${clientId}`); + + // Build ICE servers list: STUN + optional TURN with ephemeral credentials + // node-datachannel format: "stun:host:port" or "turn:user:pass@host:port" + const iceServers = [...options.iceServers]; + if (options.turnServer && options.turnSecret) { + const turnHost = options.turnServer.replace(/^turn:/, ""); + const expiry = Math.floor(Date.now() / 1000) + 3600; + const user = `${expiry}`; + const pass = createHmac("sha1", options.turnSecret) + .update(user) + .digest("base64"); + iceServers.push(`turn:${user}:${pass}@${turnHost}`); + } + console.log(`[WebRTC] ICE servers:`, iceServers); + + const pc = new PeerConnection(`gateway-${clientId}`, { + iceServers, + }); + + pc.onLocalDescription((desc, type) => { + console.log(`[WebRTC] Sending ${type} to client: ${clientId}`); + options.sendSignaling({ + type: "sdp_answer", + fromId: options.gatewayId, + targetId: clientId, + sdp: desc, + sdpType: type, + }); + }); + + pc.onLocalCandidate((candidate, mid) => { + console.log(`[WebRTC] Local ICE candidate: ${candidate}`); + options.sendSignaling({ + type: "candidate", + fromId: options.gatewayId, + targetId: clientId, + candidate: { candidate, mid }, + }); + }); + + pc.onStateChange((state) => { + console.log(`[WebRTC] Peer ${clientId} state: ${state}`); + if (state === "connected") { + // Report connection type to STUN server + try { + const pair = pc.getSelectedCandidatePair(); + const candidateType = pair?.local?.type || "unknown"; + const connectionType = candidateType === "relay" ? "turn" : "p2p"; + console.log(`[WebRTC] Peer ${clientId} connected via ${connectionType} (candidate: ${candidateType})`); + options.sendSignaling({ + type: "client_status", + clientId, + connectionType, + }); + } catch { + // Fallback — report as p2p if we can't determine + options.sendSignaling({ + type: "client_status", + clientId, + connectionType: "p2p", + }); + } + } + if (state === "closed" || state === "failed") { + peers.delete(clientId); + } + }); + + pc.onDataChannel((dc) => { + console.log(`[WebRTC] DataChannel opened with client: ${clientId} (label: ${dc.getLabel()})`); + const wsConnections = new Map(); + + dc.onMessage((msg) => { + try { + const parsed = JSON.parse(typeof msg === "string" ? msg : msg.toString()); + if (parsed.type === "http_request") { + handleDataChannelRequest(dc, parsed); + } else if (parsed.type === "ws_open") { + handleWsOpen(dc, parsed, wsConnections); + } else if (parsed.type === "ws_message") { + const ws = wsConnections.get(parsed.id); + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send(parsed.binary ? Buffer.from(parsed.data, "base64") : parsed.data); + } + } else if (parsed.type === "ws_close") { + const ws = wsConnections.get(parsed.id); + if (ws) ws.close(parsed.code || 1000, parsed.reason || ""); + } + } catch { + console.error("[WebRTC] Failed to parse DataChannel message"); + } + }); + + dc.onClosed(() => { + console.log(`[WebRTC] DataChannel closed with client: ${clientId}`); + for (const [, ws] of wsConnections) { + try { ws.close(); } catch {} + } + wsConnections.clear(); + }); + }); + + pc.setRemoteDescription(sdp, "offer"); + peers.set(clientId, pc); + } + + function handleCandidate(clientId: string, candidate: string, mid: string): void { + console.log(`[WebRTC] Remote ICE candidate from ${clientId}: ${candidate}`); + const pc = peers.get(clientId); + if (pc) { + pc.addRemoteCandidate(candidate, mid); + } + } + + /** Content types that should be streamed chunk-by-chunk instead of buffered */ + function isStreamingResponse(res: import("http").IncomingMessage): boolean { + const ct = (res.headers["content-type"] || "").toLowerCase(); + return ct.includes("text/event-stream") + || ct.includes("application/x-ndjson") + || (ct.includes("text/plain") && res.headers["transfer-encoding"] === "chunked"); + } + + // Responses smaller than this are sent as a single DC message; + // larger responses are streamed progressively. + const MAX_SINGLE_MSG = 200_000; + + /** + * Handle an HTTP request received over DataChannel. + * Small responses are buffered and sent as a single message. + * Large responses (video, downloads) and streaming content (SSE, NDJSON) + * are forwarded progressively as data arrives from the backend. + */ + function handleDataChannelRequest( + dc: DataChannel, + msg: { id: string; method?: string; url?: string; headers?: Record; body?: string } + ): void { + const requestId = msg.id; + const method = msg.method || "GET"; + const url = msg.url || "/"; + const headers = msg.headers || {}; + const bodyB64 = msg.body || ""; + + // Validate URL path — must start with / and contain no CRLF (header injection) + if (!url.startsWith("/") || /[\r\n]/.test(url)) { + if (dc.isOpen()) { + dc.sendMessageBinary(Buffer.from(JSON.stringify({ + type: "http_response", + id: requestId, + statusCode: 400, + headers: { "content-type": "application/json" }, + body: Buffer.from(JSON.stringify({ error: "Invalid URL" })).toString("base64"), + }))); + } + return; + } + + // Validate HTTP method + if (!ALLOWED_METHODS.has(method.toUpperCase())) { + if (dc.isOpen()) { + dc.sendMessageBinary(Buffer.from(JSON.stringify({ + type: "http_response", + id: requestId, + statusCode: 405, + headers: { "content-type": "application/json" }, + body: Buffer.from(JSON.stringify({ error: "Method not allowed" })).toString("base64"), + }))); + } + return; + } + + console.log(`[WebRTC] DC request: ${method} ${url} cookie=${!!(headers as Record).cookie} headers=${Object.keys(headers).join(',')}`); + + // Limit decoded body size to 10MB to prevent OOM + const MAX_BODY_SIZE = 10 * 1024 * 1024; + if (bodyB64 && bodyB64.length > MAX_BODY_SIZE * 1.37) { + if (dc.isOpen()) { + dc.sendMessageBinary(Buffer.from(JSON.stringify({ + type: "http_response", + id: requestId, + statusCode: 413, + headers: { "content-type": "application/json" }, + body: Buffer.from(JSON.stringify({ error: "Request body too large" })).toString("base64"), + }))); + } + return; + } + const bodyBuf = bodyB64 ? Buffer.from(bodyB64, "base64") : undefined; + + // Mark as DataChannel request so the HTTP proxy can use the backend cookie jar + (headers as Record)["x-dc-request"] = "1"; + // Don't forward accept-encoding — we send raw bytes over DC, compression + // breaks Content-Range offsets and confuses browser media pipelines. + delete (headers as Record)["accept-encoding"]; + + const makeReq = options.useTls ? httpsRequest : httpRequest; + const req = makeReq( + { + hostname: "127.0.0.1", + port: options.listenPort, + path: url, + method, + headers: headers as Record, + rejectUnauthorized: false, + }, + (res) => { + // Collect response headers, stripping hop-by-hop headers that are + // meaningless for SW-constructed Responses and can confuse Chrome + // (e.g. transfer-encoding: chunked would make Chrome try to + // chunk-decode an already-decoded body). + const HOP_BY_HOP = new Set(["transfer-encoding", "connection", "keep-alive", "te", "trailer", "upgrade"]); + const responseHeaders: Record = {}; + for (const [key, value] of Object.entries(res.headers)) { + if (value !== undefined && !HOP_BY_HOP.has(key)) { + responseHeaders[key] = value as string | string[]; + } + } + + // Determine response mode: + // - Small responses (< MAX_SINGLE_MSG): buffer and send as single message + // - Large or streaming: progressive streaming over DataChannel + const contentLength = parseInt(res.headers["content-length"] || "0", 10); + const isLive = isStreamingResponse(res); + const useStreaming = isLive || contentLength > MAX_SINGLE_MSG / 2; + + if (useStreaming) { + // Stream response progressively — works for SSE, video, large files + console.log(`[WebRTC] Streaming DC response: ${res.statusCode} for ${url} (${contentLength || "unknown"} bytes, live=${isLive})`); + if (!dc.isOpen()) return; + + // Queue-based sending with flow control. + // All messages sent as binary to avoid SCTP PPID confusion. + const DC_MAX_BUFFER = 65_536; + const queue: { binary: Buffer }[] = []; + let scheduled = false; + + const flush = (): void => { + scheduled = false; + while (queue.length > 0) { + if (!dc.isOpen()) { req.destroy(); return; } + if (dc.bufferedAmount() > DC_MAX_BUFFER) { + res.pause(); + scheduled = true; + setTimeout(flush, 5); + return; + } + try { + const sent = dc.sendMessageBinary(queue[0].binary); + if (!sent) { + res.pause(); + scheduled = true; + setTimeout(flush, 10); + return; + } + } catch { + req.destroy(); + return; + } + queue.shift(); + } + res.resume(); + } + + // All streaming messages are sent as binary to avoid SCTP PPID + // confusion when interleaving text (PPID 51) and binary (PPID 53). + // Control messages are JSON in a Buffer; chunk data uses requestId prefix. + // The browser detects JSON by checking if the first byte is '{'. + let chunksSent = 0; + + // Start message — JSON as binary + // live=true for SSE/NDJSON (client uses ReadableStream) + // live=false for large responses like video (client buffers then delivers complete) + queue.push({ binary: Buffer.from(JSON.stringify({ + type: "http_response_start", + id: requestId, + statusCode: res.statusCode || 200, + headers: responseHeaders, + streaming: true, + live: isLive, + })) }); + flush(); + + res.on("data", (chunk: Buffer) => { + if (!dc.isOpen()) { req.destroy(); return; } + // Binary chunk: 36-byte requestId prefix + raw bytes (no base64) + const idBuf = Buffer.from(requestId, "ascii"); + queue.push({ binary: Buffer.concat([idBuf, chunk]) }); + chunksSent++; + if (!scheduled) flush(); + }); + + res.on("end", () => { + console.log(`[WebRTC] Streaming complete for ${url}: ${chunksSent} chunks sent`); + // End message — JSON as binary + queue.push({ binary: Buffer.from(JSON.stringify({ type: "http_response_end", id: requestId })) }); + if (!scheduled) flush(); + }); + + res.on("error", (err) => { + console.error(`[WebRTC] Streaming response error for ${url}: ${err.message}`); + queue.push({ binary: Buffer.from(JSON.stringify({ type: "http_response_end", id: requestId })) }); + if (!scheduled) flush(); + }); + } else { + // Small response: buffer and send as single message (binary to avoid PPID confusion) + const chunks: Buffer[] = []; + let totalResponseSize = 0; + const MAX_BUFFERED = 50 * 1024 * 1024; // 50MB + let aborted = false; + res.on("data", (chunk: Buffer) => { + totalResponseSize += chunk.length; + if (totalResponseSize > MAX_BUFFERED) { + if (!aborted) { + aborted = true; + req.destroy(); + if (dc.isOpen()) { + dc.sendMessageBinary(Buffer.from(JSON.stringify({ + type: "http_response", id: requestId, statusCode: 502, + headers: { "content-type": "application/json" }, + body: Buffer.from('{"error":"Response too large"}').toString("base64"), + }))); + } + } + return; + } + chunks.push(chunk); + }); + res.on("end", () => { + if (aborted || !dc.isOpen()) return; + const responseBody = Buffer.concat(chunks).toString("base64"); + dc.sendMessageBinary(Buffer.from(JSON.stringify({ + type: "http_response", + id: requestId, + statusCode: res.statusCode || 500, + headers: responseHeaders, + body: responseBody, + }))); + }); + } + } + ); + + req.setTimeout(30000, () => { + req.destroy(); + if (dc.isOpen()) { + dc.sendMessageBinary(Buffer.from(JSON.stringify({ + type: "http_response", + id: requestId, + statusCode: 504, + headers: { "content-type": "application/json" }, + body: Buffer.from(JSON.stringify({ error: "Gateway timeout" })).toString("base64"), + }))); + } + }); + + req.on("error", (err) => { + console.error(`[WebRTC] Local request failed: ${err.message}`); + if (dc.isOpen()) { + dc.sendMessageBinary(Buffer.from(JSON.stringify({ + type: "http_response", + id: requestId, + statusCode: 502, + headers: { "content-type": "application/json" }, + body: Buffer.from(JSON.stringify({ error: "Gateway internal error" })).toString("base64"), + }))); + } + }); + + if (bodyBuf && bodyBuf.length > 0) { + req.end(bodyBuf); + } else { + req.end(); + } + } + + const MAX_WS_PER_DC = 50; + + function handleWsOpen( + dc: DataChannel, + msg: { id: string; url?: string; protocols?: string[]; headers?: Record }, + wsConnections: Map + ): void { + if (wsConnections.size >= MAX_WS_PER_DC) { + if (dc.isOpen()) { + dc.sendMessageBinary(Buffer.from(JSON.stringify({ type: "ws_error", id: msg.id, message: "Too many WebSocket connections" }))); + } + return; + } + + const wsPath = msg.url || "/"; + if (!wsPath.startsWith("/") || /[\r\n]/.test(wsPath)) { + if (dc.isOpen()) { + dc.sendMessageBinary(Buffer.from(JSON.stringify({ type: "ws_error", id: msg.id, message: "Invalid URL" }))); + } + return; + } + + const protocol = options.useTls ? "wss" : "ws"; + const wsUrl = `${protocol}://127.0.0.1:${options.listenPort}${wsPath}`; + const headers: Record = { ...(msg.headers || {}) }; + headers["x-dc-request"] = "1"; + + const ws = new WebSocket(wsUrl, msg.protocols || [], { + rejectUnauthorized: false, + headers, + }); + + ws.on("open", () => { + if (dc.isOpen()) { + dc.sendMessageBinary(Buffer.from(JSON.stringify({ type: "ws_opened", id: msg.id, protocol: ws.protocol || "" }))); + } + }); + + ws.on("message", (data: Buffer, isBinary: boolean) => { + if (!dc.isOpen()) { ws.close(); return; } + dc.sendMessageBinary(Buffer.from(JSON.stringify({ + type: "ws_message", + id: msg.id, + data: isBinary ? data.toString("base64") : data.toString("utf-8"), + binary: isBinary, + }))); + }); + + ws.on("close", (code: number, reason: Buffer) => { + wsConnections.delete(msg.id); + if (dc.isOpen()) { + dc.sendMessageBinary(Buffer.from(JSON.stringify({ type: "ws_close", id: msg.id, code, reason: reason.toString() }))); + } + }); + + ws.on("error", (err: Error) => { + wsConnections.delete(msg.id); + if (dc.isOpen()) { + dc.sendMessageBinary(Buffer.from(JSON.stringify({ type: "ws_error", id: msg.id, message: err.message }))); + } + }); + + wsConnections.set(msg.id, ws); + console.log(`[WebRTC] WS tunnel opened: ${msg.url} (id: ${msg.id})`); + } + + function cleanup(): void { + for (const [id, pc] of peers) { + pc.close(); + } + peers.clear(); + } + + return { handleSdpOffer, handleCandidate, cleanup }; +} diff --git a/bridges/punchd-bridge/gateway/start.sh b/bridges/punchd-bridge/gateway/start.sh new file mode 100755 index 0000000..a1c2f87 --- /dev/null +++ b/bridges/punchd-bridge/gateway/start.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# Start the gateway with sensible defaults. +# Override any variable by setting it before running, e.g.: +# STUN_SERVER_URL=wss://example.com:9090 ./start.sh +# TC_PORT=8180 ./start.sh + +set -euo pipefail + +TC_PORT="${TC_PORT:-8080}" + +export STUN_SERVER_URL="${STUN_SERVER_URL:-wss://tidestun.codesyo.com:9090}" +export ICE_SERVERS="${ICE_SERVERS:-stun:20.211.145.216:3478}" +export BACKEND_URL="${BACKEND_URL:-http://localhost:3000}" +export BACKENDS="${BACKENDS:-}" +export LISTEN_PORT="${LISTEN_PORT:-7891}" +export HEALTH_PORT="${HEALTH_PORT:-7892}" +export API_SECRET="${API_SECRET:-}" +export TURN_SECRET="${TURN_SECRET:-}" +export TURN_SERVER="${TURN_SERVER:-turn:20.211.145.216:3478}" + +cd "$(dirname "$0")" + +echo "[Gateway] Building..." +npm run build + +echo "[Gateway] Starting with:" +echo " STUN_SERVER_URL=$STUN_SERVER_URL" +echo " ICE_SERVERS=$ICE_SERVERS" +echo " BACKEND_URL=$BACKEND_URL" +echo " BACKENDS=${BACKENDS:-}" +echo " LISTEN_PORT=$LISTEN_PORT" +echo " HEALTH_PORT=$HEALTH_PORT" +echo " API_SECRET=${API_SECRET:+set}" +echo " TURN_SECRET=${TURN_SECRET:+set}" +echo " TURN_SERVER=$TURN_SERVER" + +npm start diff --git a/tcp-bridge/tsconfig.json b/bridges/punchd-bridge/gateway/tsconfig.json similarity index 100% rename from tcp-bridge/tsconfig.json rename to bridges/punchd-bridge/gateway/tsconfig.json diff --git a/bridges/punchd-bridge/package.json b/bridges/punchd-bridge/package.json new file mode 100644 index 0000000..fecc294 --- /dev/null +++ b/bridges/punchd-bridge/package.json @@ -0,0 +1,10 @@ +{ + "name": "puncd", + "version": "0.1.0", + "private": true, + "description": "NAT-traversing authenticated reverse proxy — STUN/TURN signaling server + gateway", + "scripts": { + "build": "cd gateway && npm run build", + "start": "cd gateway && npm start" + } +} diff --git a/bridges/punchd-bridge/reinit.sh b/bridges/punchd-bridge/reinit.sh new file mode 100755 index 0000000..9ba99bf --- /dev/null +++ b/bridges/punchd-bridge/reinit.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# Wipe TideCloak and re-initialize from scratch. +# Uses localhost hostname for init (so invite link works), +# then restarts with the public hostname before starting the gateway. +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")" && pwd)" + +# Stop gateway if running +pkill -f "node.*dist/index.js" 2>/dev/null || true + +# Remove TideCloak container +docker rm -f mytidecloak 2>/dev/null || true + +# Remove old adapter configs +rm -f "${REPO_ROOT}/data/tidecloak.json" "${REPO_ROOT}/gateway/data/tidecloak.json" + +# Run start.sh (handles two-phase TC startup + gateway) +exec bash "${REPO_ROOT}/start.sh" diff --git a/bridges/punchd-bridge/script/gateway/start.sh b/bridges/punchd-bridge/script/gateway/start.sh new file mode 100755 index 0000000..fa46d60 --- /dev/null +++ b/bridges/punchd-bridge/script/gateway/start.sh @@ -0,0 +1,96 @@ +#!/usr/bin/env bash +# Start the gateway for local development. +# Prompts for secrets on first run and saves them for next time. +# +# The STUN server operator generates API_SECRET and TURN_SECRET. +# Get them from whoever runs the STUN server you're connecting to. +# +# Usage: +# ./start.sh # prompts for secrets on first run +# API_SECRET=xxx TURN_SECRET=yyy ./start.sh # pass secrets directly +# STUN_SERVER_URL=wss://stun:9090 ./start.sh # custom STUN server + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +ENV_FILE="${SCRIPT_DIR}/.env" +TC_PORT="${TC_PORT:-8080}" + +# ── Load or prompt for secrets ───────────────────────────────── +load_secrets() { + # 1. Already set via environment + if [ -n "${API_SECRET:-}" ] && [ -n "${TURN_SECRET:-}" ]; then + echo "[Gateway] Using secrets from environment" + return + fi + + # 2. Saved from previous run + if [ -f "$ENV_FILE" ]; then + echo "[Gateway] Loading secrets from ${ENV_FILE}" + source "$ENV_FILE" + if [ -n "${API_SECRET:-}" ] && [ -n "${TURN_SECRET:-}" ]; then + return + fi + fi + + # 3. Prompt — get these from whoever runs the STUN server + echo "" + echo "[Gateway] Secrets not found." + echo " Get API_SECRET and TURN_SECRET from the STUN server operator." + echo "" + read -rp " API_SECRET: " API_SECRET + read -rp " TURN_SECRET: " TURN_SECRET + echo "" + save_secrets +} + +save_secrets() { + cat > "$ENV_FILE" <}" +echo " LISTEN_PORT=$LISTEN_PORT" +echo " API_SECRET=${API_SECRET:+set}" +echo " TURN_SECRET=${TURN_SECRET:+set}" + +# ── Install, build and start ────────────────────────────────────── +cd "${REPO_ROOT}/gateway" +npm install +npm run build +exec npm start diff --git a/bridges/punchd-bridge/script/tidecloak/init-tidecloak.sh b/bridges/punchd-bridge/script/tidecloak/init-tidecloak.sh new file mode 100755 index 0000000..7ccedb9 --- /dev/null +++ b/bridges/punchd-bridge/script/tidecloak/init-tidecloak.sh @@ -0,0 +1,367 @@ +#!/usr/bin/env sh + +# Tidecloak Realm Initialization Script (Codespaces Compatible) +# This script sets up a new Tidecloak realm with Tide configuration + + + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' + +log_info() { echo -e "${GREEN}[INFO]${NC} $1"; } +log_error() { echo -e "${RED}[ERROR]${NC} $1"; } +log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; } + +echo "==================================" +echo " Tidecloak Realm Initialization " +echo "==================================" +echo "" + +# Check dependencies +log_info "Checking dependencies..." +for cmd in curl jq; do + if ! command -v $cmd &> /dev/null; then + log_error "$cmd is not installed" + exit 1 + fi +done +log_info "✓ All dependencies installed" +echo "" + +# Configuration +TIDECLOAK_LOCAL_URL="${TIDECLOAK_LOCAL_URL:-http://tidecloakP:8080}" +CLIENT_APP_URL="${CLIENT_APP_URL:-http://localhost:3000}" +REALM_JSON_PATH="${REALM_JSON_PATH:-realm.json}" +ADAPTER_OUTPUT_PATH="${ADAPTER_OUTPUT_PATH:-../../data/tidecloak.json}" +NEW_REALM_NAME="${NEW_REALM_NAME:-keylessh}" +REALM_MGMT_CLIENT_ID="realm-management" +ADMIN_ROLE_NAME="tide-realm-admin" +KC_USER="${KC_USER:-admin}" +KC_PASSWORD="${KC_PASSWORD:-password}" +CLIENT_NAME="${CLIENT_NAME:-myclient}" +SCRIPT_DIR="${SCRIPT_DIR:-$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)}" + +CURL_OPTS="-f" +if [[ "$TIDECLOAK_LOCAL_URL" == https://* ]]; then + CURL_OPTS="-k" +fi + +log_info "Configuration:" +log_info " Tidecloak URL: $TIDECLOAK_LOCAL_URL" +log_info " Client App URL: $CLIENT_APP_URL" +echo "" + +# Check if realm.json exists +if [ ! -f "$REALM_JSON_PATH" ]; then + log_error "Realm template not found at: $REALM_JSON_PATH" + exit 1 +fi + +# Wait for Tidecloak +log_info "Checking Tidecloak connectivity..." +for i in {1..15}; do + if curl -s -f $CURL_OPTS --connect-timeout 5 "$TIDECLOAK_LOCAL_URL" > /dev/null 2>&1; then + log_info "✓ Tidecloak is accessible" + break + fi + if [ $i -eq 15 ]; then + log_error "Cannot connect to Tidecloak at $TIDECLOAK_LOCAL_URL" + exit 1 + fi + log_warn "Waiting for Tidecloak (attempt $i/15)..." + sleep 5 +done +echo "" + +# Function to get admin token +get_admin_token() { + curl -s $CURL_OPTS -X POST "${TIDECLOAK_LOCAL_URL}/realms/master/protocol/openid-connect/token" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "username=${KC_USER}" \ + -d "password=${KC_PASSWORD}" \ + -d "grant_type=password" \ + -d "client_id=admin-cli" | jq -r '.access_token' +} + +REALM_NAME="${NEW_REALM_NAME}" +echo "📄 Generated realm name: $REALM_NAME" + +TMP_REALM_JSON="$(mktemp)" +cp "$REALM_JSON_PATH" "$TMP_REALM_JSON" +# sed -i "s|http://localhost:3000|$CLIENT_APP_URL|g" "$TMP_REALM_JSON" +sed -i "s|KEYLESSH|$REALM_NAME|g" "$TMP_REALM_JSON" + +# Create realm +echo "🌍 Creating realm..." +TOKEN="$(get_admin_token)" +status=$(curl -s $CURL_OPTS -o /dev/null -w "%{http_code}" \ + -X POST "${TIDECLOAK_LOCAL_URL}/admin/realms" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + --data-binary @"$TMP_REALM_JSON") + +if [[ $status == 2* ]]; then + echo "✅ Realm created." +else + echo "❌ Realm creation failed (HTTP $status)" + exit 1 +fi + +# Initialize Tide realm + IGA +echo "🔐 Initializing Tide realm + IGA..." + +# Prompt for email +echo "" +while true; do + echo -ne "${YELLOW}Enter an email to manage your license: ${NC}" + read LICENSE_EMAIL + if [[ -n "$LICENSE_EMAIL" && "$LICENSE_EMAIL" =~ ^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$ ]]; then + break + else + log_error "Please enter a valid email address" + fi +done + +# Prompt for terms acceptance +echo "" +echo "Please review the Terms & Conditions at: https://tide.org/legal" +while true; do + echo -ne "${YELLOW}I agree to the Terms & Conditions (enter 'y' or 'yes' to continue): ${NC}" + read TERMS_ACCEPTANCE + if [[ "$TERMS_ACCEPTANCE" == "y" || "$TERMS_ACCEPTANCE" == "yes" ]]; then + break + else + log_error "You must explicitly agree to the Terms & Conditions by entering 'y' or 'yes'" + fi +done +echo "" + +TOKEN="$(get_admin_token)" +curl -s $CURL_OPTS -X POST "${TIDECLOAK_LOCAL_URL}/admin/realms/${REALM_NAME}/vendorResources/setUpTideRealm" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + --data-urlencode "email=${LICENSE_EMAIL}" \ + --data-urlencode "isRagnarokEnabled=true" > /dev/null 2>&1 + +curl -s $CURL_OPTS -X POST "${TIDECLOAK_LOCAL_URL}/admin/realms/${REALM_NAME}/tide-admin/toggle-iga" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + --data-urlencode "isIGAEnabled=true" > /dev/null 2>&1 +echo "✅ Tide realm + IGA done." + +approve_and_commit() { + local TYPE=$1 + echo "🔄 Processing ${TYPE} change-sets..." + TOKEN="$(get_admin_token)" + + # Get change-sets (don't use -f here as empty results are OK) + local requests + requests=$(curl -s -X GET "${TIDECLOAK_LOCAL_URL}/admin/realms/${REALM_NAME}/tide-admin/change-set/${TYPE}/requests" \ + -H "Authorization: Bearer $TOKEN" 2>/dev/null || echo "[]") + + # Check if there are any requests to process + local count + count=$(echo "$requests" | jq 'length' 2>/dev/null || echo "0") + + if [ "$count" = "0" ] || [ "$count" = "" ]; then + echo " No ${TYPE} change-sets to process" + else + echo "$requests" | jq -c '.[]' | while read -r req; do + payload=$(jq -n --arg id "$(echo "$req" | jq -r .draftRecordId)" \ + --arg cst "$(echo "$req" | jq -r .changeSetType)" \ + --arg at "$(echo "$req" | jq -r .actionType)" \ + '{changeSetId:$id,changeSetType:$cst,actionType:$at}') + + # Sign the change-set + local sign_response + sign_response=$(curl -s -w "\n%{http_code}" -X POST "${TIDECLOAK_LOCAL_URL}/admin/realms/${REALM_NAME}/tide-admin/change-set/sign" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d "$payload" 2>&1) + + local sign_status=$(echo "$sign_response" | tail -1) + local sign_body=$(echo "$sign_response" | sed '$d') + + if [[ ! "$sign_status" =~ ^2 ]]; then + log_error "Failed to sign ${TYPE} change-set (HTTP $sign_status): $(echo "$req" | jq -r .draftRecordId)" + log_error "Response: $sign_body" + return 1 + fi + + # Commit the change-set + local commit_response + commit_response=$(curl -s -w "\n%{http_code}" -X POST "${TIDECLOAK_LOCAL_URL}/admin/realms/${REALM_NAME}/tide-admin/change-set/commit" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d "$payload" 2>&1) + + local commit_status=$(echo "$commit_response" | tail -1) + local commit_body=$(echo "$commit_response" | sed '$d') + + if [[ ! "$commit_status" =~ ^2 ]]; then + log_error "Failed to commit ${TYPE} change-set (HTTP $commit_status): $(echo "$req" | jq -r .draftRecordId)" + log_error "Response: $commit_body" + return 1 + fi + done + fi + echo "✅ ${TYPE} change-sets done." +} + +approve_and_commit clients + +# Create admin user +TOKEN="$(get_admin_token)" +echo "👤 Creating admin user..." +curl -s $CURL_OPTS -X POST "${TIDECLOAK_LOCAL_URL}/admin/realms/${REALM_NAME}/users" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"username":"admin","email":"admin@yourorg.com","firstName":"admin","lastName":"user","enabled":true,"emailVerified":false,"requiredActions":[],"attributes":{"locale":""},"groups":[]}' > /dev/null 2>&1 + +USER_ID=$(curl -s $CURL_OPTS -X GET "${TIDECLOAK_LOCAL_URL}/admin/realms/${REALM_NAME}/users?username=admin" \ + -H "Authorization: Bearer $TOKEN" | jq -r '.[0].id') + +CLIENT_UUID=$(curl -s $CURL_OPTS -X GET "${TIDECLOAK_LOCAL_URL}/admin/realms/${REALM_NAME}/clients?clientId=${REALM_MGMT_CLIENT_ID}" \ + -H "Authorization: Bearer $TOKEN" | jq -r '.[0].id') + +ROLE_JSON=$(curl -s $CURL_OPTS -X GET "${TIDECLOAK_LOCAL_URL}/admin/realms/${REALM_NAME}/clients/$CLIENT_UUID/roles/${ADMIN_ROLE_NAME}" \ + -H "Authorization: Bearer $TOKEN") + +curl -s $CURL_OPTS -X POST "${TIDECLOAK_LOCAL_URL}/admin/realms/${REALM_NAME}/users/$USER_ID/role-mappings/clients/$CLIENT_UUID" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d "[$ROLE_JSON]" > /dev/null 2>&1 +echo "✅ Admin user & role done." + +# Fetch adapter config +TOKEN="$(get_admin_token)" +echo "📥 Fetching adapter config…" +CLIENT_UUID=$(curl -s $CURL_OPTS -X GET "${TIDECLOAK_LOCAL_URL}/admin/realms/${REALM_NAME}/clients?clientId=${CLIENT_NAME}" \ + -H "Authorization: Bearer $TOKEN" | jq -r '.[0].id') + +curl -s $CURL_OPTS -X GET "${TIDECLOAK_LOCAL_URL}/admin/realms/${REALM_NAME}/vendorResources/get-installations-provider?clientId=${CLIENT_UUID}&providerId=keycloak-oidc-keycloak-json" \ + -H "Authorization: Bearer $TOKEN" > "$ADAPTER_OUTPUT_PATH" +echo "✅ Adapter config saved to $ADAPTER_OUTPUT_PATH" + +rm -f "$TMP_REALM_JSON" + +# Upload branding images (logo and background) +upload_branding() { + echo "🎨 Uploading branding images..." + TOKEN="$(get_admin_token)" + + if [ -z "$TOKEN" ] || [ "$TOKEN" = "null" ]; then + log_warn "Failed to get token for branding upload" + return 1 + fi + + # Look for images in public folder (relative to script dir's parent) + local PUBLIC_DIR="${SCRIPT_DIR}/tidecloak/public" + + # Upload logo if exists (use -s only, not $CURL_OPTS which has -f that exits on error) + # Endpoint: tide-idp-admin-resources/images/upload + if [ -f "${PUBLIC_DIR}/keylessh-logo_icon.svg" ]; then + local logo_status + logo_status=$(curl -s -k -o /dev/null -w "%{http_code}" \ + -X POST "${TIDECLOAK_LOCAL_URL}/admin/realms/${REALM_NAME}/tide-idp-admin-resources/images/upload" \ + -H "Authorization: Bearer ${TOKEN}" \ + -F "fileData=@${PUBLIC_DIR}/keylessh-logo_icon.svg" \ + -F "fileName=keylessh-logo_icon.svg" \ + -F "fileType=LOGO" 2>/dev/null || echo "000") + if [[ "$logo_status" =~ ^2 ]]; then + echo " ✅ Logo uploaded" + else + log_warn "Logo upload failed (HTTP $logo_status) - continuing anyway" + fi + else + log_warn "Logo not found at ${PUBLIC_DIR}/keylessh-logo_icon.svg" + fi + + # Upload background if exists + if [ -f "${PUBLIC_DIR}/keylessh_bg.gif" ]; then + local bg_status + bg_status=$(curl -s -k -o /dev/null -w "%{http_code}" \ + -X POST "${TIDECLOAK_LOCAL_URL}/admin/realms/${REALM_NAME}/tide-idp-admin-resources/images/upload" \ + -H "Authorization: Bearer ${TOKEN}" \ + -F "fileData=@${PUBLIC_DIR}/keylessh_bg.gif" \ + -F "fileName=keylessh_bg.gif" \ + -F "fileType=BACKGROUND_IMAGE" 2>/dev/null || echo "000") + if [[ "$bg_status" =~ ^2 ]]; then + echo " ✅ Background uploaded" + else + log_warn "Background upload failed (HTTP $bg_status) - continuing anyway" + fi + else + log_warn "Background not found at ${PUBLIC_DIR}/keylessh_bg.gif" + fi + + echo "✅ Branding upload complete." +} + +upload_branding + +# Generate invite link +TOKEN="$(get_admin_token)" +echo "🔗 Generating invite link..." + +RAW_INVITE_LINK=$(curl -s -k -X POST "${TIDECLOAK_LOCAL_URL}/admin/realms/${REALM_NAME}/tideAdminResources/get-required-action-link?userId=${USER_ID}&lifespan=43200" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '["link-tide-account-action"]') + +if [ -z "$RAW_INVITE_LINK" ]; then + log_error "Failed to generate invite link (USER_ID=${USER_ID})" + log_error "Retrying..." + sleep 3 + TOKEN="$(get_admin_token)" + RAW_INVITE_LINK=$(curl -s -k -X POST "${TIDECLOAK_LOCAL_URL}/admin/realms/${REALM_NAME}/tideAdminResources/get-required-action-link?userId=${USER_ID}&lifespan=43200" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '["link-tide-account-action"]') +fi + +echo "" +echo "================================================" +echo "🔗 INVITE LINK (open on this machine):" +echo "$RAW_INVITE_LINK" +echo "================================================" +echo "" +echo "→ Open this link in a browser on this machine to link the admin account." +echo "" +echo -n "Checking link status… " + +while true; do + TOKEN="$(get_admin_token)" + ATTRS=$(curl -s $CURL_OPTS -X GET "${TIDECLOAK_LOCAL_URL}/admin/realms/${REALM_NAME}/users?username=admin" \ + -H "Authorization: Bearer $TOKEN") + KEY=$(echo "$ATTRS" | jq -r '.[0].attributes.tideUserKey[0] // empty') + VUID=$(echo "$ATTRS" | jq -r '.[0].attributes.vuid[0] // empty') + if [[ -n "$KEY" && -n "$VUID" ]]; then + echo "✅ Linked!" + break + fi + sleep 5 +done + +approve_and_commit users + +# Update CustomAdminUIDomain +TOKEN="$(get_admin_token)" +echo "🌐 Updating CustomAdminUIDomain..." +INST=$(curl -s $CURL_OPTS -X GET "${TIDECLOAK_LOCAL_URL}/admin/realms/${REALM_NAME}/identity-provider/instances/tide" \ + -H "Authorization: Bearer $TOKEN") +UPDATED=$(echo "$INST" | jq --arg d "$CLIENT_APP_URL" '.config.CustomAdminUIDomain=$d') + +curl -s $CURL_OPTS -X PUT "${TIDECLOAK_LOCAL_URL}/admin/realms/${REALM_NAME}/identity-provider/instances/tide" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d "$UPDATED" > /dev/null 2>&1 + +curl -s $CURL_OPTS -X POST "${TIDECLOAK_LOCAL_URL}/admin/realms/${REALM_NAME}/vendorResources/sign-idp-settings" \ + -H "Authorization: Bearer $TOKEN" > /dev/null 2>&1 +echo "✅ CustomAdminUIDomain updated + signed." + +echo "" +echo "🎉 Tidecloak initialization complete!" diff --git a/bridges/punchd-bridge/script/tidecloak/public/keylessh-logo_icon.svg b/bridges/punchd-bridge/script/tidecloak/public/keylessh-logo_icon.svg new file mode 100644 index 0000000..867e92d --- /dev/null +++ b/bridges/punchd-bridge/script/tidecloak/public/keylessh-logo_icon.svg @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/bridges/punchd-bridge/script/tidecloak/public/keylessh_bg.gif b/bridges/punchd-bridge/script/tidecloak/public/keylessh_bg.gif new file mode 100644 index 0000000..26080b5 Binary files /dev/null and b/bridges/punchd-bridge/script/tidecloak/public/keylessh_bg.gif differ diff --git a/bridges/punchd-bridge/script/tidecloak/public/silent-check-sso.html b/bridges/punchd-bridge/script/tidecloak/public/silent-check-sso.html new file mode 100644 index 0000000..60a7af9 --- /dev/null +++ b/bridges/punchd-bridge/script/tidecloak/public/silent-check-sso.html @@ -0,0 +1 @@ + diff --git a/bridges/punchd-bridge/script/tidecloak/realm.json b/bridges/punchd-bridge/script/tidecloak/realm.json new file mode 100644 index 0000000..17c9cef --- /dev/null +++ b/bridges/punchd-bridge/script/tidecloak/realm.json @@ -0,0 +1,164 @@ +{ + "realm": "keylessh", + "accessTokenLifespan": 600, + "enabled": true, + "sslRequired": "external", + "registrationAllowed": false, + "duplicateEmailsAllowed": true, + "roles": { + "realm": [ + { + "name": "appUser", + "description": "Standard application user" + }, + { + "name": "_tide_enabled", + "description": "Represents a tide user thats allowed perform actions on tide" + }, + { + "name": "default-roles-keylessh", + "description": "${role_default-roles}", + "composite": true, + "composites": { + "realm": [ + "_tide_enabled", + "appUser" + ] + } + } + ], + "client": { + "myclient": [] + } + }, + "defaultRole": { + "name": "default-roles-keylessh", + "description": "${role_default-roles}", + "composite": true, + "clientRole": false + }, + "clients": [ + { + "clientId": "myclient", + "enabled": true, + "redirectUris": [ + "*", + "http://localhost:3000", + "http://localhost:3000/*", + "http://localhost:3000/silent-check-sso.html", + "http://localhost:3000/auth/redirect" + ], + "webOrigins": [ + "http://localhost:3000" + ], + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "publicClient": true, + "fullScopeAllowed": true, + "protocolMappers": [ + { + "name": "Tide User Key", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "tideUserKey", + "lightweight.claim": "true", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "tideuserkey", + "jsonType.label": "String" + } + }, + { + "name": "Tide IGA Role Mapper", + "protocol": "openid-connect", + "protocolMapper": "tide-roles-mapper", + "consentRequired": false, + "config": { + "lightweight.claim": "true", + "access.token.claim": "true" + } + }, + { + "name": "Tide vuid", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "introspection.token.claim": "true", + "userinfo.token.claim": "true", + "user.attribute": "vuid", + "lightweight.claim": "true", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "vuid", + "jsonType.label": "String" + } + } + ] + } + ], + "components": { + "org.keycloak.userprofile.UserProfileProvider": [ + { + "providerId": "declarative-user-profile", + "config": { + "kc.user.profile.config": [ + "{\"attributes\":[{\"name\":\"username\",\"displayName\":\"${username}\",\"validations\":{\"length\":{\"min\":3,\"max\":255},\"username-prohibited-characters\":{},\"up-username-not-idn-homograph\":{}},\"permissions\":{\"view\":[\"admin\",\"user\"],\"edit\":[\"admin\",\"user\"]},\"multivalued\":false},{\"name\":\"email\",\"displayName\":\"${email}\",\"validations\":{\"email\":{},\"length\":{\"max\":255}},\"permissions\":{\"view\":[\"admin\",\"user\"],\"edit\":[\"admin\",\"user\"]},\"multivalued\":false},{\"name\":\"firstName\",\"displayName\":\"${firstName}\",\"permissions\":{\"view\":[\"admin\",\"user\"],\"edit\":[\"admin\",\"user\"]},\"multivalued\":false},{\"name\":\"lastName\",\"displayName\":\"${lastName}\",\"permissions\":{\"view\":[\"admin\",\"user\"],\"edit\":[\"admin\",\"user\"]},\"multivalued\":false}],\"groups\":[{\"name\":\"user-metadata\",\"displayHeader\":\"User metadata\",\"displayDescription\":\"Attributes, which refer to user metadata\"}],\"unmanagedAttributePolicy\":\"ENABLED\"}" + ] + } + } + ] + }, + "authenticationFlows": [ + { + "alias": "tidebrowser", + "providerId": "basic-flow", + "topLevel": true, + "authenticationExecutions": [ + { + "authenticator": "auth-cookie", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 10, + "userSetupAllowed": false + }, + { + "authenticatorConfig": "tide browser", + "authenticator": "identity-provider-redirector", + "authenticatorFlow": false, + "requirement": "ALTERNATIVE", + "priority": 25, + "userSetupAllowed": false + } + ] + } + ], + "authenticatorConfig": [ + { + "alias": "tide browser", + "config": { + "defaultProvider": "tide" + } + } + ], + "browserFlow": "tidebrowser", + "requiredActions": [ + { + "alias": "link-tide-account-action", + "name": "Link Tide Account", + "providerId": "link-tide-account-action", + "enabled": true + }, + { + "alias": "UPDATE_PASSWORD", + "name": "Update Password", + "providerId": "UPDATE_PASSWORD", + "enabled": false + } + ], + "keycloakVersion": "26.1.4" +} \ No newline at end of file diff --git a/bridges/punchd-bridge/script/tidecloak/start.sh b/bridges/punchd-bridge/script/tidecloak/start.sh new file mode 100755 index 0000000..143a2aa --- /dev/null +++ b/bridges/punchd-bridge/script/tidecloak/start.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +# Start TideCloak and initialize a realm. +# Configurable port to avoid conflicts (default: 8080). +# +# Usage: +# ./start.sh # port 8080 +# TC_PORT=8180 ./start.sh # port 8180 +# ./start.sh --skip-init # start only, no realm init +# TC_PUBLIC_URL=https://example.com ./start.sh # public hostname + +set -euo pipefail + +TC_PORT="${TC_PORT:-8080}" +TC_CONTAINER="${TC_CONTAINER:-mytidecloak}" +SKIP_INIT="${1:-}" +KC_HOSTNAME="${TC_PUBLIC_URL:-http://localhost:${TC_PORT}}" + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" + +# ── Stop existing container if running ──────────────────────── +if docker ps -a --format '{{.Names}}' | grep -q "^${TC_CONTAINER}$"; then + echo "[TideCloak] Stopping existing container..." + docker rm -f "$TC_CONTAINER" > /dev/null 2>&1 || true +fi + +# ── Check port is free ──────────────────────────────────────── +if ss -tlnp 2>/dev/null | grep -q ":${TC_PORT} "; then + echo "[TideCloak] ERROR: Port ${TC_PORT} is already in use" + echo " Use a different port: TC_PORT=8180 ./start.sh" + exit 1 +fi + +# ── Start TideCloak ────────────────────────────────────────── +echo "[TideCloak] Starting on port ${TC_PORT} (KC_HOSTNAME=${KC_HOSTNAME})..." +docker run \ + --name "$TC_CONTAINER" \ + -d \ + -v "${SCRIPT_DIR}":/opt/keycloak/data/h2 \ + -p "${TC_PORT}:8080" \ + -e KC_BOOTSTRAP_ADMIN_USERNAME=admin \ + -e KC_BOOTSTRAP_ADMIN_PASSWORD=password \ + -e "KC_HOSTNAME=${KC_HOSTNAME}" \ + -e SYSTEM_HOME_ORK=https://sork1.tideprotocol.com \ + -e USER_HOME_ORK=https://sork1.tideprotocol.com \ + -e THRESHOLD_T=3 \ + -e THRESHOLD_N=5 \ + -e PAYER_PUBLIC=20000011d6a0e8212d682657147d864b82d10e92776c15ead43dcfdc100ebf4dcfe6a8 \ + tideorg/tidecloak-stg-dev:latest + +echo "[TideCloak] Container '${TC_CONTAINER}' started on port ${TC_PORT}" + +# ── Initialize realm ───────────────────────────────────────── +if [ "$SKIP_INIT" = "--skip-init" ]; then + echo "[TideCloak] Skipping initialization (--skip-init)" +else + export TIDECLOAK_LOCAL_URL="http://localhost:${TC_PORT}" + mkdir -p "${SCRIPT_DIR}/../../data" + cd "$SCRIPT_DIR" + bash ./init-tidecloak.sh +fi + +echo "" +echo "[TideCloak] Ready at http://localhost:${TC_PORT}" +echo "[TideCloak] Admin console: http://localhost:${TC_PORT}/admin" diff --git a/bridges/punchd-bridge/start.sh b/bridges/punchd-bridge/start.sh new file mode 100755 index 0000000..9ecaef5 --- /dev/null +++ b/bridges/punchd-bridge/start.sh @@ -0,0 +1,291 @@ +#!/usr/bin/env bash +# Start Punc'd for local development. +# +# On first run: +# 1. Starts TideCloak (Docker) and initializes a realm (localhost) +# 2. Restarts TideCloak with public hostname so Tide SDK works remotely +# 3. Re-signs IdP settings + re-fetches adapter config +# 4. Prompts for STUN server secrets (API_SECRET + TURN_SECRET) +# 5. Installs deps, builds, and starts the gateway +# +# On subsequent runs, TideCloak and secrets are reused automatically. +# +# Usage: +# ./start.sh # full setup +# ./start.sh --skip-tc # skip TideCloak, gateway only +# API_SECRET=xxx TURN_SECRET=yyy ./start.sh # pass secrets directly +# STUN_SERVER_URL=wss://stun:9090 ./start.sh # custom STUN server + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")" && pwd)" +ENV_FILE="${REPO_ROOT}/script/gateway/.env" +TC_PORT="${TC_PORT:-8080}" +TC_CONTAINER="${TC_CONTAINER:-mytidecloak}" +SKIP_TC="${1:-}" +REALM_NAME="${NEW_REALM_NAME:-keylessh}" +CLIENT_NAME="${CLIENT_NAME:-myclient}" + +# Resolve STUN server URL early +STUN_SERVER_URL="${STUN_SERVER_URL:-wss://tidestun.codesyo.com:9090}" + +# Derive public URL from STUN server URL: +# wss://tidestun.codesyo.com:9090 → https://tidestun.codesyo.com +TC_PUBLIC_URL="${TC_PUBLIC_URL:-$(echo "$STUN_SERVER_URL" | sed 's|^wss://|https://|;s|^ws://|http://|;s|:[0-9]*$||')}" + +# ── Adapter config paths ──────────────────────────────────────── +ADAPTER_CONFIG="${REPO_ROOT}/gateway/data/tidecloak.json" +if [ ! -f "$ADAPTER_CONFIG" ]; then + ADAPTER_CONFIG="${REPO_ROOT}/data/tidecloak.json" +fi + +# ── TideCloak ─────────────────────────────────────────────────── +start_tidecloak() { + if [ "$SKIP_TC" = "--skip-tc" ]; then + echo "[TideCloak] Skipped (--skip-tc)" + return + fi + + # Already have adapter config — just make sure container is running + if [ -f "$ADAPTER_CONFIG" ]; then + if docker ps --format '{{.Names}}' | grep -q "^${TC_CONTAINER}$"; then + echo "[TideCloak] Already running on port ${TC_PORT}" + patch_admin_console_redirects + return + fi + echo "[TideCloak] Adapter config exists — starting container with public URL" + start_tc_container "$TC_PUBLIC_URL" + wait_for_tc + patch_admin_console_redirects + return + fi + + # First run — two-phase startup + echo "[TideCloak] No adapter config found — running full setup" + + # Phase 1: Init with localhost (so invite link works in local browser) + start_tc_container "http://localhost:${TC_PORT}" + init_tc_realm + + # Phase 2: Restart with public URL so Tide SDK/enclave uses reachable URLs + echo "" + echo "[TideCloak] Restarting with public hostname: ${TC_PUBLIC_URL}" + start_tc_container "$TC_PUBLIC_URL" + wait_for_tc + + # Re-sign IdP settings so ork registration gets the public URL + resign_idp_settings + + # Re-fetch adapter config (now has public URL as auth-server-url) + refetch_adapter_config + + # Patch admin console so gateway-proxied login works + patch_admin_console_redirects +} + +start_tc_container() { + local kc_hostname="${1:-http://localhost:${TC_PORT}}" + + # Stop existing container if present + if docker ps -a --format '{{.Names}}' | grep -q "^${TC_CONTAINER}$"; then + echo "[TideCloak] Stopping existing container..." + docker rm -f "$TC_CONTAINER" > /dev/null 2>&1 || true + fi + + # Check port is free + if ss -tlnp 2>/dev/null | grep -q ":${TC_PORT} "; then + echo "[TideCloak] ERROR: Port ${TC_PORT} is already in use" + echo " Use a different port: TC_PORT=8180 ./start.sh" + exit 1 + fi + + echo "[TideCloak] Starting on port ${TC_PORT} (KC_HOSTNAME=${kc_hostname})..." + docker run \ + --name "$TC_CONTAINER" \ + -d \ + -v "${REPO_ROOT}/script/tidecloak":/opt/keycloak/data/h2 \ + -p "${TC_PORT}:8080" \ + -e KC_BOOTSTRAP_ADMIN_USERNAME=admin \ + -e KC_BOOTSTRAP_ADMIN_PASSWORD=password \ + -e "KC_HOSTNAME=${kc_hostname}" \ + -e "KC_HOSTNAME_ADMIN=http://localhost:${TC_PORT}" \ + -e SYSTEM_HOME_ORK=https://sork1.tideprotocol.com \ + -e USER_HOME_ORK=https://sork1.tideprotocol.com \ + -e THRESHOLD_T=3 \ + -e THRESHOLD_N=5 \ + -e PAYER_PUBLIC=20000011d6a0e8212d682657147d864b82d10e92776c15ead43dcfdc100ebf4dcfe6a8 \ + tideorg/tidecloak-stg-dev:latest + + echo "[TideCloak] Container '${TC_CONTAINER}' started on port ${TC_PORT}" +} + +wait_for_tc() { + echo -n "[TideCloak] Waiting for TideCloak to be ready..." + for i in $(seq 1 30); do + if curl -s -f --connect-timeout 3 "http://localhost:${TC_PORT}" > /dev/null 2>&1; then + echo " ready" + return + fi + echo -n "." + sleep 3 + done + echo " timeout!" + echo "[TideCloak] ERROR: TideCloak did not start in time" + exit 1 +} + +get_admin_token() { + curl -s -f -X POST "http://localhost:${TC_PORT}/realms/master/protocol/openid-connect/token" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "username=admin" -d "password=password" \ + -d "grant_type=password" -d "client_id=admin-cli" | jq -r '.access_token' +} + +resign_idp_settings() { + echo "[TideCloak] Re-signing IdP settings with public URL..." + local token + token="$(get_admin_token)" + + curl -s -f -X POST "http://localhost:${TC_PORT}/admin/realms/${REALM_NAME}/vendorResources/sign-idp-settings" \ + -H "Authorization: Bearer $token" > /dev/null 2>&1 + + echo "[TideCloak] IdP settings re-signed" +} + +refetch_adapter_config() { + echo "[TideCloak] Re-fetching adapter config with public URL..." + local token + token="$(get_admin_token)" + + local client_uuid + client_uuid=$(curl -s -f -X GET "http://localhost:${TC_PORT}/admin/realms/${REALM_NAME}/clients?clientId=${CLIENT_NAME}" \ + -H "Authorization: Bearer $token" | jq -r '.[0].id') + + mkdir -p "$(dirname "$ADAPTER_CONFIG")" + curl -s -f -X GET "http://localhost:${TC_PORT}/admin/realms/${REALM_NAME}/vendorResources/get-installations-provider?clientId=${client_uuid}&providerId=keycloak-oidc-keycloak-json" \ + -H "Authorization: Bearer $token" > "$ADAPTER_CONFIG" + + echo "[TideCloak] Adapter config updated: $ADAPTER_CONFIG" +} + +patch_admin_console_redirects() { + echo "[TideCloak] Patching admin console redirect URIs..." + local token + token="$(get_admin_token)" || return + + local client_uuid + client_uuid=$(curl -s -f "http://localhost:${TC_PORT}/admin/realms/master/clients?clientId=security-admin-console" \ + -H "Authorization: Bearer $token" | jq -r '.[0].id') + [ -z "$client_uuid" ] || [ "$client_uuid" = "null" ] && return + + local gw_port="${LISTEN_PORT:-7891}" + local existing + existing=$(curl -s -f "http://localhost:${TC_PORT}/admin/realms/master/clients/$client_uuid" \ + -H "Authorization: Bearer $token") + + local updated + updated=$(echo "$existing" | jq \ + --arg gw_uri "https://localhost:${gw_port}/*" \ + --arg gw_origin "https://localhost:${gw_port}" \ + --arg pub_uri "${TC_PUBLIC_URL}/*" \ + --arg pub_origin "${TC_PUBLIC_URL}" \ + '.redirectUris += [$gw_uri, $pub_uri] | .webOrigins += [$gw_origin, $pub_origin] | .redirectUris |= unique | .webOrigins |= unique') + + curl -s -f -X PUT "http://localhost:${TC_PORT}/admin/realms/master/clients/$client_uuid" \ + -H "Authorization: Bearer $token" \ + -H "Content-Type: application/json" \ + -d "$updated" > /dev/null 2>&1 + + echo "[TideCloak] Admin console redirect URIs patched (Gateway :${gw_port})" +} + +init_tc_realm() { + export TIDECLOAK_LOCAL_URL="http://localhost:${TC_PORT}" + mkdir -p "${REPO_ROOT}/data" + cd "${REPO_ROOT}/script/tidecloak" + bash ./init-tidecloak.sh + cd "$REPO_ROOT" + # Re-resolve adapter config path after init creates it + ADAPTER_CONFIG="${REPO_ROOT}/data/tidecloak.json" +} + +start_tidecloak + +# ── Load or prompt for secrets ───────────────────────────────── +load_secrets() { + # 1. Already set via environment + if [ -n "${API_SECRET:-}" ] && [ -n "${TURN_SECRET:-}" ]; then + echo "[Gateway] Using secrets from environment" + return + fi + + # 2. Saved from previous run + if [ -f "$ENV_FILE" ]; then + echo "[Gateway] Loading secrets from ${ENV_FILE}" + source "$ENV_FILE" + if [ -n "${API_SECRET:-}" ] && [ -n "${TURN_SECRET:-}" ]; then + return + fi + fi + + # 3. Prompt — get these from whoever runs the STUN server + echo "" + echo "[Gateway] Secrets not found." + echo " Get API_SECRET and TURN_SECRET from the STUN server operator." + echo "" + read -rp " API_SECRET: " API_SECRET + read -rp " TURN_SECRET: " TURN_SECRET + echo "" + save_secrets +} + +save_secrets() { + mkdir -p "$(dirname "$ENV_FILE")" + cat > "$ENV_FILE" <}" +echo " LISTEN_PORT=$LISTEN_PORT" +echo " API_SECRET=${API_SECRET:+set}" +echo " TURN_SECRET=${TURN_SECRET:+set}" + +# ── Install, build and start gateway ──────────────────────────── +cd "${REPO_ROOT}/gateway" +npm install +npm run build +exec npm start diff --git a/tcp-bridge/Dockerfile b/bridges/tcp-bridge/Dockerfile similarity index 100% rename from tcp-bridge/Dockerfile rename to bridges/tcp-bridge/Dockerfile diff --git a/tcp-bridge/README.md b/bridges/tcp-bridge/README.md similarity index 100% rename from tcp-bridge/README.md rename to bridges/tcp-bridge/README.md diff --git a/tcp-bridge/azure/container-app.yaml b/bridges/tcp-bridge/azure/container-app.yaml similarity index 100% rename from tcp-bridge/azure/container-app.yaml rename to bridges/tcp-bridge/azure/container-app.yaml diff --git a/tcp-bridge/azure/deploy.sh b/bridges/tcp-bridge/azure/deploy.sh similarity index 100% rename from tcp-bridge/azure/deploy.sh rename to bridges/tcp-bridge/azure/deploy.sh diff --git a/tcp-bridge/package-lock.json b/bridges/tcp-bridge/package-lock.json similarity index 100% rename from tcp-bridge/package-lock.json rename to bridges/tcp-bridge/package-lock.json diff --git a/tcp-bridge/package.json b/bridges/tcp-bridge/package.json similarity index 100% rename from tcp-bridge/package.json rename to bridges/tcp-bridge/package.json diff --git a/tcp-bridge/src/index.ts b/bridges/tcp-bridge/src/index.ts similarity index 83% rename from tcp-bridge/src/index.ts rename to bridges/tcp-bridge/src/index.ts index 8d89bbe..7c68623 100644 --- a/tcp-bridge/src/index.ts +++ b/bridges/tcp-bridge/src/index.ts @@ -6,15 +6,13 @@ import { readFileSync, existsSync } from "fs"; import { join } from "path"; /** - * TCP Bridge - Stateless WebSocket to TCP bridge for SSH connections. + * TCP Bridge — Stateless WebSocket-to-TCP forwarder for SSH connections. * - * - Validates JWT using embedded JWKS (no external API calls) - * - Receives host/port from browser (browser got them from main server session creation) - * - Forwards WebSocket data to TCP and vice versa - * - Scales horizontally (Azure Container Apps) + * Clients connect via WebSocket with JWT auth, and the bridge opens a TCP + * connection to the target SSH server, forwarding data bidirectionally. * * Environment variables: - * - PORT: Port to listen on (default: 8080) + * - PORT: Port to listen on (default: 8081) * - client_adapter: JSON string of tidecloak.json config (highest priority) * - TIDECLOAK_CONFIG_B64: Base64-encoded config (alternative for Azure) */ @@ -118,17 +116,28 @@ if (!loadConfig()) { process.exit(1); } +// ── HTTP Server ───────────────────────────────────────────────── + +let activeTcpConnections = 0; + const server = createServer((req, res) => { - if (req.url === "/health") { + const url = req.url || "/"; + const path = url.split("?")[0]; + + if (path === "/health") { res.writeHead(200, { "Content-Type": "application/json" }); - res.end(JSON.stringify({ status: "ok", connections: activeConnections })); + res.end(JSON.stringify({ + status: "ok", + tcpConnections: activeTcpConnections, + })); return; } - res.writeHead(404); + + res.writeHead(404, { "Content-Type": "text/plain" }); res.end("Not found"); }); -let activeConnections = 0; +// ── WebSocket Server ──────────────────────────────────────────── const wss = new WebSocketServer({ server }); @@ -158,7 +167,7 @@ wss.on("connection", async (ws: WebSocket, req) => { const userId = payload.sub || "unknown"; console.log(`[Bridge] Connection: ${userId} -> ${host}:${port} (session: ${sessionId})`); - activeConnections++; + activeTcpConnections++; // Connect to SSH server const tcpSocket: Socket = connect({ host, port }); @@ -199,7 +208,7 @@ wss.on("connection", async (ws: WebSocket, req) => { ws.on("close", () => { console.log("[Bridge] WebSocket closed"); - activeConnections--; + activeTcpConnections--; if (!tcpSocket.destroyed) { tcpSocket.destroy(); } @@ -213,9 +222,11 @@ wss.on("connection", async (ws: WebSocket, req) => { }); }); +// ── Start ─────────────────────────────────────────────────────── + server.listen(PORT, () => { console.log(`[Bridge] TCP Bridge listening on port ${PORT}`); - console.log(`[Bridge] Health check: http://localhost:${PORT}/health`); + console.log(`[Bridge] Health: http://localhost:${PORT}/health`); }); process.on("SIGTERM", () => { diff --git a/bridges/tcp-bridge/tsconfig.json b/bridges/tcp-bridge/tsconfig.json new file mode 100644 index 0000000..4845caf --- /dev/null +++ b/bridges/tcp-bridge/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "node", + "esModuleInterop": true, + "strict": true, + "outDir": "./dist", + "rootDir": "./src", + "skipLibCheck": true, + "declaration": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/client/src/App.tsx b/client/src/App.tsx index 4c77c50..67f808e 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -22,6 +22,7 @@ import AdminLogs from "@/pages/AdminLogs"; import AdminLicense from "@/pages/AdminLicense"; import AdminRecordings from "@/pages/AdminRecordings"; import AdminBridges from "@/pages/AdminBridges"; +import AdminSignalServers from "@/pages/AdminSignalServers"; import NotFound from "@/pages/not-found"; import { Loader2, Terminal } from "lucide-react"; @@ -146,6 +147,9 @@ function AuthenticatedApp() { {isAdmin ? : } + + {isAdmin ? : } + {isAdmin ? : } diff --git a/client/src/components/layout/AppLayout.tsx b/client/src/components/layout/AppLayout.tsx index f9aa65a..9ea8d75 100644 --- a/client/src/components/layout/AppLayout.tsx +++ b/client/src/components/layout/AppLayout.tsx @@ -24,7 +24,7 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; -import { Server, Users, Activity, LogOut, Shield, ChevronDown, Layers, ScrollText, KeyRound, CheckSquare, RefreshCw, CreditCard, Video, Zap, Sun, Moon, Network } from "lucide-react"; +import { Server, Users, Activity, LogOut, Shield, ChevronDown, Layers, ScrollText, KeyRound, CheckSquare, RefreshCw, CreditCard, Video, Zap, Sun, Moon, Network, Radio } from "lucide-react"; function KeyleSSHLogo({ className = "" }: { className?: string }) { return ( @@ -76,6 +76,7 @@ const adminNavGroups = [ { title: "Overview", url: "/admin", icon: Shield }, { title: "Servers", url: "/admin/servers", icon: Server }, { title: "Bridges", url: "/admin/bridges", icon: Network }, + { title: "Signal Servers", url: "/admin/signal-servers", icon: Radio }, { title: "Sessions", url: "/admin/sessions", icon: Activity }, ], }, diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts index 9dee00d..6fa3784 100644 --- a/client/src/lib/api.ts +++ b/client/src/lib/api.ts @@ -10,6 +10,8 @@ import type { TemplateParameter, Bridge, InsertBridge, + SignalServer, + InsertSignalServer, } from "@shared/schema"; const API_BASE = import.meta.env.VITE_API_BASE_URL || ""; @@ -96,6 +98,9 @@ export const api = { list: () => apiRequest("/api/servers"), get: (id: string) => apiRequest(`/api/servers/${id}`), }, + gatewayEndpoints: { + list: () => apiRequest("/api/gateway-endpoints"), + }, sessions: { list: () => apiRequest("/api/sessions"), create: (data: { serverId: string; sshUser: string }) => @@ -138,6 +143,22 @@ export const api = { delete: (id: string) => apiRequest(`/api/admin/bridges/${id}`, { method: "DELETE" }), }, + signalServers: { + list: () => apiRequest("/api/admin/signal-servers"), + get: (id: string) => apiRequest(`/api/admin/signal-servers/${id}`), + create: (data: InsertSignalServer) => + apiRequest("/api/admin/signal-servers", { + method: "POST", + body: JSON.stringify(data), + }), + update: (id: string, data: Partial) => + apiRequest(`/api/admin/signal-servers/${id}`, { + method: "PUT", + body: JSON.stringify(data), + }), + delete: (id: string) => + apiRequest(`/api/admin/signal-servers/${id}`, { method: "DELETE" }), + }, users: { list: () => apiRequest("/api/admin/users"), add: (data: { username: string; firstName: string; lastName: string; email: string }) => @@ -422,8 +443,21 @@ export const api = { }, }; +// Gateway endpoint from signal server aggregation +export interface GatewayEndpoint { + id: string; + displayName: string; + description: string; + backends: { name: string; accessible: boolean }[]; + online: boolean; + clientCount: number; + signalServerId: string; + signalServerName: string; + signalServerUrl: string; +} + // Re-export types for convenience -export type { PolicyTemplate, InsertPolicyTemplate, TemplateParameter, Bridge, InsertBridge }; +export type { PolicyTemplate, InsertPolicyTemplate, TemplateParameter, Bridge, InsertBridge, SignalServer, InsertSignalServer }; // SSH Policy Configuration for role creation export interface SshPolicyConfig { diff --git a/client/src/pages/AdminRoles.tsx b/client/src/pages/AdminRoles.tsx index 0662aec..13ba758 100644 --- a/client/src/pages/AdminRoles.tsx +++ b/client/src/pages/AdminRoles.tsx @@ -44,11 +44,11 @@ import { } from "@/components/ui/table"; import { useToast } from "@/hooks/use-toast"; import { queryClient } from "@/lib/queryClient"; -import { api, type PolicyTemplate, type TemplateParameter } from "@/lib/api"; +import { api, type PolicyTemplate, type TemplateParameter, type GatewayEndpoint } from "@/lib/api"; import { useAutoRefresh } from "@/hooks/useAutoRefresh"; import { RefreshButton } from "@/components/RefreshButton"; import { useAuth, useAuthConfig } from "@/contexts/AuthContext"; -import { KeyRound, Pencil, Plus, Trash2, Search, Shield, FileCode } from "lucide-react"; +import { KeyRound, Pencil, Plus, Trash2, Search, Shield, FileCode, Globe } from "lucide-react"; import type { AdminRole } from "@shared/schema"; import { ADMIN_ROLE_SET } from "@shared/config/roles"; import { createSshPolicyRequest, createSshPolicyRequestWithCode, bytesToBase64, SSH_MODEL_IDS, SSH_FORSETI_CONTRACT } from "@/lib/sshPolicy"; @@ -86,7 +86,7 @@ export default function AdminRoles() { const [editingRole, setEditingRole] = useState(null); const [creatingRole, setCreatingRole] = useState(false); const [deletingRole, setDeletingRole] = useState(null); - const [createAsSshRole, setCreateAsSshRole] = useState(true); + const [roleType, setRoleType] = useState<"ssh" | "endpoint" | "custom">("ssh"); const [isCreatingPolicy, setIsCreatingPolicy] = useState(false); const [formData, setFormData] = useState<{ name: string; description: string }>({ name: "", @@ -95,6 +95,9 @@ export default function AdminRoles() { const [policyConfig, setPolicyConfig] = useState(defaultPolicyConfig); const [selectedTemplateId, setSelectedTemplateId] = useState(null); const [templateParams, setTemplateParams] = useState>({}); + // Endpoint role selection state + const [selectedGatewayId, setSelectedGatewayId] = useState(""); + const [selectedBackendName, setSelectedBackendName] = useState(""); const normalizeSshRoleName = (value: string) => { const trimmed = value.trim(); @@ -116,6 +119,12 @@ export default function AdminRoles() { const roles = rolesData?.roles || []; + // Query for gateway endpoints (for endpoint role creation) + const { data: gatewayEndpoints } = useQuery({ + queryKey: ["/api/gateway-endpoints"], + queryFn: api.gatewayEndpoints.list, + }); + // Query for policy templates const { data: templatesData } = useQuery({ queryKey: ["/api/admin/policy-templates"], @@ -189,6 +198,8 @@ export default function AdminRoles() { setPolicyConfig(defaultPolicyConfig); setSelectedTemplateId(null); setTemplateParams({}); + setSelectedGatewayId(""); + setSelectedBackendName(""); toast({ title: "Role created successfully" }); }, onError: (error: Error) => { @@ -283,10 +294,12 @@ export default function AdminRoles() { const handleCreate = () => { setFormData({ name: "", description: "" }); - setCreateAsSshRole(true); + setRoleType("ssh"); setPolicyConfig(defaultPolicyConfig); setSelectedTemplateId(null); setTemplateParams({}); + setSelectedGatewayId(""); + setSelectedBackendName(""); setCreatingRole(true); }; @@ -379,7 +392,18 @@ export default function AdminRoles() { const handleCreateSubmit = async (e: React.FormEvent) => { e.preventDefault(); - const name = createAsSshRole ? normalizeSshRoleName(formData.name) : formData.name.trim(); + let name: string; + if (roleType === "ssh") { + name = normalizeSshRoleName(formData.name); + } else if (roleType === "endpoint") { + if (!selectedGatewayId || !selectedBackendName) { + toast({ title: "Please select a gateway and backend", variant: "destructive" }); + return; + } + name = `dest:${selectedGatewayId}:${selectedBackendName}`; + } else { + name = formData.name.trim(); + } if (!name) { toast({ title: "Role name is required", variant: "destructive" }); return; @@ -389,11 +413,11 @@ export default function AdminRoles() { createMutation.mutate({ name, description: formData.description || undefined, - policy: createAsSshRole && policyConfig.enabled ? policyConfig : undefined, + policy: roleType === "ssh" && policyConfig.enabled ? policyConfig : undefined, }); // If policy is enabled, create the PolicySignRequest with Forseti contract - if (createAsSshRole && policyConfig.enabled) { + if (roleType === "ssh" && policyConfig.enabled) { setIsCreatingPolicy(true); try { let policyRequest; @@ -484,6 +508,11 @@ export default function AdminRoles() { [roles] ); + const destRoleCount = useMemo( + () => roles.filter((r) => /^dest[:\-]/i.test(r.name)).length, + [roles] + ); + return (
@@ -496,8 +525,8 @@ export default function AdminRoles() { Create and manage user roles for access control

- SSH username roles use the format ssh:<username> (e.g.{" "} - ssh:root) — {sshRoleCount} configured. + ssh:<username> for SSH access ({sshRoleCount}),{" "} + dest:<gateway>:<backend> for endpoint access ({destRoleCount}).

@@ -562,11 +591,16 @@ export default function AdminRoles() {

{role.name}

- {/^(ssh[:\-])/i.test(role.name) && ( + {/^ssh[:\-]/i.test(role.name) && ( SSH )} + {/^dest[:\-]/i.test(role.name) && ( + + Endpoint + + )}
@@ -852,7 +886,7 @@ export default function AdminRoles() { {/* Create Role Dialog */} !open && setCreatingRole(false)}> - + Add New Role @@ -860,37 +894,130 @@ export default function AdminRoles() {
+ {/* Role Type Selector */}
- -
- setCreateAsSshRole(Boolean(v))} - /> - + +
+ + +
- { - const raw = e.target.value; - setFormData({ - ...formData, - name: createAsSshRole ? normalizeSshRoleName(raw) : raw, - }); - }} - placeholder={createAsSshRole ? "e.g., root" : "e.g., developer"} - required - /> - {createAsSshRole && ( +
+ + {/* SSH Role Name Input */} + {roleType === "ssh" && ( +
+ + { + const raw = e.target.value; + setFormData({ ...formData, name: normalizeSshRoleName(raw) }); + }} + placeholder="e.g., root" + required + />

- This will create a role like ssh:root, which grants SSH username access. + Creates role ssh:{formData.name.replace(/^ssh[:\-]/i, "") || "username"} — grants SSH access as this user.

- )} -
+
+ )} + + {/* Endpoint Role - Gateway & Backend Dropdowns */} + {roleType === "endpoint" && ( +
+
+ + +
+ {selectedGatewayId && ( +
+ + +
+ )} + {selectedGatewayId && selectedBackendName && ( +

+ Creates role dest:{selectedGatewayId}:{selectedBackendName} — grants access to this endpoint. +

+ )} + {(!gatewayEndpoints || gatewayEndpoints.length === 0) && ( +

+ No gateways are currently online. You can still create the role manually using "Custom" type. +

+ )} +
+ )} + + {/* Custom Role Name Input */} + {roleType === "custom" && ( +
+ + setFormData({ ...formData, name: e.target.value })} + placeholder="e.g., developer" + required + /> +
+ )}
@@ -904,7 +1031,7 @@ export default function AdminRoles() {
{/* Policy Configuration Section - only for SSH roles */} - {createAsSshRole && ( + {roleType === "ssh" && (
@@ -1128,7 +1255,14 @@ export default function AdminRoles() { - diff --git a/client/src/pages/AdminSignalServers.tsx b/client/src/pages/AdminSignalServers.tsx new file mode 100644 index 0000000..6b9667b --- /dev/null +++ b/client/src/pages/AdminSignalServers.tsx @@ -0,0 +1,451 @@ +import { useState } from "react"; +import { useQuery, useMutation, useIsFetching } from "@tanstack/react-query"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Textarea } from "@/components/ui/textarea"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { useToast } from "@/hooks/use-toast"; +import { queryClient } from "@/lib/queryClient"; +import { useAutoRefresh } from "@/hooks/useAutoRefresh"; +import { Plus, Pencil, Trash2, Radio, Search, Loader2, CheckCircle, XCircle, Wifi } from "lucide-react"; +import type { SignalServer } from "@shared/schema"; +import { api } from "@/lib/api"; +import { RefreshButton } from "@/components/RefreshButton"; + +interface SignalServerFormData { + name: string; + url: string; + description: string; + enabled: boolean; +} + +const defaultFormData: SignalServerFormData = { + name: "", + url: "", + description: "", + enabled: true, +}; + +function SignalServerForm({ + initialData, + onSubmit, + onCancel, + isLoading, +}: { + initialData?: SignalServerFormData; + onSubmit: (data: SignalServerFormData) => void; + onCancel: () => void; + isLoading: boolean; +}) { + const [formData, setFormData] = useState(initialData || defaultFormData); + const [testStatus, setTestStatus] = useState<"idle" | "testing" | "success" | "error">("idle"); + const [testMessage, setTestMessage] = useState(""); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + onSubmit(formData); + }; + + const handleTestConnection = async () => { + if (!formData.url) { + setTestStatus("error"); + setTestMessage("Please enter a URL first"); + return; + } + + setTestStatus("testing"); + setTestMessage(""); + + try { + const healthUrl = formData.url.replace(/\/$/, "") + "/health"; + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 5000); + const resp = await fetch(healthUrl, { signal: controller.signal }); + clearTimeout(timeout); + + if (resp.ok) { + const data = await resp.json(); + setTestStatus("success"); + setTestMessage(`Online — ${data.gateways ?? 0} gateways, ${data.clients ?? 0} clients`); + } else { + setTestStatus("error"); + setTestMessage(`Server returned ${resp.status}`); + } + } catch (err) { + setTestStatus("error"); + setTestMessage(err instanceof Error && err.name === "AbortError" ? "Connection timeout" : "Cannot reach server"); + } + }; + + return ( + +
+ + setFormData({ ...formData, name: e.target.value })} + placeholder="Production Signal Server" + required + /> +
+ +
+ + setFormData({ ...formData, url: e.target.value })} + placeholder="https://tidestun.codesyo.com:9090" + required + /> +

+ The signal server URL (HTTPS). Gateways register here for P2P signaling and HTTP relay. +

+
+ +
+ + {testStatus === "success" && ( +
+ + {testMessage} +
+ )} + {testStatus === "error" && ( +
+ + {testMessage} +
+ )} +
+ +
+ +