Compare commits
1 Commits
main
...
fix/bridge
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1fc07c4efa |
@ -68,19 +68,6 @@ 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 artifacts collect --id <id-or-slug> --all --output artifacts/<slug>
|
||||
crabbox artifacts publish --dir artifacts/<slug> --pr <number>
|
||||
crabbox sync-plan
|
||||
crabbox history --lease <id-or-slug>
|
||||
crabbox events <run_id> --json
|
||||
@ -93,27 +80,6 @@ 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.
|
||||
|
||||
When desktop/WebVNC hangs, trust the inline rescue output first: `problem: VNC
|
||||
bridge disconnected`, `problem: browser not launched`, `problem: input stack
|
||||
dead`, or similar will be followed by exact `rescue:` commands such as
|
||||
`crabbox webvnc status/reset` or `crabbox desktop doctor`.
|
||||
|
||||
For UI QA proof, use `crabbox artifacts collect` instead of ad hoc screenshots
|
||||
and shell recordings. It can bundle screenshots, MP4 recordings, trimmed GIFs,
|
||||
desktop doctor output, WebVNC status, run logs, and metadata, then
|
||||
`crabbox artifacts publish --pr <n>` can publish inline-ready Markdown through
|
||||
the configured coordinator artifact backend. Use explicit `--storage s3`,
|
||||
`--storage r2`, or `--storage local` only as a local fallback.
|
||||
|
||||
## Run Inspection Workflow
|
||||
|
||||
Use the CLI for durable run inspection; do not expect extra OpenClaw plugin
|
||||
|
||||
29
CHANGELOG.md
29
CHANGELOG.md
@ -1,41 +1,14 @@
|
||||
# Changelog
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Added
|
||||
|
||||
- Added `provider: azure` for managed Azure Linux and native Windows SSH leases, including direct and brokered provisioning, shared Azure networking, SKU fallback, Azure docs, and cleanup support. Thanks @jwmoss.
|
||||
|
||||
## 0.7.0 - 2026-05-07
|
||||
|
||||
### Added
|
||||
|
||||
- Added mediated egress commands and browser wiring so Linux desktop leases can proxy selected app traffic through the operator machine via the coordinator bridge.
|
||||
- Added WebVNC portal clipboard controls for sending local clipboard text into the remote session and copying remote clipboard text back to the local browser.
|
||||
- Added rescue-first desktop/WebVNC failure output that names the failing layer and prints exact `rescue:` or native VNC fallback commands when bridges, viewers, browser launches, VNC targets, or input stacks hang.
|
||||
- Added lease sharing for individual users or the owning org, including `crabbox share`, `crabbox unshare`, API access checks, and a portal share control on lease detail pages.
|
||||
- Added collaborative WebVNC observer mode, with one active controller, read-only observers, and a portal takeover button that shows who is controlling the session.
|
||||
- Added first-class `crabbox artifacts` commands for desktop screenshots, MP4 recordings, trimmed GIFs, logs, metadata, Mantis/OpenClaw QA templates, and PR-ready publishing through broker-owned artifact storage, AWS S3, or Cloudflare R2.
|
||||
|
||||
### Changed
|
||||
|
||||
- Changed WebVNC portal sharing to open as an in-session modal, added a standalone share-page back action, and simplified collaboration controls into a single stateful control button.
|
||||
## 0.6.2 - Unreleased
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed `egress start --coordinator` so live public-route egress starts work when the local default coordinator is Cloudflare Access-protected.
|
||||
- Fixed macOS WebVNC cursor visibility by enabling noVNC's dot-cursor fallback when Screen Sharing sends a transparent or zero-sized cursor.
|
||||
- Fixed Tailscale exit-node bootstrap paths to prefer tailnet metadata and fail clearly when remote exit-node egress is not active.
|
||||
- Fixed `run --no-sync` timing summaries so they report `sync_skipped=true`.
|
||||
- Fixed native Windows command output so first-use PowerShell progress records do not leak CLIXML into run logs.
|
||||
- Fixed Islo provider sync so `crabbox run --provider islo` uploads the local workspace, uses the correct `/workspace/<workdir>`, and falls back to chunked exec upload while the archive API returns server errors.
|
||||
- Fixed Code and WebVNC bridge websocket auth so upgraded brokers receive short-lived bridge tickets in the `Authorization` header instead of logging them in URL query strings, while preserving query fallback for older brokers.
|
||||
- Fixed managed AWS macOS desktop leases so readiness and WebVNC use a writable `ec2-user` work root, call `crabbox-ready` by absolute path, and read the generated Screen Sharing password via sudo.
|
||||
- Fixed managed AWS macOS bootstrap so VNC password generation does not abort under `pipefail` before Screen Sharing readiness is installed.
|
||||
- Fixed managed Linux bootstrap so SSH service activation cannot hang cloud-init before desktop/browser setup and readiness checks run.
|
||||
- Fixed WebVNC daemon start-by-slug so coordinator-backed leases use the resolved target OS in the background bridge command.
|
||||
- Fixed coordinator-backed `crabbox list` so a stale admin token no longer blocks normal logged-in users; the CLI now falls back to active user-visible leases instead of failing with `401 unauthorized`.
|
||||
- Fixed desktop, screenshot, VNC, and WebVNC SSH helpers so they retry live fallback ports when a coordinator lease advertises an SSH port that is not ready yet.
|
||||
|
||||
## 0.6.0 - 2026-05-07
|
||||
|
||||
|
||||
25
README.md
25
README.md
@ -12,7 +12,7 @@ Crabbox is an open-source remote testbox runner for maintainers and AI agents. L
|
||||
crabbox run -- pnpm test
|
||||
```
|
||||
|
||||
Behind that single command: a Go CLI on your laptop, a Cloudflare Worker broker that owns provider credentials and lease state, and a managed runner on Hetzner Cloud, AWS EC2, or Azure. Azure supports managed Linux and native Windows VMs. Crabbox can also wrap Blacksmith Testboxes when you choose `provider: blacksmith-testbox`, use Daytona or Islo sandboxes for direct-provider workflows, or use `provider: ssh` for existing macOS and Windows targets.
|
||||
Behind that single command: a Go CLI on your laptop, a Cloudflare Worker broker that owns provider credentials and lease state, and a managed runner on Hetzner Cloud or AWS EC2. Crabbox can also wrap Blacksmith Testboxes when you choose `provider: blacksmith-testbox`, use Daytona or Islo sandboxes for direct-provider workflows, or use `provider: ssh` for existing macOS and Windows targets.
|
||||
|
||||
---
|
||||
|
||||
@ -53,7 +53,7 @@ Every lease has a stable `cbx_...` ID and a friendly crustacean slug (`blue-lobs
|
||||
```text
|
||||
your laptop Cloudflare Worker cloud provider
|
||||
------------- ------------------ --------------
|
||||
crabbox CLI -- HTTPS --> Fleet Durable Object --> Hetzner / AWS EC2 / Azure
|
||||
crabbox CLI -- HTTPS --> Fleet Durable Object --> Hetzner / AWS EC2
|
||||
| lease + cost state |
|
||||
| |
|
||||
+------------ SSH + rsync to leased runner <--------------+
|
||||
@ -61,9 +61,9 @@ crabbox CLI -- HTTPS --> Fleet Durable Object --> Hetzner / AWS EC2 / Azur
|
||||
|
||||
- **CLI** — Go binary. Loads config, mints a per-lease SSH key, asks the broker for a lease, waits for SSH, seeds remote Git, rsyncs the dirty checkout (with fingerprint skip when nothing changed), runs the command, streams output, releases.
|
||||
- **Broker** — Cloudflare Worker at `crabbox.openclaw.ai` plus a single Durable Object. Owns provider credentials, serializes lease state, enforces active-lease and monthly spend caps, and expires stale leases by alarm. Auth is GitHub login or a shared bearer token.
|
||||
- **Runner** — a throwaway SSH machine prepared with SSH on the primary port, default `2222`, plus configured fallback ports and Crabbox's sync/run prerequisites. Linux uses Ubuntu with cloud-init and `/work/crabbox`; native Windows uses OpenSSH, Git for Windows, and `C:\crabbox`. No broker credentials live on the box. Project runtimes (Go, Node, Docker, services, secrets) come from your repo's GitHub Actions hydration, devcontainer, Nix, mise/asdf, or setup scripts — not from Crabbox.
|
||||
- **Runner** — vanilla Ubuntu prepared by cloud-init with SSH on the primary port, default `2222`, plus configured fallback ports, Git, rsync, curl, jq, and `/work/crabbox`. No broker credentials live on the box. Project runtimes (Go, Node, Docker, services, secrets) come from your repo's GitHub Actions hydration, devcontainer, Nix, mise/asdf, or setup scripts — not from Crabbox.
|
||||
|
||||
A direct-provider mode (`--provider hetzner|aws|azure` with local credentials) exists for debugging the broker itself; the brokered path is the default.
|
||||
A direct-provider mode (`--provider hetzner|aws` with local credentials) exists for debugging the broker itself; the brokered path is the default.
|
||||
|
||||
For the full mental model, see [How Crabbox Works](docs/how-it-works.md). For the doc-to-code map, see [Source Map](docs/source-map.md).
|
||||
|
||||
@ -73,16 +73,15 @@ For the full mental model, see [How Crabbox Works](docs/how-it-works.md). For th
|
||||
- **Run observability.** Every coordinator-backed run gets an early `run_...` handle. Use `crabbox attach <run-id>` while it is active, `crabbox events <run-id> --after <seq> --limit <n>` for durable lifecycle/output events, and `crabbox logs <run-id>` for retained output after completion.
|
||||
- **Stable timing records.** `--timing-json` on `run`, `warmup`, and `actions hydrate` gives scripts one machine-readable sync/command/total timing schema across AWS, Hetzner, and Blacksmith Testboxes.
|
||||
- **Local-first sync.** No clean-checkout requirement. Tracked + nonignored files only, fingerprint skip on no-op runs, sanity checks against suspicious mass deletions, optional shallow base-ref hydration for changed-test workflows.
|
||||
- **Brokered cloud.** Maintainers and agents share infra without sharing provider tokens. Hetzner, AWS EC2, and Azure are managed providers; AWS also owns Windows WSL2 and EC2 Mac targets. Linux defaults to Spot unless capacity config says otherwise. Providers fall back across compatible instance families when capacity or quota rejects a request.
|
||||
- **Azure Linux and native Windows.** `provider: azure` provisions Linux and native Windows VMs in a configurable Azure subscription using `DefaultAzureCredential` in direct mode or service-principal secrets in the broker. Crabbox creates a shared resource group, vnet, subnet, and NSG on first use, then per-lease public IPs, NICs, and VMs. Linux uses cloud-init; Windows uses VM Agent Custom Script Extension to install OpenSSH/Git and configure the Crabbox user.
|
||||
- **Brokered cloud.** Maintainers and agents share infra without sharing provider tokens. Hetzner and AWS EC2 are first-class managed providers; AWS also owns managed Windows and EC2 Mac targets. Linux defaults to Spot unless capacity config says otherwise. Providers fall back across compatible instance families when capacity or quota rejects a request.
|
||||
- **macOS and Windows static hosts.** `provider: ssh` reuses existing machines; it does not create macOS or Windows Crabbox boxes. macOS and Windows WSL2 use the POSIX rsync path; native Windows uses PowerShell plus tar archive sync.
|
||||
- **Blacksmith Testbox wrapper.** Set `provider: blacksmith-testbox` to delegate warmup/run/list/status/stop to the Blacksmith CLI while Crabbox keeps local slugs, repo claims, timing summaries, config conventions, and portal visibility for active external runners.
|
||||
- **Daytona and Islo sandboxes.** Set `provider: daytona` for Daytona SDK/toolbox execution from a snapshot with explicit SSH access when needed, or `provider: islo` for delegated Islo sandbox execution through the Islo Go SDK.
|
||||
- **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. `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. QA systems such as Mantis own scenario logic, screenshots, and PR evidence. Azure native Windows is SSH/sync/run only; use AWS for managed Windows desktop/WSL2 or `provider: ssh` for an existing Windows host.
|
||||
- **Authenticated web portal.** Browser login opens owner-scoped and explicitly shared lease/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. `crabbox share` can grant a lease to one user or the owning org, and the lease page exposes the same sharing controls for owners/managers. 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.
|
||||
- **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.
|
||||
- **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.
|
||||
@ -113,16 +112,6 @@ AWS WSL2 standard m8i.large, m8i-flex.large, c8i.large, r8i.large
|
||||
beast m8i.4xlarge, m8i-flex.4xlarge, c8i.4xlarge, r8i.4xlarge, m8i.2xlarge
|
||||
|
||||
AWS macOS all mac2.metal unless --type is set
|
||||
|
||||
Azure standard Standard_D32ads_v6, Standard_D32ds_v6, Standard_F32s_v2, then 16-vCPU fallbacks
|
||||
fast Standard_D64ads_v6, Standard_D64ds_v6, Standard_F64s_v2, then 48/32-vCPU fallbacks
|
||||
large Standard_D96ads_v6, Standard_D96ds_v6, then 64/48-vCPU fallbacks
|
||||
beast Standard_D192ds_v6, Standard_D128ds_v6, then 96/64-vCPU fallbacks
|
||||
|
||||
Azure Win standard Standard_D2ads_v6, Standard_D2ds_v6, Standard_D2ads_v5, Standard_D2ds_v5, Standard_D2as_v6
|
||||
fast Standard_D4ads_v6, Standard_D4ds_v6, Standard_D4ads_v5, Standard_D4ds_v5, Standard_D4as_v6
|
||||
large Standard_D8ads_v6, Standard_D8ds_v6, Standard_D8ads_v5, Standard_D8ds_v5, Standard_D8as_v6
|
||||
beast Standard_D16ads_v6, Standard_D16ds_v6, Standard_D16ads_v5, Standard_D16ds_v5, Standard_D8ads_v6
|
||||
```
|
||||
|
||||
Override with `--type` or `CRABBOX_SERVER_TYPE` for a specific instance.
|
||||
|
||||
@ -13,13 +13,13 @@ A `crabbox run` command leases a brokered cloud machine or reuses a static SSH h
|
||||
```text
|
||||
your laptop Cloudflare Worker cloud provider
|
||||
------------- ------------------ --------------
|
||||
crabbox CLI -- HTTPS --> Fleet Durable Object --> Hetzner / AWS EC2 / Azure
|
||||
crabbox CLI -- HTTPS --> Fleet Durable Object --> Hetzner / AWS EC2
|
||||
| lease + cost state |
|
||||
| |
|
||||
+------------ SSH + rsync to leased runner <--------------+
|
||||
```
|
||||
|
||||
The CLI is a Go binary. The broker is a Cloudflare Worker plus a single Durable Object. Brokered Linux runners are vanilla Ubuntu boxes prepared by cloud-init with SSH, Git, rsync, curl, jq, and `/work/crabbox`; AWS can also broker managed Windows/WSL2 and EC2 Mac desktop targets, while Azure can broker native Windows SSH/sync/run targets. Static hosts are existing SSH machines selected with `provider: ssh`. Project runtimes come from Actions hydration or repo-owned setup. Runners hold no broker credentials - they are leaf nodes.
|
||||
The CLI is a Go binary. The broker is a Cloudflare Worker plus a single Durable Object. Brokered Linux runners are vanilla Ubuntu boxes prepared by cloud-init with SSH, Git, rsync, curl, jq, and `/work/crabbox`; AWS can also broker managed Windows and EC2 Mac desktop targets. Static hosts are existing SSH machines selected with `provider: ssh`. Project runtimes come from Actions hydration or repo-owned setup. Runners hold no broker credentials - they are leaf nodes.
|
||||
|
||||
## A run, end to end
|
||||
|
||||
|
||||
@ -104,7 +104,6 @@ Owned backends:
|
||||
- `hetzner-static`: pre-created warm machines.
|
||||
- `hetzner-ephemeral`: created per lease or overflow.
|
||||
- `aws`: one-time EC2 instances for burst capacity, managed Windows/WSL2, and EC2 Mac.
|
||||
- `azure`: one-time Azure VMs for Linux and native Windows SSH/sync/run.
|
||||
- `ssh-static`: manually managed machines reachable by SSH.
|
||||
|
||||
Brokered backends, later:
|
||||
@ -112,7 +111,7 @@ Brokered backends, later:
|
||||
- `github-actions`: register or dispatch real Actions-backed runner work when workflow parity is required.
|
||||
- `external-runner`: adapter boundary for other hosted runner systems if needed.
|
||||
|
||||
The current broker implements `hetzner-ephemeral`, `aws`, and `azure`, and leaves interfaces ready for `hetzner-static`.
|
||||
The current broker implements `hetzner-ephemeral` and `aws`, and leaves interfaces ready for `hetzner-static`.
|
||||
|
||||
## Machine Bootstrap
|
||||
|
||||
|
||||
58
docs/cli.md
58
docs/cli.md
@ -25,35 +25,19 @@ Primary output goes to stdout. Progress, diagnostics, and errors go to stderr. J
|
||||
|
||||
```text
|
||||
crabbox doctor
|
||||
crabbox login [--url <url>] [--provider hetzner|aws|azure] [--no-browser]
|
||||
crabbox login --url <url> --token-stdin [--provider hetzner|aws|azure]
|
||||
crabbox login [--url <url>] [--provider hetzner|aws] [--no-browser]
|
||||
crabbox login --url <url> --token-stdin [--provider hetzner|aws]
|
||||
crabbox logout
|
||||
crabbox whoami [--json]
|
||||
crabbox init [--force]
|
||||
crabbox config show [--json]
|
||||
crabbox config path
|
||||
crabbox config set-broker --url <url> --token-stdin [--provider hetzner|aws|azure]
|
||||
crabbox warmup [--provider hetzner|aws|azure|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|azure|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 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>] [--webvnc] [--open] [-- <command...>]
|
||||
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>]
|
||||
crabbox egress client --id <lease-id-or-slug> [--listen <addr>] [--ticket <ticket>] [--session <id>]
|
||||
crabbox egress status --id <lease-id-or-slug>
|
||||
crabbox egress stop --id <lease-id-or-slug>
|
||||
crabbox media preview --input <video> --output <preview.gif> [--trimmed-video-output <change.mp4>]
|
||||
crabbox artifacts collect --id <lease-id-or-slug> [--output <dir>] [--run <run-id>] [--all] [--screenshot] [--video] [--gif] [--doctor] [--webvnc-status] [--metadata] [--duration <duration>] [--fps <n>] [--gif-width <px>] [--network auto|tailscale|public] [--json]
|
||||
crabbox artifacts video --id <lease-id-or-slug> [--output <path>] [--duration <duration>] [--fps <n>]
|
||||
crabbox artifacts gif --input <video> --output <preview.gif> [--trimmed-video-output <change.mp4>]
|
||||
crabbox artifacts template openclaw|mantis [--summary <text>|--summary-file <path>] [--before <path>] [--after <path>] [--output <path>]
|
||||
crabbox artifacts publish --dir <dir> [--pr <n>] [--repo owner/name] [--storage auto|broker|s3|cloudflare|r2|local] [--bucket <name>] [--prefix <path>] [--base-url <url>] [--region <region>] [--profile <profile>] [--endpoint-url <url>] [--acl <acl>] [--presign] [--expires <duration>] [--dry-run] [--no-comment]
|
||||
crabbox screenshot --id <lease-id-or-slug> [--output <path>]
|
||||
crabbox sync-plan [--limit <n>]
|
||||
crabbox history [--lease <lease-id>] [--owner <email>] [--org <name>] [--limit <n>] [--json]
|
||||
@ -69,8 +53,6 @@ crabbox actions register --id <lease-id-or-slug> [--repo owner/name]
|
||||
crabbox actions dispatch [--workflow <file|name|id>] [-f key=value]
|
||||
crabbox status --id <lease-id-or-slug> [--network auto|tailscale|public] [--wait]
|
||||
crabbox list [--json]
|
||||
crabbox share --id <lease-id-or-slug> [--user <email>] [--org] [--role use|manage] [--list] [--json]
|
||||
crabbox unshare --id <lease-id-or-slug> [--user <email>] [--org] [--all] [--json]
|
||||
crabbox usage [--scope user|org|all] [--user <email>] [--org <name>] [--month YYYY-MM] [--json]
|
||||
crabbox admin leases [--state active|released|expired|failed] [--owner <email>] [--org <name>] [--json]
|
||||
crabbox admin release <lease-id-or-slug> [--delete]
|
||||
@ -78,11 +60,6 @@ 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]
|
||||
@ -111,23 +88,10 @@ 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
|
||||
crabbox egress stop --id blue-lobster
|
||||
crabbox share --id blue-lobster --user friend@example.com
|
||||
crabbox share --id blue-lobster --org
|
||||
crabbox screenshot --id blue-lobster --output desktop.png
|
||||
crabbox media preview --input desktop.mp4 --output desktop-preview.gif --trimmed-video-output desktop-change.mp4
|
||||
crabbox artifacts collect --id blue-lobster --all --output artifacts/blue-lobster
|
||||
crabbox artifacts publish --dir artifacts/blue-lobster --pr 123
|
||||
crabbox run --id blue-lobster --shell 'pnpm install --frozen-lockfile && pnpm test'
|
||||
crabbox stop blue-lobster
|
||||
```
|
||||
@ -605,16 +569,6 @@ CRABBOX_TAILSCALE_AUTH_KEY_ENV
|
||||
CRABBOX_TAILSCALE_AUTH_KEY direct-provider only, via auth-key env
|
||||
CRABBOX_TAILSCALE_EXIT_NODE
|
||||
CRABBOX_TAILSCALE_EXIT_NODE_ALLOW_LAN_ACCESS
|
||||
CRABBOX_ARTIFACTS_STORAGE default --storage for artifacts publish
|
||||
CRABBOX_ARTIFACTS_BUCKET
|
||||
CRABBOX_ARTIFACTS_PREFIX
|
||||
CRABBOX_ARTIFACTS_BASE_URL
|
||||
CRABBOX_ARTIFACTS_AWS_REGION
|
||||
CRABBOX_ARTIFACTS_AWS_PROFILE
|
||||
CRABBOX_ARTIFACTS_ENDPOINT_URL
|
||||
CRABBOX_ARTIFACTS_S3_ACL
|
||||
CRABBOX_ARTIFACTS_PRESIGN
|
||||
CRABBOX_ARTIFACTS_EXPIRES
|
||||
```
|
||||
|
||||
Provider/deploy variables live outside normal CLI operation:
|
||||
|
||||
@ -13,7 +13,6 @@ Command docs live here, one file per top-level command. Keep `docs/cli.md` as th
|
||||
- [run](run.md)
|
||||
- [desktop](desktop.md)
|
||||
- [media](media.md)
|
||||
- [artifacts](artifacts.md)
|
||||
- [sync-plan](sync-plan.md)
|
||||
- [history](history.md)
|
||||
- [logs](logs.md)
|
||||
@ -23,8 +22,6 @@ Command docs live here, one file per top-level command. Keep `docs/cli.md` as th
|
||||
- [cache](cache.md)
|
||||
- [status](status.md)
|
||||
- [list](list.md)
|
||||
- [share](share.md)
|
||||
- [unshare](unshare.md)
|
||||
- [image](image.md)
|
||||
- [usage](usage.md)
|
||||
- [admin](admin.md)
|
||||
@ -33,7 +30,6 @@ Command docs live here, one file per top-level command. Keep `docs/cli.md` as th
|
||||
- [vnc](vnc.md)
|
||||
- [webvnc](webvnc.md)
|
||||
- [code](code.md)
|
||||
- [egress](egress.md)
|
||||
- [screenshot](screenshot.md)
|
||||
- [inspect](inspect.md)
|
||||
- [stop](stop.md)
|
||||
|
||||
@ -1,235 +0,0 @@
|
||||
# artifacts
|
||||
|
||||
`crabbox artifacts` collects desktop QA evidence into a durable bundle, creates
|
||||
trimmed review media, and publishes inline-ready assets for pull requests.
|
||||
|
||||
Use it when a desktop/WebVNC issue or UI fix needs more than a one-off
|
||||
screenshot: MP4 recording, trimmed GIF, logs, doctor output, WebVNC status, and
|
||||
metadata in one directory.
|
||||
|
||||
## Collect
|
||||
|
||||
```sh
|
||||
crabbox artifacts collect --id blue-lobster --output artifacts/blue-lobster
|
||||
crabbox artifacts collect --id blue-lobster --all --duration 20s --output artifacts/blue-lobster
|
||||
crabbox artifacts collect --id blue-lobster --run run_123 --output artifacts/blue-lobster
|
||||
```
|
||||
|
||||
By default `collect` writes:
|
||||
|
||||
- `metadata.json`
|
||||
- `screenshot.png`
|
||||
- `doctor.txt`
|
||||
- `webvnc-status.json` when a coordinator login is configured
|
||||
- `logs.txt` and `run.json` when `--run <run-id>` is provided
|
||||
|
||||
`--all` also records `screen.mp4`, creates `screen.trimmed.gif`, and writes
|
||||
`screen.trimmed.mp4` using the same motion window. Video/GIF capture currently
|
||||
requires a Linux desktop lease with `ffmpeg` and X11 capture support.
|
||||
|
||||
Useful flags:
|
||||
|
||||
```text
|
||||
--id <lease-id-or-slug>
|
||||
--output <dir>
|
||||
--run <run-id>
|
||||
--all
|
||||
--screenshot
|
||||
--video
|
||||
--gif
|
||||
--doctor
|
||||
--webvnc-status
|
||||
--metadata
|
||||
--duration <duration> default 10s
|
||||
--fps <n> default 15
|
||||
--gif-width <px> default 640
|
||||
--provider <name>
|
||||
--network auto|public|tailscale
|
||||
--json
|
||||
```
|
||||
|
||||
When collection hits an unhealthy desktop, WebVNC, VNC, or input layer, it
|
||||
prints the same inline `problem:`, `detail:`, and `rescue:` commands used by the
|
||||
desktop and WebVNC commands. With `--json`, stdout remains valid JSON and those
|
||||
same repair hints are returned in the `warnings` array instead of being printed
|
||||
as text before the JSON document. If a capture step fails after the bundle has
|
||||
started, the command still exits nonzero and includes an `error` object with a
|
||||
stable code and message.
|
||||
|
||||
## Video
|
||||
|
||||
```sh
|
||||
crabbox artifacts video --id blue-lobster --duration 15s --output screen.mp4
|
||||
```
|
||||
|
||||
`video` records only an MP4 from a Linux desktop lease. It is useful when you
|
||||
want to keep capture separate from bundle collection.
|
||||
|
||||
## GIF
|
||||
|
||||
```sh
|
||||
crabbox artifacts gif \
|
||||
--input screen.mp4 \
|
||||
--output screen.trimmed.gif \
|
||||
--trimmed-video-output screen.trimmed.mp4
|
||||
```
|
||||
|
||||
`gif` is an alias for the same local motion-trimmed preview logic as
|
||||
[`crabbox media preview`](media.md).
|
||||
|
||||
## Templates
|
||||
|
||||
```sh
|
||||
crabbox artifacts template openclaw \
|
||||
--before before.png \
|
||||
--after after.gif \
|
||||
--summary "Login modal no longer overlaps the toolbar." \
|
||||
--output summary.md
|
||||
|
||||
crabbox artifacts template mantis --summary-file qa-notes.md
|
||||
```
|
||||
|
||||
Templates write Markdown with `Summary`, `Before / After`, and `Evidence`
|
||||
sections sized for Mantis/OpenClaw QA comments.
|
||||
|
||||
## Publish
|
||||
|
||||
```sh
|
||||
crabbox artifacts publish \
|
||||
--dir artifacts/blue-lobster \
|
||||
--pr 123
|
||||
|
||||
crabbox artifacts publish \
|
||||
--dir artifacts/blue-lobster \
|
||||
--pr 123 \
|
||||
--storage s3 \
|
||||
--bucket qa-artifacts \
|
||||
--prefix pr-123/blue-lobster \
|
||||
--base-url https://qa-artifacts.example.com
|
||||
|
||||
crabbox artifacts publish \
|
||||
--dir artifacts/blue-lobster \
|
||||
--pr 123 \
|
||||
--storage cloudflare \
|
||||
--bucket qa-artifacts \
|
||||
--prefix pr-123/blue-lobster \
|
||||
--base-url https://artifacts.example.com
|
||||
```
|
||||
|
||||
`publish` uploads bundle files, writes `published-artifacts.md`, and comments
|
||||
on the PR with inline images/GIFs plus links to videos, logs, and metadata.
|
||||
Use `--dry-run` to generate markdown and print intended actions without upload
|
||||
or comment side effects.
|
||||
|
||||
Storage backends:
|
||||
|
||||
- `--storage auto` is the default. When a coordinator is configured, Crabbox
|
||||
asks the broker for upload URLs and the broker-owned artifact backend handles
|
||||
storage credentials. Without a coordinator, auto falls back to local markdown.
|
||||
- `--storage broker` requires a configured coordinator and uploads through
|
||||
broker-minted URLs.
|
||||
- `--storage s3` uses the AWS CLI and uploads to `s3://<bucket>/<prefix>/...`.
|
||||
- `--storage cloudflare` uses `wrangler r2 object put --remote`.
|
||||
- `--storage r2` uses the AWS CLI against an S3-compatible R2 endpoint.
|
||||
- `--storage local` writes markdown only. For `--pr`, local publishing needs a
|
||||
`--base-url` that already serves the files, otherwise the PR would contain
|
||||
unusable local paths.
|
||||
|
||||
S3 flags:
|
||||
|
||||
```text
|
||||
--bucket <name>
|
||||
--prefix <path>
|
||||
--base-url <url>
|
||||
--region <region>
|
||||
--profile <profile>
|
||||
--endpoint-url <url>
|
||||
--acl <acl>
|
||||
--presign
|
||||
--expires <duration> default 168h
|
||||
```
|
||||
|
||||
When `--base-url` is supplied, published links use that public URL. Otherwise
|
||||
`--presign` generates temporary AWS/R2 S3 URLs after upload.
|
||||
|
||||
Cloudflare R2 flags:
|
||||
|
||||
```text
|
||||
--bucket <name>
|
||||
--prefix <path>
|
||||
--base-url <url> required for --pr inline-ready links
|
||||
```
|
||||
|
||||
For native Cloudflare publishing, `publish` runs `wrangler` with
|
||||
`CRABBOX_ARTIFACTS_CLOUDFLARE_*` when present, then the generic
|
||||
`CLOUDFLARE_*` environment. Prefer brokered publishing for shared teams so
|
||||
Cloudflare and object-store secrets stay on the coordinator.
|
||||
|
||||
For S3-compatible R2 publishing, pass `--storage r2 --endpoint-url <r2-endpoint>
|
||||
--profile <r2-profile>`. When present, Crabbox uses
|
||||
`CRABBOX_ARTIFACTS_R2_ENDPOINT_URL` and `CRABBOX_ARTIFACTS_R2_AWS_PROFILE`
|
||||
before falling back to generic AWS defaults.
|
||||
|
||||
Coordinator artifact backend configuration:
|
||||
|
||||
```text
|
||||
CRABBOX_ARTIFACTS_BACKEND=s3|r2
|
||||
CRABBOX_ARTIFACTS_BUCKET
|
||||
CRABBOX_ARTIFACTS_PREFIX
|
||||
CRABBOX_ARTIFACTS_BASE_URL
|
||||
CRABBOX_ARTIFACTS_REGION
|
||||
CRABBOX_ARTIFACTS_ENDPOINT_URL
|
||||
CRABBOX_ARTIFACTS_ACCESS_KEY_ID
|
||||
CRABBOX_ARTIFACTS_SECRET_ACCESS_KEY
|
||||
CRABBOX_ARTIFACTS_SESSION_TOKEN
|
||||
CRABBOX_ARTIFACTS_UPLOAD_EXPIRES_SECONDS
|
||||
CRABBOX_ARTIFACTS_URL_EXPIRES_SECONDS
|
||||
```
|
||||
|
||||
For brokered publishing, the CLI never receives object-store credentials. It
|
||||
sends artifact names, sizes, content types, and hashes to
|
||||
`POST /v1/artifacts/uploads`; the coordinator returns one short-lived upload URL
|
||||
per file plus the final URL to place in Markdown. Upload grants are signed with
|
||||
the declared `content-length`, so the object store rejects oversized PUTs during
|
||||
the grant window; the broker also caps each upload request at 5 GiB total before
|
||||
signing grants. When `--prefix` is omitted for hosted publishing, the CLI derives
|
||||
a unique prefix from the PR number, bundle directory, and current time so later
|
||||
QA comments do not overwrite earlier evidence.
|
||||
|
||||
Coordinator artifact values split into two groups:
|
||||
|
||||
- Worker vars: `CRABBOX_ARTIFACTS_BACKEND`, `CRABBOX_ARTIFACTS_BUCKET`,
|
||||
`CRABBOX_ARTIFACTS_PREFIX`, `CRABBOX_ARTIFACTS_BASE_URL`,
|
||||
`CRABBOX_ARTIFACTS_REGION`, `CRABBOX_ARTIFACTS_ENDPOINT_URL`,
|
||||
`CRABBOX_ARTIFACTS_UPLOAD_EXPIRES_SECONDS`, and
|
||||
`CRABBOX_ARTIFACTS_URL_EXPIRES_SECONDS`. These describe where artifacts go and
|
||||
how long URLs should live.
|
||||
- Worker secrets: `CRABBOX_ARTIFACTS_ACCESS_KEY_ID`,
|
||||
`CRABBOX_ARTIFACTS_SECRET_ACCESS_KEY`, and optional
|
||||
`CRABBOX_ARTIFACTS_SESSION_TOKEN`. These are S3-compatible object-store keys
|
||||
used only by the coordinator to sign artifact upload/read URLs.
|
||||
|
||||
Our deployed coordinator currently uses R2-compatible storage with public final
|
||||
URLs on `https://artifacts.openclaw.ai`, bucket
|
||||
`openclaw-crabbox-artifacts`, and object prefix `crabbox-artifacts`. The actual
|
||||
R2 access key id and secret access key are Worker secrets; they are not required
|
||||
on developer machines for normal `crabbox artifacts publish`.
|
||||
|
||||
Environment defaults:
|
||||
|
||||
```text
|
||||
CRABBOX_ARTIFACTS_STORAGE
|
||||
CRABBOX_ARTIFACTS_BUCKET
|
||||
CRABBOX_ARTIFACTS_PREFIX
|
||||
CRABBOX_ARTIFACTS_BASE_URL
|
||||
CRABBOX_ARTIFACTS_AWS_REGION
|
||||
CRABBOX_ARTIFACTS_AWS_PROFILE
|
||||
CRABBOX_ARTIFACTS_ENDPOINT_URL
|
||||
CRABBOX_ARTIFACTS_S3_ACL
|
||||
CRABBOX_ARTIFACTS_PRESIGN
|
||||
CRABBOX_ARTIFACTS_EXPIRES
|
||||
```
|
||||
|
||||
`publish --pr` uses `gh issue comment <pr> --body-file ...`, so the current
|
||||
checkout must be authenticated with GitHub. Pass `--repo owner/name` when the
|
||||
working directory is not inside the target repository.
|
||||
@ -47,7 +47,7 @@ error and continue with the next candidate.
|
||||
## Flags
|
||||
|
||||
```text
|
||||
--provider hetzner|aws|azure provider to sweep (delegated providers do not need cleanup)
|
||||
--provider hetzner|aws provider to sweep (delegated providers do not need cleanup)
|
||||
--target linux|macos|windows for AWS, restrict by target
|
||||
--windows-mode normal|wsl2 when target=windows
|
||||
--static-host <host> ignored (provider=ssh has nothing to sweep)
|
||||
|
||||
@ -46,10 +46,7 @@ browser
|
||||
|
||||
Keep the local `crabbox code` process running while using the editor. The
|
||||
coordinator authenticates the browser through portal auth and authenticates the
|
||||
local bridge with a one-use, short-lived ticket. The CLI sends the ticket as
|
||||
an `Authorization: Bearer ...` header so it stays out of websocket URLs and
|
||||
proxy/access logs; the coordinator accepts a `?ticket=` query string as a
|
||||
fallback for older CLIs.
|
||||
local bridge with a one-use, short-lived ticket.
|
||||
|
||||
If the browser opens before the local bridge connects, the Code portal renders a
|
||||
waiting state with the exact `crabbox code --id <lease> --open` command, copy
|
||||
@ -64,7 +61,7 @@ and extension-host traffic stay below coordinator websocket frame limits.
|
||||
|
||||
```text
|
||||
--id <lease-id-or-slug>
|
||||
--provider hetzner|aws|azure
|
||||
--provider hetzner|aws
|
||||
--target linux
|
||||
--network auto|tailscale|public
|
||||
--local-port <port>
|
||||
|
||||
@ -15,7 +15,7 @@ Subcommands:
|
||||
```text
|
||||
path
|
||||
show [--json]
|
||||
set-broker --url <url> [--token-stdin] [--admin-token-stdin] [--provider hetzner|aws|azure]
|
||||
set-broker --url <url> [--token-stdin] [--admin-token-stdin] [--provider hetzner|aws]
|
||||
```
|
||||
|
||||
`config show` reports broker auth as `auth` and `admin_auth`, plus
|
||||
|
||||
@ -7,15 +7,7 @@ over VNC manually.
|
||||
crabbox warmup --desktop --browser
|
||||
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
|
||||
@ -27,13 +19,6 @@ into the authenticated WebVNC portal. Add `--open` to open that portal locally.
|
||||
Browser launches default to a windowed human desktop with the remote panel and
|
||||
title bar visible; use `--fullscreen` only for capture/video workflows.
|
||||
|
||||
`--egress <profile>` passes the active lease-local egress proxy to the launched
|
||||
browser as `--proxy-server=http://127.0.0.1:3128`, so the browser exits to the
|
||||
internet through the operator machine running `crabbox egress start`. Start
|
||||
the egress bridge first; the flag currently requires `--browser`. Override the
|
||||
proxy address with `--egress-proxy host:port` if you started egress on a
|
||||
non-default port. See [egress](egress.md) for the full bridge model.
|
||||
|
||||
On Windows, SSH sessions cannot directly own the visible console desktop, so
|
||||
Crabbox writes a one-shot PowerShell launcher under `C:\ProgramData\crabbox` and
|
||||
runs it as an interactive scheduled task for the logged-in `crabbox` user. The
|
||||
@ -41,39 +26,11 @@ 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.
|
||||
|
||||
Desktop launch and input failures now surface the failing layer directly in the
|
||||
CLI output. For example, a missing visible browser reports `problem: browser not
|
||||
launched`, a dead input path reports `problem: input stack dead`, and a broken
|
||||
portal path reports `problem: VNC bridge disconnected` or `problem: WebVNC
|
||||
daemon not running`. The same output includes exact `rescue:` commands such as
|
||||
`crabbox desktop doctor --id <lease>` or `crabbox webvnc reset --id <lease>
|
||||
--open`.
|
||||
|
||||
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
|
||||
--id <lease-id-or-slug>
|
||||
--provider hetzner|aws|azure|ssh|daytona
|
||||
--provider hetzner|aws|ssh
|
||||
--target linux|macos|windows
|
||||
--windows-mode normal|wsl2
|
||||
--static-host <host>
|
||||
@ -85,28 +42,5 @@ Flags:
|
||||
--webvnc
|
||||
--open
|
||||
--fullscreen
|
||||
--egress <profile>
|
||||
--egress-proxy <host:port>
|
||||
--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)
|
||||
- [vnc](vnc.md)
|
||||
- [webvnc](webvnc.md)
|
||||
- [Lease capabilities](../features/capabilities.md)
|
||||
- [Mediated egress](../features/egress.md)
|
||||
|
||||
@ -71,7 +71,7 @@ the exit code.
|
||||
## Flags
|
||||
|
||||
```text
|
||||
--provider hetzner|aws|azure|ssh provider to validate
|
||||
--provider hetzner|aws|ssh provider to validate
|
||||
--target linux|macos|windows target OS for ssh provider checks
|
||||
--windows-mode normal|wsl2 when target=windows
|
||||
--static-host <host> static SSH host
|
||||
|
||||
@ -1,135 +0,0 @@
|
||||
# egress
|
||||
|
||||
`crabbox egress` bridges lease-local browser or app traffic through the machine
|
||||
running the egress host agent.
|
||||
|
||||
```sh
|
||||
crabbox egress start --id blue-lobster --profile discord
|
||||
crabbox egress start --id blue-lobster --profile discord --daemon
|
||||
crabbox desktop launch --id blue-lobster --browser --url https://discord.com/login --egress discord
|
||||
crabbox egress status --id blue-lobster
|
||||
crabbox egress stop --id blue-lobster
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
`egress start` installs a short-lived egress client helper on the lease, starts
|
||||
a loopback HTTP proxy such as `127.0.0.1:3128`, then runs a local host bridge on
|
||||
the operator machine. Both sides connect outbound to the coordinator with
|
||||
one-use tickets. The coordinator pairs the two WebSockets and forwards
|
||||
multiplexed proxy messages; it does not open internet connections itself.
|
||||
|
||||
The browser/app data path is:
|
||||
|
||||
```text
|
||||
Chrome in lease
|
||||
-> lease 127.0.0.1:3128
|
||||
-> coordinator Durable Object
|
||||
-> local crabbox egress host process
|
||||
-> internet from the operator machine
|
||||
```
|
||||
|
||||
`desktop launch --egress <profile>` passes the lease-local proxy to Chrome as:
|
||||
|
||||
```text
|
||||
--proxy-server=http://127.0.0.1:3128
|
||||
```
|
||||
|
||||
The portal lease detail page shows the active egress session, host/client
|
||||
connection state, and copyable `egress status` / `egress stop` commands. It
|
||||
does not expose tickets or raw proxy URLs.
|
||||
|
||||
## Subcommands
|
||||
|
||||
```text
|
||||
start Start a remote lease proxy and local host bridge
|
||||
host Run only the local egress host bridge
|
||||
client Run only the lease-side proxy bridge
|
||||
status Show coordinator bridge status
|
||||
stop Stop the local host daemon and remote lease client
|
||||
```
|
||||
|
||||
Use `host` and `client` directly when debugging tickets, custom tunnels, or a
|
||||
manually installed helper.
|
||||
|
||||
## Profiles And Allowlist
|
||||
|
||||
The host side refuses to become an open proxy. Use a built-in profile or an
|
||||
explicit allowlist:
|
||||
|
||||
```sh
|
||||
crabbox egress start --id blue-lobster --profile discord
|
||||
crabbox egress start --id blue-lobster --allow example.com,*.example.com
|
||||
```
|
||||
|
||||
Built-in profiles:
|
||||
|
||||
- `discord`: `discord.com`, `*.discord.com`, `discordcdn.com`,
|
||||
`*.discordcdn.com`, `hcaptcha.com`, `*.hcaptcha.com`
|
||||
- `slack`: `slack.com`, `*.slack.com`, `slack-edge.com`, `*.slack-edge.com`
|
||||
|
||||
Wildcard entries match the named domain and subdomains.
|
||||
|
||||
## Flags
|
||||
|
||||
Common:
|
||||
|
||||
```text
|
||||
--id <lease-id-or-slug>
|
||||
--provider hetzner|aws
|
||||
--profile <name>
|
||||
--allow <comma-separated-host-patterns>
|
||||
```
|
||||
|
||||
`start`:
|
||||
|
||||
```text
|
||||
--listen 127.0.0.1:3128
|
||||
--daemon
|
||||
--coordinator <public-coordinator-url>
|
||||
--target linux
|
||||
--network auto|tailscale|public
|
||||
```
|
||||
|
||||
`host` and `client` debugging:
|
||||
|
||||
```text
|
||||
--coordinator <url>
|
||||
--ticket <ticket>
|
||||
--session <session-id>
|
||||
```
|
||||
|
||||
## Limitations
|
||||
|
||||
- The shipped path is per-app/per-process egress, not full VM routing.
|
||||
- `egress start` supports coordinator-backed Linux SSH leases.
|
||||
- `egress start` refuses non-Linux targets until target-specific remote helper
|
||||
install/start commands exist.
|
||||
- `egress start` does not install Cloudflare Access service-token credentials
|
||||
on the remote lease. If Access credentials are configured locally, use a
|
||||
public coordinator route, or run `egress client` manually only when it is safe
|
||||
to provide the required access headers.
|
||||
- The first implementation uses JSON/base64 bridge frames. That is good enough
|
||||
for browser QA but can be optimized with binary frames later.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
`egress host requires --profile or --allow`
|
||||
|
||||
The host bridge will not start as an open proxy. Pick a profile or pass an
|
||||
explicit allowlist.
|
||||
|
||||
`remote egress client did not listen`
|
||||
|
||||
Inspect the remote helper log:
|
||||
|
||||
```sh
|
||||
crabbox ssh --id blue-lobster
|
||||
cat /tmp/crabbox-egress-client.log
|
||||
```
|
||||
|
||||
`desktop launch --egress currently requires --browser`
|
||||
|
||||
The automatic proxy flag is wired for browser launches. For custom apps, pass
|
||||
the app's proxy flag yourself or use the lease-local proxy address printed by
|
||||
`egress start`.
|
||||
@ -37,7 +37,7 @@ included.
|
||||
|
||||
```text
|
||||
--id <lease-id-or-slug> lease to inspect; required for managed providers
|
||||
--provider hetzner|aws|azure|ssh|daytona override the configured provider
|
||||
--provider hetzner|aws|ssh override the configured provider
|
||||
--target linux|macos|windows
|
||||
--windows-mode normal|wsl2
|
||||
--static-host <host> static SSH host for provider=ssh
|
||||
|
||||
@ -35,7 +35,7 @@ use the normalized Crabbox lease view.
|
||||
Flags:
|
||||
|
||||
```text
|
||||
--provider hetzner|aws|azure|ssh|blacksmith-testbox|daytona|islo
|
||||
--provider hetzner|aws|ssh|blacksmith-testbox|daytona|islo
|
||||
--target linux|macos|windows
|
||||
--windows-mode normal|wsl2
|
||||
--static-host <host>
|
||||
|
||||
@ -27,7 +27,7 @@ Flags:
|
||||
|
||||
```text
|
||||
--url <url> broker URL
|
||||
--provider hetzner|aws|azure default provider to store with the broker
|
||||
--provider hetzner|aws default provider to store with the broker
|
||||
--no-browser print the GitHub login URL instead of opening it
|
||||
--token-stdin read broker token from stdin for operator automation
|
||||
--json print JSON
|
||||
|
||||
@ -89,7 +89,7 @@ Flags:
|
||||
|
||||
```text
|
||||
--id <lease-id-or-slug>
|
||||
--provider hetzner|aws|azure|ssh|blacksmith-testbox|daytona|islo
|
||||
--provider hetzner|aws|ssh|blacksmith-testbox|daytona|islo
|
||||
--target linux|macos|windows
|
||||
--windows-mode normal|wsl2
|
||||
--static-host <host>
|
||||
|
||||
@ -38,7 +38,7 @@ Flags:
|
||||
|
||||
```text
|
||||
--id <lease-id-or-slug>
|
||||
--provider hetzner|aws|azure|ssh|daytona
|
||||
--provider hetzner|aws|ssh
|
||||
--target linux|macos|windows
|
||||
--windows-mode normal|wsl2
|
||||
--static-host <host>
|
||||
|
||||
@ -1,43 +0,0 @@
|
||||
# share
|
||||
|
||||
`crabbox share` grants access to an existing coordinator lease.
|
||||
|
||||
```sh
|
||||
crabbox share --id blue-lobster --user friend@example.com
|
||||
crabbox share --id blue-lobster --user friend@example.com --role manage
|
||||
crabbox share --id blue-lobster --org
|
||||
crabbox share --id blue-lobster --org --role manage
|
||||
crabbox share --id blue-lobster --list
|
||||
crabbox share blue-lobster --list --json
|
||||
```
|
||||
|
||||
Roles:
|
||||
|
||||
```text
|
||||
use see the lease and use visible portal bridges such as WebVNC/code
|
||||
manage use access plus changing sharing and stopping the lease
|
||||
```
|
||||
|
||||
`--org` shares with authenticated users whose org matches the lease org.
|
||||
`--user` is repeatable and stores normalized lowercase email addresses.
|
||||
|
||||
SSH-based commands still require a local private key accepted by the runner.
|
||||
Sharing grants coordinator and portal access; it does not copy SSH private keys
|
||||
between people.
|
||||
|
||||
Flags:
|
||||
|
||||
```text
|
||||
--id <lease-id-or-slug>
|
||||
--user <email>
|
||||
--org
|
||||
--role use|manage
|
||||
--list
|
||||
--json
|
||||
```
|
||||
|
||||
Related docs:
|
||||
|
||||
- [unshare](unshare.md)
|
||||
- [Auth and admin](../features/auth-admin.md)
|
||||
- [Browser portal](../features/portal.md)
|
||||
@ -15,7 +15,7 @@ Flags:
|
||||
|
||||
```text
|
||||
--id <lease-id-or-slug>
|
||||
--provider hetzner|aws|azure|ssh|daytona
|
||||
--provider hetzner|aws|ssh|daytona
|
||||
--target linux|macos|windows
|
||||
--windows-mode normal|wsl2
|
||||
--static-host <host>
|
||||
|
||||
@ -26,7 +26,7 @@ Flags:
|
||||
|
||||
```text
|
||||
--id <lease-id-or-slug>
|
||||
--provider hetzner|aws|azure|ssh|blacksmith-testbox|daytona|islo
|
||||
--provider hetzner|aws|ssh|blacksmith-testbox|daytona|islo
|
||||
--target linux|macos|windows
|
||||
--windows-mode normal|wsl2
|
||||
--static-host <host>
|
||||
|
||||
@ -15,7 +15,7 @@ The argument accepts the stable `cbx_...` ID or an active friendly slug. In `bla
|
||||
Flags:
|
||||
|
||||
```text
|
||||
--provider hetzner|aws|azure|ssh|blacksmith-testbox|daytona|islo
|
||||
--provider hetzner|aws|ssh|blacksmith-testbox|daytona|islo
|
||||
--target linux|macos|windows
|
||||
--windows-mode normal|wsl2
|
||||
--static-host <host>
|
||||
|
||||
@ -1,30 +0,0 @@
|
||||
# unshare
|
||||
|
||||
`crabbox unshare` removes sharing from an existing coordinator lease.
|
||||
|
||||
```sh
|
||||
crabbox unshare --id blue-lobster --user friend@example.com
|
||||
crabbox unshare --id blue-lobster --org
|
||||
crabbox unshare --id blue-lobster --all
|
||||
crabbox unshare blue-lobster --all --json
|
||||
```
|
||||
|
||||
Use `--user` to remove individual users, `--org` to remove org-wide access, or
|
||||
`--all` to clear every sharing rule. Only the lease owner, a `manage` share, or
|
||||
an admin session can change sharing.
|
||||
|
||||
Flags:
|
||||
|
||||
```text
|
||||
--id <lease-id-or-slug>
|
||||
--user <email>
|
||||
--org
|
||||
--all
|
||||
--json
|
||||
```
|
||||
|
||||
Related docs:
|
||||
|
||||
- [share](share.md)
|
||||
- [Auth and admin](../features/auth-admin.md)
|
||||
- [Browser portal](../features/portal.md)
|
||||
@ -233,7 +233,7 @@ make sure the Dedicated Host is allocated in the selected AWS region.
|
||||
|
||||
```text
|
||||
--id <lease-id-or-slug>
|
||||
--provider hetzner|aws|azure|ssh|daytona
|
||||
--provider hetzner|aws|ssh
|
||||
--target linux|macos|windows
|
||||
--windows-mode normal|wsl2
|
||||
--static-host <host>
|
||||
|
||||
@ -9,7 +9,6 @@ crabbox warmup --browser
|
||||
crabbox warmup --tailscale
|
||||
crabbox warmup --desktop --browser
|
||||
crabbox warmup --provider aws --target windows --desktop
|
||||
crabbox warmup --provider azure --target windows
|
||||
crabbox warmup --provider aws --target macos --desktop --market on-demand --type mac2.metal
|
||||
crabbox warmup --actions-runner
|
||||
crabbox warmup --provider blacksmith-testbox --blacksmith-workflow .github/workflows/ci-check-testbox.yml --blacksmith-job test
|
||||
@ -42,10 +41,8 @@ the updated PATH.
|
||||
|
||||
With `--provider hetzner`, managed provisioning supports Linux only. Hetzner can
|
||||
run Windows through ISO/snapshot installation flows, but Crabbox does not manage
|
||||
that path today. Use `--provider aws --target windows` for managed Windows
|
||||
desktop or WSL2, `--provider azure --target windows` for native Windows
|
||||
SSH/sync/run, or `--provider ssh --target windows` for an existing Hetzner
|
||||
Windows host.
|
||||
that path today. Use `--provider aws --target windows` for managed Windows, or
|
||||
`--provider ssh --target windows` for an existing Hetzner Windows host.
|
||||
|
||||
With `--provider aws --target windows --windows-mode normal --desktop`, Crabbox
|
||||
creates a real AWS Windows Server lease. EC2Launch user data installs OpenSSH
|
||||
@ -60,11 +57,6 @@ imports an Ubuntu rootfs, and prepares the Linux-side `crabbox-ready` toolchain.
|
||||
The AWS launch enables nested virtualization and uses C8i, M8i, or R8i instance
|
||||
families for this mode. Commands and sync then use the POSIX WSL contract.
|
||||
|
||||
With `--provider azure --target windows`, Crabbox creates a native Windows
|
||||
Server lease, uses the Azure VM Agent Custom Script Extension to install
|
||||
OpenSSH Server and Git for Windows, and configures the `crabbox` user for
|
||||
SSH/sync/run. Azure Windows does not provision VNC/browser/WSL2.
|
||||
|
||||
With `--provider aws --target macos --desktop`, Crabbox launches an EC2 Mac
|
||||
instance on an already allocated Dedicated Host. Set `CRABBOX_AWS_MAC_HOST_ID`
|
||||
or `aws.macHostId`, use `--market on-demand`, and expect EC2 Mac host lifecycle
|
||||
@ -77,7 +69,7 @@ On success, `warmup` prints a concise total duration line. Add `--timing-json` t
|
||||
Flags:
|
||||
|
||||
```text
|
||||
--provider hetzner|aws|azure|ssh|blacksmith-testbox|daytona|islo
|
||||
--provider hetzner|aws|ssh|blacksmith-testbox|daytona|islo
|
||||
--target linux|macos|windows
|
||||
--windows-mode normal|wsl2
|
||||
--static-host <host>
|
||||
|
||||
@ -11,12 +11,7 @@ crabbox warmup --desktop
|
||||
crabbox webvnc --id blue-lobster
|
||||
crabbox webvnc --id blue-lobster --network tailscale
|
||||
crabbox webvnc --id blue-lobster --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
|
||||
crabbox webvnc --id blue-lobster --daemon --open
|
||||
```
|
||||
|
||||
## How It Works
|
||||
@ -50,68 +45,16 @@ This keeps the security boundary the same as `crabbox vnc`:
|
||||
- VNC stays bound to runner loopback.
|
||||
- The cloud provider does not open public VNC ingress.
|
||||
- The coordinator authenticates the browser through portal auth and the bridge
|
||||
through a one-use short-lived ticket. The CLI sends the ticket as an
|
||||
`Authorization: Bearer ...` header so it stays out of websocket URLs and
|
||||
proxy/access logs; the coordinator falls back to a `?ticket=` query string
|
||||
for older CLIs.
|
||||
through a one-use short-lived ticket.
|
||||
- The noVNC client is served from the coordinator origin, not a third-party CDN.
|
||||
- The local `crabbox webvnc` process must keep running while the browser uses
|
||||
the desktop.
|
||||
|
||||
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 bridge keeps a warm pool of backend VNC sessions open (default 4 slots,
|
||||
which is what the `slots=` field in `webvnc status` reports). That lets
|
||||
multiple portal viewers join the same lease: one viewer is the controller,
|
||||
later viewers start in observer mode, and any viewer can press **take over**
|
||||
to become the controller — including the prior controller, who stays connected
|
||||
as an observer and can reclaim control the same way. Observer mode is a
|
||||
collaboration UX for trusted shared leases; it relies on the portal noVNC
|
||||
client staying read-only and is not a hostile-client isolation boundary.
|
||||
|
||||
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 viewers=2 observers=1 slots=2
|
||||
portal controller: peter
|
||||
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
|
||||
```
|
||||
|
||||
When a layer is unhealthy, the CLI prints `problem:`, optional `detail:`, and
|
||||
one or more exact `rescue:` commands in the command output, not only in docs.
|
||||
Common problems include `VNC bridge disconnected`, `WebVNC daemon not running`,
|
||||
`waiting for an available WebVNC observer slot`, and `VNC target unreachable`.
|
||||
If the browser portal path looks unhealthy but the target VNC service is
|
||||
reachable, the output also prints the native `crabbox vnc ... --open` fallback
|
||||
command with the same provider/target/network flags.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
`--network tailscale` changes only the SSH endpoint used for the local tunnel.
|
||||
The runner VNC service stays bound to loopback.
|
||||
@ -126,9 +69,8 @@ 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 `WebVNC daemon not running` or `waiting for VNC
|
||||
bridge` until the local command has connected. If you opened the portal first,
|
||||
start:
|
||||
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>
|
||||
@ -136,26 +78,13 @@ 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`.
|
||||
|
||||
The WebVNC toolbar includes clipboard controls. The paste control reads the
|
||||
local browser clipboard, sends it through noVNC, and then sends the target paste
|
||||
shortcut: Command-V for macOS targets, Ctrl-V for Linux and Windows targets.
|
||||
When the remote VNC server publishes clipboard text, the copy-remote control is
|
||||
enabled; click it to write that remote text into the local browser clipboard.
|
||||
Browsers require a user gesture for clipboard writes, so remote-to-local copy is
|
||||
explicit instead of fully automatic.
|
||||
|
||||
## Flags
|
||||
|
||||
Flags:
|
||||
|
||||
```text
|
||||
--id <lease-id-or-slug>
|
||||
--provider hetzner|aws|azure
|
||||
--provider hetzner|aws
|
||||
--target linux|macos|windows
|
||||
--windows-mode normal|wsl2
|
||||
--static-host <host>
|
||||
@ -165,11 +94,10 @@ Flags:
|
||||
--network auto|tailscale|public
|
||||
--local-port <port>
|
||||
--open
|
||||
status
|
||||
reset
|
||||
daemon start
|
||||
daemon status
|
||||
daemon stop
|
||||
--daemon
|
||||
--background
|
||||
--status
|
||||
--stop
|
||||
--reclaim
|
||||
```
|
||||
|
||||
@ -177,7 +105,7 @@ daemon stop
|
||||
|
||||
Limitations:
|
||||
|
||||
- Coordinator-backed Hetzner, AWS, and Azure Linux desktop leases are supported.
|
||||
- Coordinator-backed Hetzner and AWS desktop leases are supported.
|
||||
- Static SSH hosts are intentionally not supported yet because the portal cannot
|
||||
prove that host-managed VNC credentials and prompts are safe to expose.
|
||||
- Blacksmith Testbox still owns its own machine connectivity.
|
||||
@ -189,7 +117,7 @@ Limitations:
|
||||
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/azure desktop leases`
|
||||
`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
|
||||
@ -201,26 +129,12 @@ 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 `WebVNC daemon not running` or `waiting for VNC bridge`
|
||||
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 daemon start --id <lease>
|
||||
--open`, or run `crabbox webvnc reset --id <lease> --open` when stale tabs or
|
||||
session state are likely. If the command is still running, wait for the portal
|
||||
retry or reload the browser tab.
|
||||
|
||||
`waiting for an available WebVNC observer slot`
|
||||
|
||||
The portal is reachable, but all bridge slots are already paired with viewers.
|
||||
Restart the bridge with a current Crabbox CLI so it opens the default backend
|
||||
pool. If the portal still cannot get a slot, 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>`.
|
||||
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
|
||||
|
||||
|
||||
@ -35,7 +35,6 @@ Read when:
|
||||
- [Provider backends](../provider-backends.md): contract reference for backend interfaces and registration.
|
||||
- [Authoring a provider](provider-authoring.md): step-by-step guide to writing a new provider.
|
||||
- [AWS](aws.md): EC2 Linux, Windows, WSL2, EC2 Mac, capacity, AMIs, and security groups.
|
||||
- [Azure](azure.md): Azure Linux/native Windows, shared infra, capacity, and cleanup.
|
||||
- [Hetzner](hetzner.md): Linux-only managed Hetzner behavior, classes, and cleanup.
|
||||
- [Blacksmith Testbox](blacksmith-testbox.md): delegated Testbox backend behavior.
|
||||
- [Daytona](daytona.md): Daytona SDK/toolbox sandbox leases with optional short-lived SSH access.
|
||||
@ -44,7 +43,6 @@ Read when:
|
||||
## Runners and reachability
|
||||
|
||||
- [Tailscale](tailscale.md): optional tailnet reachability for managed Linux leases and static hosts.
|
||||
- [Mediated egress](egress.md): browser/app egress through an operator machine using the Cloudflare Worker mediator.
|
||||
- [Runner bootstrap](runner-bootstrap.md): cloud-init, installed tools, SSH port, and readiness.
|
||||
- [Prebaked runner images](prebaked-images.md): provider-owned image storage and the image/cache/state boundary.
|
||||
- [Image bake runbook](image-bake-runbook.md): exact AWS bake, candidate smoke, promotion, rollback, and cleanup flow.
|
||||
@ -55,7 +53,6 @@ Read when:
|
||||
- [Sync](sync.md): Git file-list manifests, rsync, fingerprints, excludes, guardrails, and sanity checks.
|
||||
- [Actions hydration](actions-hydration.md): let GitHub Actions prepare a runner, then sync local work into that workspace.
|
||||
- [Interactive desktop and VNC](interactive-desktop-vnc.md): VNC hub, support matrix, tunnel model, and QA boundaries.
|
||||
- [Artifacts](artifacts.md): screenshots, video, trimmed GIFs, logs, metadata, templates, and PR publishing.
|
||||
- [Linux VNC](vnc-linux.md), [Windows VNC](vnc-windows.md), [macOS VNC](vnc-macos.md): OS-specific desktop setup and troubleshooting.
|
||||
- [Test results](test-results.md): JUnit summaries attached to recorded runs.
|
||||
- [Cache controls](cache.md): inspect, purge, and warm remote package/build caches.
|
||||
@ -75,7 +72,6 @@ Read when:
|
||||
- [history](../commands/history.md)
|
||||
- [logs](../commands/logs.md)
|
||||
- [results](../commands/results.md)
|
||||
- [artifacts](../commands/artifacts.md)
|
||||
- [cache](../commands/cache.md)
|
||||
- [status](../commands/status.md)
|
||||
- [list](../commands/list.md)
|
||||
|
||||
@ -8,9 +8,9 @@ Read when:
|
||||
|
||||
Actions hydration lets a repository reuse its existing GitHub Actions setup without putting repository-specific setup code in the Crabbox binary.
|
||||
|
||||
Runner registration is currently Linux-only. Brokered Hetzner/AWS/Azure Linux
|
||||
targets work; static macOS/Windows and managed Windows/macOS targets are for
|
||||
direct `crabbox run` loops until platform-specific runner installation is added.
|
||||
Runner registration is currently Linux-only. Brokered Hetzner/AWS Linux targets
|
||||
work; static macOS/Windows and managed AWS Windows/macOS targets are for direct
|
||||
`crabbox run` loops until platform-specific runner installation is added.
|
||||
|
||||
The flow:
|
||||
|
||||
|
||||
@ -1,119 +0,0 @@
|
||||
# Artifacts
|
||||
|
||||
Read when:
|
||||
|
||||
- collecting screenshots, videos, logs, or metadata from a desktop lease;
|
||||
- turning a desktop recording into a trimmed GIF;
|
||||
- publishing QA proof into a GitHub PR;
|
||||
- deciding whether AWS S3 or Cloudflare R2 should host inline assets.
|
||||
|
||||
Crabbox artifacts are a local bundle plus optional hosted URLs. The command is
|
||||
designed for QA handoff: capture the state of a lease, preserve enough metadata
|
||||
to reproduce what happened, and publish a concise before/after/summary comment.
|
||||
|
||||
## Bundle Contract
|
||||
|
||||
`crabbox artifacts collect --id <lease>` writes a directory such as
|
||||
`artifacts/blue-lobster` with:
|
||||
|
||||
- `metadata.json`: Crabbox version, lease id, slug, provider, network, target,
|
||||
run id when provided, and capture time.
|
||||
- `screenshot.png`: a desktop screenshot captured through the managed VNC
|
||||
boundary.
|
||||
- `doctor.txt`: the same desktop/session checks as `crabbox desktop doctor`.
|
||||
- `webvnc-status.json`: bridge/viewer status when the lease is coordinator
|
||||
backed.
|
||||
- `logs.txt` and `run.json`: retained run output and run metadata when
|
||||
`--run <run-id>` is set.
|
||||
- `screen.mp4`, `screen.trimmed.gif`, and `screen.trimmed.mp4` when video/GIF
|
||||
capture is requested.
|
||||
|
||||
Failures keep the rescue-first UX. If the input stack is dead, the VNC bridge
|
||||
is disconnected, the browser did not launch, or screenshot/video capture fails,
|
||||
the command prints a concrete `problem:` plus exact `rescue:` commands before
|
||||
returning. In `--json` mode those hints are kept in `warnings`, stdout remains
|
||||
parseable JSON, and post-start capture failures add an `error` object while
|
||||
still returning a nonzero exit code.
|
||||
|
||||
## Media
|
||||
|
||||
Video capture is intentionally lease-local and Linux-first. The CLI records
|
||||
the X11 desktop with remote `ffmpeg` and streams the MP4 back over SSH. GIF
|
||||
generation then reuses the local motion-trimming logic from `crabbox media
|
||||
preview`: leading/trailing static regions are removed and an optional trimmed
|
||||
MP4 can be emitted beside the GIF.
|
||||
|
||||
Use `desktop launch --fullscreen` only when the artifact should show a
|
||||
browser-only capture. The standard human QA profile remains windowed so panel
|
||||
and window chrome stay visible.
|
||||
|
||||
## Publishing
|
||||
|
||||
GitHub comments cannot directly upload arbitrary local files through the issue
|
||||
comment API. `crabbox artifacts publish --pr <n>` therefore uploads files to a
|
||||
storage backend first, renders Markdown with inline image/GIF links, writes the
|
||||
same body to `published-artifacts.md`, and posts that body with `gh`.
|
||||
|
||||
Supported storage:
|
||||
|
||||
- Brokered coordinator publishing through `crabbox artifacts publish` with no
|
||||
storage flags. The coordinator owns object-store credentials and returns
|
||||
short-lived upload URLs plus final public URLs.
|
||||
- AWS S3 through the `aws` CLI.
|
||||
- Cloudflare R2 through `wrangler r2 object put`.
|
||||
- Local/hosted mode through `--storage local --base-url <url>` when another
|
||||
process already serves the bundle.
|
||||
|
||||
For AWS S3, use either public/custom-domain URLs through `--base-url` or
|
||||
temporary links through `--presign --expires <duration>`. For Cloudflare R2,
|
||||
provide a public bucket/custom-domain `--base-url` when publishing to a PR;
|
||||
without it, the upload can succeed but the PR would only have `r2://` object
|
||||
identifiers, not inline-ready links.
|
||||
|
||||
## Broker Secret Model
|
||||
|
||||
Brokered publishing is intentionally asymmetric. Local users and agents only
|
||||
need normal Crabbox coordinator auth. The coordinator holds the storage keys and
|
||||
uses them to sign one upload request per artifact. Each upload grant includes a
|
||||
signed `content-length`, so the configured size cap is enforced by the storage
|
||||
backend, not only by the request metadata. The broker enforces both a 1 GiB
|
||||
per-file cap and a 5 GiB per-request aggregate cap before minting upload URLs.
|
||||
When users do not pass `--prefix`, hosted publishing adds a unique
|
||||
PR/bundle/timestamp prefix so later artifact bundles cannot overwrite links from
|
||||
earlier QA comments.
|
||||
|
||||
Coordinator artifact vars describe the backend:
|
||||
|
||||
- `CRABBOX_ARTIFACTS_BACKEND`: `s3` or `r2`.
|
||||
- `CRABBOX_ARTIFACTS_BUCKET`: destination bucket.
|
||||
- `CRABBOX_ARTIFACTS_PREFIX`: root object prefix for all brokered uploads.
|
||||
- `CRABBOX_ARTIFACTS_BASE_URL`: public URL prefix for final Markdown links.
|
||||
- `CRABBOX_ARTIFACTS_REGION` and `CRABBOX_ARTIFACTS_ENDPOINT_URL`: S3/R2 signing
|
||||
endpoint details.
|
||||
- `CRABBOX_ARTIFACTS_UPLOAD_EXPIRES_SECONDS`: lifetime for write grants.
|
||||
- `CRABBOX_ARTIFACTS_URL_EXPIRES_SECONDS`: lifetime for signed read URLs when
|
||||
no public base URL is configured.
|
||||
|
||||
Coordinator artifact secrets authorize signing:
|
||||
|
||||
- `CRABBOX_ARTIFACTS_ACCESS_KEY_ID`
|
||||
- `CRABBOX_ARTIFACTS_SECRET_ACCESS_KEY`
|
||||
- `CRABBOX_ARTIFACTS_SESSION_TOKEN` when the backend uses temporary
|
||||
credentials.
|
||||
|
||||
These keys are object-store credentials, not Crabbox provider credentials. They
|
||||
should be scoped to the artifact bucket/prefix and should not grant Worker
|
||||
deployment, Cloudflare account administration, lease creation, or cloud VM
|
||||
provider access. The CLI receives only pre-signed URLs and final asset URLs.
|
||||
|
||||
## Templates
|
||||
|
||||
`crabbox artifacts template openclaw` and `crabbox artifacts template mantis`
|
||||
produce Markdown with:
|
||||
|
||||
- `Summary`
|
||||
- `Before / After`
|
||||
- `Evidence`
|
||||
|
||||
The publish command uses the same layout, so local preview and PR comments stay
|
||||
consistent.
|
||||
@ -26,9 +26,6 @@ crabbox login --no-browser
|
||||
crabbox login --url <url> --token-stdin
|
||||
crabbox whoami
|
||||
crabbox logout
|
||||
crabbox share --id blue-lobster --user friend@example.com
|
||||
crabbox share --id blue-lobster --org
|
||||
crabbox unshare --id blue-lobster --user friend@example.com
|
||||
```
|
||||
|
||||
Trusted operator controls:
|
||||
@ -44,23 +41,15 @@ Admin commands require the separate admin token. GitHub browser-login tokens can
|
||||
Normal user tokens are owner/org scoped:
|
||||
|
||||
```text
|
||||
GET /v1/leases own and shared leases only
|
||||
GET /v1/leases/{id-or-slug} exact ID and slug lookup must be visible
|
||||
POST /v1/leases/{id}/heartbeat own or shared leases
|
||||
PUT/DELETE /v1/leases/{id}/share owner, manage share, or admin only
|
||||
POST /v1/leases/{id}/release owner, manage share, or admin only
|
||||
GET /v1/leases own leases only
|
||||
GET /v1/leases/{id-or-slug} exact ID and slug lookup must match owner/org
|
||||
POST /v1/leases/{id}/heartbeat own leases only
|
||||
POST /v1/leases/{id}/release own leases only
|
||||
GET /v1/runs and logs own runs only
|
||||
GET /v1/usage own usage only
|
||||
GET /v1/pool admin token only
|
||||
```
|
||||
|
||||
Lease sharing grants coordinator and portal access without distributing the
|
||||
shared bearer token or admin token. A `use` share can see the lease and open
|
||||
visible portal bridges such as WebVNC/code. A `manage` share can also change
|
||||
sharing and stop the lease. `--org` shares with authenticated users whose org
|
||||
matches the lease org. SSH-based CLI use still requires a local private key
|
||||
accepted by the runner; sharing does not copy SSH private keys between users.
|
||||
|
||||
Do not distribute the shared token or admin token to untrusted users. Keep the admin token narrower and more closely held than the shared automation token.
|
||||
|
||||
Related docs:
|
||||
|
||||
@ -1,133 +0,0 @@
|
||||
# Azure
|
||||
|
||||
Read when:
|
||||
|
||||
- choosing Azure as the Crabbox provider;
|
||||
- debugging Azure VM capacity, quotas, images, or SSH readiness;
|
||||
- changing Azure provisioning code in the CLI.
|
||||
|
||||
Azure is a managed provider for Linux and native Windows SSH leases. It
|
||||
creates VMs in a shared resource group, tags them with Crabbox lease
|
||||
metadata, and bootstraps the normal SSH/sync contract through cloud-init
|
||||
on Linux or Custom Script Extension on Windows. It works in direct mode with
|
||||
local Azure auth and in brokered mode through Worker-owned service principal
|
||||
secrets.
|
||||
|
||||
## Targets
|
||||
|
||||
| Target | Managed | Notes |
|
||||
| --- | --- | --- |
|
||||
| Linux | Yes | Cloud-init bootstrap, SSH, rsync, optional desktop/browser/code. |
|
||||
| Windows | Yes | Native Windows SSH/sync/run only. No Azure desktop/browser/WSL2. |
|
||||
| macOS | No | Azure does not offer managed macOS; use AWS EC2 Mac or static SSH. |
|
||||
|
||||
Examples:
|
||||
|
||||
```sh
|
||||
crabbox warmup --provider azure --class beast
|
||||
crabbox run --provider azure --class standard -- pnpm test
|
||||
crabbox warmup --provider azure --target windows --class standard
|
||||
crabbox warmup --provider azure --desktop --browser
|
||||
crabbox vnc --id blue-lobster --open
|
||||
```
|
||||
|
||||
## Classes
|
||||
|
||||
```text
|
||||
standard Standard_D32ads_v6, Standard_D32ds_v6, Standard_F32s_v2, then D/F 16-vCPU fallbacks
|
||||
fast Standard_D64ads_v6, Standard_D64ds_v6, Standard_F64s_v2, then D/F 48-vCPU and 32-vCPU fallbacks
|
||||
large Standard_D96ads_v6, Standard_D96ds_v6, then D/F 64-vCPU and 48-vCPU fallbacks
|
||||
beast Standard_D192ds_v6, Standard_D128ds_v6, then D/F 96-vCPU and 64-vCPU fallbacks
|
||||
```
|
||||
|
||||
Native Windows uses the smaller AWS Windows class scale:
|
||||
|
||||
```text
|
||||
standard Standard_D2ads_v6, Standard_D2ds_v6, Standard_D2ads_v5, Standard_D2ds_v5, then Standard_D2as_v6
|
||||
fast Standard_D4ads_v6, Standard_D4ds_v6, Standard_D4ads_v5, Standard_D4ds_v5, then Standard_D4as_v6
|
||||
large Standard_D8ads_v6, Standard_D8ds_v6, Standard_D8ads_v5, Standard_D8ds_v5, then Standard_D8as_v6
|
||||
beast Standard_D16ads_v6, Standard_D16ds_v6, Standard_D16ads_v5, Standard_D16ds_v5, then Standard_D8ads_v6
|
||||
```
|
||||
|
||||
Crabbox falls back through the candidate list when Azure rejects a SKU for
|
||||
capacity or quota. Explicit `--type` is exact and fails clearly when the
|
||||
SKU cannot be created. Spot leases fall back to on-demand when
|
||||
`capacity.fallback` starts with `on-demand`.
|
||||
|
||||
Default Azure Linux class candidates mirror the vCPU scale of the AWS Linux
|
||||
class table. Default Azure native Windows candidates mirror the AWS native
|
||||
Windows class table. Crabbox asks Azure Resource SKUs whether the selected VM
|
||||
supports ephemeral OS disks; ephemeral-capable sizes use local OS disks,
|
||||
while exact `--type` requests for non-ephemeral sizes use managed
|
||||
`StandardSSD_LRS` OS disks.
|
||||
|
||||
## Direct Auth And Env
|
||||
|
||||
Service principal env vars consumed by `DefaultAzureCredential`:
|
||||
|
||||
```text
|
||||
AZURE_TENANT_ID
|
||||
AZURE_CLIENT_ID
|
||||
AZURE_CLIENT_SECRET
|
||||
AZURE_SUBSCRIPTION_ID
|
||||
```
|
||||
|
||||
Crabbox-specific overrides:
|
||||
|
||||
```text
|
||||
CRABBOX_AZURE_SUBSCRIPTION_ID
|
||||
CRABBOX_AZURE_TENANT_ID
|
||||
CRABBOX_AZURE_CLIENT_ID
|
||||
CRABBOX_AZURE_LOCATION
|
||||
CRABBOX_AZURE_RESOURCE_GROUP
|
||||
CRABBOX_AZURE_IMAGE
|
||||
CRABBOX_AZURE_VNET
|
||||
CRABBOX_AZURE_SUBNET
|
||||
CRABBOX_AZURE_NSG
|
||||
CRABBOX_AZURE_SSH_CIDRS
|
||||
```
|
||||
|
||||
The service principal needs the
|
||||
[Contributor](https://learn.microsoft.com/azure/role-based-access-control/built-in-roles#contributor)
|
||||
role on the target resource group (or subscription, if you want Crabbox to
|
||||
create the resource group on first use).
|
||||
|
||||
Brokered Azure uses `AZURE_TENANT_ID`, `AZURE_CLIENT_ID`,
|
||||
`AZURE_CLIENT_SECRET`, and `AZURE_SUBSCRIPTION_ID` on the Worker. Operators
|
||||
own the shared infra settings through `CRABBOX_AZURE_*`. Lease requests may
|
||||
override only `azureLocation` and `azureImage`.
|
||||
|
||||
## Shared Infra
|
||||
|
||||
The first acquire in an empty subscription creates:
|
||||
|
||||
- a resource group (default `crabbox-leases`);
|
||||
- a virtual network and subnet (`10.42.0.0/16` / `10.42.0.0/24`);
|
||||
- a network security group with SSH rules derived from `azure.sshCIDRs`,
|
||||
the configured SSH port, and fallback ports.
|
||||
|
||||
These resources are created with `createOrUpdate` and reused across leases.
|
||||
Per-lease provisioning creates only the public IP, NIC, VM, and OS disk.
|
||||
|
||||
Azure pricing is not hardcoded. Use `CRABBOX_COST_RATES_JSON` for exact
|
||||
Azure cost guardrails.
|
||||
|
||||
## Desktop
|
||||
|
||||
Azure desktop leases use the standard Linux VNC path: Xvfb, a lightweight
|
||||
desktop session, x11vnc bound to `127.0.0.1:5900`, and an SSH local tunnel
|
||||
created by `crabbox vnc`. Azure native Windows currently supports SSH, sync,
|
||||
and run only. Use AWS for managed Windows desktop or Windows WSL2.
|
||||
|
||||
## Cleanup
|
||||
|
||||
Direct cleanup is best-effort through Crabbox lease tags. `crabbox cleanup
|
||||
--provider azure` enumerates VMs in the configured resource group, skips
|
||||
kept or unexpired leases, and cascade-deletes expired ones. The shared
|
||||
resource group, vnet, subnet, and NSG are preserved.
|
||||
|
||||
Related docs:
|
||||
|
||||
- [Providers](providers.md)
|
||||
- [Linux VNC](vnc-linux.md)
|
||||
- [cleanup command](../commands/cleanup.md)
|
||||
@ -116,12 +116,6 @@ Test runners that read `BROWSER` or `CHROME_BIN` (Vitest, Playwright, etc.)
|
||||
work without extra plumbing. If a browser is requested but no binary is
|
||||
found, the run aborts before the command starts.
|
||||
|
||||
For browser QA where the remote service is sensitive to source IP (Discord
|
||||
login, Slack workspace bootstrap, regional CDN behavior), pair `--browser`
|
||||
with [mediated egress](egress.md). `crabbox egress start` opens a lease-local
|
||||
proxy that exits to the internet through the operator machine, and `crabbox
|
||||
desktop launch --egress <profile>` passes that proxy to Chrome.
|
||||
|
||||
## Code
|
||||
|
||||
`--code` provisions code-server on managed Linux leases:
|
||||
@ -193,7 +187,5 @@ Related docs:
|
||||
- [vnc command](../commands/vnc.md)
|
||||
- [webvnc command](../commands/webvnc.md)
|
||||
- [code command](../commands/code.md)
|
||||
- [egress command](../commands/egress.md)
|
||||
- [Interactive desktop and VNC](interactive-desktop-vnc.md)
|
||||
- [Mediated egress](egress.md)
|
||||
- [Browser portal](portal.md)
|
||||
|
||||
@ -251,37 +251,6 @@ tailscale:
|
||||
exitNodeAllowLanAccess: false
|
||||
```
|
||||
|
||||
### Mediated Egress
|
||||
|
||||
Mediated egress is a browser/app QA feature where a lease exits to the internet
|
||||
through an operator machine over the Cloudflare Worker mediator. The first
|
||||
implementation is opt-in and profile-based.
|
||||
|
||||
```yaml
|
||||
egress:
|
||||
enabled: false
|
||||
listen: 127.0.0.1:3128
|
||||
browserProxy: true
|
||||
profiles:
|
||||
discord:
|
||||
allow:
|
||||
- discord.com
|
||||
- "*.discord.com"
|
||||
- discordcdn.com
|
||||
- "*.discordcdn.com"
|
||||
slack:
|
||||
allow:
|
||||
- slack.com
|
||||
- "*.slack.com"
|
||||
- slack-edge.com
|
||||
- "*.slack-edge.com"
|
||||
```
|
||||
|
||||
See [Mediated egress](egress.md) for the design, security model, and command
|
||||
surface. The current CLI ships built-in `discord` and `slack` profiles; the
|
||||
YAML shape is the intended config surface for making those profiles
|
||||
user-configurable.
|
||||
|
||||
## Profiles
|
||||
|
||||
Profiles are named bundles of config that get applied as a layer on top of
|
||||
|
||||
@ -14,7 +14,6 @@ Responsibilities:
|
||||
- serialize fleet state in one Durable Object;
|
||||
- create, heartbeat, release, expire, and look up leases;
|
||||
- own provider credentials;
|
||||
- own artifact storage credentials and mint scoped artifact upload URLs;
|
||||
- create and delete provider resources;
|
||||
- list the pool;
|
||||
- enforce cost and active-lease guardrails;
|
||||
@ -36,7 +35,6 @@ POST /v1/runs
|
||||
GET /v1/runs/{run-id}
|
||||
GET /v1/runs/{run-id}/logs
|
||||
POST /v1/runs/{run-id}/finish
|
||||
POST /v1/artifacts/uploads
|
||||
GET /v1/runners
|
||||
POST /v1/runners/sync
|
||||
GET /v1/usage
|
||||
|
||||
@ -1,489 +0,0 @@
|
||||
# Mediated Egress
|
||||
|
||||
Read when:
|
||||
|
||||
- browser or app QA needs a lease to use the same public internet path as an
|
||||
operator workstation;
|
||||
- adding the `crabbox egress` command family;
|
||||
- comparing mediated browser/app egress with Tailscale exit nodes, Cloudflare
|
||||
Tunnel, or full-VM routing;
|
||||
- wiring Mantis-style visual QA for Discord, Slack, or other web apps that
|
||||
are sensitive to source IP, browser login, or regional routing.
|
||||
|
||||
Status: implemented as a CLI-first bridge. The shipped slice supports
|
||||
`egress start`, `host`, `client`, `status`, and browser launches with
|
||||
`desktop launch --egress`.
|
||||
|
||||
## Goal
|
||||
|
||||
Some QA scenarios need the runner to look like it is browsing from the same
|
||||
network as the human or agent driving the test. Discord and Slack are good
|
||||
examples: login, bot verification, abuse heuristics, and regional behavior can
|
||||
change when the browser comes from a fresh cloud IP.
|
||||
|
||||
The first Crabbox egress goal is:
|
||||
|
||||
```text
|
||||
Chrome or an app inside a Crabbox lease
|
||||
uses a local proxy inside the lease
|
||||
and exits to the internet from the operator machine running Crabbox.
|
||||
```
|
||||
|
||||
This is intentionally per-app/per-process egress. It should make browser QA and
|
||||
Mantis scenarios reproducible without changing every route on the VM. Full
|
||||
machine routing can be added later through a Linux exit node or a dedicated
|
||||
gateway when a scenario truly needs all traffic to move.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
Mediated egress is not:
|
||||
|
||||
- a public open proxy;
|
||||
- a replacement for provider firewalls or SSH access controls;
|
||||
- a transparent VM-wide VPN in the first implementation;
|
||||
- a way for the Cloudflare Worker itself to become the internet egress point;
|
||||
- a place to store browser login state, app credentials, or provider secrets.
|
||||
|
||||
The Cloudflare Worker is the mediator. The operator machine is the egress point.
|
||||
|
||||
## Existing Pieces
|
||||
|
||||
Crabbox already has two bridge models that are close to the desired shape:
|
||||
|
||||
- WebVNC: `crabbox webvnc` keeps an SSH tunnel to the lease VNC service and
|
||||
connects a local bridge process to the coordinator with a one-use ticket. The
|
||||
browser portal then talks to that bridge through the Worker Durable Object.
|
||||
- Code portal: `crabbox code` starts a code-server process on the lease and
|
||||
proxies HTTP/WebSocket traffic through a ticketed coordinator bridge.
|
||||
|
||||
Those bridges establish the important boundaries:
|
||||
|
||||
- the Worker owns authenticated routing, tickets, status, and cleanup;
|
||||
- bridge agents connect outbound to the Worker;
|
||||
- each bridge is tied to one lease and short-lived ticket material;
|
||||
- the portal is not allowed to reach private runner services by itself.
|
||||
|
||||
Mediated egress should reuse that model instead of introducing an unrelated
|
||||
proxy service.
|
||||
|
||||
## Architecture
|
||||
|
||||
Mediated egress has two long-running agents and one Worker Durable Object
|
||||
session.
|
||||
|
||||
```text
|
||||
Cloudflare Worker / Fleet Durable Object
|
||||
+----------------------------------------+
|
||||
| ticket auth, socket pairing, status, |
|
||||
| allowlist metadata, cleanup, counters |
|
||||
+-------------------+--------------------+
|
||||
|
|
||||
paired WebSocket streams over HTTPS
|
||||
|
|
||||
+------------------------------+------------------------------+
|
||||
| |
|
||||
+-------v-----------------+ +-------------v------+
|
||||
| lease egress client | | host egress agent |
|
||||
| runs inside the lease | | runs on operator |
|
||||
| listens on 127.0.0.1 | | machine / gateway |
|
||||
+-----------+-------------+ +-------------+------+
|
||||
| |
|
||||
| HTTP CONNECT / proxy | TCP
|
||||
| |
|
||||
+-----v------+ +------v-----+
|
||||
| Chrome / | | internet |
|
||||
| Slack app | | from host |
|
||||
+------------+ +------------+
|
||||
```
|
||||
|
||||
The lease side exposes a loopback proxy such as `127.0.0.1:3128`. Chrome or a
|
||||
desktop app is launched with:
|
||||
|
||||
```sh
|
||||
--proxy-server=http://127.0.0.1:3128
|
||||
```
|
||||
|
||||
The host side opens the real outbound TCP connections. Remote services see the
|
||||
operator machine's internet path, not the cloud provider's default egress IP.
|
||||
|
||||
## Setup And Traffic Flow
|
||||
|
||||
```text
|
||||
Operator CLI
|
||||
|
|
||||
| crabbox egress start --id blue-lobster --profile discord --daemon
|
||||
v
|
||||
Resolve lease through coordinator
|
||||
|
|
||||
+-- if local coordinator is Access-protected:
|
||||
| use --coordinator https://crabbox.openclaw.ai
|
||||
| so the lease can connect without private Access credentials
|
||||
|
|
||||
v
|
||||
Create shared egress session
|
||||
|
|
||||
+--> create client ticket
|
||||
| |
|
||||
| v
|
||||
| SSH to lease
|
||||
| |
|
||||
| v
|
||||
| install/run crabbox egress client
|
||||
| |
|
||||
| v
|
||||
| listen on 127.0.0.1:3128 inside lease
|
||||
|
|
||||
+--> create host ticket
|
||||
|
|
||||
v
|
||||
run local crabbox egress host
|
||||
|
|
||||
v
|
||||
connect outbound to coordinator
|
||||
|
||||
Runtime browser request
|
||||
|
|
||||
| Chrome --proxy-server=http://127.0.0.1:3128
|
||||
v
|
||||
Lease-local proxy
|
||||
|
|
||||
| HTTP CONNECT host:443
|
||||
v
|
||||
Cloudflare Worker / Fleet Durable Object
|
||||
|
|
||||
| pair lease client + host agent by leaseID/sessionID
|
||||
v
|
||||
Host egress agent on operator machine
|
||||
|
|
||||
| enforce allowlist, open TCP connection
|
||||
v
|
||||
Internet service sees operator public IP
|
||||
```
|
||||
|
||||
Teardown runs in the opposite direction: `crabbox egress stop` stops the local
|
||||
host daemon and asks the lease to kill the remote client; releasing a lease also
|
||||
clears coordinator-side egress sockets and session status.
|
||||
|
||||
## Command Shape
|
||||
|
||||
The CLI is explicit enough for debugging but ergonomic for the common
|
||||
desktop-browser case.
|
||||
|
||||
Low-level commands:
|
||||
|
||||
```sh
|
||||
crabbox egress host --id blue-lobster --profile discord
|
||||
crabbox egress client --id blue-lobster --listen 127.0.0.1:3128
|
||||
crabbox egress status --id blue-lobster
|
||||
crabbox egress stop --id blue-lobster
|
||||
```
|
||||
|
||||
Operator-friendly orchestration:
|
||||
|
||||
```sh
|
||||
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
|
||||
```
|
||||
|
||||
`egress start`:
|
||||
|
||||
1. resolve the lease;
|
||||
2. create a host ticket and start the host bridge locally;
|
||||
3. create a client ticket and start the lease-side proxy over SSH;
|
||||
4. write the active proxy endpoint into lease-local state;
|
||||
5. print status and cleanup commands.
|
||||
|
||||
Today the orchestrated `egress start` path is Linux-only because it installs a
|
||||
Linux helper and starts it with POSIX shell commands. Non-Linux targets should
|
||||
use manual target-specific setup until Crabbox grows native helper install
|
||||
commands for those operating systems. If your coordinator needs Cloudflare
|
||||
Access credentials, use a public coordinator route for `egress start`, or run
|
||||
the low-level pieces manually with an explicit secret-handling plan.
|
||||
|
||||
`desktop launch --egress <profile>` passes the configured lease-local proxy to
|
||||
the browser command. Start `egress start` first so something is listening on the
|
||||
lease proxy port.
|
||||
|
||||
## Worker API
|
||||
|
||||
The coordinator exposes ticketed routes next to the WebVNC and code bridge
|
||||
routes:
|
||||
|
||||
```text
|
||||
POST /v1/leases/{leaseID}/egress/ticket
|
||||
GET /v1/leases/{leaseID}/egress/host?ticket=...
|
||||
GET /v1/leases/{leaseID}/egress/client?ticket=...
|
||||
GET /v1/leases/{leaseID}/egress/status
|
||||
```
|
||||
|
||||
The ticket request should include:
|
||||
|
||||
```json
|
||||
{
|
||||
"role": "host",
|
||||
"profile": "discord",
|
||||
"allow": ["discord.com", "*.discord.com"],
|
||||
"sessionID": "egress_..."
|
||||
}
|
||||
```
|
||||
|
||||
The Worker tracks enough state to answer status and clean up stale bridges:
|
||||
|
||||
```text
|
||||
leaseID
|
||||
sessionID
|
||||
owner/org
|
||||
profile
|
||||
allowlist
|
||||
hostConnected
|
||||
clientConnected
|
||||
activeConnections
|
||||
bytesIn
|
||||
bytesOut
|
||||
lastHostnames
|
||||
createdAt
|
||||
lastSeenAt
|
||||
expiresAt
|
||||
```
|
||||
|
||||
Like WebVNC/code, agent WebSocket upgrades should be accepted only after a
|
||||
one-use ticket is consumed by the Fleet Durable Object. Cloudflare Access
|
||||
service-token headers may get the request through the edge, but Crabbox ticket
|
||||
auth still owns the bridge authorization.
|
||||
|
||||
## Stream Protocol
|
||||
|
||||
WebVNC can forward one raw byte stream. Egress needs many concurrent TCP
|
||||
connections because a browser opens several sockets at once.
|
||||
|
||||
The bridge protocol needs multiplexed streams:
|
||||
|
||||
```text
|
||||
hello { role, sessionID, protocolVersion }
|
||||
open { connId, host, port }
|
||||
open_ok { connId }
|
||||
data { connId, bytes }
|
||||
close { connId }
|
||||
error { connId, message }
|
||||
stats { activeConnections, bytesIn, bytesOut }
|
||||
```
|
||||
|
||||
The lease egress client parses HTTP proxy requests from Chrome. For `CONNECT
|
||||
host:port`, it asks the host agent to open a TCP connection. For plain HTTP
|
||||
absolute-form requests, it can either proxy them directly or translate them to
|
||||
a stream to port 80.
|
||||
|
||||
The first implementation may use JSON control frames and base64 data chunks for
|
||||
simplicity. The protocol should reserve a version field so a later binary frame
|
||||
format can avoid base64 overhead without changing the CLI surface.
|
||||
|
||||
## Security Model
|
||||
|
||||
Mediated egress must default closed.
|
||||
|
||||
Required guardrails:
|
||||
|
||||
- no listener bound to a public interface;
|
||||
- one-use, short-lived tickets bound to lease, owner/org, role, and session;
|
||||
- explicit domain allowlist or named profile;
|
||||
- idle timeout and lease TTL cleanup;
|
||||
- bounded active connections per session;
|
||||
- bounded per-frame size;
|
||||
- hostname logging only, not URLs or payload;
|
||||
- no proxy passwords, tickets, or credentials in logs;
|
||||
- host agent refuses destinations outside the allowlist;
|
||||
- session closes when either side disconnects for longer than a short grace
|
||||
period.
|
||||
|
||||
The host agent is powerful because it opens internet connections from the
|
||||
operator network. It should show a clear startup summary before connecting:
|
||||
|
||||
```text
|
||||
lease: blue-lobster
|
||||
profile: discord
|
||||
allowed: discord.com, *.discord.com, discordcdn.com, *.discordcdn.com
|
||||
listening: none public; outbound websocket only
|
||||
```
|
||||
|
||||
## Profiles
|
||||
|
||||
Profiles keep common browser QA scenarios repeatable without turning egress
|
||||
into a blanket tunnel.
|
||||
|
||||
Intended config shape:
|
||||
|
||||
```yaml
|
||||
egress:
|
||||
enabled: false
|
||||
listen: 127.0.0.1:3128
|
||||
browserProxy: true
|
||||
profiles:
|
||||
discord:
|
||||
allow:
|
||||
- discord.com
|
||||
- "*.discord.com"
|
||||
- discordcdn.com
|
||||
- "*.discordcdn.com"
|
||||
- hcaptcha.com
|
||||
- "*.hcaptcha.com"
|
||||
slack:
|
||||
allow:
|
||||
- slack.com
|
||||
- "*.slack.com"
|
||||
- slack-edge.com
|
||||
- "*.slack-edge.com"
|
||||
```
|
||||
|
||||
Profiles should be merged like other config: flags over env over repo config
|
||||
over user config over defaults. Repo config can define scenario profiles; user
|
||||
config can define local preferences such as the default listen address.
|
||||
|
||||
## Browser And Desktop Integration
|
||||
|
||||
`--browser` leases already install a browser wrapper exposed through `BROWSER`
|
||||
and `CHROME_BIN`. Egress should integrate at that seam.
|
||||
|
||||
Planned behavior:
|
||||
|
||||
- `crabbox egress start` launches the lease-local proxy at
|
||||
`127.0.0.1:3128` by default;
|
||||
- `crabbox desktop launch --egress <profile>` passes
|
||||
`--proxy-server=http://127.0.0.1:<port>` when launching Chrome/Chromium;
|
||||
- a later `crabbox run --egress <profile>` may opt command processes into
|
||||
`HTTP_PROXY`, `HTTPS_PROXY`, and `ALL_PROXY`, but should never do this by
|
||||
default for every run.
|
||||
|
||||
This keeps browser QA easy while avoiding surprising build or package-manager
|
||||
traffic through a workstation.
|
||||
|
||||
## Portal Integration
|
||||
|
||||
The portal lease detail page shows egress status when a session exists:
|
||||
|
||||
- profile and allowlist;
|
||||
- host/client connected state;
|
||||
- copyable start/status/stop commands.
|
||||
|
||||
The portal should not expose raw proxy URLs or ticket values. It should treat
|
||||
egress like WebVNC/code: a bridge that exists only while local agents are
|
||||
running.
|
||||
|
||||
Connection counts, byte counters, and recent hostnames are still CLI/API-only
|
||||
follow-ups once the bridge reports structured runtime stats.
|
||||
|
||||
## Comparison With Alternatives
|
||||
|
||||
### Tailscale Exit Node
|
||||
|
||||
A Tailscale exit node can route the whole VM through another machine. That is
|
||||
useful when every process must share the same egress path. It is also more
|
||||
fragile: OS forwarding, NAT, ACLs, and route approval all have to line up.
|
||||
|
||||
Use Tailscale exit nodes later for full-machine scenarios. Use mediated egress
|
||||
first for browser/app QA.
|
||||
|
||||
### Cloudflare Tunnel TCP
|
||||
|
||||
A named Cloudflare Tunnel plus Access can expose private TCP services without a
|
||||
public listener. It is useful as an operational building block, but it still
|
||||
needs host and lease processes plus lifecycle management. Keeping the first
|
||||
implementation inside the existing Worker/Durable Object bridge gives Crabbox
|
||||
one auth, status, and cleanup model.
|
||||
|
||||
### Cloudflare Worker As Egress
|
||||
|
||||
Workers should not be the source of browser internet traffic for this feature.
|
||||
The goal is not "use Cloudflare's IP"; it is "use the operator machine's
|
||||
internet". The Worker mediates the two sides.
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Phase 1: CLI-Only Mediated Proxy
|
||||
|
||||
Done:
|
||||
|
||||
- egress ticket and status routes in the Fleet Durable Object;
|
||||
- host/client WebSocket bridge attachments;
|
||||
- multiplexed stream protocol with connection IDs;
|
||||
- `crabbox egress host`, `client`, `start`, `status`, and `stop`;
|
||||
- domain allowlist enforcement on the host side;
|
||||
- tests for ticket use, allowlist rejection, request parsing, and status
|
||||
reporting.
|
||||
|
||||
### Phase 2: Browser Wiring
|
||||
|
||||
- Add `desktop launch --egress <profile>`. Done.
|
||||
- Add optional browser wrapper support for `CRABBOX_BROWSER_PROXY_SERVER`.
|
||||
- Add lease-local egress state beyond the active proxy port.
|
||||
- Add a live smoke that launches a browser through the proxy and proves the
|
||||
observed public IP matches the host agent path.
|
||||
|
||||
### Phase 3: Portal And Daemon UX
|
||||
|
||||
Done:
|
||||
|
||||
- portal egress status on the lease detail page;
|
||||
- daemon supervisor behavior matching WebVNC;
|
||||
- duplicate-daemon replacement and cleanup;
|
||||
- clearer cleanup on lease stop/expiry.
|
||||
|
||||
Remaining:
|
||||
|
||||
- Add docs and examples for Discord and Slack QA.
|
||||
|
||||
### Phase 4: Full-Machine Options
|
||||
|
||||
- Keep mediated per-app egress as the default.
|
||||
- Add a separate full-route mode only when the target is a suitable Linux
|
||||
gateway or a confirmed Tailscale exit node.
|
||||
- Document full-route mode as higher-risk and provider/OS dependent.
|
||||
|
||||
## Verification
|
||||
|
||||
Useful proof for the first implementation:
|
||||
|
||||
```sh
|
||||
crabbox warmup --provider hetzner --desktop --browser
|
||||
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
|
||||
```
|
||||
|
||||
Expected evidence:
|
||||
|
||||
- `egress status` reports host and client connected;
|
||||
- a browser IP check shows the host-side egress IP;
|
||||
- Discord loads inside the WebVNC desktop;
|
||||
- the host agent logs only allowed hostnames and byte counters;
|
||||
- stopping the lease tears down the bridge and local proxy.
|
||||
|
||||
## Source Map
|
||||
|
||||
Planned implementation files:
|
||||
|
||||
- CLI command router: `internal/cli/cli_kong.go`
|
||||
- egress command implementation: `internal/cli/egress.go`
|
||||
- coordinator client ticket/status calls: `internal/cli/coordinator.go`
|
||||
- desktop/browser launch integration: `internal/cli/desktop.go`
|
||||
- browser wrapper bootstrap: `internal/cli/bootstrap.go`, `worker/src/bootstrap.ts`
|
||||
- Worker top-level WebSocket routing: `worker/src/index.ts`
|
||||
- Fleet Durable Object bridge state and routes: `worker/src/fleet.ts`
|
||||
- Worker request/record types: `worker/src/types.ts`
|
||||
- portal lease detail status: `worker/src/portal.ts`
|
||||
|
||||
Related docs:
|
||||
|
||||
- [Interactive desktop and VNC](interactive-desktop-vnc.md)
|
||||
- [Broker auth and routing](broker-auth-routing.md)
|
||||
- [Browser portal](portal.md)
|
||||
- [Tailscale](tailscale.md)
|
||||
- [Configuration](configuration.md)
|
||||
@ -4,9 +4,6 @@ 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.
|
||||
|
||||
@ -20,10 +17,8 @@ boundary.
|
||||
|
||||
```sh
|
||||
crabbox warmup --desktop --browser
|
||||
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 webvnc --id blue-lobster --open
|
||||
crabbox screenshot --id blue-lobster --output desktop.png
|
||||
```
|
||||
|
||||
@ -69,10 +64,8 @@ Scenario systems such as Mantis own:
|
||||
| --- | --- | --- | --- |
|
||||
| Linux on Hetzner | Yes | Xvfb/XFCE/x11vnc over SSH tunnel | [Linux VNC](vnc-linux.md) |
|
||||
| Linux on AWS | Yes | Xvfb/XFCE/x11vnc over SSH tunnel | [Linux VNC](vnc-linux.md) |
|
||||
| Linux on Azure | Yes | Xvfb/XFCE/x11vnc over SSH tunnel | [Linux VNC](vnc-linux.md) |
|
||||
| AWS Windows | Yes | TightVNC over SSH tunnel | [Windows VNC](vnc-windows.md) |
|
||||
| AWS EC2 Mac | Yes | Screen Sharing/VNC over SSH tunnel | [macOS VNC](vnc-macos.md) |
|
||||
| Azure Windows | No | SSH/sync/run only | [Azure](azure.md) |
|
||||
| Static Linux | Host-managed | Existing loopback VNC service | [Linux VNC](vnc-linux.md) |
|
||||
| Static macOS | Host-managed | Existing Screen Sharing/VNC | [macOS VNC](vnc-macos.md) |
|
||||
| Static Windows | Host-managed | Existing VNC service | [Windows VNC](vnc-windows.md) |
|
||||
@ -80,18 +73,7 @@ Scenario systems such as Mantis own:
|
||||
|
||||
## Commands
|
||||
|
||||
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:
|
||||
Use `crabbox vnc` for a native VNC client:
|
||||
|
||||
```sh
|
||||
crabbox vnc --id blue-lobster
|
||||
@ -99,41 +81,24 @@ 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
|
||||
bundled noVNC from the authenticated portal. The portal does not connect to the
|
||||
runner by itself; the local bridge must keep running.
|
||||
|
||||
WebVNC supports collaborative viewing. The local bridge keeps a warm pool of
|
||||
backend VNC sessions (default 4 slots), the first browser viewer controls the
|
||||
lease, and additional viewers join as read-only observers. Any viewer — a new
|
||||
observer or the prior controller — can press **take over** to become the
|
||||
controller; whoever loses control stays connected as an observer and sees who
|
||||
took over. Observer mode is intended for trusted shared leases; it is not a
|
||||
hostile-client security boundary.
|
||||
|
||||
The portal toolbar supports explicit clipboard exchange. Paste reads the local
|
||||
browser clipboard, forwards it to the remote VNC server, and sends the target
|
||||
paste shortcut. Copy-remote is enabled after the remote server publishes
|
||||
clipboard text and then writes that text to the local browser clipboard on
|
||||
click; browsers generally block fully automatic clipboard writes without a user
|
||||
gesture.
|
||||
|
||||
Use `crabbox screenshot` when you need a PNG without taking over the session:
|
||||
|
||||
```sh
|
||||
crabbox screenshot --id blue-lobster --output desktop.png
|
||||
```
|
||||
|
||||
Use `crabbox artifacts` when QA needs a durable proof bundle instead of a
|
||||
single screenshot:
|
||||
|
||||
```sh
|
||||
crabbox artifacts collect --id blue-lobster --all --output artifacts/blue-lobster
|
||||
crabbox artifacts publish --dir artifacts/blue-lobster --pr 123 --storage s3 --bucket qa-artifacts
|
||||
```
|
||||
|
||||
Use `crabbox desktop launch` to start a browser or app inside the visible
|
||||
session without keeping the SSH command attached:
|
||||
|
||||
@ -146,36 +111,6 @@ 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.
|
||||
|
||||
Failure output is designed for rescue-first debugging. When a desktop command
|
||||
cannot prove the expected state, Crabbox prints the failed layer as
|
||||
`problem: browser not launched`, `problem: input stack dead`, `problem: VNC
|
||||
bridge disconnected`, `problem: WebVNC daemon not running`, or similar, followed
|
||||
by an exact `rescue:` command. WebVNC status/reset also prints the exact native
|
||||
`crabbox vnc ... --open` fallback when the native viewer is the better next
|
||||
step.
|
||||
|
||||
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:
|
||||
@ -187,22 +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.
|
||||
- WebVNC browser websockets are paired with local bridge backend sessions
|
||||
inside the coordinator Durable Object. One viewer is the controller; other
|
||||
viewers are observers until they press **take over**. If a browser view
|
||||
disconnects, only its paired backend session is reset and the local command
|
||||
reconnects a fresh bridge slot for the next portal retry.
|
||||
- `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.
|
||||
- WebVNC and desktop commands print rescue commands inline when the bridge,
|
||||
viewer, browser launch, VNC target, or input stack fails, so operators do not
|
||||
need to dig through troubleshooting docs during a demo.
|
||||
- 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
|
||||
@ -242,6 +165,4 @@ often machine- and user-encrypted.
|
||||
- [AWS](aws.md): AWS target matrix, capacity, AMIs, and EC2 Mac host requirements.
|
||||
- [Hetzner](hetzner.md): Linux-only managed Hetzner behavior.
|
||||
- [Blacksmith Testbox](blacksmith-testbox.md): delegated Testbox behavior and why VNC is not a Crabbox feature there yet.
|
||||
- [vnc command](../commands/vnc.md), [webvnc command](../commands/webvnc.md), [screenshot command](../commands/screenshot.md), [desktop command](../commands/desktop.md), [artifacts command](../commands/artifacts.md), [egress command](../commands/egress.md).
|
||||
- [Mediated egress](egress.md): per-app browser/app egress through the operator
|
||||
machine for Discord, Slack, and similar source-IP-sensitive QA.
|
||||
- [vnc command](../commands/vnc.md), [webvnc command](../commands/webvnc.md), [screenshot command](../commands/screenshot.md), [desktop command](../commands/desktop.md).
|
||||
|
||||
@ -16,8 +16,6 @@ client-side JavaScript only for filtering, sorting, and clipboard copy.
|
||||
```text
|
||||
GET /portal
|
||||
GET /portal/leases/{id-or-slug}
|
||||
GET /portal/leases/{id-or-slug}/share
|
||||
POST /portal/leases/{id-or-slug}/share
|
||||
POST /portal/leases/{id-or-slug}/release
|
||||
GET /portal/leases/{id-or-slug}/vnc
|
||||
GET /portal/leases/{id-or-slug}/code/
|
||||
@ -45,8 +43,7 @@ Default view rules:
|
||||
|
||||
- Defaults to active leases when any are active.
|
||||
- Falls back to all visible leases when the active list is empty.
|
||||
- Normal browser sessions see their own leases plus leases shared directly
|
||||
with them or with their org.
|
||||
- Normal browser sessions see only their own owner/org leases.
|
||||
- Admin sessions also see non-owned runner leases. `mine` and `system`
|
||||
filters distinguish personal leases from external runners (Blacksmith
|
||||
Testboxes synced from CLI list output) so external rows do not leak to
|
||||
@ -65,35 +62,22 @@ visibility-only detail page.
|
||||
The lease detail page shows:
|
||||
|
||||
- compact provider/target badges and the lease state pill;
|
||||
- bridge status for the WebVNC, code-server, and mediated egress bridges,
|
||||
including host/client connection state for an active egress session;
|
||||
- bridge status (whether the WebVNC and code-server bridges are up);
|
||||
- the latest Linux telemetry sample as gauges, with sparklines when multiple
|
||||
samples are present;
|
||||
- stale-telemetry, high-load, high-memory, and high-disk status pills when
|
||||
thresholds are exceeded;
|
||||
- an access panel with copy-to-clipboard commands for `crabbox ssh`,
|
||||
`crabbox run`, `crabbox webvnc`, `crabbox code`, and (when an egress
|
||||
session is active) `crabbox egress status` / `crabbox egress stop`;
|
||||
`crabbox run`, `crabbox webvnc`, and `crabbox code`;
|
||||
- a viewport-fitted "recent runs" grid with state filters;
|
||||
- a stop action when the lease is releasable.
|
||||
|
||||
Owners and users with `manage` access see a share control in the top-right
|
||||
lease header. The share page can add individual users, set org-wide access, or
|
||||
clear sharing. `use` shares can open visible lease pages and portal bridges;
|
||||
`manage` shares can also change sharing and stop the lease.
|
||||
|
||||
`/portal/leases/{id-or-slug}/vnc` and `/portal/leases/{id-or-slug}/code/`
|
||||
are bridges, not portal pages. They proxy WebSocket and HTTP traffic to the
|
||||
matching capability on the lease so a user does not need an SSH tunnel to
|
||||
open the desktop or editor. The mediated egress bridge has its own
|
||||
ticketed websocket route under `/v1/leases/{id-or-slug}/egress/...` rather
|
||||
than a portal path, because egress is operator-driven and never opens an
|
||||
HTML view. See [Interactive desktop and VNC](interactive-desktop-vnc.md),
|
||||
[code command](../commands/code.md), and [Mediated egress](egress.md).
|
||||
|
||||
All bridge tickets travel as `Authorization: Bearer ...` headers on the
|
||||
agent websocket upgrade, with a `?ticket=` query string fallback for older
|
||||
CLIs. The portal never echoes ticket values back to the browser.
|
||||
open the desktop or editor. See
|
||||
[Interactive desktop and VNC](interactive-desktop-vnc.md) and
|
||||
[code command](../commands/code.md).
|
||||
|
||||
## Run Detail `/portal/runs/{run-id}`
|
||||
|
||||
|
||||
@ -2,22 +2,20 @@
|
||||
|
||||
Read when:
|
||||
|
||||
- changing Hetzner, AWS, Azure, or Blacksmith Testbox provisioning;
|
||||
- changing Hetzner, AWS, or Blacksmith Testbox provisioning;
|
||||
- adding a backend;
|
||||
- adjusting machine classes, fallback order, regions, or images.
|
||||
|
||||
Crabbox currently supports three brokered providers:
|
||||
Crabbox currently supports two brokered providers:
|
||||
|
||||
```text
|
||||
hetzner Hetzner Cloud servers
|
||||
aws AWS EC2 instances
|
||||
azure Azure Virtual Machines
|
||||
```
|
||||
|
||||
Brokered Hetzner leases are Linux targets. Brokered AWS supports Linux, native
|
||||
Windows Server, Windows WSL2, and EC2 Mac when a Dedicated Host is configured.
|
||||
Brokered Azure supports Linux and native Windows SSH/sync/run. Static SSH still
|
||||
exists for reusing existing macOS and Windows machines:
|
||||
Windows Server, and EC2 Mac when a Dedicated Host is configured. Static SSH
|
||||
still exists for reusing existing macOS and Windows machines:
|
||||
|
||||
```text
|
||||
ssh Existing SSH host selected by static.host
|
||||
@ -34,7 +32,6 @@ islo Islo sandboxes with delegated command execution
|
||||
|
||||
- [Provider reference](../providers/README.md): one page per built-in backend.
|
||||
- [AWS](../providers/aws.md): EC2 Linux, Windows, WSL2, EC2 Mac, capacity, AMIs, and security groups.
|
||||
- [Azure](../providers/azure.md): Azure Linux/native Windows, shared infra, capacity, and cleanup.
|
||||
- [Hetzner](../providers/hetzner.md): Linux-only managed provider behavior, classes, and cleanup.
|
||||
- [Static SSH](../providers/ssh.md): existing Linux, macOS, and Windows SSH hosts.
|
||||
- [Blacksmith Testbox](../providers/blacksmith-testbox.md): delegated Testbox backend behavior.
|
||||
|
||||
@ -7,7 +7,7 @@ Read when:
|
||||
- changing SSH, VNC, or coordinator bootstrap behavior.
|
||||
|
||||
Tailscale is an optional Crabbox reachability layer. It is not a provider.
|
||||
Providers still own machines: Hetzner, AWS, Azure, static SSH hosts, and Blacksmith
|
||||
Providers still own machines: Hetzner, AWS, static SSH hosts, and Blacksmith
|
||||
Testbox. Tailscale only changes which host Crabbox dials for SSH-backed work.
|
||||
|
||||
V1 support:
|
||||
|
||||
@ -15,8 +15,6 @@ 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
|
||||
```
|
||||
@ -27,7 +25,6 @@ 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;
|
||||
@ -83,29 +80,10 @@ 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)
|
||||
|
||||
@ -29,9 +29,7 @@ EC2 Mac requirements:
|
||||
|
||||
Bootstrap enables Screen Sharing for `ec2-user`, sets a generated per-lease
|
||||
password, stores it at `/var/db/crabbox/vnc.password`, and keeps access behind
|
||||
the SSH tunnel. Managed EC2 Mac leases use `/Users/ec2-user/crabbox` as the
|
||||
default work root because the macOS system volume is read-only. `crabbox vnc`
|
||||
prints:
|
||||
the SSH tunnel. `crabbox vnc` prints:
|
||||
|
||||
```text
|
||||
macos username: ec2-user
|
||||
|
||||
@ -352,26 +352,8 @@ CRABBOX_TAILSCALE_CLIENT_ID optional; required for brokered --tailscale
|
||||
CRABBOX_TAILSCALE_CLIENT_SECRET optional; required for brokered --tailscale
|
||||
CRABBOX_TAILSCALE_TAILNET optional
|
||||
CRABBOX_TAILSCALE_TAGS optional
|
||||
CRABBOX_ARTIFACTS_BACKEND optional; currently r2
|
||||
CRABBOX_ARTIFACTS_BUCKET optional; currently openclaw-crabbox-artifacts
|
||||
CRABBOX_ARTIFACTS_PREFIX optional; currently crabbox-artifacts
|
||||
CRABBOX_ARTIFACTS_BASE_URL optional; currently https://artifacts.openclaw.ai
|
||||
CRABBOX_ARTIFACTS_REGION optional; currently auto
|
||||
CRABBOX_ARTIFACTS_ENDPOINT_URL optional; currently the R2 S3-compatible endpoint
|
||||
CRABBOX_ARTIFACTS_ACCESS_KEY_ID optional; Worker secret when artifacts backend is enabled
|
||||
CRABBOX_ARTIFACTS_SECRET_ACCESS_KEY optional; Worker secret when artifacts backend is enabled
|
||||
CRABBOX_ARTIFACTS_SESSION_TOKEN optional; Worker secret for temporary credentials
|
||||
CRABBOX_ARTIFACTS_UPLOAD_EXPIRES_SECONDS optional
|
||||
CRABBOX_ARTIFACTS_URL_EXPIRES_SECONDS optional
|
||||
```
|
||||
|
||||
Artifact credentials on the coordinator are storage-only S3-compatible keys.
|
||||
They exist so the Worker can sign one upload URL per artifact and return the
|
||||
final asset URL. They are not Cloudflare deploy tokens, not Crabbox bearer/admin
|
||||
tokens, and not VM provider credentials. Keep direct local S3/R2 credentials as
|
||||
operator fallback only; normal artifact publishing should go through the
|
||||
coordinator.
|
||||
|
||||
## Verified OpenClaw Run
|
||||
|
||||
Historical warm-run command from an OpenClaw checkout through the Cloudflare coordinator:
|
||||
|
||||
@ -115,41 +115,8 @@ CRABBOX_TAILSCALE_CLIENT_SECRET required for brokered --tailscale
|
||||
CRABBOX_TAILSCALE_TAILNET optional
|
||||
CRABBOX_TAILSCALE_TAGS optional
|
||||
CRABBOX_TAILSCALE_ENABLED optional; set 0 to disable brokered Tailscale
|
||||
CRABBOX_ARTIFACTS_BACKEND optional; enables brokered artifact publishing
|
||||
CRABBOX_ARTIFACTS_BUCKET required when artifact backend is enabled
|
||||
CRABBOX_ARTIFACTS_PREFIX optional
|
||||
CRABBOX_ARTIFACTS_BASE_URL optional; public final artifact URL prefix
|
||||
CRABBOX_ARTIFACTS_REGION optional
|
||||
CRABBOX_ARTIFACTS_ENDPOINT_URL optional; required for R2/custom S3 endpoints
|
||||
CRABBOX_ARTIFACTS_ACCESS_KEY_ID required when artifact backend is enabled
|
||||
CRABBOX_ARTIFACTS_SECRET_ACCESS_KEY required when artifact backend is enabled
|
||||
CRABBOX_ARTIFACTS_SESSION_TOKEN optional
|
||||
CRABBOX_ARTIFACTS_UPLOAD_EXPIRES_SECONDS optional
|
||||
CRABBOX_ARTIFACTS_URL_EXPIRES_SECONDS optional
|
||||
```
|
||||
|
||||
Artifact backend vars are ordinary Worker vars except
|
||||
`CRABBOX_ARTIFACTS_ACCESS_KEY_ID`, `CRABBOX_ARTIFACTS_SECRET_ACCESS_KEY`, and
|
||||
optional `CRABBOX_ARTIFACTS_SESSION_TOKEN`, which must be Worker secrets. These
|
||||
object-store keys let the coordinator sign short-lived artifact upload/read
|
||||
URLs; they should be scoped to the artifact bucket or prefix and should not have
|
||||
Cloudflare account, Worker deployment, lease-provider, or VM permissions.
|
||||
|
||||
Our current coordinator artifact config is R2-compatible:
|
||||
|
||||
```text
|
||||
CRABBOX_ARTIFACTS_BACKEND=r2
|
||||
CRABBOX_ARTIFACTS_BUCKET=openclaw-crabbox-artifacts
|
||||
CRABBOX_ARTIFACTS_PREFIX=crabbox-artifacts
|
||||
CRABBOX_ARTIFACTS_BASE_URL=https://artifacts.openclaw.ai
|
||||
CRABBOX_ARTIFACTS_REGION=auto
|
||||
CRABBOX_ARTIFACTS_ENDPOINT_URL=<account>.r2.cloudflarestorage.com
|
||||
```
|
||||
|
||||
The corresponding R2 access key id and secret access key are deployed as Worker
|
||||
secrets, not local CLI defaults. Normal users should run
|
||||
`crabbox artifacts publish` without direct S3/R2 credentials.
|
||||
|
||||
Cost-control secrets and settings:
|
||||
|
||||
```text
|
||||
|
||||
@ -12,7 +12,6 @@ static SSH provider for existing machines.
|
||||
| Provider | Backend kind | Targets | Best for |
|
||||
| --- | --- | --- | --- |
|
||||
| [AWS](aws.md) | SSH lease | Linux, Windows, macOS | broad managed capacity, Windows, EC2 Mac |
|
||||
| [Azure](azure.md) | SSH lease | Linux, Windows | Azure-backed Linux and native Windows capacity |
|
||||
| [Hetzner](hetzner.md) | SSH lease | Linux | fast Linux capacity at low cost |
|
||||
| [Static SSH](ssh.md) | SSH lease | Linux, macOS, Windows | reusing an existing host |
|
||||
| [Blacksmith Testbox](blacksmith-testbox.md) | delegated run | Linux | existing Blacksmith Testbox workflows |
|
||||
@ -39,8 +38,7 @@ crabbox run --provider blacksmith-testbox --id tbx_123 -- pnpm test
|
||||
|
||||
## Brokered Versus Direct
|
||||
|
||||
AWS, Azure, and Hetzner can run through the Crabbox coordinator or directly
|
||||
from the CLI.
|
||||
AWS and Hetzner can run through the Crabbox coordinator or directly from the CLI.
|
||||
Coordinator mode is the normal shared-team path: the Worker owns cloud
|
||||
credentials, cost state, cleanup alarms, and lease accounting.
|
||||
|
||||
@ -58,7 +56,6 @@ Delegated providers do not use the Crabbox coordinator:
|
||||
| Provider | `run` | `warmup` | `ssh` | VNC/code | Crabbox sync | Provider sync |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| AWS | yes | yes | yes | yes | yes | no |
|
||||
| Azure | yes | yes | yes | Linux VNC/code | yes | no |
|
||||
| Hetzner | yes | yes | yes | Linux VNC/code | yes | no |
|
||||
| Static SSH | yes | resolves host | yes | host-dependent | yes | no |
|
||||
| Blacksmith Testbox | yes | yes | no | no | no | yes |
|
||||
|
||||
@ -1,215 +0,0 @@
|
||||
# Azure Provider
|
||||
|
||||
Read when:
|
||||
|
||||
- choosing `provider: azure`;
|
||||
- debugging Azure VM capacity, quotas, images, or SSH readiness;
|
||||
- changing `internal/providers/azure` or the direct Azure provisioning code.
|
||||
|
||||
Azure is a managed provider for Linux and native Windows SSH leases. Azure
|
||||
provisions the VM, public IP, NIC, and OS disk, then Crabbox owns SSH
|
||||
readiness, sync, command execution, results, and cleanup.
|
||||
|
||||
## When To Use
|
||||
|
||||
Use Azure when the team's cloud capacity lives in an Azure subscription, or
|
||||
when Microsoft tooling, Entra ID, or Azure-specific networking constraints
|
||||
make AWS or Hetzner inappropriate. Use Hetzner for cheaper Linux-only
|
||||
capacity and AWS for Windows desktop, Windows WSL2, or macOS targets.
|
||||
|
||||
Azure supports direct mode and brokered Linux/native Windows leases. Direct
|
||||
mode uses local Azure credentials. Brokered mode uses the operator-owned
|
||||
Azure service principal configured on the Worker.
|
||||
|
||||
## Commands
|
||||
|
||||
```sh
|
||||
crabbox warmup --provider azure --class beast
|
||||
crabbox run --provider azure --class standard -- pnpm test
|
||||
crabbox warmup --provider azure --target windows --class standard
|
||||
crabbox warmup --provider azure --desktop --browser
|
||||
crabbox ssh --provider azure --id blue-lobster
|
||||
crabbox stop --provider azure blue-lobster
|
||||
crabbox cleanup --provider azure
|
||||
```
|
||||
|
||||
`--type` is exact (e.g. `--type Standard_D32ads_v6`). Use `--class` when SKU
|
||||
fallback is desired.
|
||||
|
||||
## Config
|
||||
|
||||
```yaml
|
||||
provider: azure
|
||||
target: linux
|
||||
class: beast
|
||||
azure:
|
||||
subscriptionId: 00000000-0000-0000-0000-000000000000
|
||||
tenantId: 00000000-0000-0000-0000-000000000000
|
||||
clientId: 00000000-0000-0000-0000-000000000000
|
||||
location: eastus
|
||||
resourceGroup: crabbox-leases
|
||||
image: Canonical:0001-com-ubuntu-server-jammy:22_04-lts-gen2:latest
|
||||
vnet: crabbox-vnet
|
||||
subnet: crabbox-subnet
|
||||
nsg: crabbox-nsg
|
||||
sshCIDRs: []
|
||||
```
|
||||
|
||||
`subscriptionId`, `tenantId`, and `clientId` may be set in config or sourced
|
||||
from environment variables. The client secret is never read from config; it
|
||||
must come from the environment.
|
||||
|
||||
Important direct-mode environment:
|
||||
|
||||
```text
|
||||
AZURE_SUBSCRIPTION_ID
|
||||
AZURE_TENANT_ID
|
||||
AZURE_CLIENT_ID
|
||||
AZURE_CLIENT_SECRET
|
||||
CRABBOX_AZURE_SUBSCRIPTION_ID
|
||||
CRABBOX_AZURE_TENANT_ID
|
||||
CRABBOX_AZURE_CLIENT_ID
|
||||
CRABBOX_AZURE_LOCATION
|
||||
CRABBOX_AZURE_RESOURCE_GROUP
|
||||
CRABBOX_AZURE_IMAGE
|
||||
CRABBOX_AZURE_VNET
|
||||
CRABBOX_AZURE_SUBNET
|
||||
CRABBOX_AZURE_NSG
|
||||
CRABBOX_AZURE_SSH_CIDRS
|
||||
```
|
||||
|
||||
`AZURE_*` are the standard service principal env vars consumed by
|
||||
`DefaultAzureCredential`. Crabbox does not read or print the client secret.
|
||||
|
||||
Brokered mode uses the same Azure service-principal secrets on the Worker:
|
||||
`AZURE_TENANT_ID`, `AZURE_CLIENT_ID`, `AZURE_CLIENT_SECRET`, and
|
||||
`AZURE_SUBSCRIPTION_ID`. Operators own the resource group, vnet, subnet,
|
||||
NSG, and SSH CIDR defaults through `CRABBOX_AZURE_*` env vars. A lease
|
||||
request may override only `azureLocation` and `azureImage`.
|
||||
|
||||
## Auth
|
||||
|
||||
If `azure.tenantId` and `azure.clientId` (or `CRABBOX_AZURE_TENANT_ID` /
|
||||
`CRABBOX_AZURE_CLIENT_ID`) are configured and `AZURE_CLIENT_SECRET` is set
|
||||
in the environment, Crabbox builds a `ClientSecretCredential` from those
|
||||
explicit values. Otherwise it falls back to
|
||||
[`azidentity.NewDefaultAzureCredential`](https://pkg.go.dev/github.com/Azure/azure-sdk-for-go/sdk/azidentity#DefaultAzureCredential),
|
||||
which scans environment, workload identity, managed identity, and CLI
|
||||
credentials in order. The simplest setup is a service principal with the
|
||||
[Contributor](https://learn.microsoft.com/azure/role-based-access-control/built-in-roles#contributor)
|
||||
role scoped to the resource group, configured via:
|
||||
|
||||
```sh
|
||||
export AZURE_TENANT_ID=...
|
||||
export AZURE_CLIENT_ID=...
|
||||
export AZURE_CLIENT_SECRET=...
|
||||
export AZURE_SUBSCRIPTION_ID=...
|
||||
```
|
||||
|
||||
See [Authenticate Go apps to Azure services with service principals](https://learn.microsoft.com/azure/developer/go/sdk/authentication/local-development-service-principal).
|
||||
|
||||
## Lifecycle
|
||||
|
||||
1. Resolve credentials per the rules above.
|
||||
2. Ensure the shared resource group, virtual network, subnet, and network
|
||||
security group exist. Crabbox first issues `Get` calls against each
|
||||
resource. If a resource exists without the `managed_by=crabbox` tag,
|
||||
Crabbox refuses to mutate it and returns an adopt-or-rename error. If a
|
||||
resource exists with the tag, it is left alone (Crabbox does not
|
||||
overwrite tags, address spaces, subnets, or rules on subsequent
|
||||
acquires). If a resource is missing, it is created with Crabbox tags
|
||||
and the configured layout. Inbound SSH rules are derived from
|
||||
`azure.sshCIDRs`, the configured SSH port, and any fallback ports.
|
||||
3. Mint a per-lease SSH key.
|
||||
4. Pick the configured class SKU candidates and try each in order.
|
||||
5. For each lease: create a public IP, NIC, and VM with cloud-init in
|
||||
`osProfile.customData` and the SSH key in
|
||||
`osProfile.linuxConfiguration.ssh.publicKeys` for Linux. Native Windows
|
||||
uses a Windows Server small-disk Gen2 image, Windows `osProfile` fields
|
||||
(`adminPassword`, `computerName`, and `windowsConfiguration`), and a
|
||||
Custom Script Extension that runs the Crabbox bootstrap saved in
|
||||
`C:\AzureData\CustomData.bin`.
|
||||
6. Query Azure Resource SKUs for the VM size. If Azure reports ephemeral OS
|
||||
disk support, use a local ephemeral OS disk. Otherwise use a managed
|
||||
`StandardSSD_LRS` OS disk.
|
||||
7. Tag the VM, NIC, and public IP with Crabbox lease metadata.
|
||||
8. Wait for the public IP to allocate, then for SSH and `crabbox-ready`.
|
||||
9. Let core sync and run over SSH.
|
||||
10. On release/cleanup, cascade-delete VM → NIC → public IP → OS disk. The
|
||||
shared infra remains.
|
||||
|
||||
## Classes
|
||||
|
||||
Default Linux SKUs:
|
||||
|
||||
```text
|
||||
standard Standard_D32ads_v6, Standard_D32ds_v6, Standard_F32s_v2, Standard_D32ads_v5, Standard_D32ds_v5, then D/F 16-vCPU fallbacks
|
||||
fast Standard_D64ads_v6, Standard_D64ds_v6, Standard_F64s_v2, Standard_D64ads_v5, Standard_D64ds_v5, then D/F 48-vCPU and 32-vCPU fallbacks
|
||||
large Standard_D96ads_v6, Standard_D96ds_v6, Standard_D96ads_v5, Standard_D96ds_v5, then D/F 64-vCPU and 48-vCPU fallbacks
|
||||
beast Standard_D192ds_v6, Standard_D128ds_v6, then D/F 96-vCPU and 64-vCPU fallbacks
|
||||
```
|
||||
|
||||
Default native Windows SKUs:
|
||||
|
||||
```text
|
||||
standard Standard_D2ads_v6, Standard_D2ds_v6, Standard_D2ads_v5, Standard_D2ds_v5, then Standard_D2as_v6
|
||||
fast Standard_D4ads_v6, Standard_D4ds_v6, Standard_D4ads_v5, Standard_D4ds_v5, then Standard_D4as_v6
|
||||
large Standard_D8ads_v6, Standard_D8ds_v6, Standard_D8ads_v5, Standard_D8ds_v5, then Standard_D8as_v6
|
||||
beast Standard_D16ads_v6, Standard_D16ds_v6, Standard_D16ads_v5, Standard_D16ds_v5, then Standard_D8ads_v6
|
||||
```
|
||||
|
||||
Class-based provisioning falls back across the candidate list when Azure
|
||||
rejects a SKU for capacity or quota
|
||||
(`SkuNotAvailable`, `QuotaExceeded`, `AllocationFailed`,
|
||||
`OverconstrainedAllocationRequest`). Spot leases fall back to on-demand when
|
||||
`capacity.fallback` starts with `on-demand`. Explicit `--type` is exact.
|
||||
The default Linux candidates mirror the AWS Linux class table's vCPU scale.
|
||||
The default Windows candidates mirror the AWS native Windows class table's
|
||||
vCPU scale. Azure native Windows support covers SSH, sync, and run; Windows
|
||||
WSL2 and macOS remain AWS or static-SSH targets.
|
||||
|
||||
## Capabilities
|
||||
|
||||
- SSH: yes.
|
||||
- Crabbox sync: yes.
|
||||
- Native Windows: yes for SSH, sync, and run.
|
||||
- Desktop / browser / code: Linux only on Azure.
|
||||
- Tailscale: Linux managed leases.
|
||||
- Actions hydration: yes, Linux SSH leases.
|
||||
- Coordinator: yes, brokered Linux/native Windows leases.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Azure VM names are constrained to 1-64 characters and cannot contain
|
||||
underscores. The `leaseProviderName` helper substitutes underscores
|
||||
for dashes; if you customize naming, keep that constraint in mind.
|
||||
- Windows computer names are limited to 15 characters. Crabbox keeps the VM
|
||||
resource name stable and derives a shorter Windows `computerName`.
|
||||
- The first acquire in an empty subscription pays the cost of creating the
|
||||
shared resource group, vnet, and NSG. Subsequent acquires only create
|
||||
per-lease resources.
|
||||
- If you already have a resource group / vnet / NSG with the configured
|
||||
names, Crabbox will refuse to mutate them unless they carry
|
||||
`managed_by=crabbox` as a tag. Either tag them to adopt, choose
|
||||
different names in `azure.*` config, or let Crabbox create dedicated
|
||||
resources.
|
||||
- `crabbox stop --provider azure <name>` will only act on VMs that carry
|
||||
`crabbox=true` (and either no `provider` tag or `provider=azure`). A
|
||||
manually-named VM in the resource group will not be deleted by Crabbox.
|
||||
- The default SSH NSG rule allows `0.0.0.0/0` when `azure.sshCIDRs` is
|
||||
empty. Set explicit CIDRs for any production-adjacent setup.
|
||||
- Azure costs are not hardcoded in Crabbox. Set `CRABBOX_COST_RATES_JSON`
|
||||
when you need exact Azure cost guardrails.
|
||||
- Azure native Windows uses Custom Script Extension because Windows custom
|
||||
data is saved to disk but not executed by Azure provisioning. Do not add
|
||||
rebooting bootstrap work to that extension path.
|
||||
- Azure does not provide managed Windows WSL2 or macOS through this provider.
|
||||
Use AWS or `provider: ssh` for those targets.
|
||||
- Direct-mode cleanup is best effort. Use `crabbox cleanup --provider azure`
|
||||
to sweep expired direct leases.
|
||||
|
||||
Related docs:
|
||||
|
||||
- [Feature: Azure](../features/azure.md)
|
||||
- [Linux VNC](../features/vnc-linux.md)
|
||||
- [Provider backends](../provider-backends.md)
|
||||
@ -71,16 +71,6 @@ Rules:
|
||||
- `CRABBOX_TAILSCALE_CLIENT_ID` and `CRABBOX_TAILSCALE_CLIENT_SECRET` are
|
||||
Worker secrets for minting one-off Tailscale auth keys when brokered
|
||||
`--tailscale` leases are requested.
|
||||
- `CRABBOX_ARTIFACTS_ACCESS_KEY_ID`, `CRABBOX_ARTIFACTS_SECRET_ACCESS_KEY`,
|
||||
and optional `CRABBOX_ARTIFACTS_SESSION_TOKEN` are Worker secrets for
|
||||
brokered artifact publishing. They should be scoped to the artifact
|
||||
bucket/prefix and used only to sign short-lived upload/read URLs.
|
||||
- `CRABBOX_ARTIFACTS_BACKEND`, `CRABBOX_ARTIFACTS_BUCKET`,
|
||||
`CRABBOX_ARTIFACTS_PREFIX`, `CRABBOX_ARTIFACTS_BASE_URL`,
|
||||
`CRABBOX_ARTIFACTS_REGION`, `CRABBOX_ARTIFACTS_ENDPOINT_URL`,
|
||||
`CRABBOX_ARTIFACTS_UPLOAD_EXPIRES_SECONDS`, and
|
||||
`CRABBOX_ARTIFACTS_URL_EXPIRES_SECONDS` are Worker config values, not secret
|
||||
material.
|
||||
- `CRABBOX_GITHUB_ALLOWED_ORG(S)` and `CRABBOX_GITHUB_ALLOWED_TEAMS` are Worker config values for browser-login authorization.
|
||||
- `CRABBOX_TAILSCALE_TAGS` is the coordinator allowlist/default for requested
|
||||
Tailscale ACL tags. Do not allow arbitrary user-supplied tags.
|
||||
|
||||
@ -35,7 +35,6 @@ This page maps user-facing behavior back to implementation files. Keep docs desc
|
||||
|
||||
- Direct Hetzner provider: `internal/providers/hetzner`, with API client helpers in `internal/cli/hcloud.go`
|
||||
- Direct AWS provider: `internal/providers/aws`, with API client helpers in `internal/cli/aws.go`
|
||||
- Direct Azure provider: `internal/providers/azure`, with API client helpers in `internal/cli/azure.go`
|
||||
- Static SSH macOS/Windows provider: `internal/providers/ssh`, with target mapping helpers in `internal/cli/static.go`
|
||||
- Blacksmith Testbox backend and argument/parsing helpers: `internal/providers/blacksmith`
|
||||
- Daytona provider backend and SDK/toolbox wrapper: `internal/providers/daytona`
|
||||
@ -44,21 +43,19 @@ This page maps user-facing behavior back to implementation files. Keep docs desc
|
||||
`internal/cli/provider_backend.go`
|
||||
- Built-in provider registration packages:
|
||||
`internal/providers/hetzner`, `internal/providers/aws`,
|
||||
`internal/providers/azure`,
|
||||
`internal/providers/ssh`, `internal/providers/blacksmith`,
|
||||
`internal/providers/daytona`, `internal/providers/islo`,
|
||||
`internal/providers/all`
|
||||
- Built-in provider backend implementations:
|
||||
`internal/providers/aws`, `internal/providers/azure`,
|
||||
`internal/providers/hetzner`,
|
||||
`internal/providers/aws`, `internal/providers/hetzner`,
|
||||
`internal/providers/ssh`, `internal/providers/blacksmith`,
|
||||
`internal/providers/daytona`, `internal/providers/islo`,
|
||||
plus shared helpers in `internal/providers/shared`
|
||||
- Worker Hetzner provider: `worker/src/hetzner.ts`
|
||||
- Worker AWS EC2 provider: `worker/src/aws.ts`
|
||||
- Worker AWS AMI create/read/promote routes: `worker/src/fleet.ts`, `worker/src/aws.ts`
|
||||
- Provider feature docs: `docs/features/aws.md`, `docs/features/azure.md`, `docs/features/hetzner.md`, `docs/features/blacksmith-testbox.md`, `docs/features/daytona.md`, `docs/features/islo.md`
|
||||
- Provider reference docs: `docs/providers/README.md`, `docs/providers/aws.md`, `docs/providers/azure.md`, `docs/providers/hetzner.md`, `docs/providers/ssh.md`, `docs/providers/blacksmith-testbox.md`, `docs/providers/daytona.md`, `docs/providers/islo.md`
|
||||
- Provider feature docs: `docs/features/aws.md`, `docs/features/hetzner.md`, `docs/features/blacksmith-testbox.md`, `docs/features/daytona.md`, `docs/features/islo.md`
|
||||
- Provider reference docs: `docs/providers/README.md`, `docs/providers/aws.md`, `docs/providers/hetzner.md`, `docs/providers/ssh.md`, `docs/providers/blacksmith-testbox.md`, `docs/providers/daytona.md`, `docs/providers/islo.md`
|
||||
- Provider/backend authoring guide: `docs/provider-backends.md`
|
||||
- CLI cloud-init bootstrap: `internal/cli/bootstrap.go`
|
||||
- Worker cloud-init bootstrap: `worker/src/bootstrap.ts`
|
||||
@ -68,7 +65,6 @@ This page maps user-facing behavior back to implementation files. Keep docs desc
|
||||
- VNC tunnel command: `internal/cli/vnc.go`
|
||||
- WebVNC portal bridge: `internal/cli/webvnc.go`, `worker/src/portal.ts`, `worker/src/fleet.ts`
|
||||
- Web code portal bridge: `internal/cli/code.go`, `worker/src/portal.ts`, `worker/src/fleet.ts`
|
||||
- Mediated egress bridge: `internal/cli/egress.go`, `internal/cli/coordinator.go`, `internal/cli/desktop.go`, `worker/src/index.ts`, `worker/src/fleet.ts`, `docs/features/egress.md`
|
||||
- Desktop screenshot command: `internal/cli/screenshot.go`
|
||||
- Interactive desktop/VNC contract: `docs/features/interactive-desktop-vnc.md`, `docs/features/vnc-linux.md`, `docs/features/vnc-windows.md`, `docs/features/vnc-macos.md`
|
||||
|
||||
@ -118,7 +114,6 @@ repository-owned setup, usually through Actions hydration or repo scripts.
|
||||
- Network and reachability: `docs/features/network.md` (code in `internal/cli/network.go`)
|
||||
- Lease capabilities: `docs/features/capabilities.md` (code in `internal/cli/capabilities.go`)
|
||||
- Environment forwarding: `docs/features/env-forwarding.md` (forwarding logic in `internal/cli/run.go`)
|
||||
- Mediated egress: `docs/features/egress.md` (CLI/Worker bridge for browser/app egress through an operator machine)
|
||||
- Capacity and fallback: `docs/features/capacity-fallback.md` (code in `internal/cli/aws.go`, `worker/src/aws.ts`, class maps in `internal/cli/config.go`)
|
||||
- Telemetry: `docs/features/telemetry.md` (code in `internal/cli/telemetry.go`)
|
||||
- Browser portal: `docs/features/portal.md` (code in `worker/src/portal.ts`)
|
||||
|
||||
17
go.mod
17
go.mod
@ -17,13 +17,6 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1 // indirect
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 // indirect
|
||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 // indirect
|
||||
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v6 v6.4.0 // indirect
|
||||
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v6 v6.2.0 // indirect
|
||||
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.2.0 // indirect
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.16 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 // indirect
|
||||
@ -45,12 +38,9 @@ require (
|
||||
github.com/daytonaio/daytona/libs/toolbox-api-client-go v0.172.0 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect
|
||||
github.com/kylelemons/godebug v1.1.0 // indirect
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/otel v1.43.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0 // indirect
|
||||
@ -61,10 +51,9 @@ require (
|
||||
go.opentelemetry.io/otel/sdk/metric v1.43.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.43.0 // indirect
|
||||
go.opentelemetry.io/proto/otlp v1.10.0 // indirect
|
||||
golang.org/x/crypto v0.50.0 // indirect
|
||||
golang.org/x/net v0.53.0 // indirect
|
||||
golang.org/x/sys v0.43.0 // indirect
|
||||
golang.org/x/text v0.36.0 // indirect
|
||||
golang.org/x/net v0.52.0 // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
golang.org/x/text v0.35.0 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect
|
||||
google.golang.org/grpc v1.80.0 // indirect
|
||||
|
||||
29
go.sum
29
go.sum
@ -1,17 +1,3 @@
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1 h1:jHb/wfvRikGdxMXYV3QG/SzUOPYN9KEUUuC0Yd0/vC0=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1/go.mod h1:pzBXCYn05zvYIrwLgtK8Ap8QcjRg+0i76tMQdWN6wOk=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 h1:Hk5QBxZQC1jb2Fwj6mpzme37xbCDdNTxU7O9eb5+LB4=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1/go.mod h1:IYus9qsFobWIc2YVwe/WPjcnyCkPKtnHAqUYeebc8z0=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 h1:fhqpLE3UEXi9lPaBRpQ6XuRW0nU7hgg4zlmZZa+a9q4=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0/go.mod h1:7dCRMLwisfRH3dBupKeNCioWYUZ4SS09Z14H+7i8ZoY=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v6 v6.4.0 h1:z7Mqz6l0EFH549GvHEqfjKvi+cRScxLWbaoeLm9wxVQ=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v6 v6.4.0/go.mod h1:v6gbfH+7DG7xH2kUNs+ZJ9tF6O3iNnR85wMtmr+F54o=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v6 v6.2.0 h1:HYGD75g0bQ3VO/Omedm54v4LrD3B1cGImuRF3AJ5wLo=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v6 v6.2.0/go.mod h1:ulHyBFJOI0ONiRL4vcJTmS7rx18jQQlEPmAgo80cRdM=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.2.0 h1:Dd+RhdJn0OTtVGaeDLZpcumkIVCtA/3/Fo42+eoYvVM=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.2.0/go.mod h1:5kakwfW5CjC9KK+Q4wjXAg+ShuIm2mBMua0ZFj2C8PE=
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 h1:XRzhVemXdgvJqCH0sFfrBUTnUJSBrBf7++ypk+twtRs=
|
||||
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk=
|
||||
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
|
||||
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
|
||||
github.com/alecthomas/kong v1.15.0 h1:BVJstKbpO73zKpmIu+m/aLRrNmWwxXPIGTNin9VmLVI=
|
||||
@ -74,8 +60,6 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
@ -95,10 +79,6 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
@ -130,21 +110,12 @@ go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpu
|
||||
go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
||||
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
||||
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
||||
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
||||
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
|
||||
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
|
||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA=
|
||||
|
||||
@ -89,12 +89,8 @@ func (a App) directCommandHelp(ctx context.Context, args []string) (error, bool)
|
||||
return a.webvnc(ctx, helpArgs), true
|
||||
case "code":
|
||||
return a.webCode(ctx, helpArgs), true
|
||||
case "egress":
|
||||
return a.egress(ctx, helpArgs), true
|
||||
case "screenshot":
|
||||
return a.screenshot(ctx, helpArgs), true
|
||||
case "artifacts":
|
||||
return nil, false
|
||||
case "inspect":
|
||||
return a.inspect(ctx, helpArgs), true
|
||||
case "stop", "release":
|
||||
@ -141,7 +137,6 @@ Commands:
|
||||
run Sync the repo, run a remote command, stream output
|
||||
desktop Launch apps into a visible desktop session
|
||||
media Create preview artifacts from recorded desktop videos
|
||||
artifacts Collect, transform, and publish QA artifacts
|
||||
sync-plan Show local sync manifest size hotspots
|
||||
history List recorded remote runs
|
||||
logs Print recorded run logs
|
||||
@ -151,8 +146,6 @@ Commands:
|
||||
cache Inspect, purge, or warm remote caches
|
||||
status Show lease state; add --wait to block until ready
|
||||
list List Crabbox machines
|
||||
share Share a lease with users or the owning org
|
||||
unshare Remove lease sharing
|
||||
image Create or promote brokered AWS runner images
|
||||
usage Show cost and usage estimates by user, org, or fleet
|
||||
admin Lease admin controls for trusted operators
|
||||
@ -161,7 +154,6 @@ Commands:
|
||||
vnc Print or open VNC connection details for a desktop lease
|
||||
webvnc Bridge a desktop lease into the authenticated web portal
|
||||
code Bridge a code lease into the authenticated web portal
|
||||
egress Bridge lease browser/app traffic through this machine
|
||||
screenshot Capture a PNG from a desktop lease
|
||||
inspect Print lease/provider details; add --json for scripts
|
||||
stop Release a lease or delete a direct-provider machine
|
||||
@ -177,13 +169,8 @@ Common Flows:
|
||||
crabbox vnc --id blue-lobster --open
|
||||
crabbox desktop launch --id blue-lobster --browser --url https://example.com --webvnc --open
|
||||
crabbox media preview --input desktop.mp4 --output desktop-preview.gif --trimmed-video-output desktop-change.mp4
|
||||
crabbox artifacts collect --id blue-lobster --all --output artifacts/blue-lobster
|
||||
crabbox artifacts publish --pr 123 --dir artifacts/blue-lobster --storage s3 --bucket qa-artifacts
|
||||
crabbox webvnc --id blue-lobster --open
|
||||
crabbox code --id blue-lobster --open
|
||||
crabbox egress start --id blue-lobster --profile discord --daemon
|
||||
crabbox share --id blue-lobster --user friend@example.com
|
||||
crabbox share --id blue-lobster --org
|
||||
crabbox screenshot --id blue-lobster --output desktop.png
|
||||
crabbox inspect --id blue-lobster --json
|
||||
crabbox history --lease cbx_abcdef123456
|
||||
@ -206,11 +193,11 @@ Global:
|
||||
--version Print version
|
||||
|
||||
Config:
|
||||
crabbox login [--url <url>] [--provider aws|azure|hetzner] [--no-browser]
|
||||
crabbox login --url <url> --token-stdin [--provider aws|azure|hetzner]
|
||||
crabbox login [--url <url>] [--provider aws|hetzner] [--no-browser]
|
||||
crabbox login --url <url> --token-stdin [--provider aws|hetzner]
|
||||
crabbox config path
|
||||
crabbox config show [--json]
|
||||
crabbox config set-broker --url <url> --token-stdin [--provider aws|azure|hetzner]
|
||||
crabbox config set-broker --url <url> --token-stdin [--provider aws|hetzner]
|
||||
|
||||
Environment:
|
||||
CRABBOX_COORDINATOR Broker URL
|
||||
@ -220,7 +207,7 @@ Environment:
|
||||
CRABBOX_ACCESS_CLIENT_ID Cloudflare Access service token client ID
|
||||
CRABBOX_ACCESS_CLIENT_SECRET Cloudflare Access service token client secret
|
||||
CRABBOX_ACCESS_TOKEN Cloudflare Access JWT for protected routes
|
||||
CRABBOX_PROVIDER hetzner, aws, azure, ssh, blacksmith-testbox, daytona, or islo
|
||||
CRABBOX_PROVIDER hetzner, aws, ssh, blacksmith-testbox, daytona, or islo
|
||||
CRABBOX_TARGET linux, macos, or windows
|
||||
CRABBOX_WINDOWS_MODE normal or wsl2
|
||||
CRABBOX_DESKTOP Provision or require desktop/VNC capability
|
||||
|
||||
@ -1,607 +0,0 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type artifactFile struct {
|
||||
Kind string `json:"kind"`
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
URL string `json:"url,omitempty"`
|
||||
}
|
||||
|
||||
type artifactBundleMetadata struct {
|
||||
CreatedAt string `json:"createdAt"`
|
||||
Version string `json:"crabboxVersion"`
|
||||
LeaseID string `json:"leaseId,omitempty"`
|
||||
Slug string `json:"slug,omitempty"`
|
||||
Provider string `json:"provider,omitempty"`
|
||||
Network string `json:"network,omitempty"`
|
||||
TargetOS string `json:"targetOS,omitempty"`
|
||||
RunID string `json:"runId,omitempty"`
|
||||
}
|
||||
|
||||
type artifactCollectResult struct {
|
||||
Directory string `json:"directory"`
|
||||
Files []artifactFile `json:"files"`
|
||||
Metadata artifactBundleMetadata `json:"metadata"`
|
||||
Warnings []artifactWarning `json:"warnings,omitempty"`
|
||||
Error *artifactCollectError `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type artifactCollectError struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type artifactWarning struct {
|
||||
Problem string `json:"problem"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
Rescue []string `json:"rescue,omitempty"`
|
||||
Fallback string `json:"fallback,omitempty"`
|
||||
}
|
||||
|
||||
type artifactPublishOptions struct {
|
||||
Directory string
|
||||
Storage string
|
||||
Bucket string
|
||||
Prefix string
|
||||
BaseURL string
|
||||
PR int
|
||||
Repo string
|
||||
Template string
|
||||
Summary string
|
||||
SummaryFile string
|
||||
Region string
|
||||
Profile string
|
||||
EndpointURL string
|
||||
ACL string
|
||||
Presign bool
|
||||
Expires time.Duration
|
||||
DryRun bool
|
||||
NoComment bool
|
||||
}
|
||||
|
||||
func (a App) artifactsCollect(ctx context.Context, args []string) error {
|
||||
defaults := defaultConfig()
|
||||
fs := newFlagSet("artifacts collect", a.Stderr)
|
||||
provider := fs.String("provider", defaults.Provider, "provider: hetzner, aws, or ssh")
|
||||
id := fs.String("id", "", "lease id or slug")
|
||||
output := fs.String("output", "", "artifact bundle directory")
|
||||
runID := fs.String("run", "", "optional run id whose retained logs should be copied")
|
||||
all := fs.Bool("all", false, "collect screenshot, video, GIF, doctor/status, logs, and metadata")
|
||||
screenshot := fs.Bool("screenshot", true, "capture desktop screenshot")
|
||||
video := fs.Bool("video", false, "record desktop video")
|
||||
gif := fs.Bool("gif", false, "create trimmed GIF from recorded video")
|
||||
doctor := fs.Bool("doctor", true, "write desktop doctor output")
|
||||
webvncStatus := fs.Bool("webvnc-status", true, "write WebVNC portal status when coordinator is configured")
|
||||
metadata := fs.Bool("metadata", true, "write metadata.json")
|
||||
duration := fs.Duration("duration", 10*time.Second, "video capture duration")
|
||||
fps := fs.Float64("fps", 15, "video frames per second")
|
||||
gifWidth := fs.Int("gif-width", 640, "trimmed GIF width")
|
||||
reclaim := fs.Bool("reclaim", false, "claim this lease for the current repo")
|
||||
targetFlags := registerTargetFlags(fs, defaults)
|
||||
networkFlags := registerNetworkModeFlag(fs, defaults)
|
||||
jsonOut := fs.Bool("json", false, "print machine-readable result")
|
||||
if err := parseFlags(fs, args); err != nil {
|
||||
return err
|
||||
}
|
||||
setIDFromFirstArg(fs, id)
|
||||
if *all {
|
||||
*video = true
|
||||
*gif = true
|
||||
}
|
||||
if *gif && !*video {
|
||||
return exit(2, "artifacts collect --gif requires --video or --all")
|
||||
}
|
||||
if *duration <= 0 {
|
||||
return exit(2, "artifacts collect --duration must be positive")
|
||||
}
|
||||
if *fps <= 0 {
|
||||
return exit(2, "artifacts collect --fps must be positive")
|
||||
}
|
||||
if *gifWidth <= 0 {
|
||||
return exit(2, "artifacts collect --gif-width must be positive")
|
||||
}
|
||||
cfg, err := loadLeaseTargetConfig(fs, *provider, targetFlags, networkFlags, leaseTargetConfigOptions{Desktop: true})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if isBlacksmithProvider(cfg.Provider) {
|
||||
return exit(2, "artifacts collect is not supported for provider=%s; Blacksmith owns machine connectivity", cfg.Provider)
|
||||
}
|
||||
if err := requireLeaseID(*id, "crabbox artifacts collect --id <lease-id-or-slug> [--output <dir>]", cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
server, target, leaseID, err := a.resolveNetworkLeaseTarget(ctx, cfg, *id, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if isStaticProvider(cfg.Provider) && target.TargetOS != targetLinux {
|
||||
return exit(2, "desktop artifacts are not collected from static %s hosts because those are existing host machines, not Crabbox-created desktops", target.TargetOS)
|
||||
}
|
||||
if err := enforceManagedLeaseCapabilities(cfg, server, leaseID); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.claimAndTouchLeaseTarget(ctx, cfg, server, leaseID, *reclaim); err != nil {
|
||||
return err
|
||||
}
|
||||
dir := strings.TrimSpace(*output)
|
||||
if dir == "" {
|
||||
dir = defaultArtifactBundleDir(leaseID, serverSlug(server))
|
||||
}
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return exit(2, "create artifact directory: %v", err)
|
||||
}
|
||||
|
||||
result := artifactCollectResult{
|
||||
Directory: dir,
|
||||
Metadata: artifactBundleMetadata{
|
||||
CreatedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
Version: version,
|
||||
LeaseID: leaseID,
|
||||
Slug: serverSlug(server),
|
||||
Provider: cfg.Provider,
|
||||
Network: string(cfg.Network),
|
||||
TargetOS: target.TargetOS,
|
||||
RunID: strings.TrimSpace(*runID),
|
||||
},
|
||||
}
|
||||
addFile := func(kind, path string) {
|
||||
result.Files = append(result.Files, artifactFile{Kind: kind, Name: filepath.Base(path), Path: path})
|
||||
}
|
||||
fail := func(err error, warning artifactWarning) error {
|
||||
return a.finishArtifactCollectFailure(&result, *jsonOut, err, warning)
|
||||
}
|
||||
|
||||
if *metadata {
|
||||
path := filepath.Join(dir, "metadata.json")
|
||||
if err := writeJSONFile(path, result.Metadata); err != nil {
|
||||
return err
|
||||
}
|
||||
addFile("metadata", path)
|
||||
}
|
||||
if *screenshot {
|
||||
if err := waitForLoopbackVNC(ctx, &target); err != nil {
|
||||
return fail(err, artifactWarning{
|
||||
Problem: rescueVNCTargetUnreachable,
|
||||
Detail: err.Error(),
|
||||
Rescue: []string{desktopDoctorCommand(rescueContext{Cfg: cfg, Target: target, LeaseID: leaseID})},
|
||||
})
|
||||
}
|
||||
path := filepath.Join(dir, "screenshot.png")
|
||||
if err := captureDesktopScreenshot(ctx, target, path); err != nil {
|
||||
return fail(err, artifactWarning{
|
||||
Problem: classifyDesktopFailure(err.Error()),
|
||||
Detail: err.Error(),
|
||||
Rescue: []string{desktopDoctorCommand(rescueContext{Cfg: cfg, Target: target, LeaseID: leaseID})},
|
||||
})
|
||||
}
|
||||
addFile("screenshot", path)
|
||||
}
|
||||
if *doctor {
|
||||
path := filepath.Join(dir, "doctor.txt")
|
||||
out, err := runSSHOutput(ctx, target, desktopDoctorRemoteCommand(target))
|
||||
if err != nil {
|
||||
doctorErr := exit(5, "desktop doctor failed: %v", err)
|
||||
return fail(doctorErr, artifactWarning{
|
||||
Problem: classifyDesktopFailure(out),
|
||||
Detail: trimFailureDetail(out),
|
||||
Rescue: []string{desktopDoctorCommand(rescueContext{Cfg: cfg, Target: target, LeaseID: leaseID})},
|
||||
})
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(out+"\n"), 0o644); err != nil {
|
||||
return exit(2, "write doctor artifact: %v", err)
|
||||
}
|
||||
addFile("doctor", path)
|
||||
}
|
||||
if *webvncStatus {
|
||||
if path, ok, err := a.writeArtifactWebVNCStatus(ctx, cfg, target, leaseID, dir, &result.Warnings); err != nil {
|
||||
return err
|
||||
} else if ok {
|
||||
addFile("webvnc-status", path)
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(*runID) != "" {
|
||||
logPath, runPath, err := writeArtifactRunLogs(ctx, strings.TrimSpace(*runID), dir)
|
||||
if err != nil {
|
||||
return fail(err, artifactWarning{
|
||||
Problem: rescueArtifactCaptureFailed,
|
||||
Detail: err.Error(),
|
||||
Rescue: []string{"crabbox logs " + strings.TrimSpace(*runID)},
|
||||
})
|
||||
}
|
||||
addFile("logs", logPath)
|
||||
addFile("run", runPath)
|
||||
}
|
||||
if *video {
|
||||
if target.TargetOS != targetLinux {
|
||||
err := exit(2, "artifacts collect --video currently requires target=linux with ffmpeg/x11grab")
|
||||
return fail(err, artifactWarning{
|
||||
Problem: rescueArtifactCaptureFailed,
|
||||
Detail: err.Error(),
|
||||
Rescue: []string{desktopDoctorCommand(rescueContext{Cfg: cfg, Target: target, LeaseID: leaseID})},
|
||||
})
|
||||
}
|
||||
path := filepath.Join(dir, "screen.mp4")
|
||||
if err := captureDesktopVideo(ctx, target, path, *duration, *fps); err != nil {
|
||||
return fail(err, artifactWarning{
|
||||
Problem: classifyDesktopFailure(err.Error()),
|
||||
Detail: err.Error(),
|
||||
Rescue: []string{desktopDoctorCommand(rescueContext{Cfg: cfg, Target: target, LeaseID: leaseID})},
|
||||
})
|
||||
}
|
||||
addFile("video", path)
|
||||
if *gif {
|
||||
gifPath := filepath.Join(dir, "screen.trimmed.gif")
|
||||
trimmedPath := filepath.Join(dir, "screen.trimmed.mp4")
|
||||
preview, err := createMediaPreview(ctx, mediaPreviewOptions{
|
||||
Input: path,
|
||||
Output: gifPath,
|
||||
TrimmedVideoOutput: trimmedPath,
|
||||
Width: *gifWidth,
|
||||
FPS: 4,
|
||||
TrimStatic: true,
|
||||
TrimPadding: 750 * time.Millisecond,
|
||||
FreezeDuration: 500 * time.Millisecond,
|
||||
FreezeNoise: "-50dB",
|
||||
MinDuration: 1500 * time.Millisecond,
|
||||
})
|
||||
if err != nil {
|
||||
return fail(err, artifactWarning{
|
||||
Problem: rescueArtifactCaptureFailed,
|
||||
Detail: err.Error(),
|
||||
})
|
||||
}
|
||||
addFile("gif", preview.Output)
|
||||
if preview.TrimmedVideoOutput != "" {
|
||||
addFile("trimmed-video", preview.TrimmedVideoOutput)
|
||||
}
|
||||
}
|
||||
}
|
||||
sortArtifactFiles(result.Files)
|
||||
if result.Files == nil {
|
||||
result.Files = []artifactFile{}
|
||||
}
|
||||
if *jsonOut {
|
||||
enc := json.NewEncoder(a.Stdout)
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(result)
|
||||
}
|
||||
for _, warning := range result.Warnings {
|
||||
printArtifactWarning(a.Stdout, warning)
|
||||
}
|
||||
fmt.Fprintf(a.Stdout, "artifacts: %s\n", dir)
|
||||
for _, file := range result.Files {
|
||||
fmt.Fprintf(a.Stdout, "%s: %s\n", file.Kind, file.Path)
|
||||
}
|
||||
fmt.Fprintf(a.Stdout, "publish: crabbox artifacts publish --dir %s --pr <n>\n", strings.Join(readableShellWords([]string{dir}), " "))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a App) finishArtifactCollectFailure(result *artifactCollectResult, jsonOut bool, err error, warning artifactWarning) error {
|
||||
if result == nil {
|
||||
return err
|
||||
}
|
||||
sortArtifactFiles(result.Files)
|
||||
if result.Files == nil {
|
||||
result.Files = []artifactFile{}
|
||||
}
|
||||
if strings.TrimSpace(warning.Problem) != "" {
|
||||
result.Warnings = append(result.Warnings, normalizeArtifactWarning(warning))
|
||||
}
|
||||
result.Error = &artifactCollectError{
|
||||
Code: artifactErrorCode(result.Warnings),
|
||||
Message: strings.TrimSpace(err.Error()),
|
||||
}
|
||||
if jsonOut {
|
||||
enc := json.NewEncoder(a.Stdout)
|
||||
enc.SetIndent("", " ")
|
||||
if encodeErr := enc.Encode(result); encodeErr != nil {
|
||||
return encodeErr
|
||||
}
|
||||
return err
|
||||
}
|
||||
for _, warning := range result.Warnings {
|
||||
printArtifactWarning(a.Stdout, warning)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (a App) artifactsVideo(ctx context.Context, args []string) error {
|
||||
target, cfg, leaseID, err := a.desktopCommandTarget(ctx, "artifacts video", args, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
output, _ := stringFlagValue(args, "output")
|
||||
if strings.TrimSpace(output) == "" {
|
||||
output = "crabbox-" + normalizeLeaseSlug(leaseID) + "-screen.mp4"
|
||||
}
|
||||
duration := durationFlagValue(args, "duration", 10*time.Second)
|
||||
fps := floatFlagValue(args, "fps", 15)
|
||||
if duration <= 0 {
|
||||
return exit(2, "artifacts video --duration must be positive")
|
||||
}
|
||||
if fps <= 0 {
|
||||
return exit(2, "artifacts video --fps must be positive")
|
||||
}
|
||||
if target.TargetOS != targetLinux {
|
||||
return exit(2, "artifacts video currently requires target=linux with ffmpeg/x11grab")
|
||||
}
|
||||
if err := captureDesktopVideo(ctx, target, output, duration, fps); err != nil {
|
||||
printRescue(a.Stdout, classifyDesktopFailure(err.Error()), err.Error(), desktopDoctorCommand(rescueContext{Cfg: cfg, Target: target, LeaseID: leaseID}))
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(a.Stdout, "video: %s\n", output)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a App) artifactsGif(ctx context.Context, args []string) error {
|
||||
return a.mediaPreview(ctx, args)
|
||||
}
|
||||
|
||||
func (a App) artifactsTemplate(ctx context.Context, args []string) error {
|
||||
_ = ctx
|
||||
initialKind := ""
|
||||
if len(args) > 0 && !strings.HasPrefix(args[0], "-") {
|
||||
initialKind = args[0]
|
||||
args = args[1:]
|
||||
}
|
||||
fs := newFlagSet("artifacts template", a.Stderr)
|
||||
kind := fs.String("kind", initialKind, "template kind: openclaw or mantis")
|
||||
before := fs.String("before", "", "before screenshot/GIF URL or path")
|
||||
after := fs.String("after", "", "after screenshot/GIF URL or path")
|
||||
summary := fs.String("summary", "", "summary text")
|
||||
summaryFile := fs.String("summary-file", "", "summary markdown file")
|
||||
output := fs.String("output", "", "output markdown path; stdout when omitted")
|
||||
if err := parseFlags(fs, args); err != nil {
|
||||
return err
|
||||
}
|
||||
text, err := summaryText(*summary, *summaryFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body := artifactTemplateMarkdown(*kind, text, *before, *after, nil)
|
||||
if strings.TrimSpace(*output) == "" {
|
||||
fmt.Fprint(a.Stdout, body)
|
||||
return nil
|
||||
}
|
||||
if err := os.WriteFile(*output, []byte(body), 0o644); err != nil {
|
||||
return exit(2, "write template: %v", err)
|
||||
}
|
||||
fmt.Fprintf(a.Stdout, "template: %s\n", *output)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a App) artifactsPublish(ctx context.Context, args []string) error {
|
||||
opts, err := parseArtifactPublishOptions(args, a.Stderr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var coord *CoordinatorClient
|
||||
if opts.Storage == "auto" || opts.Storage == "broker" {
|
||||
cfg, cfgErr := loadConfig()
|
||||
if cfgErr != nil {
|
||||
return cfgErr
|
||||
}
|
||||
var useCoordinator bool
|
||||
coord, useCoordinator, err = newCoordinatorClient(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if opts.Storage == "auto" {
|
||||
if useCoordinator && coord != nil && coord.Token != "" {
|
||||
opts.Storage = "broker"
|
||||
} else {
|
||||
opts.Storage = "local"
|
||||
}
|
||||
}
|
||||
}
|
||||
ensureArtifactPublishPrefix(&opts)
|
||||
files, err := listArtifactBundleFiles(opts.Directory)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(files) == 0 {
|
||||
return exit(2, "artifact directory has no files: %s", opts.Directory)
|
||||
}
|
||||
summary, err := summaryText(opts.Summary, opts.SummaryFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var published []artifactFile
|
||||
if opts.Storage == "broker" {
|
||||
published, err = publishArtifactFilesBroker(ctx, coord, opts, files)
|
||||
} else {
|
||||
published, err = publishArtifactFiles(ctx, opts, files)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body := artifactTemplateMarkdown(opts.Template, summary, "", "", published)
|
||||
bodyPath := filepath.Join(opts.Directory, "published-artifacts.md")
|
||||
if err := os.WriteFile(bodyPath, []byte(body), 0o644); err != nil {
|
||||
return exit(2, "write publish markdown: %v", err)
|
||||
}
|
||||
if opts.PR > 0 && !opts.NoComment {
|
||||
if opts.Storage == "local" && opts.BaseURL == "" {
|
||||
return exit(2, "artifacts publish --pr needs brokered publishing, --storage s3|r2|cloudflare, or --base-url for already-hosted local assets")
|
||||
}
|
||||
if opts.DryRun {
|
||||
fmt.Fprintf(a.Stdout, "dry-run comment: gh issue comment %d --body-file %s\n", opts.PR, bodyPath)
|
||||
} else if err := postGitHubPRComment(ctx, opts.PR, opts.Repo, bodyPath); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, file := range published {
|
||||
if file.URL != "" {
|
||||
fmt.Fprintf(a.Stdout, "%s: %s\n", file.Kind, file.URL)
|
||||
} else {
|
||||
fmt.Fprintf(a.Stdout, "%s: %s\n", file.Kind, file.Path)
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(a.Stdout, "markdown: %s\n", bodyPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
func defaultArtifactBundleDir(leaseID, slug string) string {
|
||||
name := strings.TrimSpace(slug)
|
||||
if name == "" {
|
||||
name = leaseID
|
||||
}
|
||||
if name == "" {
|
||||
name = time.Now().UTC().Format("20060102-150405")
|
||||
}
|
||||
return filepath.Join("artifacts", normalizeLeaseSlug(name))
|
||||
}
|
||||
|
||||
func writeJSONFile(path string, value any) error {
|
||||
data, err := json.MarshalIndent(value, "", " ")
|
||||
if err != nil {
|
||||
return exit(2, "encode %s: %v", path, err)
|
||||
}
|
||||
data = append(data, '\n')
|
||||
if err := os.WriteFile(path, data, 0o644); err != nil {
|
||||
return exit(2, "write %s: %v", path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a App) writeArtifactWebVNCStatus(ctx context.Context, cfg Config, target SSHTarget, leaseID, dir string, warnings *[]artifactWarning) (string, bool, error) {
|
||||
if isStaticProvider(cfg.Provider) || isBlacksmithProvider(cfg.Provider) {
|
||||
return "", false, nil
|
||||
}
|
||||
coord, useCoordinator, err := newTargetCoordinatorClient(cfg)
|
||||
if err != nil || !useCoordinator || coord == nil || coord.Token == "" {
|
||||
return "", false, nil
|
||||
}
|
||||
status, err := coord.WebVNCStatus(ctx, leaseID)
|
||||
path := filepath.Join(dir, "webvnc-status.json")
|
||||
payload := map[string]any{"leaseId": leaseID, "target": target.TargetOS}
|
||||
if err != nil {
|
||||
payload["error"] = err.Error()
|
||||
rescueCtx := rescueContext{Cfg: cfg, Target: target, LeaseID: leaseID}
|
||||
appendArtifactWarning(warnings, rescueVNCBridgeDisconnected, err.Error(), "", webVNCStatusRescueCommand(rescueCtx), webVNCResetRescueCommand(rescueCtx))
|
||||
} else {
|
||||
payload["status"] = status
|
||||
rescueCtx := rescueContext{Cfg: cfg, Target: target, LeaseID: leaseID}
|
||||
if !status.BridgeConnected {
|
||||
appendArtifactWarning(warnings, rescueVNCBridgeNotRunning, "portal has no active WebVNC bridge for this lease", "", webVNCDaemonStartRescueCommand(rescueCtx), webVNCResetRescueCommand(rescueCtx))
|
||||
} else if webVNCObserverSlotsExhausted(status) {
|
||||
appendArtifactWarning(warnings, rescueVNCObserverSlotsFull, "all WebVNC observer slots are in use or stale", "", webVNCDaemonStartRescueCommand(rescueCtx), webVNCResetRescueCommand(rescueCtx))
|
||||
}
|
||||
}
|
||||
if err := writeJSONFile(path, payload); err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
return path, true, nil
|
||||
}
|
||||
|
||||
func appendArtifactWarning(warnings *[]artifactWarning, problem, detail, fallback string, rescue ...string) {
|
||||
if warnings == nil {
|
||||
return
|
||||
}
|
||||
clean := normalizeArtifactWarning(artifactWarning{Problem: problem, Detail: detail, Fallback: fallback, Rescue: rescue})
|
||||
if clean.Problem != "" {
|
||||
*warnings = append(*warnings, clean)
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeArtifactWarning(warning artifactWarning) artifactWarning {
|
||||
clean := artifactWarning{
|
||||
Problem: strings.TrimSpace(warning.Problem),
|
||||
Detail: strings.TrimSpace(warning.Detail),
|
||||
Fallback: strings.TrimSpace(warning.Fallback),
|
||||
}
|
||||
for _, command := range warning.Rescue {
|
||||
if strings.TrimSpace(command) != "" {
|
||||
clean.Rescue = append(clean.Rescue, strings.TrimSpace(command))
|
||||
}
|
||||
}
|
||||
return clean
|
||||
}
|
||||
|
||||
func artifactErrorCode(warnings []artifactWarning) string {
|
||||
if len(warnings) == 0 || strings.TrimSpace(warnings[len(warnings)-1].Problem) == "" {
|
||||
return "artifact_collect_failed"
|
||||
}
|
||||
return normalizeLeaseSlug(warnings[len(warnings)-1].Problem)
|
||||
}
|
||||
|
||||
func printArtifactWarning(w io.Writer, warning artifactWarning) {
|
||||
printRescueWithFallback(w, warning.Problem, warning.Detail, warning.Fallback, warning.Rescue...)
|
||||
}
|
||||
|
||||
func writeArtifactRunLogs(ctx context.Context, runID, dir string) (string, string, error) {
|
||||
coord, err := configuredCoordinator()
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
logText, err := coord.RunLogs(ctx, runID)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
run, err := coord.Run(ctx, runID)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
logPath := filepath.Join(dir, "logs.txt")
|
||||
runPath := filepath.Join(dir, "run.json")
|
||||
if err := os.WriteFile(logPath, []byte(logText), 0o644); err != nil {
|
||||
return "", "", exit(2, "write logs artifact: %v", err)
|
||||
}
|
||||
if err := writeJSONFile(runPath, run); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
return logPath, runPath, nil
|
||||
}
|
||||
|
||||
func captureDesktopVideo(ctx context.Context, target SSHTarget, outputPath string, duration time.Duration, fps float64) error {
|
||||
if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil && filepath.Dir(outputPath) != "." {
|
||||
return exit(2, "create video directory: %v", err)
|
||||
}
|
||||
file, err := os.Create(outputPath)
|
||||
if err != nil {
|
||||
return exit(2, "create video %s: %v", outputPath, err)
|
||||
}
|
||||
ok := false
|
||||
defer func() {
|
||||
_ = file.Close()
|
||||
if !ok {
|
||||
_ = os.Remove(outputPath)
|
||||
}
|
||||
}()
|
||||
if err := runSSHToWriter(ctx, target, desktopVideoRemoteCommand(duration, fps), file); err != nil {
|
||||
return exit(5, "capture video: %v", err)
|
||||
}
|
||||
ok = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func desktopVideoRemoteCommand(duration time.Duration, fps float64) string {
|
||||
seconds := strconv.FormatFloat(duration.Seconds(), 'f', 3, 64)
|
||||
frameRate := strconv.FormatFloat(fps, 'f', 3, 64)
|
||||
return fmt.Sprintf(`set -eu
|
||||
export DISPLAY="${DISPLAY:-:99}"
|
||||
if ! command -v ffmpeg >/dev/null 2>&1; then
|
||||
echo "missing ffmpeg; warm a new --desktop lease or install ffmpeg" >&2
|
||||
exit 127
|
||||
fi
|
||||
if command -v xdpyinfo >/dev/null 2>&1; then
|
||||
size="$(xdpyinfo | awk '/dimensions:/{print $2; exit}')"
|
||||
else
|
||||
size=""
|
||||
fi
|
||||
if [ -z "$size" ]; then size="1920x1080"; fi
|
||||
ffmpeg -hide_banner -loglevel error -y -f x11grab -video_size "$size" -framerate %s -i "$DISPLAY" -t %s -pix_fmt yuv420p -an -movflags frag_keyframe+empty_moov -f mp4 -
|
||||
`, frameRate, seconds)
|
||||
}
|
||||
@ -1,636 +0,0 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"mime"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func parseArtifactPublishOptions(args []string, stderr io.Writer) (artifactPublishOptions, error) {
|
||||
fs := newFlagSet("artifacts publish", stderr)
|
||||
dir := fs.String("dir", os.Getenv("CRABBOX_ARTIFACTS_DIR"), "artifact bundle directory")
|
||||
storage := fs.String("storage", firstNonBlank(os.Getenv("CRABBOX_ARTIFACTS_STORAGE"), "auto"), "storage backend: auto, broker, local, s3, cloudflare, or r2")
|
||||
bucket := fs.String("bucket", os.Getenv("CRABBOX_ARTIFACTS_BUCKET"), "storage bucket")
|
||||
prefix := fs.String("prefix", os.Getenv("CRABBOX_ARTIFACTS_PREFIX"), "object key prefix")
|
||||
baseURL := fs.String("base-url", os.Getenv("CRABBOX_ARTIFACTS_BASE_URL"), "public base URL for inline-ready asset links")
|
||||
pr := fs.Int("pr", 0, "GitHub pull request number to comment on")
|
||||
repo := fs.String("repo", "", "GitHub repository slug for gh, e.g. openclaw/crabbox")
|
||||
template := fs.String("template", "openclaw", "comment template: openclaw or mantis")
|
||||
summary := fs.String("summary", "", "summary text")
|
||||
summaryFile := fs.String("summary-file", "", "summary markdown file")
|
||||
region := fs.String("region", firstNonBlank(os.Getenv("CRABBOX_ARTIFACTS_AWS_REGION"), os.Getenv("AWS_REGION"), os.Getenv("AWS_DEFAULT_REGION")), "AWS region for S3 URLs/CLI")
|
||||
profile := fs.String("profile", firstNonBlank(os.Getenv("CRABBOX_ARTIFACTS_AWS_PROFILE"), os.Getenv("AWS_PROFILE")), "AWS profile for S3 CLI")
|
||||
endpointURL := fs.String("endpoint-url", os.Getenv("CRABBOX_ARTIFACTS_ENDPOINT_URL"), "S3-compatible endpoint URL")
|
||||
acl := fs.String("acl", os.Getenv("CRABBOX_ARTIFACTS_S3_ACL"), "optional S3 ACL, e.g. public-read")
|
||||
presign := fs.Bool("presign", envBool("CRABBOX_ARTIFACTS_PRESIGN"), "use aws s3 presign URLs after upload")
|
||||
expires := fs.Duration("expires", envDuration("CRABBOX_ARTIFACTS_EXPIRES", 7*24*time.Hour), "presigned URL lifetime")
|
||||
dryRun := fs.Bool("dry-run", false, "print upload/comment commands without running them")
|
||||
noComment := fs.Bool("no-comment", false, "skip GitHub PR comment")
|
||||
if err := parseFlags(fs, args); err != nil {
|
||||
return artifactPublishOptions{}, err
|
||||
}
|
||||
explicit := map[string]bool{}
|
||||
fs.Visit(func(f *flag.Flag) {
|
||||
explicit[f.Name] = true
|
||||
})
|
||||
opts := artifactPublishOptions{
|
||||
Directory: strings.TrimSpace(*dir),
|
||||
Storage: normalizeArtifactStorage(*storage),
|
||||
Bucket: strings.TrimSpace(*bucket),
|
||||
Prefix: strings.Trim(strings.TrimSpace(*prefix), "/"),
|
||||
BaseURL: strings.TrimRight(strings.TrimSpace(*baseURL), "/"),
|
||||
PR: *pr,
|
||||
Repo: strings.TrimSpace(*repo),
|
||||
Template: strings.TrimSpace(*template),
|
||||
Summary: *summary,
|
||||
SummaryFile: strings.TrimSpace(*summaryFile),
|
||||
Region: strings.TrimSpace(*region),
|
||||
Profile: strings.TrimSpace(*profile),
|
||||
EndpointURL: strings.TrimSpace(*endpointURL),
|
||||
ACL: strings.TrimSpace(*acl),
|
||||
Presign: *presign,
|
||||
Expires: *expires,
|
||||
DryRun: *dryRun,
|
||||
NoComment: *noComment,
|
||||
}
|
||||
if opts.Storage == "r2" {
|
||||
if !explicit["profile"] {
|
||||
opts.Profile = firstNonBlank(os.Getenv("CRABBOX_ARTIFACTS_R2_AWS_PROFILE"), opts.Profile)
|
||||
}
|
||||
if !explicit["endpoint-url"] {
|
||||
opts.EndpointURL = firstNonBlank(os.Getenv("CRABBOX_ARTIFACTS_R2_ENDPOINT_URL"), opts.EndpointURL)
|
||||
}
|
||||
if !explicit["region"] {
|
||||
opts.Region = firstNonBlank(os.Getenv("CRABBOX_ARTIFACTS_R2_AWS_REGION"), "auto")
|
||||
}
|
||||
}
|
||||
if opts.Directory == "" {
|
||||
return artifactPublishOptions{}, exit(2, "artifacts publish requires --dir")
|
||||
}
|
||||
if opts.PR < 0 {
|
||||
return artifactPublishOptions{}, exit(2, "artifacts publish --pr must be positive")
|
||||
}
|
||||
if opts.Expires <= 0 {
|
||||
return artifactPublishOptions{}, exit(2, "artifacts publish --expires must be positive")
|
||||
}
|
||||
switch opts.Storage {
|
||||
case "auto", "broker", "local":
|
||||
case "s3", "cloudflare", "r2":
|
||||
if opts.Bucket == "" {
|
||||
return artifactPublishOptions{}, exit(2, "artifacts publish --storage %s requires --bucket", opts.Storage)
|
||||
}
|
||||
default:
|
||||
return artifactPublishOptions{}, exit(2, "artifacts publish --storage must be auto, broker, local, s3, cloudflare, or r2")
|
||||
}
|
||||
if opts.Storage == "r2" && opts.EndpointURL == "" {
|
||||
return artifactPublishOptions{}, exit(2, "artifacts publish --storage r2 requires --endpoint-url or CRABBOX_ARTIFACTS_R2_ENDPOINT_URL")
|
||||
}
|
||||
if (opts.Storage == "cloudflare" || opts.Storage == "r2") && opts.PR > 0 && !opts.NoComment && opts.BaseURL == "" {
|
||||
return artifactPublishOptions{}, exit(2, "artifacts publish --storage %s --pr requires --base-url for inline-ready R2 asset links", opts.Storage)
|
||||
}
|
||||
return opts, nil
|
||||
}
|
||||
|
||||
func normalizeArtifactStorage(storage string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(storage)) {
|
||||
case "", "auto":
|
||||
return "auto"
|
||||
case "broker", "coordinator":
|
||||
return "broker"
|
||||
case "local":
|
||||
return "local"
|
||||
case "s3", "aws", "aws-s3":
|
||||
return "s3"
|
||||
case "r2", "cloudflare-r2":
|
||||
return "r2"
|
||||
case "cloudflare", "cf":
|
||||
return "cloudflare"
|
||||
default:
|
||||
return strings.ToLower(strings.TrimSpace(storage))
|
||||
}
|
||||
}
|
||||
|
||||
func listArtifactBundleFiles(dir string) ([]artifactFile, error) {
|
||||
var files []artifactFile
|
||||
err := filepath.WalkDir(dir, func(path string, entry fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if entry.IsDir() {
|
||||
return nil
|
||||
}
|
||||
name := entry.Name()
|
||||
if name == "published-artifacts.md" {
|
||||
return nil
|
||||
}
|
||||
rel, err := filepath.Rel(dir, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
files = append(files, artifactFile{Kind: artifactKindForPath(path), Name: filepath.ToSlash(rel), Path: path})
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, exit(2, "read artifact directory: %v", err)
|
||||
}
|
||||
sortArtifactFiles(files)
|
||||
return files, nil
|
||||
}
|
||||
|
||||
func publishArtifactFiles(ctx context.Context, opts artifactPublishOptions, files []artifactFile) ([]artifactFile, error) {
|
||||
published := make([]artifactFile, 0, len(files))
|
||||
for _, file := range files {
|
||||
out := file
|
||||
key := artifactObjectKey(opts.Prefix, file.Name)
|
||||
switch opts.Storage {
|
||||
case "local":
|
||||
if opts.BaseURL != "" {
|
||||
out.URL = joinURLPath(opts.BaseURL, file.Name)
|
||||
}
|
||||
case "s3", "r2":
|
||||
url, err := uploadArtifactS3(ctx, opts, file.Path, key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out.URL = url
|
||||
case "cloudflare":
|
||||
url, err := uploadArtifactCloudflare(ctx, opts, file.Path, key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out.URL = url
|
||||
}
|
||||
published = append(published, out)
|
||||
}
|
||||
return published, nil
|
||||
}
|
||||
|
||||
func publishArtifactFilesBroker(ctx context.Context, coord *CoordinatorClient, opts artifactPublishOptions, files []artifactFile) ([]artifactFile, error) {
|
||||
if coord == nil || coord.Token == "" {
|
||||
return nil, exit(2, "artifacts publish --storage broker requires a configured coordinator; run `crabbox login` or pass --storage local|s3|r2")
|
||||
}
|
||||
ensureArtifactPublishPrefix(&opts)
|
||||
input := CoordinatorArtifactUploadRequest{
|
||||
Prefix: opts.Prefix,
|
||||
Files: make([]CoordinatorArtifactUploadInput, 0, len(files)),
|
||||
}
|
||||
for _, file := range files {
|
||||
info, err := os.Stat(file.Path)
|
||||
if err != nil {
|
||||
return nil, exit(2, "stat artifact %s: %v", file.Name, err)
|
||||
}
|
||||
hash, err := fileSHA256(file.Path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
input.Files = append(input.Files, CoordinatorArtifactUploadInput{
|
||||
Name: file.Name,
|
||||
Size: info.Size(),
|
||||
ContentType: artifactContentType(file.Path),
|
||||
SHA256: hash,
|
||||
})
|
||||
}
|
||||
grants, err := coord.CreateArtifactUploads(ctx, input)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
byName := map[string]CoordinatorArtifactUploadGrant{}
|
||||
for _, grant := range grants.Files {
|
||||
byName[grant.Name] = grant
|
||||
}
|
||||
published := make([]artifactFile, 0, len(files))
|
||||
for _, file := range files {
|
||||
grant, ok := byName[file.Name]
|
||||
if !ok {
|
||||
return nil, exit(2, "artifact broker did not return an upload grant for %s", file.Name)
|
||||
}
|
||||
if !opts.DryRun {
|
||||
if err := uploadArtifactGrant(ctx, file.Path, grant); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
out := file
|
||||
out.URL = grant.URL
|
||||
published = append(published, out)
|
||||
}
|
||||
return published, nil
|
||||
}
|
||||
|
||||
func ensureArtifactPublishPrefix(opts *artifactPublishOptions) {
|
||||
if opts == nil || opts.Prefix != "" || opts.Storage == "local" {
|
||||
return
|
||||
}
|
||||
opts.Prefix = defaultArtifactPublishPrefix(*opts, time.Now())
|
||||
}
|
||||
|
||||
func defaultArtifactPublishPrefix(opts artifactPublishOptions, now time.Time) string {
|
||||
scope := "publish"
|
||||
if opts.PR > 0 {
|
||||
scope = "pr-" + strconv.Itoa(opts.PR)
|
||||
}
|
||||
bundle := normalizeLeaseSlug(filepath.Base(filepath.Clean(opts.Directory)))
|
||||
if bundle == "" || bundle == "." {
|
||||
bundle = "bundle"
|
||||
}
|
||||
stamp := now.UTC().Format("20060102-150405") + "-" + fmt.Sprintf("%09d", now.UTC().Nanosecond())
|
||||
return strings.Join([]string{scope, bundle, stamp}, "/")
|
||||
}
|
||||
|
||||
func fileSHA256(path string) (string, error) {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return "", exit(2, "open artifact %s: %v", path, err)
|
||||
}
|
||||
defer file.Close()
|
||||
hash := sha256.New()
|
||||
if _, err := io.Copy(hash, file); err != nil {
|
||||
return "", exit(2, "hash artifact %s: %v", path, err)
|
||||
}
|
||||
return hex.EncodeToString(hash.Sum(nil)), nil
|
||||
}
|
||||
|
||||
func uploadArtifactGrant(ctx context.Context, path string, grant CoordinatorArtifactUploadGrant) error {
|
||||
if grant.Upload.URL == "" {
|
||||
return exit(2, "artifact broker returned an empty upload URL for %s", grant.Name)
|
||||
}
|
||||
method := strings.ToUpper(strings.TrimSpace(grant.Upload.Method))
|
||||
if method == "" {
|
||||
method = http.MethodPut
|
||||
}
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return exit(2, "open artifact %s: %v", grant.Name, err)
|
||||
}
|
||||
defer file.Close()
|
||||
info, err := file.Stat()
|
||||
if err != nil {
|
||||
return exit(2, "stat artifact %s: %v", grant.Name, err)
|
||||
}
|
||||
contentLength := info.Size()
|
||||
if expected, ok, err := grantContentLength(grant.Upload.Headers); err != nil {
|
||||
return exit(2, "artifact broker returned an invalid content-length for %s: %v", grant.Name, err)
|
||||
} else if ok {
|
||||
if expected != info.Size() {
|
||||
return exit(2, "artifact %s size changed after broker grant: got %d bytes, expected %d", grant.Name, info.Size(), expected)
|
||||
}
|
||||
contentLength = expected
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, grant.Upload.URL, file)
|
||||
if err != nil {
|
||||
return exit(2, "create artifact upload request for %s: %v", grant.Name, err)
|
||||
}
|
||||
req.ContentLength = contentLength
|
||||
for key, value := range grant.Upload.Headers {
|
||||
if strings.EqualFold(strings.TrimSpace(key), "content-length") {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(key) != "" && strings.TrimSpace(value) != "" {
|
||||
req.Header.Set(key, value)
|
||||
}
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return exit(2, "upload artifact %s: %v", grant.Name, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
return exit(2, "upload artifact %s: http %d: %s", grant.Name, resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func grantContentLength(headers map[string]string) (int64, bool, error) {
|
||||
for key, value := range headers {
|
||||
if !strings.EqualFold(strings.TrimSpace(key), "content-length") {
|
||||
continue
|
||||
}
|
||||
n, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64)
|
||||
if err != nil || n < 0 {
|
||||
if err == nil {
|
||||
err = fmt.Errorf("negative value")
|
||||
}
|
||||
return 0, true, err
|
||||
}
|
||||
return n, true, nil
|
||||
}
|
||||
return 0, false, nil
|
||||
}
|
||||
|
||||
func uploadArtifactS3(ctx context.Context, opts artifactPublishOptions, path, key string) (string, error) {
|
||||
dest := "s3://" + opts.Bucket + "/" + key
|
||||
args := awsBaseArgs(opts)
|
||||
args = append(args, "s3", "cp", path, dest, "--content-type", artifactContentType(path))
|
||||
if opts.ACL != "" {
|
||||
args = append(args, "--acl", opts.ACL)
|
||||
}
|
||||
if opts.DryRun {
|
||||
return artifactS3URL(opts, key), nil
|
||||
}
|
||||
if _, err := exec.LookPath("aws"); err != nil {
|
||||
return "", exit(2, "aws CLI is required for artifacts publish --storage s3: %v", err)
|
||||
}
|
||||
if out, err := commandOutput(ctx, "aws", args...); err != nil {
|
||||
return "", exit(2, "aws s3 upload failed: %v: %s", err, tailForError(out))
|
||||
}
|
||||
if opts.Presign && opts.BaseURL == "" {
|
||||
presignArgs := awsBaseArgs(opts)
|
||||
presignArgs = append(presignArgs, "s3", "presign", dest, "--expires-in", fmt.Sprintf("%.0f", opts.Expires.Seconds()))
|
||||
out, err := commandOutput(ctx, "aws", presignArgs...)
|
||||
if err != nil {
|
||||
return "", exit(2, "aws s3 presign failed: %v: %s", err, tailForError(out))
|
||||
}
|
||||
return strings.TrimSpace(out), nil
|
||||
}
|
||||
return artifactS3URL(opts, key), nil
|
||||
}
|
||||
|
||||
func awsBaseArgs(opts artifactPublishOptions) []string {
|
||||
var args []string
|
||||
if opts.Profile != "" {
|
||||
args = append(args, "--profile", opts.Profile)
|
||||
}
|
||||
if opts.Region != "" {
|
||||
args = append(args, "--region", opts.Region)
|
||||
}
|
||||
if opts.EndpointURL != "" {
|
||||
args = append(args, "--endpoint-url", opts.EndpointURL)
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
func uploadArtifactCloudflare(ctx context.Context, opts artifactPublishOptions, path, key string) (string, error) {
|
||||
if opts.DryRun {
|
||||
return artifactCloudflareURL(opts, key), nil
|
||||
}
|
||||
if _, err := exec.LookPath("wrangler"); err != nil {
|
||||
return "", exit(2, "wrangler CLI is required for artifacts publish --storage cloudflare: %v", err)
|
||||
}
|
||||
out, err := commandOutputWithEnv(ctx, artifactCloudflareEnv(), "wrangler", "r2", "object", "put", opts.Bucket+"/"+key, "--file", path, "--content-type", artifactContentType(path), "--remote")
|
||||
if err != nil {
|
||||
return "", exit(2, "wrangler r2 upload failed: %v: %s", err, tailForError(out))
|
||||
}
|
||||
return artifactCloudflareURL(opts, key), nil
|
||||
}
|
||||
|
||||
func artifactCloudflareEnv() []string {
|
||||
env := os.Environ()
|
||||
if token := firstNonBlank(
|
||||
os.Getenv("CRABBOX_ARTIFACTS_CLOUDFLARE_API_TOKEN"),
|
||||
os.Getenv("CLOUDFLARE_API_TOKEN"),
|
||||
); token != "" {
|
||||
env = append(env, "CLOUDFLARE_API_TOKEN="+token)
|
||||
}
|
||||
if accountID := firstNonBlank(
|
||||
os.Getenv("CRABBOX_ARTIFACTS_CLOUDFLARE_ACCOUNT_ID"),
|
||||
os.Getenv("CLOUDFLARE_ACCOUNT_ID"),
|
||||
); accountID != "" {
|
||||
env = append(env, "CLOUDFLARE_ACCOUNT_ID="+accountID)
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
func artifactS3URL(opts artifactPublishOptions, key string) string {
|
||||
if opts.BaseURL != "" {
|
||||
return joinURLPath(opts.BaseURL, key)
|
||||
}
|
||||
if opts.EndpointURL != "" {
|
||||
return joinURLPath(opts.EndpointURL, opts.Bucket+"/"+key)
|
||||
}
|
||||
escapedKey := pathEscapeSegments(key)
|
||||
if opts.Region != "" {
|
||||
return "https://" + opts.Bucket + ".s3." + opts.Region + ".amazonaws.com/" + escapedKey
|
||||
}
|
||||
return "https://" + opts.Bucket + ".s3.amazonaws.com/" + escapedKey
|
||||
}
|
||||
|
||||
func artifactCloudflareURL(opts artifactPublishOptions, key string) string {
|
||||
if opts.BaseURL != "" {
|
||||
return joinURLPath(opts.BaseURL, key)
|
||||
}
|
||||
return "r2://" + opts.Bucket + "/" + key
|
||||
}
|
||||
|
||||
func artifactObjectKey(prefix, name string) string {
|
||||
name = strings.TrimLeft(filepath.ToSlash(name), "/")
|
||||
if strings.TrimSpace(prefix) == "" {
|
||||
return name
|
||||
}
|
||||
return strings.Trim(strings.TrimSpace(prefix), "/") + "/" + name
|
||||
}
|
||||
|
||||
func artifactContentType(path string) string {
|
||||
if contentType := mime.TypeByExtension(strings.ToLower(filepath.Ext(path))); contentType != "" {
|
||||
return contentType
|
||||
}
|
||||
switch strings.ToLower(filepath.Ext(path)) {
|
||||
case ".md":
|
||||
return "text/markdown; charset=utf-8"
|
||||
case ".log":
|
||||
return "text/plain; charset=utf-8"
|
||||
default:
|
||||
return "application/octet-stream"
|
||||
}
|
||||
}
|
||||
|
||||
func artifactKindForPath(path string) string {
|
||||
name := strings.ToLower(filepath.Base(path))
|
||||
ext := strings.ToLower(filepath.Ext(path))
|
||||
switch {
|
||||
case ext == ".gif":
|
||||
return "gif"
|
||||
case artifactExtIsVideo(ext):
|
||||
return "video"
|
||||
case (artifactExtIsImage(ext) || ext == "") && (strings.Contains(name, "screenshot") || strings.Contains(name, "before") || strings.Contains(name, "after")):
|
||||
return "screenshot"
|
||||
case strings.Contains(name, "doctor"):
|
||||
return "doctor"
|
||||
case strings.Contains(name, "webvnc"):
|
||||
return "webvnc-status"
|
||||
case strings.Contains(name, "metadata"):
|
||||
return "metadata"
|
||||
case strings.Contains(name, "log") || ext == ".txt":
|
||||
return "logs"
|
||||
default:
|
||||
return strings.TrimPrefix(ext, ".")
|
||||
}
|
||||
}
|
||||
|
||||
func artifactExtIsVideo(ext string) bool {
|
||||
switch strings.ToLower(ext) {
|
||||
case ".mp4", ".mov", ".webm":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func artifactExtIsImage(ext string) bool {
|
||||
switch strings.ToLower(ext) {
|
||||
case ".png", ".jpg", ".jpeg", ".gif":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func artifactTemplateMarkdown(kind, summary, before, after string, files []artifactFile) string {
|
||||
title := "OpenClaw QA Artifacts"
|
||||
if strings.EqualFold(strings.TrimSpace(kind), "mantis") {
|
||||
title = "Mantis QA Artifacts"
|
||||
}
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "## %s\n\n", title)
|
||||
if strings.TrimSpace(summary) != "" {
|
||||
fmt.Fprintf(&b, "### Summary\n%s\n\n", strings.TrimSpace(summary))
|
||||
}
|
||||
if before != "" || after != "" {
|
||||
b.WriteString("### Before / After\n\n")
|
||||
if before != "" {
|
||||
fmt.Fprintf(&b, "**Before**\n\n%s\n\n", artifactMarkdownForAsset("before", before))
|
||||
}
|
||||
if after != "" {
|
||||
fmt.Fprintf(&b, "**After**\n\n%s\n\n", artifactMarkdownForAsset("after", after))
|
||||
}
|
||||
}
|
||||
if len(files) > 0 {
|
||||
b.WriteString("### Evidence\n\n")
|
||||
for _, file := range files {
|
||||
location := firstNonBlank(file.URL, file.Path)
|
||||
if location == "" {
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(&b, "- %s: %s\n", file.Kind, artifactMarkdownForFile(file, location))
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func artifactMarkdownForFile(file artifactFile, location string) string {
|
||||
if artifactFileIsInlineImage(file, location) {
|
||||
return fmt.Sprintf("", file.Name, location)
|
||||
}
|
||||
return fmt.Sprintf("[%s](%s)", file.Name, location)
|
||||
}
|
||||
|
||||
func artifactMarkdownForAsset(label, location string) string {
|
||||
if artifactLocationHasImageExtension(location) {
|
||||
return fmt.Sprintf("", label, location)
|
||||
}
|
||||
return fmt.Sprintf("[%s](%s)", label, location)
|
||||
}
|
||||
|
||||
func artifactFileIsInlineImage(file artifactFile, location string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(file.Kind)) {
|
||||
case "gif", "screenshot", "image":
|
||||
return true
|
||||
}
|
||||
return artifactLocationHasImageExtension(firstNonBlank(file.Name, location))
|
||||
}
|
||||
|
||||
func artifactLocationHasImageExtension(location string) bool {
|
||||
location = strings.TrimSpace(location)
|
||||
if parsed, err := url.Parse(location); err == nil && parsed.Path != "" {
|
||||
location = parsed.Path
|
||||
} else {
|
||||
location = strings.SplitN(location, "?", 2)[0]
|
||||
location = strings.SplitN(location, "#", 2)[0]
|
||||
}
|
||||
switch strings.ToLower(filepath.Ext(location)) {
|
||||
case ".png", ".jpg", ".jpeg", ".gif":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func summaryText(summary, summaryFile string) (string, error) {
|
||||
if strings.TrimSpace(summaryFile) == "" {
|
||||
return strings.TrimSpace(summary), nil
|
||||
}
|
||||
data, err := os.ReadFile(summaryFile)
|
||||
if err != nil {
|
||||
return "", exit(2, "read summary file: %v", err)
|
||||
}
|
||||
if strings.TrimSpace(summary) != "" {
|
||||
return strings.TrimSpace(summary) + "\n\n" + strings.TrimSpace(string(data)), nil
|
||||
}
|
||||
return strings.TrimSpace(string(data)), nil
|
||||
}
|
||||
|
||||
func postGitHubPRComment(ctx context.Context, pr int, repo, bodyPath string) error {
|
||||
args := []string{"issue", "comment", strconv.Itoa(pr), "--body-file", bodyPath}
|
||||
if strings.TrimSpace(repo) != "" {
|
||||
args = append(args, "--repo", strings.TrimSpace(repo))
|
||||
}
|
||||
if _, err := exec.LookPath("gh"); err != nil {
|
||||
return exit(2, "gh CLI is required for artifacts publish --pr: %v", err)
|
||||
}
|
||||
if out, err := commandOutput(ctx, "gh", args...); err != nil {
|
||||
return exit(2, "gh issue comment failed: %v: %s", err, tailForError(out))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sortArtifactFiles(files []artifactFile) {
|
||||
sort.Slice(files, func(i, j int) bool {
|
||||
if files[i].Kind == files[j].Kind {
|
||||
return files[i].Name < files[j].Name
|
||||
}
|
||||
return files[i].Kind < files[j].Kind
|
||||
})
|
||||
}
|
||||
|
||||
func joinURLPath(base, rel string) string {
|
||||
base = strings.TrimRight(strings.TrimSpace(base), "/")
|
||||
rel = strings.TrimLeft(filepath.ToSlash(rel), "/")
|
||||
if base == "" {
|
||||
return rel
|
||||
}
|
||||
return base + "/" + pathEscapeSegments(rel)
|
||||
}
|
||||
|
||||
func pathEscapeSegments(path string) string {
|
||||
parts := strings.Split(filepath.ToSlash(path), "/")
|
||||
for i, part := range parts {
|
||||
parts[i] = url.PathEscape(part)
|
||||
}
|
||||
return strings.Join(parts, "/")
|
||||
}
|
||||
|
||||
func envBool(name string) bool {
|
||||
value := strings.TrimSpace(os.Getenv(name))
|
||||
switch strings.ToLower(value) {
|
||||
case "1", "true", "yes", "on":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func envDuration(name string, fallback time.Duration) time.Duration {
|
||||
value := strings.TrimSpace(os.Getenv(name))
|
||||
if value == "" {
|
||||
return fallback
|
||||
}
|
||||
duration, err := time.ParseDuration(value)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return duration
|
||||
}
|
||||
@ -1,518 +0,0 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestParseArtifactPublishOptionsNormalizesStorage(t *testing.T) {
|
||||
opts, err := parseArtifactPublishOptions([]string{
|
||||
"--dir", "bundle",
|
||||
"--storage", "r2",
|
||||
"--bucket", "qa",
|
||||
"--base-url", "https://artifacts.example.com/root/",
|
||||
"--endpoint-url", "https://account.r2.cloudflarestorage.com",
|
||||
"--prefix", "/runs/123/",
|
||||
"--pr", "42",
|
||||
}, io.Discard)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if opts.Storage != "r2" {
|
||||
t.Fatalf("storage=%q", opts.Storage)
|
||||
}
|
||||
if opts.BaseURL != "https://artifacts.example.com/root" {
|
||||
t.Fatalf("baseURL=%q", opts.BaseURL)
|
||||
}
|
||||
if opts.Prefix != "runs/123" {
|
||||
t.Fatalf("prefix=%q", opts.Prefix)
|
||||
}
|
||||
if opts.PR != 42 {
|
||||
t.Fatalf("pr=%d", opts.PR)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseArtifactPublishOptionsRequiresExplicitDir(t *testing.T) {
|
||||
t.Setenv("CRABBOX_ARTIFACTS_DIR", "")
|
||||
_, err := parseArtifactPublishOptions([]string{
|
||||
"--storage", "local",
|
||||
}, io.Discard)
|
||||
if err == nil {
|
||||
t.Fatal("expected missing dir error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "requires --dir") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseArtifactPublishOptionsAllowsDirFromEnv(t *testing.T) {
|
||||
t.Setenv("CRABBOX_ARTIFACTS_DIR", "bundle")
|
||||
opts, err := parseArtifactPublishOptions([]string{
|
||||
"--storage", "local",
|
||||
}, io.Discard)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if opts.Directory != "bundle" {
|
||||
t.Fatalf("dir=%q", opts.Directory)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultArtifactPublishPrefixIsUniqueAndScoped(t *testing.T) {
|
||||
when := time.Date(2026, 5, 8, 3, 40, 41, 123456789, time.UTC)
|
||||
got := defaultArtifactPublishPrefix(artifactPublishOptions{
|
||||
Directory: "/tmp/artifacts/Blue Lobster",
|
||||
PR: 42,
|
||||
}, when)
|
||||
if got != "pr-42/blue-lobster/20260508-034041-123456789" {
|
||||
t.Fatalf("prefix=%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureArtifactPublishPrefixOnlyForHostedStorage(t *testing.T) {
|
||||
hosted := artifactPublishOptions{Storage: "s3", Directory: "/tmp/artifacts/Blue Lobster", PR: 42}
|
||||
ensureArtifactPublishPrefix(&hosted)
|
||||
if !strings.HasPrefix(hosted.Prefix, "pr-42/blue-lobster/") {
|
||||
t.Fatalf("hosted prefix=%q", hosted.Prefix)
|
||||
}
|
||||
|
||||
local := artifactPublishOptions{Storage: "local", Directory: "/tmp/artifacts/Blue Lobster", PR: 42}
|
||||
ensureArtifactPublishPrefix(&local)
|
||||
if local.Prefix != "" {
|
||||
t.Fatalf("local prefix=%q", local.Prefix)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseArtifactPublishOptionsR2UsesR2Defaults(t *testing.T) {
|
||||
t.Setenv("AWS_PROFILE", "default")
|
||||
t.Setenv("AWS_REGION", "us-east-1")
|
||||
t.Setenv("CRABBOX_ARTIFACTS_R2_AWS_PROFILE", "qa-r2")
|
||||
t.Setenv("CRABBOX_ARTIFACTS_R2_ENDPOINT_URL", "https://account.r2.cloudflarestorage.com")
|
||||
|
||||
opts, err := parseArtifactPublishOptions([]string{
|
||||
"--dir", "bundle",
|
||||
"--storage", "r2",
|
||||
"--bucket", "qa",
|
||||
}, io.Discard)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if opts.Profile != "qa-r2" {
|
||||
t.Fatalf("profile=%q", opts.Profile)
|
||||
}
|
||||
if opts.Region != "auto" {
|
||||
t.Fatalf("region=%q", opts.Region)
|
||||
}
|
||||
if opts.EndpointURL != "https://account.r2.cloudflarestorage.com" {
|
||||
t.Fatalf("endpointURL=%q", opts.EndpointURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseArtifactPublishOptionsRequiresCloudflareBaseURLForPR(t *testing.T) {
|
||||
_, err := parseArtifactPublishOptions([]string{
|
||||
"--dir", "bundle",
|
||||
"--storage", "cloudflare",
|
||||
"--bucket", "qa",
|
||||
"--pr", "42",
|
||||
}, io.Discard)
|
||||
if err == nil {
|
||||
t.Fatal("expected base-url validation error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "requires --base-url") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArtifactStorageURLs(t *testing.T) {
|
||||
s3 := artifactS3URL(artifactPublishOptions{Bucket: "qa", Region: "eu-west-1"}, "runs/1/screen shot.png")
|
||||
if s3 != "https://qa.s3.eu-west-1.amazonaws.com/runs/1/screen%20shot.png" {
|
||||
t.Fatalf("s3 url=%s", s3)
|
||||
}
|
||||
custom := artifactS3URL(artifactPublishOptions{Bucket: "qa", EndpointURL: "https://s3.example.com/root"}, "runs/1/screen shot.png")
|
||||
if custom != "https://s3.example.com/root/qa/runs/1/screen%20shot.png" {
|
||||
t.Fatalf("custom s3 url=%s", custom)
|
||||
}
|
||||
r2 := artifactCloudflareURL(artifactPublishOptions{Bucket: "qa", BaseURL: "https://assets.example.com/base"}, "runs/1/after.gif")
|
||||
if r2 != "https://assets.example.com/base/runs/1/after.gif" {
|
||||
t.Fatalf("r2 url=%s", r2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArtifactCloudflareEnvUsesGenericCredentials(t *testing.T) {
|
||||
t.Setenv("CLOUDFLARE_API_TOKEN", "generic-token")
|
||||
t.Setenv("CLOUDFLARE_ACCOUNT_ID", "generic-account")
|
||||
|
||||
env := strings.Join(artifactCloudflareEnv(), "\n")
|
||||
for _, want := range []string{
|
||||
"CLOUDFLARE_API_TOKEN=generic-token",
|
||||
"CLOUDFLARE_ACCOUNT_ID=generic-account",
|
||||
} {
|
||||
if !strings.Contains(env, want) {
|
||||
t.Fatalf("env missing %q in %s", want, env)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestArtifactCloudflareEnvArtifactCredentialsWin(t *testing.T) {
|
||||
t.Setenv("CLOUDFLARE_API_TOKEN", "generic-token")
|
||||
t.Setenv("CLOUDFLARE_ACCOUNT_ID", "generic-account")
|
||||
t.Setenv("CRABBOX_ARTIFACTS_CLOUDFLARE_API_TOKEN", "artifact-token")
|
||||
t.Setenv("CRABBOX_ARTIFACTS_CLOUDFLARE_ACCOUNT_ID", "artifact-account")
|
||||
|
||||
env := strings.Join(artifactCloudflareEnv(), "\n")
|
||||
for _, want := range []string{
|
||||
"CLOUDFLARE_API_TOKEN=artifact-token",
|
||||
"CLOUDFLARE_ACCOUNT_ID=artifact-account",
|
||||
} {
|
||||
if !strings.Contains(env, want) {
|
||||
t.Fatalf("env missing %q in %s", want, env)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestArtifactTemplateMarkdownUsesInlineImages(t *testing.T) {
|
||||
body := artifactTemplateMarkdown("mantis", "fixed login", "before.png", "https://cdn.example.com/after.gif", []artifactFile{
|
||||
{Kind: "logs", Name: "logs.txt", URL: "https://cdn.example.com/logs.txt"},
|
||||
{Kind: "screenshot", Name: "screenshot.png", URL: "https://s3.example.com/screenshot.png?X-Amz-Signature=abc"},
|
||||
})
|
||||
for _, want := range []string{
|
||||
"## Mantis QA Artifacts",
|
||||
"fixed login",
|
||||
"",
|
||||
"",
|
||||
"[logs.txt](https://cdn.example.com/logs.txt)",
|
||||
"",
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Fatalf("markdown missing %q:\n%s", want, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestArtifactMarkdownForAssetIgnoresQueryString(t *testing.T) {
|
||||
got := artifactMarkdownForAsset("after", "https://cdn.example.com/after.gif?token=secret")
|
||||
if got != "" {
|
||||
t.Fatalf("markdown=%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArtifactKindClassifiesBeforeAfterVideosAsVideo(t *testing.T) {
|
||||
for _, name := range []string{"before.mp4", "after.mov", "nested/after.webm"} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if got := artifactKindForPath(name); got != "video" {
|
||||
t.Fatalf("kind=%q", got)
|
||||
}
|
||||
markdown := artifactMarkdownForFile(artifactFile{Kind: artifactKindForPath(name), Name: filepath.Base(name)}, "https://cdn.example.com/"+filepath.Base(name))
|
||||
if strings.HasPrefix(markdown, "![") {
|
||||
t.Fatalf("video markdown should be a link, got %s", markdown)
|
||||
}
|
||||
if !strings.HasPrefix(markdown, "[") {
|
||||
t.Fatalf("video markdown should be a link, got %s", markdown)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestListArtifactBundleFilesSkipsPublishedMarkdown(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
mustWriteFile(t, filepath.Join(dir, "screenshot.png"), "png")
|
||||
mustWriteFile(t, filepath.Join(dir, "published-artifacts.md"), "old")
|
||||
mustWriteFile(t, filepath.Join(dir, "nested", "logs.txt"), "logs")
|
||||
files, err := listArtifactBundleFiles(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var names []string
|
||||
for _, file := range files {
|
||||
names = append(names, file.Name)
|
||||
}
|
||||
got := strings.Join(names, ",")
|
||||
if got != "nested/logs.txt,screenshot.png" {
|
||||
t.Fatalf("files=%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishArtifactFilesDryRunS3(t *testing.T) {
|
||||
files := []artifactFile{{Kind: "gif", Name: "screen.gif", Path: "screen.gif"}}
|
||||
opts := artifactPublishOptions{
|
||||
Storage: "s3",
|
||||
Bucket: "qa",
|
||||
Region: "us-east-1",
|
||||
Prefix: "runs/abc",
|
||||
DryRun: true,
|
||||
Expires: time.Hour,
|
||||
}
|
||||
published, err := publishArtifactFiles(context.Background(), opts, files)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(published) != 1 || published[0].URL != "https://qa.s3.us-east-1.amazonaws.com/runs/abc/screen.gif" {
|
||||
t.Fatalf("published=%#v", published)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishArtifactFilesDryRunS3BaseURLWinsOverPresign(t *testing.T) {
|
||||
files := []artifactFile{{Kind: "screenshot", Name: "screenshot.png", Path: "screenshot.png"}}
|
||||
opts := artifactPublishOptions{
|
||||
Storage: "r2",
|
||||
Bucket: "qa",
|
||||
Region: "auto",
|
||||
Prefix: "runs/abc",
|
||||
BaseURL: "https://artifacts.example.com",
|
||||
Presign: true,
|
||||
DryRun: true,
|
||||
Expires: time.Hour,
|
||||
}
|
||||
published, err := publishArtifactFiles(context.Background(), opts, files)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(published) != 1 || published[0].URL != "https://artifacts.example.com/runs/abc/screenshot.png" {
|
||||
t.Fatalf("published=%#v", published)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishArtifactFilesBrokerUploadsViaGrantedURL(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "screenshot.png")
|
||||
mustWriteFile(t, path, "png-data")
|
||||
wantHash := fmt.Sprintf("%x", sha256.Sum256([]byte("png-data")))
|
||||
var uploaded string
|
||||
var server *httptest.Server
|
||||
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/v1/artifacts/uploads":
|
||||
var req CoordinatorArtifactUploadRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
t.Fatalf("decode upload request: %v", err)
|
||||
}
|
||||
if len(req.Files) != 1 || req.Files[0].Name != "screenshot.png" || req.Files[0].SHA256 != wantHash {
|
||||
t.Fatalf("request=%#v", req)
|
||||
}
|
||||
w.Header().Set("content-type", "application/json")
|
||||
_, _ = fmt.Fprintf(w, `{
|
||||
"backend":"r2",
|
||||
"bucket":"qa",
|
||||
"prefix":"runs/abc",
|
||||
"expiresAt":"2026-05-08T00:00:00Z",
|
||||
"files":[{
|
||||
"name":"screenshot.png",
|
||||
"key":"runs/abc/screenshot.png",
|
||||
"upload":{"method":"PUT","url":%q,"headers":{"content-type":"image/png","content-length":"8"},"expiresAt":"2026-05-08T00:00:00Z"},
|
||||
"url":"https://artifacts.example.com/runs/abc/screenshot.png"
|
||||
}]
|
||||
}`, server.URL+"/upload/screenshot.png")
|
||||
case "/upload/screenshot.png":
|
||||
if r.Method != http.MethodPut {
|
||||
t.Fatalf("method=%s", r.Method)
|
||||
}
|
||||
if r.ContentLength != int64(len("png-data")) {
|
||||
t.Fatalf("content length=%d", r.ContentLength)
|
||||
}
|
||||
if len(r.TransferEncoding) > 0 {
|
||||
t.Fatalf("transfer encoding=%v", r.TransferEncoding)
|
||||
}
|
||||
data, _ := io.ReadAll(r.Body)
|
||||
uploaded = string(data)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
default:
|
||||
t.Fatalf("unexpected path %s", r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
published, err := publishArtifactFilesBroker(context.Background(), &CoordinatorClient{
|
||||
BaseURL: server.URL,
|
||||
Token: "token",
|
||||
Client: server.Client(),
|
||||
}, artifactPublishOptions{Storage: "broker", Prefix: "runs/abc"}, []artifactFile{
|
||||
{Kind: "screenshot", Name: "screenshot.png", Path: path},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if uploaded != "png-data" {
|
||||
t.Fatalf("uploaded=%q", uploaded)
|
||||
}
|
||||
if len(published) != 1 || published[0].URL != "https://artifacts.example.com/runs/abc/screenshot.png" {
|
||||
t.Fatalf("published=%#v", published)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadArtifactGrantRejectsSizeMismatch(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "screenshot.png")
|
||||
mustWriteFile(t, path, "png-data")
|
||||
|
||||
err := uploadArtifactGrant(context.Background(), path, CoordinatorArtifactUploadGrant{
|
||||
Name: "screenshot.png",
|
||||
Upload: struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Headers map[string]string `json:"headers"`
|
||||
ExpiresAt string `json:"expiresAt"`
|
||||
}{
|
||||
Method: "PUT",
|
||||
URL: "https://artifacts.example.com/upload",
|
||||
Headers: map[string]string{"content-length": "1"},
|
||||
},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected size mismatch error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "size changed after broker grant") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArtifactsPublishValidatesSummaryBeforeMarkdownSideEffects(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
mustWriteFile(t, filepath.Join(dir, "screenshot.png"), "png")
|
||||
var stdout, stderr bytes.Buffer
|
||||
app := App{Stdout: &stdout, Stderr: &stderr}
|
||||
err := app.artifactsPublish(context.Background(), []string{
|
||||
"--dir", dir,
|
||||
"--storage", "s3",
|
||||
"--bucket", "qa",
|
||||
"--dry-run",
|
||||
"--summary-file", filepath.Join(dir, "missing.md"),
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected missing summary file error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "read summary file") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if _, statErr := os.Stat(filepath.Join(dir, "published-artifacts.md")); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("published markdown should not be written before summary validation: %v", statErr)
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("stdout should be empty before publish side effects, got %q", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintArtifactWarningUsesRescueShape(t *testing.T) {
|
||||
var out bytes.Buffer
|
||||
printArtifactWarning(&out, artifactWarning{
|
||||
Problem: "WebVNC daemon not running",
|
||||
Detail: "portal has no active bridge",
|
||||
Rescue: []string{"crabbox webvnc reset --id blue --open"},
|
||||
})
|
||||
for _, want := range []string{
|
||||
"problem: WebVNC daemon not running\n",
|
||||
"detail: portal has no active bridge\n",
|
||||
"rescue: crabbox webvnc reset --id blue --open\n",
|
||||
} {
|
||||
if !strings.Contains(out.String(), want) {
|
||||
t.Fatalf("warning missing %q:\n%s", want, out.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestArtifactCollectFailureJSONIsParseable(t *testing.T) {
|
||||
var stdout bytes.Buffer
|
||||
app := App{Stdout: &stdout, Stderr: io.Discard}
|
||||
result := artifactCollectResult{
|
||||
Directory: "/tmp/bundle",
|
||||
Metadata: artifactBundleMetadata{LeaseID: "cbx_123"},
|
||||
Files: []artifactFile{{Kind: "metadata", Name: "metadata.json", Path: "/tmp/bundle/metadata.json"}},
|
||||
}
|
||||
err := app.finishArtifactCollectFailure(&result, true, exit(5, "capture screenshot: boom"), artifactWarning{
|
||||
Problem: rescueScreenshotCaptureBroken,
|
||||
Detail: "capture screenshot: boom",
|
||||
Rescue: []string{"crabbox desktop doctor --id cbx_123"},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected original collection error")
|
||||
}
|
||||
if strings.Contains(stdout.String(), "problem:") {
|
||||
t.Fatalf("json stdout contains human rescue text: %q", stdout.String())
|
||||
}
|
||||
var decoded artifactCollectResult
|
||||
if decodeErr := json.Unmarshal(stdout.Bytes(), &decoded); decodeErr != nil {
|
||||
t.Fatalf("stdout is not valid JSON: %v\n%s", decodeErr, stdout.String())
|
||||
}
|
||||
if decoded.Error == nil || decoded.Error.Code != "screenshot-capture-broken" {
|
||||
t.Fatalf("decoded error=%#v", decoded.Error)
|
||||
}
|
||||
if decoded.Files == nil || len(decoded.Files) != 1 {
|
||||
t.Fatalf("files should remain a JSON array: %#v", decoded.Files)
|
||||
}
|
||||
if len(decoded.Warnings) != 1 || decoded.Warnings[0].Problem != rescueScreenshotCaptureBroken {
|
||||
t.Fatalf("warnings=%#v", decoded.Warnings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArtifactsCollectValidationThroughKongStripsCommandPath(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
app := App{Stdout: &stdout, Stderr: &stderr}
|
||||
err := app.Run(context.Background(), []string{"artifacts", "collect", "--gif", "--id", "dummy"})
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--gif requires --video or --all") {
|
||||
t.Fatalf("err=%v stderr=%s stdout=%s", err, stderr.String(), stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteArtifactWebVNCStatusRecordsWarningsWithoutStdout(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v1/leases/cbx_123/webvnc/status" {
|
||||
t.Fatalf("unexpected path %s", r.URL.Path)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(CoordinatorWebVNCStatus{
|
||||
LeaseID: "cbx_123",
|
||||
Slug: "blue-lobster",
|
||||
BridgeConnected: false,
|
||||
ViewerConnected: true,
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
dir := t.TempDir()
|
||||
var stdout bytes.Buffer
|
||||
app := App{Stdout: &stdout, Stderr: io.Discard}
|
||||
var warnings []artifactWarning
|
||||
path, ok, err := app.writeArtifactWebVNCStatus(context.Background(), Config{
|
||||
Provider: "aws",
|
||||
TargetOS: targetLinux,
|
||||
Coordinator: server.URL,
|
||||
CoordToken: "token",
|
||||
}, SSHTarget{TargetOS: targetLinux}, "cbx_123", dir, &warnings)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !ok || path == "" {
|
||||
t.Fatalf("path=%q ok=%t", path, ok)
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("status helper should not write rescue text directly to stdout, got %q", stdout.String())
|
||||
}
|
||||
if len(warnings) != 1 {
|
||||
t.Fatalf("warnings=%#v", warnings)
|
||||
}
|
||||
if warnings[0].Problem != rescueVNCBridgeNotRunning {
|
||||
t.Fatalf("warnings=%#v", warnings)
|
||||
}
|
||||
}
|
||||
|
||||
func mustWriteFile(t *testing.T, path, data string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(data), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@ -21,7 +21,7 @@ const defaultCoordinatorURL = "https://crabbox.openclaw.ai"
|
||||
func (a App) login(ctx context.Context, args []string) error {
|
||||
fs := newFlagSet("login", a.Stderr)
|
||||
brokerURL := fs.String("url", "", "broker URL")
|
||||
provider := fs.String("provider", "", "default provider: hetzner, aws, or azure")
|
||||
provider := fs.String("provider", "", "default provider: hetzner or aws")
|
||||
tokenStdin := fs.Bool("token-stdin", false, "read broker token from stdin")
|
||||
noBrowser := fs.Bool("no-browser", false, "print GitHub login URL instead of opening a browser")
|
||||
jsonOut := fs.Bool("json", false, "print JSON")
|
||||
|
||||
@ -1,976 +0,0 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/azcore"
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/azcore/to"
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/azidentity"
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v6"
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v6"
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources"
|
||||
)
|
||||
|
||||
const (
|
||||
azureAddressSpace = "10.42.0.0/16"
|
||||
azureSubnetCIDR = "10.42.0.0/24"
|
||||
azureProviderTag = "crabbox"
|
||||
defaultAzureLinuxImage = "Canonical:0001-com-ubuntu-server-jammy:22_04-lts-gen2:latest"
|
||||
defaultAzureWindowsImage = "MicrosoftWindowsServer:windowsserver2022:2022-datacenter-smalldisk-g2:latest"
|
||||
azureDeleteRetryDelay = 15 * time.Second
|
||||
azureDeleteRetryAttempts = 13
|
||||
)
|
||||
|
||||
type AzureClient struct {
|
||||
SubscriptionID string
|
||||
Location string
|
||||
ResourceGroup string
|
||||
VNet string
|
||||
Subnet string
|
||||
NSG string
|
||||
SSHCIDRs []string
|
||||
Image azureImageRef
|
||||
SSHPort string
|
||||
FallbackPorts []string
|
||||
|
||||
cred azcore.TokenCredential
|
||||
rg *armresources.ResourceGroupsClient
|
||||
vnetc *armnetwork.VirtualNetworksClient
|
||||
sgc *armnetwork.SecurityGroupsClient
|
||||
pipc *armnetwork.PublicIPAddressesClient
|
||||
nicc *armnetwork.InterfacesClient
|
||||
vmc *armcompute.VirtualMachinesClient
|
||||
vmextc *armcompute.VirtualMachineExtensionsClient
|
||||
diskc *armcompute.DisksClient
|
||||
skuc *armcompute.ResourceSKUsClient
|
||||
|
||||
ephemeralOSSupport map[string]bool
|
||||
}
|
||||
|
||||
type azureImageRef struct{ Publisher, Offer, SKU, Version string }
|
||||
|
||||
func NewAzureClient(ctx context.Context, cfg Config) (*AzureClient, error) {
|
||||
_ = ctx
|
||||
if cfg.AzureSubscription == "" {
|
||||
return nil, exit(3, "AZURE_SUBSCRIPTION_ID is required for direct azure provider")
|
||||
}
|
||||
if cfg.AzureLocation == "" {
|
||||
return nil, exit(3, "azure location is required (set azure.location or CRABBOX_AZURE_LOCATION)")
|
||||
}
|
||||
cred, err := azureCredentialForConfig(cfg)
|
||||
if err != nil {
|
||||
return nil, exit(3, "azure credential: %v", err)
|
||||
}
|
||||
img, err := parseAzureImageRef(azureImageForConfig(cfg))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rgFactory, err := armresources.NewClientFactory(cfg.AzureSubscription, cred, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("armresources factory: %w", err)
|
||||
}
|
||||
netFactory, err := armnetwork.NewClientFactory(cfg.AzureSubscription, cred, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("armnetwork factory: %w", err)
|
||||
}
|
||||
cmpFactory, err := armcompute.NewClientFactory(cfg.AzureSubscription, cred, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("armcompute factory: %w", err)
|
||||
}
|
||||
cidrs := cfg.AzureSSHCIDRs
|
||||
if len(cidrs) == 0 {
|
||||
cidrs = []string{"0.0.0.0/0"}
|
||||
}
|
||||
return &AzureClient{
|
||||
SubscriptionID: cfg.AzureSubscription,
|
||||
Location: cfg.AzureLocation,
|
||||
ResourceGroup: cfg.AzureResourceGroup,
|
||||
VNet: cfg.AzureVNet,
|
||||
Subnet: cfg.AzureSubnet,
|
||||
NSG: cfg.AzureNSG,
|
||||
SSHCIDRs: cidrs,
|
||||
Image: img,
|
||||
SSHPort: cfg.SSHPort,
|
||||
FallbackPorts: cfg.SSHFallbackPorts,
|
||||
cred: cred,
|
||||
rg: rgFactory.NewResourceGroupsClient(),
|
||||
vnetc: netFactory.NewVirtualNetworksClient(),
|
||||
sgc: netFactory.NewSecurityGroupsClient(),
|
||||
pipc: netFactory.NewPublicIPAddressesClient(),
|
||||
nicc: netFactory.NewInterfacesClient(),
|
||||
vmc: cmpFactory.NewVirtualMachinesClient(),
|
||||
vmextc: cmpFactory.NewVirtualMachineExtensionsClient(),
|
||||
diskc: cmpFactory.NewDisksClient(),
|
||||
skuc: cmpFactory.NewResourceSKUsClient(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func azureCredentialForConfig(cfg Config) (azcore.TokenCredential, error) {
|
||||
if cfg.AzureTenant != "" && cfg.AzureClientID != "" {
|
||||
if secret := os.Getenv("AZURE_CLIENT_SECRET"); secret != "" {
|
||||
return azidentity.NewClientSecretCredential(cfg.AzureTenant, cfg.AzureClientID, secret, nil)
|
||||
}
|
||||
}
|
||||
return azidentity.NewDefaultAzureCredential(nil)
|
||||
}
|
||||
|
||||
func parseAzureImageRef(s string) (azureImageRef, error) {
|
||||
parts := strings.Split(s, ":")
|
||||
if len(parts) != 4 {
|
||||
return azureImageRef{}, exit(2, "azure image must be Publisher:Offer:SKU:Version, got %q", s)
|
||||
}
|
||||
return azureImageRef{Publisher: parts[0], Offer: parts[1], SKU: parts[2], Version: parts[3]}, nil
|
||||
}
|
||||
|
||||
func azureImageForConfig(cfg Config) string {
|
||||
if cfg.TargetOS == targetWindows && (cfg.AzureImage == "" || cfg.AzureImage == defaultAzureLinuxImage) {
|
||||
return defaultAzureWindowsImage
|
||||
}
|
||||
if cfg.AzureImage == "" {
|
||||
return defaultAzureLinuxImage
|
||||
}
|
||||
return cfg.AzureImage
|
||||
}
|
||||
|
||||
func azureVMSizeCandidatesForConfig(cfg Config) []string {
|
||||
return azureVMSizeCandidatesForTargetModeClass(cfg.TargetOS, cfg.WindowsMode, cfg.Class)
|
||||
}
|
||||
|
||||
func azureVMSizeCandidatesForTargetModeClass(target, windowsMode, class string) []string {
|
||||
switch target {
|
||||
case targetLinux:
|
||||
return azureVMSizeCandidatesForClass(class)
|
||||
case targetWindows:
|
||||
if windowsMode == windowsModeNormal {
|
||||
return azureWindowsVMSizeCandidatesForClass(class)
|
||||
}
|
||||
return []string{class}
|
||||
default:
|
||||
return []string{class}
|
||||
}
|
||||
}
|
||||
|
||||
func azureVMSizeCandidatesForClass(class string) []string {
|
||||
switch class {
|
||||
case "standard":
|
||||
return []string{"Standard_D32ads_v6", "Standard_D32ds_v6", "Standard_F32s_v2", "Standard_D32ads_v5", "Standard_D32ds_v5", "Standard_D16ads_v6", "Standard_D16ds_v6", "Standard_F16s_v2"}
|
||||
case "fast":
|
||||
return []string{"Standard_D64ads_v6", "Standard_D64ds_v6", "Standard_F64s_v2", "Standard_D64ads_v5", "Standard_D64ds_v5", "Standard_D48ads_v6", "Standard_D48ds_v6", "Standard_F48s_v2", "Standard_D32ads_v6", "Standard_D32ds_v6", "Standard_F32s_v2"}
|
||||
case "large":
|
||||
return []string{"Standard_D96ads_v6", "Standard_D96ds_v6", "Standard_D96ads_v5", "Standard_D96ds_v5", "Standard_D64ads_v6", "Standard_D64ds_v6", "Standard_F64s_v2", "Standard_D48ads_v6", "Standard_D48ds_v6", "Standard_F48s_v2"}
|
||||
case "beast":
|
||||
return []string{"Standard_D192ds_v6", "Standard_D128ds_v6", "Standard_D96ads_v6", "Standard_D96ds_v6", "Standard_D96ads_v5", "Standard_D96ds_v5", "Standard_D64ads_v6", "Standard_D64ds_v6", "Standard_F64s_v2"}
|
||||
default:
|
||||
return []string{class}
|
||||
}
|
||||
}
|
||||
|
||||
func azureWindowsVMSizeCandidatesForClass(class string) []string {
|
||||
switch class {
|
||||
case "standard":
|
||||
return []string{"Standard_D2ads_v6", "Standard_D2ds_v6", "Standard_D2ads_v5", "Standard_D2ds_v5", "Standard_D2as_v6"}
|
||||
case "fast":
|
||||
return []string{"Standard_D4ads_v6", "Standard_D4ds_v6", "Standard_D4ads_v5", "Standard_D4ds_v5", "Standard_D4as_v6"}
|
||||
case "large":
|
||||
return []string{"Standard_D8ads_v6", "Standard_D8ds_v6", "Standard_D8ads_v5", "Standard_D8ds_v5", "Standard_D8as_v6"}
|
||||
case "beast":
|
||||
return []string{"Standard_D16ads_v6", "Standard_D16ds_v6", "Standard_D16ads_v5", "Standard_D16ds_v5", "Standard_D8ads_v6"}
|
||||
default:
|
||||
return []string{class}
|
||||
}
|
||||
}
|
||||
|
||||
func azureSupportsEphemeralOS(vmSize string) bool {
|
||||
normalized := strings.ToLower(vmSize)
|
||||
if strings.HasPrefix(normalized, "standard_f") && strings.HasSuffix(normalized, "s_v2") {
|
||||
return true
|
||||
}
|
||||
if (strings.HasPrefix(normalized, "standard_d") || strings.HasPrefix(normalized, "standard_e")) &&
|
||||
(strings.Contains(normalized, "ds_v5") || strings.Contains(normalized, "ds_v6")) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (c *AzureClient) supportsEphemeralOS(ctx context.Context, vmSize string) bool {
|
||||
if c.skuc == nil {
|
||||
return azureSupportsEphemeralOS(vmSize)
|
||||
}
|
||||
if c.ephemeralOSSupport == nil {
|
||||
if err := c.loadEphemeralOSSupport(ctx); err != nil {
|
||||
return azureSupportsEphemeralOS(vmSize)
|
||||
}
|
||||
}
|
||||
supported, ok := c.ephemeralOSSupport[vmSize]
|
||||
if !ok {
|
||||
return azureSupportsEphemeralOS(vmSize)
|
||||
}
|
||||
return supported
|
||||
}
|
||||
|
||||
func (c *AzureClient) loadEphemeralOSSupport(ctx context.Context) error {
|
||||
support := map[string]bool{}
|
||||
filter := fmt.Sprintf("location eq '%s'", c.Location)
|
||||
pager := c.skuc.NewListPager(&armcompute.ResourceSKUsClientListOptions{Filter: to.Ptr(filter)})
|
||||
for pager.More() {
|
||||
page, err := pager.NextPage(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, sku := range page.Value {
|
||||
if sku == nil || sku.Name == nil || sku.ResourceType == nil || *sku.ResourceType != "virtualMachines" {
|
||||
continue
|
||||
}
|
||||
support[*sku.Name] = azureSKUCapabilityTrue(sku.Capabilities, "EphemeralOSDiskSupported")
|
||||
}
|
||||
}
|
||||
c.ephemeralOSSupport = support
|
||||
return nil
|
||||
}
|
||||
|
||||
func azureSKUCapabilityTrue(capabilities []*armcompute.ResourceSKUCapabilities, name string) bool {
|
||||
for _, capability := range capabilities {
|
||||
if capability == nil || capability.Name == nil || capability.Value == nil {
|
||||
continue
|
||||
}
|
||||
if *capability.Name == name && strings.EqualFold(*capability.Value, "true") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (c *AzureClient) EnsureSharedInfra(ctx context.Context) error {
|
||||
if err := c.ensureResourceGroup(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.ensureVNet(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
return c.ensureNSG(ctx)
|
||||
}
|
||||
|
||||
func azureSharedTags() map[string]*string {
|
||||
return map[string]*string{
|
||||
azureProviderTag: to.Ptr("true"),
|
||||
"managed_by": to.Ptr("crabbox"),
|
||||
}
|
||||
}
|
||||
|
||||
func azureManagedByCrabbox(tags map[string]*string) bool {
|
||||
if tags == nil {
|
||||
return false
|
||||
}
|
||||
v := tags["managed_by"]
|
||||
if v == nil {
|
||||
return false
|
||||
}
|
||||
return *v == "crabbox"
|
||||
}
|
||||
|
||||
func azureAdoptError(kind, name string) error {
|
||||
return fmt.Errorf("azure %s %q exists but is not Crabbox-managed; either delete it, set tag managed_by=crabbox to adopt it, or use a different name", kind, name)
|
||||
}
|
||||
|
||||
func preserveNonCrabboxRules(rules []*armnetwork.SecurityRule) []*armnetwork.SecurityRule {
|
||||
out := make([]*armnetwork.SecurityRule, 0, len(rules))
|
||||
for _, rule := range rules {
|
||||
if rule == nil || rule.Name == nil {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(*rule.Name, "crabbox-ssh-") {
|
||||
continue
|
||||
}
|
||||
out = append(out, rule)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (c *AzureClient) ensureResourceGroup(ctx context.Context) error {
|
||||
existing, err := c.rg.Get(ctx, c.ResourceGroup, nil)
|
||||
if err == nil {
|
||||
if !azureManagedByCrabbox(existing.Tags) {
|
||||
return azureAdoptError("resource group", c.ResourceGroup)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !isAzureNotFoundError(err) {
|
||||
return fmt.Errorf("get resource group: %w", err)
|
||||
}
|
||||
if _, err := c.rg.CreateOrUpdate(ctx, c.ResourceGroup, armresources.ResourceGroup{
|
||||
Location: to.Ptr(c.Location),
|
||||
Tags: azureSharedTags(),
|
||||
}, nil); err != nil {
|
||||
return fmt.Errorf("create resource group: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *AzureClient) ensureVNet(ctx context.Context) error {
|
||||
existing, err := c.vnetc.Get(ctx, c.ResourceGroup, c.VNet, nil)
|
||||
if err == nil {
|
||||
if !azureManagedByCrabbox(existing.Tags) {
|
||||
return azureAdoptError("virtual network", c.VNet)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !isAzureNotFoundError(err) {
|
||||
return fmt.Errorf("get vnet: %w", err)
|
||||
}
|
||||
poller, err := c.vnetc.BeginCreateOrUpdate(ctx, c.ResourceGroup, c.VNet, armnetwork.VirtualNetwork{
|
||||
Location: to.Ptr(c.Location),
|
||||
Tags: azureSharedTags(),
|
||||
Properties: &armnetwork.VirtualNetworkPropertiesFormat{
|
||||
AddressSpace: &armnetwork.AddressSpace{
|
||||
AddressPrefixes: []*string{to.Ptr(azureAddressSpace)},
|
||||
},
|
||||
Subnets: []*armnetwork.Subnet{{
|
||||
Name: to.Ptr(c.Subnet),
|
||||
Properties: &armnetwork.SubnetPropertiesFormat{
|
||||
AddressPrefix: to.Ptr(azureSubnetCIDR),
|
||||
},
|
||||
}},
|
||||
},
|
||||
}, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin vnet create: %w", err)
|
||||
}
|
||||
if _, err := poller.PollUntilDone(ctx, nil); err != nil {
|
||||
return fmt.Errorf("vnet create: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *AzureClient) ensureNSG(ctx context.Context) error {
|
||||
existing, err := c.sgc.Get(ctx, c.ResourceGroup, c.NSG, nil)
|
||||
existingRules := []*armnetwork.SecurityRule{}
|
||||
if err == nil {
|
||||
if !azureManagedByCrabbox(existing.Tags) {
|
||||
return azureAdoptError("network security group", c.NSG)
|
||||
}
|
||||
if existing.Properties != nil {
|
||||
existingRules = existing.Properties.SecurityRules
|
||||
}
|
||||
} else if !isAzureNotFoundError(err) {
|
||||
return fmt.Errorf("get nsg: %w", err)
|
||||
}
|
||||
rules := preserveNonCrabboxRules(existingRules)
|
||||
usedPriorities := azureNSGUsedPriorities(rules)
|
||||
for _, port := range sshPortCandidates(c.SSHPort, c.FallbackPorts) {
|
||||
for j, cidr := range c.SSHCIDRs {
|
||||
priority, err := nextAzureNSGPriority(usedPriorities)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rules = append(rules, &armnetwork.SecurityRule{
|
||||
Name: to.Ptr(fmt.Sprintf("crabbox-ssh-%s-%d", port, j)),
|
||||
Properties: &armnetwork.SecurityRulePropertiesFormat{
|
||||
Protocol: to.Ptr(armnetwork.SecurityRuleProtocolTCP),
|
||||
Access: to.Ptr(armnetwork.SecurityRuleAccessAllow),
|
||||
Direction: to.Ptr(armnetwork.SecurityRuleDirectionInbound),
|
||||
Priority: to.Ptr(priority),
|
||||
SourceAddressPrefix: to.Ptr(cidr),
|
||||
SourcePortRange: to.Ptr("*"),
|
||||
DestinationAddressPrefix: to.Ptr("*"),
|
||||
DestinationPortRange: to.Ptr(port),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
poller, err := c.sgc.BeginCreateOrUpdate(ctx, c.ResourceGroup, c.NSG, armnetwork.SecurityGroup{
|
||||
Location: to.Ptr(c.Location),
|
||||
Tags: azureSharedTags(),
|
||||
Properties: &armnetwork.SecurityGroupPropertiesFormat{
|
||||
SecurityRules: rules,
|
||||
},
|
||||
}, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin nsg create: %w", err)
|
||||
}
|
||||
if _, err := poller.PollUntilDone(ctx, nil); err != nil {
|
||||
return fmt.Errorf("nsg create: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func azureNSGUsedPriorities(rules []*armnetwork.SecurityRule) map[int32]bool {
|
||||
used := map[int32]bool{}
|
||||
for _, rule := range rules {
|
||||
if rule == nil || rule.Properties == nil || rule.Properties.Priority == nil {
|
||||
continue
|
||||
}
|
||||
used[*rule.Properties.Priority] = true
|
||||
}
|
||||
return used
|
||||
}
|
||||
|
||||
func nextAzureNSGPriority(used map[int32]bool) (int32, error) {
|
||||
for priority := int32(100); priority <= 4096; priority++ {
|
||||
if !used[priority] {
|
||||
used[priority] = true
|
||||
return priority, nil
|
||||
}
|
||||
}
|
||||
return 0, errors.New("azure nsg: no available security rule priorities")
|
||||
}
|
||||
|
||||
func (c *AzureClient) CreateServerWithFallback(ctx context.Context, cfg Config, publicKey, leaseID, slug string, keep bool, logf func(string, ...any)) (Server, Config, error) {
|
||||
if err := c.EnsureSharedInfra(ctx); err != nil {
|
||||
return Server{}, cfg, err
|
||||
}
|
||||
var candidates []string
|
||||
if cfg.ServerTypeExplicit && cfg.ServerType != "" {
|
||||
candidates = []string{cfg.ServerType}
|
||||
} else {
|
||||
candidates = azureVMSizeCandidatesForConfig(cfg)
|
||||
if cfg.ServerType != "" && cfg.ServerType != candidates[0] {
|
||||
candidates = append([]string{cfg.ServerType}, candidates...)
|
||||
}
|
||||
}
|
||||
var errs []error
|
||||
for i, vmSize := range candidates {
|
||||
next := cfg
|
||||
next.ServerType = vmSize
|
||||
if i > 0 && logf != nil {
|
||||
logf("fallback provisioning type=%s after quota/capacity rejection\n", vmSize)
|
||||
}
|
||||
server, err := c.createServer(ctx, next, publicKey, leaseID, slug, keep)
|
||||
if err == nil {
|
||||
return server, next, nil
|
||||
}
|
||||
errs = append(errs, fmt.Errorf("%s: %w", vmSize, err))
|
||||
if !isAzureRetryableProvisioningError(err) {
|
||||
return Server{}, next, joinErrors(errs)
|
||||
}
|
||||
}
|
||||
if strings.EqualFold(cfg.Capacity.Market, "spot") && strings.HasPrefix(cfg.Capacity.Fallback, "on-demand") {
|
||||
for _, vmSize := range candidates {
|
||||
next := cfg
|
||||
next.ServerType = vmSize
|
||||
next.Capacity.Market = "on-demand"
|
||||
if logf != nil {
|
||||
logf("fallback provisioning type=%s market=on-demand after spot rejection\n", vmSize)
|
||||
}
|
||||
server, err := c.createServer(ctx, next, publicKey, leaseID, slug, keep)
|
||||
if err == nil {
|
||||
return server, next, nil
|
||||
}
|
||||
errs = append(errs, fmt.Errorf("on-demand %s: %w", vmSize, err))
|
||||
if !isAzureRetryableProvisioningError(err) {
|
||||
return Server{}, next, joinErrors(errs)
|
||||
}
|
||||
}
|
||||
}
|
||||
return Server{}, cfg, joinErrors(errs)
|
||||
}
|
||||
|
||||
func (c *AzureClient) createServer(ctx context.Context, cfg Config, publicKey, leaseID, slug string, keep bool) (server Server, err error) {
|
||||
name := leaseProviderName(leaseID, slug)
|
||||
defer func() {
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
_ = c.deleteVMResources(context.Background(), name)
|
||||
}()
|
||||
return c.createServerSteps(ctx, cfg, publicKey, leaseID, slug, keep, name)
|
||||
}
|
||||
|
||||
func (c *AzureClient) createServerSteps(ctx context.Context, cfg Config, publicKey, leaseID, slug string, keep bool, name string) (Server, error) {
|
||||
pipName := name + "-pip"
|
||||
nicName := name + "-nic"
|
||||
diskName := name + "-osdisk"
|
||||
|
||||
if cfg.Tailscale.Enabled && cfg.Tailscale.Hostname == "" {
|
||||
cfg.Tailscale.Hostname = renderTailscaleHostname(cfg.Tailscale.HostnameTemplate, leaseID, slug, cfg.Provider)
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
labels := directLeaseLabels(cfg, leaseID, slug, "azure", mapMarket(strings.EqualFold(cfg.Capacity.Market, "spot")), keep, now)
|
||||
tags := azureLabelsToTags(labels)
|
||||
|
||||
pipPoller, err := c.pipc.BeginCreateOrUpdate(ctx, c.ResourceGroup, pipName, armnetwork.PublicIPAddress{
|
||||
Location: to.Ptr(c.Location),
|
||||
Tags: tags,
|
||||
SKU: &armnetwork.PublicIPAddressSKU{
|
||||
Name: to.Ptr(armnetwork.PublicIPAddressSKUNameStandard),
|
||||
},
|
||||
Properties: &armnetwork.PublicIPAddressPropertiesFormat{
|
||||
PublicIPAllocationMethod: to.Ptr(armnetwork.IPAllocationMethodStatic),
|
||||
},
|
||||
}, nil)
|
||||
if err != nil {
|
||||
return Server{}, fmt.Errorf("begin public ip: %w", err)
|
||||
}
|
||||
pipResp, err := pipPoller.PollUntilDone(ctx, nil)
|
||||
if err != nil {
|
||||
return Server{}, fmt.Errorf("public ip: %w", err)
|
||||
}
|
||||
pipID := *pipResp.ID
|
||||
|
||||
subnetID := fmt.Sprintf("/subscriptions/%s/resourceGroups/%s/providers/Microsoft.Network/virtualNetworks/%s/subnets/%s",
|
||||
c.SubscriptionID, c.ResourceGroup, c.VNet, c.Subnet)
|
||||
nsgID := fmt.Sprintf("/subscriptions/%s/resourceGroups/%s/providers/Microsoft.Network/networkSecurityGroups/%s",
|
||||
c.SubscriptionID, c.ResourceGroup, c.NSG)
|
||||
nicPoller, err := c.nicc.BeginCreateOrUpdate(ctx, c.ResourceGroup, nicName, armnetwork.Interface{
|
||||
Location: to.Ptr(c.Location),
|
||||
Tags: tags,
|
||||
Properties: &armnetwork.InterfacePropertiesFormat{
|
||||
IPConfigurations: []*armnetwork.InterfaceIPConfiguration{{
|
||||
Name: to.Ptr("ipconfig"),
|
||||
Properties: &armnetwork.InterfaceIPConfigurationPropertiesFormat{
|
||||
PrivateIPAllocationMethod: to.Ptr(armnetwork.IPAllocationMethodDynamic),
|
||||
Subnet: &armnetwork.Subnet{ID: to.Ptr(subnetID)},
|
||||
PublicIPAddress: &armnetwork.PublicIPAddress{ID: to.Ptr(pipID)},
|
||||
},
|
||||
}},
|
||||
NetworkSecurityGroup: &armnetwork.SecurityGroup{ID: to.Ptr(nsgID)},
|
||||
},
|
||||
}, nil)
|
||||
if err != nil {
|
||||
return Server{}, fmt.Errorf("begin nic: %w", err)
|
||||
}
|
||||
nicResp, err := nicPoller.PollUntilDone(ctx, nil)
|
||||
if err != nil {
|
||||
return Server{}, fmt.Errorf("nic: %w", err)
|
||||
}
|
||||
nicID := *nicResp.ID
|
||||
|
||||
osProfile, err := c.azureOSProfile(cfg, publicKey, name, leaseID)
|
||||
if err != nil {
|
||||
return Server{}, err
|
||||
}
|
||||
osDisk := &armcompute.OSDisk{
|
||||
Name: to.Ptr(diskName),
|
||||
CreateOption: to.Ptr(armcompute.DiskCreateOptionTypesFromImage),
|
||||
}
|
||||
if c.supportsEphemeralOS(ctx, cfg.ServerType) {
|
||||
osDisk.Caching = to.Ptr(armcompute.CachingTypesReadOnly)
|
||||
osDisk.DiffDiskSettings = &armcompute.DiffDiskSettings{
|
||||
Option: to.Ptr(armcompute.DiffDiskOptionsLocal),
|
||||
}
|
||||
} else {
|
||||
osDisk.Caching = to.Ptr(armcompute.CachingTypesReadWrite)
|
||||
osDisk.ManagedDisk = &armcompute.ManagedDiskParameters{
|
||||
StorageAccountType: to.Ptr(armcompute.StorageAccountTypesStandardSSDLRS),
|
||||
}
|
||||
}
|
||||
vmProperties := &armcompute.VirtualMachineProperties{
|
||||
HardwareProfile: &armcompute.HardwareProfile{
|
||||
VMSize: to.Ptr(armcompute.VirtualMachineSizeTypes(cfg.ServerType)),
|
||||
},
|
||||
StorageProfile: &armcompute.StorageProfile{
|
||||
ImageReference: &armcompute.ImageReference{
|
||||
Publisher: to.Ptr(c.Image.Publisher),
|
||||
Offer: to.Ptr(c.Image.Offer),
|
||||
SKU: to.Ptr(c.Image.SKU),
|
||||
Version: to.Ptr(c.Image.Version),
|
||||
},
|
||||
OSDisk: osDisk,
|
||||
},
|
||||
OSProfile: osProfile,
|
||||
NetworkProfile: &armcompute.NetworkProfile{
|
||||
NetworkInterfaces: []*armcompute.NetworkInterfaceReference{{
|
||||
ID: to.Ptr(nicID),
|
||||
}},
|
||||
},
|
||||
}
|
||||
if strings.EqualFold(cfg.Capacity.Market, "spot") {
|
||||
vmProperties.Priority = to.Ptr(armcompute.VirtualMachinePriorityTypesSpot)
|
||||
vmProperties.EvictionPolicy = to.Ptr(armcompute.VirtualMachineEvictionPolicyTypesDelete)
|
||||
}
|
||||
vmPoller, err := c.vmc.BeginCreateOrUpdate(ctx, c.ResourceGroup, name, armcompute.VirtualMachine{
|
||||
Location: to.Ptr(c.Location),
|
||||
Tags: tags,
|
||||
Properties: vmProperties,
|
||||
}, nil)
|
||||
if err != nil {
|
||||
return Server{}, fmt.Errorf("begin vm: %w", err)
|
||||
}
|
||||
vmResp, err := vmPoller.PollUntilDone(ctx, nil)
|
||||
if err != nil {
|
||||
return Server{}, fmt.Errorf("vm: %w", err)
|
||||
}
|
||||
if cfg.TargetOS == targetWindows {
|
||||
if err := c.installWindowsBootstrapExtension(ctx, name, tags); err != nil {
|
||||
return Server{}, err
|
||||
}
|
||||
}
|
||||
return azureVMToServer(vmResp.VirtualMachine, ""), nil
|
||||
}
|
||||
|
||||
func (c *AzureClient) azureOSProfile(cfg Config, publicKey, name, leaseID string) (*armcompute.OSProfile, error) {
|
||||
if cfg.TargetOS != targetWindows {
|
||||
sshPath := fmt.Sprintf("/home/%s/.ssh/authorized_keys", cfg.SSHUser)
|
||||
return &armcompute.OSProfile{
|
||||
ComputerName: to.Ptr(name),
|
||||
AdminUsername: to.Ptr(cfg.SSHUser),
|
||||
CustomData: to.Ptr(base64.StdEncoding.EncodeToString([]byte(cloudInit(cfg, publicKey)))),
|
||||
LinuxConfiguration: &armcompute.LinuxConfiguration{
|
||||
DisablePasswordAuthentication: to.Ptr(true),
|
||||
SSH: &armcompute.SSHConfiguration{
|
||||
PublicKeys: []*armcompute.SSHPublicKey{{
|
||||
Path: to.Ptr(sshPath),
|
||||
KeyData: to.Ptr(publicKey),
|
||||
}},
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
password, err := azureRandomAdminPassword()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &armcompute.OSProfile{
|
||||
ComputerName: to.Ptr(azureComputerName(name, leaseID, cfg.TargetOS)),
|
||||
AdminUsername: to.Ptr("crabadmin"),
|
||||
AdminPassword: to.Ptr(password),
|
||||
AllowExtensionOperations: to.Ptr(true),
|
||||
CustomData: to.Ptr(base64.StdEncoding.EncodeToString([]byte(azureWindowsBootstrapPowerShell(cfg, publicKey)))),
|
||||
WindowsConfiguration: &armcompute.WindowsConfiguration{
|
||||
EnableAutomaticUpdates: to.Ptr(false),
|
||||
ProvisionVMAgent: to.Ptr(true),
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *AzureClient) installWindowsBootstrapExtension(ctx context.Context, vmName string, tags map[string]*string) error {
|
||||
poller, err := c.vmextc.BeginCreateOrUpdate(ctx, c.ResourceGroup, vmName, "crabbox-bootstrap", armcompute.VirtualMachineExtension{
|
||||
Location: to.Ptr(c.Location),
|
||||
Tags: tags,
|
||||
Properties: &armcompute.VirtualMachineExtensionProperties{
|
||||
Publisher: to.Ptr("Microsoft.Compute"),
|
||||
Type: to.Ptr("CustomScriptExtension"),
|
||||
TypeHandlerVersion: to.Ptr("1.10"),
|
||||
AutoUpgradeMinorVersion: to.Ptr(true),
|
||||
Settings: map[string]any{"timestamp": time.Now().Unix()},
|
||||
ProtectedSettings: map[string]any{
|
||||
"commandToExecute": azureWindowsBootstrapCommand(),
|
||||
},
|
||||
},
|
||||
}, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin windows bootstrap extension: %w", err)
|
||||
}
|
||||
if _, err := poller.PollUntilDone(ctx, nil); err != nil {
|
||||
return fmt.Errorf("windows bootstrap extension: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func azureWindowsBootstrapCommand() string {
|
||||
return `powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "$p=Join-Path $env:SystemDrive 'AzureData\CustomData.bin'; $d=Join-Path $env:SystemDrive 'AzureData\crabbox-bootstrap.ps1'; Copy-Item -Force $p $d; & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $d"`
|
||||
}
|
||||
|
||||
func azureRandomAdminPassword() (string, error) {
|
||||
var b [18]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
return "", fmt.Errorf("generate azure admin password: %w", err)
|
||||
}
|
||||
return "Cb1!" + base64.StdEncoding.EncodeToString(b[:])[:18], nil
|
||||
}
|
||||
|
||||
func azureComputerName(vmName, leaseID, target string) string {
|
||||
if target != targetWindows {
|
||||
return vmName
|
||||
}
|
||||
source := leaseID
|
||||
if source == "" {
|
||||
source = vmName
|
||||
}
|
||||
var b strings.Builder
|
||||
for _, r := range strings.ToLower(source) {
|
||||
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
suffix := b.String()
|
||||
if suffix == "" {
|
||||
suffix = "windows"
|
||||
}
|
||||
if len(suffix) > 12 {
|
||||
suffix = suffix[:12]
|
||||
}
|
||||
return "cbx" + suffix
|
||||
}
|
||||
|
||||
func (c *AzureClient) WaitForServerIP(ctx context.Context, name string) (Server, error) {
|
||||
pipName := name + "-pip"
|
||||
deadline := time.Now().Add(2 * time.Minute)
|
||||
for {
|
||||
pip, err := c.pipc.Get(ctx, c.ResourceGroup, pipName, nil)
|
||||
if err != nil {
|
||||
return Server{}, err
|
||||
}
|
||||
if pip.Properties != nil && pip.Properties.IPAddress != nil && *pip.Properties.IPAddress != "" {
|
||||
vm, err := c.vmc.Get(ctx, c.ResourceGroup, name, nil)
|
||||
if err != nil {
|
||||
return Server{}, err
|
||||
}
|
||||
return azureVMToServer(vm.VirtualMachine, *pip.Properties.IPAddress), nil
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
return Server{}, fmt.Errorf("timeout waiting for public ip on %s", name)
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return Server{}, ctx.Err()
|
||||
case <-time.After(5 * time.Second):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *AzureClient) GetServer(ctx context.Context, name string) (Server, error) {
|
||||
vm, err := c.vmc.Get(ctx, c.ResourceGroup, name, nil)
|
||||
if err != nil {
|
||||
return Server{}, err
|
||||
}
|
||||
pipName := name + "-pip"
|
||||
ip := ""
|
||||
if pip, err := c.pipc.Get(ctx, c.ResourceGroup, pipName, nil); err == nil && pip.Properties != nil && pip.Properties.IPAddress != nil {
|
||||
ip = *pip.Properties.IPAddress
|
||||
}
|
||||
return azureVMToServer(vm.VirtualMachine, ip), nil
|
||||
}
|
||||
|
||||
func (c *AzureClient) ListCrabboxServers(ctx context.Context) ([]Server, error) {
|
||||
pager := c.vmc.NewListPager(c.ResourceGroup, nil)
|
||||
var servers []Server
|
||||
for pager.More() {
|
||||
page, err := pager.NextPage(ctx)
|
||||
if err != nil {
|
||||
if isAzureNotFoundError(err) {
|
||||
return servers, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
for _, vm := range page.Value {
|
||||
if vm == nil {
|
||||
continue
|
||||
}
|
||||
if vm.Tags == nil || vm.Tags[azureProviderTag] == nil || *vm.Tags[azureProviderTag] != "true" {
|
||||
continue
|
||||
}
|
||||
ip := ""
|
||||
if vm.Name != nil {
|
||||
pipName := *vm.Name + "-pip"
|
||||
if pip, err := c.pipc.Get(ctx, c.ResourceGroup, pipName, nil); err == nil && pip.Properties != nil && pip.Properties.IPAddress != nil {
|
||||
ip = *pip.Properties.IPAddress
|
||||
}
|
||||
}
|
||||
servers = append(servers, azureVMToServer(*vm, ip))
|
||||
}
|
||||
}
|
||||
return servers, nil
|
||||
}
|
||||
|
||||
func (c *AzureClient) DeleteServer(ctx context.Context, name string) error {
|
||||
return c.deleteVMResources(ctx, name)
|
||||
}
|
||||
|
||||
func (c *AzureClient) deleteVMResources(ctx context.Context, name string) error {
|
||||
for attempt := 0; ; attempt++ {
|
||||
errs, retry := c.deleteVMResourcesOnce(ctx, name)
|
||||
if len(errs) == 0 {
|
||||
return nil
|
||||
}
|
||||
if !retry || attempt >= azureDeleteRetryAttempts-1 {
|
||||
return joinErrors(errs)
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
errs = append(errs, ctx.Err())
|
||||
return joinErrors(errs)
|
||||
case <-time.After(azureDeleteRetryDelay):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *AzureClient) deleteVMResourcesOnce(ctx context.Context, name string) ([]error, bool) {
|
||||
var errs []error
|
||||
retry := false
|
||||
if poller, err := c.vmc.BeginDelete(ctx, c.ResourceGroup, name, nil); err == nil {
|
||||
if _, err := poller.PollUntilDone(ctx, nil); err != nil && !isAzureNotFoundError(err) {
|
||||
errs = append(errs, fmt.Errorf("delete vm %s: %w", name, err))
|
||||
retry = retry || isAzureRetryableDeleteError(err)
|
||||
}
|
||||
} else if !isAzureNotFoundError(err) {
|
||||
errs = append(errs, fmt.Errorf("begin delete vm: %w", err))
|
||||
retry = retry || isAzureRetryableDeleteError(err)
|
||||
}
|
||||
if poller, err := c.nicc.BeginDelete(ctx, c.ResourceGroup, name+"-nic", nil); err == nil {
|
||||
if _, err := poller.PollUntilDone(ctx, nil); err != nil && !isAzureNotFoundError(err) {
|
||||
errs = append(errs, fmt.Errorf("delete nic %s-nic: %w", name, err))
|
||||
retry = retry || isAzureRetryableDeleteError(err)
|
||||
}
|
||||
} else if !isAzureNotFoundError(err) {
|
||||
errs = append(errs, fmt.Errorf("begin delete nic: %w", err))
|
||||
retry = retry || isAzureRetryableDeleteError(err)
|
||||
}
|
||||
if err := c.deletePublicIP(ctx, name+"-pip"); err != nil {
|
||||
errs = append(errs, err)
|
||||
retry = retry || isAzureRetryableDeleteError(err)
|
||||
}
|
||||
if poller, err := c.diskc.BeginDelete(ctx, c.ResourceGroup, name+"-osdisk", nil); err == nil {
|
||||
if _, err := poller.PollUntilDone(ctx, nil); err != nil && !isAzureNotFoundError(err) {
|
||||
errs = append(errs, fmt.Errorf("delete disk %s-osdisk: %w", name, err))
|
||||
retry = retry || isAzureRetryableDeleteError(err)
|
||||
}
|
||||
} else if !isAzureNotFoundError(err) {
|
||||
errs = append(errs, fmt.Errorf("begin delete disk: %w", err))
|
||||
retry = retry || isAzureRetryableDeleteError(err)
|
||||
}
|
||||
return errs, retry
|
||||
}
|
||||
|
||||
func (c *AzureClient) deletePublicIP(ctx context.Context, pipName string) error {
|
||||
poller, err := c.pipc.BeginDelete(ctx, c.ResourceGroup, pipName, nil)
|
||||
if err != nil {
|
||||
if isAzureNotFoundError(err) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("begin delete pip: %w", err)
|
||||
}
|
||||
if _, err := poller.PollUntilDone(ctx, nil); err != nil && !isAzureNotFoundError(err) {
|
||||
return fmt.Errorf("delete pip %s: %w", pipName, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *AzureClient) SetTags(ctx context.Context, name string, labels map[string]string) error {
|
||||
poller, err := c.vmc.BeginUpdate(ctx, c.ResourceGroup, name, armcompute.VirtualMachineUpdate{
|
||||
Tags: azureLabelsToTags(labels),
|
||||
}, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := poller.PollUntilDone(ctx, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func azureVMToServer(vm armcompute.VirtualMachine, ip string) Server {
|
||||
s := Server{
|
||||
Provider: "azure",
|
||||
Labels: map[string]string{},
|
||||
}
|
||||
if vm.Name != nil {
|
||||
s.CloudID = *vm.Name
|
||||
s.Name = *vm.Name
|
||||
}
|
||||
if vm.Properties != nil && vm.Properties.ProvisioningState != nil {
|
||||
s.Status = *vm.Properties.ProvisioningState
|
||||
}
|
||||
if vm.Properties != nil && vm.Properties.HardwareProfile != nil && vm.Properties.HardwareProfile.VMSize != nil {
|
||||
s.ServerType.Name = string(*vm.Properties.HardwareProfile.VMSize)
|
||||
}
|
||||
s.PublicNet.IPv4.IP = ip
|
||||
for k, v := range vm.Tags {
|
||||
if v != nil {
|
||||
s.Labels[azureTagToLabelKey(k)] = *v
|
||||
}
|
||||
}
|
||||
normalizeAzureWindowsModeLabel(s.Labels)
|
||||
return s
|
||||
}
|
||||
|
||||
func azureLabelsToTags(labels map[string]string) map[string]*string {
|
||||
return stringMapToPtrMap(azureTagsFromLabels(labels))
|
||||
}
|
||||
|
||||
func azureTagsFromLabels(labels map[string]string) map[string]string {
|
||||
out := make(map[string]string, len(labels))
|
||||
for k, v := range labels {
|
||||
out[azureLabelToTagKey(k)] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func azureLabelToTagKey(key string) string {
|
||||
if strings.HasPrefix(strings.ToLower(key), "windows") {
|
||||
return "crabbox_" + key
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
func azureTagToLabelKey(key string) string {
|
||||
if strings.HasPrefix(key, "crabbox_windows") {
|
||||
return strings.TrimPrefix(key, "crabbox_")
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
func normalizeAzureWindowsModeLabel(labels map[string]string) {
|
||||
if labels == nil {
|
||||
return
|
||||
}
|
||||
if labels["windows_mode"] == "" && labels["crabbox_windows_mode"] != "" {
|
||||
labels["windows_mode"] = labels["crabbox_windows_mode"]
|
||||
}
|
||||
}
|
||||
|
||||
func stringMapToPtrMap(m map[string]string) map[string]*string {
|
||||
out := make(map[string]*string, len(m))
|
||||
for k, v := range m {
|
||||
out[k] = to.Ptr(v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func isAzureRetryableProvisioningError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
s := err.Error()
|
||||
return strings.Contains(s, "SkuNotAvailable") ||
|
||||
strings.Contains(s, "QuotaExceeded") ||
|
||||
strings.Contains(s, "OperationNotAllowed") ||
|
||||
strings.Contains(s, "AllocationFailed") ||
|
||||
strings.Contains(s, "ZonalAllocationFailed") ||
|
||||
strings.Contains(s, "OverconstrainedAllocationRequest")
|
||||
}
|
||||
|
||||
func isAzureNotFoundError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
var respErr *azcore.ResponseError
|
||||
if errors.As(err, &respErr) && respErr.StatusCode == 404 {
|
||||
return true
|
||||
}
|
||||
s := err.Error()
|
||||
return strings.Contains(s, "ResourceNotFound") || strings.Contains(s, "NotFound")
|
||||
}
|
||||
|
||||
func isAzureRetryableDeleteError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
s := err.Error()
|
||||
return strings.Contains(s, "NicReservedForAnotherVm") ||
|
||||
strings.Contains(s, "PublicIPAddressCannotBeDeleted") ||
|
||||
strings.Contains(s, "InUse") ||
|
||||
strings.Contains(s, "AnotherOperationInProgress") ||
|
||||
(strings.Contains(s, "OperationNotAllowed") && strings.Contains(s, "retry after"))
|
||||
}
|
||||
|
||||
func deleteAzureServer(ctx context.Context, cfg Config, server Server) error {
|
||||
client, err := NewAzureClient(ctx, cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
name := server.CloudID
|
||||
if name == "" {
|
||||
name = server.Name
|
||||
}
|
||||
if name == "" {
|
||||
return errors.New("azure delete: server has no name")
|
||||
}
|
||||
return client.DeleteServer(ctx, name)
|
||||
}
|
||||
@ -1,389 +0,0 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/azcore/to"
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/azidentity"
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v6"
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v6"
|
||||
)
|
||||
|
||||
func TestParseAzureImageRef(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
input string
|
||||
want azureImageRef
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "ubuntu jammy gen2",
|
||||
input: "Canonical:0001-com-ubuntu-server-jammy:22_04-lts-gen2:latest",
|
||||
want: azureImageRef{Publisher: "Canonical", Offer: "0001-com-ubuntu-server-jammy", SKU: "22_04-lts-gen2", Version: "latest"},
|
||||
},
|
||||
{
|
||||
name: "missing version",
|
||||
input: "Canonical:offer:sku",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "empty",
|
||||
input: "",
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
got, err := parseAzureImageRef(tc.input)
|
||||
if tc.wantErr {
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for %q, got nil", tc.input)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != tc.want {
|
||||
t.Fatalf("got %+v, want %+v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAzureImageForConfig(t *testing.T) {
|
||||
t.Parallel()
|
||||
linux := baseConfig()
|
||||
linux.TargetOS = targetLinux
|
||||
if got := azureImageForConfig(linux); got != defaultAzureLinuxImage {
|
||||
t.Fatalf("linux image=%q want %q", got, defaultAzureLinuxImage)
|
||||
}
|
||||
windows := baseConfig()
|
||||
windows.TargetOS = targetWindows
|
||||
if got := azureImageForConfig(windows); got != defaultAzureWindowsImage {
|
||||
t.Fatalf("windows image=%q want %q", got, defaultAzureWindowsImage)
|
||||
}
|
||||
windows.AzureImage = "Contoso:offer:sku:latest"
|
||||
if got := azureImageForConfig(windows); got != windows.AzureImage {
|
||||
t.Fatalf("windows explicit image=%q want %q", got, windows.AzureImage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAzureVMSizeCandidatesForClass(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
class string
|
||||
want []string
|
||||
}{
|
||||
{class: "standard", want: []string{"Standard_D32ads_v6", "Standard_D32ds_v6", "Standard_F32s_v2", "Standard_D32ads_v5", "Standard_D32ds_v5", "Standard_D16ads_v6", "Standard_D16ds_v6", "Standard_F16s_v2"}},
|
||||
{class: "fast", want: []string{"Standard_D64ads_v6", "Standard_D64ds_v6", "Standard_F64s_v2", "Standard_D64ads_v5", "Standard_D64ds_v5", "Standard_D48ads_v6", "Standard_D48ds_v6", "Standard_F48s_v2", "Standard_D32ads_v6", "Standard_D32ds_v6", "Standard_F32s_v2"}},
|
||||
{class: "large", want: []string{"Standard_D96ads_v6", "Standard_D96ds_v6", "Standard_D96ads_v5", "Standard_D96ds_v5", "Standard_D64ads_v6", "Standard_D64ds_v6", "Standard_F64s_v2", "Standard_D48ads_v6", "Standard_D48ds_v6", "Standard_F48s_v2"}},
|
||||
{class: "beast", want: []string{"Standard_D192ds_v6", "Standard_D128ds_v6", "Standard_D96ads_v6", "Standard_D96ds_v6", "Standard_D96ads_v5", "Standard_D96ds_v5", "Standard_D64ads_v6", "Standard_D64ds_v6", "Standard_F64s_v2"}},
|
||||
{class: "Standard_F2s", want: []string{"Standard_F2s"}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got := azureVMSizeCandidatesForClass(tc.class)
|
||||
if !reflect.DeepEqual(got, tc.want) {
|
||||
t.Fatalf("class=%q: got %v, want %v", tc.class, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAzureVMSizeCandidatesForTargetModeClass(t *testing.T) {
|
||||
t.Parallel()
|
||||
linux := azureVMSizeCandidatesForTargetModeClass(targetLinux, windowsModeNormal, "standard")
|
||||
if !reflect.DeepEqual(linux, azureVMSizeCandidatesForClass("standard")) {
|
||||
t.Fatalf("linux target got %v want azure linux table", linux)
|
||||
}
|
||||
windows := azureVMSizeCandidatesForTargetModeClass(targetWindows, windowsModeNormal, "standard")
|
||||
if want := azureWindowsVMSizeCandidatesForClass("standard"); !reflect.DeepEqual(windows, want) {
|
||||
t.Fatalf("windows target got %v want %v", windows, want)
|
||||
}
|
||||
wsl2 := azureVMSizeCandidatesForTargetModeClass(targetWindows, windowsModeWSL2, "standard")
|
||||
if !reflect.DeepEqual(wsl2, []string{"standard"}) {
|
||||
t.Fatalf("wsl2 target got %v want explicit fallback", wsl2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAzureWindowsVMSizeCandidatesForClass(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := azureWindowsVMSizeCandidatesForClass("beast")
|
||||
want := []string{"Standard_D16ads_v6", "Standard_D16ds_v6", "Standard_D16ads_v5", "Standard_D16ds_v5", "Standard_D8ads_v6"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("got %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerTypeForProviderClassAzure(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := serverTypeForProviderClass("azure", "beast")
|
||||
if got != "Standard_D192ds_v6" {
|
||||
t.Fatalf("got %q, want Standard_D192ds_v6", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAzureSupportsEphemeralOS(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := map[string]bool{
|
||||
"Standard_D2as_v5": false,
|
||||
"Standard_D8s_v5": false,
|
||||
"Standard_D2ads_v5": true,
|
||||
"Standard_D2ads_v6": true,
|
||||
"Standard_F2s_v2": true,
|
||||
"Standard_E4ds_v5": true,
|
||||
"Standard_D2as_v6": false,
|
||||
"Standard_D2s_v6": false,
|
||||
"Standard_B2s": false,
|
||||
"Standard_A2_v2": false,
|
||||
"": false,
|
||||
}
|
||||
for size, want := range cases {
|
||||
if got := azureSupportsEphemeralOS(size); got != want {
|
||||
t.Fatalf("size=%q got %v want %v", size, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAzureComputerNameWindowsLimit(t *testing.T) {
|
||||
t.Parallel()
|
||||
got := azureComputerName("crabbox-coral-lobster-c9adbbb9", "cbx_8556d7bc1580", targetWindows)
|
||||
if len(got) > 15 {
|
||||
t.Fatalf("computer name %q length=%d", got, len(got))
|
||||
}
|
||||
if got != "cbxcbx8556d7bc1" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
if linux := azureComputerName("crabbox-coral-lobster-c9adbbb9", "cbx_8556d7bc1580", targetLinux); linux != "crabbox-coral-lobster-c9adbbb9" {
|
||||
t.Fatalf("linux computer name changed to %q", linux)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAzureWindowsBootstrapPowerShell(t *testing.T) {
|
||||
t.Parallel()
|
||||
cfg := baseConfig()
|
||||
cfg.Provider = "azure"
|
||||
cfg.TargetOS = targetWindows
|
||||
cfg.WorkRoot = defaultWindowsWorkRoot
|
||||
got := azureWindowsBootstrapPowerShell(cfg, "ssh-rsa test")
|
||||
for _, want := range []string{
|
||||
"OpenSSH-Win64.zip",
|
||||
"Git-2.52.0-64-bit.exe",
|
||||
"administrators_authorized_keys",
|
||||
"Match Group administrators",
|
||||
"$sshPorts = @('2222', '22')",
|
||||
"PasswordAuthentication no",
|
||||
"Restart-Service sshd -Force",
|
||||
"Set-Content -NoNewline -Encoding ASCII -Path $setupCompletePath",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("bootstrap missing %q", want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(got, "Restart-Computer") {
|
||||
t.Fatalf("azure extension bootstrap must not restart inside Custom Script Extension")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAzureTagsMapReservedWindowsPrefix(t *testing.T) {
|
||||
t.Parallel()
|
||||
labels := map[string]string{
|
||||
"crabbox": "true",
|
||||
"windows_mode": "normal",
|
||||
}
|
||||
tags := azureTagsFromLabels(labels)
|
||||
if tags["windows_mode"] != "" {
|
||||
t.Fatalf("reserved windows tag key was not remapped: %#v", tags)
|
||||
}
|
||||
if tags["crabbox_windows_mode"] != "normal" {
|
||||
t.Fatalf("missing remapped windows mode tag: %#v", tags)
|
||||
}
|
||||
server := azureVMToServer(armcompute.VirtualMachine{
|
||||
Tags: stringMapToPtrMap(tags),
|
||||
}, "")
|
||||
if server.Labels["windows_mode"] != "normal" {
|
||||
t.Fatalf("windows_mode label not restored: %#v", server.Labels)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAzureSKUCapabilityTrue(t *testing.T) {
|
||||
t.Parallel()
|
||||
caps := []*armcompute.ResourceSKUCapabilities{
|
||||
{Name: to.Ptr("EphemeralOSDiskSupported"), Value: to.Ptr("True")},
|
||||
}
|
||||
if !azureSKUCapabilityTrue(caps, "EphemeralOSDiskSupported") {
|
||||
t.Fatal("capability should be true")
|
||||
}
|
||||
caps[0].Value = to.Ptr("False")
|
||||
if azureSKUCapabilityTrue(caps, "EphemeralOSDiskSupported") {
|
||||
t.Fatal("capability should be false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStringMapToPtrMap(t *testing.T) {
|
||||
t.Parallel()
|
||||
in := map[string]string{"a": "1", "b": "2"}
|
||||
out := stringMapToPtrMap(in)
|
||||
if len(out) != 2 {
|
||||
t.Fatalf("len=%d, want 2", len(out))
|
||||
}
|
||||
if *out["a"] != "1" || *out["b"] != "2" {
|
||||
t.Fatalf("values = %v, %v", *out["a"], *out["b"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsAzureRetryableProvisioningError(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := map[string]bool{
|
||||
"": false,
|
||||
"some other error": false,
|
||||
"compute.VMs: SkuNotAvailable in this region": true,
|
||||
"QuotaExceeded for cores": true,
|
||||
"AllocationFailed: out of capacity": true,
|
||||
"OverconstrainedAllocationRequest: zone exhausted": true,
|
||||
}
|
||||
for msg, want := range cases {
|
||||
var err error
|
||||
if msg != "" {
|
||||
err = errSentinel(msg)
|
||||
}
|
||||
if got := isAzureRetryableProvisioningError(err); got != want {
|
||||
t.Fatalf("msg=%q got %v want %v", msg, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsAzureNotFoundError(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := map[string]bool{
|
||||
"": false,
|
||||
"transient": false,
|
||||
"ResponseError: ResourceNotFound: vm missing": true,
|
||||
"NotFound: pip already deleted": true,
|
||||
}
|
||||
for msg, want := range cases {
|
||||
var err error
|
||||
if msg != "" {
|
||||
err = errSentinel(msg)
|
||||
}
|
||||
if got := isAzureNotFoundError(err); got != want {
|
||||
t.Fatalf("msg=%q got %v want %v", msg, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsAzureRetryableDeleteError(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := map[string]bool{
|
||||
"": false,
|
||||
"validation failed": false,
|
||||
"NicReservedForAnotherVm retry after 180 seconds": true,
|
||||
"PublicIPAddressCannotBeDeleted because in use": true,
|
||||
"AnotherOperationInProgress": true,
|
||||
"OperationNotAllowed retry after 180 seconds": true,
|
||||
}
|
||||
for msg, want := range cases {
|
||||
var err error
|
||||
if msg != "" {
|
||||
err = errSentinel(msg)
|
||||
}
|
||||
if got := isAzureRetryableDeleteError(err); got != want {
|
||||
t.Fatalf("msg=%q got %v want %v", msg, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreserveNonCrabboxRules(t *testing.T) {
|
||||
t.Parallel()
|
||||
in := []*armnetwork.SecurityRule{
|
||||
{Name: to.Ptr("crabbox-ssh-2222-0")},
|
||||
{Name: to.Ptr("operator-https")},
|
||||
nil,
|
||||
{},
|
||||
}
|
||||
got := preserveNonCrabboxRules(in)
|
||||
if len(got) != 1 || got[0] == nil || got[0].Name == nil || *got[0].Name != "operator-https" {
|
||||
t.Fatalf("got %+v, want a single operator-https rule", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextAzureNSGPrioritySkipsPreservedRules(t *testing.T) {
|
||||
t.Parallel()
|
||||
used := azureNSGUsedPriorities([]*armnetwork.SecurityRule{{
|
||||
Name: to.Ptr("operator-ssh"),
|
||||
Properties: &armnetwork.SecurityRulePropertiesFormat{
|
||||
Priority: to.Ptr[int32](100),
|
||||
},
|
||||
}})
|
||||
got, err := nextAzureNSGPriority(used)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != 101 {
|
||||
t.Fatalf("got %d want 101", got)
|
||||
}
|
||||
}
|
||||
|
||||
type errSentinel string
|
||||
|
||||
func (e errSentinel) Error() string { return string(e) }
|
||||
|
||||
func TestAzureManagedByCrabbox(t *testing.T) {
|
||||
t.Parallel()
|
||||
val := "crabbox"
|
||||
other := "platform-team"
|
||||
cases := []struct {
|
||||
name string
|
||||
tags map[string]*string
|
||||
want bool
|
||||
}{
|
||||
{name: "nil tags", tags: nil, want: false},
|
||||
{name: "missing key", tags: map[string]*string{"crabbox": &val}, want: false},
|
||||
{name: "wrong value", tags: map[string]*string{"managed_by": &other}, want: false},
|
||||
{name: "match", tags: map[string]*string{"managed_by": &val}, want: true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := azureManagedByCrabbox(tc.tags); got != tc.want {
|
||||
t.Fatalf("got %v want %v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAzureCredentialForConfigPrefersClientSecret(t *testing.T) {
|
||||
t.Setenv("AZURE_CLIENT_SECRET", "shh")
|
||||
cfg := Config{
|
||||
AzureTenant: "00000000-0000-0000-0000-000000000001",
|
||||
AzureClientID: "00000000-0000-0000-0000-000000000002",
|
||||
}
|
||||
cred, err := azureCredentialForConfig(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if _, ok := cred.(*azidentity.ClientSecretCredential); !ok {
|
||||
t.Fatalf("got %T, want *azidentity.ClientSecretCredential", cred)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAzureCredentialForConfigFallsBackToDefault(t *testing.T) {
|
||||
// Make sure env vars don't accidentally yield ClientSecretCredential.
|
||||
t.Setenv("AZURE_CLIENT_SECRET", "")
|
||||
cfg := Config{AzureTenant: "tenant", AzureClientID: "client"}
|
||||
cred, err := azureCredentialForConfig(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if _, ok := cred.(*azidentity.ClientSecretCredential); ok {
|
||||
t.Fatalf("got ClientSecretCredential, want DefaultAzureCredential")
|
||||
}
|
||||
if _, ok := cred.(*azidentity.DefaultAzureCredential); !ok {
|
||||
t.Fatalf("got %T, want *azidentity.DefaultAzureCredential", cred)
|
||||
}
|
||||
}
|
||||
@ -84,8 +84,8 @@ runcmd:
|
||||
mkdir -p %[3]s /var/cache/crabbox/pnpm /var/cache/crabbox/npm
|
||||
chown -R %[1]s:%[1]s %[3]s /var/cache/crabbox
|
||||
install -d /var/lib/crabbox
|
||||
systemctl enable ssh || true
|
||||
timeout 30s systemctl restart ssh || timeout 30s systemctl restart ssh.socket || true
|
||||
systemctl enable --now ssh
|
||||
systemctl restart ssh
|
||||
%[7]s
|
||||
touch /var/lib/crabbox/bootstrapped
|
||||
crabbox-ready
|
||||
@ -102,7 +102,12 @@ tasks:
|
||||
`
|
||||
}
|
||||
|
||||
func windowsBootstrapHeaderPowerShell(cfg Config, publicKey, workRoot string) string {
|
||||
func windowsBootstrapPowerShell(cfg Config, publicKey string) string {
|
||||
workRoot := cfg.WorkRoot
|
||||
if workRoot == "" {
|
||||
workRoot = `C:\crabbox`
|
||||
}
|
||||
wslMode := cfg.WindowsMode == windowsModeWSL2
|
||||
return `
|
||||
$ErrorActionPreference = "Stop"
|
||||
$ProgressPreference = "SilentlyContinue"
|
||||
@ -125,108 +130,6 @@ function New-CrabboxPassword {
|
||||
$user = ` + psQuote(cfg.SSHUser) + `
|
||||
$publicKey = ` + psQuote(publicKey) + `
|
||||
$workRoot = ` + psQuote(workRoot) + `
|
||||
$sshPorts = ` + windowsSSHPortsPowerShell(cfg) + `
|
||||
$base = "C:\ProgramData\crabbox"
|
||||
$setupCompletePath = Join-Path $base "setup-complete"
|
||||
$openSSHZip = "$env:TEMP\OpenSSH-Win64.zip"
|
||||
$gitInstaller = "$env:TEMP\Git-2.52.0-64-bit.exe"
|
||||
New-Item -ItemType Directory -Force -Path $base, $workRoot | Out-Null
|
||||
`
|
||||
}
|
||||
|
||||
func windowsBootstrapCorePowerShell() string {
|
||||
return `
|
||||
if (-not (Test-Path -LiteralPath $passwordPath)) {
|
||||
New-CrabboxPassword | Set-Content -NoNewline -Encoding ASCII -Path $passwordPath
|
||||
}
|
||||
$userPassword = (Get-Content -Raw -Path $passwordPath).Trim()
|
||||
if ($userPassword.Length -lt 12 -or $userPassword -notmatch '[A-Z]' -or $userPassword -notmatch '[a-z]' -or $userPassword -notmatch '[0-9]' -or $userPassword -notmatch '[^A-Za-z0-9]') {
|
||||
$userPassword = New-CrabboxPassword
|
||||
Set-Content -NoNewline -Encoding ASCII -Path $passwordPath -Value $userPassword
|
||||
}
|
||||
$secure = ConvertTo-SecureString $userPassword -AsPlainText -Force
|
||||
if (-not (Get-LocalUser -Name $user -ErrorAction SilentlyContinue)) {
|
||||
New-LocalUser -Name $user -Password $secure -PasswordNeverExpires -AccountNeverExpires | Out-Null
|
||||
} else {
|
||||
Set-LocalUser -Name $user -Password $secure -PasswordNeverExpires $true
|
||||
}
|
||||
Add-LocalGroupMember -Group "Administrators" -Member $user -ErrorAction SilentlyContinue
|
||||
Set-Content -NoNewline -Encoding ASCII -Path $usernamePath -Value $user
|
||||
if ($passwordMirrorPath) {
|
||||
Set-Content -NoNewline -Encoding ASCII -Path $passwordMirrorPath -Value $userPassword
|
||||
}
|
||||
$userSID = (Get-LocalUser -Name $user).SID.Value
|
||||
icacls.exe $workRoot /grant "*${userSID}:(OI)(CI)F" | Out-Null
|
||||
$userSSHDir = Join-Path (Join-Path "C:\Users" $user) ".ssh"
|
||||
$userAuthorizedKeys = Join-Path $userSSHDir "authorized_keys"
|
||||
New-Item -ItemType Directory -Force -Path $userSSHDir | Out-Null
|
||||
Set-Content -Encoding ASCII -Path $userAuthorizedKeys -Value $publicKey
|
||||
icacls.exe $userSSHDir /inheritance:r /grant "*${userSID}:F" /grant "*S-1-5-32-544:F" /grant "*S-1-5-18:F" | Out-Null
|
||||
icacls.exe $userAuthorizedKeys /inheritance:r /grant "*${userSID}:F" /grant "*S-1-5-32-544:F" /grant "*S-1-5-18:F" | Out-Null
|
||||
if (-not (Get-Service -Name sshd -ErrorAction SilentlyContinue)) {
|
||||
Retry { Invoke-WebRequest -Uri ` + psQuote(openSSHWin64ZipURL) + ` -OutFile $openSSHZip -UseBasicParsing }
|
||||
Remove-Item -Recurse -Force "C:\Program Files\OpenSSH" -ErrorAction SilentlyContinue
|
||||
Expand-Archive -LiteralPath $openSSHZip -DestinationPath "C:\Program Files" -Force
|
||||
if (Test-Path -LiteralPath "C:\Program Files\OpenSSH-Win64") {
|
||||
Rename-Item -LiteralPath "C:\Program Files\OpenSSH-Win64" -NewName "OpenSSH" -Force
|
||||
}
|
||||
& "C:\Program Files\OpenSSH\install-sshd.ps1"
|
||||
}
|
||||
New-Item -ItemType Directory -Force -Path "$env:ProgramData\ssh" | Out-Null
|
||||
Set-Content -Encoding ASCII -Path "$env:ProgramData\ssh\administrators_authorized_keys" -Value $publicKey
|
||||
icacls.exe "$env:ProgramData\ssh\administrators_authorized_keys" /inheritance:r /grant "*S-1-5-32-544:F" /grant "*S-1-5-18:F" | Out-Null
|
||||
$sshdConfigPath = "$env:ProgramData\ssh\sshd_config"
|
||||
$sshdConfig = ""
|
||||
if (Test-Path -LiteralPath $sshdConfigPath) {
|
||||
$sshdConfig = Get-Content -Raw -LiteralPath $sshdConfigPath
|
||||
}
|
||||
$globalLines = @()
|
||||
$matchLines = @()
|
||||
$inMatch = $false
|
||||
foreach ($line in ($sshdConfig -split "\r?\n")) {
|
||||
if ($line -match '^\s*Match\s+') { $inMatch = $true }
|
||||
if (-not $inMatch -and $line -match '^\s*Port\s+\d+\s*$') { continue }
|
||||
if ($enforceKeyAuth -and -not $inMatch -and $line -match '^\s*(PasswordAuthentication|PubkeyAuthentication)\s+') { continue }
|
||||
if ($inMatch) { $matchLines += $line } else { $globalLines += $line }
|
||||
}
|
||||
foreach ($port in $sshPorts) { $globalLines += "Port $port" }
|
||||
if ($enforceKeyAuth) {
|
||||
$globalLines += "PubkeyAuthentication yes"
|
||||
$globalLines += "PasswordAuthentication no"
|
||||
}
|
||||
if (($matchLines -join [Environment]::NewLine) -notmatch '(?im)^\s*Match\s+Group\s+administrators\b') {
|
||||
$matchLines += "Match Group administrators"
|
||||
$matchLines += " AuthorizedKeysFile __PROGRAMDATA__/ssh/administrators_authorized_keys"
|
||||
}
|
||||
Set-Content -Encoding ASCII -LiteralPath $sshdConfigPath -Value (($globalLines + $matchLines) -join [Environment]::NewLine)
|
||||
foreach ($port in $sshPorts) {
|
||||
$ruleName = "crabbox-sshd-$port"
|
||||
if (-not (Get-NetFirewallRule -Name $ruleName -ErrorAction SilentlyContinue)) {
|
||||
New-NetFirewallRule -Name $ruleName -DisplayName "Crabbox OpenSSH $port" -Enabled True -Direction Inbound -Protocol TCP -Action Allow -LocalPort $port | Out-Null
|
||||
}
|
||||
}
|
||||
Set-Service -Name sshd -StartupType Automatic
|
||||
Start-Service sshd
|
||||
if (-not (Test-Path -LiteralPath "C:\Program Files\Git\cmd\git.exe")) {
|
||||
Retry { Invoke-WebRequest -Uri ` + psQuote(gitForWindowsSetupURL) + ` -OutFile $gitInstaller -UseBasicParsing }
|
||||
Start-Process -FilePath $gitInstaller -ArgumentList "/VERYSILENT","/NORESTART","/NOCANCEL","/SP-" -Wait
|
||||
}
|
||||
$machinePath = [Environment]::GetEnvironmentVariable("Path", "Machine")
|
||||
foreach ($path in @("C:\Program Files\OpenSSH", "C:\Program Files\Git\cmd", "C:\Program Files\Git\usr\bin")) {
|
||||
if ($machinePath -notlike "*$path*") { $machinePath = "$machinePath;$path" }
|
||||
if ($env:Path -notlike "*$path*") { $env:Path = "$env:Path;$path" }
|
||||
}
|
||||
[Environment]::SetEnvironmentVariable("Path", $machinePath, "Machine")
|
||||
`
|
||||
}
|
||||
|
||||
func windowsBootstrapPowerShell(cfg Config, publicKey string) string {
|
||||
workRoot := cfg.WorkRoot
|
||||
if workRoot == "" {
|
||||
workRoot = `C:\crabbox`
|
||||
}
|
||||
wslMode := cfg.WindowsMode == windowsModeWSL2
|
||||
return windowsBootstrapHeaderPowerShell(cfg, publicKey, workRoot) + `
|
||||
$wslMode = $` + fmt.Sprint(wslMode) + `
|
||||
$wslDistro = "Crabbox"
|
||||
$wslRoot = "C:\ProgramData\crabbox\wsl\Crabbox"
|
||||
@ -236,16 +139,17 @@ $wslRootfsMinBytes = 100 * 1024 * 1024
|
||||
$wslSetup = "C:\ProgramData\crabbox\wsl\linux-setup.sh"
|
||||
$wslFeaturesMarker = "C:\ProgramData\crabbox\wsl-features-rebooted"
|
||||
$wslKernelMarker = "C:\ProgramData\crabbox\wsl-kernel-rebooted"
|
||||
$sshPorts = ` + windowsSSHPortsPowerShell(cfg) + `
|
||||
$vncPasswordPath = "C:\ProgramData\crabbox\vnc.password"
|
||||
$windowsUsernamePath = "C:\ProgramData\crabbox\windows.username"
|
||||
$windowsPasswordPath = "C:\ProgramData\crabbox\windows.password"
|
||||
$passwordPath = $vncPasswordPath
|
||||
$usernamePath = $windowsUsernamePath
|
||||
$passwordMirrorPath = $windowsPasswordPath
|
||||
$enforceKeyAuth = $false
|
||||
$userVNCStartupPath = "C:\ProgramData\crabbox\start-user-vnc.ps1"
|
||||
$userVNCStartupCommandPath = Join-Path (Join-Path (Join-Path "C:\Users" $user) "AppData\Roaming\Microsoft\Windows\Start Menu\Programs\Startup") "crabbox-user-vnc.cmd"
|
||||
$setupCompletePath = "C:\ProgramData\crabbox\setup-complete"
|
||||
$openSSHZip = "$env:TEMP\OpenSSH-Win64.zip"
|
||||
$gitInstaller = "$env:TEMP\Git-2.52.0-64-bit.exe"
|
||||
$tightVNCInstaller = "$env:TEMP\tightvnc-2.8.85-gpl-setup-64bit.msi"
|
||||
New-Item -ItemType Directory -Force -Path "C:\ProgramData\crabbox", $workRoot | Out-Null
|
||||
function Restart-CrabboxBootstrap($MarkerPath) {
|
||||
Set-Content -NoNewline -Encoding ASCII -Path $MarkerPath -Value (Get-Date).ToString("o")
|
||||
Restart-Computer -Force
|
||||
@ -340,7 +244,74 @@ crabbox-ready
|
||||
wsl.exe -d $wslDistro --user root --exec bash /mnt/c/ProgramData/crabbox/wsl/linux-setup.sh
|
||||
if ($LASTEXITCODE -ne 0) { throw "WSL setup failed with exit $LASTEXITCODE" }
|
||||
}
|
||||
` + windowsBootstrapCorePowerShell() + `
|
||||
if (-not (Test-Path -LiteralPath $vncPasswordPath)) {
|
||||
New-CrabboxPassword | Set-Content -NoNewline -Encoding ASCII -Path $vncPasswordPath
|
||||
}
|
||||
$userPassword = Get-Content -Raw -Path $vncPasswordPath
|
||||
if ($userPassword.Length -lt 12 -or $userPassword -notmatch '[A-Z]' -or $userPassword -notmatch '[a-z]' -or $userPassword -notmatch '[0-9]' -or $userPassword -notmatch '[^A-Za-z0-9]') {
|
||||
$userPassword = New-CrabboxPassword
|
||||
Set-Content -NoNewline -Encoding ASCII -Path $vncPasswordPath -Value $userPassword
|
||||
}
|
||||
$secure = ConvertTo-SecureString $userPassword -AsPlainText -Force
|
||||
if (-not (Get-LocalUser -Name $user -ErrorAction SilentlyContinue)) {
|
||||
New-LocalUser -Name $user -Password $secure -PasswordNeverExpires -AccountNeverExpires | Out-Null
|
||||
} else {
|
||||
Set-LocalUser -Name $user -Password $secure -PasswordNeverExpires $true
|
||||
}
|
||||
Add-LocalGroupMember -Group "Administrators" -Member $user -ErrorAction SilentlyContinue
|
||||
Set-Content -NoNewline -Encoding ASCII -Path $windowsUsernamePath -Value $user
|
||||
Set-Content -NoNewline -Encoding ASCII -Path $windowsPasswordPath -Value $userPassword
|
||||
$userSID = (Get-LocalUser -Name $user).SID.Value
|
||||
$userSSHDir = Join-Path (Join-Path "C:\Users" $user) ".ssh"
|
||||
$userAuthorizedKeys = Join-Path $userSSHDir "authorized_keys"
|
||||
New-Item -ItemType Directory -Force -Path $userSSHDir | Out-Null
|
||||
Set-Content -Encoding ASCII -Path $userAuthorizedKeys -Value $publicKey
|
||||
icacls.exe $userAuthorizedKeys /inheritance:r /grant "*${userSID}:F" /grant "*S-1-5-32-544:F" /grant "*S-1-5-18:F" | Out-Null
|
||||
if (-not (Get-Service -Name sshd -ErrorAction SilentlyContinue)) {
|
||||
Retry { Invoke-WebRequest -Uri ` + psQuote(openSSHWin64ZipURL) + ` -OutFile $openSSHZip -UseBasicParsing }
|
||||
Remove-Item -Recurse -Force "C:\Program Files\OpenSSH" -ErrorAction SilentlyContinue
|
||||
Expand-Archive -LiteralPath $openSSHZip -DestinationPath "C:\Program Files" -Force
|
||||
if (Test-Path -LiteralPath "C:\Program Files\OpenSSH-Win64") {
|
||||
Rename-Item -LiteralPath "C:\Program Files\OpenSSH-Win64" -NewName "OpenSSH" -Force
|
||||
}
|
||||
& "C:\Program Files\OpenSSH\install-sshd.ps1"
|
||||
}
|
||||
New-Item -ItemType Directory -Force -Path "$env:ProgramData\ssh" | Out-Null
|
||||
Set-Content -Encoding ASCII -Path "$env:ProgramData\ssh\administrators_authorized_keys" -Value $publicKey
|
||||
icacls.exe "$env:ProgramData\ssh\administrators_authorized_keys" /inheritance:r /grant "*S-1-5-32-544:F" /grant "*S-1-5-18:F" | Out-Null
|
||||
$sshdConfigPath = "$env:ProgramData\ssh\sshd_config"
|
||||
$sshdConfig = ""
|
||||
if (Test-Path -LiteralPath $sshdConfigPath) {
|
||||
$sshdConfig = Get-Content -Raw -LiteralPath $sshdConfigPath
|
||||
}
|
||||
$globalLines = @()
|
||||
$matchLines = @()
|
||||
$inMatch = $false
|
||||
foreach ($line in ($sshdConfig -split "\r?\n")) {
|
||||
if ($line -match '^\s*Match\s+') { $inMatch = $true }
|
||||
if (-not $inMatch -and $line -match '^\s*Port\s+\d+\s*$') { continue }
|
||||
if ($inMatch) { $matchLines += $line } else { $globalLines += $line }
|
||||
}
|
||||
foreach ($port in $sshPorts) { $globalLines += "Port $port" }
|
||||
Set-Content -Encoding ASCII -LiteralPath $sshdConfigPath -Value (($globalLines + $matchLines) -join [Environment]::NewLine)
|
||||
foreach ($port in $sshPorts) {
|
||||
$ruleName = "crabbox-sshd-$port"
|
||||
if (-not (Get-NetFirewallRule -Name $ruleName -ErrorAction SilentlyContinue)) {
|
||||
New-NetFirewallRule -Name $ruleName -DisplayName "Crabbox OpenSSH $port" -Enabled True -Direction Inbound -Protocol TCP -Action Allow -LocalPort $port | Out-Null
|
||||
}
|
||||
}
|
||||
Set-Service -Name sshd -StartupType Automatic
|
||||
Start-Service sshd
|
||||
if (-not (Test-Path -LiteralPath "C:\Program Files\Git\cmd\git.exe")) {
|
||||
Retry { Invoke-WebRequest -Uri ` + psQuote(gitForWindowsSetupURL) + ` -OutFile $gitInstaller -UseBasicParsing }
|
||||
Start-Process -FilePath $gitInstaller -ArgumentList "/VERYSILENT","/NORESTART","/NOCANCEL","/SP-" -Wait
|
||||
}
|
||||
$machinePath = [Environment]::GetEnvironmentVariable("Path", "Machine")
|
||||
foreach ($path in @("C:\Program Files\OpenSSH", "C:\Program Files\Git\cmd", "C:\Program Files\Git\usr\bin")) {
|
||||
if ($machinePath -notlike "*$path*") { $machinePath = "$machinePath;$path" }
|
||||
if ($env:Path -notlike "*$path*") { $env:Path = "$env:Path;$path" }
|
||||
}
|
||||
[Environment]::SetEnvironmentVariable("Path", $machinePath, "Machine")
|
||||
Initialize-CrabboxWSL2
|
||||
if (-not (Test-Path -LiteralPath "C:\Program Files\TightVNC\tvnserver.exe")) {
|
||||
Retry { Invoke-WebRequest -Uri ` + psQuote(tightVNCMSIURL) + ` -OutFile $tightVNCInstaller -UseBasicParsing }
|
||||
@ -409,24 +380,6 @@ if (-not (Test-Path -LiteralPath $setupCompletePath)) {
|
||||
`
|
||||
}
|
||||
|
||||
func azureWindowsBootstrapPowerShell(cfg Config, publicKey string) string {
|
||||
workRoot := cfg.WorkRoot
|
||||
if workRoot == "" {
|
||||
workRoot = defaultWindowsWorkRoot
|
||||
}
|
||||
return windowsBootstrapHeaderPowerShell(cfg, publicKey, workRoot) + `
|
||||
$passwordPath = Join-Path $base "windows.password"
|
||||
$usernamePath = Join-Path $base "windows.username"
|
||||
$passwordMirrorPath = $null
|
||||
$enforceKeyAuth = $true
|
||||
` + windowsBootstrapCorePowerShell() + `
|
||||
Restart-Service sshd -Force
|
||||
git --version | Out-Null
|
||||
tar --version | Out-Null
|
||||
Set-Content -NoNewline -Encoding ASCII -Path $setupCompletePath -Value (Get-Date).ToString("o")
|
||||
`
|
||||
}
|
||||
|
||||
func windowsSSHPortsPowerShell(cfg Config) string {
|
||||
ports := sshPortCandidates(cfg.SSHPort, cfg.SSHFallbackPorts)
|
||||
quoted := make([]string, 0, len(ports))
|
||||
@ -439,20 +392,14 @@ func windowsSSHPortsPowerShell(cfg Config) string {
|
||||
func macOSUserData(cfg Config, _ string) string {
|
||||
workRoot := cfg.WorkRoot
|
||||
if workRoot == "" {
|
||||
workRoot = defaultMacOSWorkRoot
|
||||
workRoot = "/work/crabbox"
|
||||
}
|
||||
return `#!/bin/bash
|
||||
set -euxo pipefail
|
||||
install -d -m 0755 ` + shellQuote(workRoot) + ` /var/db/crabbox
|
||||
chown -R ` + shellQuote(cfg.SSHUser) + `:staff ` + shellQuote(workRoot) + `
|
||||
if [ ! -s /var/db/crabbox/vnc.password ]; then
|
||||
set +o pipefail
|
||||
pw="$(LC_ALL=C tr -dc 'A-Za-z0-9' </dev/urandom | head -c 16)"
|
||||
set -o pipefail
|
||||
if [ "${#pw}" -ne 16 ]; then
|
||||
echo "failed to generate vnc password" >&2
|
||||
exit 1
|
||||
fi
|
||||
printf '%s\n' "$pw" >/var/db/crabbox/vnc.password
|
||||
dscl . -passwd /Users/` + shellQuote(cfg.SSHUser) + ` "$pw"
|
||||
fi
|
||||
@ -587,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 xclip xsel 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 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)
|
||||
@ -625,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 --window-size=1500,900 --window-position=80,80 \"\$@\"" > "$browser_wrapper"
|
||||
printf '%s\n' '#!/bin/sh' "exec \"$browser_path\" --no-first-run --no-default-browser-check --disable-default-apps \"\$@\"" > "$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
|
||||
|
||||
@ -17,8 +17,6 @@ func TestCloudInitUsesRetryingBootstrap(t *testing.T) {
|
||||
"test -f /var/lib/crabbox/bootstrapped",
|
||||
"test -w /work/crabbox",
|
||||
" Port 2222\n Port 22",
|
||||
"systemctl enable ssh || true",
|
||||
"timeout 30s systemctl restart ssh || timeout 30s systemctl restart ssh.socket || true",
|
||||
"touch /var/lib/crabbox/bootstrapped",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
@ -28,9 +26,6 @@ func TestCloudInitUsesRetryingBootstrap(t *testing.T) {
|
||||
if strings.Contains(got, "\npackages:\n") {
|
||||
t.Fatal("cloudInit() must not use cloud-init's one-shot packages module")
|
||||
}
|
||||
if strings.Contains(got, "systemctl enable --now ssh") {
|
||||
t.Fatal("cloudInit() must not use blocking systemctl enable --now ssh")
|
||||
}
|
||||
for _, notWant := range []string{"go version", "golang-go", "go.dev/dl/go", "/usr/local/go", "node --version", "pnpm --version", "docker --version", "build-essential", "docker.io", "corepack"} {
|
||||
if strings.Contains(got, notWant) {
|
||||
t.Fatalf("cloudInit() should not install project language runtime %q", notWant)
|
||||
@ -42,7 +37,7 @@ func TestCloudInitStartsSSHBeforeOptionalDesktopBootstrap(t *testing.T) {
|
||||
cfg := baseConfig()
|
||||
cfg.Desktop = true
|
||||
got := cloudInit(cfg, "ssh-ed25519 test")
|
||||
sshIndex := strings.Index(got, "timeout 30s systemctl restart ssh")
|
||||
sshIndex := strings.Index(got, "systemctl restart ssh")
|
||||
desktopIndex := strings.Index(got, "retry apt-get install -y --no-install-recommends xvfb")
|
||||
bootstrappedIndex := strings.Index(got, "touch /var/lib/crabbox/bootstrapped")
|
||||
if sshIndex < 0 || desktopIndex < 0 || bootstrappedIndex < 0 {
|
||||
@ -63,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 xclip xsel",
|
||||
"x11-xserver-utils xterm scrot ffmpeg xdotool wmctrl",
|
||||
"/etc/systemd/system/crabbox-xvfb.service",
|
||||
"/etc/systemd/system/crabbox-desktop.service",
|
||||
"/usr/local/bin/crabbox-desktop-session",
|
||||
@ -100,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 --window-size=1500,900 --window-position=80,80",
|
||||
"--no-first-run --no-default-browser-check --disable-default-apps",
|
||||
"/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 --window-size=1500,900 --window-position=80,80 \\\"\\$@\\\"\" > \"$browser_wrapper\"",
|
||||
"printf '%s\\n' '#!/bin/sh' \"exec \\\"$browser_path\\\" --no-first-run --no-default-browser-check --disable-default-apps \\\"\\$@\\\"\" > \"$browser_wrapper\"",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("cloudInit(browser) missing %q", want)
|
||||
@ -187,7 +182,6 @@ func TestAWSUserDataWindowsProfile(t *testing.T) {
|
||||
"OpenSSH-Win64.zip",
|
||||
"install-sshd.ps1",
|
||||
"administrators_authorized_keys",
|
||||
"Match Group administrators",
|
||||
"$sshPorts = @('2222', '22')",
|
||||
"sshd_config",
|
||||
"Port $port",
|
||||
@ -260,15 +254,10 @@ func TestAWSUserDataMacOSProfile(t *testing.T) {
|
||||
cfg.Provider = "aws"
|
||||
cfg.TargetOS = targetMacOS
|
||||
cfg.SSHUser = "ec2-user"
|
||||
cfg.WorkRoot = defaultMacOSWorkRoot
|
||||
got := awsUserData(cfg, "ssh-ed25519 test")
|
||||
for _, want := range []string{
|
||||
"#!/bin/bash",
|
||||
defaultMacOSWorkRoot,
|
||||
"/var/db/crabbox/vnc.password",
|
||||
"set +o pipefail",
|
||||
"set -o pipefail",
|
||||
"failed to generate vnc password",
|
||||
"com.apple.screensharing",
|
||||
"/usr/local/bin/crabbox-ready",
|
||||
"nc -z 127.0.0.1 5900",
|
||||
|
||||
@ -47,9 +47,6 @@ func validateRequestedCapabilities(cfg Config) error {
|
||||
if cfg.Code && !featureSetHas(spec.Features, FeatureCode) {
|
||||
return exit(2, "web code is not supported for provider=%s", provider.Name())
|
||||
}
|
||||
if cfg.Provider == "azure" && cfg.TargetOS == targetWindows && (cfg.Desktop || cfg.Browser || cfg.Code || cfg.Tailscale.Enabled) {
|
||||
return exit(2, "provider=azure target=windows currently supports SSH, sync, and run; desktop/browser/code/tailscale require Linux or AWS Windows where supported")
|
||||
}
|
||||
if cfg.Code && cfg.TargetOS != targetLinux {
|
||||
return exit(2, "web code currently supports managed Linux leases only")
|
||||
}
|
||||
@ -273,7 +270,7 @@ func vncPasswordCommand(target SSHTarget) string {
|
||||
return powershellCommand("Get-Content -Raw -LiteralPath " + psQuote(windowsVNCPasswordPath))
|
||||
}
|
||||
if target.TargetOS == targetMacOS {
|
||||
return "sudo cat " + shellQuote(macOSVNCPasswordPath)
|
||||
return "cat " + shellQuote(macOSVNCPasswordPath)
|
||||
}
|
||||
return "cat " + shellQuote(vncPasswordPath)
|
||||
}
|
||||
|
||||
@ -21,7 +21,6 @@ type crabboxKongCLI struct {
|
||||
Run runKongCmd `cmd:"" passthrough:"" help:"Sync the repo, run a remote command, stream output."`
|
||||
Desktop desktopKongCmd `cmd:"" help:"Launch apps into a visible desktop session."`
|
||||
Media mediaKongCmd `cmd:"" help:"Create preview artifacts from recorded desktop videos."`
|
||||
Artifacts artifactsKongCmd `cmd:"" help:"Collect, transform, and publish QA artifacts."`
|
||||
SyncPlan syncPlanKongCmd `cmd:"" name:"sync-plan" passthrough:"" help:"Show local sync manifest size hotspots."`
|
||||
History historyKongCmd `cmd:"" passthrough:"" help:"List recorded remote runs."`
|
||||
Logs logsKongCmd `cmd:"" passthrough:"" help:"Print recorded run logs."`
|
||||
@ -31,8 +30,6 @@ type crabboxKongCLI struct {
|
||||
Cache cacheKongCmd `cmd:"" help:"Inspect, purge, or warm remote caches."`
|
||||
Status statusKongCmd `cmd:"" passthrough:"" help:"Show lease state; add --wait to block until ready."`
|
||||
List listKongCmd `cmd:"" passthrough:"" help:"List Crabbox machines."`
|
||||
Share shareKongCmd `cmd:"" passthrough:"" help:"Share a lease with users or the owning org."`
|
||||
Unshare unshareKongCmd `cmd:"" passthrough:"" help:"Remove lease sharing."`
|
||||
Image imageKongCmd `cmd:"" help:"Create or promote brokered AWS runner images."`
|
||||
Usage usageKongCmd `cmd:"" passthrough:"" help:"Show cost and usage estimates by user, org, or fleet."`
|
||||
Admin adminKongCmd `cmd:"" help:"Lease admin controls for trusted operators."`
|
||||
@ -41,7 +38,6 @@ type crabboxKongCLI struct {
|
||||
Vnc vncKongCmd `cmd:"" name:"vnc" passthrough:"" help:"Print or open VNC connection details for a desktop lease."`
|
||||
Webvnc webvncKongCmd `cmd:"" name:"webvnc" passthrough:"" help:"Bridge a desktop lease into the authenticated web portal."`
|
||||
Code codeKongCmd `cmd:"" passthrough:"" help:"Bridge a code lease into the authenticated web portal."`
|
||||
Egress egressKongCmd `cmd:"" passthrough:"" help:"Bridge lease browser/app traffic through this machine."`
|
||||
Screenshot screenshotKongCmd `cmd:"" passthrough:"" help:"Capture a PNG from a desktop lease."`
|
||||
Inspect inspectKongCmd `cmd:"" passthrough:"" help:"Print lease/provider details; add --json for scripts."`
|
||||
Stop stopKongCmd `cmd:"" passthrough:"" help:"Release a lease or delete a direct-provider machine."`
|
||||
@ -112,7 +108,7 @@ func normalizeKongHelpArgs(args []string) []string {
|
||||
|
||||
func isKongCommandGroup(command string) bool {
|
||||
switch command {
|
||||
case "actions", "admin", "artifacts", "cache", "config", "desktop", "image", "machine", "media", "pool":
|
||||
case "actions", "admin", "cache", "config", "desktop", "image", "machine", "media", "pool":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
@ -164,12 +160,6 @@ type statusKongCmd struct {
|
||||
type listKongCmd struct {
|
||||
Args []string `arg:"" optional:""`
|
||||
}
|
||||
type shareKongCmd struct {
|
||||
Args []string `arg:"" optional:""`
|
||||
}
|
||||
type unshareKongCmd struct {
|
||||
Args []string `arg:"" optional:""`
|
||||
}
|
||||
type usageKongCmd struct {
|
||||
Args []string `arg:"" optional:""`
|
||||
}
|
||||
@ -185,9 +175,6 @@ type webvncKongCmd struct {
|
||||
type codeKongCmd struct {
|
||||
Args []string `arg:"" optional:""`
|
||||
}
|
||||
type egressKongCmd struct {
|
||||
Args []string `arg:"" optional:""`
|
||||
}
|
||||
type screenshotKongCmd struct {
|
||||
Args []string `arg:"" optional:""`
|
||||
}
|
||||
@ -206,30 +193,10 @@ 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."`
|
||||
@ -238,29 +205,6 @@ type mediaPreviewKongCmd struct {
|
||||
Args []string `arg:"" optional:""`
|
||||
}
|
||||
|
||||
type artifactsKongCmd struct {
|
||||
Collect artifactsCollectKongCmd `cmd:"" passthrough:"" help:"Collect screenshots, video, logs, status, and metadata into a bundle."`
|
||||
Video artifactsVideoKongCmd `cmd:"" passthrough:"" help:"Record an MP4 from a desktop lease."`
|
||||
Gif artifactsGifKongCmd `cmd:"" passthrough:"" help:"Create a trimmed GIF preview from a video."`
|
||||
Template artifactsTemplateKongCmd `cmd:"" passthrough:"" help:"Write Mantis/OpenClaw QA summary markdown."`
|
||||
Publish artifactsPublishKongCmd `cmd:"" passthrough:"" help:"Upload a bundle and optionally comment inline-ready assets on a PR."`
|
||||
}
|
||||
type artifactsCollectKongCmd struct {
|
||||
Args []string `arg:"" optional:""`
|
||||
}
|
||||
type artifactsVideoKongCmd struct {
|
||||
Args []string `arg:"" optional:""`
|
||||
}
|
||||
type artifactsGifKongCmd struct {
|
||||
Args []string `arg:"" optional:""`
|
||||
}
|
||||
type artifactsTemplateKongCmd struct {
|
||||
Args []string `arg:"" optional:""`
|
||||
}
|
||||
type artifactsPublishKongCmd struct {
|
||||
Args []string `arg:"" optional:""`
|
||||
}
|
||||
|
||||
type cacheKongCmd struct {
|
||||
List cacheListKongCmd `cmd:"" passthrough:"" help:"Show remote cache usage."`
|
||||
Stats cacheStatsKongCmd `cmd:"" passthrough:"" help:"Show remote cache usage."`
|
||||
@ -365,14 +309,11 @@ func (c *attachKongCmd) Run(ctx context.Context, app App) error { return app.a
|
||||
func (c *resultsKongCmd) Run(ctx context.Context, app App) error { return app.results(ctx, c.Args) }
|
||||
func (c *statusKongCmd) Run(ctx context.Context, app App) error { return app.status(ctx, c.Args) }
|
||||
func (c *listKongCmd) Run(ctx context.Context, app App) error { return app.list(ctx, c.Args) }
|
||||
func (c *shareKongCmd) Run(ctx context.Context, app App) error { return app.share(ctx, c.Args) }
|
||||
func (c *unshareKongCmd) Run(ctx context.Context, app App) error { return app.unshare(ctx, c.Args) }
|
||||
func (c *usageKongCmd) Run(ctx context.Context, app App) error { return app.usage(ctx, c.Args) }
|
||||
func (c *sshKongCmd) Run(ctx context.Context, app App) error { return app.ssh(ctx, c.Args) }
|
||||
func (c *vncKongCmd) Run(ctx context.Context, app App) error { return app.vnc(ctx, c.Args) }
|
||||
func (c *webvncKongCmd) Run(ctx context.Context, app App) error { return app.webvnc(ctx, c.Args) }
|
||||
func (c *codeKongCmd) Run(ctx context.Context, app App) error { return app.webCode(ctx, c.Args) }
|
||||
func (c *egressKongCmd) Run(ctx context.Context, app App) error { return app.egress(ctx, c.Args) }
|
||||
func (c *screenshotKongCmd) Run(ctx context.Context, app App) error {
|
||||
return app.screenshot(ctx, c.Args)
|
||||
}
|
||||
@ -384,42 +325,11 @@ 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)
|
||||
}
|
||||
|
||||
func (c *artifactsCollectKongCmd) Run(ctx context.Context, app App) error {
|
||||
return app.artifactsCollect(ctx, stripKongCommandPath(c.Args, "artifacts", "collect"))
|
||||
}
|
||||
func (c *artifactsVideoKongCmd) Run(ctx context.Context, app App) error {
|
||||
return app.artifactsVideo(ctx, stripKongCommandPath(c.Args, "artifacts", "video"))
|
||||
}
|
||||
func (c *artifactsGifKongCmd) Run(ctx context.Context, app App) error {
|
||||
return app.artifactsGif(ctx, stripKongCommandPath(c.Args, "artifacts", "gif"))
|
||||
}
|
||||
func (c *artifactsTemplateKongCmd) Run(ctx context.Context, app App) error {
|
||||
return app.artifactsTemplate(ctx, stripKongCommandPath(c.Args, "artifacts", "template"))
|
||||
}
|
||||
func (c *artifactsPublishKongCmd) Run(ctx context.Context, app App) error {
|
||||
return app.artifactsPublish(ctx, stripKongCommandPath(c.Args, "artifacts", "publish"))
|
||||
}
|
||||
|
||||
func (c *cacheListKongCmd) Run(ctx context.Context, app App) error {
|
||||
return app.cacheStats(ctx, c.Args)
|
||||
}
|
||||
@ -484,14 +394,3 @@ func (c *versionKongCmd) Run(app App) error {
|
||||
fmt.Fprintln(app.Stdout, version)
|
||||
return nil
|
||||
}
|
||||
|
||||
func stripKongCommandPath(args []string, path ...string) []string {
|
||||
out := append([]string{}, args...)
|
||||
for _, part := range path {
|
||||
if len(out) == 0 || out[0] != part {
|
||||
return out
|
||||
}
|
||||
out = out[1:]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@ -53,7 +53,7 @@ const (
|
||||
func (a App) webCode(ctx context.Context, args []string) error {
|
||||
defaults := defaultConfig()
|
||||
fs := newFlagSet("code", a.Stderr)
|
||||
provider := fs.String("provider", defaults.Provider, "provider: hetzner, aws, or azure")
|
||||
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 code-server tunnel port")
|
||||
|
||||
@ -38,16 +38,6 @@ type Config struct {
|
||||
AWSRootGB int32
|
||||
AWSSSHCIDRs []string
|
||||
AWSMacHostID string
|
||||
AzureSubscription string
|
||||
AzureTenant string
|
||||
AzureClientID string
|
||||
AzureLocation string
|
||||
AzureResourceGroup string
|
||||
AzureImage string
|
||||
AzureVNet string
|
||||
AzureSubnet string
|
||||
AzureNSG string
|
||||
AzureSSHCIDRs []string
|
||||
SSHUser string
|
||||
SSHKey string
|
||||
SSHPort string
|
||||
@ -205,31 +195,25 @@ func baseConfig() Config {
|
||||
class := "beast"
|
||||
provider := "hetzner"
|
||||
return Config{
|
||||
Profile: "default",
|
||||
Provider: provider,
|
||||
TargetOS: "linux",
|
||||
WindowsMode: "normal",
|
||||
Network: NetworkAuto,
|
||||
Class: class,
|
||||
ServerType: "",
|
||||
Location: "fsn1",
|
||||
Image: "ubuntu-24.04",
|
||||
AWSRegion: "eu-west-1",
|
||||
AWSRootGB: 400,
|
||||
AzureLocation: "eastus",
|
||||
AzureResourceGroup: "crabbox-leases",
|
||||
AzureImage: defaultAzureLinuxImage,
|
||||
AzureVNet: "crabbox-vnet",
|
||||
AzureSubnet: "crabbox-subnet",
|
||||
AzureNSG: "crabbox-nsg",
|
||||
SSHUser: "crabbox",
|
||||
SSHKey: sshKey,
|
||||
SSHPort: "2222",
|
||||
SSHFallbackPorts: []string{"22"},
|
||||
ProviderKey: "crabbox-steipete",
|
||||
WorkRoot: defaultPOSIXWorkRoot,
|
||||
TTL: 90 * time.Minute,
|
||||
IdleTimeout: 30 * time.Minute,
|
||||
Profile: "default",
|
||||
Provider: provider,
|
||||
TargetOS: "linux",
|
||||
WindowsMode: "normal",
|
||||
Network: NetworkAuto,
|
||||
Class: class,
|
||||
ServerType: "",
|
||||
Location: "fsn1",
|
||||
Image: "ubuntu-24.04",
|
||||
AWSRegion: "eu-west-1",
|
||||
AWSRootGB: 400,
|
||||
SSHUser: "crabbox",
|
||||
SSHKey: sshKey,
|
||||
SSHPort: "2222",
|
||||
SSHFallbackPorts: []string{"22"},
|
||||
ProviderKey: "crabbox-steipete",
|
||||
WorkRoot: defaultPOSIXWorkRoot,
|
||||
TTL: 90 * time.Minute,
|
||||
IdleTimeout: 30 * time.Minute,
|
||||
Sync: SyncConfig{
|
||||
Delete: true,
|
||||
Checksum: false,
|
||||
@ -299,7 +283,6 @@ type fileConfig struct {
|
||||
Broker *fileBrokerConfig `yaml:"broker,omitempty"`
|
||||
Hetzner *fileHetznerConfig `yaml:"hetzner,omitempty"`
|
||||
AWS *fileAWSConfig `yaml:"aws,omitempty"`
|
||||
Azure *fileAzureConfig `yaml:"azure,omitempty"`
|
||||
SSH *fileSSHConfig `yaml:"ssh,omitempty"`
|
||||
Sync *fileSyncConfig `yaml:"sync,omitempty"`
|
||||
Env *fileEnvConfig `yaml:"env,omitempty"`
|
||||
@ -353,19 +336,6 @@ type fileAWSConfig struct {
|
||||
MacHostID string `yaml:"macHostId,omitempty"`
|
||||
}
|
||||
|
||||
type fileAzureConfig struct {
|
||||
SubscriptionID string `yaml:"subscriptionId,omitempty"`
|
||||
TenantID string `yaml:"tenantId,omitempty"`
|
||||
ClientID string `yaml:"clientId,omitempty"`
|
||||
Location string `yaml:"location,omitempty"`
|
||||
ResourceGroup string `yaml:"resourceGroup,omitempty"`
|
||||
Image string `yaml:"image,omitempty"`
|
||||
VNet string `yaml:"vnet,omitempty"`
|
||||
Subnet string `yaml:"subnet,omitempty"`
|
||||
NSG string `yaml:"nsg,omitempty"`
|
||||
SSHCIDRs []string `yaml:"sshCIDRs,omitempty"`
|
||||
}
|
||||
|
||||
type fileSSHConfig struct {
|
||||
User string `yaml:"user,omitempty"`
|
||||
Key string `yaml:"key,omitempty"`
|
||||
@ -678,38 +648,6 @@ func applyFileConfig(cfg *Config, file fileConfig) {
|
||||
cfg.AWSMacHostID = file.AWS.MacHostID
|
||||
}
|
||||
}
|
||||
if file.Azure != nil {
|
||||
if file.Azure.SubscriptionID != "" {
|
||||
cfg.AzureSubscription = file.Azure.SubscriptionID
|
||||
}
|
||||
if file.Azure.TenantID != "" {
|
||||
cfg.AzureTenant = file.Azure.TenantID
|
||||
}
|
||||
if file.Azure.ClientID != "" {
|
||||
cfg.AzureClientID = file.Azure.ClientID
|
||||
}
|
||||
if file.Azure.Location != "" {
|
||||
cfg.AzureLocation = file.Azure.Location
|
||||
}
|
||||
if file.Azure.ResourceGroup != "" {
|
||||
cfg.AzureResourceGroup = file.Azure.ResourceGroup
|
||||
}
|
||||
if file.Azure.Image != "" {
|
||||
cfg.AzureImage = file.Azure.Image
|
||||
}
|
||||
if file.Azure.VNet != "" {
|
||||
cfg.AzureVNet = file.Azure.VNet
|
||||
}
|
||||
if file.Azure.Subnet != "" {
|
||||
cfg.AzureSubnet = file.Azure.Subnet
|
||||
}
|
||||
if file.Azure.NSG != "" {
|
||||
cfg.AzureNSG = file.Azure.NSG
|
||||
}
|
||||
if len(file.Azure.SSHCIDRs) > 0 {
|
||||
cfg.AzureSSHCIDRs = file.Azure.SSHCIDRs
|
||||
}
|
||||
}
|
||||
if file.SSH != nil {
|
||||
if file.SSH.User != "" {
|
||||
cfg.SSHUser = file.SSH.User
|
||||
@ -1005,18 +943,6 @@ func applyEnv(cfg *Config) {
|
||||
if cidrs := os.Getenv("CRABBOX_AWS_SSH_CIDRS"); cidrs != "" {
|
||||
cfg.AWSSSHCIDRs = splitCommaList(cidrs)
|
||||
}
|
||||
cfg.AzureSubscription = getenv("CRABBOX_AZURE_SUBSCRIPTION_ID", getenv("AZURE_SUBSCRIPTION_ID", cfg.AzureSubscription))
|
||||
cfg.AzureTenant = getenv("CRABBOX_AZURE_TENANT_ID", getenv("AZURE_TENANT_ID", cfg.AzureTenant))
|
||||
cfg.AzureClientID = getenv("CRABBOX_AZURE_CLIENT_ID", getenv("AZURE_CLIENT_ID", cfg.AzureClientID))
|
||||
cfg.AzureLocation = getenv("CRABBOX_AZURE_LOCATION", cfg.AzureLocation)
|
||||
cfg.AzureResourceGroup = getenv("CRABBOX_AZURE_RESOURCE_GROUP", cfg.AzureResourceGroup)
|
||||
cfg.AzureImage = getenv("CRABBOX_AZURE_IMAGE", cfg.AzureImage)
|
||||
cfg.AzureVNet = getenv("CRABBOX_AZURE_VNET", cfg.AzureVNet)
|
||||
cfg.AzureSubnet = getenv("CRABBOX_AZURE_SUBNET", cfg.AzureSubnet)
|
||||
cfg.AzureNSG = getenv("CRABBOX_AZURE_NSG", cfg.AzureNSG)
|
||||
if cidrs := os.Getenv("CRABBOX_AZURE_SSH_CIDRS"); cidrs != "" {
|
||||
cfg.AzureSSHCIDRs = splitCommaList(cidrs)
|
||||
}
|
||||
cfg.SSHUser = getenv("CRABBOX_SSH_USER", cfg.SSHUser)
|
||||
cfg.SSHKey = getenv("CRABBOX_SSH_KEY", cfg.SSHKey)
|
||||
cfg.SSHPort = getenv("CRABBOX_SSH_PORT", cfg.SSHPort)
|
||||
@ -1183,9 +1109,6 @@ func serverTypeForConfig(cfg Config) string {
|
||||
if cfg.Provider == "aws" {
|
||||
return awsInstanceTypeCandidatesForConfig(cfg)[0]
|
||||
}
|
||||
if cfg.Provider == "azure" {
|
||||
return azureVMSizeCandidatesForConfig(cfg)[0]
|
||||
}
|
||||
return serverTypeForClass(cfg.Class)
|
||||
}
|
||||
|
||||
@ -1199,9 +1122,6 @@ func serverTypeForProviderClass(provider, class string) string {
|
||||
if provider == "aws" {
|
||||
return awsInstanceTypeCandidatesForClass(class)[0]
|
||||
}
|
||||
if provider == "azure" {
|
||||
return azureVMSizeCandidatesForClass(class)[0]
|
||||
}
|
||||
return serverTypeForClass(class)
|
||||
}
|
||||
|
||||
|
||||
@ -134,7 +134,7 @@ func (a App) configShow(args []string) error {
|
||||
func (a App) configSetBroker(args []string) error {
|
||||
fs := newFlagSet("config set-broker", a.Stderr)
|
||||
url := fs.String("url", "", "broker URL")
|
||||
provider := fs.String("provider", "", "default provider: hetzner, aws, or azure")
|
||||
provider := fs.String("provider", "", "default provider: hetzner or aws")
|
||||
tokenStdin := fs.Bool("token-stdin", false, "read broker token from stdin")
|
||||
adminTokenStdin := fs.Bool("admin-token-stdin", false, "read broker admin token from stdin")
|
||||
if err := parseFlags(fs, args); err != nil {
|
||||
|
||||
@ -37,7 +37,6 @@ type CoordinatorLease struct {
|
||||
Region string `json:"region,omitempty"`
|
||||
Owner string `json:"owner"`
|
||||
Org string `json:"org"`
|
||||
Share *CoordinatorShare `json:"share,omitempty"`
|
||||
Profile string `json:"profile"`
|
||||
Class string `json:"class"`
|
||||
ServerType string `json:"serverType"`
|
||||
@ -65,20 +64,6 @@ type CoordinatorLease struct {
|
||||
TelemetryHistory []*LeaseTelemetry `json:"telemetryHistory,omitempty"`
|
||||
}
|
||||
|
||||
type CoordinatorShareRole string
|
||||
|
||||
const (
|
||||
CoordinatorShareUse CoordinatorShareRole = "use"
|
||||
CoordinatorShareManage CoordinatorShareRole = "manage"
|
||||
)
|
||||
|
||||
type CoordinatorShare struct {
|
||||
Users map[string]CoordinatorShareRole `json:"users,omitempty"`
|
||||
Org CoordinatorShareRole `json:"org,omitempty"`
|
||||
UpdatedAt string `json:"updatedAt,omitempty"`
|
||||
UpdatedBy string `json:"updatedBy,omitempty"`
|
||||
}
|
||||
|
||||
type ProvisioningAttempt struct {
|
||||
Region string `json:"region,omitempty"`
|
||||
ServerType string `json:"serverType"`
|
||||
@ -150,55 +135,6 @@ 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"`
|
||||
ViewerCount int `json:"viewerCount,omitempty"`
|
||||
ObserverCount int `json:"observerCount,omitempty"`
|
||||
AvailableViewerSlots int `json:"availableViewerSlots,omitempty"`
|
||||
ControllerLabel string `json:"controllerLabel,omitempty"`
|
||||
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"`
|
||||
Role string `json:"role"`
|
||||
SessionID string `json:"sessionID"`
|
||||
ExpiresAt string `json:"expiresAt"`
|
||||
}
|
||||
|
||||
type CoordinatorEgressStatus struct {
|
||||
LeaseID string `json:"leaseID"`
|
||||
Slug string `json:"slug,omitempty"`
|
||||
SessionID string `json:"sessionID,omitempty"`
|
||||
Profile string `json:"profile,omitempty"`
|
||||
Allow []string `json:"allow,omitempty"`
|
||||
HostConnected bool `json:"hostConnected"`
|
||||
ClientConnected bool `json:"clientConnected"`
|
||||
CreatedAt string `json:"createdAt,omitempty"`
|
||||
UpdatedAt string `json:"updatedAt,omitempty"`
|
||||
}
|
||||
|
||||
type CoordinatorRunsResponse struct {
|
||||
Runs []CoordinatorRun `json:"runs"`
|
||||
}
|
||||
@ -302,38 +238,6 @@ type CoordinatorRunEventInput struct {
|
||||
ExitCode *int `json:"exitCode,omitempty"`
|
||||
}
|
||||
|
||||
type CoordinatorArtifactUploadRequest struct {
|
||||
Prefix string `json:"prefix,omitempty"`
|
||||
Files []CoordinatorArtifactUploadInput `json:"files"`
|
||||
}
|
||||
|
||||
type CoordinatorArtifactUploadInput struct {
|
||||
Name string `json:"name"`
|
||||
Size int64 `json:"size"`
|
||||
ContentType string `json:"contentType,omitempty"`
|
||||
SHA256 string `json:"sha256,omitempty"`
|
||||
}
|
||||
|
||||
type CoordinatorArtifactUploadResponse struct {
|
||||
Backend string `json:"backend"`
|
||||
Bucket string `json:"bucket"`
|
||||
Prefix string `json:"prefix"`
|
||||
ExpiresAt string `json:"expiresAt"`
|
||||
Files []CoordinatorArtifactUploadGrant `json:"files"`
|
||||
}
|
||||
|
||||
type CoordinatorArtifactUploadGrant struct {
|
||||
Name string `json:"name"`
|
||||
Key string `json:"key"`
|
||||
Upload struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Headers map[string]string `json:"headers"`
|
||||
ExpiresAt string `json:"expiresAt"`
|
||||
} `json:"upload"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
type TestResultSummary struct {
|
||||
Format string `json:"format"`
|
||||
Files []string `json:"files"`
|
||||
@ -492,8 +396,6 @@ func (c *CoordinatorClient) CreateLease(ctx context.Context, cfg Config, publicK
|
||||
"awsRootGB": cfg.AWSRootGB,
|
||||
"awsSSHCIDRs": cfg.AWSSSHCIDRs,
|
||||
"awsMacHostID": cfg.AWSMacHostID,
|
||||
"azureLocation": cfg.AzureLocation,
|
||||
"azureImage": cfg.AzureImage,
|
||||
"sshUser": cfg.SSHUser,
|
||||
"sshPort": cfg.SSHPort,
|
||||
"sshFallbackPorts": cfg.SSHFallbackPorts,
|
||||
@ -527,37 +429,6 @@ func (c *CoordinatorClient) GetLease(ctx context.Context, id string) (Coordinato
|
||||
return res.Lease, err
|
||||
}
|
||||
|
||||
func (c *CoordinatorClient) LeaseShare(ctx context.Context, id string) (CoordinatorShare, error) {
|
||||
var res struct {
|
||||
Share CoordinatorShare `json:"share"`
|
||||
}
|
||||
err := c.do(ctx, http.MethodGet, "/v1/leases/"+url.PathEscape(id)+"/share", nil, &res)
|
||||
return res.Share, err
|
||||
}
|
||||
|
||||
func (c *CoordinatorClient) UpdateLeaseShare(ctx context.Context, id string, share CoordinatorShare) (CoordinatorShare, error) {
|
||||
var res struct {
|
||||
Share CoordinatorShare `json:"share"`
|
||||
}
|
||||
err := c.do(ctx, http.MethodPut, "/v1/leases/"+url.PathEscape(id)+"/share", share, &res)
|
||||
return res.Share, err
|
||||
}
|
||||
|
||||
func (c *CoordinatorClient) DeleteLeaseShare(ctx context.Context, id, user string, org bool) (CoordinatorShare, error) {
|
||||
var res struct {
|
||||
Share CoordinatorShare `json:"share"`
|
||||
}
|
||||
body := map[string]any{}
|
||||
if strings.TrimSpace(user) != "" {
|
||||
body["user"] = strings.TrimSpace(user)
|
||||
}
|
||||
if org {
|
||||
body["org"] = true
|
||||
}
|
||||
err := c.do(ctx, http.MethodDelete, "/v1/leases/"+url.PathEscape(id)+"/share", body, &res)
|
||||
return res.Share, err
|
||||
}
|
||||
|
||||
func (c *CoordinatorClient) ReleaseLease(ctx context.Context, id string, deleteServer bool) (CoordinatorLease, error) {
|
||||
var res struct {
|
||||
Lease CoordinatorLease `json:"lease"`
|
||||
@ -613,25 +484,6 @@ func (c *CoordinatorClient) Pool(ctx context.Context, cfg Config) ([]Coordinator
|
||||
return res.Machines, err
|
||||
}
|
||||
|
||||
func (c *CoordinatorClient) Leases(ctx context.Context, state string, limit int) ([]CoordinatorLease, error) {
|
||||
var res struct {
|
||||
Leases []CoordinatorLease `json:"leases"`
|
||||
}
|
||||
values := url.Values{}
|
||||
if state != "" {
|
||||
values.Set("state", state)
|
||||
}
|
||||
if limit > 0 {
|
||||
values.Set("limit", strconv.Itoa(limit))
|
||||
}
|
||||
path := "/v1/leases"
|
||||
if encoded := values.Encode(); encoded != "" {
|
||||
path += "?" + encoded
|
||||
}
|
||||
err := c.do(ctx, http.MethodGet, path, nil, &res)
|
||||
return res.Leases, err
|
||||
}
|
||||
|
||||
func (c *CoordinatorClient) Usage(ctx context.Context, scope, owner, org, month string) (CoordinatorUsageResponse, error) {
|
||||
var res CoordinatorUsageResponse
|
||||
values := url.Values{}
|
||||
@ -686,42 +538,6 @@ 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{
|
||||
"role": role,
|
||||
}
|
||||
if strings.TrimSpace(sessionID) != "" {
|
||||
body["sessionID"] = strings.TrimSpace(sessionID)
|
||||
}
|
||||
if strings.TrimSpace(profile) != "" {
|
||||
body["profile"] = strings.TrimSpace(profile)
|
||||
}
|
||||
if len(allow) > 0 {
|
||||
body["allow"] = allow
|
||||
}
|
||||
err := c.do(ctx, http.MethodPost, "/v1/leases/"+url.PathEscape(leaseID)+"/egress/ticket", body, &res)
|
||||
return res, err
|
||||
}
|
||||
|
||||
func (c *CoordinatorClient) EgressStatus(ctx context.Context, leaseID string) (CoordinatorEgressStatus, error) {
|
||||
var res CoordinatorEgressStatus
|
||||
err := c.do(ctx, http.MethodGet, "/v1/leases/"+url.PathEscape(leaseID)+"/egress/status", nil, &res)
|
||||
return res, err
|
||||
}
|
||||
|
||||
func (c *CoordinatorClient) AdminLeases(ctx context.Context, state, owner, org string, limit int) ([]CoordinatorLease, error) {
|
||||
var res struct {
|
||||
Leases []CoordinatorLease `json:"leases"`
|
||||
@ -838,12 +654,6 @@ func (c *CoordinatorClient) AppendRunEvent(ctx context.Context, runID string, in
|
||||
return res.Event, err
|
||||
}
|
||||
|
||||
func (c *CoordinatorClient) CreateArtifactUploads(ctx context.Context, input CoordinatorArtifactUploadRequest) (CoordinatorArtifactUploadResponse, error) {
|
||||
var res CoordinatorArtifactUploadResponse
|
||||
err := c.do(ctx, http.MethodPost, "/v1/artifacts/uploads", input, &res)
|
||||
return res, err
|
||||
}
|
||||
|
||||
func (c *CoordinatorClient) SyncExternalRunners(ctx context.Context, provider string, runners []CoordinatorExternalRunner) (CoordinatorExternalRunnerSyncResponse, error) {
|
||||
var res CoordinatorExternalRunnerSyncResponse
|
||||
err := c.do(ctx, http.MethodPost, "/v1/runners/sync", map[string]any{
|
||||
|
||||
@ -379,8 +379,6 @@ func TestCoordinatorLeaseWatchCancelsWhenLeaseReleased(t *testing.T) {
|
||||
func TestCoordinatorCreateLeaseSendsAWSSSHCIDRs(t *testing.T) {
|
||||
var body struct {
|
||||
AWSSSHCIDRs []string `json:"awsSSHCIDRs"`
|
||||
AzureLocation string `json:"azureLocation"`
|
||||
AzureImage string `json:"azureImage"`
|
||||
SSHFallbackPorts []string `json:"sshFallbackPorts"`
|
||||
ServerTypeExplicit bool `json:"serverTypeExplicit"`
|
||||
Capacity map[string]any
|
||||
@ -403,8 +401,6 @@ func TestCoordinatorCreateLeaseSendsAWSSSHCIDRs(t *testing.T) {
|
||||
ServerType: "t3.small",
|
||||
ServerTypeExplicit: true,
|
||||
AWSSSHCIDRs: []string{"198.51.100.7/32"},
|
||||
AzureLocation: "eastus",
|
||||
AzureImage: "Canonical:0001-com-ubuntu-server-jammy:22_04-lts-gen2:latest",
|
||||
SSHFallbackPorts: []string{"22", "2022"},
|
||||
Capacity: CapacityConfig{
|
||||
Market: "spot",
|
||||
@ -419,12 +415,6 @@ func TestCoordinatorCreateLeaseSendsAWSSSHCIDRs(t *testing.T) {
|
||||
if len(body.AWSSSHCIDRs) != 1 || body.AWSSSHCIDRs[0] != "198.51.100.7/32" {
|
||||
t.Fatalf("awsSSHCIDRs=%v", body.AWSSSHCIDRs)
|
||||
}
|
||||
if body.AzureLocation != "eastus" {
|
||||
t.Fatalf("azureLocation=%q", body.AzureLocation)
|
||||
}
|
||||
if body.AzureImage != "Canonical:0001-com-ubuntu-server-jammy:22_04-lts-gen2:latest" {
|
||||
t.Fatalf("azureImage=%q", body.AzureImage)
|
||||
}
|
||||
if len(body.SSHFallbackPorts) != 2 || body.SSHFallbackPorts[0] != "22" || body.SSHFallbackPorts[1] != "2022" {
|
||||
t.Fatalf("sshFallbackPorts=%v", body.SSHFallbackPorts)
|
||||
}
|
||||
|
||||
@ -4,34 +4,27 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func (a App) desktopLaunch(ctx context.Context, args []string) error {
|
||||
defaults := defaultConfig()
|
||||
fs := newFlagSet("desktop launch", a.Stderr)
|
||||
provider := fs.String("provider", defaults.Provider, providerHelpSSH())
|
||||
provider := fs.String("provider", defaults.Provider, "provider: hetzner, aws, or ssh")
|
||||
id := fs.String("id", "", "lease id or slug")
|
||||
browser := fs.Bool("browser", false, "launch the target browser")
|
||||
url := fs.String("url", "", "URL to pass to the launched browser")
|
||||
webvnc := fs.Bool("webvnc", false, "bridge the launched desktop into the authenticated WebVNC portal")
|
||||
openPortal := fs.Bool("open", false, "open the WebVNC portal when --webvnc is set")
|
||||
fullscreen := fs.Bool("fullscreen", false, "leave launched browser fullscreen for capture/video workflows")
|
||||
egress := fs.String("egress", "", "egress profile; passes the active lease-local proxy to the browser")
|
||||
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
|
||||
}
|
||||
if *openPortal && !*webvnc {
|
||||
return exit(2, "desktop launch --open requires --webvnc")
|
||||
}
|
||||
if strings.TrimSpace(*egress) != "" && !*browser {
|
||||
return exit(2, "desktop launch --egress currently requires --browser")
|
||||
}
|
||||
positionalID := false
|
||||
if *id == "" && fs.NArg() > 0 {
|
||||
*id = fs.Arg(0)
|
||||
@ -47,19 +40,16 @@ 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
|
||||
}
|
||||
if *webvnc && (isBlacksmithProvider(cfg.Provider) || isStaticProvider(cfg.Provider)) {
|
||||
return exit(2, "desktop launch --webvnc currently supports coordinator-backed hetzner/aws/azure desktop leases")
|
||||
return exit(2, "desktop launch --webvnc currently supports coordinator-backed hetzner/aws desktop leases")
|
||||
}
|
||||
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.resolveNetworkLeaseTarget(ctx, cfg, *id, false)
|
||||
server, target, leaseID, err := a.resolveLeaseTarget(ctx, cfg, *id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@ -85,49 +75,26 @@ func (a App) desktopLaunch(ctx context.Context, args []string) error {
|
||||
if positionalID && len(command) > 0 && command[0] == *id {
|
||||
command = command[1:]
|
||||
}
|
||||
expectBrowserLaunch := false
|
||||
if *browser {
|
||||
if len(command) == 0 {
|
||||
if env["BROWSER"] == "" {
|
||||
printRescue(a.Stdout, rescueBrowserNotLaunched, "browser=true requested but target did not report BROWSER", desktopDoctorCommand(rescueContext{Cfg: cfg, Target: target, LeaseID: leaseID}))
|
||||
return exit(2, "browser=true requested but target did not report BROWSER")
|
||||
}
|
||||
command = []string{env["BROWSER"]}
|
||||
expectBrowserLaunch = true
|
||||
if strings.TrimSpace(*egress) != "" {
|
||||
command = append(command, "--proxy-server=http://"+strings.TrimSpace(*egressProxy))
|
||||
}
|
||||
if strings.TrimSpace(*url) != "" {
|
||||
command = append(command, strings.TrimSpace(*url))
|
||||
}
|
||||
} else if strings.TrimSpace(*url) != "" {
|
||||
expectBrowserLaunch = desktopCommandLooksLikeBrowser(command, env["BROWSER"])
|
||||
if strings.TrimSpace(*egress) != "" {
|
||||
command = append(command, "--proxy-server=http://"+strings.TrimSpace(*egressProxy))
|
||||
}
|
||||
command = append(command, strings.TrimSpace(*url))
|
||||
} else if strings.TrimSpace(*egress) != "" {
|
||||
expectBrowserLaunch = desktopCommandLooksLikeBrowser(command, env["BROWSER"])
|
||||
command = append(command, "--proxy-server=http://"+strings.TrimSpace(*egressProxy))
|
||||
} else {
|
||||
expectBrowserLaunch = desktopCommandLooksLikeBrowser(command, env["BROWSER"])
|
||||
}
|
||||
}
|
||||
if len(command) == 0 {
|
||||
return exit(2, "usage: crabbox desktop launch --id <lease-id-or-slug> -- <command...>")
|
||||
}
|
||||
workdir := remoteJoin(cfg, leaseID, repo.Name)
|
||||
rescueCtx := rescueContext{Cfg: cfg, Target: target, LeaseID: leaseID}
|
||||
if out, err := runSSHCombinedOutput(ctx, target, desktopLaunchRemoteCommand(target, workdir, env, command, *browser && !*fullscreen)); err != nil {
|
||||
printRescue(a.Stdout, classifyDesktopFailure(out), trimFailureDetail(out), desktopDoctorCommand(rescueCtx), desktopLaunchRetryCommand(rescueCtx, command))
|
||||
if err := runSSHQuiet(ctx, target, desktopLaunchRemoteCommand(target, workdir, env, command, *browser && !*fullscreen)); err != nil {
|
||||
return exit(5, "launch desktop command: %v", err)
|
||||
}
|
||||
if expectBrowserLaunch && target.TargetOS == targetLinux {
|
||||
if out, err := runSSHCombinedOutput(ctx, target, desktopBrowserLaunchCheckCommand()); err != nil {
|
||||
printRescue(a.Stdout, rescueBrowserNotLaunched, trimFailureDetail(out), desktopDoctorCommand(rescueCtx), desktopLaunchRetryCommand(rescueCtx, command))
|
||||
return exit(5, "browser not launched for %s: %v", leaseID, err)
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(a.Stdout, "launched: %s\n", strings.Join(command, " "))
|
||||
if *webvnc {
|
||||
return a.webvnc(ctx, desktopLaunchWebVNCArgs(cfg, target, leaseID, *openPortal))
|
||||
@ -138,9 +105,6 @@ 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)
|
||||
@ -217,45 +181,6 @@ func posixWindowBrowserCommand() string {
|
||||
`
|
||||
}
|
||||
|
||||
func desktopBrowserLaunchCheckCommand() string {
|
||||
return `set +e
|
||||
export DISPLAY="${DISPLAY:-:99}"
|
||||
sleep 5
|
||||
if command -v xdotool >/dev/null 2>&1; then
|
||||
window="$(xdotool search --onlyvisible --class google-chrome 2>/dev/null | tail -1 || true)"
|
||||
[ -n "$window" ] || window="$(xdotool search --onlyvisible --class chromium 2>/dev/null | tail -1 || true)"
|
||||
if [ -n "$window" ]; then
|
||||
exit 0
|
||||
fi
|
||||
echo "browser window not visible on DISPLAY=$DISPLAY" >&2
|
||||
fi
|
||||
if command -v pgrep >/dev/null 2>&1 && {
|
||||
pgrep -x google-chrome >/dev/null 2>&1 ||
|
||||
pgrep -x chrome >/dev/null 2>&1 ||
|
||||
pgrep -x chromium >/dev/null 2>&1 ||
|
||||
pgrep -x chromium-browser >/dev/null 2>&1
|
||||
}; then
|
||||
exit 0
|
||||
fi
|
||||
echo "browser process not found" >&2
|
||||
exit 1`
|
||||
}
|
||||
|
||||
func desktopCommandLooksLikeBrowser(command []string, browserEnv string) bool {
|
||||
if len(command) == 0 {
|
||||
return false
|
||||
}
|
||||
first := strings.TrimSpace(command[0])
|
||||
if first == "" {
|
||||
return false
|
||||
}
|
||||
if strings.TrimSpace(browserEnv) != "" && first == strings.TrimSpace(browserEnv) {
|
||||
return true
|
||||
}
|
||||
lower := strings.ToLower(filepath.Base(first))
|
||||
return strings.Contains(lower, "chrome") || strings.Contains(lower, "chromium")
|
||||
}
|
||||
|
||||
func writeShellArgv(b *bytes.Buffer, command []string) {
|
||||
for i, arg := range command {
|
||||
if i > 0 {
|
||||
|
||||
@ -1,356 +0,0 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
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 != "" {
|
||||
rescueCtx := rescueContext{Cfg: cfg, Target: target, LeaseID: leaseID}
|
||||
status, err := coord.WebVNCStatus(ctx, leaseID)
|
||||
if err != nil {
|
||||
fmt.Fprintf(a.Stdout, "portal failed webvnc %v\n", err)
|
||||
printRescue(a.Stdout, rescueVNCBridgeDisconnected, err.Error(), webVNCStatusRescueCommand(rescueCtx), webVNCResetRescueCommand(rescueCtx))
|
||||
} else {
|
||||
fmt.Fprintf(a.Stdout, "portal ok webvnc bridge=%t viewers=%d observers=%d slots=%d\n", status.BridgeConnected, status.ViewerCount, status.ObserverCount, status.AvailableViewerSlots)
|
||||
if !status.BridgeConnected {
|
||||
printRescue(a.Stdout, rescueVNCBridgeNotRunning, "portal has no active WebVNC bridge for this lease", webVNCDaemonStartRescueCommand(rescueCtx), webVNCResetRescueCommand(rescueCtx))
|
||||
} else if webVNCObserverSlotsExhausted(status) {
|
||||
printRescue(a.Stdout, rescueVNCObserverSlotsFull, "all WebVNC observer slots are in use or stale", webVNCDaemonStartRescueCommand(rescueCtx), webVNCResetRescueCommand(rescueCtx))
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a App) desktopClick(ctx context.Context, args []string) error {
|
||||
target, cfg, 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 out, err := runSSHCombinedOutput(ctx, target, desktopClickRemoteCommand(x, y)); err != nil {
|
||||
a.printDesktopInputRescue(classifyDesktopFailure(out), out, cfg, target, leaseID)
|
||||
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, cfg, 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
|
||||
}
|
||||
var stdout, stderr strings.Builder
|
||||
if err := runSSHInput(ctx, target, desktopPasteRemoteCommand(), strings.NewReader(text), &stdout, &stderr); err != nil {
|
||||
a.printDesktopInputRescue(classifyDesktopFailure(stderr.String()+"\n"+stdout.String()), stderr.String()+"\n"+stdout.String(), cfg, target, leaseID)
|
||||
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, cfg, 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) {
|
||||
var stdout, stderr strings.Builder
|
||||
if err := runSSHInput(ctx, target, desktopPasteRemoteCommand(), strings.NewReader(text), &stdout, &stderr); err != nil {
|
||||
a.printDesktopInputRescue(classifyDesktopFailure(stderr.String()+"\n"+stdout.String()), stderr.String()+"\n"+stdout.String(), cfg, target, leaseID)
|
||||
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 out, err := runSSHCombinedOutput(ctx, target, desktopTypeRemoteCommand(text)); err != nil {
|
||||
a.printDesktopInputRescue(classifyDesktopFailure(out), out, cfg, target, leaseID)
|
||||
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, cfg, 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 out, err := runSSHCombinedOutput(ctx, target, desktopKeyRemoteCommand(keys)); err != nil {
|
||||
a.printDesktopInputRescue(classifyDesktopFailure(out), out, cfg, target, leaseID)
|
||||
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) printDesktopInputRescue(problem, output string, cfg Config, target SSHTarget, leaseID string) {
|
||||
ctx := rescueContext{Cfg: cfg, Target: target, LeaseID: leaseID}
|
||||
printRescue(a.Stdout, problem, trimFailureDetail(output), desktopDoctorCommand(ctx))
|
||||
}
|
||||
|
||||
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 name == "artifacts video" {
|
||||
fs.String("output", "", "local MP4 output path")
|
||||
fs.Duration("duration", 10*time.Second, "video capture duration")
|
||||
fs.Float64("fps", 15, "video frames per second")
|
||||
}
|
||||
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) {
|
||||
prefixes := []string{"--" + name + "=", "-" + name + "="}
|
||||
names := map[string]bool{"--" + name: true, "-" + name: true}
|
||||
for i, arg := range args {
|
||||
for _, prefix := range prefixes {
|
||||
if strings.HasPrefix(arg, prefix) {
|
||||
return strings.TrimPrefix(arg, prefix), true
|
||||
}
|
||||
}
|
||||
if names[arg] && 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 floatFlagValue(args []string, name string, fallback float64) float64 {
|
||||
value, ok := stringFlagValue(args, name)
|
||||
if !ok {
|
||||
return fallback
|
||||
}
|
||||
n, err := strconv.ParseFloat(value, 64)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func durationFlagValue(args []string, name string, fallback time.Duration) time.Duration {
|
||||
value, ok := stringFlagValue(args, name)
|
||||
if !ok {
|
||||
return fallback
|
||||
}
|
||||
duration, err := time.ParseDuration(value)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return duration
|
||||
}
|
||||
|
||||
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`
|
||||
}
|
||||
@ -30,109 +30,9 @@ 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 TestStringFlagValueAcceptsGoFlagForms(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
want string
|
||||
}{
|
||||
{name: "double dash space", args: []string{"--output", "screen.mp4"}, want: "screen.mp4"},
|
||||
{name: "double dash equals", args: []string{"--output=screen.mp4"}, want: "screen.mp4"},
|
||||
{name: "single dash space", args: []string{"-output", "screen.mp4"}, want: "screen.mp4"},
|
||||
{name: "single dash equals", args: []string{"-output=screen.mp4"}, want: "screen.mp4"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, ok := stringFlagValue(tt.args, "output")
|
||||
if !ok {
|
||||
t.Fatal("missing flag")
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Fatalf("value=%q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDesktopLaunchWebVNCArgsCarriesTargetDetails(t *testing.T) {
|
||||
got := desktopLaunchWebVNCArgs(
|
||||
Config{Provider: "aws", TargetOS: targetWindows, WindowsMode: windowsModeWSL2, Network: NetworkTailscale},
|
||||
Config{Provider: "aws", TargetOS: targetWindows, WindowsMode: windowsModeWSL2},
|
||||
SSHTarget{TargetOS: targetWindows, WindowsMode: windowsModeWSL2},
|
||||
"cbx_1",
|
||||
true,
|
||||
@ -141,7 +41,6 @@ func TestDesktopLaunchWebVNCArgsCarriesTargetDetails(t *testing.T) {
|
||||
for _, want := range []string{
|
||||
"--provider aws",
|
||||
"--target windows",
|
||||
"--network tailscale",
|
||||
"--windows-mode wsl2",
|
||||
"--id cbx_1",
|
||||
"--open",
|
||||
@ -152,48 +51,6 @@ func TestDesktopLaunchWebVNCArgsCarriesTargetDetails(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDesktopLaunchRemoteCommandCanPassEgressProxyToBrowser(t *testing.T) {
|
||||
got := desktopLaunchRemoteCommand(
|
||||
SSHTarget{TargetOS: targetLinux},
|
||||
"/work/crabbox/cbx_1/repo",
|
||||
map[string]string{"DISPLAY": ":99", "BROWSER": "/usr/bin/chromium"},
|
||||
[]string{"/usr/bin/chromium", "--proxy-server=http://127.0.0.1:3128", "https://discord.com/login"},
|
||||
true,
|
||||
)
|
||||
if !strings.Contains(got, "'/usr/bin/chromium' '--proxy-server=http://127.0.0.1:3128' 'https://discord.com/login'") {
|
||||
t.Fatalf("desktop launch command missing egress proxy arg:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDesktopCommandLooksLikeBrowser(t *testing.T) {
|
||||
if !desktopCommandLooksLikeBrowser([]string{"/usr/bin/google-chrome"}, "") {
|
||||
t.Fatal("google-chrome should be treated as browser")
|
||||
}
|
||||
if !desktopCommandLooksLikeBrowser([]string{"/opt/crabbox-browser"}, "/opt/crabbox-browser") {
|
||||
t.Fatal("BROWSER env wrapper should be treated as browser")
|
||||
}
|
||||
if desktopCommandLooksLikeBrowser([]string{"xterm"}, "/opt/crabbox-browser") {
|
||||
t.Fatal("xterm should not be treated as browser")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDesktopBrowserLaunchCheckAvoidsSelfMatchingShell(t *testing.T) {
|
||||
got := desktopBrowserLaunchCheckCommand()
|
||||
if strings.Contains(got, "pgrep -f") {
|
||||
t.Fatalf("launch check must not match its own shell text:\n%s", got)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"pgrep -x google-chrome",
|
||||
"pgrep -x chrome",
|
||||
"pgrep -x chromium",
|
||||
"pgrep -x chromium-browser",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("launch check missing process-name probe %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWindowsDesktopLaunchRemoteCommandUsesInteractiveTask(t *testing.T) {
|
||||
got := desktopLaunchRemoteCommand(
|
||||
SSHTarget{TargetOS: targetWindows, WindowsMode: windowsModeNormal},
|
||||
|
||||
@ -9,7 +9,7 @@ import (
|
||||
|
||||
func (a App) doctor(ctx context.Context, args []string) error {
|
||||
fs := newFlagSet("doctor", a.Stderr)
|
||||
provider := fs.String("provider", defaultConfig().Provider, "provider: hetzner, aws, azure, or ssh")
|
||||
provider := fs.String("provider", defaultConfig().Provider, "provider: hetzner, aws, or ssh")
|
||||
id := fs.String("id", "", "remote lease id to inspect")
|
||||
targetFlags := registerTargetFlags(fs, defaultConfig())
|
||||
if err := parseFlags(fs, args); err != nil {
|
||||
@ -138,20 +138,6 @@ func (a App) doctor(ctx context.Context, args []string) error {
|
||||
} else {
|
||||
fmt.Fprintf(a.Stdout, "ok aws crabbox_servers=%d region=%s default_type=%s\n", len(servers), cfg.AWSRegion, cfg.ServerType)
|
||||
}
|
||||
case "azure":
|
||||
client, err := NewAzureClient(ctx, cfg)
|
||||
if err != nil {
|
||||
fmt.Fprintf(a.Stdout, "failed azure %v\n", err)
|
||||
ok = false
|
||||
break
|
||||
}
|
||||
servers, err := client.ListCrabboxServers(ctx)
|
||||
if err != nil {
|
||||
fmt.Fprintf(a.Stdout, "failed azure %v\n", err)
|
||||
ok = false
|
||||
} else {
|
||||
fmt.Fprintf(a.Stdout, "ok azure crabbox_servers=%d location=%s default_type=%s\n", len(servers), cfg.AzureLocation, cfg.ServerType)
|
||||
}
|
||||
default:
|
||||
client, err := newHetznerClient()
|
||||
if err != nil {
|
||||
|
||||
@ -1,970 +0,0 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"nhooyr.io/websocket"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultEgressListen = "127.0.0.1:3128"
|
||||
egressRemoteBinary = "/tmp/crabbox-egress-client"
|
||||
egressRemoteLog = "/tmp/crabbox-egress-client.log"
|
||||
egressMaxMessageBytes = 2 * 1024 * 1024
|
||||
egressCopyChunkBytes = 32 * 1024
|
||||
egressOpenTimeout = 20 * time.Second
|
||||
egressDialTimeout = 15 * time.Second
|
||||
egressRemoteReadyWait = 5 * time.Second
|
||||
egressDaemonRestartWait = 1 * time.Second
|
||||
)
|
||||
|
||||
type egressProxyMessage struct {
|
||||
Type string `json:"type"`
|
||||
ID string `json:"id,omitempty"`
|
||||
Host string `json:"host,omitempty"`
|
||||
Port string `json:"port,omitempty"`
|
||||
Body string `json:"body,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type egressOpenResult struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func (a App) egress(ctx context.Context, args []string) error {
|
||||
if len(args) == 0 || isHelpArg(args[0]) {
|
||||
a.printEgressHelp()
|
||||
if len(args) == 0 {
|
||||
return exit(2, "missing egress subcommand")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
switch args[0] {
|
||||
case "host":
|
||||
return a.egressHost(ctx, args[1:])
|
||||
case "client":
|
||||
return a.egressClient(ctx, args[1:])
|
||||
case "start":
|
||||
return a.egressStart(ctx, args[1:])
|
||||
case "status":
|
||||
return a.egressStatus(ctx, args[1:])
|
||||
case "stop":
|
||||
return a.egressStop(ctx, args[1:])
|
||||
default:
|
||||
a.printEgressHelp()
|
||||
return exit(2, "unknown egress subcommand %q", args[0])
|
||||
}
|
||||
}
|
||||
|
||||
func (a App) printEgressHelp() {
|
||||
fmt.Fprintln(a.Stdout, `Usage:
|
||||
crabbox egress start --id <lease-id-or-slug> --profile discord [--daemon]
|
||||
crabbox egress host --id <lease-id-or-slug> --profile discord
|
||||
crabbox egress client --id <lease-id-or-slug> --listen 127.0.0.1:3128
|
||||
crabbox egress status --id <lease-id-or-slug>
|
||||
crabbox egress stop --id <lease-id-or-slug>
|
||||
|
||||
Mediated egress lets a lease-local browser/app proxy exit through the machine
|
||||
running the egress host agent. The coordinator only mediates paired WebSocket
|
||||
bridges; the host agent opens the real outbound TCP connections.`)
|
||||
}
|
||||
|
||||
func (a App) egressHost(ctx context.Context, args []string) error {
|
||||
defaults := defaultConfig()
|
||||
fs := newFlagSet("egress host", a.Stderr)
|
||||
provider := fs.String("provider", defaults.Provider, "provider: hetzner or aws")
|
||||
id := fs.String("id", "", "lease id or slug")
|
||||
coordinatorURL := fs.String("coordinator", "", "coordinator URL override")
|
||||
ticket := fs.String("ticket", "", "pre-created egress host ticket")
|
||||
sessionID := fs.String("session", "", "egress session id")
|
||||
profile := fs.String("profile", "", "egress profile name")
|
||||
allowCSV := fs.String("allow", "", "comma-separated allowed host patterns")
|
||||
if err := parseFlags(fs, args); err != nil {
|
||||
return err
|
||||
}
|
||||
setIDFromFirstArg(fs, id)
|
||||
allow := egressAllowlist(*profile, splitCSV(*allowCSV))
|
||||
if *id == "" {
|
||||
return exit(2, "usage: crabbox egress host --id <lease-id-or-slug> --profile <name>|--allow <hosts>")
|
||||
}
|
||||
if len(allow) == 0 {
|
||||
return exit(2, "egress host requires --profile or --allow; refusing to start an open proxy")
|
||||
}
|
||||
coord, leaseID, err := a.egressCoordinatorAndLease(ctx, *provider, *coordinatorURL, *id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
bridge, err := connectEgressBridge(ctx, coord, leaseID, "host", *ticket, *sessionID, *profile, allow)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(a.Stdout, "egress host: connected lease=%s session=%s profile=%s allow=%s\n", leaseID, bridge.sessionID, blank(*profile, "-"), strings.Join(allow, ","))
|
||||
return bridge.serveHost(ctx, allow)
|
||||
}
|
||||
|
||||
func (a App) egressClient(ctx context.Context, args []string) error {
|
||||
defaults := defaultConfig()
|
||||
fs := newFlagSet("egress client", a.Stderr)
|
||||
provider := fs.String("provider", defaults.Provider, "provider: hetzner or aws")
|
||||
id := fs.String("id", "", "lease id or slug")
|
||||
coordinatorURL := fs.String("coordinator", "", "coordinator URL override")
|
||||
ticket := fs.String("ticket", "", "pre-created egress client ticket")
|
||||
sessionID := fs.String("session", "", "egress session id")
|
||||
listen := fs.String("listen", defaultEgressListen, "lease-local proxy listen address")
|
||||
if err := parseFlags(fs, args); err != nil {
|
||||
return err
|
||||
}
|
||||
setIDFromFirstArg(fs, id)
|
||||
if *id == "" {
|
||||
return exit(2, "usage: crabbox egress client --id <lease-id-or-slug> [--listen 127.0.0.1:3128]")
|
||||
}
|
||||
if err := validateEgressListen(*listen); err != nil {
|
||||
return err
|
||||
}
|
||||
coord, leaseID, err := a.egressCoordinatorAndLease(ctx, *provider, *coordinatorURL, *id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
bridge, err := connectEgressBridge(ctx, coord, leaseID, "client", *ticket, *sessionID, "", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(a.Stdout, "egress client: connected lease=%s session=%s listen=%s\n", leaseID, bridge.sessionID, *listen)
|
||||
return bridge.serveClient(ctx, *listen)
|
||||
}
|
||||
|
||||
func (a App) egressStart(ctx context.Context, args []string) error {
|
||||
defaults := defaultConfig()
|
||||
fs := newFlagSet("egress start", a.Stderr)
|
||||
provider := fs.String("provider", defaults.Provider, "provider: hetzner or aws")
|
||||
id := fs.String("id", "", "lease id or slug")
|
||||
profile := fs.String("profile", "", "egress profile name")
|
||||
allowCSV := fs.String("allow", "", "comma-separated allowed host patterns")
|
||||
listen := fs.String("listen", defaultEgressListen, "lease-local proxy listen address")
|
||||
coordinatorURL := fs.String("coordinator", "", "coordinator URL override")
|
||||
daemon := fs.Bool("daemon", false, "start the local host bridge in the background")
|
||||
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 egress start --id <lease-id-or-slug> --profile <name>|--allow <hosts>")
|
||||
}
|
||||
allow := egressAllowlist(*profile, splitCSV(*allowCSV))
|
||||
if len(allow) == 0 {
|
||||
return exit(2, "egress start requires --profile or --allow; refusing to start an open proxy")
|
||||
}
|
||||
if err := validateEgressListen(*listen); err != nil {
|
||||
return err
|
||||
}
|
||||
cfg, err := loadLeaseTargetConfig(fs, *provider, targetFlags, networkFlags, leaseTargetConfigOptions{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg, err = egressStartCoordinatorConfig(cfg, *coordinatorURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
coord, useCoordinator, err := newTargetCoordinatorClient(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !useCoordinator || coord == nil || coord.Token == "" {
|
||||
return exit(2, "egress start 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
|
||||
}
|
||||
sessionID := newLocalEgressSessionID()
|
||||
if err := installRemoteEgressClient(ctx, target); err != nil {
|
||||
return err
|
||||
}
|
||||
clientTicket, err := coord.CreateEgressTicket(ctx, leaseID, "client", sessionID, *profile, allow)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
remote := remoteEgressClientCommand(coord.BaseURL, leaseID, clientTicket.Ticket, sessionID, *listen)
|
||||
if err := runSSHQuiet(ctx, target, remote); err != nil {
|
||||
return exit(5, "start remote egress client: %v", err)
|
||||
}
|
||||
if err := waitRemoteEgressClient(ctx, target, *listen); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(a.Stdout, "egress client: lease=%s listen=%s log=%s\n", leaseID, *listen, egressRemoteLog)
|
||||
hostArgs := []string{
|
||||
"host",
|
||||
"--provider", cfg.Provider,
|
||||
"--id", leaseID,
|
||||
"--coordinator", coord.BaseURL,
|
||||
"--session", sessionID,
|
||||
}
|
||||
if strings.TrimSpace(*profile) != "" {
|
||||
hostArgs = append(hostArgs, "--profile", strings.TrimSpace(*profile))
|
||||
}
|
||||
if len(allow) > 0 {
|
||||
hostArgs = append(hostArgs, "--allow", strings.Join(allow, ","))
|
||||
}
|
||||
if *daemon {
|
||||
return a.startEgressHostDaemon(leaseID, hostArgs)
|
||||
}
|
||||
hostTicket, err := coord.CreateEgressTicket(ctx, leaseID, "host", sessionID, *profile, allow)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
hostArgs = append(hostArgs, "--ticket", hostTicket.Ticket)
|
||||
return a.egressHost(ctx, hostArgs)
|
||||
}
|
||||
|
||||
func (a App) egressStatus(ctx context.Context, args []string) error {
|
||||
defaults := defaultConfig()
|
||||
fs := newFlagSet("egress status", a.Stderr)
|
||||
provider := fs.String("provider", defaults.Provider, "provider: hetzner or aws")
|
||||
id := fs.String("id", "", "lease id or slug")
|
||||
coordinatorURL := fs.String("coordinator", "", "coordinator URL override")
|
||||
if err := parseFlags(fs, args); err != nil {
|
||||
return err
|
||||
}
|
||||
setIDFromFirstArg(fs, id)
|
||||
if *id == "" {
|
||||
return exit(2, "usage: crabbox egress status --id <lease-id-or-slug>")
|
||||
}
|
||||
coord, leaseID, err := a.egressCoordinatorAndLease(ctx, *provider, *coordinatorURL, *id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
status, err := coord.EgressStatus(ctx, leaseID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(a.Stdout, "egress: lease=%s session=%s profile=%s host=%t client=%t allow=%s\n", status.LeaseID, blank(status.SessionID, "-"), blank(status.Profile, "-"), status.HostConnected, status.ClientConnected, strings.Join(status.Allow, ","))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a App) egressStop(ctx context.Context, args []string) error {
|
||||
defaults := defaultConfig()
|
||||
fs := newFlagSet("egress stop", a.Stderr)
|
||||
provider := fs.String("provider", defaults.Provider, "provider: hetzner or aws")
|
||||
id := fs.String("id", "", "lease id or slug")
|
||||
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 egress stop --id <lease-id-or-slug>")
|
||||
}
|
||||
stoppedLocal, err := a.stopEgressHostDaemon(*id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg, cfgErr := loadLeaseTargetConfig(fs, *provider, targetFlags, networkFlags, leaseTargetConfigOptions{})
|
||||
if cfgErr == nil {
|
||||
if _, target, leaseID, resolveErr := a.resolveNetworkLeaseTarget(ctx, cfg, *id, false); resolveErr == nil {
|
||||
_ = runSSHQuiet(ctx, target, "pkill -f '[c]rabbox-egress-client egress client' >/dev/null 2>&1 || true")
|
||||
if leaseID != *id && !stoppedLocal {
|
||||
stoppedLocal, _ = a.stopEgressHostDaemon(leaseID)
|
||||
}
|
||||
fmt.Fprintf(a.Stdout, "egress remote client: stopped lease=%s\n", leaseID)
|
||||
}
|
||||
}
|
||||
if !stoppedLocal {
|
||||
fmt.Fprintf(a.Stdout, "egress host daemon: no local daemon for %s\n", *id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a App) egressCoordinatorAndLease(ctx context.Context, provider, coordinatorURL, id string) (*CoordinatorClient, string, error) {
|
||||
cfg, err := loadConfig()
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
cfg.Provider = provider
|
||||
if strings.TrimSpace(coordinatorURL) != "" {
|
||||
cfg.Coordinator = strings.TrimRight(strings.TrimSpace(coordinatorURL), "/")
|
||||
cfg.CoordToken = firstNonBlank(cfg.CoordToken, "ticket-only")
|
||||
}
|
||||
coord, useCoordinator, err := newTargetCoordinatorClient(cfg)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if !useCoordinator || coord == nil || coord.BaseURL == "" {
|
||||
return nil, "", exit(2, "egress requires a configured coordinator")
|
||||
}
|
||||
if strings.TrimSpace(coordinatorURL) != "" && coord.Token == "ticket-only" {
|
||||
return coord, id, nil
|
||||
}
|
||||
if coord.Token == "" {
|
||||
return nil, "", exit(2, "egress requires a configured coordinator login; run crabbox login first")
|
||||
}
|
||||
lease, err := coord.GetLease(ctx, id)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return coord, lease.ID, nil
|
||||
}
|
||||
|
||||
type egressBridge struct {
|
||||
ws *websocket.Conn
|
||||
sessionID string
|
||||
writeMu sync.Mutex
|
||||
mu sync.Mutex
|
||||
conns map[string]net.Conn
|
||||
pending map[string]chan egressOpenResult
|
||||
}
|
||||
|
||||
func connectEgressBridge(ctx context.Context, coord *CoordinatorClient, leaseID, role, ticket, sessionID, profile string, allow []string) (*egressBridge, error) {
|
||||
if strings.TrimSpace(ticket) == "" {
|
||||
resolvedSessionID, err := reusableEgressSessionID(ctx, coord, leaseID, sessionID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sessionID = resolvedSessionID
|
||||
created, err := coord.CreateEgressTicket(ctx, leaseID, role, sessionID, profile, allow)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ticket = created.Ticket
|
||||
sessionID = created.SessionID
|
||||
} else if strings.TrimSpace(sessionID) == "" {
|
||||
sessionID = "egress_manual"
|
||||
}
|
||||
ws, _, err := websocket.Dial(ctx, egressAgentURL(coord.BaseURL, leaseID, role, ticket), &websocket.DialOptions{
|
||||
HTTPHeader: coord.webVNCAccessHeaders(),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ws.SetReadLimit(egressMaxMessageBytes)
|
||||
return &egressBridge{
|
||||
ws: ws,
|
||||
sessionID: sessionID,
|
||||
conns: map[string]net.Conn{},
|
||||
pending: map[string]chan egressOpenResult{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func reusableEgressSessionID(ctx context.Context, coord *CoordinatorClient, leaseID, sessionID string) (string, error) {
|
||||
if strings.TrimSpace(sessionID) != "" {
|
||||
return strings.TrimSpace(sessionID), nil
|
||||
}
|
||||
status, err := coord.EgressStatus(ctx, leaseID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSpace(status.SessionID), nil
|
||||
}
|
||||
|
||||
func (b *egressBridge) serveHost(ctx context.Context, allow []string) error {
|
||||
defer b.close()
|
||||
for {
|
||||
var msg egressProxyMessage
|
||||
if err := b.readMessage(ctx, &msg); err != nil {
|
||||
return err
|
||||
}
|
||||
switch msg.Type {
|
||||
case "open":
|
||||
go b.hostOpen(ctx, msg, allow)
|
||||
case "data":
|
||||
b.writeConn(msg)
|
||||
case "close":
|
||||
b.closeConn(msg.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (b *egressBridge) hostOpen(ctx context.Context, msg egressProxyMessage, allow []string) {
|
||||
if !egressHostAllowed(msg.Host, allow) {
|
||||
_ = b.writeJSON(ctx, egressProxyMessage{Type: "error", ID: msg.ID, Error: "host not allowed"})
|
||||
return
|
||||
}
|
||||
conn, err := (&net.Dialer{Timeout: egressDialTimeout}).DialContext(ctx, "tcp", net.JoinHostPort(msg.Host, msg.Port))
|
||||
if err != nil {
|
||||
_ = b.writeJSON(ctx, egressProxyMessage{Type: "error", ID: msg.ID, Error: err.Error()})
|
||||
return
|
||||
}
|
||||
b.mu.Lock()
|
||||
b.conns[msg.ID] = conn
|
||||
b.mu.Unlock()
|
||||
if err := b.writeJSON(ctx, egressProxyMessage{Type: "open_ok", ID: msg.ID}); err != nil {
|
||||
_ = conn.Close()
|
||||
return
|
||||
}
|
||||
go b.copyConnToBridge(ctx, msg.ID, conn)
|
||||
}
|
||||
|
||||
func (b *egressBridge) serveClient(ctx context.Context, listen string) error {
|
||||
defer b.close()
|
||||
if err := validateEgressListen(listen); err != nil {
|
||||
return err
|
||||
}
|
||||
ln, err := net.Listen("tcp", listen)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer ln.Close()
|
||||
errc := make(chan error, 2)
|
||||
go func() { errc <- b.clientReadLoop(ctx) }()
|
||||
go func() {
|
||||
for {
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
errc <- err
|
||||
return
|
||||
}
|
||||
go b.handleProxyConn(ctx, conn)
|
||||
}
|
||||
}()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return context.Cause(ctx)
|
||||
case err := <-errc:
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
func (b *egressBridge) clientReadLoop(ctx context.Context) error {
|
||||
for {
|
||||
var msg egressProxyMessage
|
||||
if err := b.readMessage(ctx, &msg); err != nil {
|
||||
return err
|
||||
}
|
||||
switch msg.Type {
|
||||
case "open_ok":
|
||||
b.finishOpen(msg.ID, nil)
|
||||
case "error":
|
||||
b.finishOpen(msg.ID, errors.New(msg.Error))
|
||||
b.closeConn(msg.ID)
|
||||
case "data":
|
||||
b.writeConn(msg)
|
||||
case "close":
|
||||
b.closeConn(msg.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (b *egressBridge) handleProxyConn(ctx context.Context, conn net.Conn) {
|
||||
defer conn.Close()
|
||||
reader := bufio.NewReader(conn)
|
||||
req, err := http.ReadRequest(reader)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
host, port, err := egressRequestHostPort(req)
|
||||
if err != nil {
|
||||
_, _ = io.WriteString(conn, "HTTP/1.1 400 Bad Request\r\n\r\n")
|
||||
return
|
||||
}
|
||||
id := newLocalEgressConnID()
|
||||
if err := b.openRemote(ctx, id, host, port); err != nil {
|
||||
_, _ = io.WriteString(conn, "HTTP/1.1 502 Bad Gateway\r\n\r\n")
|
||||
return
|
||||
}
|
||||
b.mu.Lock()
|
||||
b.conns[id] = conn
|
||||
b.mu.Unlock()
|
||||
defer b.closeConn(id)
|
||||
if req.Method == http.MethodConnect {
|
||||
_, _ = io.WriteString(conn, "HTTP/1.1 200 Connection Established\r\nProxy-Agent: crabbox\r\n\r\n")
|
||||
} else {
|
||||
var buf bytes.Buffer
|
||||
req.RequestURI = ""
|
||||
req.URL.Scheme = ""
|
||||
req.URL.Host = ""
|
||||
if err := req.Write(&buf); err != nil {
|
||||
return
|
||||
}
|
||||
if err := b.writeJSON(ctx, egressProxyMessage{Type: "data", ID: id, Body: base64.StdEncoding.EncodeToString(buf.Bytes())}); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
if reader.Buffered() > 0 {
|
||||
buffered, _ := reader.Peek(reader.Buffered())
|
||||
if len(buffered) > 0 {
|
||||
_ = b.writeJSON(ctx, egressProxyMessage{Type: "data", ID: id, Body: base64.StdEncoding.EncodeToString(buffered)})
|
||||
}
|
||||
}
|
||||
b.copyConnToBridge(ctx, id, conn)
|
||||
}
|
||||
|
||||
func (b *egressBridge) openRemote(ctx context.Context, id, host, port string) error {
|
||||
ch := make(chan egressOpenResult, 1)
|
||||
b.mu.Lock()
|
||||
b.pending[id] = ch
|
||||
b.mu.Unlock()
|
||||
if err := b.writeJSON(ctx, egressProxyMessage{Type: "open", ID: id, Host: host, Port: port}); err != nil {
|
||||
return err
|
||||
}
|
||||
timer := time.NewTimer(egressOpenTimeout)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case result := <-ch:
|
||||
return result.err
|
||||
case <-timer.C:
|
||||
return errors.New("egress open timed out")
|
||||
case <-ctx.Done():
|
||||
return context.Cause(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
func (b *egressBridge) finishOpen(id string, err error) {
|
||||
b.mu.Lock()
|
||||
ch := b.pending[id]
|
||||
delete(b.pending, id)
|
||||
b.mu.Unlock()
|
||||
if ch != nil {
|
||||
ch <- egressOpenResult{err: err}
|
||||
}
|
||||
}
|
||||
|
||||
func (b *egressBridge) copyConnToBridge(ctx context.Context, id string, conn net.Conn) {
|
||||
buf := make([]byte, egressCopyChunkBytes)
|
||||
for {
|
||||
n, err := conn.Read(buf)
|
||||
if n > 0 {
|
||||
if writeErr := b.writeJSON(ctx, egressProxyMessage{
|
||||
Type: "data",
|
||||
ID: id,
|
||||
Body: base64.StdEncoding.EncodeToString(buf[:n]),
|
||||
}); writeErr != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
_ = b.writeJSON(ctx, egressProxyMessage{Type: "close", ID: id})
|
||||
b.closeConn(id)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (b *egressBridge) writeConn(msg egressProxyMessage) {
|
||||
data, err := base64.StdEncoding.DecodeString(msg.Body)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
b.mu.Lock()
|
||||
conn := b.conns[msg.ID]
|
||||
b.mu.Unlock()
|
||||
if conn != nil {
|
||||
_, _ = conn.Write(data)
|
||||
}
|
||||
}
|
||||
|
||||
func (b *egressBridge) closeConn(id string) {
|
||||
b.mu.Lock()
|
||||
conn := b.conns[id]
|
||||
delete(b.conns, id)
|
||||
delete(b.pending, id)
|
||||
b.mu.Unlock()
|
||||
if conn != nil {
|
||||
_ = conn.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func (b *egressBridge) readMessage(ctx context.Context, msg *egressProxyMessage) error {
|
||||
_, data, err := b.ws.Read(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return json.Unmarshal(data, msg)
|
||||
}
|
||||
|
||||
func (b *egressBridge) writeJSON(ctx context.Context, msg egressProxyMessage) error {
|
||||
data, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
b.writeMu.Lock()
|
||||
defer b.writeMu.Unlock()
|
||||
return b.ws.Write(ctx, websocket.MessageText, data)
|
||||
}
|
||||
|
||||
func (b *egressBridge) close() {
|
||||
if b == nil {
|
||||
return
|
||||
}
|
||||
_ = b.ws.Close(websocket.StatusNormalClosure, "egress stopped")
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
for id, conn := range b.conns {
|
||||
_ = conn.Close()
|
||||
delete(b.conns, id)
|
||||
}
|
||||
for id, ch := range b.pending {
|
||||
ch <- egressOpenResult{err: errors.New("egress bridge stopped")}
|
||||
delete(b.pending, id)
|
||||
}
|
||||
}
|
||||
|
||||
func egressRequestHostPort(req *http.Request) (string, string, error) {
|
||||
hostport := req.Host
|
||||
if req.URL != nil && req.URL.Host != "" {
|
||||
hostport = req.URL.Host
|
||||
}
|
||||
if hostport == "" {
|
||||
return "", "", errors.New("missing host")
|
||||
}
|
||||
host, port, err := net.SplitHostPort(hostport)
|
||||
if err == nil {
|
||||
return strings.ToLower(strings.Trim(host, "[]")), port, nil
|
||||
}
|
||||
host = strings.ToLower(strings.Trim(hostport, "[]"))
|
||||
if req.Method == http.MethodConnect {
|
||||
return "", "", fmt.Errorf("CONNECT target must include port: %s", hostport)
|
||||
}
|
||||
if req.URL != nil && req.URL.Scheme == "https" {
|
||||
return host, "443", nil
|
||||
}
|
||||
return host, "80", nil
|
||||
}
|
||||
|
||||
func egressAllowlist(profile string, explicit []string) []string {
|
||||
out := sanitizeEgressAllowlist(explicit)
|
||||
switch strings.ToLower(strings.TrimSpace(profile)) {
|
||||
case "discord":
|
||||
out = append(out, "discord.com", "*.discord.com", "discordcdn.com", "*.discordcdn.com", "hcaptcha.com", "*.hcaptcha.com")
|
||||
case "slack":
|
||||
out = append(out, "slack.com", "*.slack.com", "slack-edge.com", "*.slack-edge.com")
|
||||
}
|
||||
return sanitizeEgressAllowlist(out)
|
||||
}
|
||||
|
||||
func sanitizeEgressAllowlist(values []string) []string {
|
||||
seen := map[string]bool{}
|
||||
var out []string
|
||||
for _, value := range values {
|
||||
normalized := strings.ToLower(strings.TrimSpace(value))
|
||||
if normalized == "" || normalized == "*" || seen[normalized] {
|
||||
continue
|
||||
}
|
||||
seen[normalized] = true
|
||||
out = append(out, normalized)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func egressHostAllowed(host string, allow []string) bool {
|
||||
host = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(host)), ".")
|
||||
if host == "" {
|
||||
return false
|
||||
}
|
||||
for _, pattern := range allow {
|
||||
pattern = strings.TrimSuffix(strings.ToLower(strings.TrimSpace(pattern)), ".")
|
||||
switch {
|
||||
case strings.HasPrefix(pattern, "*."):
|
||||
suffix := strings.TrimPrefix(pattern, "*.")
|
||||
if host == suffix || strings.HasSuffix(host, "."+suffix) {
|
||||
return true
|
||||
}
|
||||
case host == pattern:
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func validateEgressListen(listen string) error {
|
||||
host, port, err := net.SplitHostPort(strings.TrimSpace(listen))
|
||||
if err != nil || strings.TrimSpace(port) == "" {
|
||||
return exit(2, "invalid egress listen address %q; use 127.0.0.1:<port>", listen)
|
||||
}
|
||||
host = strings.Trim(strings.ToLower(strings.TrimSpace(host)), "[]")
|
||||
if host == "localhost" {
|
||||
return nil
|
||||
}
|
||||
ip := net.ParseIP(host)
|
||||
if ip == nil || !ip.IsLoopback() {
|
||||
return exit(2, "egress listen address must be loopback-only; use 127.0.0.1:<port>")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func splitCSV(value string) []string {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return nil
|
||||
}
|
||||
parts := strings.Split(value, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
if normalized := strings.TrimSpace(part); normalized != "" {
|
||||
out = append(out, normalized)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func egressCoordinatorNeedsAccess(access AccessConfig) bool {
|
||||
return strings.TrimSpace(access.ClientID) != "" ||
|
||||
strings.TrimSpace(access.ClientSecret) != "" ||
|
||||
strings.TrimSpace(access.Token) != ""
|
||||
}
|
||||
|
||||
func egressStartCoordinatorConfig(cfg Config, coordinatorURL string) (Config, error) {
|
||||
if override := strings.TrimSpace(coordinatorURL); override != "" {
|
||||
cfg.Coordinator = strings.TrimRight(override, "/")
|
||||
cfg.Access = AccessConfig{}
|
||||
return cfg, nil
|
||||
}
|
||||
if egressCoordinatorNeedsAccess(cfg.Access) {
|
||||
return cfg, exit(2, "egress start cannot install a remote client when coordinator Access credentials are configured; use --coordinator with a public coordinator route or run egress client manually with safe credentials")
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func egressAgentURL(base, leaseID, role, ticket string) string {
|
||||
u, err := url.Parse(base)
|
||||
if err != nil {
|
||||
return base
|
||||
}
|
||||
if u.Scheme == "https" {
|
||||
u.Scheme = "wss"
|
||||
} else {
|
||||
u.Scheme = "ws"
|
||||
}
|
||||
u.Path = strings.TrimRight(u.Path, "/") + "/v1/leases/" + url.PathEscape(leaseID) + "/egress/" + role
|
||||
values := url.Values{}
|
||||
values.Set("ticket", ticket)
|
||||
u.RawQuery = values.Encode()
|
||||
u.Fragment = ""
|
||||
return u.String()
|
||||
}
|
||||
|
||||
func newLocalEgressSessionID() string {
|
||||
return "egress_" + strconv.FormatInt(time.Now().UnixNano(), 36)
|
||||
}
|
||||
|
||||
func newLocalEgressConnID() string {
|
||||
return "conn_" + strconv.FormatInt(time.Now().UnixNano(), 36)
|
||||
}
|
||||
|
||||
func installRemoteEgressClient(ctx context.Context, target SSHTarget) error {
|
||||
exe, cleanup, err := egressClientBinaryForTarget(ctx, target)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cleanup()
|
||||
args := append(scpBaseArgs(target), exe, target.User+"@"+target.Host+":"+egressRemoteBinary)
|
||||
cmd := exec.CommandContext(ctx, "scp", args...)
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
return exit(5, "copy egress client: %v: %s", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return runSSHQuiet(ctx, target, "chmod 700 "+shellQuote(egressRemoteBinary))
|
||||
}
|
||||
|
||||
func egressClientBinaryForTarget(ctx context.Context, target SSHTarget) (string, func(), error) {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return "", func() {}, exit(2, "resolve crabbox executable: %v", err)
|
||||
}
|
||||
if target.TargetOS != "" && target.TargetOS != targetLinux {
|
||||
return "", func() {}, exit(2, "egress start only supports Linux lease targets; target=%s is not supported", target.TargetOS)
|
||||
}
|
||||
if runtime.GOOS == "linux" {
|
||||
return exe, func() {}, nil
|
||||
}
|
||||
repo, err := findRepo()
|
||||
if err != nil {
|
||||
return "", func() {}, exit(2, "cross-build egress client: %v", err)
|
||||
}
|
||||
out := filepath.Join(os.TempDir(), "crabbox-egress-client-linux-amd64-"+strconv.FormatInt(time.Now().UnixNano(), 36))
|
||||
cmd := exec.CommandContext(ctx, "go", "build", "-trimpath", "-o", out, "./cmd/crabbox")
|
||||
cmd.Dir = repo.Root
|
||||
cmd.Env = append(os.Environ(), "GOOS=linux", "GOARCH=amd64", "CGO_ENABLED=0")
|
||||
if data, err := cmd.CombinedOutput(); err != nil {
|
||||
return "", func() {}, exit(5, "cross-build linux egress client: %v: %s", err, strings.TrimSpace(string(data)))
|
||||
}
|
||||
return out, func() { _ = os.Remove(out) }, nil
|
||||
}
|
||||
|
||||
func scpBaseArgs(target SSHTarget) []string {
|
||||
args := []string{
|
||||
"-o", "BatchMode=yes",
|
||||
"-o", "StrictHostKeyChecking=accept-new",
|
||||
"-o", "UserKnownHostsFile=" + sshConfigFileValue(knownHostsFile(target)),
|
||||
"-o", "ConnectTimeout=10",
|
||||
"-o", "ConnectionAttempts=3",
|
||||
"-P", target.Port,
|
||||
}
|
||||
if target.Key != "" {
|
||||
args = append([]string{"-i", target.Key, "-o", "IdentitiesOnly=yes"}, args...)
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
func remoteEgressClientCommand(coordinatorURL, leaseID, ticket, sessionID, listen string) string {
|
||||
args := []string{
|
||||
egressRemoteBinary,
|
||||
"egress",
|
||||
"client",
|
||||
"--coordinator", coordinatorURL,
|
||||
"--id", leaseID,
|
||||
"--ticket", ticket,
|
||||
"--session", sessionID,
|
||||
"--listen", listen,
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString("pkill -f '[c]rabbox-egress-client egress client' >/dev/null 2>&1 || true\n")
|
||||
b.WriteString("nohup ")
|
||||
for i, arg := range args {
|
||||
if i > 0 {
|
||||
b.WriteByte(' ')
|
||||
}
|
||||
b.WriteString(shellQuote(arg))
|
||||
}
|
||||
b.WriteString(" >" + shellQuote(egressRemoteLog) + " 2>&1 < /dev/null &\n")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func waitRemoteEgressClient(ctx context.Context, target SSHTarget, listen string) error {
|
||||
host, port, err := net.SplitHostPort(listen)
|
||||
if err != nil {
|
||||
return exit(2, "invalid egress listen address %q", listen)
|
||||
}
|
||||
deadline := time.Now().Add(egressRemoteReadyWait)
|
||||
for time.Now().Before(deadline) {
|
||||
if ctx.Err() != nil {
|
||||
return context.Cause(ctx)
|
||||
}
|
||||
if runSSHQuiet(ctx, target, egressRemoteProbeCommand(host, port)) == nil {
|
||||
return nil
|
||||
}
|
||||
time.Sleep(250 * time.Millisecond)
|
||||
}
|
||||
return exit(5, "remote egress client did not listen on %s; inspect %s", listen, egressRemoteLog)
|
||||
}
|
||||
|
||||
func egressRemoteProbeCommand(host, port string) string {
|
||||
return "if command -v nc >/dev/null 2>&1; then nc -z " + shellQuote(host) + " " + shellQuote(port) + " >/dev/null 2>&1; else timeout 1 bash -lc " + shellQuote("</dev/tcp/"+host+"/"+port) + " >/dev/null 2>&1; fi"
|
||||
}
|
||||
|
||||
func (a App) startEgressHostDaemon(leaseID string, args []string) error {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return exit(2, "resolve crabbox executable: %v", err)
|
||||
}
|
||||
if stopped, err := a.stopEgressHostDaemon(leaseID); err != nil {
|
||||
return err
|
||||
} else if stopped {
|
||||
fmt.Fprintln(a.Stdout, "egress host daemon: replacing previous daemon")
|
||||
}
|
||||
logPath, pidPath, err := egressDaemonPaths(leaseID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(logPath), 0o700); err != nil {
|
||||
return exit(2, "create egress daemon directory: %v", err)
|
||||
}
|
||||
logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
|
||||
if err != nil {
|
||||
return exit(2, "open egress daemon log: %v", err)
|
||||
}
|
||||
defer logFile.Close()
|
||||
childArgs := append([]string{"egress"}, args...)
|
||||
cmd := exec.Command("sh", "-c", egressDaemonSupervisorScript(exe, childArgs))
|
||||
cmd.Stdin = nil
|
||||
cmd.Stdout = logFile
|
||||
cmd.Stderr = logFile
|
||||
configureDaemonCommand(cmd)
|
||||
if err := cmd.Start(); err != nil {
|
||||
return exit(5, "start egress daemon: %v", err)
|
||||
}
|
||||
pid := cmd.Process.Pid
|
||||
if err := os.WriteFile(pidPath, []byte(fmt.Sprintf("%d\n", pid)), 0o600); err != nil {
|
||||
_ = cmd.Process.Kill()
|
||||
return exit(2, "write egress daemon pid: %v", err)
|
||||
}
|
||||
if err := cmd.Process.Release(); err != nil {
|
||||
return exit(5, "release egress daemon process: %v", err)
|
||||
}
|
||||
fmt.Fprintf(a.Stdout, "egress host daemon: pid=%d log=%s\n", pid, logPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a App) stopEgressHostDaemon(leaseID string) (bool, error) {
|
||||
_, pidPath, err := egressDaemonPaths(leaseID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
pid, err := readWebVNCDaemonPID(pidPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
command, alive := webVNCDaemonProcessCommand(pid)
|
||||
if !alive {
|
||||
_ = os.Remove(pidPath)
|
||||
fmt.Fprintf(a.Stdout, "egress host daemon: removed stale pid=%d\n", pid)
|
||||
return true, nil
|
||||
}
|
||||
if !isEgressDaemonCommand(command) {
|
||||
return false, exit(5, "refusing to stop pid %d; command does not look like crabbox egress: %s", pid, strings.TrimSpace(command))
|
||||
}
|
||||
process, err := os.FindProcess(pid)
|
||||
if err != nil {
|
||||
return false, exit(5, "find egress daemon pid %d: %v", pid, err)
|
||||
}
|
||||
if err := stopDaemonProcess(process, pid); err != nil {
|
||||
return false, exit(5, "stop egress daemon pid %d: %v", pid, err)
|
||||
}
|
||||
_ = os.Remove(pidPath)
|
||||
fmt.Fprintf(a.Stdout, "egress host daemon: stopped pid=%d\n", pid)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func isEgressDaemonCommand(command string) bool {
|
||||
command = strings.ToLower(command)
|
||||
return strings.Contains(command, "crabbox") && strings.Contains(command, "egress")
|
||||
}
|
||||
|
||||
func egressDaemonSupervisorScript(exe string, args []string) string {
|
||||
argv := make([]string, 0, len(args)+1)
|
||||
argv = append(argv, shellQuote(exe))
|
||||
for _, arg := range args {
|
||||
argv = append(argv, shellQuote(arg))
|
||||
}
|
||||
return "set -u\n" +
|
||||
"echo 'egress daemon supervisor: starting'\n" +
|
||||
"while :; do\n" +
|
||||
" " + strings.Join(argv, " ") + "\n" +
|
||||
" code=$?\n" +
|
||||
" echo \"egress daemon supervisor: child exited code=$code; restarting in 1s\"\n" +
|
||||
" sleep " + strconv.Itoa(int(egressDaemonRestartWait/time.Second)) + "\n" +
|
||||
"done\n"
|
||||
}
|
||||
|
||||
func egressDaemonPaths(leaseID string) (string, string, error) {
|
||||
dir, err := crabboxStateDir()
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
bridgeDir := filepath.Join(dir, "egress")
|
||||
name := safeWebVNCDaemonName(leaseID)
|
||||
return filepath.Join(bridgeDir, name+".log"), filepath.Join(bridgeDir, name+".pid"), nil
|
||||
}
|
||||
@ -1,183 +0,0 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEgressHostAllowedMatchesExactAndWildcards(t *testing.T) {
|
||||
allow := []string{"discord.com", "*.discordcdn.com"}
|
||||
for _, host := range []string{"discord.com", "cdn.discordcdn.com", "media.cdn.discordcdn.com"} {
|
||||
if !egressHostAllowed(host, allow) {
|
||||
t.Fatalf("expected %s to be allowed", host)
|
||||
}
|
||||
}
|
||||
for _, host := range []string{"example.com", "discord.com.evil.test"} {
|
||||
if egressHostAllowed(host, allow) {
|
||||
t.Fatalf("expected %s to be rejected", host)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEgressAllowlistRejectsBareWildcard(t *testing.T) {
|
||||
allow := egressAllowlist("", []string{"*"})
|
||||
if len(allow) != 0 {
|
||||
t.Fatalf("bare wildcard allowlist=%v, want empty", allow)
|
||||
}
|
||||
if egressHostAllowed("example.com", []string{"*"}) {
|
||||
t.Fatal("bare wildcard should not allow every host")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateEgressListenRequiresLoopback(t *testing.T) {
|
||||
for _, listen := range []string{"127.0.0.1:3128", "localhost:3128", "[::1]:3128"} {
|
||||
if err := validateEgressListen(listen); err != nil {
|
||||
t.Fatalf("expected %s to be valid: %v", listen, err)
|
||||
}
|
||||
}
|
||||
for _, listen := range []string{"0.0.0.0:3128", ":3128", "192.168.1.10:3128", "[::]:3128"} {
|
||||
if err := validateEgressListen(listen); err == nil {
|
||||
t.Fatalf("expected %s to be rejected", listen)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEgressCoordinatorNeedsAccess(t *testing.T) {
|
||||
if egressCoordinatorNeedsAccess(AccessConfig{}) {
|
||||
t.Fatal("empty access config should not block egress start")
|
||||
}
|
||||
for _, access := range []AccessConfig{
|
||||
{ClientID: "client"},
|
||||
{ClientSecret: "secret"},
|
||||
{Token: "jwt"},
|
||||
} {
|
||||
if !egressCoordinatorNeedsAccess(access) {
|
||||
t.Fatalf("access config should block egress start: %#v", access)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEgressStartCoordinatorOverrideUsesPublicRoute(t *testing.T) {
|
||||
cfg := Config{
|
||||
Coordinator: "https://crabbox-access.openclaw.ai",
|
||||
Access: AccessConfig{ClientID: "client", ClientSecret: "secret", Token: "jwt"},
|
||||
}
|
||||
got, err := egressStartCoordinatorConfig(cfg, "https://crabbox.openclaw.ai/")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Coordinator != "https://crabbox.openclaw.ai" {
|
||||
t.Fatalf("coordinator=%q", got.Coordinator)
|
||||
}
|
||||
if egressCoordinatorNeedsAccess(got.Access) {
|
||||
t.Fatalf("override should clear access headers for remote-safe start: %#v", got.Access)
|
||||
}
|
||||
if _, err := egressStartCoordinatorConfig(cfg, ""); err == nil {
|
||||
t.Fatal("expected access-protected coordinator without override to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEgressClientBinaryRejectsNonLinuxTargets(t *testing.T) {
|
||||
_, cleanup, err := egressClientBinaryForTarget(context.Background(), SSHTarget{TargetOS: targetWindows})
|
||||
defer cleanup()
|
||||
if err == nil {
|
||||
t.Fatal("expected non-Linux egress target to be rejected")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "only supports Linux lease targets") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManualEgressTicketCreationReusesActiveSession(t *testing.T) {
|
||||
var ticketBody map[string]any
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/v1/leases/cbx_abcdef123456/egress/status":
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"leaseID": "cbx_abcdef123456",
|
||||
"sessionID": "egress_shared123",
|
||||
})
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/v1/leases/cbx_abcdef123456/egress/ticket":
|
||||
if err := json.NewDecoder(r.Body).Decode(&ticketBody); err != nil {
|
||||
t.Fatalf("decode ticket body: %v", err)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"ticket": "egress_ticket",
|
||||
"leaseID": "cbx_abcdef123456",
|
||||
"role": "client",
|
||||
"sessionID": ticketBody["sessionID"],
|
||||
"expiresAt": "2026-05-07T00:00:00Z",
|
||||
})
|
||||
default:
|
||||
t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
coord := &CoordinatorClient{BaseURL: server.URL, Client: server.Client()}
|
||||
sessionID, err := reusableEgressSessionID(context.Background(), coord, "cbx_abcdef123456", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if sessionID != "egress_shared123" {
|
||||
t.Fatalf("sessionID=%q", sessionID)
|
||||
}
|
||||
if _, err := coord.CreateEgressTicket(context.Background(), "cbx_abcdef123456", "client", sessionID, "", nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ticketBody["sessionID"] != "egress_shared123" {
|
||||
t.Fatalf("ticket sessionID=%v", ticketBody["sessionID"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestEgressRequestHostPort(t *testing.T) {
|
||||
connect := &http.Request{Method: http.MethodConnect, Host: "discord.com:443"}
|
||||
host, port, err := egressRequestHostPort(connect)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if host != "discord.com" || port != "443" {
|
||||
t.Fatalf("CONNECT host/port=%s/%s", host, port)
|
||||
}
|
||||
|
||||
absolute := &http.Request{
|
||||
Method: http.MethodGet,
|
||||
Host: "proxy.local",
|
||||
URL: &url.URL{Scheme: "http", Host: "example.com", Path: "/"},
|
||||
}
|
||||
host, port, err = egressRequestHostPort(absolute)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if host != "example.com" || port != "80" {
|
||||
t.Fatalf("absolute URL host/port=%s/%s", host, port)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEgressAgentURL(t *testing.T) {
|
||||
got := egressAgentURL("https://crabbox.openclaw.ai", "cbx_abcdef123456", "host", "egress_abc")
|
||||
want := "wss://crabbox.openclaw.ai/v1/leases/cbx_abcdef123456/egress/host?ticket=egress_abc"
|
||||
if got != want {
|
||||
t.Fatalf("egressAgentURL=%q want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteEgressClientCommandRedactsThroughShellQuoting(t *testing.T) {
|
||||
got := remoteEgressClientCommand("https://crabbox.openclaw.ai", "cbx_abcdef123456", "egress_ticket", "egress_session", "127.0.0.1:3128")
|
||||
for _, want := range []string{
|
||||
"pkill -f '[c]rabbox-egress-client egress client'",
|
||||
"'/tmp/crabbox-egress-client' 'egress' 'client'",
|
||||
"'--coordinator' 'https://crabbox.openclaw.ai'",
|
||||
"'--ticket' 'egress_ticket'",
|
||||
">'/tmp/crabbox-egress-client.log' 2>&1",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("remote command missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -38,13 +38,7 @@ func TestFlagWasSet(t *testing.T) {
|
||||
if !flagWasSet(fs, "id") {
|
||||
t.Fatal("id should be marked set")
|
||||
}
|
||||
if !FlagWasSet(fs, "id") {
|
||||
t.Fatal("exported id check should be marked set")
|
||||
}
|
||||
if flagWasSet(fs, "json") {
|
||||
t.Fatal("json should not be marked set")
|
||||
}
|
||||
if FlagWasSet(fs, "json") {
|
||||
t.Fatal("exported json check should not be marked set")
|
||||
}
|
||||
}
|
||||
|
||||
@ -10,7 +10,7 @@ import (
|
||||
func (a App) inspect(ctx context.Context, args []string) error {
|
||||
defaults := defaultConfig()
|
||||
fs := newFlagSet("inspect", a.Stderr)
|
||||
provider := fs.String("provider", defaults.Provider, providerHelpSSH())
|
||||
provider := fs.String("provider", defaults.Provider, "provider: hetzner, aws, or ssh")
|
||||
id := fs.String("id", "", "lease id or slug")
|
||||
jsonOut := fs.Bool("json", false, "print JSON")
|
||||
targetFlags := registerTargetFlags(fs, defaults)
|
||||
|
||||
@ -57,7 +57,7 @@ func EnsureTestboxKey(leaseID string) (string, string, error) {
|
||||
}
|
||||
|
||||
func ensureTestboxKeyForConfig(cfg Config, leaseID string) (string, string, error) {
|
||||
if (cfg.Provider == "aws" || cfg.Provider == "azure") && cfg.TargetOS == targetWindows {
|
||||
if cfg.Provider == "aws" && cfg.TargetOS == targetWindows {
|
||||
return ensureTestboxKeyWithType(leaseID, "rsa")
|
||||
}
|
||||
return ensureTestboxKey(leaseID)
|
||||
|
||||
@ -129,9 +129,6 @@ func (a App) resolveNetworkLeaseTarget(ctx context.Context, cfg Config, id strin
|
||||
return Server{}, SSHTarget{}, "", err
|
||||
}
|
||||
target = resolved.Target
|
||||
if target.Host != "" {
|
||||
_ = probeSSHTransport(ctx, &target, 4*time.Second)
|
||||
}
|
||||
if printFallback && resolved.FallbackReason != "" {
|
||||
fmt.Fprintf(a.Stderr, "network fallback %s\n", resolved.FallbackReason)
|
||||
}
|
||||
|
||||
@ -269,16 +269,6 @@ func commandOutput(ctx context.Context, name string, args ...string) (string, er
|
||||
return out.String(), err
|
||||
}
|
||||
|
||||
func commandOutputWithEnv(ctx context.Context, env []string, name string, args ...string) (string, error) {
|
||||
cmd := exec.CommandContext(ctx, name, args...)
|
||||
cmd.Env = env
|
||||
var out bytes.Buffer
|
||||
cmd.Stdout = &out
|
||||
cmd.Stderr = &out
|
||||
err := cmd.Run()
|
||||
return out.String(), err
|
||||
}
|
||||
|
||||
func tailForError(text string) string {
|
||||
text = strings.TrimSpace(text)
|
||||
const limit = 4096
|
||||
|
||||
@ -39,16 +39,6 @@ func (a App) list(ctx context.Context, args []string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if *jsonOut {
|
||||
if jsonBackend, ok := backend.(JSONListBackend); ok {
|
||||
view, err := jsonBackend.ListJSON(ctx, ListRequest{Options: leaseOptionsFromConfig(cfg)})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
a.syncExternalRunnersBestEffort(ctx, cfg, backend)
|
||||
return json.NewEncoder(a.Stdout).Encode(view)
|
||||
}
|
||||
}
|
||||
var servers []Server
|
||||
switch b := backend.(type) {
|
||||
case SSHLeaseBackend:
|
||||
@ -63,6 +53,13 @@ func (a App) list(ctx context.Context, args []string) error {
|
||||
}
|
||||
a.syncExternalRunnersBestEffort(ctx, cfg, backend)
|
||||
if *jsonOut {
|
||||
if jsonBackend, ok := backend.(JSONListBackend); ok {
|
||||
view, err := jsonBackend.ListJSON(ctx, ListRequest{Options: leaseOptionsFromConfig(cfg)})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return json.NewEncoder(a.Stdout).Encode(view)
|
||||
}
|
||||
return json.NewEncoder(a.Stdout).Encode(servers)
|
||||
}
|
||||
renderServerList(a.Stdout, servers)
|
||||
@ -305,7 +302,7 @@ func coordinatorMachineOrphanField(labels map[string]string, activeLeaseIDs map[
|
||||
func (a App) cleanup(ctx context.Context, args []string) error {
|
||||
defaults := defaultConfig()
|
||||
fs := newFlagSet("machine cleanup", a.Stderr)
|
||||
provider := fs.String("provider", defaults.Provider, "provider: hetzner, aws, or azure")
|
||||
provider := fs.String("provider", defaults.Provider, "provider: hetzner or aws")
|
||||
dryRun := fs.Bool("dry-run", false, "only print")
|
||||
providerFlags := registerProviderFlags(fs, defaults)
|
||||
targetFlags := registerTargetFlags(fs, defaults)
|
||||
|
||||
@ -311,11 +311,11 @@ func normalizeProviderName(name string) string {
|
||||
}
|
||||
|
||||
func providerHelpAll() string {
|
||||
return "provider: hetzner, aws, azure, ssh, blacksmith-testbox, daytona, or islo"
|
||||
return "provider: hetzner, aws, ssh, blacksmith-testbox, daytona, or islo"
|
||||
}
|
||||
|
||||
func providerHelpSSH() string {
|
||||
return "provider: hetzner, aws, azure, ssh, or daytona"
|
||||
return "provider: hetzner, aws, ssh, or daytona"
|
||||
}
|
||||
|
||||
func isBlacksmithProvider(provider string) bool {
|
||||
|
||||
@ -132,11 +132,7 @@ func (b *coordinatorLeaseBackend) Status(ctx context.Context, req StatusRequest)
|
||||
func (b *coordinatorLeaseBackend) List(ctx context.Context, req ListRequest) ([]Server, error) {
|
||||
machines, activeLeaseIDs, err := b.listMachines(ctx)
|
||||
if err != nil {
|
||||
leases, fallbackErr := b.listLeasesFallback(ctx, err)
|
||||
if fallbackErr != nil {
|
||||
return nil, fallbackErr
|
||||
}
|
||||
return coordinatorLeasesToServers(leases, b.cfg), nil
|
||||
return nil, err
|
||||
}
|
||||
return coordinatorMachinesToServers(machines, activeLeaseIDs), nil
|
||||
}
|
||||
@ -145,7 +141,7 @@ func (b *coordinatorLeaseBackend) ListJSON(ctx context.Context, req ListRequest)
|
||||
_ = req
|
||||
machines, _, err := b.listMachines(ctx)
|
||||
if err != nil {
|
||||
return b.listLeasesFallback(ctx, err)
|
||||
return nil, err
|
||||
}
|
||||
return machines, nil
|
||||
}
|
||||
@ -172,51 +168,6 @@ func (b *coordinatorLeaseBackend) listMachines(ctx context.Context) ([]Coordinat
|
||||
return machines, activeCoordinatorLeaseIDs(activeLeases), nil
|
||||
}
|
||||
|
||||
func (b *coordinatorLeaseBackend) listLeasesFallback(ctx context.Context, adminErr error) ([]CoordinatorLease, error) {
|
||||
if b.cfg.CoordToken == "" {
|
||||
return nil, adminErr
|
||||
}
|
||||
if adminErr != nil && isCoordinatorUnauthorized(adminErr) {
|
||||
fmt.Fprintf(b.rt.Stderr, "warning: coordinator admin pool list unauthorized; falling back to user-visible leases\n")
|
||||
} else if adminErr != nil && b.cfg.CoordAdminToken == "" {
|
||||
fmt.Fprintf(b.rt.Stderr, "warning: coordinator admin pool list unavailable; falling back to user-visible leases\n")
|
||||
} else if adminErr != nil {
|
||||
return nil, adminErr
|
||||
}
|
||||
leases, err := b.coord.Leases(ctx, "active", 1000)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return filterCoordinatorLeasesForProvider(leases, b.cfg.Provider), nil
|
||||
}
|
||||
|
||||
func coordinatorLeasesToServers(leases []CoordinatorLease, cfg Config) []Server {
|
||||
servers := make([]Server, 0, len(leases))
|
||||
for _, lease := range leases {
|
||||
server, _, _ := leaseToServerTarget(lease, cfg)
|
||||
servers = append(servers, server)
|
||||
}
|
||||
return servers
|
||||
}
|
||||
|
||||
func filterCoordinatorLeasesForProvider(leases []CoordinatorLease, provider string) []CoordinatorLease {
|
||||
provider = strings.TrimSpace(provider)
|
||||
if provider == "" {
|
||||
return leases
|
||||
}
|
||||
out := make([]CoordinatorLease, 0, len(leases))
|
||||
for _, lease := range leases {
|
||||
if strings.EqualFold(strings.TrimSpace(lease.Provider), provider) {
|
||||
out = append(out, lease)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func isCoordinatorUnauthorized(err error) bool {
|
||||
return err != nil && strings.Contains(err.Error(), "http 401")
|
||||
}
|
||||
|
||||
func (b *coordinatorLeaseBackend) ReleaseLease(ctx context.Context, req ReleaseLeaseRequest) error {
|
||||
if req.Lease.LeaseID == "" {
|
||||
return exit(2, "missing coordinator lease id")
|
||||
|
||||
@ -1,115 +0,0 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCoordinatorListFallsBackToUserLeasesWhenAdminTokenUnauthorized(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/v1/pool":
|
||||
if got := r.Header.Get("Authorization"); got != "Bearer stale-admin-token" {
|
||||
t.Fatalf("pool auth=%q", got)
|
||||
}
|
||||
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
|
||||
case "/v1/leases":
|
||||
if got := r.URL.Query().Get("state"); got != "active" {
|
||||
t.Fatalf("leases state=%q", got)
|
||||
}
|
||||
if got := r.Header.Get("Authorization"); got != "Bearer user-token" {
|
||||
t.Fatalf("leases auth=%q", got)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"leases": []CoordinatorLease{
|
||||
{
|
||||
ID: "cbx_123",
|
||||
Slug: "blue-lobster",
|
||||
Provider: "aws",
|
||||
TargetOS: targetLinux,
|
||||
ServerID: 42,
|
||||
CloudID: "i-123",
|
||||
ServerName: "crabbox-blue-lobster",
|
||||
Host: "203.0.113.10",
|
||||
SSHUser: "crabbox",
|
||||
SSHPort: "2222",
|
||||
ServerType: "c7a.48xlarge",
|
||||
State: "active",
|
||||
Keep: true,
|
||||
ExpiresAt: "2026-05-07T15:00:00Z",
|
||||
IdleTimeoutSeconds: 1800,
|
||||
},
|
||||
{ID: "cbx_other", Provider: "hetzner", State: "active"},
|
||||
}})
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
var stderr bytes.Buffer
|
||||
cfg := Config{
|
||||
Provider: "aws",
|
||||
TargetOS: targetLinux,
|
||||
Coordinator: server.URL,
|
||||
CoordToken: "user-token",
|
||||
CoordAdminToken: "stale-admin-token",
|
||||
}
|
||||
coord, _, err := newCoordinatorClient(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
backend := &coordinatorLeaseBackend{cfg: cfg, coord: coord, rt: Runtime{Stderr: &stderr}}
|
||||
|
||||
servers, err := backend.List(context.Background(), ListRequest{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(servers) != 1 {
|
||||
t.Fatalf("servers=%d, want 1: %#v", len(servers), servers)
|
||||
}
|
||||
if servers[0].Labels["lease"] != "cbx_123" || servers[0].Labels["slug"] != "blue-lobster" {
|
||||
t.Fatalf("server labels=%#v", servers[0].Labels)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "falling back to user-visible leases") {
|
||||
t.Fatalf("missing fallback warning: %q", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoordinatorListJSONFallsBackWhenAdminTokenMissing(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v1/leases" {
|
||||
t.Fatalf("unexpected path %s", r.URL.Path)
|
||||
}
|
||||
if got := r.URL.Query().Get("state"); got != "active" {
|
||||
t.Fatalf("leases state=%q", got)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"leases": []CoordinatorLease{
|
||||
{ID: "cbx_123", Provider: "aws", State: "active"},
|
||||
}})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
cfg := Config{Provider: "aws", TargetOS: targetLinux, Coordinator: server.URL, CoordToken: "user-token"}
|
||||
coord, _, err := newCoordinatorClient(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
backend := &coordinatorLeaseBackend{cfg: cfg, coord: coord, rt: Runtime{Stderr: &bytes.Buffer{}}}
|
||||
|
||||
view, err := backend.ListJSON(context.Background(), ListRequest{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
leases, ok := view.([]CoordinatorLease)
|
||||
if !ok {
|
||||
t.Fatalf("view=%T, want []CoordinatorLease", view)
|
||||
}
|
||||
if len(leases) != 1 || leases[0].ID != "cbx_123" {
|
||||
t.Fatalf("leases=%#v", leases)
|
||||
}
|
||||
}
|
||||
@ -8,37 +8,12 @@ import (
|
||||
func init() {
|
||||
RegisterProvider(testHetznerProvider{})
|
||||
RegisterProvider(testAWSProvider{})
|
||||
RegisterProvider(testAzureProvider{})
|
||||
RegisterProvider(testStaticSSHProvider{})
|
||||
RegisterProvider(testBlacksmithProvider{})
|
||||
RegisterProvider(testDaytonaProvider{})
|
||||
RegisterProvider(testIsloProvider{})
|
||||
}
|
||||
|
||||
type testAzureProvider struct{}
|
||||
|
||||
func (testAzureProvider) Name() string { return "azure" }
|
||||
func (testAzureProvider) Aliases() []string { return nil }
|
||||
func (testAzureProvider) Spec() ProviderSpec {
|
||||
return ProviderSpec{
|
||||
Name: "azure",
|
||||
Kind: ProviderKindSSHLease,
|
||||
Targets: []TargetSpec{
|
||||
{OS: targetLinux},
|
||||
{OS: targetWindows, WindowsMode: windowsModeNormal},
|
||||
},
|
||||
Features: FeatureSet{FeatureSSH, FeatureCrabboxSync, FeatureCleanup, FeatureDesktop, FeatureBrowser, FeatureCode, FeatureTailscale},
|
||||
Coordinator: CoordinatorSupported,
|
||||
}
|
||||
}
|
||||
func (testAzureProvider) RegisterFlags(*flag.FlagSet, Config) any { return noProviderFlags{} }
|
||||
func (testAzureProvider) ApplyFlags(*Config, *flag.FlagSet, any) error {
|
||||
return nil
|
||||
}
|
||||
func (p testAzureProvider) Configure(cfg Config, rt Runtime) (Backend, error) {
|
||||
return testSSHBackend{spec: p.Spec()}, nil
|
||||
}
|
||||
|
||||
type testHetznerProvider struct{}
|
||||
|
||||
func (testHetznerProvider) Name() string { return "hetzner" }
|
||||
|
||||
@ -31,21 +31,6 @@ func touchDirectLeaseBestEffort(ctx context.Context, cfg Config, server Server,
|
||||
}
|
||||
return server
|
||||
}
|
||||
if cfg.Provider == "azure" || server.Provider == "azure" {
|
||||
client, err := NewAzureClient(ctx, cfg)
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "warning: direct touch state=%s: %v\n", state, err)
|
||||
return server
|
||||
}
|
||||
name := server.CloudID
|
||||
if name == "" {
|
||||
name = server.Name
|
||||
}
|
||||
if err := client.SetTags(ctx, name, server.Labels); err != nil {
|
||||
fmt.Fprintf(stderr, "warning: direct touch state=%s: %v\n", state, err)
|
||||
}
|
||||
return server
|
||||
}
|
||||
client, err := newHetznerClient()
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "warning: direct touch state=%s: %v\n", state, err)
|
||||
|
||||
@ -1,148 +0,0 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
rescueBrowserNotLaunched = "browser not launched"
|
||||
rescueClipboardUnavailable = "clipboard unavailable"
|
||||
rescueDesktopSessionMissing = "desktop session missing"
|
||||
rescueInputStackDead = "input stack dead"
|
||||
rescueVNCBridgeDisconnected = "VNC bridge disconnected"
|
||||
rescueVNCBridgeNotRunning = "WebVNC daemon not running"
|
||||
rescueVNCObserverSlotsFull = "WebVNC observer slots exhausted"
|
||||
rescueVNCStaleViewer = "WebVNC viewer already active"
|
||||
rescueVNCTargetUnreachable = "VNC target unreachable"
|
||||
rescueWindowManagerMissing = "window manager missing"
|
||||
rescueScreenshotCaptureBroken = "screenshot capture broken"
|
||||
rescueArtifactCaptureFailed = "artifact capture failed"
|
||||
)
|
||||
|
||||
type rescueContext struct {
|
||||
Cfg Config
|
||||
Target SSHTarget
|
||||
LeaseID string
|
||||
}
|
||||
|
||||
func printRescue(w io.Writer, problem, detail string, commands ...string) {
|
||||
fmt.Fprintf(w, "problem: %s\n", problem)
|
||||
if strings.TrimSpace(detail) != "" {
|
||||
fmt.Fprintf(w, "detail: %s\n", strings.TrimSpace(detail))
|
||||
}
|
||||
for _, command := range commands {
|
||||
if strings.TrimSpace(command) != "" {
|
||||
fmt.Fprintf(w, "rescue: %s\n", command)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func printRescueWithFallback(w io.Writer, problem, detail, fallback string, commands ...string) {
|
||||
printRescue(w, problem, detail, commands...)
|
||||
if strings.TrimSpace(fallback) != "" {
|
||||
fmt.Fprintf(w, "fallback: %s\n", fallback)
|
||||
}
|
||||
}
|
||||
|
||||
func desktopDoctorCommand(ctx rescueContext) string {
|
||||
return crabboxLeaseCommand(ctx, "desktop", "doctor")
|
||||
}
|
||||
|
||||
func webVNCStatusRescueCommand(ctx rescueContext) string {
|
||||
return crabboxLeaseCommand(ctx, "webvnc", "status")
|
||||
}
|
||||
|
||||
func webVNCResetRescueCommand(ctx rescueContext) string {
|
||||
args := crabboxLeaseCommandArgs(ctx, "webvnc", "reset")
|
||||
args = append(args, "--open")
|
||||
return strings.Join(readableShellWords(args), " ")
|
||||
}
|
||||
|
||||
func webVNCDaemonStartRescueCommand(ctx rescueContext) string {
|
||||
args := crabboxLeaseCommandArgs(ctx, "webvnc", "daemon", "start")
|
||||
args = append(args, "--open")
|
||||
return strings.Join(readableShellWords(args), " ")
|
||||
}
|
||||
|
||||
func desktopLaunchRetryCommand(ctx rescueContext, command []string) string {
|
||||
args := crabboxLeaseCommandArgs(ctx, "desktop", "launch")
|
||||
args = append(args, "--")
|
||||
args = append(args, command...)
|
||||
return strings.Join(readableShellWords(args), " ")
|
||||
}
|
||||
|
||||
func crabboxLeaseCommand(ctx rescueContext, command ...string) string {
|
||||
return strings.Join(readableShellWords(crabboxLeaseCommandArgs(ctx, command...)), " ")
|
||||
}
|
||||
|
||||
func crabboxLeaseCommandArgs(ctx rescueContext, command ...string) []string {
|
||||
targetOS := firstNonBlank(ctx.Target.TargetOS, ctx.Cfg.TargetOS)
|
||||
args := append([]string{"crabbox"}, command...)
|
||||
if strings.TrimSpace(ctx.Cfg.Provider) != "" {
|
||||
args = append(args, "--provider", strings.TrimSpace(ctx.Cfg.Provider))
|
||||
}
|
||||
if targetOS != "" {
|
||||
args = append(args, "--target", targetOS)
|
||||
}
|
||||
if strings.TrimSpace(ctx.Cfg.Provider) == staticProvider {
|
||||
if staticHost := firstNonBlank(ctx.Cfg.Static.Host, ctx.Target.Host); staticHost != "" {
|
||||
args = append(args, "--static-host", staticHost)
|
||||
}
|
||||
if staticUser := firstNonBlank(ctx.Cfg.Static.User, ctx.Target.User); staticUser != "" {
|
||||
args = append(args, "--static-user", staticUser)
|
||||
}
|
||||
if staticPort := firstNonBlank(ctx.Cfg.Static.Port, ctx.Target.Port); staticPort != "" {
|
||||
args = append(args, "--static-port", staticPort)
|
||||
}
|
||||
if strings.TrimSpace(ctx.Cfg.Static.WorkRoot) != "" {
|
||||
args = append(args, "--static-work-root", strings.TrimSpace(ctx.Cfg.Static.WorkRoot))
|
||||
}
|
||||
}
|
||||
if ctx.Cfg.Network != "" && ctx.Cfg.Network != NetworkAuto {
|
||||
args = append(args, "--network", string(ctx.Cfg.Network))
|
||||
}
|
||||
windowsMode := firstNonBlank(ctx.Target.WindowsMode, ctx.Cfg.WindowsMode)
|
||||
if targetOS == targetWindows && windowsMode != "" {
|
||||
args = append(args, "--windows-mode", windowsMode)
|
||||
}
|
||||
args = append(args, "--id", ctx.LeaseID)
|
||||
return args
|
||||
}
|
||||
|
||||
func classifyDesktopFailure(output string) string {
|
||||
text := strings.ToLower(output)
|
||||
switch {
|
||||
case strings.Contains(text, "missing xdotool"), strings.Contains(text, "xdotool: not found"):
|
||||
return rescueInputStackDead
|
||||
case strings.Contains(text, "missing clipboard tool"), strings.Contains(text, "xclip: not found"), strings.Contains(text, "xsel: not found"):
|
||||
return rescueClipboardUnavailable
|
||||
case strings.Contains(text, "browser window not visible"), strings.Contains(text, "browser process not found"):
|
||||
return rescueBrowserNotLaunched
|
||||
case strings.Contains(text, "can't open display"), strings.Contains(text, "unable to open display"), strings.Contains(text, "display"):
|
||||
return rescueDesktopSessionMissing
|
||||
case strings.Contains(text, "xfwm4"), strings.Contains(text, "window manager"):
|
||||
return rescueWindowManagerMissing
|
||||
case strings.Contains(text, "scrot"), strings.Contains(text, "screenshot"):
|
||||
return rescueScreenshotCaptureBroken
|
||||
case strings.Contains(text, "browser=true requested"), strings.Contains(text, "no such file"), strings.Contains(text, "not found"):
|
||||
return rescueBrowserNotLaunched
|
||||
default:
|
||||
return rescueInputStackDead
|
||||
}
|
||||
}
|
||||
|
||||
func trimFailureDetail(output string) string {
|
||||
lines := strings.Split(strings.TrimSpace(output), "\n")
|
||||
for i := len(lines) - 1; i >= 0; i-- {
|
||||
line := strings.TrimSpace(lines[i])
|
||||
if line != "" {
|
||||
if len(line) > 240 {
|
||||
return line[:240] + "..."
|
||||
}
|
||||
return line
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@ -1,116 +0,0 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestClassifyDesktopFailure(t *testing.T) {
|
||||
cases := []struct {
|
||||
output string
|
||||
want string
|
||||
}{
|
||||
{output: "missing xdotool; warm a new --desktop lease", want: rescueInputStackDead},
|
||||
{output: "missing clipboard tool; install xclip or xsel", want: rescueClipboardUnavailable},
|
||||
{output: "browser process not found", want: rescueBrowserNotLaunched},
|
||||
{output: "Error: Can't open display: :99", want: rescueDesktopSessionMissing},
|
||||
{output: "capture failed screenshot repair=restart desktop services", want: rescueScreenshotCaptureBroken},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.output, func(t *testing.T) {
|
||||
if got := classifyDesktopFailure(tc.output); got != tc.want {
|
||||
t.Fatalf("problem=%q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRescueCommandsCarryLeaseRoutingFlags(t *testing.T) {
|
||||
ctx := rescueContext{
|
||||
Cfg: Config{
|
||||
Provider: "aws",
|
||||
TargetOS: targetWindows,
|
||||
Network: NetworkTailscale,
|
||||
WindowsMode: windowsModeWSL2,
|
||||
},
|
||||
Target: SSHTarget{TargetOS: targetWindows, WindowsMode: windowsModeWSL2},
|
||||
LeaseID: "cbx_1",
|
||||
}
|
||||
for name, got := range map[string]string{
|
||||
"doctor": desktopDoctorCommand(ctx),
|
||||
"status": webVNCStatusRescueCommand(ctx),
|
||||
"reset": webVNCResetRescueCommand(ctx),
|
||||
"daemon": webVNCDaemonStartRescueCommand(ctx),
|
||||
} {
|
||||
for _, want := range []string{"--provider aws", "--target windows", "--network tailscale", "--windows-mode wsl2", "--id cbx_1"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("%s command missing %q: %s", name, want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !strings.Contains(webVNCResetRescueCommand(ctx), "--open") {
|
||||
t.Fatalf("reset command should open portal: %s", webVNCResetRescueCommand(ctx))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRescueCommandsCarryStaticHostFlags(t *testing.T) {
|
||||
ctx := rescueContext{
|
||||
Cfg: Config{
|
||||
Provider: staticProvider,
|
||||
TargetOS: targetLinux,
|
||||
Static: StaticConfig{
|
||||
Host: "devbox.local",
|
||||
User: "qa",
|
||||
Port: "2222",
|
||||
WorkRoot: "/srv/crabbox",
|
||||
},
|
||||
},
|
||||
Target: SSHTarget{TargetOS: targetLinux},
|
||||
LeaseID: "static_devbox_local",
|
||||
}
|
||||
got := desktopDoctorCommand(ctx)
|
||||
for _, want := range []string{
|
||||
"--provider ssh",
|
||||
"--target linux",
|
||||
"--static-host devbox.local",
|
||||
"--static-user qa",
|
||||
"--static-port 2222",
|
||||
"--static-work-root /srv/crabbox",
|
||||
"--id static_devbox_local",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("static rescue command missing %q: %s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRescueCommandsCarryResolvedStaticTargetFallback(t *testing.T) {
|
||||
ctx := rescueContext{
|
||||
Cfg: Config{Provider: staticProvider, TargetOS: targetLinux},
|
||||
Target: SSHTarget{TargetOS: targetLinux, Host: "flagged.local", User: "runner", Port: "2022"},
|
||||
LeaseID: "static_flagged_local",
|
||||
}
|
||||
got := desktopDoctorCommand(ctx)
|
||||
for _, want := range []string{"--static-host flagged.local", "--static-user runner", "--static-port 2022"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("static rescue command missing resolved target %q: %s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintRescueWithFallback(t *testing.T) {
|
||||
var b bytes.Buffer
|
||||
printRescueWithFallback(&b, rescueVNCBridgeDisconnected, "dial tcp EOF", "crabbox vnc --id cbx_1 --open", "crabbox webvnc status --id cbx_1")
|
||||
got := b.String()
|
||||
for _, want := range []string{
|
||||
"problem: VNC bridge disconnected\n",
|
||||
"detail: dial tcp EOF\n",
|
||||
"rescue: crabbox webvnc status --id cbx_1\n",
|
||||
"fallback: crabbox vnc --id cbx_1 --open\n",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("rescue output missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1107,9 +1107,6 @@ func deleteServer(ctx context.Context, cfg Config, server Server) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if cfg.Provider == "azure" || server.Provider == "azure" {
|
||||
return deleteAzureServer(ctx, cfg, server)
|
||||
}
|
||||
client, err := newHetznerClient()
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@ -249,18 +249,6 @@ func TestApplyTargetFlagOverridesRefreshesDefaultWorkRoot(t *testing.T) {
|
||||
args: []string{"--windows-mode", "wsl2"},
|
||||
want: `/custom/root`,
|
||||
},
|
||||
{
|
||||
name: "linux to macos",
|
||||
cfg: Config{
|
||||
Provider: "aws",
|
||||
TargetOS: targetLinux,
|
||||
WindowsMode: windowsModeNormal,
|
||||
SSHUser: baseConfig().SSHUser,
|
||||
WorkRoot: defaultPOSIXWorkRoot,
|
||||
},
|
||||
args: []string{"--target", "macos"},
|
||||
want: defaultMacOSWorkRoot,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
|
||||
@ -14,7 +14,7 @@ import (
|
||||
func (a App) screenshot(ctx context.Context, args []string) error {
|
||||
defaults := defaultConfig()
|
||||
fs := newFlagSet("screenshot", a.Stderr)
|
||||
provider := fs.String("provider", defaults.Provider, providerHelpSSH())
|
||||
provider := fs.String("provider", defaults.Provider, "provider: hetzner, aws, or ssh")
|
||||
id := fs.String("id", "", "lease id or slug")
|
||||
output := fs.String("output", "", "local PNG output path")
|
||||
reclaim := fs.Bool("reclaim", false, "claim this lease for the current repo")
|
||||
@ -96,32 +96,18 @@ func captureDesktopScreenshot(ctx context.Context, target SSHTarget, outputPath
|
||||
|
||||
func runSSHToWriter(ctx context.Context, target SSHTarget, remote string, stdout io.Writer) error {
|
||||
remote = wrapRemoteForTarget(target, remote)
|
||||
var lastErr error
|
||||
var lastMessage string
|
||||
for _, port := range sshPortCandidates(target.Port, target.FallbackPorts) {
|
||||
probe := target
|
||||
probe.Port = port
|
||||
cmd := exec.CommandContext(ctx, "ssh", sshArgs(probe, remote)...)
|
||||
cmd.Stdout = stdout
|
||||
var stderr bytes.Buffer
|
||||
cmd.Stderr = &stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
lastErr = err
|
||||
lastMessage = strings.TrimSpace(stderr.String())
|
||||
if shouldRetrySSHPort(err) {
|
||||
continue
|
||||
}
|
||||
if lastMessage != "" {
|
||||
return fmt.Errorf("%w: %s", err, lastMessage)
|
||||
}
|
||||
return err
|
||||
cmd := exec.CommandContext(ctx, "ssh", sshArgs(target, remote)...)
|
||||
cmd.Stdout = stdout
|
||||
var stderr bytes.Buffer
|
||||
cmd.Stderr = &stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
message := strings.TrimSpace(stderr.String())
|
||||
if message != "" {
|
||||
return fmt.Errorf("%w: %s", err, message)
|
||||
}
|
||||
return nil
|
||||
return err
|
||||
}
|
||||
if lastMessage != "" {
|
||||
return fmt.Errorf("%w: %s", lastErr, lastMessage)
|
||||
}
|
||||
return lastErr
|
||||
return nil
|
||||
}
|
||||
|
||||
func screenshotRemoteCommand(target SSHTarget) string {
|
||||
|
||||
@ -1,160 +0,0 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func (a App) share(ctx context.Context, args []string) error {
|
||||
fs := newFlagSet("share", a.Stderr)
|
||||
id := fs.String("id", "", "lease id or slug")
|
||||
var users stringListFlag
|
||||
fs.Var(&users, "user", "user email to share with; repeatable")
|
||||
org := fs.Bool("org", false, "share with the lease org")
|
||||
role := fs.String("role", string(CoordinatorShareUse), "role: use or manage")
|
||||
list := fs.Bool("list", false, "list current sharing")
|
||||
jsonOut := fs.Bool("json", false, "print JSON")
|
||||
if err := parseFlags(fs, args); err != nil {
|
||||
return err
|
||||
}
|
||||
setIDFromFirstArg(fs, id)
|
||||
if *id == "" {
|
||||
return exit(2, "usage: crabbox share --id <lease-id-or-slug> [--user <email>|--org|--list]")
|
||||
}
|
||||
shareRole, err := parseCoordinatorShareRole(*role)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
coord, err := shareCoordinator()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
current, err := coord.LeaseShare(ctx, *id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if *list || (len(users) == 0 && !*org) {
|
||||
return printCoordinatorShare(a.Stdout, current, *jsonOut)
|
||||
}
|
||||
if current.Users == nil {
|
||||
current.Users = map[string]CoordinatorShareRole{}
|
||||
}
|
||||
for _, user := range users {
|
||||
normalized := normalizeShareEmail(user)
|
||||
if normalized == "" {
|
||||
return exit(2, "invalid empty --user")
|
||||
}
|
||||
current.Users[normalized] = shareRole
|
||||
}
|
||||
if *org {
|
||||
current.Org = shareRole
|
||||
}
|
||||
updated, err := coord.UpdateLeaseShare(ctx, *id, current)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return printCoordinatorShare(a.Stdout, updated, *jsonOut)
|
||||
}
|
||||
|
||||
func (a App) unshare(ctx context.Context, args []string) error {
|
||||
fs := newFlagSet("unshare", a.Stderr)
|
||||
id := fs.String("id", "", "lease id or slug")
|
||||
var users stringListFlag
|
||||
fs.Var(&users, "user", "user email to remove; repeatable")
|
||||
org := fs.Bool("org", false, "remove org sharing")
|
||||
all := fs.Bool("all", false, "remove all sharing")
|
||||
jsonOut := fs.Bool("json", false, "print JSON")
|
||||
if err := parseFlags(fs, args); err != nil {
|
||||
return err
|
||||
}
|
||||
setIDFromFirstArg(fs, id)
|
||||
if *id == "" {
|
||||
return exit(2, "usage: crabbox unshare --id <lease-id-or-slug> [--user <email>|--org|--all]")
|
||||
}
|
||||
if len(users) == 0 && !*org && !*all {
|
||||
return exit(2, "usage: crabbox unshare --id <lease-id-or-slug> [--user <email>|--org|--all]")
|
||||
}
|
||||
coord, err := shareCoordinator()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var updated CoordinatorShare
|
||||
if *all {
|
||||
updated, err = coord.DeleteLeaseShare(ctx, *id, "", false)
|
||||
} else {
|
||||
updated, err = coord.LeaseShare(ctx, *id)
|
||||
if err == nil {
|
||||
if updated.Users == nil {
|
||||
updated.Users = map[string]CoordinatorShareRole{}
|
||||
}
|
||||
for _, user := range users {
|
||||
delete(updated.Users, normalizeShareEmail(user))
|
||||
}
|
||||
if *org {
|
||||
updated.Org = ""
|
||||
}
|
||||
updated, err = coord.UpdateLeaseShare(ctx, *id, updated)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return printCoordinatorShare(a.Stdout, updated, *jsonOut)
|
||||
}
|
||||
|
||||
func shareCoordinator() (*CoordinatorClient, error) {
|
||||
cfg, err := loadConfig()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
coord, ok, err := newCoordinatorClient(cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !ok {
|
||||
return nil, exit(2, "share requires a configured coordinator")
|
||||
}
|
||||
return coord, nil
|
||||
}
|
||||
|
||||
func parseCoordinatorShareRole(value string) (CoordinatorShareRole, error) {
|
||||
switch CoordinatorShareRole(strings.TrimSpace(value)) {
|
||||
case CoordinatorShareUse:
|
||||
return CoordinatorShareUse, nil
|
||||
case CoordinatorShareManage:
|
||||
return CoordinatorShareManage, nil
|
||||
default:
|
||||
return "", exit(2, "share role must be use or manage")
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeShareEmail(value string) string {
|
||||
return strings.ToLower(strings.TrimSpace(value))
|
||||
}
|
||||
|
||||
func printCoordinatorShare(out interface{ Write([]byte) (int, error) }, share CoordinatorShare, jsonOut bool) error {
|
||||
if jsonOut {
|
||||
return json.NewEncoder(out).Encode(map[string]any{"share": share})
|
||||
}
|
||||
if share.Org != "" {
|
||||
fmt.Fprintf(out, "org=%s\n", share.Org)
|
||||
} else {
|
||||
fmt.Fprintln(out, "org=off")
|
||||
}
|
||||
users := make([]string, 0, len(share.Users))
|
||||
for user := range share.Users {
|
||||
users = append(users, user)
|
||||
}
|
||||
sort.Strings(users)
|
||||
if len(users) == 0 {
|
||||
fmt.Fprintln(out, "users=none")
|
||||
return nil
|
||||
}
|
||||
for _, user := range users {
|
||||
fmt.Fprintf(out, "user=%s role=%s\n", user, share.Users[user])
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@ -198,7 +198,7 @@ git --version | Out-Null
|
||||
tar --version | Out-Null
|
||||
if (-not (Test-Path -LiteralPath ` + psQuote(targetWindowsReadyRoot(target)) + `)) { throw "work root missing" }`)
|
||||
}
|
||||
return "test -x /usr/local/bin/crabbox-ready && /usr/local/bin/crabbox-ready >/tmp/crabbox-ready.log 2>&1"
|
||||
return "test -x /usr/local/bin/crabbox-ready && crabbox-ready >/tmp/crabbox-ready.log 2>&1"
|
||||
}
|
||||
|
||||
func targetWindowsReadyRoot(target SSHTarget) string {
|
||||
@ -233,65 +233,27 @@ func runSSHQuiet(ctx context.Context, target SSHTarget, remote string) error {
|
||||
|
||||
func runSSHQuietWithOptions(ctx context.Context, target SSHTarget, remote, connectTimeout, connectionAttempts string) error {
|
||||
remote = wrapRemoteForTarget(target, remote)
|
||||
var lastErr error
|
||||
for _, port := range sshPortCandidates(target.Port, target.FallbackPorts) {
|
||||
probe := target
|
||||
probe.Port = port
|
||||
cmd := exec.CommandContext(ctx, "ssh", sshArgsWithOptions(probe, remote, connectTimeout, connectionAttempts)...)
|
||||
cmd.Stdout = io.Discard
|
||||
cmd.Stderr = io.Discard
|
||||
err := cmd.Run()
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
lastErr = err
|
||||
if !shouldRetrySSHPort(err) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return lastErr
|
||||
cmd := exec.CommandContext(ctx, "ssh", sshArgsWithOptions(target, remote, connectTimeout, connectionAttempts)...)
|
||||
cmd.Stdout = io.Discard
|
||||
cmd.Stderr = io.Discard
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
func runSSHOutput(ctx context.Context, target SSHTarget, remote string) (string, error) {
|
||||
remote = wrapRemoteForTarget(target, remote)
|
||||
var lastOut []byte
|
||||
var lastErr error
|
||||
for _, port := range sshPortCandidates(target.Port, target.FallbackPorts) {
|
||||
probe := target
|
||||
probe.Port = port
|
||||
cmd := exec.CommandContext(ctx, "ssh", sshArgs(probe, remote)...)
|
||||
out, err := cmd.Output()
|
||||
if err == nil {
|
||||
return strings.TrimSpace(string(out)), nil
|
||||
}
|
||||
lastOut = out
|
||||
lastErr = err
|
||||
if !shouldRetrySSHPort(err) {
|
||||
return "", err
|
||||
}
|
||||
cmd := exec.CommandContext(ctx, "ssh", sshArgs(target, remote)...)
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSpace(string(lastOut)), lastErr
|
||||
return strings.TrimSpace(string(out)), nil
|
||||
}
|
||||
|
||||
func runSSHCombinedOutput(ctx context.Context, target SSHTarget, remote string) (string, error) {
|
||||
remote = wrapRemoteForTarget(target, remote)
|
||||
var lastOut []byte
|
||||
var lastErr error
|
||||
for _, port := range sshPortCandidates(target.Port, target.FallbackPorts) {
|
||||
probe := target
|
||||
probe.Port = port
|
||||
cmd := exec.CommandContext(ctx, "ssh", sshArgs(probe, remote)...)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err == nil {
|
||||
return strings.TrimSpace(string(out)), nil
|
||||
}
|
||||
lastOut = out
|
||||
lastErr = err
|
||||
if !shouldRetrySSHPort(err) {
|
||||
return strings.TrimSpace(string(out)), err
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(string(lastOut)), lastErr
|
||||
cmd := exec.CommandContext(ctx, "ssh", sshArgs(target, remote)...)
|
||||
out, err := cmd.CombinedOutput()
|
||||
return strings.TrimSpace(string(out)), err
|
||||
}
|
||||
|
||||
func runSSHInputQuiet(ctx context.Context, target SSHTarget, remote, input string) error {
|
||||
@ -300,31 +262,11 @@ func runSSHInputQuiet(ctx context.Context, target SSHTarget, remote, input strin
|
||||
|
||||
func runSSHInput(ctx context.Context, target SSHTarget, remote string, input io.Reader, stdout, stderr io.Writer) error {
|
||||
remote = wrapRemoteForTarget(target, remote)
|
||||
if input == nil {
|
||||
input = strings.NewReader("")
|
||||
}
|
||||
data, err := io.ReadAll(input)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var lastErr error
|
||||
for _, port := range sshPortCandidates(target.Port, target.FallbackPorts) {
|
||||
probe := target
|
||||
probe.Port = port
|
||||
cmd := exec.CommandContext(ctx, "ssh", sshArgs(probe, remote)...)
|
||||
cmd.Stdin = bytes.NewReader(data)
|
||||
cmd.Stdout = stdout
|
||||
cmd.Stderr = stderr
|
||||
err := cmd.Run()
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
lastErr = err
|
||||
if !shouldRetrySSHPort(err) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return lastErr
|
||||
cmd := exec.CommandContext(ctx, "ssh", sshArgs(target, remote)...)
|
||||
cmd.Stdin = input
|
||||
cmd.Stdout = stdout
|
||||
cmd.Stderr = stderr
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
func runSSHStream(ctx context.Context, target SSHTarget, remote string, stdout, stderr io.Writer) int {
|
||||
@ -349,10 +291,6 @@ func sshArgs(target SSHTarget, remote string) []string {
|
||||
return sshArgsWithOptions(target, remote, "10", "3")
|
||||
}
|
||||
|
||||
func shouldRetrySSHPort(err error) bool {
|
||||
return exitCode(err) == 255
|
||||
}
|
||||
|
||||
func sshArgsWithOptions(target SSHTarget, remote, connectTimeout, connectionAttempts string) []string {
|
||||
return append(sshBaseArgsWithOptions(target, connectTimeout, connectionAttempts),
|
||||
target.User+"@"+target.Host,
|
||||
|
||||
@ -270,15 +270,6 @@ func TestSSHArgsAuthSecretDisablesControlMaster(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldRetrySSHPortOnlyForTransportExit(t *testing.T) {
|
||||
if !shouldRetrySSHPort(exec.Command("sh", "-c", "exit 255").Run()) {
|
||||
t.Fatal("ssh transport exit 255 should retry fallback ports")
|
||||
}
|
||||
if shouldRetrySSHPort(exec.Command("sh", "-c", "exit 7").Run()) {
|
||||
t.Fatal("remote command failure should not retry fallback ports")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSHCommandLineRedactsSecretAuthUser(t *testing.T) {
|
||||
target := SSHTarget{
|
||||
User: "tok_live_secret",
|
||||
@ -306,13 +297,6 @@ func TestSSHTransportProbeDoesNotRequireCrabboxReady(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSHReadyCommandUsesAbsoluteCrabboxReadyPath(t *testing.T) {
|
||||
got := sshReadyCommand(SSHTarget{})
|
||||
if !strings.Contains(got, "/usr/local/bin/crabbox-ready >/tmp/crabbox-ready.log") {
|
||||
t.Fatalf("sshReadyCommand() should use absolute crabbox-ready path: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSHArgsQuoteKnownHostsPathWithSpaces(t *testing.T) {
|
||||
got := strings.Join(sshArgs(SSHTarget{
|
||||
User: "crabbox",
|
||||
|
||||
@ -14,7 +14,6 @@ const (
|
||||
windowsModeWSL2 = "wsl2"
|
||||
|
||||
defaultPOSIXWorkRoot = "/work/crabbox"
|
||||
defaultMacOSWorkRoot = "/Users/ec2-user/crabbox"
|
||||
defaultWindowsWorkRoot = `C:\crabbox`
|
||||
)
|
||||
|
||||
@ -27,6 +26,9 @@ const (
|
||||
func normalizeTargetConfig(cfg *Config) {
|
||||
cfg.TargetOS = normalizeTargetOS(cfg.TargetOS)
|
||||
cfg.WindowsMode = normalizeWindowsMode(cfg.WindowsMode)
|
||||
if isDefaultWorkRoot(cfg.WorkRoot) {
|
||||
cfg.WorkRoot = defaultWorkRootForTarget(cfg.TargetOS, cfg.WindowsMode)
|
||||
}
|
||||
if cfg.Provider == "aws" && cfg.TargetOS == targetMacOS && cfg.SSHUser == baseConfig().SSHUser {
|
||||
cfg.SSHUser = "ec2-user"
|
||||
}
|
||||
@ -36,9 +38,6 @@ func normalizeTargetConfig(cfg *Config) {
|
||||
if cfg.Static.User != "" && cfg.SSHUser == baseConfig().SSHUser {
|
||||
cfg.SSHUser = cfg.Static.User
|
||||
}
|
||||
if isDefaultWorkRoot(cfg.WorkRoot) {
|
||||
cfg.WorkRoot = defaultWorkRootForTarget(cfg.TargetOS, cfg.WindowsMode)
|
||||
}
|
||||
if cfg.Static.Port != "" && cfg.SSHPort == baseConfig().SSHPort {
|
||||
cfg.SSHPort = cfg.Static.Port
|
||||
}
|
||||
@ -49,7 +48,7 @@ func normalizeTargetConfig(cfg *Config) {
|
||||
|
||||
func isDefaultWorkRoot(value string) bool {
|
||||
switch value {
|
||||
case "", defaultPOSIXWorkRoot, defaultMacOSWorkRoot, defaultWindowsWorkRoot:
|
||||
case "", defaultPOSIXWorkRoot, defaultWindowsWorkRoot:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
@ -57,9 +56,6 @@ func isDefaultWorkRoot(value string) bool {
|
||||
}
|
||||
|
||||
func defaultWorkRootForTarget(targetOS, windowsMode string) string {
|
||||
if targetOS == targetMacOS {
|
||||
return defaultMacOSWorkRoot
|
||||
}
|
||||
if targetOS == targetWindows && windowsMode == windowsModeNormal {
|
||||
return defaultWindowsWorkRoot
|
||||
}
|
||||
@ -115,7 +111,7 @@ func validateProviderTarget(cfg Config) error {
|
||||
return err
|
||||
}
|
||||
if !providerSpecSupportsTarget(provider.Spec(), cfg.TargetOS, cfg.WindowsMode) {
|
||||
return exit(2, "%s", unsupportedManagedTargetMessageForConfig(provider.Name(), cfg))
|
||||
return exit(2, "%s", unsupportedManagedTargetMessage(provider.Name(), cfg.TargetOS))
|
||||
}
|
||||
if cfg.Provider == "aws" && cfg.TargetOS == targetMacOS {
|
||||
if cfg.AWSMacHostID == "" && cfg.Coordinator == "" {
|
||||
@ -143,20 +139,6 @@ func providerSpecSupportsTarget(spec ProviderSpec, targetOS, windowsMode string)
|
||||
}
|
||||
|
||||
func unsupportedManagedTargetMessage(provider, target string) string {
|
||||
return unsupportedManagedTargetMessageForConfig(provider, Config{TargetOS: target, WindowsMode: windowsModeNormal})
|
||||
}
|
||||
|
||||
func unsupportedManagedTargetMessageForConfig(provider string, cfg Config) string {
|
||||
target := cfg.TargetOS
|
||||
if provider == "azure" && target == targetWindows && cfg.WindowsMode == windowsModeWSL2 {
|
||||
return "provider=azure supports native Windows only; use provider=aws for managed Windows WSL2 or provider=ssh for existing Windows WSL2 hosts"
|
||||
}
|
||||
if provider == "azure" {
|
||||
if target == targetMacOS {
|
||||
return "provider=azure managed provisioning supports target=linux and native Windows only; use provider=aws with an EC2 Mac Dedicated Host or provider=ssh for existing macOS hosts"
|
||||
}
|
||||
return "provider=azure managed provisioning supports target=linux and native Windows only"
|
||||
}
|
||||
switch target {
|
||||
case targetWindows:
|
||||
return sprintf("provider=%s managed provisioning supports target=linux only; use provider=aws for managed Windows or provider=ssh for existing Windows hosts", provider)
|
||||
|
||||
@ -51,43 +51,6 @@ func TestValidateProviderTargetAllowsAWSNativeWindows(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateProviderTargetAllowsAzureNativeWindowsOnly(t *testing.T) {
|
||||
cfg := baseConfig()
|
||||
cfg.Provider = "azure"
|
||||
cfg.TargetOS = targetWindows
|
||||
cfg.WindowsMode = windowsModeNormal
|
||||
if err := validateProviderTarget(cfg); err != nil {
|
||||
t.Fatalf("native err=%v", err)
|
||||
}
|
||||
|
||||
cfg.WindowsMode = windowsModeWSL2
|
||||
err := validateProviderTarget(cfg)
|
||||
if err == nil || !strings.Contains(err.Error(), "native Windows only") {
|
||||
t.Fatalf("wsl2 err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRequestedCapabilitiesRejectsAzureWindowsDesktop(t *testing.T) {
|
||||
for name, mutate := range map[string]func(*Config){
|
||||
"desktop": func(cfg *Config) { cfg.Desktop = true },
|
||||
"browser": func(cfg *Config) { cfg.Browser = true },
|
||||
"code": func(cfg *Config) { cfg.Code = true },
|
||||
"tailscale": func(cfg *Config) { cfg.Tailscale.Enabled = true },
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
cfg := baseConfig()
|
||||
cfg.Provider = "azure"
|
||||
cfg.TargetOS = targetWindows
|
||||
cfg.WindowsMode = windowsModeNormal
|
||||
mutate(&cfg)
|
||||
err := validateRequestedCapabilities(cfg)
|
||||
if err == nil || !strings.Contains(err.Error(), "SSH, sync, and run") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateProviderTargetAllowsStaticNonLinux(t *testing.T) {
|
||||
for _, target := range []string{targetMacOS, targetWindows} {
|
||||
cfg := baseConfig()
|
||||
|
||||
@ -1,3 +1,3 @@
|
||||
package cli
|
||||
|
||||
var version = "0.7.0"
|
||||
var version = "0.6.2"
|
||||
|
||||
@ -12,7 +12,7 @@ import (
|
||||
func (a App) vnc(ctx context.Context, args []string) error {
|
||||
defaults := defaultConfig()
|
||||
fs := newFlagSet("vnc", a.Stderr)
|
||||
provider := fs.String("provider", defaults.Provider, providerHelpSSH())
|
||||
provider := fs.String("provider", defaults.Provider, "provider: hetzner, aws, or ssh")
|
||||
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")
|
||||
|
||||
@ -35,7 +35,7 @@ func TestVNCPasswordCommandSupportsManagedTargets(t *testing.T) {
|
||||
if !strings.Contains(windows, "EncodedCommand") {
|
||||
t.Fatalf("windows password command should be encoded PowerShell: %q", windows)
|
||||
}
|
||||
if got := vncPasswordCommand(SSHTarget{TargetOS: targetMacOS}); got != "sudo cat '/var/db/crabbox/vnc.password'" {
|
||||
if got := vncPasswordCommand(SSHTarget{TargetOS: targetMacOS}); got != "cat '/var/db/crabbox/vnc.password'" {
|
||||
t.Fatalf("mac password command=%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
@ -18,48 +18,17 @@ 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|azure")
|
||||
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, aws, or azure")
|
||||
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, "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")
|
||||
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")
|
||||
networkFlags := registerNetworkModeFlag(fs, defaults)
|
||||
targetFlags := registerTargetFlags(fs, defaults)
|
||||
if err := parseFlags(fs, args); err != nil {
|
||||
@ -76,14 +45,14 @@ func (a App) webvnc(ctx context.Context, args []string) error {
|
||||
return a.stopWebVNCDaemon(*id)
|
||||
}
|
||||
if *daemon || *background {
|
||||
return a.webVNCDaemonStart(ctx, stripLegacyWebVNCDaemonFlags(args))
|
||||
return a.startWebVNCDaemon(args, *id)
|
||||
}
|
||||
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 currently supports coordinator-backed hetzner/aws/azure desktop leases")
|
||||
return exit(2, "webvnc currently supports coordinator-backed hetzner/aws desktop leases")
|
||||
}
|
||||
coord, useCoordinator, err := newTargetCoordinatorClient(cfg)
|
||||
if err != nil {
|
||||
@ -135,18 +104,25 @@ func (a App) webvnc(ctx context.Context, args []string) error {
|
||||
}
|
||||
|
||||
portal := webVNCPortalURL(coord.BaseURL, leaseID, username, password)
|
||||
rescueCtx := rescueContext{Cfg: cfg, Target: target, LeaseID: leaseID}
|
||||
opened := false
|
||||
return serveWebVNCBridgePool(ctx, webVNCBridgePoolConfig{
|
||||
Coord: coord,
|
||||
LeaseID: leaseID,
|
||||
Host: connHost,
|
||||
Port: connPort,
|
||||
PoolSize: defaultWebVNCBridgePoolSize,
|
||||
RescueCtx: rescueCtx,
|
||||
NativeVNC: nativeVNCOpenCommand(cfg, target, leaseID),
|
||||
Log: a.Stdout,
|
||||
OnReady: func() error {
|
||||
connectedOnce := false
|
||||
attempt := 0
|
||||
for {
|
||||
bridge, err := connectWebVNCBridge(ctx, coord, leaseID, connHost, connPort)
|
||||
if err != nil {
|
||||
if !connectedOnce {
|
||||
return err
|
||||
}
|
||||
attempt++
|
||||
delay := webVNCReconnectDelay(attempt)
|
||||
fmt.Fprintf(a.Stdout, "bridge: reconnect failed: %v; retrying in %s\n", err, delay)
|
||||
if err := waitWebVNCReconnect(ctx, delay); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
connectedOnce = true
|
||||
if attempt == 0 {
|
||||
fmt.Fprintln(a.Stdout, "bridge: connected; keep this process running while using WebVNC")
|
||||
fmt.Fprintf(a.Stdout, "webvnc: %s\n", portal)
|
||||
if strings.TrimSpace(password) != "" {
|
||||
@ -155,419 +131,32 @@ func (a App) webvnc(ctx context.Context, args []string) error {
|
||||
fmt.Fprintf(a.Stdout, "username: %s\n", strings.TrimSpace(username))
|
||||
}
|
||||
}
|
||||
if *openPortal && !opened {
|
||||
if err := openLocalURL(portal); err != nil {
|
||||
return err
|
||||
}
|
||||
opened = true
|
||||
fmt.Fprintf(a.Stdout, "opened: %s\n", portal)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const defaultWebVNCBridgePoolSize = 4
|
||||
|
||||
type webVNCBridgePoolConfig struct {
|
||||
Coord *CoordinatorClient
|
||||
LeaseID string
|
||||
Host string
|
||||
Port string
|
||||
PoolSize int
|
||||
RescueCtx rescueContext
|
||||
NativeVNC string
|
||||
Log io.Writer
|
||||
OnReady func() error
|
||||
}
|
||||
|
||||
type webVNCBridgePoolEvent struct {
|
||||
Kind string
|
||||
Slot int
|
||||
Attempt int
|
||||
Err error
|
||||
}
|
||||
|
||||
func serveWebVNCBridgePool(ctx context.Context, cfg webVNCBridgePoolConfig) error {
|
||||
if cfg.PoolSize < 1 {
|
||||
cfg.PoolSize = 1
|
||||
}
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
events := make(chan webVNCBridgePoolEvent, cfg.PoolSize)
|
||||
for slot := 0; slot < cfg.PoolSize; slot++ {
|
||||
go serveWebVNCBridgeSlot(ctx, cfg, slot, events)
|
||||
}
|
||||
ready := false
|
||||
initialFailures := make(map[int]bool)
|
||||
var firstErr error
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return context.Cause(ctx)
|
||||
case event := <-events:
|
||||
switch event.Kind {
|
||||
case "ready":
|
||||
if !ready {
|
||||
ready = true
|
||||
if cfg.OnReady != nil {
|
||||
if err := cfg.OnReady(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
case "initial-error":
|
||||
if !ready {
|
||||
initialFailures[event.Slot] = true
|
||||
if firstErr == nil {
|
||||
firstErr = event.Err
|
||||
}
|
||||
if len(initialFailures) >= cfg.PoolSize {
|
||||
printRescueWithFallback(cfg.Log, rescueVNCBridgeDisconnected, firstErr.Error(), cfg.NativeVNC, webVNCStatusRescueCommand(cfg.RescueCtx), webVNCResetRescueCommand(cfg.RescueCtx))
|
||||
return firstErr
|
||||
}
|
||||
}
|
||||
case "retry":
|
||||
if ready && event.Err != nil {
|
||||
printRescueWithFallback(cfg.Log, classifyWebVNCBridgeProblem(event.Err), event.Err.Error(), cfg.NativeVNC, webVNCStatusRescueCommand(cfg.RescueCtx), webVNCResetRescueCommand(cfg.RescueCtx))
|
||||
fmt.Fprintf(cfg.Log, "bridge[%d]: reconnecting in %s\n", event.Slot+1, webVNCReconnectDelay(event.Attempt))
|
||||
}
|
||||
case "fatal":
|
||||
if event.Err != nil {
|
||||
return event.Err
|
||||
}
|
||||
}
|
||||
} else {
|
||||
fmt.Fprintf(a.Stdout, "bridge: reconnected after viewer reset (attempt %d)\n", attempt+1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func serveWebVNCBridgeSlot(ctx context.Context, cfg webVNCBridgePoolConfig, slot int, events chan<- webVNCBridgePoolEvent) {
|
||||
connectedOnce := false
|
||||
attempt := 0
|
||||
for {
|
||||
bridge, err := connectWebVNCBridge(ctx, cfg.Coord, cfg.LeaseID, cfg.Host, cfg.Port)
|
||||
if err != nil {
|
||||
attempt, kind := nextWebVNCBridgeFailure(connectedOnce, attempt)
|
||||
events <- webVNCBridgePoolEvent{Kind: kind, Slot: slot, Attempt: attempt, Err: err}
|
||||
if err := waitWebVNCReconnect(ctx, webVNCReconnectDelay(attempt)); err != nil {
|
||||
events <- webVNCBridgePoolEvent{Kind: "fatal", Slot: slot, Err: err}
|
||||
return
|
||||
if *openPortal && !opened {
|
||||
if err := openLocalURL(portal); err != nil {
|
||||
bridge.Close()
|
||||
return err
|
||||
}
|
||||
continue
|
||||
opened = true
|
||||
fmt.Fprintf(a.Stdout, "opened: %s\n", portal)
|
||||
}
|
||||
connectedOnce = true
|
||||
attempt = 0
|
||||
events <- webVNCBridgePoolEvent{Kind: "ready", Slot: slot}
|
||||
err = bridge.Serve(ctx)
|
||||
if !retryableWebVNCBridgeError(err) {
|
||||
events <- webVNCBridgePoolEvent{Kind: "fatal", Slot: slot, Err: err}
|
||||
return
|
||||
}
|
||||
attempt++
|
||||
events <- webVNCBridgePoolEvent{Kind: "retry", Slot: slot, Attempt: attempt, Err: err}
|
||||
if err := waitWebVNCReconnect(ctx, webVNCReconnectDelay(attempt)); err != nil {
|
||||
events <- webVNCBridgePoolEvent{Kind: "fatal", Slot: slot, Err: err}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func nextWebVNCBridgeFailure(connectedOnce bool, attempt int) (int, string) {
|
||||
attempt++
|
||||
if !connectedOnce && attempt == 1 {
|
||||
return attempt, "initial-error"
|
||||
}
|
||||
return attempt, "retry"
|
||||
}
|
||||
|
||||
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(ctx context.Context, args []string) error {
|
||||
defaults := defaultConfig()
|
||||
fs := newFlagSet("webvnc daemon start", a.Stderr)
|
||||
provider := fs.String("provider", defaults.Provider, "provider: hetzner, aws, or azure")
|
||||
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}
|
||||
bridgeID := *id
|
||||
if !isBlacksmithProvider(cfg.Provider) && !isStaticProvider(cfg.Provider) {
|
||||
coord, useCoordinator, err := newTargetCoordinatorClient(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if useCoordinator && coord != nil && coord.Token != "" {
|
||||
server, resolvedTarget, leaseID, err := a.resolveNetworkLeaseTarget(ctx, cfg, *id, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := enforceManagedLeaseCapabilities(cfg, server, leaseID); err != nil {
|
||||
return err
|
||||
}
|
||||
if server.Provider != "" {
|
||||
cfg.Provider = server.Provider
|
||||
}
|
||||
if resolvedTarget.TargetOS != "" {
|
||||
cfg.TargetOS = resolvedTarget.TargetOS
|
||||
}
|
||||
if resolvedTarget.WindowsMode != "" {
|
||||
cfg.WindowsMode = resolvedTarget.WindowsMode
|
||||
}
|
||||
target = resolvedTarget
|
||||
bridgeID = leaseID
|
||||
}
|
||||
}
|
||||
daemonArgs := webVNCBridgeArgs(cfg, target, bridgeID, *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, aws, or azure")
|
||||
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/azure 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))
|
||||
rescueCtx := rescueContext{Cfg: cfg, Target: target, LeaseID: leaseID}
|
||||
if daemonErr != nil {
|
||||
fmt.Fprintf(a.Stdout, "webvnc daemon: error=%v\n", daemonErr)
|
||||
} else {
|
||||
printLocalWebVNCDaemonStatus(a.Stdout, daemon)
|
||||
if daemon.Missing || daemon.Stale {
|
||||
printRescue(a.Stdout, rescueVNCBridgeNotRunning, "", webVNCDaemonStartRescueCommand(rescueCtx))
|
||||
}
|
||||
}
|
||||
if endpointErr != nil {
|
||||
fmt.Fprintf(a.Stdout, "vnc target: unreachable 127.0.0.1:5900 (%v)\n", endpointErr)
|
||||
printRescue(a.Stdout, rescueVNCTargetUnreachable, endpointErr.Error(), desktopDoctorCommand(rescueCtx))
|
||||
} 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")
|
||||
attempt++
|
||||
delay := webVNCReconnectDelay(attempt)
|
||||
if err != nil {
|
||||
fmt.Fprintf(a.Stdout, "bridge: viewer reset: %v; reconnecting in %s\n", err, delay)
|
||||
} else {
|
||||
fmt.Fprintf(a.Stdout, "ssh tunnel: %s\n", vncTunnelCommand(target, *localPort))
|
||||
fmt.Fprintf(a.Stdout, "bridge: viewer closed; reconnecting in %s\n", delay)
|
||||
}
|
||||
if err := waitWebVNCReconnect(ctx, delay); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if statusErr != nil {
|
||||
fmt.Fprintf(a.Stdout, "portal bridge: unknown (%v)\n", statusErr)
|
||||
printRescue(a.Stdout, rescueVNCBridgeDisconnected, statusErr.Error(), webVNCStatusRescueCommand(rescueCtx), webVNCResetRescueCommand(rescueCtx))
|
||||
} else {
|
||||
fmt.Fprintf(a.Stdout, "portal bridge: connected=%t viewers=%d observers=%d slots=%d\n", status.BridgeConnected, status.ViewerCount, status.ObserverCount, status.AvailableViewerSlots)
|
||||
if strings.TrimSpace(status.ControllerLabel) != "" {
|
||||
fmt.Fprintf(a.Stdout, "portal controller: %s\n", strings.TrimSpace(status.ControllerLabel))
|
||||
}
|
||||
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.BridgeConnected {
|
||||
printRescue(a.Stdout, rescueVNCBridgeNotRunning, "portal has no active WebVNC bridge for this lease", webVNCDaemonStartRescueCommand(rescueCtx), webVNCResetRescueCommand(rescueCtx))
|
||||
} else if statusErr == nil && webVNCObserverSlotsExhausted(status) {
|
||||
printRescue(a.Stdout, rescueVNCObserverSlotsFull, "all WebVNC observer slots are in use or stale", webVNCDaemonStartRescueCommand(rescueCtx), webVNCResetRescueCommand(rescueCtx))
|
||||
}
|
||||
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, aws, or azure")
|
||||
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/azure 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
|
||||
}
|
||||
rescueCtx := rescueContext{Cfg: cfg, Target: target, LeaseID: leaseID}
|
||||
if out, err := runSSHCombinedOutput(ctx, target, webVNCResetRemoteCommand(target)); err != nil {
|
||||
printRescue(a.Stdout, classifyDesktopFailure(out), trimFailureDetail(out), desktopDoctorCommand(rescueCtx))
|
||||
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 {
|
||||
@ -575,11 +164,6 @@ func (a App) startWebVNCDaemon(args []string, leaseID string) error {
|
||||
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
|
||||
@ -592,7 +176,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"}, args...)
|
||||
childArgs := append([]string{"webvnc"}, stripWebVNCDaemonFlags(args)...)
|
||||
cmd := exec.Command("sh", "-c", webVNCDaemonSupervisorScript(exe, childArgs))
|
||||
cmd.Stdin = nil
|
||||
cmd.Stdout = logFile
|
||||
@ -610,7 +194,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 daemon stop --id <lease-id-or-slug>")
|
||||
fmt.Fprintln(a.Stdout, "webvnc daemon: stop with crabbox webvnc --id <lease-id-or-slug> --stop")
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -642,108 +226,63 @@ func webVNCDaemonSupervisorScript(exe string, args []string) string {
|
||||
}
|
||||
|
||||
func (a App) webVNCDaemonStatus(leaseID string) error {
|
||||
status, err := localWebVNCDaemonStatus(leaseID)
|
||||
logPath, pidPath, err := webVNCDaemonPaths(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) {
|
||||
status.Missing = true
|
||||
return status, nil
|
||||
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
|
||||
}
|
||||
return localWebVNCDaemon{}, err
|
||||
return err
|
||||
}
|
||||
status.PID = pid
|
||||
command, alive := webVNCDaemonProcessCommand(pid)
|
||||
status.Command = strings.TrimSpace(command)
|
||||
status.Alive = alive
|
||||
if !alive {
|
||||
status.Stale = true
|
||||
return status, nil
|
||||
fmt.Fprintf(a.Stdout, "webvnc daemon: stale pid=%d log=%s\n", pid, logPath)
|
||||
return 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))
|
||||
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))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a App) stopWebVNCDaemon(leaseID string) error {
|
||||
stopped, err := a.stopWebVNCDaemonIfRunning(leaseID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !stopped {
|
||||
fmt.Fprintf(a.Stdout, "webvnc daemon: no pid file for %s\n", leaseID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a App) stopWebVNCDaemonIfRunning(leaseID string) (bool, error) {
|
||||
_, pidPath, err := webVNCDaemonPaths(leaseID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
return err
|
||||
}
|
||||
pid, err := readWebVNCDaemonPID(pidPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return false, nil
|
||||
fmt.Fprintf(a.Stdout, "webvnc daemon: no pid file for %s\n", leaseID)
|
||||
return nil
|
||||
}
|
||||
return false, err
|
||||
return err
|
||||
}
|
||||
command, alive := webVNCDaemonProcessCommand(pid)
|
||||
if !alive {
|
||||
_ = os.Remove(pidPath)
|
||||
fmt.Fprintf(a.Stdout, "webvnc daemon: removed stale pid=%d\n", pid)
|
||||
return true, nil
|
||||
return nil
|
||||
}
|
||||
if !isWebVNCDaemonCommand(command) {
|
||||
return false, exit(5, "refusing to stop pid %d; command does not look like crabbox webvnc: %s", pid, strings.TrimSpace(command))
|
||||
return 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 false, exit(5, "find WebVNC daemon pid %d: %v", pid, err)
|
||||
return exit(5, "find WebVNC daemon pid %d: %v", pid, err)
|
||||
}
|
||||
if err := stopDaemonProcess(process, pid); err != nil {
|
||||
return false, exit(5, "stop WebVNC daemon pid %d: %v", pid, err)
|
||||
return 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 true, nil
|
||||
return nil
|
||||
}
|
||||
|
||||
func webVNCDaemonProcessCommand(pid int) (string, bool) {
|
||||
@ -772,39 +311,7 @@ func readWebVNCDaemonPID(pidPath string) (int, error) {
|
||||
return pid, nil
|
||||
}
|
||||
|
||||
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 {
|
||||
func stripWebVNCDaemonFlags(args []string) []string {
|
||||
out := make([]string, 0, len(args))
|
||||
for _, arg := range args {
|
||||
if arg == "--daemon" || arg == "--background" ||
|
||||
@ -816,87 +323,17 @@ func stripLegacyWebVNCDaemonFlags(args []string) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
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) {
|
||||
func stripWebVNCOpenFlags(args []string) []string {
|
||||
out := make([]string, 0, len(args))
|
||||
for _, arg := range args {
|
||||
if arg == "--open" || strings.HasPrefix(arg, "--open=") {
|
||||
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]
|
||||
out = append(out, arg)
|
||||
}
|
||||
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 {
|
||||
@ -1004,27 +441,6 @@ func retryableWebVNCBridgeError(err error) bool {
|
||||
strings.Contains(message, "status = StatusNormalClosure")
|
||||
}
|
||||
|
||||
func classifyWebVNCBridgeProblem(err error) string {
|
||||
if err == nil {
|
||||
return rescueVNCBridgeDisconnected
|
||||
}
|
||||
message := err.Error()
|
||||
if strings.Contains(message, "replaced by a newer WebVNC viewer") || strings.Contains(message, "another viewer") {
|
||||
return rescueVNCStaleViewer
|
||||
}
|
||||
return rescueVNCBridgeDisconnected
|
||||
}
|
||||
|
||||
func webVNCObserverSlotsExhausted(status CoordinatorWebVNCStatus) bool {
|
||||
if !status.BridgeConnected || status.AvailableViewerSlots != 0 {
|
||||
return false
|
||||
}
|
||||
if status.ViewerCount > 0 {
|
||||
return true
|
||||
}
|
||||
return strings.Contains(status.Message, "available WebVNC observer slot")
|
||||
}
|
||||
|
||||
func webVNCReconnectDelay(attempt int) time.Duration {
|
||||
if attempt < 1 {
|
||||
attempt = 1
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
@ -164,62 +163,6 @@ func TestRetryableWebVNCBridgeErrors(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyWebVNCBridgeProblem(t *testing.T) {
|
||||
if got := classifyWebVNCBridgeProblem(errors.New(`received close frame: replaced by a newer WebVNC viewer`)); got != rescueVNCStaleViewer {
|
||||
t.Fatalf("problem=%q, want %q", got, rescueVNCStaleViewer)
|
||||
}
|
||||
if got := classifyWebVNCBridgeProblem(errors.New(`failed to read frame header: EOF`)); got != rescueVNCBridgeDisconnected {
|
||||
t.Fatalf("problem=%q, want %q", got, rescueVNCBridgeDisconnected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextWebVNCBridgeFailureBacksOffInitialFailures(t *testing.T) {
|
||||
attempt, kind := nextWebVNCBridgeFailure(false, 0)
|
||||
if attempt != 1 || kind != "initial-error" {
|
||||
t.Fatalf("first failure attempt=%d kind=%q", attempt, kind)
|
||||
}
|
||||
attempt, kind = nextWebVNCBridgeFailure(false, attempt)
|
||||
if attempt != 2 || kind != "retry" {
|
||||
t.Fatalf("second initial failure attempt=%d kind=%q", attempt, kind)
|
||||
}
|
||||
if got := webVNCReconnectDelay(attempt); got != time.Second {
|
||||
t.Fatalf("second initial failure delay=%s, want 1s", got)
|
||||
}
|
||||
attempt, kind = nextWebVNCBridgeFailure(true, 0)
|
||||
if attempt != 1 || kind != "retry" {
|
||||
t.Fatalf("post-connect failure attempt=%d kind=%q", attempt, kind)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebVNCObserverSlotsExhausted(t *testing.T) {
|
||||
if !webVNCObserverSlotsExhausted(CoordinatorWebVNCStatus{
|
||||
BridgeConnected: true,
|
||||
ViewerCount: 4,
|
||||
AvailableViewerSlots: 0,
|
||||
}) {
|
||||
t.Fatal("expected full viewer pool to be exhausted")
|
||||
}
|
||||
if !webVNCObserverSlotsExhausted(CoordinatorWebVNCStatus{
|
||||
BridgeConnected: true,
|
||||
AvailableViewerSlots: 0,
|
||||
Message: "waiting for an available WebVNC observer slot",
|
||||
}) {
|
||||
t.Fatal("expected exhausted status message to be exhausted")
|
||||
}
|
||||
if webVNCObserverSlotsExhausted(CoordinatorWebVNCStatus{
|
||||
BridgeConnected: true,
|
||||
}) {
|
||||
t.Fatal("old bridge-only status must not be treated as exhausted")
|
||||
}
|
||||
if webVNCObserverSlotsExhausted(CoordinatorWebVNCStatus{
|
||||
BridgeConnected: true,
|
||||
ViewerCount: 1,
|
||||
AvailableViewerSlots: 2,
|
||||
}) {
|
||||
t.Fatal("available slots must not be exhausted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryBridgeTicketInQuery(t *testing.T) {
|
||||
resp := &http.Response{
|
||||
StatusCode: http.StatusUnauthorized,
|
||||
@ -236,81 +179,8 @@ func TestRetryBridgeTicketInQuery(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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{
|
||||
func TestWebVNCDaemonArgsStripBackgroundFlags(t *testing.T) {
|
||||
got := strings.Join(stripWebVNCDaemonFlags([]string{
|
||||
"--provider",
|
||||
"aws",
|
||||
"--daemon",
|
||||
|
||||
@ -2,7 +2,6 @@ package all
|
||||
|
||||
import (
|
||||
_ "github.com/openclaw/crabbox/internal/providers/aws"
|
||||
_ "github.com/openclaw/crabbox/internal/providers/azure"
|
||||
_ "github.com/openclaw/crabbox/internal/providers/blacksmith"
|
||||
_ "github.com/openclaw/crabbox/internal/providers/daytona"
|
||||
_ "github.com/openclaw/crabbox/internal/providers/hetzner"
|
||||
|
||||
@ -1,204 +0,0 @@
|
||||
package azure
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
core "github.com/openclaw/crabbox/internal/cli"
|
||||
"github.com/openclaw/crabbox/internal/providers/shared"
|
||||
)
|
||||
|
||||
type Config = core.Config
|
||||
type Runtime = core.Runtime
|
||||
type ProviderSpec = core.ProviderSpec
|
||||
type Backend = core.Backend
|
||||
type AcquireRequest = core.AcquireRequest
|
||||
type ResolveRequest = core.ResolveRequest
|
||||
type ListRequest = core.ListRequest
|
||||
type LeaseView = core.LeaseView
|
||||
type ReleaseLeaseRequest = core.ReleaseLeaseRequest
|
||||
type TouchRequest = core.TouchRequest
|
||||
type CleanupRequest = core.CleanupRequest
|
||||
type LeaseTarget = core.LeaseTarget
|
||||
type Server = core.Server
|
||||
type SSHTarget = core.SSHTarget
|
||||
|
||||
type azureLeaseBackend struct{ shared.DirectSSHBackend }
|
||||
|
||||
func NewAzureLeaseBackend(spec ProviderSpec, cfg Config, rt Runtime) Backend {
|
||||
cfg.Provider = "azure"
|
||||
return &azureLeaseBackend{DirectSSHBackend: shared.DirectSSHBackend{SpecValue: spec, Cfg: cfg, RT: rt}}
|
||||
}
|
||||
|
||||
func (b *azureLeaseBackend) Acquire(ctx context.Context, req AcquireRequest) (LeaseTarget, error) {
|
||||
return acquireAttemptsRetry(b.RT, req.Keep, func() (LeaseTarget, error) {
|
||||
return b.acquireOnce(ctx, req.Keep)
|
||||
})
|
||||
}
|
||||
|
||||
func (b *azureLeaseBackend) acquireOnce(ctx context.Context, keep bool) (LeaseTarget, error) {
|
||||
if b.Cfg.Tailscale.Enabled && b.Cfg.Tailscale.AuthKey == "" {
|
||||
return LeaseTarget{}, exit(2, "direct --tailscale requires %s to contain a Tailscale auth key; brokered mode uses coordinator OAuth secrets", b.Cfg.Tailscale.AuthKeyEnv)
|
||||
}
|
||||
client, err := newAzureClient(ctx, b.Cfg)
|
||||
if err != nil {
|
||||
return LeaseTarget{}, err
|
||||
}
|
||||
leaseID := newLeaseID()
|
||||
servers, err := client.ListCrabboxServers(ctx)
|
||||
if err != nil {
|
||||
return LeaseTarget{}, err
|
||||
}
|
||||
slug := allocateDirectLeaseSlug(leaseID, servers)
|
||||
cfg := b.Cfg
|
||||
keyPath, publicKey, err := ensureTestboxKeyForConfig(cfg, leaseID)
|
||||
if err != nil {
|
||||
return LeaseTarget{}, err
|
||||
}
|
||||
cfg.SSHKey = keyPath
|
||||
cfg.ProviderKey = providerKeyForLease(leaseID)
|
||||
fmt.Fprintf(b.RT.Stderr, "provisioning provider=azure lease=%s slug=%s class=%s preferred_type=%s location=%s rg=%s keep=%v\n",
|
||||
leaseID, slug, cfg.Class, cfg.ServerType, cfg.AzureLocation, cfg.AzureResourceGroup, keep)
|
||||
server, cfg, err := client.CreateServerWithFallback(ctx, cfg, publicKey, leaseID, slug, keep, func(format string, args ...any) {
|
||||
fmt.Fprintf(b.RT.Stderr, format, args...)
|
||||
})
|
||||
if err != nil {
|
||||
return LeaseTarget{}, err
|
||||
}
|
||||
fmt.Fprintf(b.RT.Stderr, "provisioned lease=%s server=%s type=%s\n", leaseID, server.DisplayID(), cfg.ServerType)
|
||||
server, err = client.WaitForServerIP(ctx, server.CloudID)
|
||||
if err != nil {
|
||||
return LeaseTarget{}, err
|
||||
}
|
||||
target := sshTargetFromConfig(cfg, server.PublicNet.IPv4.IP)
|
||||
if err := waitForSSHReady(ctx, &target, b.RT.Stderr, "bootstrap", bootstrapWaitTimeout(cfg)); err != nil {
|
||||
_ = client.DeleteServer(context.Background(), server.CloudID)
|
||||
return LeaseTarget{}, err
|
||||
}
|
||||
server.Labels["state"] = "ready"
|
||||
if err := client.SetTags(ctx, server.CloudID, server.Labels); err != nil {
|
||||
fmt.Fprintf(b.RT.Stderr, "warning: set tags: %v\n", err)
|
||||
}
|
||||
return LeaseTarget{Server: server, SSH: target, LeaseID: leaseID}, nil
|
||||
}
|
||||
|
||||
func (b *azureLeaseBackend) Resolve(ctx context.Context, req ResolveRequest) (LeaseTarget, error) {
|
||||
client, err := newAzureClient(ctx, b.Cfg)
|
||||
if err != nil {
|
||||
return LeaseTarget{}, err
|
||||
}
|
||||
if strings.HasPrefix(req.ID, "crabbox-") {
|
||||
server, err := client.GetServer(ctx, req.ID)
|
||||
if err != nil {
|
||||
return LeaseTarget{}, err
|
||||
}
|
||||
if !isCrabboxAzureLease(server) {
|
||||
return LeaseTarget{}, exit(4, "lease/server not found: %s (vm exists but is not Crabbox-managed)", req.ID)
|
||||
}
|
||||
leaseID := blank(server.Labels["lease"], req.ID)
|
||||
target := sshTargetFromConfig(b.Cfg, server.PublicNet.IPv4.IP)
|
||||
useStoredTestboxKey(&target, leaseID)
|
||||
return LeaseTarget{Server: server, SSH: target, LeaseID: leaseID}, nil
|
||||
}
|
||||
servers, err := client.ListCrabboxServers(ctx)
|
||||
if err != nil {
|
||||
return LeaseTarget{}, err
|
||||
}
|
||||
if server, leaseID, err := findServerByAlias(servers, req.ID); err != nil {
|
||||
return LeaseTarget{}, err
|
||||
} else if leaseID != "" {
|
||||
target := sshTargetFromConfig(b.Cfg, server.PublicNet.IPv4.IP)
|
||||
useStoredTestboxKey(&target, leaseID)
|
||||
return LeaseTarget{Server: server, SSH: target, LeaseID: leaseID}, nil
|
||||
}
|
||||
return LeaseTarget{}, exit(4, "lease/server not found: %s", req.ID)
|
||||
}
|
||||
|
||||
func isCrabboxAzureLease(server Server) bool {
|
||||
if server.Labels == nil {
|
||||
return false
|
||||
}
|
||||
if server.Labels["crabbox"] != "true" {
|
||||
return false
|
||||
}
|
||||
if provider := server.Labels["provider"]; provider != "" && provider != "azure" {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (b *azureLeaseBackend) List(ctx context.Context, req ListRequest) ([]LeaseView, error) {
|
||||
_ = req
|
||||
client, err := newAzureClient(ctx, b.Cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return client.ListCrabboxServers(ctx)
|
||||
}
|
||||
|
||||
func (b *azureLeaseBackend) ReleaseLease(ctx context.Context, req ReleaseLeaseRequest) error {
|
||||
if err := deleteServer(ctx, b.Cfg, req.Lease.Server); err != nil {
|
||||
return err
|
||||
}
|
||||
removeLeaseClaim(req.Lease.LeaseID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *azureLeaseBackend) Touch(ctx context.Context, req TouchRequest) (Server, error) {
|
||||
return b.DirectSSHBackend.Touch(ctx, req.Lease.Server, req.State), nil
|
||||
}
|
||||
|
||||
func (b *azureLeaseBackend) Cleanup(ctx context.Context, req CleanupRequest) error {
|
||||
servers, err := b.List(ctx, ListRequest{Options: req.Options})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return b.CleanupServers(ctx, req, servers)
|
||||
}
|
||||
|
||||
func acquireAttemptsRetry(rt Runtime, keep bool, acquire func() (LeaseTarget, error)) (LeaseTarget, error) {
|
||||
return shared.AcquireAttemptsRetry(rt, keep, acquire)
|
||||
}
|
||||
|
||||
func exit(code int, format string, args ...any) core.ExitError {
|
||||
return core.Exit(code, format, args...)
|
||||
}
|
||||
|
||||
func newAzureClient(ctx context.Context, cfg Config) (*core.AzureClient, error) {
|
||||
return core.NewAzureClient(ctx, cfg)
|
||||
}
|
||||
|
||||
func newLeaseID() string { return core.NewLeaseID() }
|
||||
func allocateDirectLeaseSlug(id string, servers []Server) string {
|
||||
return core.AllocateDirectLeaseSlug(id, servers)
|
||||
}
|
||||
func ensureTestboxKeyForConfig(cfg Config, leaseID string) (string, string, error) {
|
||||
return core.EnsureTestboxKeyForConfig(cfg, leaseID)
|
||||
}
|
||||
func providerKeyForLease(leaseID string) string { return core.ProviderKeyForLease(leaseID) }
|
||||
func sshTargetFromConfig(cfg Config, host string) SSHTarget {
|
||||
return core.SSHTargetFromConfig(cfg, host)
|
||||
}
|
||||
func waitForSSHReady(ctx context.Context, target *SSHTarget, stderr io.Writer, phase string, timeout time.Duration) error {
|
||||
return core.WaitForSSHReady(ctx, target, stderr, phase, timeout)
|
||||
}
|
||||
func bootstrapWaitTimeout(cfg Config) time.Duration { return core.BootstrapWaitTimeout(cfg) }
|
||||
func deleteServer(ctx context.Context, cfg Config, server Server) error {
|
||||
return core.DeleteServer(ctx, cfg, server)
|
||||
}
|
||||
func blank(value, fallback string) string { return core.Blank(value, fallback) }
|
||||
func useStoredTestboxKey(target *SSHTarget, leaseID string) {
|
||||
if keyPath, err := core.TestboxKeyPath(leaseID); err == nil {
|
||||
if _, statErr := os.Stat(keyPath); statErr == nil {
|
||||
target.Key = keyPath
|
||||
}
|
||||
}
|
||||
}
|
||||
func findServerByAlias(servers []Server, id string) (Server, string, error) {
|
||||
return core.FindServerByAlias(servers, id)
|
||||
}
|
||||
func removeLeaseClaim(leaseID string) { core.RemoveLeaseClaim(leaseID) }
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user