Reapply "fix: keep WebVNC binary bridge usable"
This reverts commit ac4c1953f0.
This commit is contained in:
parent
5ea56ef4f2
commit
20f7102c2f
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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 });
|
||||
|
||||
Loading…
Reference in New Issue
Block a user