diff --git a/.env.example b/.env.example index 08123c8..411cecf 100644 --- a/.env.example +++ b/.env.example @@ -30,7 +30,7 @@ DATABASE_URL=./data/keylessh.db # 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: Shared secret for TURN REST API ephemeral credentials (HMAC-SHA1) # TURN_SECRET=your-turn-secret-here # Auth server override (optional, for testing) diff --git a/README.md b/README.md index faf92f5..1cca5e5 100644 --- a/README.md +++ b/README.md @@ -29,14 +29,15 @@ The result: enterprise-grade SSH access control without any private keys to mana ## Features -- **Browser-side SSH** via `@microsoft/dev-tunnels-ssh` + `xterm.js` +- **Browser-side SSH** via `@microsoft/dev-tunnels-ssh` + `xterm.js`, with no private keys anywhere - **SFTP file browser** - Browse, upload, download, rename, delete files via split-panel UI - **Quorum-based RBAC, zero-knowledge OIDC login** with TideCloak - no passwords, no keys -- **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** (`bridges/tcp-bridge`) for scalable WS↔TCP tunneling -- **NAT-traversing HTTP gateway** (`bridges/punchd-bridge`) with WebRTC P2P upgrade +- **Programmable policy enforcement** with Forseti contracts for SSH access +- **Admin UX**: servers, users, roles, policy templates, change requests, sessions, logs +- **Browser-based RDP** - full Windows remote desktop in a browser tab via [IronRDP](https://github.com/Devolutions/IronRDP) WASM. No client install, no ports to open, no VPN. See [RDP Architecture](bridges/punchd-bridge/docs/ARCHITECTURE.md#rdp-remote-desktop-ironrdp-wasm--rdcleanpath). +- **P2P DataChannel transport** - automatic upgrade from HTTP relay to direct peer-to-peer WebRTC, with a Service Worker that silently reroutes all browser fetches through the encrypted DataChannel. See [Connection Lifecycle](bridges/punchd-bridge/docs/ARCHITECTURE.md#connection-lifecycle). +- **Signal server** (`signal-server/`) - coordinates P2P connections between browsers and gateways via WebSocket signaling (SDP/ICE), relays HTTP traffic before DataChannel is ready, and generates ephemeral TURN credentials. Deployed with a coturn sidecar for STUN NAT discovery and TURN relay fallback. See [Architecture](bridges/punchd-bridge/docs/ARCHITECTURE.md#system-overview). +- **Multi-backend routing** (`bridges/punchd-bridge`) - proxy to multiple HTTP backends and RDP servers from a single gateway. See [Multi-Backend Routing](bridges/punchd-bridge/docs/ARCHITECTURE.md#multi-backend-routing). ## Project Structure @@ -62,8 +63,8 @@ keylessh/ ### 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. +- [Punch'd Bridge](bridges/punchd-bridge/docs/ARCHITECTURE.md) — [connection lifecycle](bridges/punchd-bridge/docs/ARCHITECTURE.md#connection-lifecycle) (portal → relay → P2P → SW takeover), [RDP/RDCleanPath](bridges/punchd-bridge/docs/ARCHITECTURE.md#rdp-remote-desktop-ironrdp-wasm--rdcleanpath), [multi-backend routing](bridges/punchd-bridge/docs/ARCHITECTURE.md#multi-backend-routing), [DataChannel messages](bridges/punchd-bridge/docs/ARCHITECTURE.md#datachannel-messages-gateway--browser), [API endpoints](bridges/punchd-bridge/docs/ARCHITECTURE.md#signal-server-api-routes), [security & rate limits](bridges/punchd-bridge/docs/ARCHITECTURE.md#security), [sequence diagrams](bridges/punchd-bridge/docs/diagrams/) +- [Signal Server](signal-server/deploy.sh) — WebSocket signaling (SDP/ICE), HTTP relay, gateway registry, TURN credential generation, coturn sidecar for STUN/TURN ## Quickstart (Local Dev) diff --git a/bridges/punchd-bridge/README.md b/bridges/punchd-bridge/README.md index 7e3d553..80ab92c 100644 --- a/bridges/punchd-bridge/README.md +++ b/bridges/punchd-bridge/README.md @@ -1,12 +1,12 @@ -# Punc'd +# Punch'd -NAT-traversing authenticated reverse proxy gateway. Access private web applications from anywhere through hole-punched WebRTC DataChannels. +NAT-traversing authenticated reverse proxy gateway. Access private web applications and remote desktops from anywhere through hole-punched WebRTC DataChannels — no port forwarding, no VPN, no public IP required. ## How it works ``` -Browser → Signal Server (relay) → Gateway → Backend App - │ ↕ coturn │ +Browser → Signal Server (relay) → Gateway → Backend App (HTTP) + │ ↕ coturn │ └→ RDP Server (RDCleanPath) └──── WebRTC DataChannel (P2P) ────┘ (after hole punch) ``` @@ -19,6 +19,59 @@ The system has three components: 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. +## Features + +### Connection Lifecycle + +Connections progress through four phases automatically — the user just opens a URL: + +1. **Portal selection** — pick a gateway and backend from the signal server's portal page +2. **HTTP relay** — all traffic tunneled through the signal server's WebSocket until WebRTC is ready +3. **WebRTC upgrade** — injected `webrtc-upgrade.js` performs ICE/STUN hole punching for a direct P2P DataChannel, with TURN relay fallback +4. **Service Worker takeover** — a Service Worker transparently intercepts browser fetches and routes them through the DataChannel instead of HTTP relay + +See [Connection Lifecycle](docs/ARCHITECTURE.md#connection-lifecycle) for sequence diagrams. + +### Multi-Backend Routing + +A single gateway can proxy to multiple backends using path-based routing (`/__b//`). The gateway rewrites HTML responses (links, scripts, fetch/XHR calls) to maintain correct routing across backends. Backends can be marked `;noauth` to skip JWT validation. + +```bash +BACKENDS="App=http://localhost:3000,Auth=http://localhost:8080;noauth" +``` + +See [Multi-Backend Routing](docs/ARCHITECTURE.md#multi-backend-routing) for path prefix system and HTML rewriting details. + +### RDP Remote Desktop + +Browser-based RDP via [IronRDP](https://github.com/Devolutions/IronRDP) WASM. The gateway implements the **RDCleanPath protocol** — it handles TLS termination with the RDP server so IronRDP WASM (which can't do raw TCP/TLS from a browser) can perform CredSSP/NLA authentication. RDP traffic flows through the same WebRTC DataChannel as HTTP. No WebSocket server needed — the DataChannel carries RDCleanPath PDUs directly using a virtual WebSocket shim. + +```bash +BACKENDS="Web App=http://localhost:3000,My PC=rdp://localhost:3389" +``` + +Navigate to `/rdp?backend=My%20PC`, enter Windows credentials, and connect. + +See [RDP Architecture](docs/ARCHITECTURE.md#rdp-remote-desktop-ironrdp-wasm--rdcleanpath) for the RDCleanPath protocol, ASN.1 wire format, and IronRDP WASM build instructions. + +### Authentication + +Gateway-side OIDC authentication via TideCloak. TideCloak traffic is reverse-proxied through the gateway so it never needs direct browser access. Features transparent token refresh, server-side cookie jars for both TideCloak and backend sessions (needed because DataChannel responses can't set cookies), and `dest::` role-based access control. + +See [Authentication Flow](docs/ARCHITECTURE.md#authentication-flow) for the OIDC login flow, token validation, and endpoint reference. + +### Signal Server API + +The signal server exposes HTTP endpoints for portal interaction, gateway listing, admin actions, and TideCloak SSO — plus WebSocket signaling for gateway registration, client pairing, ICE candidate exchange, and HTTP relay. + +See [Signal Server API Routes](docs/ARCHITECTURE.md#signal-server-api-routes) and [Signaling Message Reference](docs/ARCHITECTURE.md#signaling-message-reference) for the full endpoint and message catalog. + +### Security + +JWT-validated requests at every entry point (relay, DataChannel, backend proxy). HTTP method whitelist, open redirect prevention, body size limits, CORS origin validation, timing-safe secret comparison, rate limiting per IP (20 connections, 100 msg/s), and automatic reconnection with exponential backoff. + +See [Security](docs/ARCHITECTURE.md#security) for headers, rate limits, capacity, and resilience details. + ## Quick start ```bash @@ -62,7 +115,7 @@ This starts coturn, the signal server, and the gateway together. Set `TURN_SECRE | 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) | +| `TURN_SECRET` | Signal server operator | Gateway operators | Generates ephemeral TURN credentials (HMAC-SHA1) | **Secret flow:** 1. Signal server operator deploys and generates secrets (or sets them manually) @@ -85,15 +138,25 @@ See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md#configuration-reference) for the |----------|-----------|-------------| | `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"` | +| `BACKENDS` | Gateway | Multiple backends: `"App=http://host:3000,Desktop=rdp://host:3389"` | | `API_SECRET` | Both | Shared secret for gateway registration | -| `TURN_SECRET` | Both + coturn | Shared secret for TURN credentials (HMAC-SHA256) | +| `TURN_SECRET` | Both + coturn | Shared secret for TURN credentials (HMAC-SHA1) | | `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 | +## Ports + +| Component | Port | Protocol | Purpose | +|-----------|------|----------|---------| +| coturn | 3478 | UDP + TCP | STUN/TURN | +| Signal server | 9090 | HTTP/WS | Signaling, portal, admin, HTTP relay | +| coturn | 49152-65535 | UDP | TURN relay sockets | +| Gateway | 7891 | HTTP/HTTPS | Proxy server | +| Gateway | 7892 | HTTP | Health check | + ## Documentation - [Architecture & Protocol Details](docs/ARCHITECTURE.md) — full system docs with diagrams diff --git a/bridges/punchd-bridge/docs/ARCHITECTURE.md b/bridges/punchd-bridge/docs/ARCHITECTURE.md index 8e10123..2e915ce 100644 --- a/bridges/punchd-bridge/docs/ARCHITECTURE.md +++ b/bridges/punchd-bridge/docs/ARCHITECTURE.md @@ -1,6 +1,6 @@ -# Punc'd Architecture +# Punch'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. +Punch'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. @@ -115,6 +115,138 @@ End-to-end view: portal selection → authentication → HTTP relay → WebRTC u ![Complete Lifecycle](diagrams/complete-lifecycle.svg) +## RDP Remote Desktop (IronRDP WASM + RDCleanPath) + +The gateway supports browser-based RDP remote desktop sessions using [IronRDP](https://github.com/Devolutions/IronRDP) compiled to WebAssembly. RDP traffic flows through the same WebRTC DataChannel infrastructure as HTTP — no additional ports or servers required. + +![RDP via RDCleanPath](diagrams/rdp-rdcleanpath.svg) + +### Architecture + +``` +Browser (IronRDP WASM) + │ + │ IronRDP creates a WebSocket to /ws/rdcleanpath + │ DCWebSocket shim intercepts (same-origin) + │ + ├─ Control: ws_open, ws_close → controlChannel (DataChannel) + ├─ Binary: [0x02][UUID][payload] → bulkChannel (DataChannel) + │ + │ ── WebRTC (P2P or TURN relay) ── + │ + ├─ Gateway peer-handler receives DataChannel messages + │ recognizes path "/ws/rdcleanpath" + │ routes to virtual RDCleanPath session (no real WebSocket) + │ + ├─ RDCleanPath handler: + │ 1. Parses RDCleanPath Request PDU (ASN.1 DER) + │ 2. Validates JWT + enforces dest: role + │ 3. TCP connects to RDP server + │ 4. X.224 Connection Request/Confirm + │ 5. TLS handshake (gateway terminates TLS) + │ 6. Extracts server certificate chain + │ 7. Sends RDCleanPath Response PDU + │ 8. Enters relay mode: TLS socket ↔ DataChannel + │ + └─ RDP Server (port 3389) +``` + +IronRDP WASM cannot make raw TCP/TLS connections from the browser. The **RDCleanPath protocol** solves this by having the gateway act as a TLS-terminating proxy: the gateway performs the TLS handshake with the RDP server and sends the server's certificate chain back to IronRDP, which uses it for NLA/CredSSP authentication. After the handshake, the gateway enters relay mode — forwarding encrypted RDP data bidirectionally between the browser (via DataChannel) and the RDP server (via TLS socket). + +### DCWebSocket Shim + +IronRDP WASM expects a standard `WebSocket` object. The `DCWebSocket` shim (in `rdp-client.js`) overrides `window.WebSocket` for same-origin connections and routes traffic through the existing DataChannel infrastructure: + +- `ws_open` / `ws_close` messages flow over the **control** DataChannel (JSON) +- Binary data uses the **bulk** DataChannel with the binary WS fast-path (`0x02` magic byte + 36-byte UUID + payload) + +This means no real WebSocket server is needed — the peer-handler creates a virtual WebSocket session backed by the RDCleanPath handler. + +### RDCleanPath Protocol (ASN.1 DER) + +The wire format uses ASN.1 DER encoding with EXPLICIT context-specific tags. Version constant: `3390`. + +**Request PDU** (client → gateway): + +| Tag | Field | Type | Description | +|-----|-------|------|-------------| +| [0] | version | INTEGER | Protocol version (3390) | +| [2] | destination | UTF8String | Backend name (e.g. "My PC") | +| [3] | proxy_auth | UTF8String | JWT for authentication | +| [5] | preconnection_blob | UTF8String | Optional PCB | +| [6] | x224_connection_pdu | OCTET STRING | X.224 Connection Request bytes | + +**Response PDU** (gateway → client): + +| Tag | Field | Type | Description | +|-----|-------|------|-------------| +| [0] | version | INTEGER | Protocol version (3390) | +| [6] | x224_connection_pdu | OCTET STRING | X.224 Connection Confirm bytes | +| [7] | server_cert_chain | SEQUENCE OF OCTET STRING | DER X.509 certs (leaf first) | +| [9] | server_addr | UTF8String | RDP server hostname | + +**Error PDU** (gateway → client): + +| Tag | Field | Type | Description | +|-----|-------|------|-------------| +| [0] | version | INTEGER | Protocol version (3390) | +| [1] | error | SEQUENCE | Error details | +| [1][0] | error_code | INTEGER | 1=general, 2=negotiation | +| [1][1] | http_status | INTEGER | HTTP status (401, 403, 404, 500) | +| [1][2] | wsa_error | INTEGER | Windows socket error (e.g. 10061=ECONNREFUSED) | +| [1][3] | tls_alert | INTEGER | TLS alert code | + +### RDP Backend Configuration + +RDP backends use the `rdp://` protocol scheme in the `BACKENDS` environment variable: + +```bash +BACKENDS="Web App=http://localhost:3000,My PC=rdp://localhost:3389" +``` + +The browser navigates to `/rdp?backend=My%20PC` to start an RDP session. The connect form prompts for Windows credentials (username + password), then IronRDP WASM handles the full RDP protocol including CredSSP/NLA authentication. + +### Security + +- JWT validated before any TCP connection (prevents SSRF/network scanning) +- Backend name resolved from config only (no arbitrary host:port from client) +- `dest::` role enforcement (same as HTTP proxy) +- RDCleanPath sessions count toward the per-DataChannel WebSocket limit +- TLS to the RDP server uses `rejectUnauthorized: false` (RDP servers typically use self-signed certificates — IronRDP validates via CredSSP) + +### IronRDP WASM Build + +IronRDP WASM is built from the [IronRDP](https://github.com/Devolutions/IronRDP) repository: + +```bash +# Prerequisites +rustup target add wasm32-unknown-unknown +cargo install wasm-pack + +# Build +git clone https://github.com/Devolutions/IronRDP.git /tmp/ironrdp +cd /tmp/ironrdp/crates/ironrdp-web +RUSTFLAGS="-Ctarget-feature=+simd128,+bulk-memory --cfg getrandom_backend=\"wasm_js\"" \ + wasm-pack build --target web + +# Deploy to gateway +cp pkg/ironrdp_web_bg.wasm /public/wasm/ +cp pkg/ironrdp_web.js /public/wasm/ +``` + +### Key Files + +| File | Purpose | +|------|---------| +| `src/rdcleanpath/der-codec.ts` | Minimal ASN.1 DER encoder/decoder | +| `src/rdcleanpath/rdcleanpath.ts` | RDCleanPath PDU parse/serialize | +| `src/rdcleanpath/rdcleanpath-handler.ts` | Session state machine (AWAITING_REQUEST → CONNECTING → RELAY → CLOSED) | +| `src/webrtc/peer-handler.ts` | Intercepts `/ws/rdcleanpath` WebSocket opens on DataChannel | +| `public/js/rdp-client.js` | Browser-side: signaling, DCWebSocket shim, IronRDP WASM integration, canvas rendering | +| `public/rdp.html` | RDP connect form + fullscreen canvas | +| `public/wasm/ironrdp_web.js` | IronRDP WASM JavaScript bindings | +| `public/wasm/ironrdp_web_bg.wasm` | IronRDP WASM binary | + ## Authentication Flow ### Gateway OIDC Login (through relay) @@ -141,7 +273,7 @@ The gateway also handles an `/_idp/` prefix: TideCloak URLs are rewritten to `{p **Token validation:** 1. `gateway_access` cookie (HttpOnly, `Lax`) 2. `Authorization: Bearer ` header -3. If expired, transparent refresh via `gateway_refresh` cookie (`Strict`) +3. If expired, transparent refresh via `gateway_refresh` cookie (`Strict`). Refresh is deduplicated per refresh token — concurrent requests sharing the same token reuse a single in-flight refresh call, and results are cached for 60 seconds (bounded to 100 entries). 4. Proxied requests include `x-forwarded-user` header with the subject claim ### Portal/Admin TideCloak Auth @@ -182,6 +314,7 @@ 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. +- **Public resources**: Certain browser-initiated resource requests (`manifest.json`, `*.webmanifest`, `browserconfig.xml`, `robots.txt`, `*.ico`) are always proxied without auth, regardless of backend auth settings. These are fetched by the browser before any session is established and would otherwise return 401. ### Path Prefix System @@ -222,20 +355,20 @@ STUN/TURN protocol handling is delegated to [coturn](https://github.com/coturn/c - 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 +- Ephemeral credential validation via HMAC-SHA1 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) +### TURN REST API Credentials (HMAC-SHA1) 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)) +password = Base64(HMAC-SHA1(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. +coturn validates by recomputing the HMAC-SHA1 and checking the expiry timestamp. This uses coturn's default `--use-auth-secret` mode (HMAC-SHA1). ### STUN Message Format (RFC 5389) @@ -295,17 +428,28 @@ Both servers set on every response: The signal server sets `X-Frame-Options: DENY`. The gateway sets `X-Frame-Options: SAMEORIGIN` (Tide SDK enclave uses iframes). +**CSP is intentionally not set** on proxied backend responses. The gateway proxies third-party backend HTML and injects scripts (fetch/XHR patch, WebRTC upgrade); a nonce-based CSP would break the backend's own inline scripts, and `unsafe-inline` provides no real protection since the whole point is injecting scripts. Backends should set their own CSP in their responses. + ### 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. +### CORS + +The signal server validates the `Origin` header on every request. If `ALLOWED_ORIGINS` is set, only listed origins receive CORS headers. If unset, only same-origin requests (where `Origin` matches the `Host` header) are allowed. Credentials (`Access-Control-Allow-Credentials: true`) are only sent with validated origins. + +### IP Trust + +The signal server only reads `X-Forwarded-For` when the direct socket IP is in the `TRUSTED_PROXIES` set. When no trusted proxies are configured, the socket address is always used for rate limiting and logging. + ### 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. +- **TURN_SECRET:** Shared secret for HMAC-SHA1 ephemeral TURN credentials. - **Gateway ID:** Generated with `crypto.randomBytes(8).toString("hex")` (16 hex chars) for cryptographic uniqueness. +- **Client ID:** Generated with `crypto.randomUUID()` (cryptographically random) to prevent guessing or collision. ### Rate Limiting & Capacity @@ -320,6 +464,7 @@ The signal server sets `X-Frame-Options: DENY`. The gateway sets `X-Frame-Option | WebRTC peer connections per gateway | 200 | New offers rejected | | Admin WebSocket subscribers | 50 | Subscription rejected | | TC cookie jar sessions | 10,000 | LRU eviction | +| `/auth/session-token` requests per IP | 6 per minute | HTTP 429 | ### Proxy Timeouts @@ -375,6 +520,11 @@ See [turnserver.conf](../signal-server/turnserver.conf) for the full configurati | `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 | +| `ALLOWED_ORIGINS` | (none) | Comma-separated origins for CORS (`null` = same-origin only) | +| `TRUSTED_PROXIES` | (none) | Comma-separated proxy IPs trusted for `X-Forwarded-For` | +| `ICE_SERVERS` | (none) | STUN server URL for gateway WebRTC config, e.g. `stun:host:3478` | +| `TURN_SERVER` | (none) | TURN server URL for gateway WebRTC config, e.g. `turn:host:3478` | +| `TURN_SECRET` | (none) | Shared secret for TURN ephemeral credentials (HMAC-SHA1) | ### Gateway @@ -390,7 +540,7 @@ See [turnserver.conf](../signal-server/turnserver.conf) for the full configurati | `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) | +| `TURN_SECRET` | (none) | Shared secret for TURN credentials (HMAC-SHA1) | | `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) | @@ -470,7 +620,7 @@ The signal server and gateway can be run by **different operators**. The signal | 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) | +| `TURN_SECRET` | Signal server operator | Gateway operators | Generates ephemeral TURN relay credentials (HMAC-SHA1) | **Typical flow:** 1. Signal server operator deploys → secrets auto-generated by `signal-server/deploy.sh` diff --git a/bridges/punchd-bridge/docs/diagrams/01-system-overview.puml b/bridges/punchd-bridge/docs/diagrams/01-system-overview.puml index e41b188..7628cc3 100644 --- a/bridges/punchd-bridge/docs/diagrams/01-system-overview.puml +++ b/bridges/punchd-bridge/docs/diagrams/01-system-overview.puml @@ -1,7 +1,7 @@ @startuml system-overview !theme plain !pragma layout smetana -title Punc'd — System Overview +title Punch'd — System Overview skinparam { backgroundColor #0f172a @@ -50,6 +50,7 @@ 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 + component "RDP Server\n(port 3389)" as rdpsrv } cloud "TideCloak" as tc { @@ -65,8 +66,9 @@ 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 -down-> app1 : "HTTP Proxy" +gw -down-> app2 : "HTTP Proxy" +gw -down-> rdpsrv : "RDCleanPath\n(TCP+TLS)" gw -left-> oidc : "OIDC\ncode exchange" browser -right-> oidc : "Login\n(via Gateway proxy)" diff --git a/bridges/punchd-bridge/docs/diagrams/05-hole-punching.puml b/bridges/punchd-bridge/docs/diagrams/05-hole-punching.puml index a103228..799658a 100644 --- a/bridges/punchd-bridge/docs/diagrams/05-hole-punching.puml +++ b/bridges/punchd-bridge/docs/diagrams/05-hole-punching.puml @@ -36,9 +36,9 @@ browser -> sig : GET /webrtc-config (via relay) sig --> browser : { stunServer, turnServer,\n turnUsername, turnPassword } note over sig - Credentials use HMAC-SHA256: + Credentials use HMAC-SHA1: user = unix_expiry - pass = Base64(HMAC-SHA256(secret, user)) + pass = Base64(HMAC-SHA1(secret, user)) end note == 2. SDP Offer/Answer Exchange == diff --git a/bridges/punchd-bridge/docs/diagrams/06-turn-fallback.puml b/bridges/punchd-bridge/docs/diagrams/06-turn-fallback.puml index eb6b4da..10a8029 100644 --- a/bridges/punchd-bridge/docs/diagrams/06-turn-fallback.puml +++ b/bridges/punchd-bridge/docs/diagrams/06-turn-fallback.puml @@ -43,7 +43,7 @@ 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 -> stun : Validate:\n1. timestamp > now? (not expired)\n2. password = HMAC-SHA1(secret, username)\n3. key = MD5(user:realm:pass)\n4. verify MESSAGE-INTEGRITY stun -> relay : Bind UDP socket\non random port 49152-65535 activate relay @@ -69,7 +69,7 @@ sig -> gw : forward candidate note over browser, gw Gateway also gets TURN credentials - (HMAC-SHA256) and may allocate its own + (HMAC-SHA1) and may allocate its own relay, or connect directly to the browser's relay address. end note diff --git a/bridges/punchd-bridge/docs/diagrams/10-rdp-rdcleanpath.puml b/bridges/punchd-bridge/docs/diagrams/10-rdp-rdcleanpath.puml new file mode 100644 index 0000000..725addf --- /dev/null +++ b/bridges/punchd-bridge/docs/diagrams/10-rdp-rdcleanpath.puml @@ -0,0 +1,106 @@ +@startuml rdp-rdcleanpath +!theme plain +title RDP via IronRDP WASM + RDCleanPath +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 "IronRDP\nWASM" as wasm #1e293b +participant "DCWebSocket\nShim" as shim #1e293b +participant "DataChannel\n(control+bulk)" as dc #1e293b +participant "Gateway\n(peer handler)" as gw #1e293b +participant "RDCleanPath\nHandler" as rdc #1e293b +participant "RDP Server\n(port 3389)" as rdp #1e293b + +== Phase 1: WebSocket Open (via DataChannel) == + +wasm -> shim : new WebSocket(\n "wss://host/ws/rdcleanpath") +shim -> shim : Intercept same-origin WS\n(override window.WebSocket) +shim -> dc : control: ws_open\n{ id: uuid, path: "/ws/rdcleanpath" } +dc -> gw : DataChannel message +gw -> gw : Path is /ws/rdcleanpath\n→ create virtual WS session +gw -> rdc : createRDCleanPathSession() +gw -> dc : control: ws_open_ack { id: uuid } +dc -> shim : onmessage +shim -> wasm : WebSocket.onopen() + +== Phase 2: RDCleanPath Handshake == + +wasm -> wasm : Build RDCleanPath Request\n(ASN.1 DER encoded) +wasm -> shim : ws.send(requestPdu) +shim -> dc : bulk: [0x02][uuid][pdu bytes] +dc -> gw : binary fast-path +gw -> rdc : handleMessage(pdu) +activate rdc + +rdc -> rdc : Parse ASN.1 DER:\n version: 3390\n destination: "My PC"\n proxyAuth: JWT\n x224ConnectionPdu: bytes + +rdc -> rdc : Validate JWT\n+ enforce dest: role + +rdc -> rdc : Resolve backend name\n→ rdp://host:3389 + +rdc -> rdp : TCP connect +rdc -> rdp : Send X.224\nConnection Request +rdp --> rdc : X.224 Connection\nConfirm (TPKT) + +rdc -> rdp : TLS handshake\n(rejectUnauthorized: false) +rdp --> rdc : TLS established +rdc -> rdc : Extract server cert chain\n(DER X.509, leaf first) + +rdc -> rdc : Build RDCleanPath Response\n(ASN.1 DER):\n version: 3390\n x224Confirm\n serverCertChain\n serverAddr + +rdc -> gw : sendBinary(responsePdu) +deactivate rdc +gw -> dc : bulk: [0x02][uuid][response] +dc -> shim : onmessage +shim -> wasm : ws.onmessage(responsePdu) + +wasm -> wasm : Verify server cert\nvia CredSSP/NLA\n(username + password) + +== Phase 3: Relay (bidirectional pipe) == + +note over wasm, rdp #334155 + All subsequent traffic is relayed bidirectionally: + IronRDP WASM ↔ DCWebSocket ↔ DataChannel ↔ Gateway ↔ TLS socket ↔ RDP Server + Binary WS fast-path (0x02 magic byte) on bulk DataChannel + for low-overhead streaming. Gateway handles TLS termination. +end note + +wasm -> shim : ws.send(rdpData) +shim -> dc : bulk: [0x02][uuid][data] +dc -> gw : binary fast-path +gw -> rdc : handleMessage(data) +rdc -> rdp : tlsSocket.write(data) + +rdp --> rdc : TLS data (graphics,\ncursor, audio) +rdc -> gw : sendBinary(data) +gw -> dc : bulk: [0x02][uuid][data] +dc -> shim : onmessage +shim -> wasm : ws.onmessage(data) + +wasm -> wasm : Decode RDP graphics\n→ render to + +note over wasm #334155 + Input: mouse/keyboard events on canvas + → IronRDP InputTransaction + → sent as RDP PDUs via relay +end note + +@enduml diff --git a/bridges/punchd-bridge/docs/diagrams/complete-lifecycle.svg b/bridges/punchd-bridge/docs/diagrams/complete-lifecycle.svg index 75b50b5..9c24af7 100644 --- a/bridges/punchd-bridge/docs/diagrams/complete-lifecycle.svg +++ b/bridges/punchd-bridge/docs/diagrams/complete-lifecycle.svg @@ -1 +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 +Complete Connection Lifecycle — Portal to P2PComplete 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 index 38370e6..243fac9 100644 --- a/bridges/punchd-bridge/docs/diagrams/gateway-registration.svg +++ b/bridges/punchd-bridge/docs/diagrams/gateway-registration.svg @@ -1 +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 +Gateway Registration & Client PairingGateway 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 index 8e2517a..c1c1a35 100644 --- a/bridges/punchd-bridge/docs/diagrams/hole-punching.svg +++ b/bridges/punchd-bridge/docs/diagrams/hole-punching.svg @@ -1 +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 +NAT Traversal — Hole Punching via STUN + ICENAT 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-SHA1:user = unix_expirypass = Base64(HMAC-SHA1(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 index 74d3769..59802c6 100644 --- a/bridges/punchd-bridge/docs/diagrams/http-relay.svg +++ b/bridges/punchd-bridge/docs/diagrams/http-relay.svg @@ -1 +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 +HTTP Relay — Request Tunneling via WebSocketHTTP 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 index 766705a..2c3dc2b 100644 --- a/bridges/punchd-bridge/docs/diagrams/multi-backend-routing.svg +++ b/bridges/punchd-bridge/docs/diagrams/multi-backend-routing.svg @@ -1 +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 +Multi-Backend Path-Based RoutingMulti-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 index d320632..a791685 100644 --- a/bridges/punchd-bridge/docs/diagrams/oidc-login.svg +++ b/bridges/punchd-bridge/docs/diagrams/oidc-login.svg @@ -1 +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 +OIDC Login Flow (through HTTP Relay)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/rdp-rdcleanpath.svg b/bridges/punchd-bridge/docs/diagrams/rdp-rdcleanpath.svg new file mode 100644 index 0000000..859645f --- /dev/null +++ b/bridges/punchd-bridge/docs/diagrams/rdp-rdcleanpath.svg @@ -0,0 +1 @@ +RDP via IronRDP WASM + RDCleanPathRDP via IronRDP WASM + RDCleanPathIronRDPWASMIronRDPWASMDCWebSocketShimDCWebSocketShimDataChannel(control+bulk)DataChannel(control+bulk)Gateway(peer handler)Gateway(peer handler)RDCleanPathHandlerRDCleanPathHandlerRDP Server(port 3389)RDP Server(port 3389)Phase 1: WebSocket Open (via DataChannel)new WebSocket("wss://host/ws/rdcleanpath")Intercept same-origin WS(override window.WebSocket)control: ws_open{ id: uuid, path: "/ws/rdcleanpath" }DataChannel messagePath is /ws/rdcleanpath→ create virtual WS sessioncreateRDCleanPathSession()control: ws_open_ack { id: uuid }onmessageWebSocket.onopen()Phase 2: RDCleanPath HandshakeBuild RDCleanPath Request(ASN.1 DER encoded)ws.send(requestPdu)bulk: [0x02][uuid][pdu bytes]binary fast-pathhandleMessage(pdu)Parse ASN.1 DER:version: 3390destination: "My PC"proxyAuth: JWTx224ConnectionPdu: bytesValidate JWT+ enforce dest: roleResolve backend name→ rdp://host:3389TCP connectSend X.224Connection RequestX.224 ConnectionConfirm (TPKT)TLS handshake(rejectUnauthorized: false)TLS establishedExtract server cert chain(DER X.509, leaf first)Build RDCleanPath Response(ASN.1 DER):version: 3390x224ConfirmserverCertChainserverAddrsendBinary(responsePdu)bulk: [0x02][uuid][response]onmessagews.onmessage(responsePdu)Verify server certvia CredSSP/NLA(username + password)Phase 3: Relay (bidirectional pipe)All subsequent traffic is relayed bidirectionally:IronRDP WASM ↔ DCWebSocket ↔ DataChannel ↔ Gateway ↔ TLS socket ↔ RDP ServerBinary WS fast-path (0x02 magic byte) on bulk DataChannelfor low-overhead streaming. Gateway handles TLS termination.ws.send(rdpData)bulk: [0x02][uuid][data]binary fast-pathhandleMessage(data)tlsSocket.write(data)TLS data (graphics,cursor, audio)sendBinary(data)bulk: [0x02][uuid][data]onmessagews.onmessage(data)Decode RDP graphics→ render to <canvas>Input: mouse/keyboard events on canvas→ IronRDP InputTransaction→ sent as RDP PDUs via relay \ 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 index 59e3a5b..80755ee 100644 --- a/bridges/punchd-bridge/docs/diagrams/service-worker-datachannel.svg +++ b/bridges/punchd-bridge/docs/diagrams/service-worker-datachannel.svg @@ -1 +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 +Service Worker DataChannel TunnelingService 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 index 2ee13c3..ff2177b 100644 --- a/bridges/punchd-bridge/docs/diagrams/system-overview.svg +++ b/bridges/punchd-bridge/docs/diagrams/system-overview.svg @@ -1 +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 +Punch'd — System OverviewPunch'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)RDP Server(port 3389)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)HTTP ProxyHTTP ProxyRDCleanPath(TCP+TLS)OIDCcode 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 index 538ac04..5a77148 100644 --- a/bridges/punchd-bridge/docs/diagrams/turn-fallback.svg +++ b/bridges/punchd-bridge/docs/diagrams/turn-fallback.svg @@ -1 +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 +TURN Relay Fallback (when P2P fails)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-SHA1(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-SHA1) 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/public/js/rdp-client.js b/bridges/punchd-bridge/gateway/public/js/rdp-client.js new file mode 100644 index 0000000..d36eaca --- /dev/null +++ b/bridges/punchd-bridge/gateway/public/js/rdp-client.js @@ -0,0 +1,887 @@ +/** + * RDP client over DataChannel with IronRDP WASM. + * + * Establishes a WebRTC DataChannel to the punchd gateway, installs a + * DCWebSocket shim so IronRDP WASM's internal WebSocket connection is + * transparently tunneled over the DataChannel, and uses the gateway's + * RDCleanPath handler for TLS negotiation with the RDP server. + * + * Flow: IronRDP WASM → new WebSocket("/ws/rdcleanpath") + * → DCWebSocket shim → ws_open over DataChannel + * → gateway peer-handler → RDCleanPath virtual WS + * → TCP + TLS to RDP server → bidirectional relay + */ + +(function () { + "use strict"; + + // ── Constants ───────────────────────────────────────────────── + + var CONFIG_ENDPOINT = "/webrtc-config"; + var SESSION_TOKEN_ENDPOINT = "/auth/session-token"; + var LOGIN_ENDPOINT = "/auth/login"; + var BINARY_WS_MAGIC = 0x02; + var RECONNECT_DELAY = 5000; + var MAX_RECONNECT_DELAY = 60000; + + // Save native WebSocket before shim (signaling uses it directly) + var NativeWebSocket = window.WebSocket; + + // ── DOM elements ────────────────────────────────────────────── + + var statusBar = document.getElementById("status-bar"); + var statusDot = document.getElementById("status-dot"); + var statusText = document.getElementById("status-text"); + var disconnectBtn = document.getElementById("disconnect-btn"); + var connectForm = document.getElementById("connect-form"); + var connectBtn = document.getElementById("connect-btn"); + var formError = document.getElementById("form-error"); + var rdpCanvas = document.getElementById("rdp-canvas"); + var usernameInput = document.getElementById("rdp-username"); + var passwordInput = document.getElementById("rdp-password"); + + // ── State ───────────────────────────────────────────────────── + + var config = null; + var sessionToken = null; + var signalingWs = null; + var peerConnection = null; + var controlChannel = null; + var bulkChannel = null; + var clientId = "rdp-" + crypto.randomUUID().replace(/-/g, "").slice(0, 8); + var pairedGatewayId = null; + var reconnectAttempts = 0; + var reconnectTimer = null; + var backendName = null; + var bulkEnabled = false; + var rdpSession = null; + + // ── DCWebSocket shim ────────────────────────────────────────── + // + // Intercepts same-origin WebSocket connections and routes them + // through the DataChannel using the ws_open/ws_message/ws_close + // protocol and binary WS fast-path (0x02 magic byte). + + var dcWebSockets = new Map(); + + function sendBinaryWsFrame(wsId, payload) { + if (!bulkChannel || bulkChannel.readyState !== "open") return; + var idBytes = new TextEncoder().encode(wsId); + var frame = new Uint8Array(1 + 36 + payload.length); + frame[0] = BINARY_WS_MAGIC; + frame.set(idBytes, 1); + frame.set(payload, 37); + bulkChannel.send(frame); + } + + function DCWebSocket(url, protocols) { + this._id = crypto.randomUUID(); + this._listeners = {}; + this.readyState = 0; // CONNECTING + this.protocol = ""; + this.extensions = ""; + this.bufferedAmount = 0; + this.binaryType = "arraybuffer"; + this.onopen = null; + this.onmessage = null; + this.onclose = null; + this.onerror = null; + + var parsed = new URL(url, window.location.origin); + this.url = parsed.href; + var wsPath = parsed.pathname + parsed.search; + + dcWebSockets.set(this._id, this); + + var headers = {}; + if (sessionToken) { + headers.cookie = "gateway_access=" + sessionToken; + } + + console.log("[RDP] DCWebSocket sending ws_open:", wsPath, "id:", this._id); + controlChannel.send(JSON.stringify({ + type: "ws_open", + id: this._id, + url: wsPath, + protocols: Array.isArray(protocols) ? protocols : protocols ? [protocols] : [], + headers: headers, + })); + } + + DCWebSocket.prototype.addEventListener = function (type, fn) { + if (!this._listeners[type]) this._listeners[type] = []; + if (this._listeners[type].indexOf(fn) === -1) this._listeners[type].push(fn); + }; + + DCWebSocket.prototype.removeEventListener = function (type, fn) { + if (!this._listeners[type]) return; + this._listeners[type] = this._listeners[type].filter(function (f) { return f !== fn; }); + }; + + DCWebSocket.prototype._dispatch = function (type, event) { + if (typeof this["on" + type] === "function") this["on" + type](event); + var listeners = this._listeners[type]; + if (listeners) listeners.forEach(function (fn) { fn(event); }); + }; + + DCWebSocket.prototype.send = function (data) { + if (this.readyState !== 1) throw new DOMException("WebSocket not open", "InvalidStateError"); + console.log("[RDP] DCWebSocket send, id:", this._id, "type:", typeof data, "isArrayBuffer:", data instanceof ArrayBuffer, "isView:", ArrayBuffer.isView(data), "size:", data.byteLength || data.length || 0); + if (typeof data === "string") { + controlChannel.send(JSON.stringify({ type: "ws_message", id: this._id, data: data, binary: false })); + } else if (bulkEnabled && bulkChannel && bulkChannel.readyState === "open") { + // Binary fast-path via bulk channel + if (data instanceof ArrayBuffer) { + sendBinaryWsFrame(this._id, new Uint8Array(data)); + } else if (ArrayBuffer.isView(data)) { + sendBinaryWsFrame(this._id, new Uint8Array(data.buffer, data.byteOffset, data.byteLength)); + } else if (data instanceof Blob) { + var wsId = this._id; + var ws = this; + data.arrayBuffer().then(function (buf) { + if (ws.readyState !== 1 || !bulkChannel || bulkChannel.readyState !== "open") return; + sendBinaryWsFrame(wsId, new Uint8Array(buf)); + }); + } + } else { + // Fallback: JSON+base64 + var wsId = this._id; + if (data instanceof ArrayBuffer) { + controlChannel.send(JSON.stringify({ type: "ws_message", id: wsId, data: bufToBase64(new Uint8Array(data)), binary: true })); + } else if (ArrayBuffer.isView(data)) { + controlChannel.send(JSON.stringify({ type: "ws_message", id: wsId, data: bufToBase64(new Uint8Array(data.buffer, data.byteOffset, data.byteLength)), binary: true })); + } else if (data instanceof Blob) { + data.arrayBuffer().then(function (buf) { + controlChannel.send(JSON.stringify({ type: "ws_message", id: wsId, data: bufToBase64(new Uint8Array(buf)), binary: true })); + }); + } + } + }; + + DCWebSocket.prototype.close = function (code, reason) { + if (this.readyState >= 2) return; + this.readyState = 2; // CLOSING + if (controlChannel && controlChannel.readyState === "open") { + controlChannel.send(JSON.stringify({ type: "ws_close", id: this._id, code: code || 1000, reason: reason || "" })); + } + }; + + DCWebSocket.prototype._fireOpen = function (protocol) { + console.log("[RDP] DCWebSocket _fireOpen, id:", this._id); + this.readyState = 1; + this.protocol = protocol || ""; + this._dispatch("open", new Event("open")); + }; + + DCWebSocket.prototype._fireMessage = function (data, binary) { + var payload; + if (binary) { + var raw = atob(data); + var bytes = new Uint8Array(raw.length); + for (var 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 })); + }; + + DCWebSocket.prototype._fireMessageBinary = function (arrayBuffer) { + console.log("[RDP] DCWebSocket _fireMessageBinary, id:", this._id, "bytes:", arrayBuffer.byteLength); + var payload = this.binaryType === "arraybuffer" ? arrayBuffer : new Blob([arrayBuffer]); + this._dispatch("message", new MessageEvent("message", { data: payload })); + }; + + DCWebSocket.prototype._fireClose = function (code, reason) { + console.log("[RDP] DCWebSocket _fireClose, id:", this._id, "code:", code, "reason:", 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 })); + }; + + DCWebSocket.prototype._fireError = function (message) { + console.error("[RDP] DCWebSocket _fireError, id:", this._id, "message:", message); + dcWebSockets.delete(this._id); + this._dispatch("error", new Event("error")); + this._fireClose(1006, message || "Connection failed"); + }; + + function bufToBase64(bytes) { + var binary = ""; + for (var i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]); + return btoa(binary); + } + + function installWebSocketShim() { + window.WebSocket = function (url, protocols) { + var parsed = new URL(url, window.location.origin); + // Compare hostname+port only — wss:// vs https:// have different scheme + // but should be treated as same-origin for our DataChannel tunnel + var parsedPort = parsed.port || (parsed.protocol === "wss:" ? "443" : parsed.protocol === "ws:" ? "80" : ""); + var locPort = window.location.port || (window.location.protocol === "https:" ? "443" : "80"); + var sameHost = parsed.hostname === window.location.hostname && parsedPort === locPort; + if (!sameHost || !controlChannel || controlChannel.readyState !== "open") { + return new NativeWebSocket(url, protocols); + } + console.log("[RDP] DCWebSocket shim intercepting:", url); + 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; + console.log("[RDP] WebSocket shim installed"); + } + + // ── Initialization ──────────────────────────────────────────── + + function init() { + var params = new URLSearchParams(location.search); + backendName = (params.get("backend") || "").replace(/\/+$/, ""); + if (!backendName) { + setStatus("error", "No backend specified. Use ?backend="); + return; + } + + setStatus("connecting", "Loading configuration..."); + fetchConfig(); + } + + async function fetchConfig() { + try { + var res = await fetch(CONFIG_ENDPOINT); + if (!res.ok) throw new Error("Config endpoint returned " + res.status); + config = await res.json(); + console.log("[RDP] Config loaded:", config); + await fetchSessionToken(); + } catch (err) { + setStatus("error", "Failed to load config: " + err.message); + } + } + + async function fetchSessionToken() { + try { + var res = await fetch(SESSION_TOKEN_ENDPOINT, { + credentials: "same-origin", + headers: { "X-Requested-With": "XMLHttpRequest" }, + }); + if (res.status === 401) { + location.href = LOGIN_ENDPOINT + "?redirect=" + encodeURIComponent(location.pathname + location.search); + return; + } + if (!res.ok) throw new Error("Session token returned " + res.status); + var data = await res.json(); + sessionToken = data.token || data.access_token; + if (!sessionToken) throw new Error("No token in response"); + console.log("[RDP] Session token acquired"); + connectSignaling(); + } catch (err) { + setStatus("error", "Auth failed: " + err.message); + } + } + + async function refreshSessionToken() { + try { + var res = await fetch(SESSION_TOKEN_ENDPOINT, { + credentials: "same-origin", + headers: { "X-Requested-With": "XMLHttpRequest" }, + }); + if (res.status === 401) { + throw new Error("Session expired — please reload and log in again"); + } + if (!res.ok) throw new Error("Session token returned " + res.status); + var data = await res.json(); + var newToken = data.token || data.access_token; + if (!newToken) throw new Error("No token in response"); + sessionToken = newToken; + console.log("[RDP] Session token refreshed"); + } catch (err) { + throw new Error("Auth refresh failed: " + err.message); + } + } + + // ── Signaling ───────────────────────────────────────────────── + + function connectSignaling() { + if (!config || !config.signalingUrl) { + setStatus("error", "No signaling URL in config"); + return; + } + + setStatus("connecting", "Connecting to signaling server..."); + signalingWs = new NativeWebSocket(config.signalingUrl); + + signalingWs.onopen = function () { + console.log("[RDP] Signaling connected"); + var msg = { + type: "register", + role: "client", + id: clientId, + token: sessionToken, + }; + if (config.targetGatewayId) { + msg.targetGatewayId = config.targetGatewayId; + } + signalingWs.send(JSON.stringify(msg)); + setStatus("connecting", "Waiting for gateway pairing..."); + }; + + signalingWs.onmessage = function (event) { + try { + var msg = JSON.parse(event.data); + handleSignalingMessage(msg); + } catch (e) { + // ignore + } + }; + + signalingWs.onclose = function () { + console.log("[RDP] Signaling disconnected"); + cleanup(); + scheduleReconnect(); + }; + + signalingWs.onerror = function () { + console.log("[RDP] Signaling error"); + }; + } + + function handleSignalingMessage(msg) { + switch (msg.type) { + case "registered": + console.log("[RDP] Registered as client:", msg.id); + break; + + case "paired": + pairedGatewayId = msg.gateway && msg.gateway.id; + console.log("[RDP] Paired with gateway:", pairedGatewayId); + startWebRTC(); + break; + + case "sdp_answer": + if (peerConnection && msg.sdp) { + peerConnection + .setRemoteDescription(new RTCSessionDescription({ type: msg.sdpType || "answer", sdp: msg.sdp })) + .catch(function (err) { console.error("[RDP] setRemoteDescription error:", err); }); + } + break; + + case "candidate": + if (peerConnection && msg.candidate) { + peerConnection + .addIceCandidate(new RTCIceCandidate({ candidate: msg.candidate.candidate, sdpMid: msg.candidate.mid })) + .catch(function (err) { console.error("[RDP] addIceCandidate error:", err); }); + } + break; + + case "error": + console.error("[RDP] Signaling error:", msg.message); + if (msg.message && msg.message.indexOf("No gateway") !== -1) { + setStatus("error", "No gateway available. Retrying..."); + scheduleReconnect(); + } + break; + } + } + + // ── WebRTC ──────────────────────────────────────────────────── + + function startWebRTC() { + if (!pairedGatewayId) return; + + setStatus("connecting", "Establishing P2P connection..."); + + var iceServers = []; + if (config.stunServer) { + iceServers.push({ urls: config.stunServer }); + } + if (config.turnServer && config.turnUsername && config.turnPassword) { + iceServers.push({ + urls: config.turnServer, + username: config.turnUsername, + credential: config.turnPassword, + }); + } + + peerConnection = new RTCPeerConnection({ iceServers: iceServers }); + + controlChannel = peerConnection.createDataChannel("http-tunnel", { ordered: true }); + bulkChannel = peerConnection.createDataChannel("bulk-data", { ordered: true }); + bulkChannel.binaryType = "arraybuffer"; + + setupControlChannel(); + setupBulkChannel(); + + peerConnection.onicecandidate = function (event) { + if (event.candidate && signalingWs && signalingWs.readyState === NativeWebSocket.OPEN) { + signalingWs.send(JSON.stringify({ + type: "candidate", + fromId: clientId, + targetId: pairedGatewayId, + candidate: { candidate: event.candidate.candidate, mid: event.candidate.sdpMid }, + })); + } + }; + + peerConnection.onconnectionstatechange = function () { + var state = peerConnection.connectionState; + console.log("[RDP] Connection state:", state); + if (state === "failed" || state === "disconnected") { + cleanup(); + scheduleReconnect(); + } + }; + + peerConnection.createOffer() + .then(function (offer) { return peerConnection.setLocalDescription(offer); }) + .then(function () { + signalingWs.send(JSON.stringify({ + type: "sdp_offer", + fromId: clientId, + targetId: pairedGatewayId, + sdp: peerConnection.localDescription.sdp, + sdpType: peerConnection.localDescription.type, + })); + console.log("[RDP] SDP offer sent"); + }) + .catch(function (err) { + console.error("[RDP] Offer creation failed:", err); + setStatus("error", "WebRTC offer failed"); + }); + } + + function setupControlChannel() { + controlChannel.binaryType = "arraybuffer"; + + controlChannel.onopen = function () { + console.log("[RDP] Control channel open"); + controlChannel.send(JSON.stringify({ + type: "capabilities", + version: 2, + features: ["bulk-channel", "binary-ws", "tcp-tunnel"], + })); + // Install WebSocket shim now that the DataChannel is open + installWebSocketShim(); + setStatus("connecting", "DataChannel open, ready to connect..."); + showConnectForm(); + }; + + controlChannel.onmessage = function (event) { + try { + var data = event.data; + if (data instanceof ArrayBuffer) { + data = new TextDecoder().decode(data); + } + var msg = JSON.parse(data); + handleControlMessage(msg); + } catch (e) { + // ignore + } + }; + + controlChannel.onclose = function () { + console.log("[RDP] Control channel closed"); + closeAllDcWebSockets(); + }; + } + + function setupBulkChannel() { + bulkChannel.onopen = function () { + console.log("[RDP] Bulk channel open"); + }; + + bulkChannel.onmessage = function (event) { + var buf = new Uint8Array(event.data); + if (buf.length < 37) return; + + // Binary WS fast-path: [0x02][36-byte WS UUID][payload] + if (buf[0] === BINARY_WS_MAGIC) { + var wsId = new TextDecoder().decode(buf.subarray(1, 37)); + var ws = dcWebSockets.get(wsId); + if (ws) { + // IMPORTANT: slice() (not subarray) creates a NEW ArrayBuffer copy. + // subarray().buffer returns the FULL original buffer including the header. + ws._fireMessageBinary(buf.slice(37).buffer); + } + } + }; + + bulkChannel.onclose = function () { + console.log("[RDP] Bulk channel closed"); + }; + } + + function handleControlMessage(msg) { + switch (msg.type) { + case "capabilities": + console.log("[RDP] Gateway capabilities:", msg.features); + if (msg.features && msg.features.indexOf("binary-ws") !== -1) { + bulkEnabled = true; + } + break; + + case "ws_opened": { + var ws = dcWebSockets.get(msg.id); + if (ws) ws._fireOpen(msg.protocol); + break; + } + + case "ws_message": { + var ws = dcWebSockets.get(msg.id); + if (ws) ws._fireMessage(msg.data, msg.binary); + break; + } + + case "ws_close": { + var ws = dcWebSockets.get(msg.id); + if (ws) ws._fireClose(msg.code, msg.reason); + break; + } + + case "ws_error": { + var ws = dcWebSockets.get(msg.id); + if (ws) ws._fireError(msg.message); + break; + } + } + } + + function closeAllDcWebSockets() { + dcWebSockets.forEach(function (ws) { + try { ws._fireClose(1006, "DataChannel closed"); } catch (e) {} + }); + dcWebSockets.clear(); + } + + // ── UI ──────────────────────────────────────────────────────── + + function setStatus(state, text) { + statusDot.className = "dot " + state; + statusText.textContent = text; + statusBar.classList.remove("connected"); + } + + function showConnectForm() { + connectForm.classList.remove("hidden"); + rdpCanvas.classList.add("hidden"); + connectBtn.disabled = false; + formError.textContent = ""; + } + + function hideConnectForm() { + connectForm.classList.add("hidden"); + rdpCanvas.classList.remove("hidden"); + } + + // ── IronRDP WASM Integration ────────────────────────────────── + + var wasmModule = null; + + async function loadWasm() { + if (wasmModule) return wasmModule; + try { + wasmModule = await import("/wasm/ironrdp_web.js"); + await wasmModule.default("/wasm/ironrdp_web_bg.wasm"); + wasmModule.setup("info"); + console.log("[RDP] IronRDP WASM loaded"); + return wasmModule; + } catch (err) { + console.error("[RDP] Failed to load IronRDP WASM:", err); + throw err; + } + } + + async function startRdpSession(username, password) { + hideConnectForm(); + setStatus("connecting", "Loading IronRDP WASM..."); + + try { + var wasm = await loadWasm(); + + // Refresh session token right before connecting (tokens expire after 5 min) + setStatus("connecting", "Refreshing auth token..."); + await refreshSessionToken(); + + setStatus("connecting", "Connecting to " + backendName + "..."); + + var proxyAddress = "wss://" + location.host + "/ws/rdcleanpath"; + console.log("[RDP] Proxy address:", proxyAddress); + console.log("[RDP] Destination:", backendName); + + // Use device pixels for crisp rendering on HiDPI/Retina displays + var dpr = window.devicePixelRatio || 1; + var canvasWidth = Math.floor(window.innerWidth * dpr); + var canvasHeight = Math.floor(window.innerHeight * dpr); + rdpCanvas.width = canvasWidth; + rdpCanvas.height = canvasHeight; + console.log("[RDP] Canvas size:", canvasWidth, "x", canvasHeight, "dpr:", dpr); + + var builder = new wasm.SessionBuilder(); + builder = builder.username(username); + builder = builder.password(password); + builder = builder.destination(backendName); + builder = builder.proxyAddress(proxyAddress); + builder = builder.authToken(sessionToken); + builder = builder.renderCanvas(rdpCanvas); + builder = builder.desktopSize(new wasm.DesktopSize(canvasWidth, canvasHeight)); + builder = builder.setCursorStyleCallback(function (kind, data, hotspotX, hotspotY) { + if (kind === "default") { + rdpCanvas.style.cursor = "default"; + } else if (kind === "hidden") { + rdpCanvas.style.cursor = "none"; + } else if (kind === "url" && data) { + rdpCanvas.style.cursor = "url(" + data + ") " + (hotspotX || 0) + " " + (hotspotY || 0) + ", auto"; + } + }); + builder = builder.setCursorStyleCallbackContext(window); + + console.log("[RDP] Connecting via RDCleanPath..."); + rdpSession = await builder.connect(); + + setStatus("connected", "Connected to " + backendName); + statusBar.classList.add("connected"); + disconnectBtn.classList.remove("hidden"); + console.log("[RDP] RDP session connected"); + + // Set up input handling + setupInputHandlers(); + + // Run the session (blocks until session ends) + var termInfo = await rdpSession.run(); + console.log("[RDP] Session ended:", termInfo ? termInfo.reason() : "unknown"); + + rdpSession = null; + setStatus("error", "Session ended"); + showConnectForm(); + } catch (err) { + var errMsg = ""; + if (err && typeof err.backtrace === "function") { + // IronError from WASM + var kind = err.kind !== undefined ? err.kind() : "unknown"; + var bt = err.backtrace(); + console.error("[RDP] IronError kind:", kind, "backtrace:", bt); + var details = typeof err.rdcleanpathDetails === "function" ? err.rdcleanpathDetails() : undefined; + if (details) { + console.error("[RDP] RDCleanPath details - HTTP:", details.httpStatusCode, "TLS:", details.tlsAlertCode, "WSA:", details.wsaErrorCode); + } + errMsg = "IronRDP error (kind " + kind + "): " + bt.split("\n")[0]; + } else { + errMsg = err.message || String(err); + } + console.error("[RDP] RDP session error:", errMsg, err); + rdpSession = null; + setStatus("error", "Connection failed: " + errMsg); + showConnectForm(); + } + } + + // ── Input Handling ──────────────────────────────────────────── + + function setupInputHandlers() { + if (!wasmModule || !rdpSession) return; + + var DeviceEvent = wasmModule.DeviceEvent; + var InputTransaction = wasmModule.InputTransaction; + if (!DeviceEvent || !InputTransaction) { + console.warn("[RDP] DeviceEvent/InputTransaction not available in WASM module"); + return; + } + + rdpCanvas.addEventListener("mousemove", function (e) { + if (!rdpSession) return; + try { + var rect = rdpCanvas.getBoundingClientRect(); + var scaleX = rdpCanvas.width / rect.width; + var scaleY = rdpCanvas.height / rect.height; + var x = Math.round((e.clientX - rect.left) * scaleX); + var y = Math.round((e.clientY - rect.top) * scaleY); + var tx = new InputTransaction(); + tx.addEvent(DeviceEvent.mouseMove(x, y)); + rdpSession.applyInputs(tx); + } catch (err) { /* ignore input errors */ } + }); + + rdpCanvas.addEventListener("mousedown", function (e) { + if (!rdpSession) return; + e.preventDefault(); + try { + var tx = new InputTransaction(); + tx.addEvent(DeviceEvent.mouseButtonPressed(e.button)); + rdpSession.applyInputs(tx); + } catch (err) { /* ignore */ } + }); + + rdpCanvas.addEventListener("mouseup", function (e) { + if (!rdpSession) return; + try { + var tx = new InputTransaction(); + tx.addEvent(DeviceEvent.mouseButtonReleased(e.button)); + rdpSession.applyInputs(tx); + } catch (err) { /* ignore */ } + }); + + rdpCanvas.addEventListener("wheel", function (e) { + if (!rdpSession) return; + e.preventDefault(); + try { + var tx = new InputTransaction(); + // vertical=true, amount, unit=0 (pixel) + tx.addEvent(DeviceEvent.wheelRotations(true, e.deltaY, 0)); + rdpSession.applyInputs(tx); + } catch (err) { /* ignore */ } + }, { passive: false }); + + rdpCanvas.addEventListener("contextmenu", function (e) { + e.preventDefault(); + }); + + document.addEventListener("keydown", function (e) { + if (!rdpSession || !connectForm.classList.contains("hidden")) return; + e.preventDefault(); + try { + var tx = new InputTransaction(); + tx.addEvent(DeviceEvent.keyPressed(browserKeyToScancode(e.code))); + rdpSession.applyInputs(tx); + } catch (err) { /* ignore */ } + }); + + document.addEventListener("keyup", function (e) { + if (!rdpSession || !connectForm.classList.contains("hidden")) return; + e.preventDefault(); + try { + var tx = new InputTransaction(); + tx.addEvent(DeviceEvent.keyReleased(browserKeyToScancode(e.code))); + rdpSession.applyInputs(tx); + } catch (err) { /* ignore */ } + }); + } + + // ── Keyboard Scancode Mapping ───────────────────────────────── + // Maps browser KeyboardEvent.code to USB HID scancode values + // used by IronRDP's DeviceEvent.keyPressed/keyReleased. + + var SCANCODE_MAP = { + Escape: 0x01, Digit1: 0x02, Digit2: 0x03, Digit3: 0x04, Digit4: 0x05, + Digit5: 0x06, Digit6: 0x07, Digit7: 0x08, Digit8: 0x09, Digit9: 0x0A, + Digit0: 0x0B, Minus: 0x0C, Equal: 0x0D, Backspace: 0x0E, Tab: 0x0F, + KeyQ: 0x10, KeyW: 0x11, KeyE: 0x12, KeyR: 0x13, KeyT: 0x14, + KeyY: 0x15, KeyU: 0x16, KeyI: 0x17, KeyO: 0x18, KeyP: 0x19, + BracketLeft: 0x1A, BracketRight: 0x1B, Enter: 0x1C, ControlLeft: 0x1D, + KeyA: 0x1E, KeyS: 0x1F, KeyD: 0x20, KeyF: 0x21, KeyG: 0x22, + KeyH: 0x23, KeyJ: 0x24, KeyK: 0x25, KeyL: 0x26, Semicolon: 0x27, + Quote: 0x28, Backquote: 0x29, ShiftLeft: 0x2A, Backslash: 0x2B, + KeyZ: 0x2C, KeyX: 0x2D, KeyC: 0x2E, KeyV: 0x2F, KeyB: 0x30, + KeyN: 0x31, KeyM: 0x32, Comma: 0x33, Period: 0x34, Slash: 0x35, + ShiftRight: 0x36, NumpadMultiply: 0x37, AltLeft: 0x38, Space: 0x39, + CapsLock: 0x3A, F1: 0x3B, F2: 0x3C, F3: 0x3D, F4: 0x3E, + F5: 0x3F, F6: 0x40, F7: 0x41, F8: 0x42, F9: 0x43, F10: 0x44, + NumLock: 0x45, ScrollLock: 0x46, + Numpad7: 0x47, Numpad8: 0x48, Numpad9: 0x49, NumpadSubtract: 0x4A, + Numpad4: 0x4B, Numpad5: 0x4C, Numpad6: 0x4D, NumpadAdd: 0x4E, + Numpad1: 0x4F, Numpad2: 0x50, Numpad3: 0x51, Numpad0: 0x52, + NumpadDecimal: 0x53, F11: 0x57, F12: 0x58, + // Extended keys (set bit 0x100 for extended scancode flag) + NumpadEnter: 0x11C, ControlRight: 0x11D, NumpadDivide: 0x135, + PrintScreen: 0x137, AltRight: 0x138, Home: 0x147, ArrowUp: 0x148, + PageUp: 0x149, ArrowLeft: 0x14B, ArrowRight: 0x14D, End: 0x14F, + ArrowDown: 0x150, PageDown: 0x151, Insert: 0x152, Delete: 0x153, + MetaLeft: 0x15B, MetaRight: 0x15C, ContextMenu: 0x15D, + }; + + function browserKeyToScancode(code) { + return SCANCODE_MAP[code] || 0; + } + + // ── Cleanup / Reconnect ─────────────────────────────────────── + + function cleanup() { + if (rdpSession) { + try { rdpSession.shutdown(); } catch (e) {} + rdpSession = null; + } + closeAllDcWebSockets(); + if (controlChannel) { + try { controlChannel.onclose = null; controlChannel.close(); } catch (e) {} + controlChannel = null; + } + if (bulkChannel) { + try { bulkChannel.onclose = null; bulkChannel.close(); } catch (e) {} + bulkChannel = null; + } + if (peerConnection) { + try { peerConnection.onicecandidate = null; peerConnection.onconnectionstatechange = null; peerConnection.close(); } catch (e) {} + peerConnection = null; + } + pairedGatewayId = null; + bulkEnabled = false; + } + + function scheduleReconnect() { + if (reconnectTimer) return; + var delay = Math.min(RECONNECT_DELAY * Math.pow(1.5, reconnectAttempts), MAX_RECONNECT_DELAY); + reconnectAttempts++; + console.log("[RDP] Reconnecting in " + Math.round(delay / 1000) + "s..."); + reconnectTimer = setTimeout(function () { + reconnectTimer = null; + doReconnect(); + }, delay); + } + + async function doReconnect() { + cleanup(); + if (signalingWs) { + try { signalingWs.onclose = null; signalingWs.close(); } catch (e) {} + signalingWs = null; + } + clientId = "rdp-" + crypto.randomUUID().replace(/-/g, "").slice(0, 8); + await fetchSessionToken(); + } + + // ── Event Handlers ──────────────────────────────────────────── + + connectBtn.addEventListener("click", function () { + var username = usernameInput.value.trim(); + var password = passwordInput.value; + if (!username) { + formError.textContent = "Username is required"; + return; + } + connectBtn.disabled = true; + formError.textContent = ""; + startRdpSession(username, password); + }); + + passwordInput.addEventListener("keydown", function (e) { + if (e.key === "Enter") connectBtn.click(); + }); + + disconnectBtn.addEventListener("click", function () { + if (rdpSession) { + try { rdpSession.shutdown(); } catch (e) {} + rdpSession = null; + } + closeAllDcWebSockets(); + setStatus("connecting", "Disconnected"); + showConnectForm(); + }); + + // Resize canvas with window + window.addEventListener("resize", function () { + if (rdpSession && !rdpCanvas.classList.contains("hidden")) { + try { + var dpr = window.devicePixelRatio || 1; + var w = Math.floor(window.innerWidth * dpr); + var h = Math.floor(window.innerHeight * dpr); + rdpCanvas.width = w; + rdpCanvas.height = h; + rdpSession.resize(w, h); + } catch (e) { /* ignore */ } + } + }); + + // ── Start ───────────────────────────────────────────────────── + + init(); +})(); diff --git a/bridges/punchd-bridge/gateway/public/js/sw.js b/bridges/punchd-bridge/gateway/public/js/sw.js index c4188f9..e73ed5d 100644 --- a/bridges/punchd-bridge/gateway/public/js/sw.js +++ b/bridges/punchd-bridge/gateway/public/js/sw.js @@ -1,10 +1,10 @@ /** * 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. + * Intercepts same-origin requests (including navigations) and routes + * them through the page's WebRTC DataChannel when available. If the + * requesting client has no DC (e.g. new tab), any other tab's active + * DC is used as a proxy. * * The page signals DC readiness via postMessage({ type: "dc_ready" }). * Only clients that have signaled are used for DataChannel routing. @@ -74,8 +74,42 @@ function waitForDc(clientId, timeoutMs) { }); } +/** Find any active DC client for routing (e.g. new tab without its own DC). */ +function findAnyDcClient() { + if (dcClients.size === 0) return Promise.resolve(null); + return self.clients.matchAll({ type: "window" }).then(function (allClients) { + var alive = {}; + allClients.forEach(function (c) { alive[c.id] = true; }); + for (var id of dcClients) { + if (alive[id]) return id; + } + // All DC clients are gone — clean up stale entries + dcClients.clear(); + return null; + }); +} + /** Gateway-internal paths — skip DataChannel, go through relay. */ -var GATEWAY_PATHS = /^\/(js\/|auth\/|login|webrtc-config|_idp\/|realms\/|resources\/|portal|health)/; +var GATEWAY_PATHS = /^\/(js\/|auth\/|login|webrtc-config|rdp|_idp\/|realms\/|resources\/|portal|health)/; + +/** Fetch with retry — retries on network errors and non-ok responses. */ +function fetchWithRetry(request, retries, delay) { + return fetch(request).then(function (resp) { + if (resp.ok || retries <= 0) return resp; + return new Promise(function (resolve) { + setTimeout(function () { + resolve(fetchWithRetry(request, retries - 1, delay)); + }, delay); + }); + }).catch(function (err) { + if (retries <= 0) throw err; + return new Promise(function (resolve, reject) { + setTimeout(function () { + fetchWithRetry(request, retries - 1, delay).then(resolve).catch(reject); + }, delay); + }); + }); +} function extractPrefix(pathname) { var m = pathname.match(/^\/__b\/[^/]+/); @@ -88,33 +122,44 @@ function stripPrefix(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. + // Intercept requests to localhost (any port). Backends (Jellyfin, etc.) + // may construct absolute localhost URLs in API responses (e.g. image URLs + // like http://localhost:8096/Items/{id}/Images/Primary). These bypass + // the patchScript's URL rewriting and would be blocked by CSP img-src 'self'. // 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/")) + (url.hostname === "localhost" || url.hostname === "127.0.0.1") ) { - 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, - })) + (async function () { + var targetPath = url.pathname + url.search; + // Add backend prefix from the requesting client's page URL + if (event.clientId && !targetPath.startsWith("/__b/")) { + try { + var reqClient = await self.clients.get(event.clientId); + if (reqClient) { + var clientPrefix = extractPrefix(new URL(reqClient.url).pathname); + if (clientPrefix && !GATEWAY_PATHS.test(url.pathname)) { + targetPath = clientPrefix + targetPath; + } + } + } catch (e) {} + } + var rewrittenUrl = self.location.origin + targetPath; + console.log("[SW] Rewriting localhost request:", event.request.url, "→", rewrittenUrl); + return 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; } @@ -122,38 +167,68 @@ self.addEventListener("fetch", function (event) { 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 (GATEWAY_PATHS.test(stripPrefix(url.pathname))) { + // Retry chunk/bundle file loading on failure — fast SPA navigation + // can saturate connections, causing transient load failures. + if (/\.(chunk|bundle)\.(js|css)/.test(url.pathname)) { + event.respondWith(fetchWithRetry(event.request, 2, 500)); + } + 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; + // Navigation requests (page loads, reloads) should NEVER wait for DC — + // let them go through relay immediately so the page loads fast. + // DC will be set up in the background and used for subsequent requests. + var isNavigation = event.request.mode === "navigate"; + + // Route through DataChannel if possible. Priority: + // 1. Requesting client has DC → use it immediately + // 2. Any other client has DC → use it (new tabs, redirects) + // 3. Wait briefly for requesting client's DC (only for subresources) + // 4. Fall back to network (relay) + if (event.clientId && dcClients.has(event.clientId)) { + event.respondWith(rewriteAndHandle(event, event.clientId)); + return; + } - if (dcClients.has(event.clientId)) { - event.respondWith(rewriteAndHandle(event)); + if (dcClients.size > 0) { + event.respondWith( + findAnyDcClient().then(function (dcClientId) { + if (dcClientId) return rewriteAndHandle(event, dcClientId); + // No live DC client found — navigations go straight to relay + if (isNavigation) return fetch(event.request); + if (event.clientId) { + return waitForDc(event.clientId, 3000).then(function (ready) { + if (ready) return rewriteAndHandle(event, event.clientId); + return fetch(event.request); + }); + } + return fetch(event.request); + }) + ); return; } + // No DC clients at all — navigations go straight to relay (don't block page load) + if (isNavigation || !event.clientId) return; + + // Subresource request: wait briefly for DC, then fall back to relay 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) + waitForDc(event.clientId, 3000).then(function (ready) { + if (ready) return rewriteAndHandle(event, event.clientId); return fetch(event.request); }) ); }); -async function rewriteAndHandle(event) { +async function rewriteAndHandle(event, clientId) { 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) { + // Prepend /__b/ prefix from DC client's page if needed + if (!url.pathname.startsWith("/__b/") && clientId) { try { - var client = await self.clients.get(event.clientId); + var client = await self.clients.get(clientId); if (client) { var prefix = extractPrefix(new URL(client.url).pathname); if (prefix && !GATEWAY_PATHS.test(url.pathname)) { @@ -167,7 +242,7 @@ async function rewriteAndHandle(event) { } } - return handleViaDataChannel(event.clientId, request); + return handleViaDataChannel(clientId, request); } async function handleViaDataChannel(clientId, request) { @@ -194,6 +269,11 @@ async function handleViaDataChannel(clientId, request) { for (var pair of request.headers) { headers[pair[0]] = pair[1]; } + // Strip conditional headers — DC responses bypass the browser's HTTP + // cache, so a 304 from the backend produces a null-body response that + // the browser can't match to a cache entry. Force full 200 responses. + delete headers["if-none-match"]; + delete headers["if-modified-since"]; client.postMessage( { @@ -240,6 +320,18 @@ async function handleViaDataChannel(clientId, request) { } } + // Redirect responses: return a proper redirect so the browser follows it. + // The subsequent request will be intercepted by the SW and routed through DC. + if ([301, 302, 303, 307, 308].indexOf(e.data.statusCode) !== -1) { + var location = responseHeaders.get("location"); + if (location) { + try { + resolve(Response.redirect(new URL(location, request.url).href, e.data.statusCode)); + return; + } catch (err) { /* invalid URL, fall through */ } + } + } + if (e.data.streaming) { // Live streaming response (SSE, NDJSON) — return a ReadableStream // so the browser can consume data progressively. @@ -279,26 +371,42 @@ async function handleViaDataChannel(clientId, request) { 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); - }); - } + // Null-body status codes (204, 304) must not have a body per spec + var nullBodyStatus = e.data.statusCode === 204 || e.data.statusCode === 304; + + try { + var bodyBytes; + if (nullBodyStatus) { + bodyBytes = null; + } else 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); + }); + } + + // Remove content-length — the browser derives it from bodyBytes. + // A mismatched value (from the backend's original HTTP response) can + // cause truncation or rendering failures in SW-constructed Responses. + responseHeaders.delete("content-length"); + responseHeaders.delete("content-encoding"); + + console.log("[SW] DC response:", e.data.statusCode, + "type:", responseHeaders.get("content-type") || "", + "body:", nullBodyStatus ? 0 : bodyBytes.length, "bytes", + "via:", nullBodyStatus ? "null-body" : e.data.binaryBody ? "binary" : "base64"); - 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, - }) - ); + resolve( + new Response(bodyBytes, { + status: e.data.statusCode, + headers: responseHeaders, + }) + ); + } catch (buildErr) { + console.error("[SW] Failed to build DC response:", buildErr); + resolve(fetch(fallbackRequest)); + } }; }); } catch (e) { diff --git a/bridges/punchd-bridge/gateway/public/js/webrtc-upgrade.js b/bridges/punchd-bridge/gateway/public/js/webrtc-upgrade.js index a10c813..7bce969 100644 --- a/bridges/punchd-bridge/gateway/public/js/webrtc-upgrade.js +++ b/bridges/punchd-bridge/gateway/public/js/webrtc-upgrade.js @@ -8,6 +8,11 @@ * * Falls back gracefully — if WebRTC fails, HTTP relay continues working. * Automatically reconnects when the DataChannel or signaling drops. + * + * Supports dual DataChannels for high-throughput scenarios (4K video, gaming): + * - "http-tunnel" (control): JSON control messages, small responses + * - "bulk-data" (bulk): binary streaming chunks, binary WebSocket frames + * Falls back to single-channel mode for older gateways. */ (function () { @@ -24,10 +29,32 @@ const RECONNECT_DELAY = 5000; const MAX_RECONNECT_DELAY = 60000; + // Block other Service Worker registrations (e.g. Jellyfin's serviceworker.js) + // that would steal our scope and prevent DataChannel routing. + // Must be done early, before any other code can register a SW. + const _origSWRegister = navigator.serviceWorker + ? navigator.serviceWorker.register.bind(navigator.serviceWorker) + : null; + if (navigator.serviceWorker) { + navigator.serviceWorker.register = function (scriptURL, options) { + var url = new URL(scriptURL, location.href); + if (url.pathname.endsWith("/sw.js")) { + return _origSWRegister(scriptURL, options); + } + console.log("[WebRTC] Blocking conflicting SW registration:", scriptURL); + return navigator.serviceWorker.ready; + }; + } + + // Binary WebSocket fast-path magic byte (must match gateway's BINARY_WS_MAGIC) + const BINARY_WS_MAGIC = 0x02; + let signalingWs = null; let peerConnection = null; - let dataChannel = null; - let clientId = "client-" + Math.random().toString(36).slice(2, 10); + let dataChannel = null; // Control channel ("http-tunnel") + let bulkChannel = null; // Bulk data channel ("bulk-data") + let bulkEnabled = false; // True after capability handshake confirms bulk support + let clientId = "client-" + crypto.randomUUID().replace(/-/g, "").slice(0, 8); let pairedGatewayId = null; let config = null; let sessionToken = null; @@ -36,6 +63,8 @@ let reconnectTimer = null; let swRegistered = false; let dcReadySignaled = false; + let capabilityTimer = null; + let gatewayFeatures = []; // Features confirmed by gateway capabilities response // Pending requests waiting for DataChannel responses const pendingRequests = new Map(); @@ -45,6 +74,10 @@ const streamingPorts = new Map(); // Active WebSocket connections tunneled through DataChannel const dcWebSockets = new Map(); + // Early chunks buffer: binary chunks that arrive on the bulk channel + // before their http_response_start message arrives on the control channel. + // (Dual DataChannels have independent ordering — bulk can deliver faster.) + const earlyChunks = new Map(); async function init() { try { @@ -69,6 +102,13 @@ try { dataChannel.onclose = null; dataChannel.onerror = null; dataChannel.close(); } catch {} dataChannel = null; } + if (bulkChannel) { + try { bulkChannel.onclose = null; bulkChannel.onerror = null; bulkChannel.close(); } catch {} + bulkChannel = null; + } + bulkEnabled = false; + gatewayFeatures = []; + if (capabilityTimer) { clearTimeout(capabilityTimer); capabilityTimer = null; } if (peerConnection) { try { peerConnection.onicecandidate = null; peerConnection.onconnectionstatechange = null; peerConnection.close(); } catch {} peerConnection = null; @@ -81,6 +121,7 @@ } pendingRequests.clear(); chunkedResponses.clear(); + earlyChunks.clear(); // End any in-flight streaming responses so SW promises don't hang for (var [id, port] of streamingPorts) { port.postMessage({ type: "end" }); @@ -237,6 +278,16 @@ } } + function sendCapabilities() { + if (dataChannel && dataChannel.readyState === "open") { + dataChannel.send(JSON.stringify({ + type: "capabilities", + version: 2, + features: ["bulk-channel", "binary-ws"], + })); + } + } + function startWebRTC() { if (!pairedGatewayId) return; @@ -264,22 +315,51 @@ iceServers: iceServers.length > 0 ? iceServers : undefined, }); + // --- Control channel: JSON messages, small responses --- dataChannel = peerConnection.createDataChannel("http-tunnel", { ordered: true, }); dataChannel.binaryType = "arraybuffer"; + // --- Bulk channel: binary streaming chunks, binary WS frames --- + bulkChannel = peerConnection.createDataChannel("bulk-data", { + ordered: true, + }); + bulkChannel.binaryType = "arraybuffer"; + dataChannel.onopen = async () => { - console.log("[WebRTC] DataChannel OPEN — direct connection established!"); + console.log("[WebRTC] Control 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 + // Refresh token every 2 minutes — the server proactively refreshes + // tokens within 2 min of expiry, so this ensures we always have a + // fresh token with ~3+ min remaining lifetime. if (tokenRefreshTimer) clearInterval(tokenRefreshTimer); - tokenRefreshTimer = setInterval(fetchSessionToken, 4 * 60 * 1000); + tokenRefreshTimer = setInterval(fetchSessionToken, 2 * 60 * 1000); installWebSocketShim(); await registerServiceWorker(); + // Send capability handshake to negotiate bulk channel + binary WS. + // The gateway also sends capabilities proactively on channel open, + // so we may receive them before we even send — that's fine. + sendCapabilities(); + // Retry once after 2s if no response yet (message could be lost) + capabilityTimer = setTimeout(function () { + capabilityTimer = null; + if (!bulkEnabled) { + console.log("[WebRTC] No capabilities response yet — retrying..."); + sendCapabilities(); + // Final fallback after another 3s + capabilityTimer = setTimeout(function () { + capabilityTimer = null; + if (!bulkEnabled) { + console.log("[WebRTC] Gateway did not respond to capabilities — single-channel mode"); + } + }, 3000); + } + }, 2000); + // 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) { @@ -300,6 +380,7 @@ signalDcReady(); }; + // --- Control channel message handler --- 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). @@ -320,22 +401,9 @@ 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)); - } + // Binary streaming chunk on control channel (single-channel fallback): + // 36-byte requestId prefix + raw bytes + handleBinaryChunk(buf); return; } @@ -348,7 +416,125 @@ } }; + // --- Bulk channel message handler --- + bulkChannel.onmessage = function (event) { + if (typeof event.data !== "string") { + var buf = new Uint8Array(event.data); + if (buf.length === 0) return; + + // Binary WS fast-path: [0x02][36-byte WS UUID][payload] + if (buf[0] === BINARY_WS_MAGIC && buf.length >= 37) { + var wsId = new TextDecoder().decode(buf.subarray(1, 37)); + var payload = buf.slice(37); + var ws = dcWebSockets.get(wsId); + if (ws) ws._fireMessageBinary(payload.buffer); + return; + } + + // JSON control message on bulk channel + if (buf[0] === 0x7B) { + try { + var msg = JSON.parse(new TextDecoder().decode(buf)); + handleDcMessage(msg); + } catch (parseErr) { + console.error("[WebRTC] Failed to parse bulk JSON message:", parseErr.message, "len:", buf.length); + } + return; + } + + // Binary streaming chunk: 36-byte requestId prefix + raw bytes + handleBinaryChunk(buf); + } + }; + + bulkChannel.onopen = function () { + console.log("[WebRTC] Bulk channel OPEN"); + // If gateway already confirmed bulk-channel support, enable now + if (gatewayFeatures.indexOf("bulk-channel") !== -1) { + bulkEnabled = true; + console.log("[WebRTC] Bulk channel enabled — dual-channel mode active"); + } + }; + + bulkChannel.onclose = function () { + console.log("[WebRTC] Bulk channel closed"); + bulkChannel = null; + bulkEnabled = false; + }; + + bulkChannel.onerror = function () { + console.log("[WebRTC] Bulk channel error"); + }; + + /** Handle a binary streaming chunk (shared between control and bulk channels). */ + function handleBinaryChunk(buf) { + if (buf.length < 36) return; + var requestId = new TextDecoder().decode(buf.subarray(0, 36)); + var entry = chunkedResponses.get(requestId); + if (!entry || !entry.streaming) { + // Chunk arrived before http_response_start (cross-channel race) — buffer it + var pending = earlyChunks.get(requestId); + if (!pending) { + pending = []; + earlyChunks.set(requestId, pending); + } + pending.push(buf.slice(36)); + if (pending.length % 100 === 0) { + console.log("[WebRTC] Early chunks buffered:", pending.length, "for", requestId); + } + return; + } + if (entry.live) { + // Live stream (SSE/NDJSON) — forward chunk to SW immediately + var port = streamingPorts.get(requestId); + if (port) { + var 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)); + if (entry.chunks.length % 100 === 0) { + var totalSoFar = entry.chunks.reduce(function (s, c) { return s + c.length; }, 0); + console.log("[WebRTC] Streaming chunks:", entry.chunks.length, "received for", requestId, "(" + totalSoFar + " bytes)"); + } + } + } + function handleDcMessage(msg) { + if (msg.type === "capabilities") { + // Gateway capability response — store features for deferred activation + if (capabilityTimer) { clearTimeout(capabilityTimer); capabilityTimer = null; } + gatewayFeatures = msg.features || []; + console.log("[WebRTC] Gateway capabilities:", gatewayFeatures.join(", ")); + // Enable bulk channel if it's already open + if (gatewayFeatures.indexOf("bulk-channel") !== -1 && bulkChannel && bulkChannel.readyState === "open") { + bulkEnabled = true; + console.log("[WebRTC] Bulk channel enabled — dual-channel mode active"); + } + return; + } + + if (msg.type === "http_response_ack" && msg.id) { + // Gateway acknowledged a streaming response — extend our timeout + // so the full http_response_start (on the potentially congested bulk + // channel) has time to arrive. + var ackPending = pendingRequests.get(msg.id); + if (ackPending) { + clearTimeout(ackPending.timeout); + ackPending.timeout = setTimeout(function () { + console.warn("[WebRTC] Streaming request timed out after ack:", msg.id); + pendingRequests.delete(msg.id); + streamingPorts.delete(msg.id); + ackPending.port.postMessage({ error: "Timeout" }); + }, 300000); // 5 minutes for large streaming responses + // Tell SW to extend its timeout too + ackPending.port.postMessage({ type: "progress" }); + console.log("[WebRTC] Extended timeout for streaming response:", msg.id); + } + return; + } + if (msg.type === "http_response" && msg.id) { // Single buffered response const pending = pendingRequests.get(msg.id); @@ -359,6 +545,7 @@ } else if (msg.type === "http_response_start" && msg.id) { if (msg.streaming) { var isLive = !!msg.live; + console.log("[WebRTC] Streaming start received:", msg.id, "status:", msg.statusCode, "live:", isLive); if (isLive) { // Live stream (SSE, NDJSON) — resolve immediately with ReadableStream. // Client consumes data progressively as it arrives. @@ -374,7 +561,7 @@ } else { // Buffered streaming (video, large files) — extend timeouts since // the full response must be received before we can deliver it. - // A 50MB 4K segment over DataChannel can take minutes. + // Range capping on the gateway keeps each response small (~5MB). var pending = pendingRequests.get(msg.id); if (pending) { clearTimeout(pending.timeout); @@ -388,7 +575,7 @@ } // 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) + // (Chrome's media pipeline can't consume ReadableStream 206 from SW) chunkedResponses.set(msg.id, { streaming: true, live: isLive, @@ -396,6 +583,26 @@ headers: msg.headers, chunks: isLive ? undefined : [], }); + // Apply any chunks that arrived on the bulk channel before this start message + var early = earlyChunks.get(msg.id); + if (early) { + earlyChunks.delete(msg.id); + var createdEntry = chunkedResponses.get(msg.id); + for (var ei = 0; ei < early.length; ei++) { + if (isLive) { + var livePort = streamingPorts.get(msg.id); + if (livePort) { + var earlyBuf = early[ei].buffer; + livePort.postMessage({ type: "chunk", data: earlyBuf }, [earlyBuf]); + } + } else { + createdEntry.chunks.push(early[ei]); + } + } + if (early.length > 0) { + console.log("[WebRTC] Applied " + early.length + " early chunks for " + msg.id); + } + } } else { // Size-chunked reassembly (large buffered responses) chunkedResponses.set(msg.id, { @@ -437,11 +644,12 @@ } } else if (msg.type === "http_response_end" && msg.id) { const entry = chunkedResponses.get(msg.id); + console.log("[WebRTC] Streaming end received:", msg.id, "entry:", entry ? ("chunks=" + (entry.chunks ? entry.chunks.length : "none") + " live=" + entry.live) : "MISSING"); chunkedResponses.delete(msg.id); + earlyChunks.delete(msg.id); // Clean up any orphaned early chunks 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). + // Buffered finite response — concatenate chunks and deliver. const totalLength = entry.chunks.reduce((sum, c) => sum + c.length, 0); const merged = new Uint8Array(totalLength); let offset = 0; @@ -449,7 +657,7 @@ merged.set(chunk, offset); offset += chunk.length; } - console.log(`[WebRTC] Buffered response complete: ${msg.id} (${totalLength} bytes)`); + console.log(`[WebRTC] Buffered response complete: ${msg.id} (${totalLength} bytes, ${entry.chunks.length} chunks)`); const pending = pendingRequests.get(msg.id); if (pending) { pendingRequests.delete(msg.id); @@ -458,6 +666,8 @@ headers: entry.headers, binaryBody: merged.buffer, }); + } else { + console.warn("[WebRTC] Buffered response complete but no pending request (timeout already fired?):", msg.id); } } else { // Live stream — close the ReadableStream @@ -552,13 +762,40 @@ } async function registerServiceWorker() { - if (swRegistered || !("serviceWorker" in navigator)) { + if (swRegistered || !("serviceWorker" in navigator) || !_origSWRegister) { return; } try { - await navigator.serviceWorker.register("/js/sw.js", { scope: "/", updateViaCache: "none" }); - console.log("[WebRTC] Service Worker registered"); + // Use the backend prefix scope so our SW controls the page. + // Without this, Jellyfin's serviceworker.js at web/ scope would + // take priority and our SW would never see any fetch events. + var swScope = BACKEND_PREFIX ? BACKEND_PREFIX + "/web/" : "/"; + + // Unregister any conflicting SWs: + // - Jellyfin's own serviceworker.js at the same scope + // - Stale registrations of our sw.js at "/" scope (from before scope migration) + var existingRegs = await navigator.serviceWorker.getRegistrations(); + for (var i = 0; i < existingRegs.length; i++) { + var reg = existingRegs[i]; + var regScopePath = new URL(reg.scope).pathname; + var isOurSw = reg.active && reg.active.scriptURL.endsWith("/sw.js"); + // Unregister conflicting SWs at our target scope + if (regScopePath === swScope || regScopePath.startsWith(BACKEND_PREFIX + "/web")) { + if (!isOurSw) { + console.log("[WebRTC] Unregistering conflicting SW:", reg.scope); + await reg.unregister(); + } + } + // Unregister stale sw.js at root scope when we've migrated to a prefix scope + if (isOurSw && regScopePath === "/" && swScope !== "/") { + console.log("[WebRTC] Unregistering stale root-scope SW:", reg.scope); + await reg.unregister(); + } + } + + await _origSWRegister("/js/sw.js", { scope: swScope, updateViaCache: "none" }); + console.log("[WebRTC] Service Worker registered at scope:", swScope); swRegistered = true; // When a new SW takes control mid-session (e.g., after SW update), @@ -600,7 +837,23 @@ console.log("[WebRTC] Signaled dc_ready to Service Worker"); } + // Serialize fetchSessionToken calls — multiple 401 retries must not + // trigger concurrent refresh requests (causes refresh token rotation races). + var _tokenRefreshPromise = null; + async function fetchSessionToken() { + if (_tokenRefreshPromise) { + return _tokenRefreshPromise; + } + _tokenRefreshPromise = _doFetchSessionToken(); + try { + return await _tokenRefreshPromise; + } finally { + _tokenRefreshPromise = null; + } + } + + async function _doFetchSessionToken() { try { const res = await fetch(BACKEND_PREFIX + "/auth/session-token", { headers: { "X-Requested-With": "XMLHttpRequest" }, @@ -624,12 +877,7 @@ } } - function handleSwFetch(request, responsePort) { - if (!dataChannel || dataChannel.readyState !== "open") { - responsePort.postMessage({ error: "DataChannel not open" }); - return; - } - + function sendDcRequest(request, responsePort, isRetry) { const requestId = crypto.randomUUID(); // Inject session cookie that the SW can't read (HttpOnly). @@ -656,6 +904,7 @@ ); var timeout = setTimeout(() => { + console.warn("[WebRTC] DC request timed out (15s initial):", request.method, request.url, "id:", requestId); pendingRequests.delete(requestId); streamingPorts.delete(requestId); responsePort.postMessage({ error: "Timeout" }); @@ -666,6 +915,19 @@ port: responsePort, resolve: (msg) => { clearTimeout(timeout); + // On 401, refresh token and retry once (token may have expired + // between our 4-minute refresh intervals) + if (msg.statusCode === 401 && !isRetry) { + console.log("[WebRTC] Got 401 — refreshing token and retrying"); + fetchSessionToken().then(function () { + if (sessionToken) { + sendDcRequest(request, responsePort, true); + } else { + responsePort.postMessage({ statusCode: 401, headers: msg.headers, body: msg.body }); + } + }); + return; + } if (msg.streaming) { // Live streaming response (SSE, NDJSON) — keep the port open for chunks streamingPorts.set(requestId, responsePort); @@ -692,6 +954,14 @@ }); } + function handleSwFetch(request, responsePort) { + if (!dataChannel || dataChannel.readyState !== "open") { + responsePort.postMessage({ error: "DataChannel not open" }); + return; + } + sendDcRequest(request, responsePort, false); + } + // --- WebSocket shim: tunnels same-origin WS connections through DataChannel --- function bufToBase64(bytes) { @@ -700,6 +970,17 @@ return btoa(binary); } + /** Send a binary WebSocket frame via the bulk channel fast-path. */ + function sendBinaryWsFrame(wsId, payload) { + // [0x02][36-byte UUID][raw payload] + var frame = new Uint8Array(37 + payload.length); + frame[0] = BINARY_WS_MAGIC; + var encoder = new TextEncoder(); + frame.set(encoder.encode(wsId), 1); + frame.set(payload, 37); + bulkChannel.send(frame); + } + class DCWebSocket { constructor(url, protocols) { this._id = crypto.randomUUID(); @@ -759,18 +1040,36 @@ send(data) { if (this.readyState !== 1) throw new DOMException("WebSocket not open", "InvalidStateError"); if (typeof data === "string") { + // Text messages always go via JSON on control channel 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 })); - }); + } else if (bulkEnabled && bulkChannel && bulkChannel.readyState === "open") { + // Binary fast-path: send raw binary on bulk channel (no base64/JSON) + if (data instanceof ArrayBuffer) { + sendBinaryWsFrame(this._id, new Uint8Array(data)); + } else if (ArrayBuffer.isView(data)) { + sendBinaryWsFrame(this._id, new Uint8Array(data.buffer, data.byteOffset, data.byteLength)); + } else if (data instanceof Blob) { + var wsId = this._id; + var ws = this; + data.arrayBuffer().then(function (buf) { + if (ws.readyState !== 1 || !bulkChannel || bulkChannel.readyState !== "open") return; + sendBinaryWsFrame(wsId, new Uint8Array(buf)); + }); + } + } else { + // Fallback: JSON+base64 path (single-channel mode or bulk not ready) + 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 })); + }); + } } } @@ -801,6 +1100,12 @@ this._dispatch("message", new MessageEvent("message", { data: payload })); } + /** Receive binary data directly from bulk channel (no base64 decode needed). */ + _fireMessageBinary(arrayBuffer) { + var payload = this.binaryType === "arraybuffer" ? arrayBuffer : new Blob([arrayBuffer]); + this._dispatch("message", new MessageEvent("message", { data: payload })); + } + _fireClose(code, reason) { if (this.readyState === 3) return; this.readyState = 3; diff --git a/bridges/punchd-bridge/gateway/public/rdp.html b/bridges/punchd-bridge/gateway/public/rdp.html new file mode 100644 index 0000000..88720f6 --- /dev/null +++ b/bridges/punchd-bridge/gateway/public/rdp.html @@ -0,0 +1,79 @@ + + + + + + Remote Desktop + + + +
+ + Initializing... + +
+ +
+

Remote Desktop

+
+ + +
+
+ + +
+ +

+
+ + + + + + diff --git a/bridges/punchd-bridge/gateway/public/wasm/ironrdp_web.js b/bridges/punchd-bridge/gateway/public/wasm/ironrdp_web.js new file mode 100644 index 0000000..4905b76 --- /dev/null +++ b/bridges/punchd-bridge/gateway/public/wasm/ironrdp_web.js @@ -0,0 +1,1781 @@ +/* @ts-self-types="./ironrdp_web.d.ts" */ + +export class ClipboardData { + static __wrap(ptr) { + ptr = ptr >>> 0; + const obj = Object.create(ClipboardData.prototype); + obj.__wbg_ptr = ptr; + ClipboardDataFinalization.register(obj, obj.__wbg_ptr, obj); + return obj; + } + __destroy_into_raw() { + const ptr = this.__wbg_ptr; + this.__wbg_ptr = 0; + ClipboardDataFinalization.unregister(this); + return ptr; + } + free() { + const ptr = this.__destroy_into_raw(); + wasm.__wbg_clipboarddata_free(ptr, 0); + } + /** + * @param {string} mime_type + * @param {Uint8Array} binary + */ + addBinary(mime_type, binary) { + const ptr0 = passStringToWasm0(mime_type, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len0 = WASM_VECTOR_LEN; + const ptr1 = passArray8ToWasm0(binary, wasm.__wbindgen_malloc); + const len1 = WASM_VECTOR_LEN; + wasm.clipboarddata_addBinary(this.__wbg_ptr, ptr0, len0, ptr1, len1); + } + /** + * @param {string} mime_type + * @param {string} text + */ + addText(mime_type, text) { + const ptr0 = passStringToWasm0(mime_type, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len0 = WASM_VECTOR_LEN; + const ptr1 = passStringToWasm0(text, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len1 = WASM_VECTOR_LEN; + wasm.clipboarddata_addText(this.__wbg_ptr, ptr0, len0, ptr1, len1); + } + constructor() { + const ret = wasm.clipboarddata_create(); + this.__wbg_ptr = ret >>> 0; + ClipboardDataFinalization.register(this, this.__wbg_ptr, this); + return this; + } + /** + * @returns {boolean} + */ + isEmpty() { + const ret = wasm.clipboarddata_isEmpty(this.__wbg_ptr); + return ret !== 0; + } + /** + * @returns {ClipboardItem[]} + */ + items() { + const ret = wasm.clipboarddata_items(this.__wbg_ptr); + var v1 = getArrayJsValueFromWasm0(ret[0], ret[1]).slice(); + wasm.__wbindgen_free(ret[0], ret[1] * 4, 4); + return v1; + } +} +if (Symbol.dispose) ClipboardData.prototype[Symbol.dispose] = ClipboardData.prototype.free; + +export class ClipboardItem { + static __wrap(ptr) { + ptr = ptr >>> 0; + const obj = Object.create(ClipboardItem.prototype); + obj.__wbg_ptr = ptr; + ClipboardItemFinalization.register(obj, obj.__wbg_ptr, obj); + return obj; + } + __destroy_into_raw() { + const ptr = this.__wbg_ptr; + this.__wbg_ptr = 0; + ClipboardItemFinalization.unregister(this); + return ptr; + } + free() { + const ptr = this.__destroy_into_raw(); + wasm.__wbg_clipboarditem_free(ptr, 0); + } + /** + * @returns {string} + */ + mimeType() { + let deferred1_0; + let deferred1_1; + try { + const ret = wasm.clipboarditem_mimeType(this.__wbg_ptr); + deferred1_0 = ret[0]; + deferred1_1 = ret[1]; + return getStringFromWasm0(ret[0], ret[1]); + } finally { + wasm.__wbindgen_free(deferred1_0, deferred1_1, 1); + } + } + /** + * @returns {any} + */ + value() { + const ret = wasm.clipboarditem_value(this.__wbg_ptr); + return ret; + } +} +if (Symbol.dispose) ClipboardItem.prototype[Symbol.dispose] = ClipboardItem.prototype.free; + +export class DesktopSize { + static __wrap(ptr) { + ptr = ptr >>> 0; + const obj = Object.create(DesktopSize.prototype); + obj.__wbg_ptr = ptr; + DesktopSizeFinalization.register(obj, obj.__wbg_ptr, obj); + return obj; + } + __destroy_into_raw() { + const ptr = this.__wbg_ptr; + this.__wbg_ptr = 0; + DesktopSizeFinalization.unregister(this); + return ptr; + } + free() { + const ptr = this.__destroy_into_raw(); + wasm.__wbg_desktopsize_free(ptr, 0); + } + /** + * @param {number} width + * @param {number} height + */ + constructor(width, height) { + const ret = wasm.desktopsize_create(width, height); + this.__wbg_ptr = ret >>> 0; + DesktopSizeFinalization.register(this, this.__wbg_ptr, this); + return this; + } + /** + * @returns {number} + */ + get height() { + const ret = wasm.__wbg_get_desktopsize_height(this.__wbg_ptr); + return ret; + } + /** + * @returns {number} + */ + get width() { + const ret = wasm.__wbg_get_desktopsize_width(this.__wbg_ptr); + return ret; + } + /** + * @param {number} arg0 + */ + set height(arg0) { + wasm.__wbg_set_desktopsize_height(this.__wbg_ptr, arg0); + } + /** + * @param {number} arg0 + */ + set width(arg0) { + wasm.__wbg_set_desktopsize_width(this.__wbg_ptr, arg0); + } +} +if (Symbol.dispose) DesktopSize.prototype[Symbol.dispose] = DesktopSize.prototype.free; + +export class DeviceEvent { + static __wrap(ptr) { + ptr = ptr >>> 0; + const obj = Object.create(DeviceEvent.prototype); + obj.__wbg_ptr = ptr; + DeviceEventFinalization.register(obj, obj.__wbg_ptr, obj); + return obj; + } + __destroy_into_raw() { + const ptr = this.__wbg_ptr; + this.__wbg_ptr = 0; + DeviceEventFinalization.unregister(this); + return ptr; + } + free() { + const ptr = this.__destroy_into_raw(); + wasm.__wbg_deviceevent_free(ptr, 0); + } + /** + * @param {number} scancode + * @returns {DeviceEvent} + */ + static keyPressed(scancode) { + const ret = wasm.deviceevent_keyPressed(scancode); + return DeviceEvent.__wrap(ret); + } + /** + * @param {number} scancode + * @returns {DeviceEvent} + */ + static keyReleased(scancode) { + const ret = wasm.deviceevent_keyReleased(scancode); + return DeviceEvent.__wrap(ret); + } + /** + * @param {number} button + * @returns {DeviceEvent} + */ + static mouseButtonPressed(button) { + const ret = wasm.deviceevent_mouseButtonPressed(button); + return DeviceEvent.__wrap(ret); + } + /** + * @param {number} button + * @returns {DeviceEvent} + */ + static mouseButtonReleased(button) { + const ret = wasm.deviceevent_mouseButtonReleased(button); + return DeviceEvent.__wrap(ret); + } + /** + * @param {number} x + * @param {number} y + * @returns {DeviceEvent} + */ + static mouseMove(x, y) { + const ret = wasm.deviceevent_mouseMove(x, y); + return DeviceEvent.__wrap(ret); + } + /** + * @param {string} unicode + * @returns {DeviceEvent} + */ + static unicodePressed(unicode) { + const char0 = unicode.codePointAt(0); + _assertChar(char0); + const ret = wasm.deviceevent_unicodePressed(char0); + return DeviceEvent.__wrap(ret); + } + /** + * @param {string} unicode + * @returns {DeviceEvent} + */ + static unicodeReleased(unicode) { + const char0 = unicode.codePointAt(0); + _assertChar(char0); + const ret = wasm.deviceevent_unicodeReleased(char0); + return DeviceEvent.__wrap(ret); + } + /** + * @param {boolean} vertical + * @param {number} rotation_amount + * @param {RotationUnit} rotation_unit + * @returns {DeviceEvent} + */ + static wheelRotations(vertical, rotation_amount, rotation_unit) { + const ret = wasm.deviceevent_wheelRotations(vertical, rotation_amount, rotation_unit); + return DeviceEvent.__wrap(ret); + } +} +if (Symbol.dispose) DeviceEvent.prototype[Symbol.dispose] = DeviceEvent.prototype.free; + +export class Extension { + __destroy_into_raw() { + const ptr = this.__wbg_ptr; + this.__wbg_ptr = 0; + ExtensionFinalization.unregister(this); + return ptr; + } + free() { + const ptr = this.__destroy_into_raw(); + wasm.__wbg_extension_free(ptr, 0); + } + /** + * @param {string} ident + * @param {any} value + */ + constructor(ident, value) { + const ptr0 = passStringToWasm0(ident, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len0 = WASM_VECTOR_LEN; + const ret = wasm.extension_create(ptr0, len0, value); + this.__wbg_ptr = ret >>> 0; + ExtensionFinalization.register(this, this.__wbg_ptr, this); + return this; + } +} +if (Symbol.dispose) Extension.prototype[Symbol.dispose] = Extension.prototype.free; + +export class InputTransaction { + __destroy_into_raw() { + const ptr = this.__wbg_ptr; + this.__wbg_ptr = 0; + InputTransactionFinalization.unregister(this); + return ptr; + } + free() { + const ptr = this.__destroy_into_raw(); + wasm.__wbg_inputtransaction_free(ptr, 0); + } + /** + * @param {DeviceEvent} event + */ + addEvent(event) { + _assertClass(event, DeviceEvent); + var ptr0 = event.__destroy_into_raw(); + wasm.inputtransaction_addEvent(this.__wbg_ptr, ptr0); + } + constructor() { + const ret = wasm.inputtransaction_create(); + this.__wbg_ptr = ret >>> 0; + InputTransactionFinalization.register(this, this.__wbg_ptr, this); + return this; + } +} +if (Symbol.dispose) InputTransaction.prototype[Symbol.dispose] = InputTransaction.prototype.free; + +export class IronError { + static __wrap(ptr) { + ptr = ptr >>> 0; + const obj = Object.create(IronError.prototype); + obj.__wbg_ptr = ptr; + IronErrorFinalization.register(obj, obj.__wbg_ptr, obj); + return obj; + } + __destroy_into_raw() { + const ptr = this.__wbg_ptr; + this.__wbg_ptr = 0; + IronErrorFinalization.unregister(this); + return ptr; + } + free() { + const ptr = this.__destroy_into_raw(); + wasm.__wbg_ironerror_free(ptr, 0); + } + /** + * @returns {string} + */ + backtrace() { + let deferred1_0; + let deferred1_1; + try { + const ret = wasm.ironerror_backtrace(this.__wbg_ptr); + deferred1_0 = ret[0]; + deferred1_1 = ret[1]; + return getStringFromWasm0(ret[0], ret[1]); + } finally { + wasm.__wbindgen_free(deferred1_0, deferred1_1, 1); + } + } + /** + * @returns {IronErrorKind} + */ + kind() { + const ret = wasm.ironerror_kind(this.__wbg_ptr); + return ret; + } + /** + * @returns {RDCleanPathDetails | undefined} + */ + rdcleanpathDetails() { + const ret = wasm.ironerror_rdcleanpathDetails(this.__wbg_ptr); + return ret === 0 ? undefined : RDCleanPathDetails.__wrap(ret); + } +} +if (Symbol.dispose) IronError.prototype[Symbol.dispose] = IronError.prototype.free; + +/** + * @enum {0 | 1 | 2 | 3 | 4 | 5 | 6} + */ +export const IronErrorKind = Object.freeze({ + /** + * Catch-all error kind + */ + General: 0, "0": "General", + /** + * Incorrect password used + */ + WrongPassword: 1, "1": "WrongPassword", + /** + * Unable to login to machine + */ + LogonFailure: 2, "2": "LogonFailure", + /** + * Insufficient permission, server denied access + */ + AccessDenied: 3, "3": "AccessDenied", + /** + * Something wrong happened when sending or receiving the RDCleanPath message + */ + RDCleanPath: 4, "4": "RDCleanPath", + /** + * Couldn't connect to proxy + */ + ProxyConnect: 5, "5": "ProxyConnect", + /** + * Protocol negotiation failed + */ + NegotiationFailure: 6, "6": "NegotiationFailure", +}); + +/** + * Detailed error information for RDCleanPath errors. + * + * When an RDCleanPath error occurs, this structure provides granular details + * about the underlying cause, including HTTP status codes, Windows Socket errors, + * and TLS alert codes. + */ +export class RDCleanPathDetails { + static __wrap(ptr) { + ptr = ptr >>> 0; + const obj = Object.create(RDCleanPathDetails.prototype); + obj.__wbg_ptr = ptr; + RDCleanPathDetailsFinalization.register(obj, obj.__wbg_ptr, obj); + return obj; + } + __destroy_into_raw() { + const ptr = this.__wbg_ptr; + this.__wbg_ptr = 0; + RDCleanPathDetailsFinalization.unregister(this); + return ptr; + } + free() { + const ptr = this.__destroy_into_raw(); + wasm.__wbg_rdcleanpathdetails_free(ptr, 0); + } + /** + * HTTP status code if the error originated from an HTTP response. + * + * Common values: + * - 403: Forbidden (e.g., deleted VNET, insufficient permissions) + * - 404: Not Found + * - 500: Internal Server Error + * - 502: Bad Gateway + * - 503: Service Unavailable + * @returns {number | undefined} + */ + get httpStatusCode() { + const ret = wasm.rdcleanpathdetails_httpStatusCode(this.__wbg_ptr); + return ret === 0xFFFFFF ? undefined : ret; + } + /** + * TLS alert code if the error occurred during TLS handshake. + * + * Common values: + * - 40: Handshake failure + * - 42: Bad certificate + * - 45: Certificate expired + * - 48: Unknown CA + * - 112: Unrecognized name + * @returns {number | undefined} + */ + get tlsAlertCode() { + const ret = wasm.rdcleanpathdetails_tlsAlertCode(this.__wbg_ptr); + return ret === 0xFFFFFF ? undefined : ret; + } + /** + * Windows Socket API (WSA) error code. + * + * Common values: + * - 10013: Permission denied (WSAEACCES) - often indicates deleted/invalid VNET + * - 10060: Connection timed out (WSAETIMEDOUT) + * - 10061: Connection refused (WSAECONNREFUSED) + * - 10051: Network is unreachable (WSAENETUNREACH) + * - 10065: No route to host (WSAEHOSTUNREACH) + * @returns {number | undefined} + */ + get wsaErrorCode() { + const ret = wasm.rdcleanpathdetails_wsaErrorCode(this.__wbg_ptr); + return ret === 0xFFFFFF ? undefined : ret; + } +} +if (Symbol.dispose) RDCleanPathDetails.prototype[Symbol.dispose] = RDCleanPathDetails.prototype.free; + +export class RdpFile { + __destroy_into_raw() { + const ptr = this.__wbg_ptr; + this.__wbg_ptr = 0; + RdpFileFinalization.unregister(this); + return ptr; + } + free() { + const ptr = this.__destroy_into_raw(); + wasm.__wbg_rdpfile_free(ptr, 0); + } + constructor() { + const ret = wasm.rdpfile_create(); + this.__wbg_ptr = ret >>> 0; + RdpFileFinalization.register(this, this.__wbg_ptr, this); + return this; + } + /** + * @param {string} key + * @returns {number | undefined} + */ + getInt(key) { + const ptr0 = passStringToWasm0(key, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len0 = WASM_VECTOR_LEN; + const ret = wasm.rdpfile_getInt(this.__wbg_ptr, ptr0, len0); + return ret === 0x100000001 ? undefined : ret; + } + /** + * @param {string} key + * @returns {string | undefined} + */ + getStr(key) { + const ptr0 = passStringToWasm0(key, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len0 = WASM_VECTOR_LEN; + const ret = wasm.rdpfile_getStr(this.__wbg_ptr, ptr0, len0); + let v2; + if (ret[0] !== 0) { + v2 = getStringFromWasm0(ret[0], ret[1]).slice(); + wasm.__wbindgen_free(ret[0], ret[1] * 1, 1); + } + return v2; + } + /** + * @param {string} key + * @param {number} value + */ + insertInt(key, value) { + const ptr0 = passStringToWasm0(key, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len0 = WASM_VECTOR_LEN; + wasm.rdpfile_insertInt(this.__wbg_ptr, ptr0, len0, value); + } + /** + * @param {string} key + * @param {string} value + */ + insertStr(key, value) { + const ptr0 = passStringToWasm0(key, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len0 = WASM_VECTOR_LEN; + const ptr1 = passStringToWasm0(value, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len1 = WASM_VECTOR_LEN; + wasm.rdpfile_insertStr(this.__wbg_ptr, ptr0, len0, ptr1, len1); + } + /** + * @param {string} config + */ + parse(config) { + const ptr0 = passStringToWasm0(config, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len0 = WASM_VECTOR_LEN; + wasm.rdpfile_parse(this.__wbg_ptr, ptr0, len0); + } + /** + * @returns {string} + */ + write() { + let deferred1_0; + let deferred1_1; + try { + const ret = wasm.rdpfile_write(this.__wbg_ptr); + deferred1_0 = ret[0]; + deferred1_1 = ret[1]; + return getStringFromWasm0(ret[0], ret[1]); + } finally { + wasm.__wbindgen_free(deferred1_0, deferred1_1, 1); + } + } +} +if (Symbol.dispose) RdpFile.prototype[Symbol.dispose] = RdpFile.prototype.free; + +/** + * @enum {0 | 1 | 2} + */ +export const RotationUnit = Object.freeze({ + Pixel: 0, "0": "Pixel", + Line: 1, "1": "Line", + Page: 2, "2": "Page", +}); + +export class Session { + static __wrap(ptr) { + ptr = ptr >>> 0; + const obj = Object.create(Session.prototype); + obj.__wbg_ptr = ptr; + SessionFinalization.register(obj, obj.__wbg_ptr, obj); + return obj; + } + __destroy_into_raw() { + const ptr = this.__wbg_ptr; + this.__wbg_ptr = 0; + SessionFinalization.unregister(this); + return ptr; + } + free() { + const ptr = this.__destroy_into_raw(); + wasm.__wbg_session_free(ptr, 0); + } + /** + * @param {InputTransaction} transaction + */ + applyInputs(transaction) { + _assertClass(transaction, InputTransaction); + var ptr0 = transaction.__destroy_into_raw(); + const ret = wasm.session_applyInputs(this.__wbg_ptr, ptr0); + if (ret[1]) { + throw takeFromExternrefTable0(ret[0]); + } + } + /** + * @returns {DesktopSize} + */ + desktopSize() { + const ret = wasm.session_desktopSize(this.__wbg_ptr); + return DesktopSize.__wrap(ret); + } + /** + * @param {Extension} ext + * @returns {any} + */ + invokeExtension(ext) { + _assertClass(ext, Extension); + var ptr0 = ext.__destroy_into_raw(); + const ret = wasm.session_invokeExtension(this.__wbg_ptr, ptr0); + if (ret[2]) { + throw takeFromExternrefTable0(ret[1]); + } + return takeFromExternrefTable0(ret[0]); + } + /** + * @param {ClipboardData} content + * @returns {Promise} + */ + onClipboardPaste(content) { + _assertClass(content, ClipboardData); + const ret = wasm.session_onClipboardPaste(this.__wbg_ptr, content.__wbg_ptr); + return ret; + } + releaseAllInputs() { + const ret = wasm.session_releaseAllInputs(this.__wbg_ptr); + if (ret[1]) { + throw takeFromExternrefTable0(ret[0]); + } + } + /** + * @param {number} width + * @param {number} height + * @param {number | null} [scale_factor] + * @param {number | null} [physical_width] + * @param {number | null} [physical_height] + */ + resize(width, height, scale_factor, physical_width, physical_height) { + wasm.session_resize(this.__wbg_ptr, width, height, isLikeNone(scale_factor) ? 0x100000001 : (scale_factor) >>> 0, isLikeNone(physical_width) ? 0x100000001 : (physical_width) >>> 0, isLikeNone(physical_height) ? 0x100000001 : (physical_height) >>> 0); + } + /** + * @returns {Promise} + */ + run() { + const ret = wasm.session_run(this.__wbg_ptr); + return ret; + } + shutdown() { + const ret = wasm.session_shutdown(this.__wbg_ptr); + if (ret[1]) { + throw takeFromExternrefTable0(ret[0]); + } + } + /** + * @returns {boolean} + */ + supportsUnicodeKeyboardShortcuts() { + const ret = wasm.session_supportsUnicodeKeyboardShortcuts(this.__wbg_ptr); + return ret !== 0; + } + /** + * @param {boolean} scroll_lock + * @param {boolean} num_lock + * @param {boolean} caps_lock + * @param {boolean} kana_lock + */ + synchronizeLockKeys(scroll_lock, num_lock, caps_lock, kana_lock) { + const ret = wasm.session_synchronizeLockKeys(this.__wbg_ptr, scroll_lock, num_lock, caps_lock, kana_lock); + if (ret[1]) { + throw takeFromExternrefTable0(ret[0]); + } + } +} +if (Symbol.dispose) Session.prototype[Symbol.dispose] = Session.prototype.free; + +export class SessionBuilder { + static __wrap(ptr) { + ptr = ptr >>> 0; + const obj = Object.create(SessionBuilder.prototype); + obj.__wbg_ptr = ptr; + SessionBuilderFinalization.register(obj, obj.__wbg_ptr, obj); + return obj; + } + __destroy_into_raw() { + const ptr = this.__wbg_ptr; + this.__wbg_ptr = 0; + SessionBuilderFinalization.unregister(this); + return ptr; + } + free() { + const ptr = this.__destroy_into_raw(); + wasm.__wbg_sessionbuilder_free(ptr, 0); + } + /** + * @param {string} token + * @returns {SessionBuilder} + */ + authToken(token) { + const ptr0 = passStringToWasm0(token, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len0 = WASM_VECTOR_LEN; + const ret = wasm.sessionbuilder_authToken(this.__wbg_ptr, ptr0, len0); + return SessionBuilder.__wrap(ret); + } + /** + * @param {Function} callback + * @returns {SessionBuilder} + */ + canvasResizedCallback(callback) { + const ret = wasm.sessionbuilder_canvasResizedCallback(this.__wbg_ptr, callback); + return SessionBuilder.__wrap(ret); + } + /** + * @returns {Promise} + */ + connect() { + const ret = wasm.sessionbuilder_connect(this.__wbg_ptr); + return ret; + } + constructor() { + const ret = wasm.sessionbuilder_create(); + this.__wbg_ptr = ret >>> 0; + SessionBuilderFinalization.register(this, this.__wbg_ptr, this); + return this; + } + /** + * @param {DesktopSize} desktop_size + * @returns {SessionBuilder} + */ + desktopSize(desktop_size) { + _assertClass(desktop_size, DesktopSize); + var ptr0 = desktop_size.__destroy_into_raw(); + const ret = wasm.sessionbuilder_desktopSize(this.__wbg_ptr, ptr0); + return SessionBuilder.__wrap(ret); + } + /** + * @param {string} destination + * @returns {SessionBuilder} + */ + destination(destination) { + const ptr0 = passStringToWasm0(destination, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len0 = WASM_VECTOR_LEN; + const ret = wasm.sessionbuilder_destination(this.__wbg_ptr, ptr0, len0); + return SessionBuilder.__wrap(ret); + } + /** + * @param {Extension} ext + * @returns {SessionBuilder} + */ + extension(ext) { + _assertClass(ext, Extension); + var ptr0 = ext.__destroy_into_raw(); + const ret = wasm.sessionbuilder_extension(this.__wbg_ptr, ptr0); + return SessionBuilder.__wrap(ret); + } + /** + * @param {Function} callback + * @returns {SessionBuilder} + */ + forceClipboardUpdateCallback(callback) { + const ret = wasm.sessionbuilder_forceClipboardUpdateCallback(this.__wbg_ptr, callback); + return SessionBuilder.__wrap(ret); + } + /** + * @param {string} password + * @returns {SessionBuilder} + */ + password(password) { + const ptr0 = passStringToWasm0(password, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len0 = WASM_VECTOR_LEN; + const ret = wasm.sessionbuilder_password(this.__wbg_ptr, ptr0, len0); + return SessionBuilder.__wrap(ret); + } + /** + * @param {string} address + * @returns {SessionBuilder} + */ + proxyAddress(address) { + const ptr0 = passStringToWasm0(address, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len0 = WASM_VECTOR_LEN; + const ret = wasm.sessionbuilder_proxyAddress(this.__wbg_ptr, ptr0, len0); + return SessionBuilder.__wrap(ret); + } + /** + * @param {Function} callback + * @returns {SessionBuilder} + */ + remoteClipboardChangedCallback(callback) { + const ret = wasm.sessionbuilder_remoteClipboardChangedCallback(this.__wbg_ptr, callback); + return SessionBuilder.__wrap(ret); + } + /** + * @param {HTMLCanvasElement} canvas + * @returns {SessionBuilder} + */ + renderCanvas(canvas) { + const ret = wasm.sessionbuilder_renderCanvas(this.__wbg_ptr, canvas); + return SessionBuilder.__wrap(ret); + } + /** + * @param {string} server_domain + * @returns {SessionBuilder} + */ + serverDomain(server_domain) { + const ptr0 = passStringToWasm0(server_domain, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len0 = WASM_VECTOR_LEN; + const ret = wasm.sessionbuilder_serverDomain(this.__wbg_ptr, ptr0, len0); + return SessionBuilder.__wrap(ret); + } + /** + * @param {Function} callback + * @returns {SessionBuilder} + */ + setCursorStyleCallback(callback) { + const ret = wasm.sessionbuilder_setCursorStyleCallback(this.__wbg_ptr, callback); + return SessionBuilder.__wrap(ret); + } + /** + * @param {any} context + * @returns {SessionBuilder} + */ + setCursorStyleCallbackContext(context) { + const ret = wasm.sessionbuilder_setCursorStyleCallbackContext(this.__wbg_ptr, context); + return SessionBuilder.__wrap(ret); + } + /** + * @param {string} username + * @returns {SessionBuilder} + */ + username(username) { + const ptr0 = passStringToWasm0(username, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len0 = WASM_VECTOR_LEN; + const ret = wasm.sessionbuilder_username(this.__wbg_ptr, ptr0, len0); + return SessionBuilder.__wrap(ret); + } +} +if (Symbol.dispose) SessionBuilder.prototype[Symbol.dispose] = SessionBuilder.prototype.free; + +export class SessionTerminationInfo { + static __wrap(ptr) { + ptr = ptr >>> 0; + const obj = Object.create(SessionTerminationInfo.prototype); + obj.__wbg_ptr = ptr; + SessionTerminationInfoFinalization.register(obj, obj.__wbg_ptr, obj); + return obj; + } + __destroy_into_raw() { + const ptr = this.__wbg_ptr; + this.__wbg_ptr = 0; + SessionTerminationInfoFinalization.unregister(this); + return ptr; + } + free() { + const ptr = this.__destroy_into_raw(); + wasm.__wbg_sessionterminationinfo_free(ptr, 0); + } + /** + * @returns {string} + */ + reason() { + let deferred1_0; + let deferred1_1; + try { + const ret = wasm.sessionterminationinfo_reason(this.__wbg_ptr); + deferred1_0 = ret[0]; + deferred1_1 = ret[1]; + return getStringFromWasm0(ret[0], ret[1]); + } finally { + wasm.__wbindgen_free(deferred1_0, deferred1_1, 1); + } + } +} +if (Symbol.dispose) SessionTerminationInfo.prototype[Symbol.dispose] = SessionTerminationInfo.prototype.free; + +/** + * @param {string} log_level + */ +export function setup(log_level) { + const ptr0 = passStringToWasm0(log_level, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len0 = WASM_VECTOR_LEN; + wasm.setup(ptr0, len0); +} + +function __wbg_get_imports() { + const import0 = { + __proto__: null, + __wbg___wbindgen_boolean_get_bbbb1c18aa2f5e25: function(arg0) { + const v = arg0; + const ret = typeof(v) === 'boolean' ? v : undefined; + return isLikeNone(ret) ? 0xFFFFFF : ret ? 1 : 0; + }, + __wbg___wbindgen_debug_string_0bc8482c6e3508ae: function(arg0, arg1) { + const ret = debugString(arg1); + const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len1 = WASM_VECTOR_LEN; + getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); + getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); + }, + __wbg___wbindgen_is_function_0095a73b8b156f76: function(arg0) { + const ret = typeof(arg0) === 'function'; + return ret; + }, + __wbg___wbindgen_is_string_cd444516edc5b180: function(arg0) { + const ret = typeof(arg0) === 'string'; + return ret; + }, + __wbg___wbindgen_is_undefined_9e4d92534c42d778: function(arg0) { + const ret = arg0 === undefined; + return ret; + }, + __wbg___wbindgen_number_get_8ff4255516ccad3e: function(arg0, arg1) { + const obj = arg1; + const ret = typeof(obj) === 'number' ? obj : undefined; + getDataViewMemory0().setFloat64(arg0 + 8 * 1, isLikeNone(ret) ? 0 : ret, true); + getDataViewMemory0().setInt32(arg0 + 4 * 0, !isLikeNone(ret), true); + }, + __wbg___wbindgen_string_get_72fb696202c56729: function(arg0, arg1) { + const obj = arg1; + const ret = typeof(obj) === 'string' ? obj : undefined; + var ptr1 = isLikeNone(ret) ? 0 : passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + var len1 = WASM_VECTOR_LEN; + getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); + getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); + }, + __wbg___wbindgen_throw_be289d5034ed271b: function(arg0, arg1) { + throw new Error(getStringFromWasm0(arg0, arg1)); + }, + __wbg__wbg_cb_unref_d9b87ff7982e3b21: function(arg0) { + arg0._wbg_cb_unref(); + }, + __wbg_addEventListener_3acb0aad4483804c: function() { return handleError(function (arg0, arg1, arg2, arg3) { + arg0.addEventListener(getStringFromWasm0(arg1, arg2), arg3); + }, arguments); }, + __wbg_addEventListener_c917b5aafbcf493f: function() { return handleError(function (arg0, arg1, arg2, arg3, arg4) { + arg0.addEventListener(getStringFromWasm0(arg1, arg2), arg3, arg4); + }, arguments); }, + __wbg_apply_ada2ee1a60ac7b3c: function() { return handleError(function (arg0, arg1, arg2) { + const ret = arg0.apply(arg1, arg2); + return ret; + }, arguments); }, + __wbg_arrayBuffer_bb54076166006c39: function() { return handleError(function (arg0) { + const ret = arg0.arrayBuffer(); + return ret; + }, arguments); }, + __wbg_call_389efe28435a9388: function() { return handleError(function (arg0, arg1) { + const ret = arg0.call(arg1); + return ret; + }, arguments); }, + __wbg_call_4708e0c13bdc8e95: function() { return handleError(function (arg0, arg1, arg2) { + const ret = arg0.call(arg1, arg2); + return ret; + }, arguments); }, + __wbg_clearTimeout_5a54f8841c30079a: function(arg0) { + const ret = clearTimeout(arg0); + return ret; + }, + __wbg_clipboarddata_new: function(arg0) { + const ret = ClipboardData.__wrap(arg0); + return ret; + }, + __wbg_clipboarditem_new: function(arg0) { + const ret = ClipboardItem.__wrap(arg0); + return ret; + }, + __wbg_close_1d08eaf57ed325c0: function() { return handleError(function (arg0) { + arg0.close(); + }, arguments); }, + __wbg_code_a552f1e91eda69b7: function(arg0) { + const ret = arg0.code; + return ret; + }, + __wbg_data_5330da50312d0bc1: function(arg0) { + const ret = arg0.data; + return ret; + }, + __wbg_debug_a4099fa12db6cd61: function(arg0) { + console.debug(arg0); + }, + __wbg_dispatchEvent_dc8dcc7ddca11378: function() { return handleError(function (arg0, arg1) { + const ret = arg0.dispatchEvent(arg1); + return ret; + }, arguments); }, + __wbg_error_7534b8e9a36f1ab4: function(arg0, arg1) { + let deferred0_0; + let deferred0_1; + try { + deferred0_0 = arg0; + deferred0_1 = arg1; + console.error(getStringFromWasm0(arg0, arg1)); + } finally { + wasm.__wbindgen_free(deferred0_0, deferred0_1, 1); + } + }, + __wbg_error_9a7fe3f932034cde: function(arg0) { + console.error(arg0); + }, + __wbg_fetch_a9bc66c159c18e19: function(arg0) { + const ret = fetch(arg0); + return ret; + }, + __wbg_getContext_2a5764d48600bc43: function() { return handleError(function (arg0, arg1, arg2) { + const ret = arg0.getContext(getStringFromWasm0(arg1, arg2)); + return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); + }, arguments); }, + __wbg_getRandomValues_1c61fac11405ffdc: function() { return handleError(function (arg0, arg1) { + globalThis.crypto.getRandomValues(getArrayU8FromWasm0(arg0, arg1)); + }, arguments); }, + __wbg_getRandomValues_71d446877d8b0ad4: function() { return handleError(function (arg0, arg1) { + globalThis.crypto.getRandomValues(getArrayU8FromWasm0(arg0, arg1)); + }, arguments); }, + __wbg_getTime_1e3cd1391c5c3995: function(arg0) { + const ret = arg0.getTime(); + return ret; + }, + __wbg_info_148d043840582012: function(arg0) { + console.info(arg0); + }, + __wbg_instanceof_ArrayBuffer_c367199e2fa2aa04: function(arg0) { + let result; + try { + result = arg0 instanceof ArrayBuffer; + } catch (_) { + result = false; + } + const ret = result; + return ret; + }, + __wbg_instanceof_CanvasRenderingContext2d_4bb052fd1c3d134d: function(arg0) { + let result; + try { + result = arg0 instanceof CanvasRenderingContext2D; + } catch (_) { + result = false; + } + const ret = result; + return ret; + }, + __wbg_instanceof_Error_8573fe0b0b480f46: function(arg0) { + let result; + try { + result = arg0 instanceof Error; + } catch (_) { + result = false; + } + const ret = result; + return ret; + }, + __wbg_instanceof_Response_ee1d54d79ae41977: function(arg0) { + let result; + try { + result = arg0 instanceof Response; + } catch (_) { + result = false; + } + const ret = result; + return ret; + }, + __wbg_ironerror_new: function(arg0) { + const ret = IronError.__wrap(arg0); + return ret; + }, + __wbg_length_32ed9a279acd054c: function(arg0) { + const ret = arg0.length; + return ret; + }, + __wbg_message_9ddc4b9a62a7c379: function(arg0) { + const ret = arg0.message; + return ret; + }, + __wbg_name_446e25ef2cfdab5a: function(arg0) { + const ret = arg0.name; + return ret; + }, + __wbg_new_057993d5b5e07835: function() { return handleError(function (arg0, arg1) { + const ret = new WebSocket(getStringFromWasm0(arg0, arg1)); + return ret; + }, arguments); }, + __wbg_new_074b505417ada2d9: function() { return handleError(function () { + const ret = new URLSearchParams(); + return ret; + }, arguments); }, + __wbg_new_0_73afc35eb544e539: function() { + const ret = new Date(); + return ret; + }, + __wbg_new_361308b2356cecd0: function() { + const ret = new Object(); + return ret; + }, + __wbg_new_3eb36ae241fe6f44: function() { + const ret = new Array(); + return ret; + }, + __wbg_new_64284bd487f9d239: function() { return handleError(function () { + const ret = new Headers(); + return ret; + }, arguments); }, + __wbg_new_8a6f238a6ece86ea: function() { + const ret = new Error(); + return ret; + }, + __wbg_new_b5d9e2fb389fef91: function(arg0, arg1) { + try { + var state0 = {a: arg0, b: arg1}; + var cb0 = (arg0, arg1) => { + const a = state0.a; + state0.a = 0; + try { + return wasm_bindgen__convert__closures_____invoke__h669f05f296efaf3f(a, state0.b, arg0, arg1); + } finally { + state0.a = a; + } + }; + const ret = new Promise(cb0); + return ret; + } finally { + state0.a = state0.b = 0; + } + }, + __wbg_new_c2f21774701ddac7: function() { return handleError(function (arg0, arg1) { + const ret = new URL(getStringFromWasm0(arg0, arg1)); + return ret; + }, arguments); }, + __wbg_new_dd2b680c8bf6ae29: function(arg0) { + const ret = new Uint8Array(arg0); + return ret; + }, + __wbg_new_from_slice_a3d2629dc1826784: function(arg0, arg1) { + const ret = new Uint8Array(getArrayU8FromWasm0(arg0, arg1)); + return ret; + }, + __wbg_new_no_args_1c7c842f08d00ebb: function(arg0, arg1) { + const ret = new Function(getStringFromWasm0(arg0, arg1)); + return ret; + }, + __wbg_new_with_event_init_dict_7721feeda3e1e6fa: function() { return handleError(function (arg0, arg1, arg2) { + const ret = new CloseEvent(getStringFromWasm0(arg0, arg1), arg2); + return ret; + }, arguments); }, + __wbg_new_with_str_a7c7f835549b152a: function() { return handleError(function (arg0, arg1) { + const ret = new Request(getStringFromWasm0(arg0, arg1)); + return ret; + }, arguments); }, + __wbg_new_with_str_and_init_a61cbc6bdef21614: function() { return handleError(function (arg0, arg1, arg2) { + const ret = new Request(getStringFromWasm0(arg0, arg1), arg2); + return ret; + }, arguments); }, + __wbg_new_with_u8_clamped_array_f2b5767e0116d2f8: function() { return handleError(function (arg0, arg1, arg2) { + const ret = new ImageData(getClampedArrayU8FromWasm0(arg0, arg1), arg2 >>> 0); + return ret; + }, arguments); }, + __wbg_ok_87f537440a0acf85: function(arg0) { + const ret = arg0.ok; + return ret; + }, + __wbg_prototypesetcall_bdcdcc5842e4d77d: function(arg0, arg1, arg2) { + Uint8Array.prototype.set.call(getArrayU8FromWasm0(arg0, arg1), arg2); + }, + __wbg_push_8ffdcb2063340ba5: function(arg0, arg1) { + const ret = arg0.push(arg1); + return ret; + }, + __wbg_putImageData_00f5824abb820c48: function() { return handleError(function (arg0, arg1, arg2, arg3, arg4, arg5, arg6, arg7) { + arg0.putImageData(arg1, arg2, arg3, arg4, arg5, arg6, arg7); + }, arguments); }, + __wbg_putImageData_3bac58450f9fc6d4: function() { return handleError(function (arg0, arg1, arg2, arg3, arg4, arg5, arg6, arg7) { + arg0.putImageData(arg1, arg2, arg3, arg4, arg5, arg6, arg7); + }, arguments); }, + __wbg_queueMicrotask_0aa0a927f78f5d98: function(arg0) { + const ret = arg0.queueMicrotask; + return ret; + }, + __wbg_queueMicrotask_5bb536982f78a56f: function(arg0) { + queueMicrotask(arg0); + }, + __wbg_readyState_1bb73ec7b8a54656: function(arg0) { + const ret = arg0.readyState; + return ret; + }, + __wbg_reason_35fce8e55dd90f31: function(arg0, arg1) { + const ret = arg1.reason; + const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len1 = WASM_VECTOR_LEN; + getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); + getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); + }, + __wbg_removeEventListener_e63328781a5b9af9: function() { return handleError(function (arg0, arg1, arg2, arg3) { + arg0.removeEventListener(getStringFromWasm0(arg1, arg2), arg3); + }, arguments); }, + __wbg_resolve_002c4b7d9d8f6b64: function(arg0) { + const ret = Promise.resolve(arg0); + return ret; + }, + __wbg_search_143f09a35047e800: function(arg0, arg1) { + const ret = arg1.search; + const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len1 = WASM_VECTOR_LEN; + getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); + getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); + }, + __wbg_send_542f95dea2df7994: function() { return handleError(function (arg0, arg1, arg2) { + arg0.send(getArrayU8FromWasm0(arg1, arg2)); + }, arguments); }, + __wbg_session_new: function(arg0) { + const ret = Session.__wrap(arg0); + return ret; + }, + __wbg_sessionterminationinfo_new: function(arg0) { + const ret = SessionTerminationInfo.__wrap(arg0); + return ret; + }, + __wbg_setTimeout_db2dbaeefb6f39c7: function() { return handleError(function (arg0, arg1) { + const ret = setTimeout(arg0, arg1); + return ret; + }, arguments); }, + __wbg_set_binaryType_5bbf62e9f705dc1a: function(arg0, arg1) { + arg0.binaryType = __wbindgen_enum_BinaryType[arg1]; + }, + __wbg_set_body_9a7e00afe3cfe244: function(arg0, arg1) { + arg0.body = arg1; + }, + __wbg_set_code_86d60a9542684e59: function(arg0, arg1) { + arg0.code = arg1; + }, + __wbg_set_db769d02949a271d: function() { return handleError(function (arg0, arg1, arg2, arg3, arg4) { + arg0.set(getStringFromWasm0(arg1, arg2), getStringFromWasm0(arg3, arg4)); + }, arguments); }, + __wbg_set_headers_cfc5f4b2c1f20549: function(arg0, arg1) { + arg0.headers = arg1; + }, + __wbg_set_height_b386c0f603610637: function(arg0, arg1) { + arg0.height = arg1 >>> 0; + }, + __wbg_set_height_f21f985387070100: function(arg0, arg1) { + arg0.height = arg1 >>> 0; + }, + __wbg_set_method_c3e20375f5ae7fac: function(arg0, arg1, arg2) { + arg0.method = getStringFromWasm0(arg1, arg2); + }, + __wbg_set_once_56ba1b87a9884c15: function(arg0, arg1) { + arg0.once = arg1 !== 0; + }, + __wbg_set_reason_4e11e27980c30a7f: function(arg0, arg1, arg2) { + arg0.reason = getStringFromWasm0(arg1, arg2); + }, + __wbg_set_search_1d369b0f3868e132: function(arg0, arg1, arg2) { + arg0.search = getStringFromWasm0(arg1, arg2); + }, + __wbg_set_width_7f07715a20503914: function(arg0, arg1) { + arg0.width = arg1 >>> 0; + }, + __wbg_set_width_d60bc4f2f20c56a4: function(arg0, arg1) { + arg0.width = arg1 >>> 0; + }, + __wbg_stack_0ed75d68575b0f3c: function(arg0, arg1) { + const ret = arg1.stack; + const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len1 = WASM_VECTOR_LEN; + getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); + getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); + }, + __wbg_static_accessor_GLOBAL_12837167ad935116: function() { + const ret = typeof global === 'undefined' ? null : global; + return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); + }, + __wbg_static_accessor_GLOBAL_THIS_e628e89ab3b1c95f: function() { + const ret = typeof globalThis === 'undefined' ? null : globalThis; + return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); + }, + __wbg_static_accessor_SELF_a621d3dfbb60d0ce: function() { + const ret = typeof self === 'undefined' ? null : self; + return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); + }, + __wbg_static_accessor_WINDOW_f8727f0cf888e0bd: function() { + const ret = typeof window === 'undefined' ? null : window; + return isLikeNone(ret) ? 0 : addToExternrefTable0(ret); + }, + __wbg_statusText_556131a02d60f5cd: function(arg0, arg1) { + const ret = arg1.statusText; + const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len1 = WASM_VECTOR_LEN; + getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); + getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); + }, + __wbg_status_89d7e803db911ee7: function(arg0) { + const ret = arg0.status; + return ret; + }, + __wbg_then_0d9fe2c7b1857d32: function(arg0, arg1, arg2) { + const ret = arg0.then(arg1, arg2); + return ret; + }, + __wbg_then_b9e7b3b5f1a9e1b5: function(arg0, arg1) { + const ret = arg0.then(arg1); + return ret; + }, + __wbg_toString_029ac24421fd7a24: function(arg0) { + const ret = arg0.toString(); + return ret; + }, + __wbg_toString_964ff7fe6eca8362: function(arg0) { + const ret = arg0.toString(); + return ret; + }, + __wbg_url_36c39f6580d05409: function(arg0, arg1) { + const ret = arg1.url; + const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc); + const len1 = WASM_VECTOR_LEN; + getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true); + getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true); + }, + __wbg_warn_f7ae1b2e66ccb930: function(arg0) { + console.warn(arg0); + }, + __wbg_wasClean_a9c77a7100d8534f: function(arg0) { + const ret = arg0.wasClean; + return ret; + }, + __wbindgen_cast_0000000000000001: function(arg0, arg1) { + // Cast intrinsic for `Closure(Closure { dtor_idx: 1041, function: Function { arguments: [Externref], shim_idx: 1042, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. + const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen__closure__destroy__h7411975fc5b05212, wasm_bindgen__convert__closures_____invoke__h88f98f15e682df2b); + return ret; + }, + __wbindgen_cast_0000000000000002: function(arg0, arg1) { + // Cast intrinsic for `Closure(Closure { dtor_idx: 977, function: Function { arguments: [], shim_idx: 978, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. + const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen__closure__destroy__hecfd81b7341edafd, wasm_bindgen__convert__closures_____invoke__h20ee4a831f4b0370); + return ret; + }, + __wbindgen_cast_0000000000000003: function(arg0, arg1) { + // Cast intrinsic for `Closure(Closure { dtor_idx: 995, function: Function { arguments: [NamedExternref("CloseEvent")], shim_idx: 996, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. + const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen__closure__destroy__ha4492a99f6cb603e, wasm_bindgen__convert__closures_____invoke__h8097285326869e3c); + return ret; + }, + __wbindgen_cast_0000000000000004: function(arg0, arg1) { + // Cast intrinsic for `Closure(Closure { dtor_idx: 995, function: Function { arguments: [NamedExternref("Event")], shim_idx: 996, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. + const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen__closure__destroy__ha4492a99f6cb603e, wasm_bindgen__convert__closures_____invoke__h8097285326869e3c); + return ret; + }, + __wbindgen_cast_0000000000000005: function(arg0, arg1) { + // Cast intrinsic for `Closure(Closure { dtor_idx: 995, function: Function { arguments: [NamedExternref("MessageEvent")], shim_idx: 996, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. + const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen__closure__destroy__ha4492a99f6cb603e, wasm_bindgen__convert__closures_____invoke__h8097285326869e3c); + return ret; + }, + __wbindgen_cast_0000000000000006: function(arg0, arg1) { + // Cast intrinsic for `Closure(Closure { dtor_idx: 995, function: Function { arguments: [], shim_idx: 998, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`. + const ret = makeMutClosure(arg0, arg1, wasm.wasm_bindgen__closure__destroy__ha4492a99f6cb603e, wasm_bindgen__convert__closures_____invoke__h77be7343eda2df8f); + return ret; + }, + __wbindgen_cast_0000000000000007: function(arg0) { + // Cast intrinsic for `F64 -> Externref`. + const ret = arg0; + return ret; + }, + __wbindgen_cast_0000000000000008: function(arg0, arg1) { + // Cast intrinsic for `Ref(String) -> Externref`. + const ret = getStringFromWasm0(arg0, arg1); + return ret; + }, + __wbindgen_init_externref_table: function() { + const table = wasm.__wbindgen_externrefs; + const offset = table.grow(4); + table.set(0, undefined); + table.set(offset + 0, undefined); + table.set(offset + 1, null); + table.set(offset + 2, true); + table.set(offset + 3, false); + }, + }; + return { + __proto__: null, + "./ironrdp_web_bg.js": import0, + }; +} + +function wasm_bindgen__convert__closures_____invoke__h20ee4a831f4b0370(arg0, arg1) { + wasm.wasm_bindgen__convert__closures_____invoke__h20ee4a831f4b0370(arg0, arg1); +} + +function wasm_bindgen__convert__closures_____invoke__h77be7343eda2df8f(arg0, arg1) { + wasm.wasm_bindgen__convert__closures_____invoke__h77be7343eda2df8f(arg0, arg1); +} + +function wasm_bindgen__convert__closures_____invoke__h88f98f15e682df2b(arg0, arg1, arg2) { + wasm.wasm_bindgen__convert__closures_____invoke__h88f98f15e682df2b(arg0, arg1, arg2); +} + +function wasm_bindgen__convert__closures_____invoke__h8097285326869e3c(arg0, arg1, arg2) { + wasm.wasm_bindgen__convert__closures_____invoke__h8097285326869e3c(arg0, arg1, arg2); +} + +function wasm_bindgen__convert__closures_____invoke__h669f05f296efaf3f(arg0, arg1, arg2, arg3) { + wasm.wasm_bindgen__convert__closures_____invoke__h669f05f296efaf3f(arg0, arg1, arg2, arg3); +} + + +const __wbindgen_enum_BinaryType = ["blob", "arraybuffer"]; +const ClipboardDataFinalization = (typeof FinalizationRegistry === 'undefined') + ? { register: () => {}, unregister: () => {} } + : new FinalizationRegistry(ptr => wasm.__wbg_clipboarddata_free(ptr >>> 0, 1)); +const ClipboardItemFinalization = (typeof FinalizationRegistry === 'undefined') + ? { register: () => {}, unregister: () => {} } + : new FinalizationRegistry(ptr => wasm.__wbg_clipboarditem_free(ptr >>> 0, 1)); +const DesktopSizeFinalization = (typeof FinalizationRegistry === 'undefined') + ? { register: () => {}, unregister: () => {} } + : new FinalizationRegistry(ptr => wasm.__wbg_desktopsize_free(ptr >>> 0, 1)); +const DeviceEventFinalization = (typeof FinalizationRegistry === 'undefined') + ? { register: () => {}, unregister: () => {} } + : new FinalizationRegistry(ptr => wasm.__wbg_deviceevent_free(ptr >>> 0, 1)); +const ExtensionFinalization = (typeof FinalizationRegistry === 'undefined') + ? { register: () => {}, unregister: () => {} } + : new FinalizationRegistry(ptr => wasm.__wbg_extension_free(ptr >>> 0, 1)); +const InputTransactionFinalization = (typeof FinalizationRegistry === 'undefined') + ? { register: () => {}, unregister: () => {} } + : new FinalizationRegistry(ptr => wasm.__wbg_inputtransaction_free(ptr >>> 0, 1)); +const IronErrorFinalization = (typeof FinalizationRegistry === 'undefined') + ? { register: () => {}, unregister: () => {} } + : new FinalizationRegistry(ptr => wasm.__wbg_ironerror_free(ptr >>> 0, 1)); +const RDCleanPathDetailsFinalization = (typeof FinalizationRegistry === 'undefined') + ? { register: () => {}, unregister: () => {} } + : new FinalizationRegistry(ptr => wasm.__wbg_rdcleanpathdetails_free(ptr >>> 0, 1)); +const RdpFileFinalization = (typeof FinalizationRegistry === 'undefined') + ? { register: () => {}, unregister: () => {} } + : new FinalizationRegistry(ptr => wasm.__wbg_rdpfile_free(ptr >>> 0, 1)); +const SessionFinalization = (typeof FinalizationRegistry === 'undefined') + ? { register: () => {}, unregister: () => {} } + : new FinalizationRegistry(ptr => wasm.__wbg_session_free(ptr >>> 0, 1)); +const SessionBuilderFinalization = (typeof FinalizationRegistry === 'undefined') + ? { register: () => {}, unregister: () => {} } + : new FinalizationRegistry(ptr => wasm.__wbg_sessionbuilder_free(ptr >>> 0, 1)); +const SessionTerminationInfoFinalization = (typeof FinalizationRegistry === 'undefined') + ? { register: () => {}, unregister: () => {} } + : new FinalizationRegistry(ptr => wasm.__wbg_sessionterminationinfo_free(ptr >>> 0, 1)); + +function addToExternrefTable0(obj) { + const idx = wasm.__externref_table_alloc(); + wasm.__wbindgen_externrefs.set(idx, obj); + return idx; +} + +function _assertChar(c) { + if (typeof(c) === 'number' && (c >= 0x110000 || (c >= 0xD800 && c < 0xE000))) throw new Error(`expected a valid Unicode scalar value, found ${c}`); +} + +function _assertClass(instance, klass) { + if (!(instance instanceof klass)) { + throw new Error(`expected instance of ${klass.name}`); + } +} + +const CLOSURE_DTORS = (typeof FinalizationRegistry === 'undefined') + ? { register: () => {}, unregister: () => {} } + : new FinalizationRegistry(state => state.dtor(state.a, state.b)); + +function debugString(val) { + // primitive types + const type = typeof val; + if (type == 'number' || type == 'boolean' || val == null) { + return `${val}`; + } + if (type == 'string') { + return `"${val}"`; + } + if (type == 'symbol') { + const description = val.description; + if (description == null) { + return 'Symbol'; + } else { + return `Symbol(${description})`; + } + } + if (type == 'function') { + const name = val.name; + if (typeof name == 'string' && name.length > 0) { + return `Function(${name})`; + } else { + return 'Function'; + } + } + // objects + if (Array.isArray(val)) { + const length = val.length; + let debug = '['; + if (length > 0) { + debug += debugString(val[0]); + } + for(let i = 1; i < length; i++) { + debug += ', ' + debugString(val[i]); + } + debug += ']'; + return debug; + } + // Test for built-in + const builtInMatches = /\[object ([^\]]+)\]/.exec(toString.call(val)); + let className; + if (builtInMatches && builtInMatches.length > 1) { + className = builtInMatches[1]; + } else { + // Failed to match the standard '[object ClassName]' + return toString.call(val); + } + if (className == 'Object') { + // we're a user defined class or Object + // JSON.stringify avoids problems with cycles, and is generally much + // easier than looping through ownProperties of `val`. + try { + return 'Object(' + JSON.stringify(val) + ')'; + } catch (_) { + return 'Object'; + } + } + // errors + if (val instanceof Error) { + return `${val.name}: ${val.message}\n${val.stack}`; + } + // TODO we could test for more things here, like `Set`s and `Map`s. + return className; +} + +function getArrayJsValueFromWasm0(ptr, len) { + ptr = ptr >>> 0; + const mem = getDataViewMemory0(); + const result = []; + for (let i = ptr; i < ptr + 4 * len; i += 4) { + result.push(wasm.__wbindgen_externrefs.get(mem.getUint32(i, true))); + } + wasm.__externref_drop_slice(ptr, len); + return result; +} + +function getArrayU8FromWasm0(ptr, len) { + ptr = ptr >>> 0; + return getUint8ArrayMemory0().subarray(ptr / 1, ptr / 1 + len); +} + +function getClampedArrayU8FromWasm0(ptr, len) { + ptr = ptr >>> 0; + return getUint8ClampedArrayMemory0().subarray(ptr / 1, ptr / 1 + len); +} + +let cachedDataViewMemory0 = null; +function getDataViewMemory0() { + if (cachedDataViewMemory0 === null || cachedDataViewMemory0.buffer.detached === true || (cachedDataViewMemory0.buffer.detached === undefined && cachedDataViewMemory0.buffer !== wasm.memory.buffer)) { + cachedDataViewMemory0 = new DataView(wasm.memory.buffer); + } + return cachedDataViewMemory0; +} + +function getStringFromWasm0(ptr, len) { + ptr = ptr >>> 0; + return decodeText(ptr, len); +} + +let cachedUint8ArrayMemory0 = null; +function getUint8ArrayMemory0() { + if (cachedUint8ArrayMemory0 === null || cachedUint8ArrayMemory0.byteLength === 0) { + cachedUint8ArrayMemory0 = new Uint8Array(wasm.memory.buffer); + } + return cachedUint8ArrayMemory0; +} + +let cachedUint8ClampedArrayMemory0 = null; +function getUint8ClampedArrayMemory0() { + if (cachedUint8ClampedArrayMemory0 === null || cachedUint8ClampedArrayMemory0.byteLength === 0) { + cachedUint8ClampedArrayMemory0 = new Uint8ClampedArray(wasm.memory.buffer); + } + return cachedUint8ClampedArrayMemory0; +} + +function handleError(f, args) { + try { + return f.apply(this, args); + } catch (e) { + const idx = addToExternrefTable0(e); + wasm.__wbindgen_exn_store(idx); + } +} + +function isLikeNone(x) { + return x === undefined || x === null; +} + +function makeMutClosure(arg0, arg1, dtor, f) { + const state = { a: arg0, b: arg1, cnt: 1, dtor }; + const real = (...args) => { + + // First up with a closure we increment the internal reference + // count. This ensures that the Rust closure environment won't + // be deallocated while we're invoking it. + state.cnt++; + const a = state.a; + state.a = 0; + try { + return f(a, state.b, ...args); + } finally { + state.a = a; + real._wbg_cb_unref(); + } + }; + real._wbg_cb_unref = () => { + if (--state.cnt === 0) { + state.dtor(state.a, state.b); + state.a = 0; + CLOSURE_DTORS.unregister(state); + } + }; + CLOSURE_DTORS.register(real, state, state); + return real; +} + +function passArray8ToWasm0(arg, malloc) { + const ptr = malloc(arg.length * 1, 1) >>> 0; + getUint8ArrayMemory0().set(arg, ptr / 1); + WASM_VECTOR_LEN = arg.length; + return ptr; +} + +function passStringToWasm0(arg, malloc, realloc) { + if (realloc === undefined) { + const buf = cachedTextEncoder.encode(arg); + const ptr = malloc(buf.length, 1) >>> 0; + getUint8ArrayMemory0().subarray(ptr, ptr + buf.length).set(buf); + WASM_VECTOR_LEN = buf.length; + return ptr; + } + + let len = arg.length; + let ptr = malloc(len, 1) >>> 0; + + const mem = getUint8ArrayMemory0(); + + let offset = 0; + + for (; offset < len; offset++) { + const code = arg.charCodeAt(offset); + if (code > 0x7F) break; + mem[ptr + offset] = code; + } + if (offset !== len) { + if (offset !== 0) { + arg = arg.slice(offset); + } + ptr = realloc(ptr, len, len = offset + arg.length * 3, 1) >>> 0; + const view = getUint8ArrayMemory0().subarray(ptr + offset, ptr + len); + const ret = cachedTextEncoder.encodeInto(arg, view); + + offset += ret.written; + ptr = realloc(ptr, len, offset, 1) >>> 0; + } + + WASM_VECTOR_LEN = offset; + return ptr; +} + +function takeFromExternrefTable0(idx) { + const value = wasm.__wbindgen_externrefs.get(idx); + wasm.__externref_table_dealloc(idx); + return value; +} + +let cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true }); +cachedTextDecoder.decode(); +const MAX_SAFARI_DECODE_BYTES = 2146435072; +let numBytesDecoded = 0; +function decodeText(ptr, len) { + numBytesDecoded += len; + if (numBytesDecoded >= MAX_SAFARI_DECODE_BYTES) { + cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true }); + cachedTextDecoder.decode(); + numBytesDecoded = len; + } + return cachedTextDecoder.decode(getUint8ArrayMemory0().subarray(ptr, ptr + len)); +} + +const cachedTextEncoder = new TextEncoder(); + +if (!('encodeInto' in cachedTextEncoder)) { + cachedTextEncoder.encodeInto = function (arg, view) { + const buf = cachedTextEncoder.encode(arg); + view.set(buf); + return { + read: arg.length, + written: buf.length + }; + }; +} + +let WASM_VECTOR_LEN = 0; + +let wasmModule, wasm; +function __wbg_finalize_init(instance, module) { + wasm = instance.exports; + wasmModule = module; + cachedDataViewMemory0 = null; + cachedUint8ArrayMemory0 = null; + cachedUint8ClampedArrayMemory0 = null; + wasm.__wbindgen_start(); + return wasm; +} + +async function __wbg_load(module, imports) { + if (typeof Response === 'function' && module instanceof Response) { + if (typeof WebAssembly.instantiateStreaming === 'function') { + try { + return await WebAssembly.instantiateStreaming(module, imports); + } catch (e) { + const validResponse = module.ok && expectedResponseType(module.type); + + if (validResponse && module.headers.get('Content-Type') !== 'application/wasm') { + console.warn("`WebAssembly.instantiateStreaming` failed because your server does not serve Wasm with `application/wasm` MIME type. Falling back to `WebAssembly.instantiate` which is slower. Original error:\n", e); + + } else { throw e; } + } + } + + const bytes = await module.arrayBuffer(); + return await WebAssembly.instantiate(bytes, imports); + } else { + const instance = await WebAssembly.instantiate(module, imports); + + if (instance instanceof WebAssembly.Instance) { + return { instance, module }; + } else { + return instance; + } + } + + function expectedResponseType(type) { + switch (type) { + case 'basic': case 'cors': case 'default': return true; + } + return false; + } +} + +function initSync(module) { + if (wasm !== undefined) return wasm; + + + if (module !== undefined) { + if (Object.getPrototypeOf(module) === Object.prototype) { + ({module} = module) + } else { + console.warn('using deprecated parameters for `initSync()`; pass a single object instead') + } + } + + const imports = __wbg_get_imports(); + if (!(module instanceof WebAssembly.Module)) { + module = new WebAssembly.Module(module); + } + const instance = new WebAssembly.Instance(module, imports); + return __wbg_finalize_init(instance, module); +} + +async function __wbg_init(module_or_path) { + if (wasm !== undefined) return wasm; + + + if (module_or_path !== undefined) { + if (Object.getPrototypeOf(module_or_path) === Object.prototype) { + ({module_or_path} = module_or_path) + } else { + console.warn('using deprecated parameters for the initialization function; pass a single object instead') + } + } + + if (module_or_path === undefined) { + module_or_path = new URL('ironrdp_web_bg.wasm', import.meta.url); + } + const imports = __wbg_get_imports(); + + if (typeof module_or_path === 'string' || (typeof Request === 'function' && module_or_path instanceof Request) || (typeof URL === 'function' && module_or_path instanceof URL)) { + module_or_path = fetch(module_or_path); + } + + const { instance, module } = await __wbg_load(await module_or_path, imports); + + return __wbg_finalize_init(instance, module); +} + +export { initSync, __wbg_init as default }; diff --git a/bridges/punchd-bridge/gateway/public/wasm/ironrdp_web_bg.wasm b/bridges/punchd-bridge/gateway/public/wasm/ironrdp_web_bg.wasm new file mode 100644 index 0000000..e47d7c9 Binary files /dev/null and b/bridges/punchd-bridge/gateway/public/wasm/ironrdp_web_bg.wasm differ diff --git a/bridges/punchd-bridge/gateway/src/config.ts b/bridges/punchd-bridge/gateway/src/config.ts index 406a93b..f3da169 100644 --- a/bridges/punchd-bridge/gateway/src/config.ts +++ b/bridges/punchd-bridge/gateway/src/config.ts @@ -17,6 +17,8 @@ const __dirname = dirname(__filename); export interface BackendEntry { name: string; url: string; + /** Protocol: "http" (default web proxy) or "rdp" (TCP tunnel to RDP server) */ + protocol?: "http" | "rdp"; /** Skip gateway JWT validation — backend handles its own auth */ noAuth?: boolean; /** Strip Authorization header before proxying to this backend */ @@ -137,7 +139,9 @@ function parseBackends(): BackendEntry[] { changed = true; } } - return { name: entry.slice(0, eq).trim(), url: rawUrl, noAuth: noAuth || undefined, stripAuth: stripAuth || undefined }; + // Detect protocol from URL scheme + const protocol: "http" | "rdp" = rawUrl.startsWith("rdp://") ? "rdp" : "http"; + return { name: entry.slice(0, eq).trim(), url: rawUrl, protocol, noAuth: noAuth || undefined, stripAuth: stripAuth || undefined }; }).filter((b) => b.url); } diff --git a/bridges/punchd-bridge/gateway/src/index.ts b/bridges/punchd-bridge/gateway/src/index.ts index 3e5d30d..2dc732d 100644 --- a/bridges/punchd-bridge/gateway/src/index.ts +++ b/bridges/punchd-bridge/gateway/src/index.ts @@ -72,9 +72,12 @@ async function main() { metadata: { displayName: config.displayName, description: config.description, - backends: config.backends.map((b) => ({ name: b.name })), + backends: config.backends.map((b) => ({ name: b.name, protocol: b.protocol || "http" })), realm: tcConfig.realm, }, + backends: config.backends, + verifyToken: (token: string) => auth.verifyToken(token), + tcClientId: tcConfig.resource, addresses: [`${getLocalAddress()}:${config.listenPort}`], onPaired(client) { console.log( diff --git a/bridges/punchd-bridge/gateway/src/proxy/http-proxy.ts b/bridges/punchd-bridge/gateway/src/proxy/http-proxy.ts index b2c1fcb..2720663 100644 --- a/bridges/punchd-bridge/gateway/src/proxy/http-proxy.ts +++ b/bridges/punchd-bridge/gateway/src/proxy/http-proxy.ts @@ -42,7 +42,7 @@ import { export interface ProxyOptions { listenPort: number; backendUrl: string; - backends?: { name: string; url: string; noAuth?: boolean; stripAuth?: boolean }[]; + backends?: { name: string; url: string; protocol?: string; noAuth?: boolean; stripAuth?: boolean }[]; auth: TidecloakAuth; stripAuthHeader: boolean; tcConfig: TidecloakConfig; @@ -132,6 +132,32 @@ function serveFile( } } +/** Serve a binary static file (e.g. .wasm) without UTF-8 conversion */ +function serveBinaryFile( + res: ServerResponse, + filename: string, + contentType: string +): void { + try { + const resolved = resolve(PUBLIC_DIR, filename); + const realPath = realpathSync(resolved); + if (!realPath.startsWith(PUBLIC_DIR + "/")) { + res.writeHead(403, { "Content-Type": "text/plain" }); + res.end("Forbidden"); + return; + } + const content = readFileSync(realPath); + res.writeHead(200, { + "Content-Type": contentType, + "Cache-Control": "public, max-age=86400", + }); + 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 { @@ -160,6 +186,19 @@ function isBrowserRequest(req: IncomingMessage): boolean { return accept.includes("text/html"); } +/** Browser-initiated resource requests that don't contain sensitive data. + * Without this exemption they 401 before the session token is refreshed. */ +function isPublicResource(path: string): boolean { + const basename = path.split("/").pop() || ""; + return ( + basename === "manifest.json" || + basename.endsWith(".webmanifest") || + basename === "browserconfig.xml" || + basename === "robots.txt" || + basename.endsWith(".ico") + ); +} + function getCallbackUrl(req: IncomingMessage, isTls: boolean): string { const proto = isTls ? "https" : "http"; const host = req.headers.host || `localhost`; @@ -216,10 +255,11 @@ export function createProxy(options: ProxyOptions): { rejectedRequests: 0, }; - // Build backend lookup map (name → URL) + // Build backend lookup map (name → URL) — skip non-HTTP backends (e.g. rdp://) const backendMap = new Map(); if (options.backends?.length) { for (const b of options.backends) { + if (b.protocol && b.protocol !== "http") continue; backendMap.set(b.name, new URL(b.url)); } } @@ -469,9 +509,83 @@ export function createProxy(options: ProxyOptions): { // Server-side endpoints (token exchange, refresh) always use internal URL const serverEndpoints: OidcEndpoints = getOidcEndpoints(options.tcConfig, tcInternalUrl); const clientId = options.tcConfig.resource; + + // ── Refresh token dedup cache ───────────────────────────────── + // When the access token expires, multiple concurrent requests (manifest.json, + // DC requests, session-token refresh) may all try to use the same refresh + // token simultaneously. TideCloak rotates refresh tokens on use, so the + // second concurrent refresh fails (old token consumed). Fix: deduplicate + // concurrent refreshes and cache the result briefly. + // Cache is keyed by refresh token to prevent cross-user token leaks. + interface RefreshResult { + accessToken: string; + expiresIn: number; + refreshToken?: string; + refreshExpiresIn?: number; + timestamp: number; + } + const refreshCache = new Map(); + const refreshInFlightMap = new Map>(); + + async function deduplicatedRefresh(refreshToken: string): Promise { + // Reuse a recent result (< 60 seconds) — prevents hammering TideCloak + // when multiple requests trigger refresh simultaneously or in quick succession + const cached = refreshCache.get(refreshToken); + if (cached && Date.now() - cached.timestamp < 60_000) { + return cached; + } + // If a refresh is already in flight for this token, wait for it + const inFlight = refreshInFlightMap.get(refreshToken); + if (inFlight) { + return inFlight; + } + const promise = (async () => { + try { + const tokens = await refreshAccessToken( + serverEndpoints, + clientId, + refreshToken + ); + const result: RefreshResult = { + accessToken: tokens.access_token, + expiresIn: tokens.expires_in, + refreshToken: tokens.refresh_token, + refreshExpiresIn: tokens.refresh_expires_in, + timestamp: Date.now(), + }; + // Evict old entries to prevent unbounded growth + if (refreshCache.size > 100) { + const oldest = refreshCache.keys().next().value; + if (oldest !== undefined) refreshCache.delete(oldest); + } + refreshCache.set(refreshToken, result); + return result; + } catch (err) { + console.log("[Gateway] Deduplicated refresh failed:", err); + return null; + } finally { + refreshInFlightMap.delete(refreshToken); + } + })(); + refreshInFlightMap.set(refreshToken, promise); + return promise; + } + const isTls = !!options.tls; _useSecureCookies = isTls; + // Rate limiter for /auth/session-token (per-IP sliding window) + const sessionTokenHits = new Map(); + // Evict stale IPs every 5 minutes + setInterval(() => { + const now = Date.now(); + for (const [ip, times] of sessionTokenHits) { + if (times.length === 0 || now - times[times.length - 1] > 120_000) { + sessionTokenHits.delete(ip); + } + } + }, 300_000).unref(); + /** Get browser-facing OIDC endpoints. * Uses authServerPublicUrl if explicitly set, otherwise returns relative * paths (/realms/...) so auth traffic stays on the gateway origin. @@ -482,15 +596,16 @@ export function createProxy(options: ProxyOptions): { return getOidcEndpoints(options.tcConfig, ""); } + // CSP is NOT set as a blanket header. The gateway proxies third-party + // backend HTML and injects scripts into it; a nonce-based CSP would break + // the backend's own inline scripts, and 'unsafe-inline' provides no real + // protection. Backends should set their own CSP in their responses. + const requestHandler = async (req: IncomingMessage, res: ServerResponse) => { - // ── Security headers ────────────────────────────────────────── + // ── Security headers (applied to all responses) ──────────────── 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"); } @@ -539,6 +654,20 @@ export function createProxy(options: ProxyOptions): { return; } + // WASM files (IronRDP) + if (path.startsWith("/wasm/")) { + const ext = path.slice(path.lastIndexOf(".")); + if (ext === ".wasm") { + serveBinaryFile(res, path.slice(1), "application/wasm"); + } else if (ext === ".js") { + serveFile(res, path.slice(1), "application/javascript; charset=utf-8"); + } else { + res.writeHead(404); + res.end("Not found"); + } + return; + } + // Static JS files if (path.startsWith("/js/") && path.endsWith(".js")) { // Allow SW to control root scope even though it lives under /js/ @@ -550,6 +679,12 @@ export function createProxy(options: ProxyOptions): { return; } + // RDP client page — served without auth (TCP tunnel requires JWT) + if (path === "/rdp") { + serveFile(res, "rdp.html", "text/html; 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") { @@ -561,6 +696,7 @@ export function createProxy(options: ProxyOptions): { stunServer: options.iceServers?.[0] ? `stun:${options.iceServers[0].replace("stun:", "")}` : null, + targetGatewayId: options.gatewayId || undefined, }; if (options.turnServer && options.turnSecret) { // Only serve TURN credentials to authenticated users @@ -696,6 +832,22 @@ export function createProxy(options: ProxyOptions): { res.end(JSON.stringify({ error: "Missing X-Requested-With header" })); return; } + // Rate limit: max 6 requests per minute per IP + const stIp = req.socket.remoteAddress || "unknown"; + const stNow = Date.now(); + const stHistory = sessionTokenHits.get(stIp); + if (stHistory) { + // Evict entries older than 60s + while (stHistory.length > 0 && stNow - stHistory[0] > 60_000) stHistory.shift(); + if (stHistory.length >= 6) { + res.writeHead(429, { "Content-Type": "application/json", "Retry-After": "10" }); + res.end(JSON.stringify({ error: "Too many requests" })); + return; + } + stHistory.push(stNow); + } else { + sessionTokenHits.set(stIp, [stNow]); + } const cookies = parseCookies(req.headers.cookie); let accessToken = cookies["gateway_access"]; // Also accept Authorization: Bearer token (relay flow has no cookies) @@ -705,36 +857,34 @@ export function createProxy(options: ProxyOptions): { 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 + let payload = accessToken + ? await options.auth.verifyToken(accessToken) + : null; + + // Always refresh when the session-token endpoint is called. + // This endpoint is only called by the client's periodic refresh + // (every 2 min), so it's not excessive. It ensures the client + // always gets a token with full lifetime and the browser's + // gateway_access cookie is renewed (preventing expiry-based 401s + // on non-DC requests like manifest.json). 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; + if (cookies["gateway_refresh"]) { + const refreshResult = await deduplicatedRefresh(cookies["gateway_refresh"]); + if (refreshResult) { + const refreshedPayload = await options.auth.verifyToken(refreshResult.accessToken); + if (refreshedPayload) { + payload = refreshedPayload; + accessToken = refreshResult.accessToken; setCookies.push( - buildCookieHeader("gateway_access", tokens.access_token, tokens.expires_in) + buildCookieHeader("gateway_access", refreshResult.accessToken, refreshResult.expiresIn) ); - if (tokens.refresh_token) { + if (refreshResult.refreshToken) { setCookies.push( - buildCookieHeader("gateway_refresh", tokens.refresh_token, tokens.refresh_expires_in || 1800, "Strict") + buildCookieHeader("gateway_refresh", refreshResult.refreshToken, refreshResult.refreshExpiresIn || 1800, "Strict") ); } } - } catch (err) { - console.log("[Gateway] Session token refresh failed:", err); } } @@ -956,9 +1106,9 @@ export function createProxy(options: ProxyOptions): { stats.totalRequests++; // Check if this backend skips gateway-side JWT validation - const isNoAuth = activeBackend + const isNoAuth = isPublicResource(path) || (activeBackend ? noAuthBackends.has(activeBackend) - : false; // default backend always requires JWT + : false); // default backend always requires JWT let payload: any = null; @@ -982,46 +1132,41 @@ export function createProxy(options: ProxyOptions): { // 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); - + const refreshResult = await deduplicatedRefresh(cookies["gateway_refresh"]); + if (refreshResult) { + payload = await options.auth.verifyToken(refreshResult.accessToken); if (payload) { - // Set updated cookies on the response - token = tokens.access_token; + token = refreshResult.accessToken; const refreshCookies: string[] = [ buildCookieHeader( "gateway_access", - tokens.access_token, - tokens.expires_in + refreshResult.accessToken, + refreshResult.expiresIn ), ]; - if (tokens.refresh_token) { + if (refreshResult.refreshToken) { refreshCookies.push( buildCookieHeader( "gateway_refresh", - tokens.refresh_token, - tokens.refresh_expires_in || 1800, + refreshResult.refreshToken, + refreshResult.refreshExpiresIn || 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++; + // Diagnostic: log why auth failed for DC requests + if (req.headers["x-dc-request"]) { + const tokenSnippet = token ? `${token.slice(0, 20)}...` : "null"; + console.log(`[Gateway] DC auth failed: url=${url} token=${tokenSnippet} hasRefreshCookie=${!!cookies["gateway_refresh"]}`); + } if (isBrowserRequest(req)) { const fullUrl = backendPrefix + url; @@ -1209,11 +1354,11 @@ export function createProxy(options: ProxyOptions): { // /__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) + // 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)/;` + + `var W=/^\\/(js\\/|auth\\/|login|webrtc-config|rdp|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;` + @@ -1232,6 +1377,26 @@ export function createProxy(options: ProxyOptions): { `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)};` + + // Fix CSS url() breakage when /__b/ contains apostrophes or spaces: + // strip quotes from url('…') / url("…") and percent-encode chars that + // are invalid in unquoted CSS url() (spaces, quotes, parens, tabs). + // Uses a Proxy on HTMLElement.style for reliable interception. + `function q(v){if(typeof v!=="string"||v.indexOf("url(")===-1)return v;` + + `return v.replace(/url\\(([^)]*)\\)/g,function(m,i){` + + `var u=i.trim();` + + `if(u.length>1&&(u[0]==="'"||u[0]==='"')&&u[u.length-1]===u[0])u=u.slice(1,-1);` + + `return"url("+u.replace(/ /g,"%20").replace(/'/g,"%27").replace(/"/g,"%22").replace(/\\t/g,"%09")+")"` + + `})}` + + `var _sd=Object.getOwnPropertyDescriptor(HTMLElement.prototype,"style");` + + `if(_sd&&_sd.get){var _wm=new WeakMap();Object.defineProperty(HTMLElement.prototype,"style",{` + + `get:function(){var r=_sd.get.call(this),p=_wm.get(r);if(!p){p=new Proxy(r,{` + + `set:function(t,k,v){t[k]=q(v);return true},` + + `get:function(t,k){var v=t[k];if(typeof v!=="function")return v;` + + `if(k==="setProperty")return function(){if(arguments.length>1)arguments[1]=q(arguments[1]);return t.setProperty.apply(t,arguments)};` + + `return v.bind(t)}` + + `});_wm.set(r,p)}return p},` + + `set:_sd.set?function(v){_sd.set.call(this,q(v))}:void 0,` + + `configurable:true})}` + `})()`; if (html.includes("")) { html = html.replace("", `${patchScript}`); diff --git a/bridges/punchd-bridge/gateway/src/rdcleanpath/der-codec.ts b/bridges/punchd-bridge/gateway/src/rdcleanpath/der-codec.ts new file mode 100644 index 0000000..7be20cf --- /dev/null +++ b/bridges/punchd-bridge/gateway/src/rdcleanpath/der-codec.ts @@ -0,0 +1,205 @@ +/** + * Minimal ASN.1 DER encoder/decoder for RDCleanPath PDUs. + * + * Only implements the primitives used by the RDCleanPath protocol: + * SEQUENCE, INTEGER, OCTET STRING, UTF8String, and context-specific + * EXPLICIT tags [0]–[9]. + */ + +// ── Tag constants ──────────────────────────────────────────────── + +export const TAG_INTEGER = 0x02; +export const TAG_OCTET_STRING = 0x04; +export const TAG_UTF8_STRING = 0x0c; +export const TAG_SEQUENCE = 0x30; + +/** Context-specific EXPLICIT constructed tag for [n] */ +export function contextTag(n: number): number { + return 0xa0 | n; +} + +// ── Length encoding ────────────────────────────────────────────── + +function encodeLength(len: number): Buffer { + if (len < 0x80) { + return Buffer.from([len]); + } + if (len <= 0xff) { + return Buffer.from([0x81, len]); + } + if (len <= 0xffff) { + const buf = Buffer.alloc(3); + buf[0] = 0x82; + buf.writeUInt16BE(len, 1); + return buf; + } + // 3-byte length (up to 16MB) + const buf = Buffer.alloc(4); + buf[0] = 0x83; + buf[1] = (len >> 16) & 0xff; + buf[2] = (len >> 8) & 0xff; + buf[3] = len & 0xff; + return buf; +} + +// ── TLV encoding ───────────────────────────────────────────────── + +export function encodeTlv(tag: number, content: Buffer): Buffer { + const lenBuf = encodeLength(content.length); + const out = Buffer.alloc(1 + lenBuf.length + content.length); + out[0] = tag; + lenBuf.copy(out, 1); + content.copy(out, 1 + lenBuf.length); + return out; +} + +export function encodeInteger(value: number): Buffer { + // Encode as signed big-endian with minimal bytes + const bytes: number[] = []; + if (value === 0) { + bytes.push(0); + } else { + let v = value; + while (v > 0) { + bytes.unshift(v & 0xff); + v = v >>> 8; + } + // Prepend 0x00 if high bit set (DER signed integer) + if (bytes[0] & 0x80) { + bytes.unshift(0); + } + } + return encodeTlv(TAG_INTEGER, Buffer.from(bytes)); +} + +export function encodeOctetString(data: Buffer): Buffer { + return encodeTlv(TAG_OCTET_STRING, data); +} + +export function encodeUtf8String(str: string): Buffer { + return encodeTlv(TAG_UTF8_STRING, Buffer.from(str, "utf-8")); +} + +export function encodeSequence(elements: Buffer[]): Buffer { + const content = Buffer.concat(elements); + return encodeTlv(TAG_SEQUENCE, content); +} + +/** Wrap inner content in a context-specific EXPLICIT [tagNum] */ +export function encodeExplicit(tagNum: number, inner: Buffer): Buffer { + return encodeTlv(contextTag(tagNum), inner); +} + +// ── DER Reader ─────────────────────────────────────────────────── + +export class DerReader { + private buf: Buffer; + private pos: number; + private end: number; + + constructor(buf: Buffer, offset = 0, length?: number) { + this.buf = buf; + this.pos = offset; + this.end = offset + (length ?? buf.length - offset); + } + + hasMore(): boolean { + return this.pos < this.end; + } + + /** Peek at the next tag without advancing */ + peekTag(): number { + if (this.pos >= this.end) return -1; + return this.buf[this.pos]; + } + + /** Read a tag byte */ + readTag(): number { + if (this.pos >= this.end) throw new Error("DER: unexpected end of data"); + return this.buf[this.pos++]; + } + + /** Read a DER length */ + readLength(): number { + if (this.pos >= this.end) throw new Error("DER: unexpected end of data"); + const first = this.buf[this.pos++]; + if (first < 0x80) return first; + + const numBytes = first & 0x7f; + if (numBytes === 0 || numBytes > 3) throw new Error(`DER: unsupported length form: ${numBytes} bytes`); + if (this.pos + numBytes > this.end) throw new Error("DER: length overflows buffer"); + + let len = 0; + for (let i = 0; i < numBytes; i++) { + len = (len << 8) | this.buf[this.pos++]; + } + return len; + } + + /** Read a full TLV, return tag and value buffer */ + readTlv(): { tag: number; value: Buffer } { + const tag = this.readTag(); + const len = this.readLength(); + if (this.pos + len > this.end) throw new Error("DER: value overflows buffer"); + const value = this.buf.subarray(this.pos, this.pos + len); + this.pos += len; + return { tag, value }; + } + + /** Read a SEQUENCE and return a DerReader over its contents */ + readSequence(): DerReader { + const { tag, value } = this.readTlv(); + if (tag !== TAG_SEQUENCE) throw new Error(`DER: expected SEQUENCE (0x30), got 0x${tag.toString(16)}`); + return new DerReader(value, 0, value.length); + } + + /** + * If the next tag matches EXPLICIT [tagNum], consume it and return + * a DerReader over the inner content. Otherwise return null. + */ + readExplicit(tagNum: number): DerReader | null { + if (this.peekTag() !== contextTag(tagNum)) return null; + const { value } = this.readTlv(); + return new DerReader(value, 0, value.length); + } + + /** Read an INTEGER and return as a JS number (up to 48-bit safe) */ + readInteger(): number { + const { tag, value } = this.readTlv(); + if (tag !== TAG_INTEGER) throw new Error(`DER: expected INTEGER (0x02), got 0x${tag.toString(16)}`); + let result = 0; + for (let i = 0; i < value.length; i++) { + result = result * 256 + value[i]; + } + return result; + } + + /** Read an OCTET STRING */ + readOctetString(): Buffer { + const { tag, value } = this.readTlv(); + if (tag !== TAG_OCTET_STRING) throw new Error(`DER: expected OCTET STRING (0x04), got 0x${tag.toString(16)}`); + return value; + } + + /** Read a UTF8String */ + readUtf8String(): string { + const { tag, value } = this.readTlv(); + if (tag !== TAG_UTF8_STRING) throw new Error(`DER: expected UTF8String (0x0C), got 0x${tag.toString(16)}`); + return value.toString("utf-8"); + } + + /** Skip the current TLV (consume without returning) */ + skip(): void { + this.readTlv(); + } + + /** Read a SEQUENCE OF OCTET STRING (returns array of buffers) */ + readSequenceOfOctetStrings(): Buffer[] { + const inner = this.readSequence(); + const result: Buffer[] = []; + while (inner.hasMore()) { + result.push(inner.readOctetString()); + } + return result; + } +} diff --git a/bridges/punchd-bridge/gateway/src/rdcleanpath/rdcleanpath-handler.ts b/bridges/punchd-bridge/gateway/src/rdcleanpath/rdcleanpath-handler.ts new file mode 100644 index 0000000..a61ebe6 --- /dev/null +++ b/bridges/punchd-bridge/gateway/src/rdcleanpath/rdcleanpath-handler.ts @@ -0,0 +1,389 @@ +/** + * RDCleanPath session handler. + * + * Processes the RDCleanPath protocol for a single client session: + * + * 1. AWAITING_REQUEST: parse client's RDCleanPath Request PDU, + * validate JWT, resolve backend name to rdp://host:port. + * 2. CONNECTING: open TCP to RDP server, send X.224 Connection Request, + * read X.224 Connection Confirm, perform TLS handshake, extract + * server certificate chain, send RDCleanPath Response PDU. + * 3. RELAY: bidirectional pipe — client binary ↔ TLS socket. + * + * Called from peer-handler.ts as a virtual WebSocket handler + * (no real WebSocket — messages flow over DataChannel). + */ + +import { connect as netConnect, type Socket } from "net"; +import { connect as tlsConnect, type TLSSocket } from "tls"; +import type { JWTPayload } from "jose"; +import type { BackendEntry } from "../config.js"; +import { + parseRDCleanPathRequest, + buildRDCleanPathResponse, + buildRDCleanPathError, + RDCLEANPATH_ERROR_GENERAL, + type RDCleanPathRequest, +} from "./rdcleanpath.js"; + +// ── Public interface ───────────────────────────────────────────── + +export interface RDCleanPathSession { + /** Handle a binary message from the client */ + handleMessage(data: Buffer): void; + /** Close the session */ + close(): void; +} + +export interface RDCleanPathSessionOptions { + /** Send a binary WS message back to the client */ + sendBinary: (data: Buffer) => void; + /** Send a close frame back to the client */ + sendClose: (code: number, reason: string) => void; + /** Available backends for resolution */ + backends: BackendEntry[]; + /** JWT verification function */ + verifyToken: (token: string) => Promise; + /** Gateway ID for dest: role enforcement */ + gatewayId?: string; + /** TideCloak client ID for role extraction */ + tcClientId?: string; +} + +const enum State { + AWAITING_REQUEST, + CONNECTING, + RELAY, + CLOSED, +} + +const CONNECT_TIMEOUT = 10_000; +const TLS_TIMEOUT = 10_000; +const X224_READ_TIMEOUT = 10_000; + +// ── Session factory ────────────────────────────────────────────── + +export function createRDCleanPathSession(opts: RDCleanPathSessionOptions): RDCleanPathSession { + let state = State.AWAITING_REQUEST; + let tcpSocket: Socket | null = null; + let tlsSocket: TLSSocket | null = null; + let relayBytesToClient = 0; + let relayBytesFromClient = 0; + + function sendError(errorCode: number, httpStatus?: number, wsaError?: number, tlsAlert?: number): void { + try { + const pdu = buildRDCleanPathError({ + errorCode, + httpStatusCode: httpStatus, + wsaLastError: wsaError, + tlsAlertCode: tlsAlert, + }); + opts.sendBinary(pdu); + } catch { + // best effort + } + cleanup(); + opts.sendClose(1000, "RDCleanPath error"); + } + + function cleanup(): void { + state = State.CLOSED; + if (tlsSocket) { + try { tlsSocket.destroy(); } catch {} + tlsSocket = null; + } + if (tcpSocket) { + try { tcpSocket.destroy(); } catch {} + tcpSocket = null; + } + } + + async function processRequest(data: Buffer): Promise { + // Parse the RDCleanPath Request PDU + let request: RDCleanPathRequest; + try { + request = parseRDCleanPathRequest(data); + } catch (err) { + console.error("[RDCleanPath] Failed to parse request:", (err as Error).message); + sendError(RDCLEANPATH_ERROR_GENERAL, 400); + return; + } + + // Validate JWT + const payload = await opts.verifyToken(request.proxyAuth); + if (!payload) { + console.warn("[RDCleanPath] JWT validation failed"); + sendError(RDCLEANPATH_ERROR_GENERAL, 401); + return; + } + + // Enforce dest: role + const backendName = request.destination; + if (opts.gatewayId) { + const realmRoles: string[] = (payload as any)?.realm_access?.roles ?? []; + const clientRoles: string[] = opts.tcClientId + ? ((payload as any)?.resource_access?.[opts.tcClientId]?.roles ?? []) + : []; + const allRoles = [...realmRoles, ...clientRoles]; + const gwIdLower = opts.gatewayId.toLowerCase(); + const backendLower = backendName.toLowerCase(); + const hasAccess = allRoles.some((r: string) => { + if (!/^dest:/i.test(r)) return false; + 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) { + console.warn(`[RDCleanPath] dest role denied: backend="${backendName}"`); + sendError(RDCLEANPATH_ERROR_GENERAL, 403); + return; + } + } + + // Resolve backend name → host:port + const backend = opts.backends.find((b) => b.name === backendName && b.protocol === "rdp"); + if (!backend) { + console.warn(`[RDCleanPath] No matching RDP backend: "${backendName}"`); + sendError(RDCLEANPATH_ERROR_GENERAL, 404); + return; + } + + let host: string; + let port: number; + try { + const url = new URL(backend.url); + host = url.hostname; + port = parseInt(url.port || "3389", 10); + } catch { + console.error(`[RDCleanPath] Invalid backend URL: ${backend.url}`); + sendError(RDCLEANPATH_ERROR_GENERAL, 500); + return; + } + + console.log(`[RDCleanPath] Connecting to ${host}:${port} for backend "${backendName}"`); + state = State.CONNECTING; + + try { + // Step 1: TCP connect to RDP server + tcpSocket = await tcpConnect(host, port); + + // Step 2: Send X.224 Connection Request + tcpSocket.write(request.x224ConnectionPdu); + + // Step 3: Read X.224 Connection Confirm (TPKT-framed) + const x224Response = await readTpktMessage(tcpSocket); + console.log(`[RDCleanPath] X.224 response: ${x224Response.length} bytes`); + + // Step 4: TLS handshake with RDP server + tlsSocket = await tlsUpgrade(tcpSocket, host); + + // Step 5: Extract server certificate chain + const certChain = extractCertChain(tlsSocket); + console.log(`[RDCleanPath] TLS complete, ${certChain.length} cert(s) in chain`); + + // Step 6: Send RDCleanPath Response PDU + const responsePdu = buildRDCleanPathResponse({ + x224ConnectionPdu: x224Response, + serverCertChain: certChain, + serverAddr: host, + }); + console.log(`[RDCleanPath] Sending response PDU: ${responsePdu.length} bytes`); + opts.sendBinary(responsePdu); + + // Step 7: Enter relay mode + state = State.RELAY; + console.log(`[RDCleanPath] Relay mode active for "${backendName}"`); + + // TLS socket → client + tlsSocket.on("data", (data: Buffer) => { + if (state !== State.RELAY) return; + relayBytesToClient += data.length; + console.log(`[RDCleanPath] Relay RDP→client: ${data.length} bytes (total: ${relayBytesToClient})`); + opts.sendBinary(data); + }); + + tlsSocket.on("close", () => { + console.log(`[RDCleanPath] TLS socket closed for "${backendName}" (state=${state}, toClient=${relayBytesToClient}, fromClient=${relayBytesFromClient})`); + if (state !== State.RELAY) return; + cleanup(); + opts.sendClose(1000, "RDP connection closed"); + }); + + tlsSocket.on("error", (err: Error) => { + console.error(`[RDCleanPath] TLS socket error for "${backendName}" (state=${state}): ${err.message}`); + if (state !== State.RELAY) return; + cleanup(); + opts.sendClose(1006, "RDP connection error"); + }); + } catch (err) { + const msg = (err as Error).message || "Connection failed"; + console.error(`[RDCleanPath] Connection failed: ${msg}`); + // Determine error type + if (msg.includes("TLS") || msg.includes("tls")) { + sendError(RDCLEANPATH_ERROR_GENERAL, undefined, undefined, 40); + } else { + sendError(RDCLEANPATH_ERROR_GENERAL, undefined, 10061); + } + } + } + + return { + handleMessage(data: Buffer): void { + switch (state) { + case State.AWAITING_REQUEST: + processRequest(data).catch((err) => { + console.error("[RDCleanPath] Unhandled error:", err); + sendError(RDCLEANPATH_ERROR_GENERAL, 500); + }); + break; + + case State.RELAY: + // Forward client data to TLS socket + if (tlsSocket && !tlsSocket.destroyed) { + relayBytesFromClient += data.length; + console.log(`[RDCleanPath] Relay client→RDP: ${data.length} bytes (total: ${relayBytesFromClient})`); + tlsSocket.write(data); + } + break; + + case State.CONNECTING: + // Buffer or drop — client shouldn't send data during handshake + break; + + case State.CLOSED: + break; + } + }, + + close(): void { + cleanup(); + }, + }; +} + +// ── TCP helpers ────────────────────────────────────────────────── + +function tcpConnect(host: string, port: number): Promise { + return new Promise((resolve, reject) => { + const sock = netConnect({ host, port, timeout: CONNECT_TIMEOUT }); + + sock.on("connect", () => { + sock.setTimeout(0); + resolve(sock); + }); + + sock.on("timeout", () => { + sock.destroy(); + reject(new Error(`TCP connect timeout: ${host}:${port}`)); + }); + + sock.on("error", (err: Error) => { + reject(new Error(`TCP connect error: ${err.message}`)); + }); + }); +} + +/** + * Read a TPKT-framed message from a TCP socket. + * TPKT header: [version=0x03][reserved=0x00][length_hi][length_lo] + */ +function readTpktMessage(sock: Socket): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + let totalLen = 0; + let expectedLen = 0; + + const timer = setTimeout(() => { + sock.off("data", onData); + reject(new Error("X.224 response timeout")); + }, X224_READ_TIMEOUT); + + function onData(data: Buffer): void { + chunks.push(data); + totalLen += data.length; + + if (expectedLen === 0 && totalLen >= 4) { + const header = Buffer.concat(chunks); + if (header[0] !== 0x03) { + clearTimeout(timer); + sock.off("data", onData); + reject(new Error(`Not a TPKT header: first byte 0x${header[0].toString(16)}`)); + return; + } + expectedLen = header.readUInt16BE(2); + if (expectedLen < 4 || expectedLen > 512) { + clearTimeout(timer); + sock.off("data", onData); + reject(new Error(`Invalid TPKT length: ${expectedLen}`)); + return; + } + } + + if (expectedLen > 0 && totalLen >= expectedLen) { + clearTimeout(timer); + sock.off("data", onData); + resolve(Buffer.concat(chunks).subarray(0, expectedLen)); + } + } + + sock.on("data", onData); + sock.on("error", (err) => { + clearTimeout(timer); + reject(err); + }); + sock.on("close", () => { + clearTimeout(timer); + reject(new Error("Socket closed before X.224 response")); + }); + }); +} + +/** + * Upgrade a TCP socket to TLS (wrapping the existing connection). + * Uses rejectUnauthorized=false because the RDP server typically + * uses a self-signed certificate — IronRDP validates it via CredSSP. + */ +function tlsUpgrade(sock: Socket, servername: string): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject(new Error("TLS handshake timeout")); + }, TLS_TIMEOUT); + + const tls = tlsConnect({ + socket: sock, + rejectUnauthorized: false, + servername, + }, () => { + clearTimeout(timer); + resolve(tls); + }); + + tls.on("error", (err: Error) => { + clearTimeout(timer); + reject(new Error(`TLS error: ${err.message}`)); + }); + }); +} + +/** + * Extract the TLS certificate chain from a connected TLS socket. + * Returns an array of DER-encoded X.509 certificates (leaf first). + */ +function extractCertChain(tls: TLSSocket): Buffer[] { + const chain: Buffer[] = []; + const seen = new Set(); + + let cert = tls.getPeerCertificate(true); + while (cert && cert.raw) { + const fp = cert.fingerprint256 || cert.raw.toString("hex").slice(0, 64); + if (seen.has(fp)) break; + seen.add(fp); + chain.push(cert.raw); + cert = (cert as any).issuerCertificate; + } + + return chain; +} diff --git a/bridges/punchd-bridge/gateway/src/rdcleanpath/rdcleanpath.ts b/bridges/punchd-bridge/gateway/src/rdcleanpath/rdcleanpath.ts new file mode 100644 index 0000000..92edf29 --- /dev/null +++ b/bridges/punchd-bridge/gateway/src/rdcleanpath/rdcleanpath.ts @@ -0,0 +1,148 @@ +/** + * RDCleanPath PDU types and serialization. + * + * Implements the RDCleanPath protocol PDU used by IronRDP WASM to + * negotiate RDP connections through a WebSocket proxy. The gateway + * handles the TLS handshake with the RDP server and returns the + * server's certificate chain so IronRDP can perform NLA/CredSSP. + * + * Wire format: ASN.1 DER SEQUENCE with EXPLICIT context-specific tags. + * Version constant: 3390 (BASE_VERSION 3389 + 1). + */ + +import { + DerReader, + encodeSequence, + encodeExplicit, + encodeInteger, + encodeOctetString, + encodeUtf8String, + encodeTlv, + TAG_SEQUENCE, +} from "./der-codec.js"; + +export const RDCLEANPATH_VERSION = 3390; + +// ── Request (client → gateway) ─────────────────────────────────── + +export interface RDCleanPathRequest { + version: number; + destination: string; + proxyAuth: string; + preconnectionBlob?: string; + x224ConnectionPdu: Buffer; +} + +/** + * Parse a binary buffer as an RDCleanPath Request PDU. + */ +export function parseRDCleanPathRequest(data: Buffer): RDCleanPathRequest { + const outer = new DerReader(data); + const seq = outer.readSequence(); + + // [0] version + const versionCtx = seq.readExplicit(0); + if (!versionCtx) throw new Error("RDCleanPath: missing version field"); + const version = versionCtx.readInteger(); + if (version !== RDCLEANPATH_VERSION) { + throw new Error(`RDCleanPath: unexpected version ${version}, expected ${RDCLEANPATH_VERSION}`); + } + + // Skip [1] error (not present in requests) + seq.readExplicit(1); + + // [2] destination + const destCtx = seq.readExplicit(2); + if (!destCtx) throw new Error("RDCleanPath: missing destination field"); + const destination = destCtx.readUtf8String(); + + // [3] proxy_auth + const authCtx = seq.readExplicit(3); + if (!authCtx) throw new Error("RDCleanPath: missing proxy_auth field"); + const proxyAuth = authCtx.readUtf8String(); + + // [4] server_auth (unused, skip) + seq.readExplicit(4); + + // [5] preconnection_blob (optional) + const pcbCtx = seq.readExplicit(5); + const preconnectionBlob = pcbCtx ? pcbCtx.readUtf8String() : undefined; + + // [6] x224_connection_pdu + const x224Ctx = seq.readExplicit(6); + if (!x224Ctx) throw new Error("RDCleanPath: missing x224_connection_pdu field"); + const x224ConnectionPdu = x224Ctx.readOctetString(); + + return { version, destination, proxyAuth, preconnectionBlob, x224ConnectionPdu }; +} + +// ── Response (gateway → client) ────────────────────────────────── + +export interface RDCleanPathResponse { + x224ConnectionPdu: Buffer; + serverCertChain: Buffer[]; + serverAddr: string; +} + +/** + * Build a successful RDCleanPath Response PDU. + */ +export function buildRDCleanPathResponse(resp: RDCleanPathResponse): Buffer { + const fields: Buffer[] = []; + + // [0] version + fields.push(encodeExplicit(0, encodeInteger(RDCLEANPATH_VERSION))); + + // [6] x224_connection_pdu + fields.push(encodeExplicit(6, encodeOctetString(resp.x224ConnectionPdu))); + + // [7] server_cert_chain — SEQUENCE OF OCTET STRING + const certElements = resp.serverCertChain.map((cert) => encodeOctetString(cert)); + const certSeq = encodeSequence(certElements); + fields.push(encodeExplicit(7, certSeq)); + + // [9] server_addr + fields.push(encodeExplicit(9, encodeUtf8String(resp.serverAddr))); + + return encodeSequence(fields); +} + +// ── Error (gateway → client) ───────────────────────────────────── + +export interface RDCleanPathError { + errorCode: number; + httpStatusCode?: number; + wsaLastError?: number; + tlsAlertCode?: number; +} + +/** + * Build an RDCleanPath Error PDU. + */ +export function buildRDCleanPathError(err: RDCleanPathError): Buffer { + const fields: Buffer[] = []; + + // [0] version + fields.push(encodeExplicit(0, encodeInteger(RDCLEANPATH_VERSION))); + + // [1] error — SEQUENCE { [0] errorCode, [1] httpStatus?, [2] wsaError?, [3] tlsAlert? } + const errFields: Buffer[] = []; + errFields.push(encodeExplicit(0, encodeInteger(err.errorCode))); + if (err.httpStatusCode !== undefined) { + errFields.push(encodeExplicit(1, encodeInteger(err.httpStatusCode))); + } + if (err.wsaLastError !== undefined) { + errFields.push(encodeExplicit(2, encodeInteger(err.wsaLastError))); + } + if (err.tlsAlertCode !== undefined) { + errFields.push(encodeExplicit(3, encodeInteger(err.tlsAlertCode))); + } + fields.push(encodeExplicit(1, encodeSequence(errFields))); + + return encodeSequence(fields); +} + +// ── Error codes ────────────────────────────────────────────────── + +export const RDCLEANPATH_ERROR_GENERAL = 1; +export const RDCLEANPATH_ERROR_NEGOTIATION = 2; diff --git a/bridges/punchd-bridge/gateway/src/registration/stun-client.ts b/bridges/punchd-bridge/gateway/src/registration/stun-client.ts index 8dd8537..ec8df36 100644 --- a/bridges/punchd-bridge/gateway/src/registration/stun-client.ts +++ b/bridges/punchd-bridge/gateway/src/registration/stun-client.ts @@ -9,7 +9,9 @@ import WebSocket from "ws"; import { request as httpRequest } from "http"; import { request as httpsRequest } from "https"; +import type { JWTPayload } from "jose"; import { createPeerHandler, type PeerHandler } from "../webrtc/peer-handler.js"; +import type { BackendEntry } from "../config.js"; export interface StunRegistrationOptions { stunServerUrl: string; @@ -28,7 +30,13 @@ export interface StunRegistrationOptions { /** 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 }; + metadata?: { displayName?: string; description?: string; backends?: { name: string; protocol?: string }[]; realm?: string }; + /** Backend configurations (needed by peer handler for TCP tunnels) */ + backends?: BackendEntry[]; + /** JWT verification function (for RDCleanPath auth) */ + verifyToken?: (token: string) => Promise; + /** TideCloak client ID for dest: role extraction */ + tcClientId?: string; onPaired?: (client: { id: string; reflexiveAddress: string | null }) => void; onCandidate?: (fromId: string, candidate: unknown) => void; } @@ -82,6 +90,9 @@ export function registerWithStun( useTls: options.useTls, gatewayId: options.gatewayId, sendSignaling: safeSend, + backends: options.backends || [], + verifyToken: options.verifyToken, + tcClientId: options.tcClientId, }); console.log("[STUN-Reg] WebRTC peer handler ready"); } diff --git a/bridges/punchd-bridge/gateway/src/webrtc/peer-handler.ts b/bridges/punchd-bridge/gateway/src/webrtc/peer-handler.ts index bd9e73c..b8e522a 100644 --- a/bridges/punchd-bridge/gateway/src/webrtc/peer-handler.ts +++ b/bridges/punchd-bridge/gateway/src/webrtc/peer-handler.ts @@ -6,13 +6,22 @@ * the gateway creates a PeerConnection, establishes a DataChannel, * and tunnels HTTP requests/responses over it — same format * as the WebSocket-based HTTP relay. + * + * Supports dual DataChannels for high-throughput scenarios (4K video, gaming): + * - "http-tunnel" (control): JSON control messages, small responses + * - "bulk-data" (bulk): binary streaming chunks, binary WebSocket frames + * Falls back to single-channel mode for older clients. */ import { createHmac } from "crypto"; -import { PeerConnection, DataChannel } from "node-datachannel"; +import { connect as netConnect, type Socket } from "net"; +import { PeerConnection, DataChannel, setSctpSettings } from "node-datachannel"; import { request as httpRequest } from "http"; import { request as httpsRequest } from "https"; import WebSocket from "ws"; +import type { JWTPayload } from "jose"; +import type { BackendEntry } from "../config.js"; +import { createRDCleanPathSession, type RDCleanPathSession } from "../rdcleanpath/rdcleanpath-handler.js"; export interface PeerHandlerOptions { /** STUN server for ICE, e.g. "stun:relay.example.com:3478" */ @@ -29,6 +38,12 @@ export interface PeerHandlerOptions { sendSignaling: (msg: unknown) => void; /** Gateway ID — used as fromId in signaling messages */ gatewayId: string; + /** Backend configurations (for TCP tunnel target resolution) */ + backends: BackendEntry[]; + /** JWT verification function (for RDCleanPath auth) */ + verifyToken?: (token: string) => Promise; + /** TideCloak client ID for dest: role extraction */ + tcClientId?: string; } export interface PeerHandler { @@ -40,8 +55,147 @@ export interface PeerHandler { const MAX_PEERS = 200; const ALLOWED_METHODS = new Set(["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"]); +// Buffer thresholds — separate for control (small JSON) and bulk (streaming data) +const CONTROL_MAX_BUFFER = 512_000; // 512KB for control channel +const BULK_MAX_BUFFER = 4_194_304; // 4MB for bulk channel — keeps pipe full for 4K video + +// Chunk coalescing: batch small HTTP response chunks into larger DC messages +const COALESCE_TARGET = 65_536; // 64KB target coalesced message size +const COALESCE_TIMEOUT = 1; // 1ms max coalescing delay + +// Binary WebSocket fast-path magic byte (avoids JSON+base64 overhead for gaming) +const BINARY_WS_MAGIC = 0x02; +// TCP tunnel binary fast-path magic byte +const TCP_TUNNEL_MAGIC = 0x03; +const MAX_TCP_PER_DC = 5; + +// Tune SCTP buffers for high-throughput streaming +let sctpConfigured = false; +function ensureSctpSettings(): void { + if (sctpConfigured) return; + sctpConfigured = true; + try { + setSctpSettings({ + sendBufferSize: 8 * 1024 * 1024, // 8MB send buffer + recvBufferSize: 8 * 1024 * 1024, // 8MB receive buffer + maxChunksOnQueue: 65536, // up from default 8192 + initialCongestionWindow: 32, // faster ramp-up + }); + } catch { + // setSctpSettings may fail if PeerConnections already exist + } +} + +/** Per-peer state shared between control and bulk channels. */ +interface PeerState { + wsConnections: Map; + tcpConnections: Map; + rdcleanpathSessions: Map; + capabilities: Set; + controlDc: DataChannel | null; + bulkDc: DataChannel | null; + controlQueue: Buffer[]; + bulkQueue: Buffer[]; + controlPaused: boolean; + bulkPaused: boolean; + pausedStreams: Set; +} + export function createPeerHandler(options: PeerHandlerOptions): PeerHandler { + ensureSctpSettings(); + const peers = new Map(); + const peerStates = new Map(); + + function getPeerState(clientId: string): PeerState { + let state = peerStates.get(clientId); + if (!state) { + state = { + wsConnections: new Map(), + tcpConnections: new Map(), + rdcleanpathSessions: new Map(), + capabilities: new Set(), + controlDc: null, + bulkDc: null, + controlQueue: [], + bulkQueue: [], + controlPaused: false, + bulkPaused: false, + pausedStreams: new Set(), + }; + peerStates.set(clientId, state); + } + return state; + } + + // --- Shared send-queue helpers with event-driven flow control --- + + function setupFlowControl(dc: DataChannel, queue: Buffer[], maxBuffer: number, getPaused: () => boolean, setPaused: (v: boolean) => void, state: PeerState): void { + dc.setBufferedAmountLowThreshold(maxBuffer / 4); + dc.onBufferedAmountLow(() => { + if (getPaused()) { + setPaused(false); + drainQueue(dc, queue, maxBuffer, getPaused, setPaused, state); + } + }); + } + + function drainQueue(dc: DataChannel, queue: Buffer[], maxBuffer: number, getPaused: () => boolean, setPaused: (v: boolean) => void, state: PeerState): void { + while (queue.length > 0) { + if (!dc.isOpen()) return; + if (dc.bufferedAmount() > maxBuffer) { + setPaused(true); + // Pause all in-flight HTTP response streams + for (const stream of state.pausedStreams) { + stream.pause(); + } + return; + } + try { + const sent = dc.sendMessageBinary(queue[0]); + if (!sent) { + setPaused(true); + return; + } + } catch { + return; + } + queue.shift(); + } + // Queue drained — resume any paused streams + for (const stream of state.pausedStreams) { + stream.resume(); + } + } + + function enqueueControl(state: PeerState, buf: Buffer): void { + const dc = state.controlDc; + if (!dc || !dc.isOpen()) return; + state.controlQueue.push(buf); + if (!state.controlPaused) { + drainQueue(dc, state.controlQueue, CONTROL_MAX_BUFFER, + () => state.controlPaused, (v) => { state.controlPaused = v; }, state); + } + } + + function enqueueBulk(state: PeerState, buf: Buffer): void { + // Use bulk channel if available, otherwise fall back to control + const dc = state.bulkDc && state.bulkDc.isOpen() ? state.bulkDc : state.controlDc; + if (!dc || !dc.isOpen()) return; + + if (dc === state.bulkDc) { + state.bulkQueue.push(buf); + if (!state.bulkPaused) { + drainQueue(dc, state.bulkQueue, BULK_MAX_BUFFER, + () => state.bulkPaused, (v) => { state.bulkPaused = v; }, state); + } + } else { + // Fallback to control channel (single-channel mode) + enqueueControl(state, buf); + } + } + + // --- Channel setup --- function handleSdpOffer(clientId: string, sdp: string): void { // Clean up existing peer if reconnecting @@ -49,6 +203,7 @@ export function createPeerHandler(options: PeerHandlerOptions): PeerHandler { if (existing) { existing.close(); peers.delete(clientId); + peerStates.delete(clientId); } // Reject new peers if at capacity (reconnects already cleaned up above) @@ -123,45 +278,176 @@ export function createPeerHandler(options: PeerHandlerOptions): PeerHandler { } if (state === "closed" || state === "failed") { peers.delete(clientId); + peerStates.delete(clientId); } }); pc.onDataChannel((dc) => { - console.log(`[WebRTC] DataChannel opened with client: ${clientId} (label: ${dc.getLabel()})`); - const wsConnections = new Map(); + const label = dc.getLabel(); + console.log(`[WebRTC] DataChannel opened with client: ${clientId} (label: ${label})`); + + if (label === "http-tunnel") { + setupControlChannel(dc, clientId); + } else if (label === "bulk-data") { + setupBulkChannel(dc, clientId); + } else { + console.warn(`[WebRTC] Unknown DataChannel label: ${label}, treating as control`); + setupControlChannel(dc, clientId); + } + }); - 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); + pc.setRemoteDescription(sdp, "offer"); + peers.set(clientId, pc); + } + + const GATEWAY_FEATURES = ["bulk-channel", "binary-ws", "tcp-tunnel"]; + + function sendCapabilities(state: PeerState): void { + enqueueControl(state, Buffer.from(JSON.stringify({ + type: "capabilities", + version: 2, + features: GATEWAY_FEATURES, + }))); + } + + function setupControlChannel(dc: DataChannel, clientId: string): void { + const state = getPeerState(clientId); + state.controlDc = dc; + + setupFlowControl(dc, state.controlQueue, CONTROL_MAX_BUFFER, + () => state.controlPaused, (v) => { state.controlPaused = v; }, state); + + // Send capabilities proactively as soon as the channel is open — + // don't wait for the client to ask (message could be lost or delayed). + if (dc.isOpen()) { + console.log(`[WebRTC] Sending proactive capabilities to ${clientId}`); + sendCapabilities(state); + } + dc.onOpen(() => { + console.log(`[WebRTC] Control channel fully open for ${clientId}, sending capabilities`); + sendCapabilities(state); + }); + + dc.onMessage((msg) => { + try { + const parsed = JSON.parse(typeof msg === "string" ? msg : Buffer.from(msg as ArrayBuffer).toString()); + if (parsed.type === "http_request") { + handleDataChannelRequest(state, parsed); + } else if (parsed.type === "ws_open") { + handleWsOpen(state, parsed); + } else if (parsed.type === "ws_message") { + const rdcp = state.rdcleanpathSessions.get(parsed.id); + if (rdcp) { + rdcp.handleMessage(parsed.binary ? Buffer.from(parsed.data, "base64") : Buffer.from(parsed.data)); + } else { + const ws = state.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); + } + } else if (parsed.type === "ws_close") { + const rdcp = state.rdcleanpathSessions.get(parsed.id); + if (rdcp) { + rdcp.close(); + state.rdcleanpathSessions.delete(parsed.id); + } else { + const ws = state.wsConnections.get(parsed.id); if (ws) ws.close(parsed.code || 1000, parsed.reason || ""); } - } catch { - console.error("[WebRTC] Failed to parse DataChannel message"); + } else if (parsed.type === "tcp_open") { + handleTcpOpen(state, parsed); + } else if (parsed.type === "tcp_close") { + handleTcpClose(state, parsed.id); + } else if (parsed.type === "capabilities") { + // Client capability handshake — respond with our supported features + const clientFeatures: string[] = parsed.features || []; + for (const f of clientFeatures) { + if (GATEWAY_FEATURES.includes(f)) state.capabilities.add(f); + } + console.log(`[WebRTC] Client ${clientId} capabilities: ${[...state.capabilities].join(", ")}`); + // Reply (client may have missed the proactive announcement) + sendCapabilities(state); } - }); + } catch (err) { + console.error("[WebRTC] DataChannel message error:", err instanceof Error ? err.message : err); + } + }); + + dc.onClosed(() => { + console.log(`[WebRTC] Control channel closed with client: ${clientId}`); + for (const [, ws] of state.wsConnections) { + try { ws.close(); } catch {} + } + state.wsConnections.clear(); + for (const [, sock] of state.tcpConnections) { + try { sock.destroy(); } catch {} + } + state.tcpConnections.clear(); + for (const [, session] of state.rdcleanpathSessions) { + try { session.close(); } catch {} + } + state.rdcleanpathSessions.clear(); + state.controlDc = null; + }); + } - dc.onClosed(() => { - console.log(`[WebRTC] DataChannel closed with client: ${clientId}`); - for (const [, ws] of wsConnections) { - try { ws.close(); } catch {} + function setupBulkChannel(dc: DataChannel, clientId: string): void { + const state = getPeerState(clientId); + state.bulkDc = dc; + + setupFlowControl(dc, state.bulkQueue, BULK_MAX_BUFFER, + () => state.bulkPaused, (v) => { state.bulkPaused = v; }, state); + + dc.onMessage((msg) => { + const buf = Buffer.isBuffer(msg) ? msg : Buffer.from(msg as ArrayBuffer); + if (buf.length < 1) return; + + // Binary WS fast-path: [0x02][36-byte WS UUID][payload] + if (buf[0] === BINARY_WS_MAGIC && buf.length >= 37) { + const wsId = buf.toString("ascii", 1, 37); + const payload = buf.subarray(37); + // Check RDCleanPath sessions first (virtual WS) + const rdcp = state.rdcleanpathSessions.get(wsId); + if (rdcp) { + rdcp.handleMessage(payload); + return; } - wsConnections.clear(); - }); + const ws = state.wsConnections.get(wsId); + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send(payload); + } + return; + } + + // TCP tunnel fast-path: [0x03][36-byte tunnel UUID][payload] + if (buf[0] === TCP_TUNNEL_MAGIC && buf.length >= 37) { + const tunnelId = buf.toString("ascii", 1, 37); + const payload = buf.subarray(37); + const sock = state.tcpConnections.get(tunnelId); + if (sock && !sock.destroyed) { + sock.write(payload); + } + return; + } + + // Other binary messages on bulk channel (shouldn't happen but handle gracefully) + try { + const parsed = JSON.parse(buf.toString()); + if (parsed.type === "ws_message") { + const ws = state.wsConnections.get(parsed.id); + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send(parsed.binary ? Buffer.from(parsed.data, "base64") : parsed.data); + } + } + } catch { + // Not JSON, ignore + } }); - pc.setRemoteDescription(sdp, "offer"); - peers.set(clientId, pc); + dc.onClosed(() => { + console.log(`[WebRTC] Bulk channel closed with client: ${clientId}`); + state.bulkDc = null; + }); } function handleCandidate(clientId: string, candidate: string, mid: string): void { @@ -180,9 +466,22 @@ export function createPeerHandler(options: PeerHandlerOptions): PeerHandler { || (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; + /** Binary content types that should always stream (not buffer + base64) */ + function isBinaryContent(res: import("http").IncomingMessage): boolean { + const ct = (res.headers["content-type"] || "").toLowerCase(); + return ct.startsWith("image/") + || ct.startsWith("video/") + || ct.startsWith("audio/") + || ct.startsWith("font/") + || ct.includes("application/octet-stream") + || ct.includes("application/wasm") + || ct.includes("application/zip") + || ct.includes("application/pdf"); + } + + // Responses smaller than this are sent as a single DC message (base64 on control); + // larger responses are streamed progressively as binary via bulk channel. + const MAX_SINGLE_MSG = 32_000; // 32KB — API JSON fits; images stream /** * Handle an HTTP request received over DataChannel. @@ -191,7 +490,7 @@ export function createPeerHandler(options: PeerHandlerOptions): PeerHandler { * are forwarded progressively as data arrives from the backend. */ function handleDataChannelRequest( - dc: DataChannel, + state: PeerState, msg: { id: string; method?: string; url?: string; headers?: Record; body?: string } ): void { const requestId = msg.id; @@ -202,29 +501,25 @@ export function createPeerHandler(options: PeerHandlerOptions): PeerHandler { // 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"), - }))); - } + enqueueControl(state, 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"), - }))); - } + enqueueControl(state, 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; } @@ -233,15 +528,13 @@ export function createPeerHandler(options: PeerHandlerOptions): PeerHandler { // 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"), - }))); - } + enqueueControl(state, 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; @@ -251,6 +544,30 @@ export function createPeerHandler(options: PeerHandlerOptions): PeerHandler { // 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"]; + // Strip conditional headers — DC responses bypass the browser's HTTP + // cache, so 304 responses produce null-body Responses in the Service + // Worker that the browser can't match to a cache entry. + delete (headers as Record)["if-none-match"]; + delete (headers as Record)["if-modified-since"]; + + // Cap Range request size for DataChannel responses. + // Non-live responses (video) are buffered on the client before delivery + // (Chrome's