feat: harden desktop WebVNC reliability

This commit is contained in:
Peter Steinberger 2026-05-07 13:17:23 +01:00
parent 19cbc17602
commit aca01bf512
No known key found for this signature in database
21 changed files with 1454 additions and 71 deletions

View File

@ -68,6 +68,17 @@ crabbox stop <cbx_id-or-slug>
```sh
crabbox status --id <id-or-slug> --wait
crabbox inspect --id <id-or-slug> --json
crabbox webvnc --id <id-or-slug> --open
crabbox webvnc daemon start --id <id-or-slug> --open
crabbox webvnc daemon status --id <id-or-slug>
crabbox webvnc daemon stop --id <id-or-slug>
crabbox webvnc status --id <id-or-slug>
crabbox webvnc reset --id <id-or-slug> --open
crabbox desktop doctor --id <id-or-slug>
crabbox desktop click --id <id-or-slug> --x 640 --y 420
crabbox desktop paste --id <id-or-slug> --text "peter@example.com"
crabbox desktop type --id <id-or-slug> --text "peter+qa@example.com"
crabbox desktop key --id <id-or-slug> ctrl+l
crabbox sync-plan
crabbox history --lease <id-or-slug>
crabbox events <run_id> --json
@ -80,6 +91,15 @@ crabbox usage --scope org
CRABBOX_LIVE=1 CRABBOX_LIVE_REPO=/path/to/openclaw scripts/live-smoke.sh
```
For human desktop demos, prefer WebVNC over native VNC because
`crabbox webvnc --open` preloads the lease password in the browser fragment.
Use native `crabbox vnc --id <id-or-slug> --open` as the fallback printed by
`crabbox webvnc status` or `crabbox webvnc reset`. For input automation, use
`crabbox desktop click/paste/type/key` instead of hand-written `xdotool`;
`desktop type` switches to clipboard paste for symbol-heavy text such as emails
and passwords. `desktop key` accepts both `--id <lease> <keys>` and positional
`<lease> <keys>` forms for shortcuts.
## Run Inspection Workflow
Use the CLI for durable run inspection; do not expect extra OpenClaw plugin

View File

@ -80,8 +80,8 @@ For the full mental model, see [How Crabbox Works](docs/how-it-works.md). For th
- **Trusted AWS images.** Operators can create AMIs from active brokered AWS leases and promote a known-good image as the coordinator default.
- **Cost guardrails.** Per-lease and monthly spend caps. Live pricing from EC2 Spot history or Hetzner server-type prices, with static fallbacks. `crabbox usage` summarizes spend by user, org, provider, and type.
- **GitHub Actions hydration.** `crabbox actions hydrate` registers a leased box as an ephemeral Actions runner, so the repo's own workflow installs runtimes, services, and secrets. Crabbox does not parse Actions YAML.
- **Interactive desktop and browser leases.** `--browser` provisions Chrome or Chromium for headless automation, `--desktop` provisions visible UI with tunnel-only VNC takeover on managed Linux, AWS native Windows, and AWS EC2 Mac targets, and QA systems such as Mantis own scenario logic, screenshots, and PR evidence. Hetzner Windows is not a managed target; use AWS for managed Windows or `provider: ssh` for an existing Windows host.
- **Authenticated web portal.** Browser login opens owner-scoped lease and run views with searchable, paginated tables, muted external-runner rows, compact provider/OS/access icons, relative sortable times, recent run logs/events, WebVNC, code-server, and Linux lease/run telemetry charts. Admin sessions can also see non-owned runner leases behind `mine`/`system` filters.
- **Interactive desktop and browser leases.** `--browser` provisions Chrome or Chromium for headless automation, `--desktop` provisions visible UI with tunnel-only VNC takeover on managed Linux, AWS native Windows, and AWS EC2 Mac targets. `crabbox desktop doctor` checks session, VNC, input tooling, browser, ffmpeg, screen size, screenshot capture, and WebVNC portal state; `desktop click/paste/type/key` provide first-class input helpers so agents do not hand-roll brittle `xdotool` snippets. Hetzner Windows is not a managed target; use AWS for managed Windows or `provider: ssh` for an existing Windows host.
- **Authenticated web portal.** Browser login opens owner-scoped lease and run views with searchable, paginated tables, muted external-runner rows, compact provider/OS/access icons, relative sortable times, recent run logs/events, WebVNC, code-server, and Linux lease/run telemetry charts. WebVNC is preferred for human demos because it preloads the VNC password; `webvnc status` reports local daemon, tunnel, target reachability, bridge/viewer state, recent events, URL/password, and native VNC fallback, while `webvnc reset` restarts only the selected lease's WebVNC/input stack. Admin sessions can also see non-owned runner leases behind `mine`/`system` filters.
- **Hardened coordinator auth.** GitHub browser login, owner-scoped leases, admin-only routes, optional GitHub team allowlists, Cloudflare Access JWT verification, and service-token support keep normal use and operator automation separate.
- **OpenClaw plugin.** The repo root is a native OpenClaw plugin for box lifecycle operations: `crabbox_run`, `crabbox_warmup`, `crabbox_status`, `crabbox_list`, and `crabbox_stop`. Run inspection stays in the CLI and Crabbox skill.
- **Operator surface.** `doctor`, `init`, `status`, `inspect`, `list`, `usage`, `history`, `logs`, `results`, `cache`, `admin`, `cleanup`, plus `--json` output where it matters.

View File

@ -36,6 +36,12 @@ crabbox config set-broker --url <url> --token-stdin [--provider hetzner|aws]
crabbox warmup [--provider hetzner|aws|ssh|blacksmith-testbox|daytona|islo] [--target linux|macos|windows] [--desktop] [--browser] [--code] [--tailscale] [--network auto|tailscale|public] [--profile <name>] [--idle-timeout <duration>] [--timing-json]
crabbox run [--id <lease-id-or-slug>] [--provider hetzner|aws|ssh|blacksmith-testbox|daytona|islo] [--target linux|macos|windows] [--windows-mode normal|wsl2] [--desktop] [--browser] [--code] [--tailscale] [--network auto|tailscale|public] [--shell] [--checksum] [--debug] [--force-sync-large] [--timing-json] [--blacksmith-workflow <workflow>] -- <command...>
crabbox desktop launch --id <lease-id-or-slug> [--browser] [--url <url>] [--egress <profile>] [--webvnc] [--open] [-- <command...>]
crabbox desktop doctor --id <lease-id-or-slug> [--network auto|tailscale|public]
crabbox desktop click --id <lease-id-or-slug> --x <n> --y <n> [--network auto|tailscale|public]
crabbox desktop paste --id <lease-id-or-slug> --text <text> [--network auto|tailscale|public]
crabbox desktop paste --id <lease-id-or-slug> [--network auto|tailscale|public] < input.txt
crabbox desktop type --id <lease-id-or-slug> --text <text> [--network auto|tailscale|public]
crabbox desktop key --id <lease-id-or-slug> <keys> [--network auto|tailscale|public]
crabbox code --id <lease-id-or-slug> [--open]
crabbox egress start --id <lease-id-or-slug> [--profile <name>|--allow <hosts>] [--listen <addr>] [--coordinator <url>] [--daemon]
crabbox egress host --id <lease-id-or-slug> [--profile <name>|--allow <hosts>]
@ -65,6 +71,11 @@ crabbox admin delete <lease-id-or-slug> --force
crabbox ssh --id <lease-id-or-slug> [--network auto|tailscale|public]
crabbox vnc --id <lease-id-or-slug> [--network auto|tailscale|public] [--open]
crabbox webvnc --id <lease-id-or-slug> [--network auto|tailscale|public] [--open]
crabbox webvnc daemon start --id <lease-id-or-slug> [--network auto|tailscale|public] [--open]
crabbox webvnc daemon status --id <lease-id-or-slug>
crabbox webvnc daemon stop --id <lease-id-or-slug>
crabbox webvnc status --id <lease-id-or-slug> [--network auto|tailscale|public]
crabbox webvnc reset --id <lease-id-or-slug> [--network auto|tailscale|public] [--open]
crabbox inspect --id <lease-id-or-slug> [--network auto|tailscale|public] [--json]
crabbox stop <lease-id-or-slug>
crabbox cleanup [--dry-run]
@ -93,8 +104,13 @@ crabbox warmup --desktop --browser
crabbox run --id blue-lobster -- pnpm test:changed
crabbox vnc --id blue-lobster --open
crabbox webvnc --id blue-lobster --open
crabbox webvnc status --id blue-lobster
crabbox webvnc daemon start --id blue-lobster --open
crabbox code --id blue-lobster --open
crabbox desktop launch --id blue-lobster --browser --url https://example.com --webvnc --open
crabbox desktop doctor --id blue-lobster
crabbox desktop paste --id blue-lobster --text "peter@example.com"
crabbox desktop key --id blue-lobster ctrl+l
crabbox egress start --id blue-lobster --profile discord --daemon
crabbox desktop launch --id blue-lobster --browser --url https://discord.com/login --egress discord --webvnc --open
crabbox egress status --id blue-lobster

View File

@ -9,6 +9,13 @@ crabbox desktop launch --id blue-lobster --browser --url https://example.com
crabbox desktop launch --id blue-lobster --browser --url https://example.com --webvnc --open
crabbox desktop launch --id blue-lobster --browser --url https://discord.com/login --egress discord --webvnc --open
crabbox desktop launch --id blue-lobster -- xterm
crabbox desktop doctor --id blue-lobster
crabbox desktop click --id blue-lobster --x 640 --y 420
crabbox desktop paste --id blue-lobster --text "peter@example.com"
printf 'peter@example.com' | crabbox desktop paste --id blue-lobster
crabbox desktop type --id blue-lobster --text "hello"
crabbox desktop key --id blue-lobster ctrl+l
crabbox desktop key blue-lobster ctrl+l
```
The command resolves and touches the lease, verifies `desktop=true`, waits for
@ -34,6 +41,26 @@ launcher minimizes existing windows, starts the app, and tries to foreground
the new process. On Linux and macOS, the command is detached with `setsid` or
`nohup`.
`crabbox desktop doctor` checks the selected lease without syncing the repo.
For Linux desktop leases it reports VM/session health separately from portal
health: `DISPLAY`, Xvfb/window manager/panel, VNC listener, `xdotool`,
clipboard tool, browser binary, `ffmpeg`, screen size, screenshot capture, and
WebVNC bridge/viewer state. Failures include a one-line repair suggestion so
you can tell session bugs from WebVNC/browser-portal bugs.
Input helpers also operate on the selected lease over SSH without repo sync.
Use them instead of hand-written `xdotool` snippets. `desktop type` uses raw
`xdotool type` only for simple alphanumeric text; text with emails, passwords,
symbols such as `@` or `+`, URLs, whitespace, or long payloads goes through the
remote clipboard and paste path because keyboard layouts can otherwise corrupt
special characters.
`desktop paste` accepts `--text` or stdin. `desktop key` accepts either
`--id <lease> <keys>` or the positional lease form `<lease> <keys>`; the key
sequence is parsed after lease flags so common forms such as
`crabbox desktop key blue-lobster ctrl+l` and
`crabbox desktop key -id blue-lobster ctrl+l` send `ctrl+l`, not the lease id.
Flags:
```text
@ -55,6 +82,19 @@ Flags:
--reclaim
```
Input helper flags:
```text
desktop doctor --id <lease-id-or-slug>
desktop click --id <lease-id-or-slug> --x <n> --y <n>
desktop paste --id <lease-id-or-slug> --text <text>
desktop paste --id <lease-id-or-slug> < input.txt
desktop type --id <lease-id-or-slug> --text <text>
desktop key --id <lease-id-or-slug> <keys>
desktop key <lease-id-or-slug> <keys>
desktop key --id <lease-id-or-slug> --keys <keys>
```
Related docs:
- [egress](egress.md)

View File

@ -11,7 +11,12 @@ crabbox warmup --desktop
crabbox webvnc --id blue-lobster
crabbox webvnc --id blue-lobster --network tailscale
crabbox webvnc --id blue-lobster --open
crabbox webvnc --id blue-lobster --daemon --open
crabbox webvnc daemon start --id blue-lobster --open
crabbox webvnc daemon status --id blue-lobster
crabbox webvnc daemon stop --id blue-lobster
crabbox webvnc status --id blue-lobster
crabbox webvnc status --id blue-lobster --network tailscale
crabbox webvnc reset --id blue-lobster --open
```
## How It Works
@ -53,11 +58,42 @@ This keeps the security boundary the same as `crabbox vnc`:
- The local `crabbox webvnc` process must keep running while the browser uses
the desktop.
Use `--daemon` (or `--background`) to keep the bridge running without a tmux or
foreground shell. Crabbox writes the bridge log and pid file under its local
state directory and prints both paths. Use `--status` to print those paths
again, and `--stop` to kill the background bridge for that lease. Shutdown
terminates both the daemon supervisor and the active child bridge process.
Use `crabbox webvnc daemon start --id <lease> --open` to keep the bridge
running without a tmux or foreground shell. Crabbox writes the bridge log and
pid file under its local state directory and prints both paths. Use
`crabbox webvnc daemon status --id <lease>` for the local pid/log check, and
`crabbox webvnc daemon stop --id <lease>` to kill the background bridge for
that lease. Shutdown terminates both the daemon supervisor and the active child
bridge process.
The older `crabbox webvnc --id <lease> --daemon`, `--background`, `--status`,
and `--stop` forms remain accepted as compatibility aliases, but new docs and
automation should use the explicit `daemon` subcommands.
Use `crabbox webvnc status --id <lease>` for the full health view: local daemon
pid/log, SSH tunnel command, target VNC reachability, coordinator bridge/viewer
state, recent bridge events, portal URL/password, and the exact native VNC
fallback command. If status or reset is run with `--network public` or
`--network tailscale`, the printed native VNC fallback carries the same network
selection.
Typical status output is meant to be directly actionable:
```text
webvnc daemon: pid=12345 log=...
vnc target: reachable 127.0.0.1:5900 managed=true
ssh tunnel: ssh ... -L 5901:127.0.0.1:5900 ...
portal bridge: connected=true viewer=false
event: 2026-05-07T12:00:00Z bridge_connected
webvnc: https://crabbox.openclaw.ai/portal/leases/cbx_.../vnc#password=...
fallback: crabbox vnc --provider aws --target linux --network tailscale --id cbx_... --open
```
Use `crabbox webvnc reset --id <lease> --open` when the portal is stuck on a
stale bridge/viewer/session. Reset closes only that lease's coordinator
WebVNC sockets, stops only that lease's local daemon pid after verifying it is
a Crabbox WebVNC process, restarts the target desktop helper/VNC services, then
starts a fresh background bridge and prints the new portal URL.
`--network tailscale` changes only the SSH endpoint used for the local tunnel.
The runner VNC service stays bound to loopback.
@ -81,6 +117,11 @@ crabbox webvnc --id <lease-id-or-slug>
in a terminal and leave it running.
For human demos, prefer WebVNC over native VNC because `crabbox webvnc --open`
preloads the per-lease password in the local browser URL fragment. Use native
VNC only as the fallback printed by `crabbox webvnc status` or
`crabbox webvnc reset`.
## Flags
Flags:
@ -97,10 +138,11 @@ Flags:
--network auto|tailscale|public
--local-port <port>
--open
--daemon
--background
--status
--stop
status
reset
daemon start
daemon status
daemon stop
--reclaim
```
@ -139,6 +181,17 @@ 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.
Another viewer is active
Close old WebVNC tabs first. If the portal still reports a stale viewer, run:
```sh
crabbox webvnc reset --id <lease-id-or-slug> --open
```
If WebVNC remains unreliable, use the exact native fallback command printed by
`crabbox webvnc status --id <lease-id-or-slug>`.
VNC authentication fails
Use the password printed by `crabbox webvnc`. With `--open`, the command tries

View File

@ -4,6 +4,9 @@ Read when:
- choosing a desktop target for browser/UI QA;
- opening a lease with VNC or WebVNC;
- diagnosing stale WebVNC viewers, bridge disconnects, or broken desktop
sessions;
- driving desktop input from agents without hand-written `xdotool`;
- deciding which layer owns desktop setup, browser state, screenshots, or
credentials.
@ -17,8 +20,10 @@ boundary.
```sh
crabbox warmup --desktop --browser
crabbox vnc --id blue-lobster --open
crabbox webvnc --id blue-lobster --open
crabbox webvnc status --id blue-lobster
crabbox desktop doctor --id blue-lobster
crabbox vnc --id blue-lobster --open
crabbox screenshot --id blue-lobster --output desktop.png
```
@ -73,7 +78,18 @@ Scenario systems such as Mantis own:
## Commands
Use `crabbox vnc` for a native VNC client:
Use `crabbox webvnc` for the authenticated coordinator portal. This is the
preferred path for human demos because `--open` preloads the VNC password in
the local browser fragment:
```sh
crabbox webvnc --id blue-lobster --open
crabbox webvnc status --id blue-lobster
crabbox webvnc reset --id blue-lobster --open
```
Use `crabbox vnc` for a native VNC client when WebVNC status/reset says the
portal/browser path is unhealthy or when you need a native client feature:
```sh
crabbox vnc --id blue-lobster
@ -81,12 +97,6 @@ crabbox vnc --id blue-lobster --network tailscale
crabbox vnc --id blue-lobster --open
```
Use `crabbox webvnc` for the authenticated coordinator portal:
```sh
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
@ -111,6 +121,28 @@ panel, title bar, and surrounding session remain visible. Use
`desktop launch --fullscreen` only when you intentionally want browser-only
video or capture output.
Use `crabbox desktop doctor --id <lease>` before blaming WebVNC. It checks the
lease's desktop session, VNC service, input tooling, browser binary, ffmpeg,
screen geometry, and screenshot capture, then separately reports WebVNC
bridge/viewer status with one-line repair suggestions.
Use first-class input helpers instead of hand-rolled `xdotool`:
```sh
crabbox desktop click --id blue-lobster --x 640 --y 420
crabbox desktop paste --id blue-lobster --text "peter@example.com"
printf 'peter@example.com' | crabbox desktop paste --id blue-lobster
crabbox desktop type --id blue-lobster --text "hello"
crabbox desktop key --id blue-lobster ctrl+l
crabbox desktop key blue-lobster ctrl+l
```
Prefer `desktop paste` or symbol-aware `desktop type` for emails, passwords,
URLs, and text containing characters such as `@` or `+`; raw key-symbol typing
can vary with the target X keyboard layout. `desktop key` is for shortcuts and
special keys, and supports both `--id <lease> <keys>` and positional
`<lease> <keys>` forms.
## Network Model
Managed VNC is tunnel-first:
@ -126,6 +158,14 @@ Managed VNC is tunnel-first:
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 webvnc status` reports the local daemon pid/log, SSH tunnel command,
target VNC reachability, coordinator bridge/viewer state, recent bridge
events, portal URL/password, and the exact native `crabbox vnc ... --open`
fallback. The fallback preserves explicit `--network public` or
`--network tailscale` selections.
- `crabbox webvnc reset` closes only the selected lease's WebVNC sockets,
stops only that lease's verified local WebVNC daemon, restarts the target
desktop/VNC services, then prints the fresh portal URL.
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

@ -15,6 +15,8 @@ bind x11vnc to loopback, and let the CLI create an SSH tunnel.
```sh
crabbox warmup --desktop --browser
crabbox run --id blue-lobster --desktop --browser -- google-chrome --version
crabbox desktop doctor --id blue-lobster
crabbox webvnc --id blue-lobster --open
crabbox vnc --id blue-lobster --open
crabbox screenshot --id blue-lobster --output linux.png
```
@ -25,6 +27,7 @@ Managed Linux desktop leases include:
- a lightweight desktop/window-manager session;
- x11vnc bound to `127.0.0.1:5900`;
- screenshot and video capture tools (`scrot` and `ffmpeg`);
- input helpers (`xdotool`) and clipboard paste tools (`xclip`/`xsel`);
- a generated per-lease VNC password at `/var/lib/crabbox/vnc.password`;
- optional Chrome stable or Chromium fallback, first-run suppression, and native
addon build helpers when `--browser` is requested;
@ -80,10 +83,29 @@ use:
crabbox desktop launch --id blue-lobster --browser --url https://example.com
```
Run `crabbox desktop doctor --id blue-lobster` to separate session problems
from WebVNC/browser-portal problems. Missing `xfwm4`, `xfce4-panel`, x11vnc,
clipboard tools, browser, ffmpeg, screen size, or screenshot capture each get a
specific repair line.
Input symbols are wrong
Use Crabbox's desktop helpers instead of raw `xdotool type`:
```sh
crabbox desktop paste --id blue-lobster --text "peter+qa@example.com"
crabbox desktop type --id blue-lobster --text "peter+qa@example.com"
```
`desktop type` uses clipboard paste for symbol-heavy text, so `@`, `+`,
password-like values, and URLs do not depend on the target X keyboard layout.
Related docs:
- [Interactive desktop and VNC](interactive-desktop-vnc.md)
- [Hetzner](hetzner.md)
- [AWS](aws.md)
- [vnc command](../commands/vnc.md)
- [webvnc command](../commands/webvnc.md)
- [desktop command](../commands/desktop.md)
- [screenshot command](../commands/screenshot.md)

View File

@ -534,7 +534,7 @@ func cloudInitOptionalBootstrap(cfg Config) string {
parts = append(parts, cloudInitTailscaleBootstrap(cfg))
}
if cfg.Desktop {
parts = append(parts, ` retry apt-get install -y --no-install-recommends xvfb xfce4-session xfwm4 xfce4-panel xfdesktop4 xfce4-terminal xfconf xfce4-settings x11vnc xauth dbus-x11 x11-xserver-utils xterm scrot ffmpeg xdotool wmctrl fonts-dejavu-core fonts-liberation iproute2 openssl
parts = append(parts, ` retry apt-get install -y --no-install-recommends xvfb xfce4-session xfwm4 xfce4-panel xfdesktop4 xfce4-terminal xfconf xfce4-settings x11vnc xauth dbus-x11 x11-xserver-utils xterm scrot ffmpeg xdotool wmctrl xclip xsel fonts-dejavu-core fonts-liberation iproute2 openssl
install -d -m 0750 -o crabbox -g crabbox /var/lib/crabbox
if [ ! -s /var/lib/crabbox/vnc.password ]; then
(umask 077 && openssl rand -base64 18 > /var/lib/crabbox/vnc.password)
@ -572,7 +572,7 @@ func cloudInitOptionalBootstrap(cfg Config) string {
install -d -m 0755 /etc/opt/chrome/policies/managed /etc/chromium/policies/managed
printf '%s\n' '{"DefaultBrowserSettingEnabled":false,"MetricsReportingEnabled":false,"PromotionalTabsEnabled":false}' > /etc/opt/chrome/policies/managed/crabbox.json
cp /etc/opt/chrome/policies/managed/crabbox.json /etc/chromium/policies/managed/crabbox.json
printf '%s\n' '#!/bin/sh' "exec \"$browser_path\" --no-first-run --no-default-browser-check --disable-default-apps \"\$@\"" > "$browser_wrapper"
printf '%s\n' '#!/bin/sh' "exec \"$browser_path\" --no-first-run --no-default-browser-check --disable-default-apps --window-size=1500,900 --window-position=80,80 \"\$@\"" > "$browser_wrapper"
chmod 0755 "$browser_wrapper"
printf 'CHROME_BIN=%s\nBROWSER=%s\n' "$browser_wrapper" "$browser_wrapper" > /var/lib/crabbox/browser.env
chown crabbox:crabbox /var/lib/crabbox/browser.env

View File

@ -58,7 +58,7 @@ func TestCloudInitDesktopProfile(t *testing.T) {
for _, want := range []string{
"xvfb xfce4-session xfwm4 xfce4-panel xfdesktop4 xfce4-terminal",
"xfconf xfce4-settings x11vnc xauth dbus-x11",
"x11-xserver-utils xterm scrot ffmpeg xdotool wmctrl",
"x11-xserver-utils xterm scrot ffmpeg xdotool wmctrl xclip xsel",
"/etc/systemd/system/crabbox-xvfb.service",
"/etc/systemd/system/crabbox-desktop.service",
"/usr/local/bin/crabbox-desktop-session",
@ -95,12 +95,12 @@ func TestCloudInitBrowserProfile(t *testing.T) {
"apt-cache show chromium-browser",
"/etc/opt/chrome/policies/managed/crabbox.json",
"/usr/local/bin/crabbox-browser",
"--no-first-run --no-default-browser-check --disable-default-apps",
"--no-first-run --no-default-browser-check --disable-default-apps --window-size=1500,900 --window-position=80,80",
"/var/lib/crabbox/browser.env",
"test -x \"$BROWSER\"",
"\"$BROWSER\" --version >/dev/null",
"printf '%s\\n' '{\"DefaultBrowserSettingEnabled\":false,\"MetricsReportingEnabled\":false,\"PromotionalTabsEnabled\":false}' > /etc/opt/chrome/policies/managed/crabbox.json",
"printf '%s\\n' '#!/bin/sh' \"exec \\\"$browser_path\\\" --no-first-run --no-default-browser-check --disable-default-apps \\\"\\$@\\\"\" > \"$browser_wrapper\"",
"printf '%s\\n' '#!/bin/sh' \"exec \\\"$browser_path\\\" --no-first-run --no-default-browser-check --disable-default-apps --window-size=1500,900 --window-position=80,80 \\\"\\$@\\\"\" > \"$browser_wrapper\"",
} {
if !strings.Contains(got, want) {
t.Fatalf("cloudInit(browser) missing %q", want)

View File

@ -197,10 +197,30 @@ type cleanupKongCmd struct {
type desktopKongCmd struct {
Launch desktopLaunchKongCmd `cmd:"" passthrough:"" help:"Start an app inside a desktop lease."`
Doctor desktopDoctorKongCmd `cmd:"" passthrough:"" help:"Check desktop session readiness for a lease."`
Click desktopClickKongCmd `cmd:"" passthrough:"" help:"Click inside a desktop lease."`
Paste desktopPasteKongCmd `cmd:"" passthrough:"" help:"Paste text into a desktop lease."`
Type desktopTypeKongCmd `cmd:"" passthrough:"" help:"Type text into a desktop lease."`
Key desktopKeyKongCmd `cmd:"" passthrough:"" help:"Send keys to a desktop lease."`
}
type desktopLaunchKongCmd struct {
Args []string `arg:"" optional:""`
}
type desktopDoctorKongCmd struct {
Args []string `arg:"" optional:""`
}
type desktopClickKongCmd struct {
Args []string `arg:"" optional:""`
}
type desktopPasteKongCmd struct {
Args []string `arg:"" optional:""`
}
type desktopTypeKongCmd struct {
Args []string `arg:"" optional:""`
}
type desktopKeyKongCmd struct {
Args []string `arg:"" optional:""`
}
type mediaKongCmd struct {
Preview mediaPreviewKongCmd `cmd:"" passthrough:"" help:"Create a trimmed animated GIF preview from a video."`
@ -330,6 +350,21 @@ func (c *cleanupKongCmd) Run(ctx context.Context, app App) error { return app.cl
func (c *desktopLaunchKongCmd) Run(ctx context.Context, app App) error {
return app.desktopLaunch(ctx, c.Args)
}
func (c *desktopDoctorKongCmd) Run(ctx context.Context, app App) error {
return app.desktopDoctor(ctx, c.Args)
}
func (c *desktopClickKongCmd) Run(ctx context.Context, app App) error {
return app.desktopClick(ctx, c.Args)
}
func (c *desktopPasteKongCmd) Run(ctx context.Context, app App) error {
return app.desktopPaste(ctx, c.Args)
}
func (c *desktopTypeKongCmd) Run(ctx context.Context, app App) error {
return app.desktopType(ctx, c.Args)
}
func (c *desktopKeyKongCmd) Run(ctx context.Context, app App) error {
return app.desktopKey(ctx, c.Args)
}
func (c *mediaPreviewKongCmd) Run(ctx context.Context, app App) error {
return app.mediaPreview(ctx, c.Args)

View File

@ -135,6 +135,31 @@ type CoordinatorWebVNCTicket struct {
ExpiresAt string `json:"expiresAt"`
}
type CoordinatorWebVNCEvent struct {
At string `json:"at"`
Event string `json:"event"`
Reason string `json:"reason,omitempty"`
}
type CoordinatorWebVNCStatus struct {
LeaseID string `json:"leaseID"`
Slug string `json:"slug,omitempty"`
BridgeConnected bool `json:"bridgeConnected"`
ViewerConnected bool `json:"viewerConnected"`
Command string `json:"command"`
Message string `json:"message,omitempty"`
Events []CoordinatorWebVNCEvent `json:"events,omitempty"`
}
type CoordinatorWebVNCReset struct {
LeaseID string `json:"leaseID"`
Slug string `json:"slug,omitempty"`
BridgeWasConnected bool `json:"bridgeWasConnected"`
ViewerWasConnected bool `json:"viewerWasConnected"`
Command string `json:"command"`
Events []CoordinatorWebVNCEvent `json:"events,omitempty"`
}
type CoordinatorEgressTicket struct {
Ticket string `json:"ticket"`
LeaseID string `json:"leaseID"`
@ -558,6 +583,18 @@ func (c *CoordinatorClient) CreateWebVNCTicket(ctx context.Context, leaseID stri
return res, err
}
func (c *CoordinatorClient) WebVNCStatus(ctx context.Context, leaseID string) (CoordinatorWebVNCStatus, error) {
var res CoordinatorWebVNCStatus
err := c.do(ctx, http.MethodGet, "/v1/leases/"+url.PathEscape(leaseID)+"/webvnc/status", nil, &res)
return res, err
}
func (c *CoordinatorClient) ResetWebVNC(ctx context.Context, leaseID string) (CoordinatorWebVNCReset, error) {
var res CoordinatorWebVNCReset
err := c.do(ctx, http.MethodPost, "/v1/leases/"+url.PathEscape(leaseID)+"/webvnc/reset", map[string]any{}, &res)
return res, err
}
func (c *CoordinatorClient) CreateEgressTicket(ctx context.Context, leaseID, role, sessionID, profile string, allow []string) (CoordinatorEgressTicket, error) {
var res CoordinatorEgressTicket
body := map[string]any{

View File

@ -21,6 +21,7 @@ func (a App) desktopLaunch(ctx context.Context, args []string) error {
egressProxy := fs.String("egress-proxy", defaultEgressListen, "lease-local egress proxy for --egress")
reclaim := fs.Bool("reclaim", false, "claim this lease for the current repo")
targetFlags := registerTargetFlags(fs, defaults)
networkFlags := registerNetworkModeFlag(fs, defaults)
if err := parseFlags(fs, args); err != nil {
return err
}
@ -45,6 +46,9 @@ func (a App) desktopLaunch(ctx context.Context, args []string) error {
if err := applyTargetFlagOverrides(&cfg, fs, targetFlags); err != nil {
return err
}
if err := applyNetworkModeFlagOverride(&cfg, fs, networkFlags); err != nil {
return err
}
if err := validateRequestedCapabilities(cfg); err != nil {
return err
}
@ -54,7 +58,7 @@ func (a App) desktopLaunch(ctx context.Context, args []string) error {
if *id == "" && !isStaticProvider(cfg.Provider) {
return exit(2, "usage: crabbox desktop launch --id <lease-id-or-slug> [--browser] [--url <url>] -- <command...>")
}
server, target, leaseID, err := a.resolveLeaseTarget(ctx, cfg, *id)
server, target, leaseID, err := a.resolveNetworkLeaseTarget(ctx, cfg, *id, false)
if err != nil {
return err
}
@ -118,6 +122,9 @@ func (a App) desktopLaunch(ctx context.Context, args []string) error {
func desktopLaunchWebVNCArgs(cfg Config, target SSHTarget, leaseID string, openPortal bool) []string {
targetOS := firstNonBlank(target.TargetOS, cfg.TargetOS)
args := []string{"--provider", cfg.Provider, "--target", targetOS, "--id", leaseID}
if cfg.Network != "" && cfg.Network != NetworkAuto {
args = append(args, "--network", string(cfg.Network))
}
windowsMode := firstNonBlank(target.WindowsMode, cfg.WindowsMode)
if targetOS == targetWindows && windowsMode != "" {
args = append(args, "--windows-mode", windowsMode)

View File

@ -0,0 +1,310 @@
package cli
import (
"context"
"fmt"
"io"
"os"
"strconv"
"strings"
)
func (a App) desktopDoctor(ctx context.Context, args []string) error {
target, cfg, leaseID, err := a.desktopCommandTarget(ctx, "desktop doctor", args, false)
if err != nil {
return err
}
fmt.Fprintf(a.Stdout, "lease: %s provider=%s target=%s\n", leaseID, cfg.Provider, target.TargetOS)
out, err := runSSHOutput(ctx, target, desktopDoctorRemoteCommand(target))
if err != nil {
return exit(5, "desktop doctor failed: %v", err)
}
fmt.Fprintln(a.Stdout, out)
if isBlacksmithProvider(cfg.Provider) || isStaticProvider(cfg.Provider) {
return nil
}
coord, useCoordinator, err := newTargetCoordinatorClient(cfg)
if err == nil && useCoordinator && coord != nil && coord.Token != "" {
status, err := coord.WebVNCStatus(ctx, leaseID)
if err != nil {
fmt.Fprintf(a.Stdout, "portal failed webvnc %v repair=crabbox webvnc status --id %s\n", err, leaseID)
} else {
fmt.Fprintf(a.Stdout, "portal ok webvnc bridge=%t viewer=%t\n", status.BridgeConnected, status.ViewerConnected)
if status.ViewerConnected {
fmt.Fprintf(a.Stdout, "portal warn stale-viewer repair=crabbox webvnc reset --id %s --open\n", leaseID)
}
if !status.BridgeConnected {
fmt.Fprintf(a.Stdout, "portal warn no-bridge repair=crabbox webvnc daemon start --id %s --open\n", leaseID)
}
}
}
return nil
}
func (a App) desktopClick(ctx context.Context, args []string) error {
target, _, leaseID, err := a.desktopCommandTarget(ctx, "desktop click", args, true)
if err != nil {
return err
}
x, xOK := intFlagValue(args, "x")
y, yOK := intFlagValue(args, "y")
if !xOK || !yOK || x < 0 || y < 0 {
return exit(2, "usage: crabbox desktop click --id <lease-id-or-slug> --x <n> --y <n>")
}
if err := runSSHQuiet(ctx, target, desktopClickRemoteCommand(x, y)); err != nil {
return exit(5, "desktop click failed for %s: %v", leaseID, err)
}
fmt.Fprintf(a.Stdout, "clicked: lease=%s x=%d y=%d\n", leaseID, x, y)
return nil
}
func (a App) desktopPaste(ctx context.Context, args []string) error {
target, _, leaseID, err := a.desktopCommandTarget(ctx, "desktop paste", args, true)
if err != nil {
return err
}
text, err := desktopTextArgOrStdin(a.Stderr, args, "desktop paste")
if err != nil {
return err
}
if err := runSSHInputQuiet(ctx, target, desktopPasteRemoteCommand(), text); err != nil {
return exit(5, "desktop paste failed for %s: %v", leaseID, err)
}
fmt.Fprintf(a.Stdout, "pasted: lease=%s bytes=%d\n", leaseID, len(text))
return nil
}
func (a App) desktopType(ctx context.Context, args []string) error {
target, _, leaseID, err := a.desktopCommandTarget(ctx, "desktop type", args, true)
if err != nil {
return err
}
text, err := desktopTextArgOrStdin(a.Stderr, args, "desktop type")
if err != nil {
return err
}
if desktopShouldPasteForType(text) {
if err := runSSHInputQuiet(ctx, target, desktopPasteRemoteCommand(), text); err != nil {
return exit(5, "desktop type paste fallback failed for %s: %v", leaseID, err)
}
fmt.Fprintf(a.Stdout, "typed: lease=%s method=paste bytes=%d\n", leaseID, len(text))
return nil
}
if err := runSSHQuiet(ctx, target, desktopTypeRemoteCommand(text)); err != nil {
return exit(5, "desktop type failed for %s: %v", leaseID, err)
}
fmt.Fprintf(a.Stdout, "typed: lease=%s method=xdotool bytes=%d\n", leaseID, len(text))
return nil
}
func (a App) desktopKey(ctx context.Context, args []string) error {
target, _, leaseID, err := a.desktopCommandTarget(ctx, "desktop key", args, true)
if err != nil {
return err
}
keys, err := desktopKeySequenceArg(args)
if err != nil {
return err
}
if strings.TrimSpace(keys) == "" {
return exit(2, "usage: crabbox desktop key --id <lease-id-or-slug> <keys>")
}
if err := runSSHQuiet(ctx, target, desktopKeyRemoteCommand(keys)); err != nil {
return exit(5, "desktop key failed for %s: %v", leaseID, err)
}
fmt.Fprintf(a.Stdout, "key: lease=%s keys=%s\n", leaseID, strings.TrimSpace(keys))
return nil
}
func (a App) desktopCommandTarget(ctx context.Context, name string, args []string, requireLinux bool) (SSHTarget, Config, string, error) {
defaults := defaultConfig()
fs := newFlagSet(name, a.Stderr)
provider := fs.String("provider", defaults.Provider, "provider: hetzner, aws, or ssh")
id := fs.String("id", "", "lease id or slug")
targetFlags := registerTargetFlags(fs, defaults)
networkFlags := registerNetworkModeFlag(fs, defaults)
if strings.HasSuffix(name, "click") {
fs.Int("x", -1, "x coordinate")
fs.Int("y", -1, "y coordinate")
}
if strings.HasSuffix(name, "paste") || strings.HasSuffix(name, "type") {
fs.String("text", "", "text to enter")
}
if strings.HasSuffix(name, "key") {
fs.String("keys", "", "xdotool key sequence")
}
if err := parseFlags(fs, args); err != nil {
return SSHTarget{}, Config{}, "", err
}
setIDFromFirstArg(fs, id)
cfg, err := loadLeaseTargetConfig(fs, *provider, targetFlags, networkFlags, leaseTargetConfigOptions{Desktop: true})
if err != nil {
return SSHTarget{}, Config{}, "", err
}
if isBlacksmithProvider(cfg.Provider) {
return SSHTarget{}, Config{}, "", exit(2, "desktop helpers are not supported for provider=%s; Blacksmith owns machine connectivity", cfg.Provider)
}
if err := requireLeaseID(*id, "crabbox "+name+" --id <lease-id-or-slug>", cfg); err != nil {
return SSHTarget{}, Config{}, "", err
}
server, target, leaseID, err := a.resolveNetworkLeaseTarget(ctx, cfg, *id, false)
if err != nil {
return SSHTarget{}, Config{}, "", err
}
if err := enforceManagedLeaseCapabilities(cfg, server, leaseID); err != nil {
return SSHTarget{}, Config{}, "", err
}
if requireLinux && target.TargetOS != targetLinux {
return SSHTarget{}, Config{}, "", exit(2, "desktop input helpers currently require target=linux with xdotool")
}
a.touchLeaseTargetBestEffort(ctx, cfg, LeaseTarget{Server: server, SSH: target, LeaseID: leaseID}, "")
return target, cfg, leaseID, nil
}
func desktopKeySequenceArg(args []string) (string, error) {
defaults := defaultConfig()
fs := newFlagSet("desktop key", io.Discard)
fs.String("provider", defaults.Provider, "provider: hetzner, aws, or ssh")
id := fs.String("id", "", "lease id or slug")
registerTargetFlags(fs, defaults)
registerNetworkModeFlag(fs, defaults)
keys := fs.String("keys", "", "xdotool key sequence")
if err := parseFlags(fs, args); err != nil {
return "", err
}
if strings.TrimSpace(*keys) != "" {
return *keys, nil
}
remaining := fs.Args()
if *id == "" && len(remaining) > 0 {
remaining = remaining[1:]
}
if len(remaining) == 0 {
return "", nil
}
return remaining[0], nil
}
func desktopTextArgOrStdin(stderr io.Writer, args []string, name string) (string, error) {
_ = stderr
if text, ok := stringFlagValue(args, "text"); ok {
return text, nil
}
info, err := os.Stdin.Stat()
if err == nil && info.Mode()&os.ModeCharDevice != 0 {
return "", exit(2, "usage: crabbox %s --id <lease-id-or-slug> --text <text>", name)
}
data, err := io.ReadAll(os.Stdin)
if err != nil {
return "", exit(2, "read stdin: %v", err)
}
return string(data), nil
}
func stringFlagValue(args []string, name string) (string, bool) {
prefix := "--" + name + "="
for i, arg := range args {
if strings.HasPrefix(arg, prefix) {
return strings.TrimPrefix(arg, prefix), true
}
if arg == "--"+name && i+1 < len(args) {
return args[i+1], true
}
}
return "", false
}
func intFlagValue(args []string, name string) (int, bool) {
value, ok := stringFlagValue(args, name)
if !ok {
return 0, false
}
n, err := strconv.Atoi(value)
return n, err == nil
}
func desktopShouldPasteForType(text string) bool {
if text == "" {
return false
}
if strings.ContainsAny(text, "\n\r\t @+:/\\'\"`$&|;<>[]{}()!*?=") {
return true
}
if len(text) > 64 {
return true
}
return false
}
func desktopClickRemoteCommand(x, y int) string {
return fmt.Sprintf(`set -eu
export DISPLAY="${DISPLAY:-:99}"
command -v xdotool >/dev/null 2>&1 || { echo "missing xdotool; warm a new --desktop lease or install xdotool" >&2; exit 127; }
xdotool mousemove %d %d click 1`, x, y)
}
func desktopKeyRemoteCommand(keys string) string {
return `set -eu
export DISPLAY="${DISPLAY:-:99}"
command -v xdotool >/dev/null 2>&1 || { echo "missing xdotool; warm a new --desktop lease or install xdotool" >&2; exit 127; }
xdotool key --clearmodifiers ` + shellQuote(strings.TrimSpace(keys))
}
func desktopTypeRemoteCommand(text string) string {
return `set -eu
export DISPLAY="${DISPLAY:-:99}"
command -v xdotool >/dev/null 2>&1 || { echo "missing xdotool; warm a new --desktop lease or install xdotool" >&2; exit 127; }
xdotool type --clearmodifiers --delay 1 -- ` + shellQuote(text)
}
func desktopPasteRemoteCommand() string {
return `set -eu
export DISPLAY="${DISPLAY:-:99}"
tmp="$(mktemp)"
trap 'rm -f "$tmp"' EXIT
cat > "$tmp"
if command -v xclip >/dev/null 2>&1; then
timeout 5s xclip -selection clipboard -loops 1 "$tmp" &
clip_pid=$!
elif command -v xsel >/dev/null 2>&1; then
timeout 5s xsel --clipboard --input < "$tmp" &
clip_pid=$!
elif command -v wl-copy >/dev/null 2>&1; then
wl-copy --paste-once < "$tmp" &
clip_pid=$!
else
echo "missing clipboard tool; warm a new --desktop lease or install xclip/xsel" >&2
exit 127
fi
command -v xdotool >/dev/null 2>&1 || { echo "missing xdotool; warm a new --desktop lease or install xdotool" >&2; exit 127; }
sleep 0.2
xdotool key --clearmodifiers ctrl+v
wait "$clip_pid" || true`
}
func desktopDoctorRemoteCommand(target SSHTarget) string {
if target.TargetOS != targetLinux {
return `echo "session warn target unsupported repair=desktop doctor has full checks for linux/xvfb leases"`
}
return `set +e
export DISPLAY="${DISPLAY:-:99}"
check() {
layer="$1"; item="$2"; shift 2
if "$@" >/dev/null 2>&1; then
echo "$layer ok $item"
else
echo "$layer failed $item repair=$CRABBOX_REPAIR"
fi
}
CRABBOX_REPAIR="ensure DISPLAY=:99 is exported"; [ -n "$DISPLAY" ] && echo "session ok DISPLAY=$DISPLAY" || echo "session failed DISPLAY repair=export DISPLAY=:99"
CRABBOX_REPAIR="restart crabbox-xvfb.service"; check session xvfb pgrep -f "Xvfb :99"
CRABBOX_REPAIR="restart crabbox-desktop.service"; check session xfwm4 pgrep -x xfwm4
CRABBOX_REPAIR="restart crabbox-desktop.service"; check session panel pgrep -x xfce4-panel
CRABBOX_REPAIR="restart crabbox-x11vnc.service"; check vm vnc ss -ltn sport = :5900
CRABBOX_REPAIR="warm a new --desktop lease or install xdotool"; check input xdotool command -v xdotool
CRABBOX_REPAIR="warm a new --desktop lease or install xclip"; if command -v xclip >/dev/null 2>&1 || command -v xsel >/dev/null 2>&1 || command -v wl-copy >/dev/null 2>&1; then echo "input ok clipboard"; else echo "input failed clipboard repair=$CRABBOX_REPAIR"; fi
CRABBOX_REPAIR="warm with --browser or install Chrome/Chromium"; if [ -f /var/lib/crabbox/browser.env ]; then . /var/lib/crabbox/browser.env; fi; if [ -n "${BROWSER:-}" ] && [ -x "$BROWSER" ]; then echo "session ok browser=$BROWSER"; elif command -v google-chrome >/dev/null 2>&1 || command -v chromium >/dev/null 2>&1 || command -v chromium-browser >/dev/null 2>&1; then echo "session ok browser"; else echo "session failed browser repair=$CRABBOX_REPAIR"; fi
CRABBOX_REPAIR="warm a new --desktop lease or install ffmpeg"; check capture ffmpeg command -v ffmpeg
CRABBOX_REPAIR="restart crabbox-xvfb.service"; if command -v xrandr >/dev/null 2>&1; then size="$(xrandr 2>/dev/null | awk '/ connected/{getline; print $1; exit}')"; [ -n "$size" ] && echo "session ok screen=$size" || echo "session failed screen repair=$CRABBOX_REPAIR"; else echo "session failed screen repair=install x11-xserver-utils"; fi
CRABBOX_REPAIR="restart desktop services or install scrot"; if command -v scrot >/dev/null 2>&1; then tmp="$(mktemp --suffix=.png)" && scrot -z -o "$tmp" >/dev/null 2>&1 && test -s "$tmp"; ok=$?; rm -f "$tmp"; [ "$ok" -eq 0 ] && echo "capture ok screenshot" || echo "capture failed screenshot repair=$CRABBOX_REPAIR"; else echo "capture failed screenshot repair=$CRABBOX_REPAIR"; fi`
}

View File

@ -30,9 +30,85 @@ func TestDesktopLaunchRemoteCommandUsesDetachedPOSIXSession(t *testing.T) {
}
}
func TestDesktopTypeUsesPasteForSymbolHeavyText(t *testing.T) {
for _, text := range []string{"peter@example.com", "token+secret", "line one\nline two", "https://example.com"} {
if !desktopShouldPasteForType(text) {
t.Fatalf("expected paste fallback for %q", text)
}
}
if desktopShouldPasteForType("helloWorld123") {
t.Fatal("plain alphanumeric text should use xdotool type")
}
}
func TestDesktopPasteRemoteCommandPrefersClipboardTools(t *testing.T) {
got := desktopPasteRemoteCommand()
for _, want := range []string{
"timeout 5s xclip -selection clipboard -loops 1",
"timeout 5s xsel --clipboard --input",
"wl-copy --paste-once",
"xdotool key --clearmodifiers ctrl+v",
"wait \"$clip_pid\" || true",
} {
if !strings.Contains(got, want) {
t.Fatalf("paste command missing %q:\n%s", want, got)
}
}
}
func TestDesktopKeySequenceArgSkipsLeaseID(t *testing.T) {
tests := []struct {
name string
args []string
want string
}{
{
name: "positional id",
args: []string{"blue-lobster", "ctrl+l"},
want: "ctrl+l",
},
{
name: "single dash id",
args: []string{"-id", "blue-lobster", "ctrl+l"},
want: "ctrl+l",
},
{
name: "double dash id",
args: []string{"--id", "blue-lobster", "ctrl+l"},
want: "ctrl+l",
},
{
name: "equals id",
args: []string{"--id=blue-lobster", "ctrl+l"},
want: "ctrl+l",
},
{
name: "explicit keys",
args: []string{"--id", "blue-lobster", "--keys", "ctrl+l"},
want: "ctrl+l",
},
{
name: "single dash explicit keys",
args: []string{"-id", "blue-lobster", "-keys", "ctrl+l"},
want: "ctrl+l",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := desktopKeySequenceArg(tt.args)
if err != nil {
t.Fatal(err)
}
if got != tt.want {
t.Fatalf("keys=%q, want %q", got, tt.want)
}
})
}
}
func TestDesktopLaunchWebVNCArgsCarriesTargetDetails(t *testing.T) {
got := desktopLaunchWebVNCArgs(
Config{Provider: "aws", TargetOS: targetWindows, WindowsMode: windowsModeWSL2},
Config{Provider: "aws", TargetOS: targetWindows, WindowsMode: windowsModeWSL2, Network: NetworkTailscale},
SSHTarget{TargetOS: targetWindows, WindowsMode: windowsModeWSL2},
"cbx_1",
true,
@ -41,6 +117,7 @@ func TestDesktopLaunchWebVNCArgsCarriesTargetDetails(t *testing.T) {
for _, want := range []string{
"--provider aws",
"--target windows",
"--network tailscale",
"--windows-mode wsl2",
"--id cbx_1",
"--open",

View File

@ -18,17 +18,48 @@ import (
)
func (a App) webvnc(ctx context.Context, args []string) error {
if len(args) > 0 {
switch args[0] {
case "status":
return a.webVNCStatusCommand(ctx, args[1:])
case "reset":
return a.webVNCResetCommand(ctx, args[1:])
case "daemon":
return a.webVNCDaemonCommand(ctx, args[1:])
}
}
defaults := defaultConfig()
fs := newFlagSet("webvnc", a.Stderr)
fs.Usage = func() {
fmt.Fprintln(fs.Output(), "Usage:")
fmt.Fprintln(fs.Output(), " crabbox webvnc --id <lease-id-or-slug> [--open]")
fmt.Fprintln(fs.Output(), " crabbox webvnc status --id <lease-id-or-slug>")
fmt.Fprintln(fs.Output(), " crabbox webvnc reset --id <lease-id-or-slug> [--open]")
fmt.Fprintln(fs.Output(), " crabbox webvnc daemon start|status|stop --id <lease-id-or-slug>")
fmt.Fprintln(fs.Output(), "")
fmt.Fprintln(fs.Output(), "Bridge flags:")
fmt.Fprintln(fs.Output(), " --id <lease-id-or-slug>")
fmt.Fprintln(fs.Output(), " --provider hetzner|aws")
fmt.Fprintln(fs.Output(), " --target linux|macos|windows")
fmt.Fprintln(fs.Output(), " --windows-mode normal|wsl2")
fmt.Fprintln(fs.Output(), " --static-host <host>")
fmt.Fprintln(fs.Output(), " --static-user <user>")
fmt.Fprintln(fs.Output(), " --static-port <port>")
fmt.Fprintln(fs.Output(), " --static-work-root <path>")
fmt.Fprintln(fs.Output(), " --network auto|tailscale|public")
fmt.Fprintln(fs.Output(), " --local-port <port>")
fmt.Fprintln(fs.Output(), " --open")
fmt.Fprintln(fs.Output(), " --reclaim")
}
provider := fs.String("provider", defaults.Provider, "provider: hetzner or aws")
id := fs.String("id", "", "lease id or slug")
reclaim := fs.Bool("reclaim", false, "claim this lease for the current repo")
localPort := fs.String("local-port", "", "local VNC tunnel port")
openPortal := fs.Bool("open", false, "open the web portal VNC page")
daemon := fs.Bool("daemon", false, "start the WebVNC bridge in the background")
background := fs.Bool("background", false, "alias for --daemon")
daemonStatus := fs.Bool("status", false, "show WebVNC background bridge pid/log paths")
stopDaemon := fs.Bool("stop", false, "stop the WebVNC background bridge for this lease")
daemon := fs.Bool("daemon", false, "compatibility alias for daemon start")
background := fs.Bool("background", false, "compatibility alias for daemon start")
daemonStatus := fs.Bool("status", false, "compatibility alias for daemon status")
stopDaemon := fs.Bool("stop", false, "compatibility alias for daemon stop")
networkFlags := registerNetworkModeFlag(fs, defaults)
targetFlags := registerTargetFlags(fs, defaults)
if err := parseFlags(fs, args); err != nil {
@ -45,7 +76,7 @@ func (a App) webvnc(ctx context.Context, args []string) error {
return a.stopWebVNCDaemon(*id)
}
if *daemon || *background {
return a.startWebVNCDaemon(args, *id)
return a.webVNCDaemonStart(ctx, stripLegacyWebVNCDaemonFlags(args))
}
cfg, err := loadLeaseTargetConfig(fs, *provider, targetFlags, networkFlags, leaseTargetConfigOptions{Desktop: true})
if err != nil {
@ -159,11 +190,269 @@ func (a App) webvnc(ctx context.Context, args []string) error {
}
}
func (a App) webVNCDaemonCommand(ctx context.Context, args []string) error {
if len(args) == 0 {
return exit(2, "usage: crabbox webvnc daemon start|status|stop --id <lease-id-or-slug>")
}
if isHelpArg(args[0]) {
fmt.Fprintln(a.Stdout, "Usage: crabbox webvnc daemon start|status|stop --id <lease-id-or-slug>")
return nil
}
switch args[0] {
case "start":
return a.webVNCDaemonStart(ctx, args[1:])
case "status":
return a.webVNCDaemonStatusCommand(args[1:])
case "stop":
return a.webVNCDaemonStopCommand(args[1:])
default:
return exit(2, "usage: crabbox webvnc daemon start|status|stop --id <lease-id-or-slug>")
}
}
func (a App) webVNCDaemonStart(_ context.Context, args []string) error {
defaults := defaultConfig()
fs := newFlagSet("webvnc daemon start", a.Stderr)
provider := fs.String("provider", defaults.Provider, "provider: hetzner or aws")
id := fs.String("id", "", "lease id or slug")
localPort := fs.String("local-port", "", "local VNC tunnel port")
openPortal := fs.Bool("open", false, "open the web portal VNC page")
reclaim := fs.Bool("reclaim", false, "claim this lease for the current repo")
targetFlags := registerTargetFlags(fs, defaults)
networkFlags := registerNetworkModeFlag(fs, defaults)
if err := parseFlags(fs, args); err != nil {
return err
}
setIDFromFirstArg(fs, id)
if *id == "" {
return exit(2, "usage: crabbox webvnc daemon start --id <lease-id-or-slug>")
}
cfg, err := loadLeaseTargetConfig(fs, *provider, targetFlags, networkFlags, leaseTargetConfigOptions{Desktop: true})
if err != nil {
return err
}
target := SSHTarget{TargetOS: cfg.TargetOS, WindowsMode: cfg.WindowsMode}
daemonArgs := webVNCBridgeArgs(cfg, target, *id, *openPortal)
if strings.TrimSpace(*localPort) != "" {
daemonArgs = append(daemonArgs, "--local-port", strings.TrimSpace(*localPort))
}
if *reclaim {
daemonArgs = append(daemonArgs, "--reclaim")
}
return a.startWebVNCDaemon(daemonArgs, *id)
}
func (a App) webVNCDaemonStatusCommand(args []string) error {
fs := newFlagSet("webvnc daemon status", a.Stderr)
id := fs.String("id", "", "lease id or slug")
if err := parseFlags(fs, args); err != nil {
return err
}
setIDFromFirstArg(fs, id)
if *id == "" {
return exit(2, "usage: crabbox webvnc daemon status --id <lease-id-or-slug>")
}
return a.webVNCDaemonStatus(*id)
}
func (a App) webVNCDaemonStopCommand(args []string) error {
fs := newFlagSet("webvnc daemon stop", a.Stderr)
id := fs.String("id", "", "lease id or slug")
if err := parseFlags(fs, args); err != nil {
return err
}
setIDFromFirstArg(fs, id)
if *id == "" {
return exit(2, "usage: crabbox webvnc daemon stop --id <lease-id-or-slug>")
}
return a.stopWebVNCDaemon(*id)
}
func (a App) webVNCStatusCommand(ctx context.Context, args []string) error {
defaults := defaultConfig()
fs := newFlagSet("webvnc status", a.Stderr)
provider := fs.String("provider", defaults.Provider, "provider: hetzner or aws")
id := fs.String("id", "", "lease id or slug")
localPort := fs.String("local-port", "", "local VNC tunnel port")
targetFlags := registerTargetFlags(fs, defaults)
networkFlags := registerNetworkModeFlag(fs, defaults)
if err := parseFlags(fs, args); err != nil {
return err
}
setIDFromFirstArg(fs, id)
if *id == "" {
return exit(2, "usage: crabbox webvnc status --id <lease-id-or-slug>")
}
cfg, err := loadLeaseTargetConfig(fs, *provider, targetFlags, networkFlags, leaseTargetConfigOptions{Desktop: true})
if err != nil {
return err
}
if isBlacksmithProvider(cfg.Provider) || isStaticProvider(cfg.Provider) {
return exit(2, "webvnc status currently supports coordinator-backed hetzner/aws desktop leases")
}
coord, useCoordinator, err := newTargetCoordinatorClient(cfg)
if err != nil {
return err
}
if !useCoordinator || coord == nil || coord.Token == "" {
return exit(2, "webvnc status requires a configured coordinator login; run crabbox login first")
}
server, target, leaseID, err := a.resolveNetworkLeaseTarget(ctx, cfg, *id, false)
if err != nil {
return err
}
if err := enforceManagedLeaseCapabilities(cfg, server, leaseID); err != nil {
return err
}
if *localPort == "" {
*localPort = availableLocalVNCPort()
}
endpoint, endpointErr := resolveVNCEndpoint(ctx, cfg, &target)
password := ""
username := ""
if endpointErr == nil && endpoint.Managed {
password, _ = runSSHOutput(ctx, target, vncPasswordCommand(target))
if target.TargetOS == targetMacOS {
username = target.User
}
}
status, statusErr := coord.WebVNCStatus(ctx, leaseID)
daemon, daemonErr := localWebVNCDaemonStatus(leaseID)
if daemonErr == nil && leaseID != *id {
if aliasDaemon, err := localWebVNCDaemonStatus(*id); err == nil && !aliasDaemon.Missing {
daemon = aliasDaemon
}
}
fmt.Fprintf(a.Stdout, "lease: %s slug=%s provider=%s target=%s\n", leaseID, blank(serverSlug(server), "-"), blank(server.Provider, cfg.Provider), blank(target.TargetOS, cfg.TargetOS))
if daemonErr != nil {
fmt.Fprintf(a.Stdout, "webvnc daemon: error=%v\n", daemonErr)
} else {
printLocalWebVNCDaemonStatus(a.Stdout, daemon)
}
if endpointErr != nil {
fmt.Fprintf(a.Stdout, "vnc target: unreachable 127.0.0.1:5900 (%v)\n", endpointErr)
fmt.Fprintln(a.Stdout, "repair: run crabbox desktop doctor --id "+shellQuote(leaseID))
} else {
fmt.Fprintf(a.Stdout, "vnc target: reachable %s:%s managed=%t\n", endpoint.Host, endpoint.Port, endpoint.Managed)
if endpoint.Direct {
fmt.Fprintln(a.Stdout, "ssh tunnel: not required")
} else {
fmt.Fprintf(a.Stdout, "ssh tunnel: %s\n", vncTunnelCommand(target, *localPort))
}
}
if statusErr != nil {
fmt.Fprintf(a.Stdout, "portal bridge: unknown (%v)\n", statusErr)
} else {
fmt.Fprintf(a.Stdout, "portal bridge: connected=%t viewer=%t\n", status.BridgeConnected, status.ViewerConnected)
if strings.TrimSpace(status.Message) != "" {
fmt.Fprintf(a.Stdout, "portal message: %s\n", status.Message)
}
for _, event := range status.Events {
fmt.Fprintf(a.Stdout, "event: %s %s%s\n", event.At, event.Event, optionalReason(event.Reason))
}
}
for _, line := range recentWebVNCLogEvents(daemon.LogPath, 6) {
fmt.Fprintf(a.Stdout, "log event: %s\n", line)
}
portal := webVNCPortalURL(coord.BaseURL, leaseID, username, password)
fmt.Fprintf(a.Stdout, "webvnc: %s\n", portal)
if strings.TrimSpace(password) != "" {
fmt.Fprintf(a.Stdout, "password: %s\n", strings.TrimSpace(password))
if strings.TrimSpace(username) != "" {
fmt.Fprintf(a.Stdout, "username: %s\n", strings.TrimSpace(username))
}
}
fmt.Fprintf(a.Stdout, "fallback: %s\n", nativeVNCOpenCommand(cfg, target, leaseID))
if statusErr == nil && status.ViewerConnected {
fmt.Fprintln(a.Stdout, "repair: close stale WebVNC tabs or run crabbox webvnc reset --id "+shellQuote(leaseID)+" --open")
} else if statusErr == nil && !status.BridgeConnected {
fmt.Fprintln(a.Stdout, "repair: start crabbox webvnc daemon start --id "+shellQuote(leaseID)+" --open")
}
return nil
}
func (a App) webVNCResetCommand(ctx context.Context, args []string) error {
defaults := defaultConfig()
fs := newFlagSet("webvnc reset", a.Stderr)
provider := fs.String("provider", defaults.Provider, "provider: hetzner or aws")
id := fs.String("id", "", "lease id or slug")
openPortal := fs.Bool("open", false, "open the web portal VNC page")
targetFlags := registerTargetFlags(fs, defaults)
networkFlags := registerNetworkModeFlag(fs, defaults)
if err := parseFlags(fs, args); err != nil {
return err
}
setIDFromFirstArg(fs, id)
if *id == "" {
return exit(2, "usage: crabbox webvnc reset --id <lease-id-or-slug>")
}
cfg, err := loadLeaseTargetConfig(fs, *provider, targetFlags, networkFlags, leaseTargetConfigOptions{Desktop: true})
if err != nil {
return err
}
if isBlacksmithProvider(cfg.Provider) || isStaticProvider(cfg.Provider) {
return exit(2, "webvnc reset currently supports coordinator-backed hetzner/aws desktop leases")
}
coord, useCoordinator, err := newTargetCoordinatorClient(cfg)
if err != nil {
return err
}
if !useCoordinator || coord == nil || coord.Token == "" {
return exit(2, "webvnc reset requires a configured coordinator login; run crabbox login first")
}
server, target, leaseID, err := a.resolveNetworkLeaseTarget(ctx, cfg, *id, false)
if err != nil {
return err
}
if err := enforceManagedLeaseCapabilities(cfg, server, leaseID); err != nil {
return err
}
if _, err := coord.ResetWebVNC(ctx, leaseID); err != nil {
fmt.Fprintf(a.Stdout, "portal reset: skipped (%v)\n", err)
}
if leaseID != *id {
_, _ = a.stopWebVNCDaemonIfRunning(*id)
}
if _, err := a.stopWebVNCDaemonIfRunning(leaseID); err != nil {
return err
}
if err := runSSHQuiet(ctx, target, webVNCResetRemoteCommand(target)); err != nil {
return exit(5, "reset target WebVNC/input stack: %v", err)
}
password := ""
username := ""
if target.TargetOS == targetMacOS {
username = target.User
}
password, _ = runSSHOutput(ctx, target, vncPasswordCommand(target))
portal := webVNCPortalURL(coord.BaseURL, leaseID, username, password)
daemonArgs := webVNCBridgeArgs(cfg, target, leaseID, *openPortal)
daemonName := *id
if strings.TrimSpace(daemonName) == "" {
daemonName = leaseID
}
if err := a.startWebVNCDaemon(daemonArgs, daemonName); err != nil {
return err
}
fmt.Fprintf(a.Stdout, "webvnc reset: lease=%s slug=%s\n", leaseID, blank(serverSlug(server), "-"))
fmt.Fprintf(a.Stdout, "webvnc: %s\n", portal)
if strings.TrimSpace(password) != "" {
fmt.Fprintf(a.Stdout, "password: %s\n", strings.TrimSpace(password))
}
fmt.Fprintf(a.Stdout, "fallback: %s\n", nativeVNCOpenCommand(cfg, target, leaseID))
return nil
}
func (a App) startWebVNCDaemon(args []string, leaseID string) error {
exe, err := os.Executable()
if err != nil {
return exit(2, "resolve crabbox executable: %v", err)
}
if stopped, err := a.stopWebVNCDaemonIfRunning(leaseID); err != nil {
return err
} else if stopped {
fmt.Fprintln(a.Stdout, "webvnc daemon: replacing previous daemon")
}
logPath, pidPath, err := webVNCDaemonPaths(leaseID)
if err != nil {
return err
@ -176,7 +465,7 @@ func (a App) startWebVNCDaemon(args []string, leaseID string) error {
return exit(2, "open WebVNC daemon log: %v", err)
}
defer logFile.Close()
childArgs := append([]string{"webvnc"}, stripWebVNCDaemonFlags(args)...)
childArgs := append([]string{"webvnc"}, args...)
cmd := exec.Command("sh", "-c", webVNCDaemonSupervisorScript(exe, childArgs))
cmd.Stdin = nil
cmd.Stdout = logFile
@ -194,7 +483,7 @@ func (a App) startWebVNCDaemon(args []string, leaseID string) error {
return exit(5, "release WebVNC daemon process: %v", err)
}
fmt.Fprintf(a.Stdout, "webvnc daemon: pid=%d log=%s\n", pid, logPath)
fmt.Fprintln(a.Stdout, "webvnc daemon: stop with crabbox webvnc --id <lease-id-or-slug> --stop")
fmt.Fprintln(a.Stdout, "webvnc daemon: stop with crabbox webvnc daemon stop --id <lease-id-or-slug>")
return nil
}
@ -226,63 +515,108 @@ func webVNCDaemonSupervisorScript(exe string, args []string) string {
}
func (a App) webVNCDaemonStatus(leaseID string) error {
logPath, pidPath, err := webVNCDaemonPaths(leaseID)
status, err := localWebVNCDaemonStatus(leaseID)
if err != nil {
return err
}
printLocalWebVNCDaemonStatus(a.Stdout, status)
return nil
}
type localWebVNCDaemon struct {
LeaseID string
LogPath string
PIDPath string
PID int
Command string
Alive bool
Stale bool
Missing bool
}
func localWebVNCDaemonStatus(leaseID string) (localWebVNCDaemon, error) {
logPath, pidPath, err := webVNCDaemonPaths(leaseID)
if err != nil {
return localWebVNCDaemon{}, err
}
status := localWebVNCDaemon{LeaseID: leaseID, LogPath: logPath, PIDPath: pidPath}
pid, err := readWebVNCDaemonPID(pidPath)
if err != nil {
if os.IsNotExist(err) {
fmt.Fprintf(a.Stdout, "webvnc daemon: no pid file for %s\n", leaseID)
fmt.Fprintf(a.Stdout, "webvnc daemon: expected log=%s\n", logPath)
return nil
status.Missing = true
return status, nil
}
return localWebVNCDaemon{}, err
}
status.PID = pid
command, alive := webVNCDaemonProcessCommand(pid)
status.Command = strings.TrimSpace(command)
status.Alive = alive
if !alive {
status.Stale = true
return status, nil
}
return status, nil
}
func printLocalWebVNCDaemonStatus(w io.Writer, status localWebVNCDaemon) {
if status.Missing {
fmt.Fprintf(w, "webvnc daemon: no pid file for %s\n", status.LeaseID)
fmt.Fprintf(w, "webvnc daemon: expected log=%s\n", status.LogPath)
return
}
if status.Stale {
fmt.Fprintf(w, "webvnc daemon: stale pid=%d log=%s\n", status.PID, status.LogPath)
return
}
fmt.Fprintf(w, "webvnc daemon: pid=%d log=%s\n", status.PID, status.LogPath)
if strings.TrimSpace(status.Command) != "" {
fmt.Fprintf(w, "webvnc daemon: command=%s\n", strings.TrimSpace(status.Command))
}
}
func (a App) stopWebVNCDaemon(leaseID string) error {
stopped, err := a.stopWebVNCDaemonIfRunning(leaseID)
if err != nil {
return err
}
command, alive := webVNCDaemonProcessCommand(pid)
if !alive {
fmt.Fprintf(a.Stdout, "webvnc daemon: stale pid=%d log=%s\n", pid, logPath)
return nil
}
fmt.Fprintf(a.Stdout, "webvnc daemon: pid=%d log=%s\n", pid, logPath)
if strings.TrimSpace(command) != "" {
fmt.Fprintf(a.Stdout, "webvnc daemon: command=%s\n", strings.TrimSpace(command))
if !stopped {
fmt.Fprintf(a.Stdout, "webvnc daemon: no pid file for %s\n", leaseID)
}
return nil
}
func (a App) stopWebVNCDaemon(leaseID string) error {
func (a App) stopWebVNCDaemonIfRunning(leaseID string) (bool, error) {
_, pidPath, err := webVNCDaemonPaths(leaseID)
if err != nil {
return err
return false, err
}
pid, err := readWebVNCDaemonPID(pidPath)
if err != nil {
if os.IsNotExist(err) {
fmt.Fprintf(a.Stdout, "webvnc daemon: no pid file for %s\n", leaseID)
return nil
return false, nil
}
return err
return false, err
}
command, alive := webVNCDaemonProcessCommand(pid)
if !alive {
_ = os.Remove(pidPath)
fmt.Fprintf(a.Stdout, "webvnc daemon: removed stale pid=%d\n", pid)
return nil
return true, nil
}
if !isWebVNCDaemonCommand(command) {
return exit(5, "refusing to stop pid %d; command does not look like crabbox webvnc: %s", pid, strings.TrimSpace(command))
return false, exit(5, "refusing to stop pid %d; command does not look like crabbox webvnc: %s", pid, strings.TrimSpace(command))
}
process, err := os.FindProcess(pid)
if err != nil {
return exit(5, "find WebVNC daemon pid %d: %v", pid, err)
return false, exit(5, "find WebVNC daemon pid %d: %v", pid, err)
}
if err := stopDaemonProcess(process, pid); err != nil {
return exit(5, "stop WebVNC daemon pid %d: %v", pid, err)
return false, exit(5, "stop WebVNC daemon pid %d: %v", pid, err)
}
_ = os.Remove(pidPath)
fmt.Fprintf(a.Stdout, "webvnc daemon: stopped pid=%d\n", pid)
return nil
return true, nil
}
func webVNCDaemonProcessCommand(pid int) (string, bool) {
@ -311,7 +645,39 @@ func readWebVNCDaemonPID(pidPath string) (int, error) {
return pid, nil
}
func stripWebVNCDaemonFlags(args []string) []string {
func stripWebVNCOpenFlags(args []string) []string {
out := make([]string, 0, len(args))
for _, arg := range args {
if arg == "--open" || strings.HasPrefix(arg, "--open=") {
continue
}
out = append(out, arg)
}
return out
}
func optionalReason(reason string) string {
if strings.TrimSpace(reason) == "" {
return ""
}
return " reason=" + strings.TrimSpace(reason)
}
func nativeVNCOpenCommand(cfg Config, target SSHTarget, leaseID string) string {
targetOS := firstNonBlank(target.TargetOS, cfg.TargetOS)
args := []string{"crabbox", "vnc", "--provider", cfg.Provider, "--target", targetOS}
if cfg.Network != "" && cfg.Network != NetworkAuto {
args = append(args, "--network", string(cfg.Network))
}
windowsMode := firstNonBlank(target.WindowsMode, cfg.WindowsMode)
if targetOS == targetWindows && windowsMode != "" {
args = append(args, "--windows-mode", windowsMode)
}
args = append(args, "--id", leaseID, "--open")
return strings.Join(readableShellWords(args), " ")
}
func stripLegacyWebVNCDaemonFlags(args []string) []string {
out := make([]string, 0, len(args))
for _, arg := range args {
if arg == "--daemon" || arg == "--background" ||
@ -323,17 +689,87 @@ func stripWebVNCDaemonFlags(args []string) []string {
return out
}
func stripWebVNCOpenFlags(args []string) []string {
out := make([]string, 0, len(args))
for _, arg := range args {
if arg == "--open" || strings.HasPrefix(arg, "--open=") {
continue
func readableShellWords(words []string) []string {
out := make([]string, 0, len(words))
for _, word := range words {
if shellBareWord(word) {
out = append(out, word)
} else {
out = append(out, shellQuote(word))
}
out = append(out, arg)
}
return out
}
func shellBareWord(value string) bool {
if value == "" {
return false
}
for _, r := range value {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || strings.ContainsRune("_./:@=-", r) {
continue
}
return false
}
return true
}
func webVNCBridgeArgs(cfg Config, target SSHTarget, leaseID string, openPortal bool) []string {
targetOS := firstNonBlank(target.TargetOS, cfg.TargetOS)
args := []string{"--provider", cfg.Provider, "--target", targetOS}
if cfg.Network != "" && cfg.Network != NetworkAuto {
args = append(args, "--network", string(cfg.Network))
}
windowsMode := firstNonBlank(target.WindowsMode, cfg.WindowsMode)
if targetOS == targetWindows && windowsMode != "" {
args = append(args, "--windows-mode", windowsMode)
}
args = append(args, "--id", leaseID)
if openPortal {
args = append(args, "--open")
}
return args
}
func recentWebVNCLogEvents(path string, limit int) []string {
if strings.TrimSpace(path) == "" || limit <= 0 {
return nil
}
data, err := os.ReadFile(path)
if err != nil {
return nil
}
lines := strings.Split(strings.TrimSpace(string(data)), "\n")
out := make([]string, 0, limit)
for i := len(lines) - 1; i >= 0 && len(out) < limit; i-- {
line := strings.TrimSpace(lines[i])
if line == "" {
continue
}
if strings.Contains(line, "viewer") || strings.Contains(line, "reconnect") || strings.Contains(line, "connected") || strings.Contains(line, "reset") {
out = append(out, line)
}
}
for i, j := 0, len(out)-1; i < j; i, j = i+1, j-1 {
out[i], out[j] = out[j], out[i]
}
return out
}
func webVNCResetRemoteCommand(target SSHTarget) string {
if isWindowsNativeTarget(target) {
return `$ErrorActionPreference = "SilentlyContinue"
Restart-Service -Name tvnserver -ErrorAction SilentlyContinue
Start-Sleep -Seconds 1`
}
if target.TargetOS == targetMacOS {
return `set -eu
sudo launchctl kickstart -k system/com.apple.screensharing >/dev/null 2>&1 || true`
}
return `set -eu
sudo systemctl restart crabbox-desktop-session.service crabbox-x11vnc.service`
}
func webVNCDaemonPaths(leaseID string) (string, string, error) {
dir, err := crabboxStateDir()
if err != nil {

View File

@ -1,6 +1,7 @@
package cli
import (
"bytes"
"context"
"encoding/json"
"errors"
@ -179,8 +180,81 @@ func TestRetryBridgeTicketInQuery(t *testing.T) {
}
}
func TestWebVNCDaemonArgsStripBackgroundFlags(t *testing.T) {
got := strings.Join(stripWebVNCDaemonFlags([]string{
func TestWebVNCDaemonStatusSubcommandStaysLocalDaemonCheck(t *testing.T) {
t.Setenv("XDG_STATE_HOME", t.TempDir())
var stdout bytes.Buffer
app := App{Stdout: &stdout, Stderr: io.Discard}
if err := app.webvnc(context.Background(), []string{"daemon", "status", "--id", "pearl-krill"}); err != nil {
t.Fatal(err)
}
got := stdout.String()
if !strings.Contains(got, "webvnc daemon: no pid file for pearl-krill") {
t.Fatalf("status output=%q", got)
}
if strings.Contains(got, "requires a configured coordinator") {
t.Fatalf("daemon status must not require coordinator: %q", got)
}
}
func TestWebVNCLegacyStatusAndStopFlagsStayLocalDaemonChecks(t *testing.T) {
for _, args := range [][]string{
{"--id", "pearl-krill", "--status"},
{"--id", "pearl-krill", "--stop"},
} {
t.Run(strings.Join(args, " "), func(t *testing.T) {
t.Setenv("XDG_STATE_HOME", t.TempDir())
var stdout bytes.Buffer
app := App{Stdout: &stdout, Stderr: io.Discard}
if err := app.webvnc(context.Background(), args); err != nil {
t.Fatal(err)
}
got := stdout.String()
if !strings.Contains(got, "webvnc daemon: no pid file for pearl-krill") {
t.Fatalf("legacy daemon output=%q", got)
}
if strings.Contains(got, "requires a configured coordinator") {
t.Fatalf("legacy daemon flag must not require coordinator: %q", got)
}
})
}
}
func TestNativeVNCFallbackCommand(t *testing.T) {
got := nativeVNCOpenCommand(
Config{Provider: "aws", TargetOS: targetWindows, WindowsMode: windowsModeWSL2},
SSHTarget{TargetOS: targetWindows, WindowsMode: windowsModeWSL2},
"cbx_1",
)
if got != "crabbox vnc --provider aws --target windows --windows-mode wsl2 --id cbx_1 --open" {
t.Fatalf("fallback=%q", got)
}
}
func TestNativeVNCFallbackCommandCarriesNetworkOverride(t *testing.T) {
got := nativeVNCOpenCommand(
Config{Provider: "aws", TargetOS: targetLinux, Network: NetworkTailscale},
SSHTarget{TargetOS: targetLinux},
"cbx_1",
)
if got != "crabbox vnc --provider aws --target linux --network tailscale --id cbx_1 --open" {
t.Fatalf("fallback=%q", got)
}
}
func TestWebVNCBridgeArgsCarriesNetworkOverride(t *testing.T) {
got := strings.Join(webVNCBridgeArgs(
Config{Provider: "aws", TargetOS: targetLinux, Network: NetworkTailscale},
SSHTarget{TargetOS: targetLinux},
"cbx_1",
true,
), " ")
if got != "--provider aws --target linux --network tailscale --id cbx_1 --open" {
t.Fatalf("bridge args=%q", got)
}
}
func TestStripLegacyWebVNCDaemonFlags(t *testing.T) {
got := strings.Join(stripLegacyWebVNCDaemonFlags([]string{
"--provider",
"aws",
"--daemon",

View File

@ -416,7 +416,7 @@ function optionalBootstrap(config: LeaseConfig): string {
parts.push(tailscaleBootstrap(config));
}
if (config.desktop) {
parts.push(` retry apt-get install -y --no-install-recommends xvfb xfce4-session xfwm4 xfce4-panel xfdesktop4 xfce4-terminal xfconf xfce4-settings x11vnc xauth dbus-x11 x11-xserver-utils xterm scrot ffmpeg xdotool wmctrl fonts-dejavu-core fonts-liberation iproute2 openssl
parts.push(` retry apt-get install -y --no-install-recommends xvfb xfce4-session xfwm4 xfce4-panel xfdesktop4 xfce4-terminal xfconf xfce4-settings x11vnc xauth dbus-x11 x11-xserver-utils xterm scrot ffmpeg xdotool wmctrl xclip xsel fonts-dejavu-core fonts-liberation iproute2 openssl
install -d -m 0750 -o crabbox -g crabbox /var/lib/crabbox
if [ ! -s /var/lib/crabbox/vnc.password ]; then
(umask 077 && openssl rand -base64 18 > /var/lib/crabbox/vnc.password)
@ -454,7 +454,7 @@ function optionalBootstrap(config: LeaseConfig): string {
install -d -m 0755 /etc/opt/chrome/policies/managed /etc/chromium/policies/managed
printf '%s\\n' '{"DefaultBrowserSettingEnabled":false,"MetricsReportingEnabled":false,"PromotionalTabsEnabled":false}' > /etc/opt/chrome/policies/managed/crabbox.json
cp /etc/opt/chrome/policies/managed/crabbox.json /etc/chromium/policies/managed/crabbox.json
printf '%s\\n' '#!/bin/sh' "exec \\"$browser_path\\" --no-first-run --no-default-browser-check --disable-default-apps \\"\\$@\\"" > "$browser_wrapper"
printf '%s\\n' '#!/bin/sh' "exec \\"$browser_path\\" --no-first-run --no-default-browser-check --disable-default-apps --window-size=1500,900 --window-position=80,80 \\"\\$@\\"" > "$browser_wrapper"
chmod 0755 "$browser_wrapper"
printf 'CHROME_BIN=%s\\nBROWSER=%s\\n' "$browser_wrapper" "$browser_wrapper" > /var/lib/crabbox/browser.env
chown crabbox:crabbox /var/lib/crabbox/browser.env

View File

@ -191,10 +191,17 @@ type BridgeAttachment =
| { kind: "egress-host"; leaseID: string; sessionID: string }
| { kind: "egress-client"; leaseID: string; sessionID: string };
interface WebVNCEvent {
at: string;
event: string;
reason?: string;
}
export class FleetDurableObject implements DurableObject {
private readonly webVNCAgents = new Map<string, WebSocket>();
private readonly webVNCViewers = new Map<string, WebSocket>();
private readonly pendingWebVNCToViewer = new Map<string, WebVNCBuffer>();
private readonly webVNCEvents = new Map<string, WebVNCEvent[]>();
private readonly codeAgents = new Map<string, WebSocket>();
private readonly codeViewers = new Map<string, WebSocket>();
private readonly pendingCodeRequests = new Map<string, CodePendingRequest>();
@ -321,6 +328,24 @@ export class FleetDurableObject implements DurableObject {
) {
return await this.createWebVNCTicket(request, parts[2]);
}
if (
parts[0] === "v1" &&
parts[1] === "leases" &&
parts[2] &&
parts[3] === "webvnc" &&
parts[4] === "status"
) {
return await this.webVNCStatus(request, parts[2]);
}
if (
parts[0] === "v1" &&
parts[1] === "leases" &&
parts[2] &&
parts[3] === "webvnc" &&
parts[4] === "reset"
) {
return await this.webVNCReset(request, parts[2]);
}
if (
parts[0] === "v1" &&
parts[1] === "leases" &&
@ -1029,9 +1054,13 @@ export class FleetDurableObject implements DurableObject {
const client = pair[0];
const agent = pair[1];
if (this.webVNCAgents.get(lease.id)?.readyState === WebSocket.OPEN) {
this.recordWebVNCEvent(lease.id, "bridge_replaced", "replaced by a newer WebVNC bridge");
}
closeSocket(this.webVNCAgents.get(lease.id), 1012, "replaced by a newer WebVNC bridge");
this.pendingWebVNCToViewer.delete(lease.id);
this.webVNCAgents.set(lease.id, agent);
this.recordWebVNCEvent(lease.id, "bridge_connected");
this.acceptBridgeWebSocket(agent, { kind: "webvnc-agent", leaseID: lease.id });
return new Response(null, { status: 101, webSocket: client });
}
@ -1199,7 +1228,9 @@ export class FleetDurableObject implements DurableObject {
}
private async webVNCStatus(request: Request, identifier: string): Promise<Response> {
const lease = await this.resolvePortalLease(identifier, request);
const lease = request.url.includes("/portal/")
? await this.resolvePortalLease(identifier, request)
: await this.resolveLease(identifier, request, false);
if (!lease) {
return notFound();
}
@ -1213,9 +1244,12 @@ export class FleetDurableObject implements DurableObject {
const viewerConnected = viewer?.readyState === WebSocket.OPEN;
const command = webVNCBridgeCommand(lease);
return json({
leaseID: lease.id,
slug: lease.slug ?? "",
bridgeConnected,
viewerConnected,
command,
events: this.recentWebVNCEvents(lease.id),
message: bridgeConnected
? viewerConnected
? "bridge connected; another viewer is active"
@ -1224,6 +1258,40 @@ export class FleetDurableObject implements DurableObject {
});
}
private async webVNCReset(request: Request, identifier: string): Promise<Response> {
if (request.method.toUpperCase() !== "POST") {
return json({ error: "not_found" }, { status: 404 });
}
const lease = await this.resolveLease(identifier, request, false);
if (!lease) {
return notFound();
}
const error = webVNCLeaseError(lease);
if (error) {
return json({ error: "webvnc_unavailable", message: error }, { status: 409 });
}
const bridgeWasConnected = this.webVNCAgents.get(lease.id)?.readyState === WebSocket.OPEN;
const viewerWasConnected = this.webVNCViewers.get(lease.id)?.readyState === WebSocket.OPEN;
closeSocket(this.webVNCViewers.get(lease.id), 1012, "WebVNC reset requested");
this.webVNCViewers.delete(lease.id);
resetWebVNCBridge(
this.webVNCAgents,
this.pendingWebVNCToViewer,
lease.id,
1012,
"WebVNC reset requested",
);
this.recordWebVNCEvent(lease.id, "reset", "WebVNC reset requested");
return json({
leaseID: lease.id,
slug: lease.slug ?? "",
bridgeWasConnected,
viewerWasConnected,
command: webVNCBridgeCommand(lease),
events: this.recentWebVNCEvents(lease.id),
});
}
private async createCodeTicket(request: Request, identifier: string): Promise<Response> {
if (request.method.toUpperCase() !== "POST") {
return json({ error: "not_found" }, { status: 404 });
@ -1665,6 +1733,7 @@ export class FleetDurableObject implements DurableObject {
const viewer = pair[1];
this.webVNCViewers.set(lease.id, viewer);
this.recordWebVNCEvent(lease.id, "viewer_connected");
this.acceptBridgeWebSocket(viewer, { kind: "webvnc-viewer", leaseID: lease.id });
flushPendingWebVNC(this.pendingWebVNCToViewer, lease.id, viewer);
return new Response(null, { status: 101, webSocket: client });
@ -1678,6 +1747,7 @@ export class FleetDurableObject implements DurableObject {
this.pendingWebVNCToViewer.delete(leaseID);
closeSocket(this.webVNCViewers.get(leaseID), 1011, "WebVNC bridge disconnected");
this.webVNCViewers.delete(leaseID);
this.recordWebVNCEvent(leaseID, "bridge_disconnected");
}
private clearWebVNCViewer(leaseID: string, socket: WebSocket): void {
@ -1685,6 +1755,7 @@ export class FleetDurableObject implements DurableObject {
return;
}
this.webVNCViewers.delete(leaseID);
this.recordWebVNCEvent(leaseID, "viewer_disconnected");
resetWebVNCBridge(
this.webVNCAgents,
this.pendingWebVNCToViewer,
@ -1692,6 +1763,21 @@ export class FleetDurableObject implements DurableObject {
1011,
"WebVNC viewer disconnected",
);
this.recordWebVNCEvent(leaseID, "bridge_reset", "WebVNC viewer disconnected");
}
private recordWebVNCEvent(leaseID: string, event: string, reason?: string): void {
const events = this.webVNCEvents.get(leaseID) ?? [];
const record: WebVNCEvent = { at: new Date().toISOString(), event };
if (reason) {
record.reason = reason;
}
events.push(record);
this.webVNCEvents.set(leaseID, events.slice(-12));
}
private recentWebVNCEvents(leaseID: string): WebVNCEvent[] {
return this.webVNCEvents.get(leaseID) ?? [];
}
private async consumeWebVNCTicket(request: Request): Promise<WebVNCTicketRecord | undefined> {

View File

@ -436,6 +436,7 @@ export function portalVNC(lease: LeaseRecord): Response {
const bridgeCmd = webVNCBridgeCommand(lease);
const fullscreenIcon = `<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M4 9V4h5"/><path d="M20 9V4h-5"/><path d="M4 15v5h5"/><path d="M20 15v5h-5"/></svg>`;
const reconnectIcon = `<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 12a9 9 0 1 1-3-6.7"/><path d="M21 4v5h-5"/></svg>`;
const pasteIcon = `<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 2h6a2 2 0 0 1 2 2v1H7V4a2 2 0 0 1 2-2Z"/><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/><path d="M12 11v6"/><path d="m9 14 3 3 3-3"/></svg>`;
return html(
title,
`<main class="vnc-page">
@ -444,6 +445,8 @@ export function portalVNC(lease: LeaseRecord): Response {
meta: `<span>WebVNC ${escapeHTML(slug)}</span><span class="vnc-dot"></span>${providerBadge(lease.provider)}<span class="vnc-dot"></span>${targetBadge(target, lease.windowsMode)}<span class="vnc-dot"></span><span class="vnc-id">${escapeHTML(lease.id)}</span>`,
actions: `
<span id="status" class="status-pill">waiting for bridge</span>
<button id="vnc-copy-remote" class="icon-btn" type="button" title="copy remote clipboard" aria-label="copy remote clipboard" disabled>${copyIcon}</button>
<button id="vnc-paste" class="icon-btn" type="button" title="paste clipboard" aria-label="paste clipboard">${pasteIcon}</button>
<button id="vnc-reconnect" class="icon-btn" type="button" title="reconnect" aria-label="reconnect">${reconnectIcon}</button>
<button id="vnc-fullscreen" class="icon-btn" type="button" title="fullscreen" aria-label="toggle fullscreen">${fullscreenIcon}</button>
<a class="button secondary" href="/portal">leases</a>
@ -466,6 +469,7 @@ export function portalVNC(lease: LeaseRecord): Response {
wsURL.protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const statusURL = new URL(${JSON.stringify(statusPath)}, window.location.href);
const fragment = new URLSearchParams(window.location.hash.slice(1));
const target = ${JSON.stringify(target)};
const username = fragment.get("username") || "";
const password = fragment.get("password") || "";
const credentials = {};
@ -481,9 +485,33 @@ export function portalVNC(lease: LeaseRecord): Response {
let retryAttempt = 0;
let connected = false;
let stopped = false;
let remoteClipboardText = "";
function retryDelay() {
return Math.min(5000, 500 * 2 ** retryAttempt);
}
function fallbackCopyText(text) {
const ta = document.createElement("textarea");
ta.value = text;
ta.setAttribute("readonly", "");
ta.style.position = "fixed";
ta.style.left = "-9999px";
document.body.appendChild(ta);
ta.select();
try {
document.execCommand("copy");
} finally {
ta.remove();
}
}
async function writeClipboardText(text) {
if (navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(text);
return;
} catch (_) {}
}
fallbackCopyText(text);
}
async function bridgeState() {
try {
const response = await fetch(statusURL, { cache: "no-store" });
@ -524,6 +552,15 @@ export function portalVNC(lease: LeaseRecord): Response {
retryAttempt = 0;
setStatus("connected", "ok");
});
rfb.addEventListener("clipboard", (event) => {
remoteClipboardText = event.detail?.text || "";
if (copyRemoteBtn) {
copyRemoteBtn.disabled = !remoteClipboardText;
}
if (remoteClipboardText) {
setStatus("remote clipboard ready", "ok");
}
});
rfb.addEventListener("disconnect", () => {
scheduleRetry(connected ? "bridge disconnected" : "waiting for bridge");
});
@ -568,13 +605,70 @@ export function portalVNC(lease: LeaseRecord): Response {
document.documentElement.requestFullscreen?.().catch(() => {});
}
});
async function readClipboardText() {
if (navigator.clipboard?.readText) {
try {
return await navigator.clipboard.readText();
} catch (_) {}
}
return window.prompt("Text to paste") || "";
}
function pasteModifier() {
return target === "macos"
? { keysym: 0xffeb, code: "MetaLeft" }
: { keysym: 0xffe3, code: "ControlLeft" };
}
function sendPasteShortcut() {
if (!rfb?.sendKey) return;
const modifier = pasteModifier();
rfb.sendKey(modifier.keysym, modifier.code, true);
rfb.sendKey(0x0076, "KeyV");
rfb.sendKey(modifier.keysym, modifier.code, false);
}
const pasteBtn = document.getElementById("vnc-paste");
let pasteResetTimer;
pasteBtn?.addEventListener("click", async () => {
if (!rfb || !connected) {
setStatus("connect before paste", "warn");
return;
}
const text = await readClipboardText();
if (!text) return;
try {
rfb.clipboardPasteFrom(text);
window.setTimeout(sendPasteShortcut, 80);
pasteBtn.dataset.state = "ok";
setStatus("pasted clipboard", "ok");
} catch (error) {
setStatus(error instanceof Error ? error.message : String(error), "bad");
}
window.clearTimeout(pasteResetTimer);
pasteResetTimer = window.setTimeout(() => { delete pasteBtn.dataset.state; }, 1200);
});
const copyRemoteBtn = document.getElementById("vnc-copy-remote");
let copyRemoteResetTimer;
copyRemoteBtn?.addEventListener("click", async () => {
if (!remoteClipboardText) {
setStatus("no remote clipboard yet", "warn");
return;
}
try {
await writeClipboardText(remoteClipboardText);
copyRemoteBtn.dataset.state = "ok";
setStatus("copied remote clipboard", "ok");
} catch (error) {
setStatus(error instanceof Error ? error.message : String(error), "bad");
}
window.clearTimeout(copyRemoteResetTimer);
copyRemoteResetTimer = window.setTimeout(() => { delete copyRemoteBtn.dataset.state; }, 1200);
});
const copyBtn = document.getElementById("vnc-copy");
const cmdEl = document.getElementById("vnc-bridge-cmd");
let copyResetTimer;
copyBtn?.addEventListener("click", async () => {
const text = cmdEl?.textContent || "";
try {
await navigator.clipboard.writeText(text);
await writeClipboardText(text);
} catch (_) {
const range = document.createRange();
if (cmdEl) {

View File

@ -119,7 +119,9 @@ describe("cloud-init bootstrap", () => {
expect(got).toContain("apt-cache show chromium-browser");
expect(got).toContain("/etc/opt/chrome/policies/managed/crabbox.json");
expect(got).toContain("/usr/local/bin/crabbox-browser");
expect(got).toContain("--no-first-run --no-default-browser-check --disable-default-apps");
expect(got).toContain(
"--no-first-run --no-default-browser-check --disable-default-apps --window-size=1500,900 --window-position=80,80",
);
expect(got).toContain("/var/lib/crabbox/browser.env");
expect(got).toContain('test -x "$BROWSER"');
expect(got).toContain('"$BROWSER" --version >/dev/null');
@ -127,7 +129,7 @@ describe("cloud-init bootstrap", () => {
`printf '%s\\n' '{"DefaultBrowserSettingEnabled":false,"MetricsReportingEnabled":false,"PromotionalTabsEnabled":false}' > /etc/opt/chrome/policies/managed/crabbox.json`,
);
expect(got).toContain(
`printf '%s\\n' '#!/bin/sh' "exec \\"$browser_path\\" --no-first-run --no-default-browser-check --disable-default-apps \\"\\$@\\"" > "$browser_wrapper"`,
`printf '%s\\n' '#!/bin/sh' "exec \\"$browser_path\\" --no-first-run --no-default-browser-check --disable-default-apps --window-size=1500,900 --window-position=80,80 \\"\\$@\\"" > "$browser_wrapper"`,
);
expect(got).not.toContain("<<'EOF'");
expect(got).not.toContain("<<EOF");

View File

@ -1565,7 +1565,15 @@ describe("fleet lease identity and idle", () => {
expect(pageBody).toContain("WebVNC blue-lobster");
expect(pageBody).toContain("function scheduleRetry");
expect(pageBody).toContain("/portal/leases/cbx_000000000001/vnc/status");
expect(pageBody).toContain("vnc-copy-remote");
expect(pageBody).toContain("vnc-paste");
expect(pageBody).toContain("vnc-copy");
expect(pageBody).toContain('addEventListener("clipboard"');
expect(pageBody).toContain("remote clipboard ready");
expect(pageBody).toContain("clipboardPasteFrom");
expect(pageBody).toContain('target === "macos"');
expect(pageBody).toContain("MetaLeft");
expect(pageBody).toContain("ControlLeft");
expect(pageBody).toContain("position:sticky");
expect(pageBody).toContain('data-provider="hetzner"');
expect(pageBody).toContain('data-target="linux"');
@ -1580,13 +1588,39 @@ describe("fleet lease identity and idle", () => {
);
expect(status.status).toBe(200);
await expect(status.json()).resolves.toMatchObject({
leaseID: "cbx_000000000001",
slug: "blue-lobster",
bridgeConnected: false,
viewerConnected: false,
command: "crabbox webvnc --provider hetzner --target linux --id blue-lobster --open",
events: [],
message:
"no bridge connected; run: crabbox webvnc --provider hetzner --target linux --id blue-lobster --open",
});
const apiStatus = await fleet.fetch(
request("GET", "/v1/leases/blue-lobster/webvnc/status", { headers }),
);
expect(apiStatus.status).toBe(200);
await expect(apiStatus.json()).resolves.toMatchObject({
leaseID: "cbx_000000000001",
bridgeConnected: false,
viewerConnected: false,
events: [],
});
const reset = await fleet.fetch(
request("POST", "/v1/leases/blue-lobster/webvnc/reset", { headers, body: {} }),
);
expect(reset.status).toBe(200);
await expect(reset.json()).resolves.toMatchObject({
leaseID: "cbx_000000000001",
bridgeWasConnected: false,
viewerWasConnected: false,
command: "crabbox webvnc --provider hetzner --target linux --id blue-lobster --open",
events: [{ event: "reset", reason: "WebVNC reset requested" }],
});
const plain = await fleet.fetch(
request("GET", "/portal/leases/plain-lobster/vnc", { headers }),
);