Reapply "fix: keep WebVNC binary bridge usable"

This reverts commit ac4c1953f0.
This commit is contained in:
Vincent Koc 2026-05-04 23:33:39 -07:00
parent 5ea56ef4f2
commit 20f7102c2f
No known key found for this signature in database
4 changed files with 116 additions and 8 deletions

View File

@ -3,6 +3,9 @@
`crabbox webvnc` bridges a desktop lease into the authenticated coordinator
portal.
Use it when you want the same VNC desktop that `crabbox vnc` opens, but inside
a browser tab instead of a native VNC client.
```sh
crabbox warmup --desktop
crabbox webvnc --id blue-lobster
@ -11,6 +14,8 @@ crabbox webvnc --id blue-lobster --open
crabbox webvnc --id blue-lobster --daemon --open
```
## How It Works
The command resolves the lease like `crabbox vnc`, verifies that the lease has
`desktop=true`, starts the normal SSH tunnel to the runner's loopback VNC
service, mints a short-lived bridge ticket over the authenticated coordinator
@ -18,6 +23,23 @@ API, and opens a websocket bridge to the coordinator with that ticket. The
browser connects to `/portal/leases/<lease>/vnc` after GitHub portal auth, and
the Durable Object pairs that browser websocket with the local bridge process.
The data path is:
```text
browser noVNC
<-> coordinator portal websocket
<-> local crabbox webvnc process
<-> SSH tunnel
<-> runner 127.0.0.1:5900
```
That means the local `crabbox webvnc` process is not just a launcher. It is the
live bridge between the browser and the SSH-tunneled VNC socket. Keep it
running while the browser tab is open. If the browser tab reloads or drops, the
command re-registers a fresh bridge so the portal retry can reconnect.
## Security Boundary
This keeps the security boundary the same as `crabbox vnc`:
- VNC stays bound to runner loopback.
@ -36,6 +58,8 @@ again, and `--stop` to kill the background bridge for that lease.
`--network tailscale` changes only the SSH endpoint used for the local tunnel.
The runner VNC service stays bound to loopback.
## Portal And Passwords
`--open` opens the portal page after the bridge starts. If the VNC password is
available, the command also places it in the URL fragment for the local browser
tab. URL fragments are not sent to the coordinator, and Crabbox preserves
@ -44,6 +68,17 @@ flow redirects first, the page may still prompt for the VNC password; use the
password printed by the command. If an old browser tab is retrying with a stale
fragment, close it before opening the new bridge URL.
The portal page may show `waiting for bridge` until the local command has
connected. If you opened the portal first, start:
```sh
crabbox webvnc --id <lease-id-or-slug>
```
in a terminal and leave it running.
## Flags
Flags:
```text
@ -65,6 +100,8 @@ Flags:
--reclaim
```
## Limitations
Limitations:
- Coordinator-backed Hetzner and AWS desktop leases are supported.
@ -72,6 +109,38 @@ Limitations:
prove that host-managed VNC credentials and prompts are safe to expose.
- Blacksmith Testbox still owns its own machine connectivity.
## Troubleshooting
`webvnc requires a configured coordinator login`
Run `crabbox login` for the coordinator you are using. WebVNC needs both the CLI
bridge and the browser portal to authenticate with the coordinator.
`webvnc currently supports coordinator-backed hetzner/aws desktop leases`
WebVNC is not available for static SSH hosts or Blacksmith Testbox. Use
`crabbox vnc` for static hosts when you explicitly trust the host-managed VNC
service.
`target does not expose VNC on 127.0.0.1:5900`
The lease is reachable over SSH, but the desktop service is not ready or was not
provisioned. Create the lease with `--desktop`, or wait for bootstrap to finish
and retry.
The portal keeps saying `waiting for bridge`
The browser can reach the coordinator, but no local bridge is currently paired
with that lease. Start or restart `crabbox webvnc --id <lease>` locally and keep
the process running. If the command is still running, wait for the portal retry
or reload the browser tab.
VNC authentication fails
Use the password printed by `crabbox webvnc`. With `--open`, the command tries
to pass the password in the browser URL fragment, but a portal login redirect
can lose that fragment before noVNC sees it.
Related docs:
- [Interactive desktop and VNC](../features/interactive-desktop-vnc.md)

View File

@ -87,6 +87,12 @@ Use `crabbox webvnc` for the authenticated coordinator portal:
crabbox webvnc --id blue-lobster --open
```
WebVNC uses the same runner-side VNC service as `crabbox vnc`. The difference
is the viewer path: a local `crabbox webvnc` process keeps an SSH tunnel open,
connects to the coordinator with a one-use bridge ticket, and the browser uses
bundled noVNC from the authenticated portal. The portal does not connect to the
runner by itself; the local bridge must keep running.
Use `crabbox screenshot` when you need a PNG without taking over the session:
```sh
@ -116,6 +122,10 @@ Managed VNC is tunnel-first:
- `--network tailscale` changes only the SSH endpoint used by that tunnel.
- WebVNC keeps the same local SSH tunnel and adds an authenticated browser
websocket through the coordinator.
- The WebVNC browser websocket is paired with the local bridge process inside
the coordinator Durable Object; if the browser view disconnects, the local
command reconnects a fresh bridge for the portal retry. If the local process
exits, the browser view disconnects until you start it again.
Crabbox does not bind managed VNC directly to a public IP or Tailscale 100.x
address. Static hosts can expose direct `host:5900` only when the operator has

View File

@ -475,7 +475,7 @@ export class FleetDurableObject implements DurableObject {
this.pendingWebVNCToViewer.delete(lease.id);
this.webVNCAgents.set(lease.id, agent);
agent.addEventListener("message", (event) => {
forwardOrBufferWebVNC(
void forwardOrBufferWebVNC(
event.data,
this.webVNCViewers.get(lease.id),
this.pendingWebVNCToViewer,
@ -593,7 +593,7 @@ export class FleetDurableObject implements DurableObject {
this.webVNCViewers.set(lease.id, viewer);
flushPendingWebVNC(this.pendingWebVNCToViewer, lease.id, viewer);
viewer.addEventListener("message", (event) => {
forwardWebVNC(event.data, this.webVNCAgents.get(lease.id));
void forwardWebVNC(event.data, this.webVNCAgents.get(lease.id));
});
viewer.addEventListener("close", () => this.clearWebVNCViewer(lease.id, viewer));
viewer.addEventListener("error", () => this.clearWebVNCViewer(lease.id, viewer));
@ -1325,12 +1325,13 @@ export interface WebVNCBuffer {
bytes: number;
}
export function forwardOrBufferWebVNC(
data: string | ArrayBuffer,
export async function forwardOrBufferWebVNC(
rawData: unknown,
socket: WebSocket | undefined,
buffers: Map<string, WebVNCBuffer>,
leaseID: string,
): void {
): Promise<void> {
const data = await normalizeWebVNCData(rawData);
if (socket && socket.readyState === WebSocket.OPEN) {
socket.send(data);
return;
@ -1373,13 +1374,24 @@ export function resetWebVNCBridge(
buffers.delete(leaseID);
}
function forwardWebVNC(data: string | ArrayBuffer, socket: WebSocket | undefined): void {
async function forwardWebVNC(rawData: unknown, socket: WebSocket | undefined): Promise<void> {
if (!socket || socket.readyState !== WebSocket.OPEN) {
return;
}
const data = await normalizeWebVNCData(rawData);
socket.send(data);
}
async function normalizeWebVNCData(data: unknown): Promise<string | ArrayBuffer> {
if (typeof data === "string" || data instanceof ArrayBuffer) {
return data;
}
if (data instanceof Blob) {
return await data.arrayBuffer();
}
return String(data);
}
function webVNCDataBytes(data: string | ArrayBuffer): number {
return typeof data === "string" ? textEncoder.encode(data).byteLength : data.byteLength;
}

View File

@ -586,7 +586,7 @@ describe("fleet lease identity and idle", () => {
expect(missingTicket.status).toBe(401);
});
it("buffers initial WebVNC bridge bytes until the viewer attaches", () => {
it("buffers initial WebVNC bridge bytes until the viewer attaches", async () => {
const buffers = new Map<string, WebVNCBuffer>();
const sent: Array<string | ArrayBuffer> = [];
const viewer = {
@ -596,7 +596,7 @@ describe("fleet lease identity and idle", () => {
},
} as WebSocket;
forwardOrBufferWebVNC("RFB 003.008\n", undefined, buffers, "cbx_000000000001");
await forwardOrBufferWebVNC("RFB 003.008\n", undefined, buffers, "cbx_000000000001");
expect(sent).toEqual([]);
expect(buffers.get("cbx_000000000001")).toMatchObject({
chunks: ["RFB 003.008\n"],
@ -608,6 +608,23 @@ describe("fleet lease identity and idle", () => {
expect(buffers.has("cbx_000000000001")).toBe(false);
});
it("converts WebVNC Blob frames before forwarding", async () => {
const buffers = new Map<string, WebVNCBuffer>();
const sent: Array<string | ArrayBuffer> = [];
const viewer = {
readyState: WebSocket.OPEN,
send(data: string | ArrayBuffer) {
sent.push(data);
},
} as WebSocket;
await forwardOrBufferWebVNC(new Blob(["RFB 003.008\n"]), viewer, buffers, "cbx_000000000001");
expect(sent).toHaveLength(1);
expect(new TextDecoder().decode(sent[0] as ArrayBuffer)).toBe("RFB 003.008\n");
expect(buffers.has("cbx_000000000001")).toBe(false);
});
it("resets the WebVNC bridge when the viewer goes away", () => {
const buffers = new Map<string, WebVNCBuffer>();
buffers.set("cbx_000000000001", { chunks: ["RFB 003.008\n"], bytes: 12 });