feat: add Tailscale network support (#19)

* feat: add tailscale network support

* fix: relax tailscale network probes

* fix: use configured user for tailscale metadata
This commit is contained in:
Peter Steinberger 2026-05-04 08:58:45 +01:00 committed by GitHub
parent 9ffc78e003
commit 4d15c24a7f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
49 changed files with 1717 additions and 35 deletions

View File

@ -14,6 +14,7 @@
- Added authenticated WebVNC portal support with `crabbox webvnc`, which bridges a desktop lease into the coordinator portal with short-lived bridge tickets and without exposing the remote VNC port.
- Added `crabbox desktop launch` to open a browser or app inside a visible desktop lease, including native Windows scheduled-task launch for the logged-in console session.
- Added a minimal XFCE desktop profile with panel/window manager for managed VNC leases.
- Added optional Tailscale reachability for managed Linux leases with `--tailscale`, `--network auto|tailscale|public`, brokered OAuth auth-key minting, and non-secret tailnet metadata in status/inspect output.
- Clarified static macOS/Windows VNC as existing-host access, not Crabbox-created boxes, so `--open` no longer launches an OS credential prompt unless `--host-managed` is passed.
### Fixed

View File

@ -164,6 +164,24 @@ static:
workRoot: C:\crabbox
```
Optional Tailscale reachability for managed Linux leases:
```yaml
tailscale:
enabled: true
network: auto
tags:
- tag:crabbox
hostnameTemplate: crabbox-{slug}
authKeyEnv: CRABBOX_TAILSCALE_AUTH_KEY
```
Tailscale is a network plane, not a provider. `--tailscale` joins new managed
Linux leases to the tailnet; `--network auto|tailscale|public` chooses how SSH
and VNC tunnel commands resolve the host. Brokered mode uses Worker OAuth
secrets to mint one-off keys; direct-provider mode reads the auth key from the
configured env var. See [Tailscale](docs/features/tailscale.md).
Forwarded environment is intentionally narrow: `NODE_OPTIONS` and `CI`. Do not pass secrets as command-line arguments. Full env-var reference and per-command flags are in [docs/cli.md](docs/cli.md) and [docs/commands/](docs/commands/README.md).
## OpenClaw plugin

View File

@ -33,8 +33,8 @@ crabbox init [--force]
crabbox config show [--json]
crabbox config path
crabbox config set-broker --url <url> --token-stdin [--provider hetzner|aws]
crabbox warmup [--provider hetzner|aws|ssh|blacksmith-testbox] [--target linux|macos|windows] [--desktop] [--browser] [--profile <name>] [--idle-timeout <duration>] [--timing-json]
crabbox run [--id <lease-id-or-slug>] [--provider hetzner|aws|ssh|blacksmith-testbox] [--target linux|macos|windows] [--windows-mode normal|wsl2] [--desktop] [--browser] [--shell] [--checksum] [--debug] [--force-sync-large] [--timing-json] [--blacksmith-workflow <workflow>] -- <command...>
crabbox warmup [--provider hetzner|aws|ssh|blacksmith-testbox] [--target linux|macos|windows] [--desktop] [--browser] [--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] [--target linux|macos|windows] [--windows-mode normal|wsl2] [--desktop] [--browser] [--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>] [-- <command...>]
crabbox screenshot --id <lease-id-or-slug> [--output <path>]
crabbox sync-plan [--limit <n>]
@ -49,16 +49,16 @@ crabbox cache warm --id <lease-id-or-slug> -- <command...>
crabbox actions hydrate --id <lease-id-or-slug> [--workflow <file|name|id>] [--wait-timeout <duration>] [--timing-json]
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> [--wait]
crabbox status --id <lease-id-or-slug> [--network auto|tailscale|public] [--wait]
crabbox list [--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]
crabbox admin delete <lease-id-or-slug> --force
crabbox ssh --id <lease-id-or-slug>
crabbox vnc --id <lease-id-or-slug> [--open]
crabbox webvnc --id <lease-id-or-slug> [--open]
crabbox inspect --id <lease-id-or-slug> [--json]
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 inspect --id <lease-id-or-slug> [--network auto|tailscale|public] [--json]
crabbox stop <lease-id-or-slug>
crabbox cleanup [--dry-run]
```
@ -81,6 +81,7 @@ Warm a box, then reuse it:
```sh
crabbox warmup --profile project-check
crabbox warmup --tailscale
crabbox warmup --desktop --browser
crabbox run --id blue-lobster -- pnpm test:changed
crabbox vnc --id blue-lobster --open
@ -134,6 +135,14 @@ Managed provider targets are intentionally narrow:
and EC2 Mac (`--target macos`) when the Mac Dedicated Host is provided.
- Existing macOS and Windows machines belong on `provider=ssh`.
Use Tailscale as an optional network plane:
```sh
crabbox warmup --tailscale
crabbox ssh --id blue-lobster --network tailscale
crabbox vnc --id blue-lobster --network tailscale --open
```
Inspect pool:
```sh

View File

@ -4,6 +4,7 @@
```sh
crabbox inspect --id blue-lobster
crabbox inspect --id blue-lobster --network tailscale
crabbox inspect --id blue-lobster --json
crabbox inspect --provider ssh --target windows --windows-mode wsl2 --static-host win-dev.local
```
@ -18,5 +19,10 @@ Flags:
--target linux|macos|windows
--windows-mode normal|wsl2
--static-host <host>
--network auto|tailscale|public
--json
```
JSON output includes non-secret Tailscale metadata when present. Human output
prints both the provider host and the resolved SSH command for the selected
network.

View File

@ -6,6 +6,8 @@
crabbox run --id blue-lobster -- pnpm test:changed:max
crabbox run --class beast -- pnpm check
crabbox run --provider aws --class beast --market on-demand -- pnpm check
crabbox run --tailscale -- pnpm check
crabbox run --id blue-lobster --network tailscale -- pnpm test
crabbox run --browser -- google-chrome --headless --version
crabbox run --desktop --browser --shell 'echo "$DISPLAY"; "$BROWSER" --version'
crabbox run --id blue-lobster --shell 'pnpm install --frozen-lockfile && pnpm test'
@ -36,6 +38,13 @@ browser.
`CRABBOX_DESKTOP=1` plus `DISPLAY=:99`. It does not imply a browser. Use
`--desktop --browser` for headed browser automation in the VNC-visible session.
`--tailscale` asks new managed Linux leases to join the configured tailnet.
`--network` selects how Crabbox resolves SSH for reused leases and for the final
connection after a new lease becomes ready. `auto` prefers Tailscale when
metadata exists and SSH is reachable, `tailscale` fails if the tailnet path is
not available, and `public` forces the provider host. See
[Tailscale](../features/tailscale.md).
Sync uses `git ls-files --cached --others --exclude-standard` to build a file manifest, then feeds that manifest to rsync over SSH. That means tracked files plus nonignored untracked files sync, while `.git`, ignored local build output, dependency folders, and common caches stay out of the transfer. Crabbox records a local/remote sync fingerprint and skips rsync when the tracked commit plus manifest and dirty metadata have not changed. Use `--checksum` when you need a paranoid checksum scan, and `--debug` to print sync timing, progress, and itemized rsync output.
For `provider=ssh`, `target=macos` and `target=windows windows.mode=wsl2`
@ -82,6 +91,11 @@ Flags:
--idle-timeout <duration>
--desktop
--browser
--tailscale
--tailscale-tags <comma-separated tags>
--tailscale-hostname-template <template>
--tailscale-auth-key-env <env-var>
--network auto|tailscale|public
--keep
--no-sync
--sync-only

View File

@ -6,6 +6,7 @@ client.
```sh
crabbox warmup --desktop
crabbox screenshot --id blue-lobster
crabbox screenshot --id blue-lobster --network tailscale
crabbox screenshot --id blue-lobster --output desktop.png
```
@ -44,6 +45,7 @@ Flags:
--static-user <user>
--static-port <port>
--static-work-root <path>
--network auto|tailscale|public
--output <path>
--reclaim
```

View File

@ -4,6 +4,7 @@
```sh
crabbox ssh --id blue-lobster
crabbox ssh --id blue-lobster --network tailscale
crabbox ssh --provider ssh --target macos --static-host mac-studio.local
```
@ -17,7 +18,12 @@ Flags:
--target linux|macos|windows
--windows-mode normal|wsl2
--static-host <host>
--network auto|tailscale|public
--reclaim
```
`ssh` touches the lease and validates the local repo claim. Use `--reclaim` when intentionally taking over a lease from another repo.
`--network auto` prefers the tailnet host when the lease has Tailscale metadata
and this client can reach it. `--network tailscale` requires that path.
`--network public` forces the provider host.

View File

@ -4,6 +4,7 @@
```sh
crabbox status --id blue-lobster
crabbox status --id blue-lobster --network tailscale
crabbox status --id blue-lobster --wait --wait-timeout 10m
crabbox status --id blue-lobster --json
crabbox status --provider ssh --target macos --static-host mac-studio.local
@ -19,7 +20,11 @@ Flags:
--target linux|macos|windows
--windows-mode normal|wsl2
--static-host <host>
--network auto|tailscale|public
--wait
--wait-timeout <duration>
--json
```
Human and JSON output include the selected network. With Tailscale metadata,
status also prints the tailnet host/state.

View File

@ -12,6 +12,7 @@ inside a lease:
```sh
crabbox warmup --desktop
crabbox vnc --id blue-lobster
crabbox vnc --id blue-lobster --network tailscale
crabbox vnc --id blue-lobster --open
```
@ -64,6 +65,10 @@ Use `crabbox webvnc --id <lease> --open` when you want the same desktop inside
the authenticated coordinator portal instead of a native VNC client. WebVNC
still uses a local SSH tunnel and does not expose the runner's VNC port.
When `--network tailscale` is selected, only the SSH endpoint changes. Managed
VNC remains loopback-bound on the runner and is still reached through the SSH
tunnel.
Keep the tunnel process alive while you are connected.
## Credentials
@ -235,6 +240,7 @@ make sure the Dedicated Host is allocated in the selected AWS region.
--static-user <user>
--static-port <port>
--static-work-root <path>
--network auto|tailscale|public
--local-port <port>
--open
--host-managed
@ -246,4 +252,5 @@ Related docs:
- [screenshot](screenshot.md)
- [warmup](warmup.md)
- [Interactive desktop and VNC](../features/interactive-desktop-vnc.md)
- [Tailscale](../features/tailscale.md)
- [Providers](../features/providers.md)

View File

@ -6,6 +6,7 @@
crabbox warmup --class beast
crabbox warmup --provider aws --class beast --market on-demand
crabbox warmup --browser
crabbox warmup --tailscale
crabbox warmup --desktop --browser
crabbox warmup --provider aws --target windows --desktop --market on-demand
crabbox warmup --provider aws --target macos --desktop --market on-demand --type mac2.metal
@ -64,6 +65,11 @@ Flags:
--idle-timeout <duration>
--desktop
--browser
--tailscale
--tailscale-tags <comma-separated tags>
--tailscale-hostname-template <template>
--tailscale-auth-key-env <env-var>
--network auto|tailscale|public
--keep
--actions-runner
--reclaim
@ -86,6 +92,13 @@ Chromium package fallback.
automation and operator takeover. It does not imply a browser. Use
`--desktop --browser` when a headed browser should run in the visible display.
`--tailscale` joins newly created managed Linux leases to the configured
tailnet. `--network` controls the SSH endpoint printed after readiness:
`auto` prefers the tailnet when reachable, `tailscale` requires it, and
`public` forces the provider/public host. Tailscale is a reachability layer, not
a provider; static hosts should put a MagicDNS name or 100.x address in
`static.host` instead. See [Tailscale](../features/tailscale.md).
For AWS, `--market` overrides `capacity.market` for this lease. Use
`--market on-demand` when Spot capacity is blocked or when a quota request was
approved only for the standard On-Demand quota. Explicit `--type` still means

View File

@ -6,6 +6,7 @@ portal.
```sh
crabbox warmup --desktop
crabbox webvnc --id blue-lobster
crabbox webvnc --id blue-lobster --network tailscale
crabbox webvnc --id blue-lobster --open
```
@ -26,6 +27,9 @@ This keeps the security boundary the same as `crabbox vnc`:
- The local `crabbox webvnc` process must keep running while the browser uses
the desktop.
`--network tailscale` changes only the SSH endpoint used for the local tunnel.
The runner VNC service stays bound to loopback.
`--open` opens the portal page after the bridge starts. If the VNC password is
available, the command also places it in the URL fragment for the local browser
tab. URL fragments are not sent to the coordinator. If the portal login flow
@ -39,6 +43,7 @@ Flags:
--provider hetzner|aws
--target linux|macos|windows
--windows-mode normal|wsl2
--network auto|tailscale|public
--local-port <port>
--open
--reclaim

View File

@ -13,6 +13,7 @@ Core features:
- [Coordinator](coordinator.md): brokered leases through Cloudflare Workers and Durable Objects.
- [Broker auth and routing](broker-auth-routing.md): GitHub login, shared bearer tokens, optional Cloudflare Access, and Worker routes.
- [Providers](providers.md): Hetzner, AWS EC2 Spot, static SSH macOS/Windows targets, Blacksmith Testbox selection, classes, and fallback.
- [Tailscale](tailscale.md): optional tailnet reachability for managed Linux leases and static hosts.
- [Blacksmith Testbox](blacksmith-testbox.md): wrapper mode that delegates machines and sync to the Blacksmith CLI.
- [Runner bootstrap](runner-bootstrap.md): cloud-init, installed tools, SSH port, and readiness.
- [Sync](sync.md): Git file-list manifests, rsync, fingerprints, excludes, guardrails, and sanity checks.

View File

@ -31,6 +31,8 @@ The intended contract is:
or app in the visible desktop and detaches it from SSH;
- desktop services bind to loopback on the runner and are reachable through SSH
tunnels only;
- `--network tailscale` can move the SSH tunnel endpoint onto the tailnet, but
managed VNC still binds to `127.0.0.1:5900` on the runner;
- screenshots, traces, videos, and browser profiles remain regular command
artifacts owned by the caller or repository workflow.
@ -70,6 +72,7 @@ machine debuggable and reproducible.
Security rules:
- never expose VNC directly to the public internet;
- do not expose managed VNC directly on the Tailscale 100.x interface;
- prefer SSH local forwarding such as `localhost:5901 -> 127.0.0.1:5900`;
- generate per-lease VNC passwords for managed desktop leases;
- redact passwords from logs and run records;
@ -121,5 +124,6 @@ Related docs:
- [Runner bootstrap](runner-bootstrap.md)
- [Providers](providers.md)
- [Tailscale](tailscale.md)
- [SSH keys](ssh-keys.md)
- [Actions hydration](actions-hydration.md)

View File

@ -82,6 +82,10 @@ all mac2.metal unless `--type` is set
Direct provider mode still exists when no coordinator is configured. It uses local AWS credentials or `HCLOUD_TOKEN`/`HETZNER_TOKEN` and should stay secondary to the brokered path.
Tailscale is not a provider. Use `--tailscale` to add tailnet reachability to
new managed Linux leases, or set a static host to a MagicDNS name/100.x address
when the existing host is already on a tailnet. See [Tailscale](tailscale.md).
Direct smoke shape:
```sh
@ -135,6 +139,7 @@ contract and needs `git`, `rsync`, `tar`, and SSH.
Related docs:
- [Infrastructure](../infrastructure.md)
- [Tailscale](tailscale.md)
- [Blacksmith Testbox](blacksmith-testbox.md)
- [Runner bootstrap](runner-bootstrap.md)
- [Cost and usage](cost-usage.md)

View File

@ -31,6 +31,13 @@ minimal bootstrap. See [Interactive desktop and VNC](interactive-desktop-vnc.md)
for the planned boundary: Crabbox owns the desktop/VNC machine capability, while
scenario systems own browser automation and proof artifacts.
Tailscale is optional too. `--tailscale` on a managed Linux lease installs the
Tailscale package, joins the configured tailnet, writes non-secret metadata
under `/var/lib/crabbox`, and extends `crabbox-ready` with a bounded 100.x
address check. The bootstrap does not persist the auth key after `tailscale up`.
Brokered leases receive a one-off key from the coordinator; direct-provider
leases read it from `CRABBOX_TAILSCALE_AUTH_KEY`. See [Tailscale](tailscale.md).
Static SSH targets are not bootstrapped by Crabbox. They are assumed to be
operator-managed:
@ -47,6 +54,7 @@ The CLI prefers the configured SSH port and can fall back through `ssh.fallbackP
Related docs:
- [Providers](providers.md)
- [Tailscale](tailscale.md)
- [SSH keys](ssh-keys.md)
- [run command](../commands/run.md)
- [doctor command](../commands/doctor.md)

158
docs/features/tailscale.md Normal file
View File

@ -0,0 +1,158 @@
# Tailscale
Read when:
- adding or debugging tailnet reachability;
- deciding whether a host is provider-owned or only network-reachable;
- 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, static SSH hosts, and Blacksmith
Testbox. Tailscale only changes which host Crabbox dials for SSH-backed work.
V1 support:
- managed Linux leases can join a tailnet with `--tailscale`;
- static hosts can use MagicDNS names or 100.x addresses in `static.host`;
- managed Windows and EC2 Mac Tailscale provisioning is not enabled yet;
- Blacksmith Testbox connectivity remains Blacksmith-owned.
## Commands
Create a managed Linux lease that joins the configured tailnet:
```sh
crabbox warmup --tailscale
crabbox run --tailscale -- pnpm test
crabbox run --tailscale --desktop --browser -- pnpm test:e2e
```
Choose the connection path for SSH, VNC, screenshots, WebVNC, status, inspect,
and reused `run --id` leases:
```sh
crabbox ssh --id blue-lobster --network auto
crabbox ssh --id blue-lobster --network tailscale
crabbox vnc --id blue-lobster --network tailscale --open
crabbox run --id blue-lobster --network public -- pnpm test
```
Network modes:
- `auto`: prefer Tailscale when lease metadata exists and SSH is reachable,
otherwise use the provider/public host;
- `tailscale`: require a tailnet host and fail clearly when this client cannot
reach it;
- `public`: force the provider/public host for debugging.
When `auto` falls back to the public host, Crabbox prints the selected network
in ready/status output instead of silently hiding the path.
## Config
```yaml
tailscale:
enabled: true
network: auto
tags:
- tag:crabbox
hostnameTemplate: crabbox-{slug}
authKeyEnv: CRABBOX_TAILSCALE_AUTH_KEY
```
Environment overrides:
```text
CRABBOX_TAILSCALE=1
CRABBOX_NETWORK=auto|tailscale|public
CRABBOX_TAILSCALE_TAGS=tag:crabbox,tag:ci
CRABBOX_TAILSCALE_HOSTNAME_TEMPLATE=crabbox-{slug}
CRABBOX_TAILSCALE_AUTH_KEY=<direct-provider only>
```
`tailscale.enabled` and `--tailscale` request tailnet join for newly created
managed Linux leases. `tailscale.network` and `--network` choose target
resolution for SSH-backed commands. Hostname templates support `{id}`, `{slug}`,
and `{provider}`.
Direct-provider mode reads the one-off auth key from `tailscale.authKeyEnv`.
Brokered mode does not require a local Tailscale key.
## Brokered Mode
The Worker mints a fresh auth key per requested lease using Tailscale OAuth.
Secrets live in Worker configuration:
```text
CRABBOX_TAILSCALE_CLIENT_ID
CRABBOX_TAILSCALE_CLIENT_SECRET
CRABBOX_TAILSCALE_TAILNET optional, defaults to -
CRABBOX_TAILSCALE_TAGS default/allowed comma-separated tags
CRABBOX_TAILSCALE_ENABLED set 0 to disable
```
Flow:
1. The CLI sends `tailscale`, `tailscaleTags`, and `tailscaleHostname` in
`CreateLease`.
2. The Worker validates requested tags against `CRABBOX_TAILSCALE_TAGS`.
3. The Worker uses OAuth to mint a one-off, ephemeral, pre-approved, tagged auth
key.
4. The key is injected only into cloud-init user-data.
5. The runner installs Tailscale, runs `tailscale up`, and writes non-secret
metadata under `/var/lib/crabbox`.
6. After SSH readiness, the CLI reads that metadata and posts it back to the
coordinator.
The auth key is never stored in lease records, provider labels, run logs, or
local config. User-data can still contain the short-lived key at the provider,
so use one-off ephemeral keys and avoid long-lived reusable keys.
## VNC And SSH
Crabbox continues to use OpenSSH and per-lease SSH keys. Tailscale SSH is not
enabled in v1.
Managed VNC remains loopback-bound:
```text
local localhost:5901 -> SSH -> remote 127.0.0.1:5900
```
Tailscale only changes the SSH endpoint from the public/provider host to the
tailnet host. Crabbox does not bind managed VNC to 100.x addresses, and does
not use Tailscale Serve, Funnel, or noVNC for managed leases.
## Static Hosts
Static hosts are operator-managed. Point `static.host` at a MagicDNS name or a
100.x address:
```yaml
provider: ssh
target: macos
static:
host: mac-studio.example.ts.net
user: steipete
port: "22"
workRoot: /Users/steipete/crabbox
```
For static hosts, `--network tailscale` is a reachability assertion. Crabbox
does not install or join Tailscale on the host.
## Tailscale References
- [Auth keys](https://tailscale.com/kb/1085/auth-keys)
- [Ephemeral nodes](https://tailscale.com/docs/features/ephemeral-nodes)
- [OAuth clients](https://tailscale.com/kb/1215/oauth-clients)
- [ACL tags](https://tailscale.com/kb/1068/acl-tags)
- [Secure auth-key CLI usage](https://tailscale.com/kb/1595/secure-auth-key-cli)
- [tailscale up flags](https://tailscale.com/kb/1241/tailscale-up)
Related docs:
- [Providers](providers.md)
- [Runner bootstrap](runner-bootstrap.md)
- [Interactive desktop and VNC](interactive-desktop-vnc.md)
- [Security](../security.md)

View File

@ -83,6 +83,28 @@ CRABBOX_GITHUB_ALLOWED_TEAMS
CRABBOX_SESSION_SECRET
```
Optional Tailscale brokered reachability uses a Tailscale OAuth client with the
`auth_keys` scope and only the tags Crabbox may assign, usually `tag:crabbox`.
Store OAuth credentials as Worker secrets:
```text
CRABBOX_TAILSCALE_CLIENT_ID
CRABBOX_TAILSCALE_CLIENT_SECRET
```
Optional Worker config:
```text
CRABBOX_TAILSCALE_ENABLED=1
CRABBOX_TAILSCALE_TAILNET=- # or explicit tailnet/org
CRABBOX_TAILSCALE_TAGS=tag:crabbox # allowlist/default tags
```
The Worker mints one-off ephemeral pre-approved auth keys per lease and injects
the key only into cloud-init. Lease records and provider labels store only
non-secret Tailscale metadata such as hostname, FQDN, 100.x address, state, and
tags.
Current local status:
- Core Cloudflare, Hetzner, and GitHub tokens are present in local `~/.profile`.

View File

@ -68,9 +68,16 @@ Rules:
- `CRABBOX_SHARED_TOKEN` is stored as a Worker secret for trusted operator automation; local automation can use `CRABBOX_COORDINATOR_TOKEN`.
- `CRABBOX_ADMIN_TOKEN` is stored as a Worker secret for admin and image lifecycle routes; local admin commands use `CRABBOX_COORDINATOR_ADMIN_TOKEN` or `broker.adminToken`.
- `CRABBOX_GITHUB_CLIENT_ID`, `CRABBOX_GITHUB_CLIENT_SECRET`, and `CRABBOX_SESSION_SECRET` are Worker secrets for browser login.
- `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_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.
- `CRABBOX_ACCESS_TEAM_DOMAIN` and `CRABBOX_ACCESS_AUD` let the Worker verify Cloudflare Access JWTs before using Access-provided identity.
- `CRABBOX_ACCESS_CLIENT_ID` and `CRABBOX_ACCESS_CLIENT_SECRET` are local Cloudflare Access service-token credentials. Store them only in user config or env, never repo config. They only satisfy Cloudflare Access; they do not authorize Crabbox actions by themselves.
- `CRABBOX_TAILSCALE_AUTH_KEY` is local direct-provider-only. Do not forward it
to commands, print it, or store it in repo config.
- User config files are written `0600`; `crabbox doctor` reports overly broad local config permissions because broker tokens may be stored there.
Project allowlist example:
@ -100,6 +107,13 @@ MVP SSH posture:
- Work happens under `/work/crabbox`.
- Machines are disposable or cleanable.
Tailscale does not replace this SSH model in v1. Crabbox still uses OpenSSH,
per-lease keys, scoped known_hosts, SSH tunnels, lease expiry, and cleanup.
Tailscale only changes which host the SSH client dials.
Managed VNC remains tunnel-only even on Tailscale-enabled leases. Do not bind
Crabbox-managed VNC to public interfaces or to the Tailscale 100.x interface.
MVP hardening before first shared use:
- Keep long-lived maintainer keys out of machine images.

View File

@ -13,6 +13,7 @@ This page maps user-facing behavior back to implementation files. Keep docs desc
- Command router and top-level help: `internal/cli/app.go`
- Shared flag parsing and exit helpers: `internal/cli/flags.go`, `internal/cli/errors.go`
- Config defaults, YAML keys, env overrides, target selection, and class maps: `internal/cli/config.go`, `internal/cli/target.go`
- Network target resolution and Tailscale metadata: `internal/cli/network.go`
- `crabbox init` generated repo files: `internal/cli/init.go`
- Login/logout/whoami/config commands: `internal/cli/auth.go`, `internal/cli/config_cmd.go`
- Doctor checks: `internal/cli/doctor.go`
@ -26,6 +27,7 @@ This page maps user-facing behavior back to implementation files. Keep docs desc
- Direct-provider labels, safe label encoding, idle touch labels, TTL cap math: `internal/cli/provider_labels.go`
- Coordinator client request/response structs, slug lookup, heartbeats, usage, run history: `internal/cli/coordinator.go`
- Worker lease records, public routes, slug allocation, heartbeat expiry math, alarms: `worker/src/fleet.ts`, `worker/src/types.ts`
- Worker Tailscale OAuth auth-key minting: `worker/src/tailscale.ts`
- Worker slug generation and provider labels: `worker/src/slug.ts`, `worker/src/provider-labels.ts`
## Providers And Runner Bootstrap
@ -39,6 +41,7 @@ This page maps user-facing behavior back to implementation files. Keep docs desc
- Worker AWS AMI create/read/promote routes: `worker/src/fleet.ts`, `worker/src/aws.ts`
- CLI cloud-init bootstrap: `internal/cli/bootstrap.go`
- Worker cloud-init bootstrap: `worker/src/bootstrap.ts`
- Tailscale feature contract: `docs/features/tailscale.md`
- Desktop/browser capability flags, env injection, and VNC checks: `internal/cli/capabilities.go`, `internal/cli/run.go`
- Desktop app launch into visible sessions: `internal/cli/desktop.go`
- VNC tunnel command: `internal/cli/vnc.go`

View File

@ -182,6 +182,9 @@ func (c *AWSClient) CreateServerWithFallback(ctx context.Context, cfg Config, pu
func (c *AWSClient) createServer(ctx context.Context, cfg Config, publicKey, leaseID, slug string, keep bool, imageID, securityGroupID string, spot bool) (Server, error) {
_ = publicKey
name := leaseProviderName(leaseID, slug)
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, "aws", mapMarket(spot), keep, now)
userData := base64.StdEncoding.EncodeToString([]byte(awsUserData(cfg, publicKey)))

View File

@ -306,6 +306,10 @@ chmod 0755 /usr/local/bin/crabbox-ready
func cloudInitOptionalReadyChecks(cfg Config) string {
var b strings.Builder
if cfg.Tailscale.Enabled {
b.WriteString(" test -s /var/lib/crabbox/tailscale-ipv4\n")
b.WriteString(" grep -Eq '^100\\.' /var/lib/crabbox/tailscale-ipv4\n")
}
if cfg.Desktop {
b.WriteString(" systemctl is-active --quiet crabbox-xvfb.service\n")
b.WriteString(" systemctl is-active --quiet crabbox-desktop.service\n")
@ -405,6 +409,9 @@ func cloudInitOptionalWriteFiles(cfg Config) string {
func cloudInitOptionalBootstrap(cfg Config) string {
var parts []string
if cfg.Tailscale.Enabled {
parts = append(parts, cloudInitTailscaleBootstrap(cfg))
}
if cfg.Desktop {
parts = append(parts, ` retry apt-get install -y --no-install-recommends xvfb xfce4 xfce4-terminal x11vnc xauth dbus-x11 x11-xserver-utils xterm scrot fonts-dejavu-core fonts-liberation iproute2 openssl
install -d -m 0750 -o crabbox -g crabbox /var/lib/crabbox
@ -447,3 +454,45 @@ func cloudInitOptionalBootstrap(cfg Config) string {
}
return strings.Join(parts, "\n")
}
func cloudInitTailscaleBootstrap(cfg Config) string {
authKey := strings.TrimSpace(cfg.Tailscale.AuthKey)
hostname := strings.TrimSpace(cfg.Tailscale.Hostname)
if hostname == "" {
hostname = renderTailscaleHostname(cfg.Tailscale.HostnameTemplate, "", "lease", cfg.Provider)
}
sshUser := strings.TrimSpace(cfg.SSHUser)
if sshUser == "" {
sshUser = "crabbox"
}
sshUserOwner := shellQuote(sshUser)
sshUserGroup := shellQuote(sshUser)
sshUserChown := shellQuote(sshUser + ":" + sshUser)
tags := strings.Join(cfg.Tailscale.Tags, ",")
if authKey == "" {
return ` echo "tailscale requested but no auth key was injected" >&2
exit 1`
}
return ` retry sh -c 'curl -fsSL https://tailscale.com/install.sh | sh'
systemctl enable --now tailscaled || service tailscaled start || true
install -d -m 0750 -o ` + sshUserOwner + ` -g ` + sshUserGroup + ` /var/lib/crabbox
set +x
TS_AUTHKEY=` + shellQuote(authKey) + `
tailscale up --auth-key="$TS_AUTHKEY" --hostname=` + shellQuote(hostname) + ` --advertise-tags=` + shellQuote(tags) + `
unset TS_AUTHKEY
set -x
ts_ip=""
for _ in $(seq 1 24); do
ts_ip="$(tailscale ip -4 2>/dev/null | head -n1 || true)"
if [ -n "$ts_ip" ]; then break; fi
sleep 5
done
test -n "$ts_ip"
printf '%s\n' "$ts_ip" > /var/lib/crabbox/tailscale-ipv4
printf '%s\n' ` + shellQuote(hostname) + ` > /var/lib/crabbox/tailscale-hostname
if tailscale status --json >/var/lib/crabbox/tailscale-status.json 2>/dev/null; then
jq -r '.Self.DNSName // empty' /var/lib/crabbox/tailscale-status.json > /var/lib/crabbox/tailscale-fqdn || true
fi
chown ` + sshUserChown + ` /var/lib/crabbox/tailscale-* || true
chmod 0640 /var/lib/crabbox/tailscale-* || true`
}

View File

@ -82,6 +82,32 @@ func TestCloudInitBrowserProfile(t *testing.T) {
}
}
func TestCloudInitTailscaleProfile(t *testing.T) {
cfg := baseConfig()
cfg.SSHUser = "runner"
cfg.Tailscale.Enabled = true
cfg.Tailscale.AuthKey = "tskey-secret"
cfg.Tailscale.Hostname = "crabbox-blue-lobster"
cfg.Tailscale.Tags = []string{"tag:crabbox"}
got := cloudInit(cfg, "ssh-ed25519 test")
for _, want := range []string{
"https://tailscale.com/install.sh",
"install -d -m 0750 -o 'runner' -g 'runner' /var/lib/crabbox",
"tailscale up --auth-key=\"$TS_AUTHKEY\" --hostname='crabbox-blue-lobster' --advertise-tags='tag:crabbox'",
"printf '%s\\n' 'crabbox-blue-lobster' > /var/lib/crabbox/tailscale-hostname",
"chown 'runner:runner' /var/lib/crabbox/tailscale-* || true",
"test -s /var/lib/crabbox/tailscale-ipv4",
"grep -Eq '^100\\.' /var/lib/crabbox/tailscale-ipv4",
} {
if !strings.Contains(got, want) {
t.Fatalf("cloudInit(tailscale) missing %q", want)
}
}
if strings.Contains(cloudInit(baseConfig(), "ssh-ed25519 test"), "tailscale up") {
t.Fatal("cloudInit should not install Tailscale by default")
}
}
func TestAWSUserDataWindowsProfile(t *testing.T) {
cfg := baseConfig()
cfg.Provider = "aws"

View File

@ -19,6 +19,7 @@ type Config struct {
WindowsMode string
Desktop bool
Browser bool
Network NetworkMode
Class string
ServerType string
ServerTypeExplicit bool
@ -49,6 +50,7 @@ type Config struct {
Capacity CapacityConfig
Actions ActionsConfig
Blacksmith BlacksmithConfig
Tailscale TailscaleConfig
Static StaticConfig
Results ResultsConfig
Cache CacheConfig
@ -145,6 +147,9 @@ func loadConfig() (Config, error) {
if err := validateTargetConfig(cfg); err != nil {
return Config{}, err
}
if err := validateNetworkConfig(cfg); err != nil {
return Config{}, err
}
if cfg.ServerType == "" {
cfg.ServerType = serverTypeForConfig(cfg)
}
@ -165,6 +170,7 @@ func baseConfig() Config {
Provider: provider,
TargetOS: "linux",
WindowsMode: "normal",
Network: NetworkAuto,
Class: class,
ServerType: "",
Location: "fsn1",
@ -200,6 +206,11 @@ func baseConfig() Config {
RunnerVersion: "latest",
Ephemeral: true,
},
Tailscale: TailscaleConfig{
Tags: []string{"tag:crabbox"},
HostnameTemplate: "crabbox-{slug}",
AuthKeyEnv: "CRABBOX_TAILSCALE_AUTH_KEY",
},
Cache: CacheConfig{
Pnpm: true,
Npm: true,
@ -218,6 +229,7 @@ type fileConfig struct {
Windows *fileWindowsConfig `yaml:"windows,omitempty"`
Desktop *bool `yaml:"desktop,omitempty"`
Browser *bool `yaml:"browser,omitempty"`
Network string `yaml:"network,omitempty"`
Class string `yaml:"class,omitempty"`
ServerType string `yaml:"serverType,omitempty"`
Coordinator string `yaml:"coordinator,omitempty"`
@ -231,6 +243,7 @@ type fileConfig struct {
Capacity *fileCapacityConfig `yaml:"capacity,omitempty"`
Actions *fileActionsConfig `yaml:"actions,omitempty"`
Blacksmith *fileBlacksmithConfig `yaml:"blacksmith,omitempty"`
Tailscale *fileTailscaleConfig `yaml:"tailscale,omitempty"`
Static *fileStaticConfig `yaml:"static,omitempty"`
Results *fileResultsConfig `yaml:"results,omitempty"`
Cache *fileCacheConfig `yaml:"cache,omitempty"`
@ -330,6 +343,14 @@ type fileBlacksmithConfig struct {
Debug *bool `yaml:"debug,omitempty"`
}
type fileTailscaleConfig struct {
Enabled *bool `yaml:"enabled,omitempty"`
Network string `yaml:"network,omitempty"`
Tags []string `yaml:"tags,omitempty"`
HostnameTemplate string `yaml:"hostnameTemplate,omitempty"`
AuthKeyEnv string `yaml:"authKeyEnv,omitempty"`
}
type fileStaticConfig struct {
ID string `yaml:"id,omitempty"`
Name string `yaml:"name,omitempty"`
@ -475,6 +496,9 @@ func applyFileConfig(cfg *Config, file fileConfig) {
if file.Browser != nil {
cfg.Browser = *file.Browser
}
if file.Network != "" {
cfg.Network = NetworkMode(strings.ToLower(strings.TrimSpace(file.Network)))
}
if file.Class != "" {
cfg.Class = file.Class
}
@ -675,6 +699,23 @@ func applyFileConfig(cfg *Config, file fileConfig) {
cfg.Blacksmith.Debug = *file.Blacksmith.Debug
}
}
if file.Tailscale != nil {
if file.Tailscale.Enabled != nil {
cfg.Tailscale.Enabled = *file.Tailscale.Enabled
}
if file.Tailscale.Network != "" {
cfg.Network = NetworkMode(strings.ToLower(strings.TrimSpace(file.Tailscale.Network)))
}
if len(file.Tailscale.Tags) > 0 {
cfg.Tailscale.Tags = normalizeTailscaleTags(file.Tailscale.Tags)
}
if file.Tailscale.HostnameTemplate != "" {
cfg.Tailscale.HostnameTemplate = file.Tailscale.HostnameTemplate
}
if file.Tailscale.AuthKeyEnv != "" {
cfg.Tailscale.AuthKeyEnv = file.Tailscale.AuthKeyEnv
}
}
if file.Static != nil {
if file.Static.ID != "" {
cfg.Static.ID = file.Static.ID
@ -740,6 +781,9 @@ func applyEnv(cfg *Config) {
if value, ok := getenvBool("CRABBOX_BROWSER"); ok {
cfg.Browser = value
}
if network := os.Getenv("CRABBOX_NETWORK"); network != "" {
cfg.Network = NetworkMode(strings.ToLower(strings.TrimSpace(network)))
}
cfg.Class = getenv("CRABBOX_DEFAULT_CLASS", cfg.Class)
if os.Getenv("CRABBOX_SERVER_TYPE") != "" {
cfg.ServerTypeExplicit = true
@ -789,6 +833,17 @@ func applyEnv(cfg *Config) {
cfg.Blacksmith.Workflow = getenv("CRABBOX_BLACKSMITH_WORKFLOW", cfg.Blacksmith.Workflow)
cfg.Blacksmith.Job = getenv("CRABBOX_BLACKSMITH_JOB", cfg.Blacksmith.Job)
cfg.Blacksmith.Ref = getenv("CRABBOX_BLACKSMITH_REF", cfg.Blacksmith.Ref)
if value, ok := getenvBool("CRABBOX_TAILSCALE"); ok {
cfg.Tailscale.Enabled = value
}
if tags := os.Getenv("CRABBOX_TAILSCALE_TAGS"); tags != "" {
cfg.Tailscale.Tags = normalizeTailscaleTags(splitCommaList(tags))
}
cfg.Tailscale.HostnameTemplate = getenv("CRABBOX_TAILSCALE_HOSTNAME_TEMPLATE", cfg.Tailscale.HostnameTemplate)
cfg.Tailscale.AuthKeyEnv = getenv("CRABBOX_TAILSCALE_AUTH_KEY_ENV", cfg.Tailscale.AuthKeyEnv)
if cfg.Tailscale.AuthKeyEnv != "" {
cfg.Tailscale.AuthKey = getenv(cfg.Tailscale.AuthKeyEnv, "")
}
cfg.Static.ID = getenv("CRABBOX_STATIC_ID", cfg.Static.ID)
cfg.Static.Name = getenv("CRABBOX_STATIC_NAME", cfg.Static.Name)
cfg.Static.Host = getenv("CRABBOX_STATIC_HOST", cfg.Static.Host)

View File

@ -14,6 +14,12 @@ func clearConfigEnv(t *testing.T) {
"CRABBOX_COORDINATOR_TOKEN",
"CRABBOX_COORDINATOR_ADMIN_TOKEN",
"CRABBOX_ADMIN_TOKEN",
"CRABBOX_NETWORK",
"CRABBOX_TAILSCALE",
"CRABBOX_TAILSCALE_TAGS",
"CRABBOX_TAILSCALE_HOSTNAME_TEMPLATE",
"CRABBOX_TAILSCALE_AUTH_KEY_ENV",
"CRABBOX_TAILSCALE_AUTH_KEY",
"CRABBOX_ACCESS_CLIENT_ID",
"CRABBOX_ACCESS_CLIENT_SECRET",
"CRABBOX_ACCESS_TOKEN",
@ -200,6 +206,42 @@ ssh:
}
}
func TestLoadConfigTailscaleBlock(t *testing.T) {
clearConfigEnv(t)
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
t.Setenv("CRABBOX_CONFIG", "")
path := userConfigPath()
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(`provider: aws
network: public
tailscale:
enabled: true
network: tailscale
tags:
- tag:crabbox
- tag:ci
hostnameTemplate: cbx-{slug}
authKeyEnv: TEST_TS_AUTH_KEY
`), 0o600); err != nil {
t.Fatal(err)
}
cfg, err := loadConfig()
if err != nil {
t.Fatal(err)
}
if !cfg.Tailscale.Enabled || cfg.Network != NetworkTailscale || cfg.Tailscale.HostnameTemplate != "cbx-{slug}" || cfg.Tailscale.AuthKeyEnv != "TEST_TS_AUTH_KEY" {
t.Fatalf("tailscale config not loaded: network=%s tailscale=%#v", cfg.Network, cfg.Tailscale)
}
if len(cfg.Tailscale.Tags) != 2 || cfg.Tailscale.Tags[1] != "tag:ci" {
t.Fatalf("tailscale tags not loaded: %#v", cfg.Tailscale.Tags)
}
}
func TestEnvOverridesConfig(t *testing.T) {
clearConfigEnv(t)
home := t.TempDir()
@ -216,6 +258,10 @@ func TestEnvOverridesConfig(t *testing.T) {
t.Setenv("CRABBOX_ACCESS_CLIENT_SECRET", "env-access-secret")
t.Setenv("CRABBOX_ACCESS_TOKEN", "env-access-jwt")
t.Setenv("CRABBOX_COORDINATOR_ADMIN_TOKEN", "env-admin-secret")
t.Setenv("CRABBOX_NETWORK", "public")
t.Setenv("CRABBOX_TAILSCALE_TAGS", "tag:crabbox,tag:ci")
t.Setenv("CRABBOX_TAILSCALE_HOSTNAME_TEMPLATE", "lease-{id}")
t.Setenv("CRABBOX_TAILSCALE_AUTH_KEY", "tskey-secret")
t.Setenv("CRABBOX_TARGET", "macos")
t.Setenv("CRABBOX_STATIC_HOST", "mac.local")
path := userConfigPath()
@ -248,6 +294,68 @@ func TestEnvOverridesConfig(t *testing.T) {
if cfg.TargetOS != targetMacOS || cfg.Static.Host != "mac.local" {
t.Fatalf("unexpected target env: target=%s static=%#v", cfg.TargetOS, cfg.Static)
}
if cfg.Network != NetworkPublic || cfg.Tailscale.AuthKey != "tskey-secret" || cfg.Tailscale.HostnameTemplate != "lease-{id}" {
t.Fatalf("unexpected tailscale env: network=%s tailscale=%#v", cfg.Network, cfg.Tailscale)
}
if len(cfg.Tailscale.Tags) != 2 || cfg.Tailscale.Tags[1] != "tag:ci" {
t.Fatalf("unexpected tailscale tags: %#v", cfg.Tailscale.Tags)
}
}
func TestTailscaleEnvOverrides(t *testing.T) {
clearConfigEnv(t)
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
t.Setenv("CRABBOX_CONFIG", "")
t.Setenv("CRABBOX_PROVIDER", "hetzner")
t.Setenv("CRABBOX_NETWORK", "tailscale")
t.Setenv("CRABBOX_TAILSCALE", "1")
t.Setenv("CRABBOX_TAILSCALE_TAGS", "tag:crabbox,tag:ci")
t.Setenv("CRABBOX_TAILSCALE_HOSTNAME_TEMPLATE", "lease-{slug}")
t.Setenv("CRABBOX_TAILSCALE_AUTH_KEY", "tskey-secret")
cfg, err := loadConfig()
if err != nil {
t.Fatal(err)
}
if cfg.Network != NetworkTailscale || !cfg.Tailscale.Enabled || cfg.Tailscale.AuthKey != "tskey-secret" || cfg.Tailscale.HostnameTemplate != "lease-{slug}" {
t.Fatalf("unexpected tailscale env: network=%s tailscale=%#v", cfg.Network, cfg.Tailscale)
}
if len(cfg.Tailscale.Tags) != 2 || cfg.Tailscale.Tags[1] != "tag:ci" {
t.Fatalf("unexpected tailscale tags: %#v", cfg.Tailscale.Tags)
}
}
func TestInvalidNetworkConfigFails(t *testing.T) {
clearConfigEnv(t)
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
t.Setenv("CRABBOX_CONFIG", "")
path := userConfigPath()
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte("network: private\n"), 0o600); err != nil {
t.Fatal(err)
}
if _, err := loadConfig(); err == nil {
t.Fatal("expected invalid network config to fail")
}
}
func TestInvalidNetworkEnvFails(t *testing.T) {
clearConfigEnv(t)
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
t.Setenv("CRABBOX_CONFIG", "")
t.Setenv("CRABBOX_NETWORK", "tailnet")
if _, err := loadConfig(); err == nil {
t.Fatal("expected invalid CRABBOX_NETWORK to fail")
}
}
func TestAccessAuthState(t *testing.T) {

View File

@ -32,6 +32,7 @@ type CoordinatorLease struct {
WindowsMode string `json:"windowsMode,omitempty"`
Desktop bool `json:"desktop,omitempty"`
Browser bool `json:"browser,omitempty"`
Tailscale *TailscaleMetadata `json:"tailscale,omitempty"`
Owner string `json:"owner"`
Org string `json:"org"`
Profile string `json:"profile"`
@ -314,6 +315,9 @@ func (c *CoordinatorClient) CreateLease(ctx context.Context, cfg Config, publicK
"windowsMode": cfg.WindowsMode,
"desktop": cfg.Desktop,
"browser": cfg.Browser,
"tailscale": cfg.Tailscale.Enabled,
"tailscaleTags": cfg.Tailscale.Tags,
"tailscaleHostname": cfg.Tailscale.Hostname,
"class": cfg.Class,
"serverType": cfg.ServerType,
"serverTypeExplicit": cfg.ServerTypeExplicit,
@ -347,6 +351,14 @@ func (c *CoordinatorClient) CreateLease(ctx context.Context, cfg Config, publicK
return res.Lease, err
}
func (c *CoordinatorClient) UpdateLeaseTailscale(ctx context.Context, id string, meta TailscaleMetadata) (CoordinatorLease, error) {
var res struct {
Lease CoordinatorLease `json:"lease"`
}
err := c.do(ctx, http.MethodPost, "/v1/leases/"+url.PathEscape(id)+"/tailscale", meta, &res)
return res.Lease, err
}
func (c *CoordinatorClient) GetLease(ctx context.Context, id string) (CoordinatorLease, error) {
var res struct {
Lease CoordinatorLease `json:"lease"`
@ -839,6 +851,9 @@ func leaseToServerTarget(lease CoordinatorLease, cfg Config) (Server, SSHTarget,
"idle_timeout_secs": fmt.Sprint(lease.IdleTimeoutSeconds),
},
}
if lease.Tailscale != nil {
applyTailscaleMetadataToServer(&server, *lease.Tailscale)
}
if server.Provider == "" {
server.Provider = cfg.Provider
}

View File

@ -171,6 +171,9 @@ func (c *HetznerClient) DeleteSSHKey(ctx context.Context, name string) error {
func (c *HetznerClient) CreateServer(ctx context.Context, cfg Config, publicKey, leaseID, slug string, keep bool) (Server, error) {
name := leaseProviderName(leaseID, slug)
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, "hetzner", "", keep, now)
body := map[string]any{

View File

@ -13,6 +13,7 @@ func (a App) inspect(ctx context.Context, args []string) error {
id := fs.String("id", "", "lease id or slug")
jsonOut := fs.Bool("json", false, "print JSON")
targetFlags := registerTargetFlags(fs, defaultConfig())
networkFlags := registerNetworkModeFlag(fs, defaultConfig())
if err := parseFlags(fs, args); err != nil {
return err
}
@ -27,6 +28,9 @@ func (a App) inspect(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 *id == "" && !isStaticProvider(cfg.Provider) {
return exit(2, "usage: crabbox inspect --id <lease-id-or-slug>")
}
@ -37,7 +41,10 @@ func (a App) inspect(ctx context.Context, args []string) error {
if *jsonOut {
return json.NewEncoder(a.Stdout).Encode(state)
}
fmt.Fprintf(a.Stdout, "id=%s\nslug=%s\nprovider=%s\ntarget=%s\nwindows_mode=%s\nstate=%s\nserver=%s\nhost=%s\nssh=%s -p %s %s@%s\nssh_fallback_ports=%s\nidle_for=%s\nidle_timeout=%s\nlast_touched=%s\nexpires=%s\n", state.ID, blank(state.Slug, "-"), state.Provider, state.TargetOS, blank(state.WindowsMode, "-"), state.State, state.ServerID, state.Host, state.SSHKey, state.SSHPort, state.SSHUser, state.Host, blank(strings.Join(state.SSHFallbackPorts, ","), "-"), blank(state.IdleFor, "-"), blank(state.IdleTimeout, "-"), blank(state.LastTouchedAt, "-"), blank(state.ExpiresAt, "-"))
fmt.Fprintf(a.Stdout, "id=%s\nslug=%s\nprovider=%s\ntarget=%s\nwindows_mode=%s\nstate=%s\nserver=%s\nhost=%s\nnetwork=%s\nssh=%s -p %s %s@%s\nssh_fallback_ports=%s\nidle_for=%s\nidle_timeout=%s\nlast_touched=%s\nexpires=%s\n", state.ID, blank(state.Slug, "-"), state.Provider, state.TargetOS, blank(state.WindowsMode, "-"), state.State, state.ServerID, state.Host, state.Network, state.SSHKey, state.SSHPort, state.SSHUser, state.SSHHost, blank(strings.Join(state.SSHFallbackPorts, ","), "-"), blank(state.IdleFor, "-"), blank(state.IdleTimeout, "-"), blank(state.LastTouchedAt, "-"), blank(state.ExpiresAt, "-"))
if state.Tailscale != nil && state.Tailscale.Enabled {
fmt.Fprintf(a.Stdout, "tailscale.state=%s\ntailscale.hostname=%s\ntailscale.fqdn=%s\ntailscale.ipv4=%s\ntailscale.tags=%s\n", blank(state.Tailscale.State, "-"), blank(state.Tailscale.Hostname, "-"), blank(state.Tailscale.FQDN, "-"), blank(state.Tailscale.IPv4, "-"), blank(strings.Join(state.Tailscale.Tags, ","), "-"))
}
for key, value := range state.Labels {
fmt.Fprintf(a.Stdout, "label.%s=%s\n", key, value)
}

335
internal/cli/network.go Normal file
View File

@ -0,0 +1,335 @@
package cli
import (
"context"
"flag"
"fmt"
"strings"
"time"
)
type NetworkMode string
const (
NetworkAuto NetworkMode = "auto"
NetworkTailscale NetworkMode = "tailscale"
NetworkPublic NetworkMode = "public"
)
type TailscaleConfig struct {
Enabled bool
Tags []string
HostnameTemplate string
Hostname string
AuthKeyEnv string
AuthKey string
}
type TailscaleMetadata struct {
Enabled bool `json:"enabled"`
Hostname string `json:"hostname,omitempty"`
FQDN string `json:"fqdn,omitempty"`
IPv4 string `json:"ipv4,omitempty"`
Tags []string `json:"tags,omitempty"`
State string `json:"state,omitempty"`
Error string `json:"error,omitempty"`
}
type networkFlagValues struct {
Network *string
Tailscale *bool
TailscaleTags *string
TailscaleHost *string
TailscaleKeyEnv *string
}
type networkModeFlagValues struct {
Network *string
}
type resolvedNetworkTarget struct {
Target SSHTarget
Network NetworkMode
FallbackReason string
}
func registerNetworkModeFlag(fs *flag.FlagSet, defaults Config) networkModeFlagValues {
return networkModeFlagValues{
Network: fs.String("network", string(defaults.Network), "network mode: auto, tailscale, or public"),
}
}
func applyNetworkModeFlagOverride(cfg *Config, fs *flag.FlagSet, values networkModeFlagValues) error {
if flagWasSet(fs, "network") {
mode, err := parseNetworkMode(*values.Network)
if err != nil {
return err
}
cfg.Network = mode
}
if _, err := parseNetworkMode(string(cfg.Network)); err != nil {
return err
}
return nil
}
func registerNetworkFlags(fs *flag.FlagSet, defaults Config) networkFlagValues {
return networkFlagValues{
Network: fs.String("network", string(defaults.Network), "network mode: auto, tailscale, or public"),
Tailscale: fs.Bool("tailscale", defaults.Tailscale.Enabled, "join new managed leases to the configured tailnet"),
TailscaleTags: fs.String("tailscale-tags", strings.Join(defaults.Tailscale.Tags, ","), "comma-separated Tailscale tags for new managed leases"),
TailscaleHost: fs.String("tailscale-hostname-template", defaults.Tailscale.HostnameTemplate, "Tailscale hostname template for new managed leases"),
TailscaleKeyEnv: fs.String("tailscale-auth-key-env", defaults.Tailscale.AuthKeyEnv, "environment variable containing a direct-provider Tailscale auth key"),
}
}
func applyNetworkFlagOverrides(cfg *Config, fs *flag.FlagSet, values networkFlagValues) error {
if flagWasSet(fs, "network") {
mode, err := parseNetworkMode(*values.Network)
if err != nil {
return err
}
cfg.Network = mode
}
if flagWasSet(fs, "tailscale") {
cfg.Tailscale.Enabled = *values.Tailscale
}
if flagWasSet(fs, "tailscale-tags") {
cfg.Tailscale.Tags = normalizeTailscaleTags(splitCommaList(*values.TailscaleTags))
}
if flagWasSet(fs, "tailscale-hostname-template") {
cfg.Tailscale.HostnameTemplate = strings.TrimSpace(*values.TailscaleHost)
}
if flagWasSet(fs, "tailscale-auth-key-env") {
cfg.Tailscale.AuthKeyEnv = strings.TrimSpace(*values.TailscaleKeyEnv)
cfg.Tailscale.AuthKey = getenv(cfg.Tailscale.AuthKeyEnv, "")
}
return validateNetworkConfig(*cfg)
}
func parseNetworkMode(value string) (NetworkMode, error) {
switch NetworkMode(strings.ToLower(strings.TrimSpace(value))) {
case "", NetworkAuto:
return NetworkAuto, nil
case NetworkTailscale:
return NetworkTailscale, nil
case NetworkPublic:
return NetworkPublic, nil
default:
return "", exit(2, "network must be auto, tailscale, or public")
}
}
func validateNetworkConfig(cfg Config) error {
if _, err := parseNetworkMode(string(cfg.Network)); err != nil {
return err
}
if cfg.Tailscale.Enabled {
if len(cfg.Tailscale.Tags) == 0 {
return exit(2, "tailscale.tags must include at least one tag")
}
for _, tag := range cfg.Tailscale.Tags {
if !validTailscaleTag(tag) {
return exit(2, "invalid Tailscale tag %q; tags must look like tag:crabbox", tag)
}
}
if strings.TrimSpace(cfg.Tailscale.HostnameTemplate) == "" {
return exit(2, "tailscale.hostnameTemplate must not be empty")
}
if cfg.TargetOS != targetLinux {
return exit(2, "--tailscale managed provisioning currently supports target=linux only")
}
if isBlacksmithProvider(cfg.Provider) {
return exit(2, "--tailscale is not supported for provider=%s; Blacksmith owns machine connectivity", cfg.Provider)
}
if isStaticProvider(cfg.Provider) {
return exit(2, "--tailscale only provisions managed leases; set static.host to a MagicDNS name or 100.x address and use --network tailscale")
}
}
return nil
}
func normalizeTailscaleTags(values []string) []string {
out := make([]string, 0, len(values))
for _, value := range values {
value = strings.ToLower(strings.TrimSpace(value))
if value == "" {
continue
}
out = append(out, value)
}
return appendUniqueStrings(nil, out...)
}
func validTailscaleTag(value string) bool {
if !strings.HasPrefix(value, "tag:") {
return false
}
name := strings.TrimPrefix(value, "tag:")
if name == "" || len(name) > 63 {
return false
}
for _, r := range name {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' || r == '_' {
continue
}
return false
}
return true
}
func renderTailscaleHostname(template, leaseID, slug, provider string) string {
value := strings.TrimSpace(template)
if value == "" {
value = "crabbox-{slug}"
}
replacements := map[string]string{
"{id}": strings.ReplaceAll(leaseID, "_", "-"),
"{slug}": normalizeLeaseSlug(slug),
"{provider}": normalizeLeaseSlug(provider),
}
for key, replacement := range replacements {
value = strings.ReplaceAll(value, key, replacement)
}
value = normalizeLeaseSlug(value)
if value == "" {
value = "crabbox-" + strings.ReplaceAll(leaseID, "_", "-")
}
return value
}
func resolveNetworkTarget(ctx context.Context, cfg Config, server Server, target SSHTarget) (resolvedNetworkTarget, error) {
meta := serverTailscaleMetadata(server)
switch cfg.Network {
case NetworkPublic:
return resolvedNetworkTarget{Target: target, Network: NetworkPublic}, nil
case NetworkTailscale:
host := tailscaleTargetHost(meta)
if host == "" {
if isStaticProvider(cfg.Provider) || server.Provider == staticProvider {
if !probeSSHTransport(ctx, &target, 6*time.Second) {
return resolvedNetworkTarget{}, exit(5, "network=tailscale requested for static host %s but SSH is not reachable; is this client joined to the tailnet?", target.Host)
}
return resolvedNetworkTarget{Target: target, Network: NetworkTailscale}, nil
}
return resolvedNetworkTarget{}, exit(5, "network=tailscale requested but lease %s has no tailnet address", blank(server.Labels["lease"], server.Name))
}
next := target
next.Host = host
if !probeSSHTransport(ctx, &next, 6*time.Second) {
return resolvedNetworkTarget{}, exit(5, "network=tailscale requested but %s is not reachable over SSH; is this client joined to the tailnet?", host)
}
return resolvedNetworkTarget{Target: next, Network: NetworkTailscale}, nil
default:
host := tailscaleTargetHost(meta)
if host == "" {
return resolvedNetworkTarget{Target: target, Network: NetworkPublic}, nil
}
next := target
next.Host = host
if probeSSHTransport(ctx, &next, 5*time.Second) {
return resolvedNetworkTarget{Target: next, Network: NetworkTailscale}, nil
}
return resolvedNetworkTarget{Target: target, Network: NetworkPublic, FallbackReason: "tailscale_unreachable"}, nil
}
}
func tailscaleTargetHost(meta TailscaleMetadata) string {
return firstNonEmpty(meta.FQDN, meta.IPv4, meta.Hostname)
}
func serverTailscaleMetadata(server Server) TailscaleMetadata {
labels := server.Labels
meta := TailscaleMetadata{
Enabled: labelBool(labels["tailscale"]),
Hostname: labels["tailscale_hostname"],
FQDN: labels["tailscale_fqdn"],
IPv4: labels["tailscale_ipv4"],
State: labels["tailscale_state"],
Error: labels["tailscale_error"],
}
if tags := splitCommaList(labels["tailscale_tags"]); len(tags) > 0 {
meta.Tags = tags
}
return meta
}
func applyTailscaleMetadataToServer(server *Server, meta TailscaleMetadata) {
if server.Labels == nil {
server.Labels = map[string]string{}
}
if meta.Enabled {
server.Labels["tailscale"] = "true"
}
if meta.Hostname != "" {
server.Labels["tailscale_hostname"] = meta.Hostname
}
if meta.FQDN != "" {
server.Labels["tailscale_fqdn"] = meta.FQDN
}
if meta.IPv4 != "" {
server.Labels["tailscale_ipv4"] = meta.IPv4
}
if len(meta.Tags) > 0 {
server.Labels["tailscale_tags"] = strings.Join(meta.Tags, ",")
}
if meta.State != "" {
server.Labels["tailscale_state"] = meta.State
}
if meta.Error != "" {
server.Labels["tailscale_error"] = meta.Error
}
}
func (a App) refreshTailscaleMetadata(ctx context.Context, cfg Config, coord *CoordinatorClient, useCoordinator bool, server *Server, target SSHTarget, leaseID string) {
if server == nil || !serverTailscaleMetadata(*server).Enabled {
return
}
meta, err := readRemoteTailscaleMetadata(ctx, target)
if err != nil {
meta = serverTailscaleMetadata(*server)
meta.State = "failed"
meta.Error = err.Error()
fmt.Fprintf(a.Stderr, "warning: tailscale metadata unavailable for %s: %v\n", leaseID, err)
} else {
existing := serverTailscaleMetadata(*server)
meta.Hostname = firstNonEmpty(meta.Hostname, existing.Hostname)
meta.FQDN = firstNonEmpty(meta.FQDN, existing.FQDN)
meta.Tags = appendUniqueStrings(existing.Tags, meta.Tags...)
}
applyTailscaleMetadataToServer(server, meta)
if useCoordinator && coord != nil && leaseID != "" {
if lease, err := coord.UpdateLeaseTailscale(ctx, leaseID, meta); err == nil {
updated, _, _ := leaseToServerTarget(lease, cfg)
*server = updated
} else {
fmt.Fprintf(a.Stderr, "warning: tailscale metadata update failed for %s: %v\n", leaseID, err)
}
}
}
func readRemoteTailscaleMetadata(ctx context.Context, target SSHTarget) (TailscaleMetadata, error) {
out, err := runSSHOutput(ctx, target, `if [ -f /var/lib/crabbox/tailscale-ipv4 ]; then cat /var/lib/crabbox/tailscale-ipv4; fi
printf '\n'
if [ -f /var/lib/crabbox/tailscale-hostname ]; then cat /var/lib/crabbox/tailscale-hostname; fi
printf '\n'
if [ -f /var/lib/crabbox/tailscale-fqdn ]; then cat /var/lib/crabbox/tailscale-fqdn; fi`)
if err != nil {
return TailscaleMetadata{}, err
}
lines := strings.Split(out, "\n")
meta := TailscaleMetadata{Enabled: true, State: "ready"}
if len(lines) > 0 {
meta.IPv4 = strings.TrimSpace(lines[0])
}
if len(lines) > 1 {
meta.Hostname = strings.TrimSpace(lines[1])
}
if len(lines) > 2 {
meta.FQDN = strings.TrimSpace(lines[2])
}
if meta.IPv4 == "" {
return TailscaleMetadata{}, fmt.Errorf("remote tailscale metadata missing ipv4")
}
return meta, nil
}

View File

@ -0,0 +1,49 @@
package cli
import (
"context"
"testing"
)
func TestNetworkPublicIgnoresTailscaleMetadata(t *testing.T) {
cfg := baseConfig()
cfg.Network = NetworkPublic
server := Server{Labels: map[string]string{
"lease": "cbx_abcdef123456",
"tailscale": "true",
"tailscale_fqdn": "crabbox-blue.example.ts.net",
}}
target := SSHTarget{Host: "203.0.113.10", Port: "2222"}
got, err := resolveNetworkTarget(context.Background(), cfg, server, target)
if err != nil {
t.Fatal(err)
}
if got.Network != NetworkPublic || got.Target.Host != "203.0.113.10" {
t.Fatalf("resolve public = network=%s host=%s", got.Network, got.Target.Host)
}
}
func TestNetworkTailscaleRequiresMetadata(t *testing.T) {
cfg := baseConfig()
cfg.Network = NetworkTailscale
_, err := resolveNetworkTarget(context.Background(), cfg, Server{Labels: map[string]string{"lease": "cbx_abcdef123456"}}, SSHTarget{Host: "203.0.113.10"})
if err == nil {
t.Fatal("expected network=tailscale without metadata to fail")
}
}
func TestRenderTailscaleHostname(t *testing.T) {
got := renderTailscaleHostname("CBX-{slug}-{provider}-{id}", "cbx_abcdef123456", "Blue Lobster", "aws")
if got != "cbx-blue-lobster-aws-cbx-abcdef123456" {
t.Fatalf("renderTailscaleHostname=%q", got)
}
}
func TestValidateNetworkConfigRejectsStaticProvisioning(t *testing.T) {
cfg := baseConfig()
cfg.Provider = "ssh"
cfg.Tailscale.Enabled = true
if err := validateNetworkConfig(cfg); err == nil {
t.Fatal("expected --tailscale static provider validation failure")
}
}

View File

@ -41,6 +41,12 @@ func directLeaseLabels(cfg Config, leaseID, slug, provider, market string, keep
if cfg.Browser {
labels["browser"] = "true"
}
if cfg.Tailscale.Enabled {
labels["tailscale"] = "true"
labels["tailscale_state"] = "requested"
labels["tailscale_hostname"] = cfg.Tailscale.Hostname
labels["tailscale_tags"] = strings.Join(cfg.Tailscale.Tags, ",")
}
return sanitizeProviderLabels(labels)
}

View File

@ -2,6 +2,7 @@ package cli
import (
"regexp"
"strings"
"testing"
"time"
)
@ -42,6 +43,26 @@ func TestDirectLeaseLabelsAreProviderSafe(t *testing.T) {
}
}
func TestDirectLeaseLabelsIncludeNonSecretTailscaleMetadata(t *testing.T) {
cfg := baseConfig()
cfg.Tailscale.Enabled = true
cfg.Tailscale.Hostname = "crabbox-blue-lobster"
cfg.Tailscale.Tags = []string{"tag:crabbox"}
cfg.Tailscale.AuthKey = "tskey-secret"
labels := directLeaseLabels(cfg, "cbx_abcdef123456", "blue-lobster", "hetzner", "", true, time.Now())
if labels["tailscale"] != "true" || labels["tailscale_state"] != "requested" {
t.Fatalf("tailscale labels missing: %#v", labels)
}
if labels["tailscale_hostname"] != "crabbox-blue-lobster" || labels["tailscale_tags"] != "tag_crabbox" {
t.Fatalf("tailscale metadata labels unexpected: %#v", labels)
}
for key, value := range labels {
if strings.Contains(value, "tskey-secret") || strings.Contains(key, "auth") {
t.Fatalf("tailscale secret leaked through label %s=%q", key, value)
}
}
}
func TestTouchDirectLeaseLabelsMovesExpiryForwardToTTLCap(t *testing.T) {
created := time.Date(2026, 5, 1, 12, 0, 0, 0, time.UTC)
touched := created.Add(3 * time.Minute)

View File

@ -42,6 +42,7 @@ func (a App) warmup(ctx context.Context, args []string) error {
timingJSON := fs.Bool("timing-json", false, "print final timing as JSON")
blacksmithFlags := registerBlacksmithFlags(fs, defaults)
targetFlags := registerTargetFlags(fs, defaults)
networkFlags := registerNetworkFlags(fs, defaults)
if err := parseFlags(fs, args); err != nil {
return err
}
@ -56,6 +57,9 @@ func (a App) warmup(ctx context.Context, args []string) error {
if err := applyTargetFlagOverrides(&cfg, fs, targetFlags); err != nil {
return err
}
if err := applyNetworkFlagOverrides(&cfg, fs, networkFlags); err != nil {
return err
}
if flagWasSet(fs, "type") {
cfg.ServerType = *serverType
cfg.ServerTypeExplicit = true
@ -116,8 +120,33 @@ func (a App) warmup(ctx context.Context, args []string) error {
a.releaseAcquiredLeaseBestEffort(ctx, cfg, coord, useCoordinator, server, target, leaseID)
return err
}
fmt.Fprintf(a.Stdout, "leased %s slug=%s provider=%s server=%s type=%s ip=%s idle_timeout=%s expires=%s\n", leaseID, blank(serverSlug(server), "-"), cfg.Provider, server.DisplayID(), server.ServerType.Name, target.Host, cfg.IdleTimeout, blank(leaseLabelTimeDisplay(server.Labels["expires_at"]), server.Labels["expires_at"]))
fmt.Fprintf(a.Stdout, "ready ssh=%s@%s:%s workroot=%s\n", target.User, target.Host, target.Port, cfg.WorkRoot)
if serverTailscaleMetadata(server).Enabled {
if err := waitForSSHReady(ctx, &target, a.Stderr, "tailscale metadata", 2*time.Minute); err == nil {
a.refreshTailscaleMetadata(ctx, cfg, coord, useCoordinator, &server, target, leaseID)
} else {
fmt.Fprintf(a.Stderr, "warning: tailscale metadata wait failed: %v\n", err)
}
}
if resolved, err := resolveNetworkTarget(ctx, cfg, server, target); err != nil {
a.releaseAcquiredLeaseBestEffort(ctx, cfg, coord, useCoordinator, server, target, leaseID)
return err
} else {
target = resolved.Target
if resolved.FallbackReason != "" {
fmt.Fprintf(a.Stderr, "network fallback %s\n", resolved.FallbackReason)
}
}
network := NetworkPublic
if target.Host != server.PublicNet.IPv4.IP && target.Host != "" {
network = NetworkTailscale
}
meta := serverTailscaleMetadata(server)
tailscaleSummary := ""
if meta.Enabled {
tailscaleSummary = " tailscale=" + blank(tailscaleTargetHost(meta), blank(meta.State, "requested"))
}
fmt.Fprintf(a.Stdout, "leased %s slug=%s provider=%s server=%s type=%s ip=%s%s idle_timeout=%s expires=%s\n", leaseID, blank(serverSlug(server), "-"), cfg.Provider, server.DisplayID(), server.ServerType.Name, server.PublicNet.IPv4.IP, tailscaleSummary, cfg.IdleTimeout, blank(leaseLabelTimeDisplay(server.Labels["expires_at"]), server.Labels["expires_at"]))
fmt.Fprintf(a.Stdout, "ready ssh=%s@%s:%s network=%s workroot=%s\n", target.User, target.Host, target.Port, network, cfg.WorkRoot)
if *actionsRunner {
ghRepo, err := resolveGitHubRepo(repo, cfg.Actions.Repo)
if err != nil {
@ -168,6 +197,7 @@ func (a App) runCommand(ctx context.Context, args []string) (err error) {
timingJSON := fs.Bool("timing-json", false, "print final timing as JSON")
blacksmithFlags := registerBlacksmithFlags(fs, defaults)
targetFlags := registerTargetFlags(fs, defaults)
networkFlags := registerNetworkFlags(fs, defaults)
if err := parseFlags(fs, args); err != nil {
return err
}
@ -190,6 +220,9 @@ func (a App) runCommand(ctx context.Context, args []string) (err error) {
if err := applyTargetFlagOverrides(&cfg, fs, targetFlags); err != nil {
return err
}
if err := applyNetworkFlagOverrides(&cfg, fs, networkFlags); err != nil {
return err
}
if flagWasSet(fs, "type") {
cfg.ServerType = *serverType
cfg.ServerTypeExplicit = true
@ -269,12 +302,30 @@ func (a App) runCommand(ctx context.Context, args []string) (err error) {
lease, err = coord.GetLease(ctx, *leaseIDFlag)
if err == nil {
server, target, leaseID = leaseToServerTarget(lease, cfg)
if resolved, resolveErr := resolveNetworkTarget(ctx, cfg, server, target); resolveErr != nil {
err = resolveErr
} else {
target = resolved.Target
if resolved.FallbackReason != "" {
fmt.Fprintf(a.Stderr, "network fallback %s\n", resolved.FallbackReason)
}
}
if !flagWasSet(fs, "idle-timeout") && lease.IdleTimeoutSeconds > 0 {
cfg.IdleTimeout = time.Duration(lease.IdleTimeoutSeconds) * time.Second
}
}
} else {
server, target, leaseID, err = a.findLease(ctx, cfg, *leaseIDFlag)
if err == nil {
if resolved, resolveErr := resolveNetworkTarget(ctx, cfg, server, target); resolveErr != nil {
err = resolveErr
} else {
target = resolved.Target
if resolved.FallbackReason != "" {
fmt.Fprintf(a.Stderr, "network fallback %s\n", resolved.FallbackReason)
}
}
}
if err == nil && !flagWasSet(fs, "idle-timeout") {
if duration, ok := parseDurationSecondsLabel(server.Labels["idle_timeout_secs"]); ok {
cfg.IdleTimeout = duration
@ -361,6 +412,15 @@ func (a App) runCommand(ctx context.Context, args []string) (err error) {
if err := waitForSSHReady(ctx, &target, a.Stderr, "before sync", 2*time.Minute); err != nil {
return recordFailure(err)
}
a.refreshTailscaleMetadata(ctx, cfg, coord, useCoordinator, &server, target, leaseID)
if resolved, err := resolveNetworkTarget(ctx, cfg, server, target); err != nil {
return recordFailure(err)
} else {
target = resolved.Target
if resolved.FallbackReason != "" {
fmt.Fprintf(a.Stderr, "network fallback %s\n", resolved.FallbackReason)
}
}
recorder.Event("sync.started", "sync", "")
timings.syncSteps.sshReady = time.Since(stepStart)
stepStart = time.Now()
@ -510,6 +570,15 @@ afterSync:
if err := waitForSSHReady(ctx, &target, a.Stderr, "before command", 2*time.Minute); err != nil {
return recordFailure(err)
}
a.refreshTailscaleMetadata(ctx, cfg, coord, useCoordinator, &server, target, leaseID)
if resolved, err := resolveNetworkTarget(ctx, cfg, server, target); err != nil {
return recordFailure(err)
} else {
target = resolved.Target
if resolved.FallbackReason != "" {
fmt.Fprintf(a.Stderr, "network fallback %s\n", resolved.FallbackReason)
}
}
if *noSync {
mkdirCommand := remoteMkdir(workdir)
if isWindowsNativeTarget(target) {
@ -707,6 +776,9 @@ func (a App) acquireCoordinator(ctx context.Context, cfg Config, coord *Coordina
}
cfg.SSHKey = keyPath
cfg.ProviderKey = providerKeyForLease(leaseID)
if cfg.Tailscale.Enabled && cfg.Tailscale.Hostname == "" {
cfg.Tailscale.Hostname = renderTailscaleHostname(cfg.Tailscale.HostnameTemplate, leaseID, slug, cfg.Provider)
}
ensureAWSSSHCIDRs(ctx, &cfg)
fmt.Fprintf(a.Stderr, "coordinator lease class=%s preferred_type=%s keep=%v slug=%s idle_timeout=%s ttl=%s\n", cfg.Class, cfg.ServerType, keep, slug, cfg.IdleTimeout, cfg.TTL)
lease, err := coord.CreateLease(ctx, cfg, publicKey, keep, leaseID, slug)
@ -753,6 +825,9 @@ func validateCoordinatorLeaseCapabilities(cfg Config, lease CoordinatorLease) er
if cfg.Browser && !lease.Browser {
return exit(5, "coordinator did not provision browser=true for lease %s; deploy the coordinator with browser support", blank(lease.ID, "-"))
}
if cfg.Tailscale.Enabled && (lease.Tailscale == nil || !lease.Tailscale.Enabled) {
return exit(5, "coordinator did not provision tailscale=true for lease %s; deploy the coordinator with Tailscale support", blank(lease.ID, "-"))
}
return nil
}
@ -1025,6 +1100,9 @@ func (a App) acquire(ctx context.Context, cfg Config, keep bool) (Server, SSHTar
if isStaticProvider(cfg.Provider) {
return a.acquireStatic(ctx, cfg, keep)
}
if cfg.Tailscale.Enabled && cfg.Tailscale.AuthKey == "" {
return Server{}, SSHTarget{}, "", exit(2, "direct --tailscale requires %s to contain a Tailscale auth key; brokered mode uses coordinator OAuth secrets", cfg.Tailscale.AuthKeyEnv)
}
if cfg.Provider == "aws" {
return a.acquireAWS(ctx, cfg, keep)
}

View File

@ -19,6 +19,7 @@ func (a App) screenshot(ctx context.Context, args []string) error {
output := fs.String("output", "", "local PNG output path")
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
}
@ -34,6 +35,9 @@ func (a App) screenshot(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 isBlacksmithProvider(cfg.Provider) {
return exit(2, "desktop screenshots are not supported for provider=%s; Blacksmith owns machine connectivity", cfg.Provider)
}
@ -44,6 +48,11 @@ func (a App) screenshot(ctx context.Context, args []string) error {
if err != nil {
return err
}
if resolved, err := resolveNetworkTarget(ctx, cfg, server, target); err != nil {
return err
} else {
target = resolved.Target
}
if isStaticProvider(cfg.Provider) && target.TargetOS != targetLinux {
return exit(2, "desktop screenshots are not captured from static %s hosts because those are existing host machines, not Crabbox-created desktops", target.TargetOS)
}

View File

@ -126,6 +126,33 @@ func probeSSHReady(ctx context.Context, target *SSHTarget, timeout time.Duration
return false
}
func probeSSHTransport(ctx context.Context, target *SSHTarget, timeout time.Duration) bool {
if target.Host == "" {
return false
}
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
for _, port := range sshPortCandidates(target.Port, target.FallbackPorts) {
probe := *target
probe.Port = port
dialer := net.Dialer{Timeout: minDuration(timeout, 2*time.Second)}
conn, err := dialer.DialContext(ctx, "tcp", net.JoinHostPort(probe.Host, probe.Port))
if err != nil {
continue
}
_ = conn.Close()
if runSSHQuietWithOptions(ctx, probe, sshTransportProbeCommand(probe), "2", "1") == nil {
target.Port = probe.Port
return true
}
}
return false
}
func sshTransportProbeCommand(SSHTarget) string {
return "exit 0"
}
func sshReadyCommand(target SSHTarget) string {
if target.ReadyCheck != "" {
return target.ReadyCheck

View File

@ -11,6 +11,7 @@ func (a App) ssh(ctx context.Context, args []string) error {
id := fs.String("id", "", "lease id or slug")
reclaim := fs.Bool("reclaim", false, "claim this lease for the current repo")
targetFlags := registerTargetFlags(fs, defaultConfig())
networkFlags := registerNetworkModeFlag(fs, defaultConfig())
if err := parseFlags(fs, args); err != nil {
return err
}
@ -25,6 +26,9 @@ func (a App) ssh(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 *id == "" && !isStaticProvider(cfg.Provider) {
return exit(2, "usage: crabbox ssh --id <lease-id-or-slug>")
}
@ -32,6 +36,11 @@ func (a App) ssh(ctx context.Context, args []string) error {
if err != nil {
return err
}
if resolved, err := resolveNetworkTarget(ctx, cfg, server, target); err != nil {
return err
} else {
target = resolved.Target
}
repo, err := findRepo()
if err != nil {
return err

View File

@ -186,6 +186,13 @@ func TestSSHArgsIncludeReliabilityOptions(t *testing.T) {
}
}
func TestSSHTransportProbeDoesNotRequireCrabboxReady(t *testing.T) {
got := sshTransportProbeCommand(SSHTarget{Host: "100.64.0.10", Port: "2222"})
if strings.Contains(got, "crabbox-ready") || strings.Contains(got, "git --version") || strings.Contains(got, "/work/crabbox") {
t.Fatalf("transport probe should not run readiness checks: %q", got)
}
}
func TestSSHArgsQuoteKnownHostsPathWithSpaces(t *testing.T) {
got := strings.Join(sshArgs(SSHTarget{
User: "crabbox",

View File

@ -15,6 +15,7 @@ func (a App) status(ctx context.Context, args []string) error {
waitTimeout := fs.Duration("wait-timeout", 5*time.Minute, "maximum wait duration")
jsonOut := fs.Bool("json", false, "print JSON")
targetFlags := registerTargetFlags(fs, defaultConfig())
networkFlags := registerNetworkModeFlag(fs, defaultConfig())
if err := parseFlags(fs, args); err != nil {
return err
}
@ -29,6 +30,9 @@ func (a App) status(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 *id == "" && !isStaticProvider(cfg.Provider) {
return exit(2, "usage: crabbox status --id <lease-id-or-slug>")
}
@ -49,7 +53,11 @@ func (a App) status(ctx context.Context, args []string) error {
return json.NewEncoder(a.Stdout).Encode(state)
}
} else {
fmt.Fprintf(a.Stdout, "%s slug=%s provider=%s target=%s windows_mode=%s state=%s type=%s host=%s ready=%t has_host=%t idle_for=%s idle_timeout=%s expires=%s\n", state.ID, blank(state.Slug, "-"), state.Provider, state.TargetOS, blank(state.WindowsMode, "-"), state.State, state.ServerType, state.Host, state.Ready, state.HasHost, blank(state.IdleFor, "-"), blank(state.IdleTimeout, "-"), blank(state.ExpiresAt, "-"))
tailscale := ""
if state.Tailscale != nil && state.Tailscale.Enabled {
tailscale = fmt.Sprintf(" tailscale=%s", blank(tailscaleTargetHost(*state.Tailscale), blank(state.Tailscale.State, "requested")))
}
fmt.Fprintf(a.Stdout, "%s slug=%s provider=%s target=%s windows_mode=%s state=%s type=%s host=%s network=%s%s ready=%t has_host=%t idle_for=%s idle_timeout=%s expires=%s\n", state.ID, blank(state.Slug, "-"), state.Provider, state.TargetOS, blank(state.WindowsMode, "-"), state.State, state.ServerType, state.Host, state.Network, tailscale, state.Ready, state.HasHost, blank(state.IdleFor, "-"), blank(state.IdleTimeout, "-"), blank(state.ExpiresAt, "-"))
}
if !*wait || state.Ready {
return nil
@ -62,26 +70,29 @@ func (a App) status(ctx context.Context, args []string) error {
}
type statusView struct {
ID string `json:"id"`
Slug string `json:"slug,omitempty"`
Provider string `json:"provider"`
TargetOS string `json:"target"`
WindowsMode string `json:"windowsMode,omitempty"`
State string `json:"state"`
ServerID string `json:"serverId"`
ServerType string `json:"serverType"`
Host string `json:"host"`
SSHUser string `json:"sshUser"`
SSHPort string `json:"sshPort"`
SSHFallbackPorts []string `json:"sshFallbackPorts,omitempty"`
SSHKey string `json:"sshKey"`
LastTouchedAt string `json:"lastTouchedAt,omitempty"`
IdleFor string `json:"idleFor,omitempty"`
IdleTimeout string `json:"idleTimeout,omitempty"`
ExpiresAt string `json:"expiresAt,omitempty"`
Labels map[string]string `json:"labels,omitempty"`
HasHost bool `json:"hasHost"`
Ready bool `json:"ready"`
ID string `json:"id"`
Slug string `json:"slug,omitempty"`
Provider string `json:"provider"`
TargetOS string `json:"target"`
WindowsMode string `json:"windowsMode,omitempty"`
State string `json:"state"`
ServerID string `json:"serverId"`
ServerType string `json:"serverType"`
Host string `json:"host"`
Network NetworkMode `json:"network"`
Tailscale *TailscaleMetadata `json:"tailscale,omitempty"`
SSHHost string `json:"sshHost"`
SSHUser string `json:"sshUser"`
SSHPort string `json:"sshPort"`
SSHFallbackPorts []string `json:"sshFallbackPorts,omitempty"`
SSHKey string `json:"sshKey"`
LastTouchedAt string `json:"lastTouchedAt,omitempty"`
IdleFor string `json:"idleFor,omitempty"`
IdleTimeout string `json:"idleTimeout,omitempty"`
ExpiresAt string `json:"expiresAt,omitempty"`
Labels map[string]string `json:"labels,omitempty"`
HasHost bool `json:"hasHost"`
Ready bool `json:"ready"`
}
func (a App) leaseStatus(ctx context.Context, cfg Config, id string) (statusView, error) {
@ -92,7 +103,12 @@ func (a App) leaseStatus(ctx context.Context, cfg Config, id string) (statusView
if err != nil {
return statusView{}, err
}
_, target, _ := leaseToServerTarget(lease, cfg)
server, target, _ := leaseToServerTarget(lease, cfg)
resolved, err := resolveNetworkTarget(ctx, cfg, server, target)
if err != nil {
return statusView{}, err
}
target = resolved.Target
hasHost := lease.Host != ""
ready := lease.State == "active" && hasHost && probeSSHReady(ctx, &target, 4*time.Second)
return statusView{
@ -105,6 +121,9 @@ func (a App) leaseStatus(ctx context.Context, cfg Config, id string) (statusView
ServerID: leaseDisplayID(lease),
ServerType: lease.ServerType,
Host: lease.Host,
Network: resolved.Network,
Tailscale: lease.Tailscale,
SSHHost: target.Host,
SSHUser: target.User,
SSHPort: target.Port,
SSHFallbackPorts: target.FallbackPorts,
@ -123,7 +142,17 @@ func (a App) leaseStatus(ctx context.Context, cfg Config, id string) (statusView
return statusView{}, err
}
hasHost := server.PublicNet.IPv4.IP != ""
resolved, err := resolveNetworkTarget(ctx, cfg, server, target)
if err != nil {
return statusView{}, err
}
target = resolved.Target
ready := hasHost && server.Labels["state"] != "provisioning" && probeSSHReady(ctx, &target, 4*time.Second)
meta := serverTailscaleMetadata(server)
var tailscale *TailscaleMetadata
if meta.Enabled {
tailscale = &meta
}
return statusView{
ID: leaseID,
Slug: serverSlug(server),
@ -134,6 +163,9 @@ func (a App) leaseStatus(ctx context.Context, cfg Config, id string) (statusView
ServerID: server.DisplayID(),
ServerType: server.ServerType.Name,
Host: server.PublicNet.IPv4.IP,
Network: resolved.Network,
Tailscale: tailscale,
SSHHost: target.Host,
SSHUser: target.User,
SSHPort: target.Port,
SSHFallbackPorts: target.FallbackPorts,

View File

@ -19,6 +19,7 @@ func (a App) vnc(ctx context.Context, args []string) error {
openClient := fs.Bool("open", false, "open the VNC client locally")
hostManaged := fs.Bool("host-managed", false, "allow opening host-managed static VNC")
targetFlags := registerTargetFlags(fs, defaults)
networkFlags := registerNetworkModeFlag(fs, defaults)
if err := parseFlags(fs, args); err != nil {
return err
}
@ -34,6 +35,9 @@ func (a App) vnc(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 isBlacksmithProvider(cfg.Provider) {
return exit(2, "desktop/VNC is not supported for provider=%s; Blacksmith owns machine connectivity", cfg.Provider)
}
@ -47,6 +51,14 @@ func (a App) vnc(ctx context.Context, args []string) error {
if err != nil {
return err
}
if resolved, err := resolveNetworkTarget(ctx, cfg, server, target); err != nil {
return err
} else {
target = resolved.Target
if resolved.FallbackReason != "" {
fmt.Fprintf(a.Stderr, "network fallback %s\n", resolved.FallbackReason)
}
}
if err := enforceManagedLeaseCapabilities(cfg, server, leaseID); err != nil {
return err
}

View File

@ -22,6 +22,7 @@ func (a App) webvnc(ctx context.Context, args []string) error {
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")
networkFlags := registerNetworkModeFlag(fs, defaults)
targetFlags := registerTargetFlags(fs, defaults)
if err := parseFlags(fs, args); err != nil {
return err
@ -37,6 +38,9 @@ func (a App) webvnc(ctx context.Context, args []string) error {
return err
}
cfg.Provider = *provider
if err := applyNetworkModeFlagOverride(&cfg, fs, networkFlags); err != nil {
return err
}
cfg.Desktop = true
if err := applyTargetFlagOverrides(&cfg, fs, targetFlags); err != nil {
return err
@ -55,6 +59,11 @@ func (a App) webvnc(ctx context.Context, args []string) error {
if err != nil {
return err
}
if resolved, err := resolveNetworkTarget(ctx, cfg, server, target); err != nil {
return err
} else {
target = resolved.Target
}
if err := enforceManagedLeaseCapabilities(cfg, server, leaseID); err != nil {
return err
}

View File

@ -258,6 +258,12 @@ chmod 0755 /usr/local/bin/crabbox-ready
function optionalReadyChecks(config: LeaseConfig): string {
const lines: string[] = [];
if (config.tailscale) {
lines.push(
" test -s /var/lib/crabbox/tailscale-ipv4",
" grep -Eq '^100\\.' /var/lib/crabbox/tailscale-ipv4",
);
}
if (config.desktop) {
lines.push(
" systemctl is-active --quiet crabbox-xvfb.service",
@ -361,6 +367,9 @@ function optionalWriteFiles(config: LeaseConfig): string {
function optionalBootstrap(config: LeaseConfig): string {
const parts: string[] = [];
if (config.tailscale) {
parts.push(tailscaleBootstrap(config));
}
if (config.desktop) {
parts.push(` retry apt-get install -y --no-install-recommends xvfb xfce4 xfce4-terminal x11vnc xauth dbus-x11 x11-xserver-utils xterm scrot fonts-dejavu-core fonts-liberation iproute2 openssl
install -d -m 0750 -o crabbox -g crabbox /var/lib/crabbox
@ -404,6 +413,36 @@ function optionalBootstrap(config: LeaseConfig): string {
return parts.join("\n");
}
function tailscaleBootstrap(config: LeaseConfig): string {
if (!config.tailscaleAuthKey) {
return ` echo "tailscale requested but no auth key was injected" >&2
exit 1`;
}
const sshUser = config.sshUser.trim() || "crabbox";
return ` retry sh -c 'curl -fsSL https://tailscale.com/install.sh | sh'
systemctl enable --now tailscaled || service tailscaled start || true
install -d -m 0750 -o ${shellQuote(sshUser)} -g ${shellQuote(sshUser)} /var/lib/crabbox
set +x
TS_AUTHKEY=${shellQuote(config.tailscaleAuthKey)}
tailscale up --auth-key="$TS_AUTHKEY" --hostname=${shellQuote(config.tailscaleHostname)} --advertise-tags=${shellQuote(config.tailscaleTags.join(","))}
unset TS_AUTHKEY
set -x
ts_ip=""
for _ in $(seq 1 24); do
ts_ip="$(tailscale ip -4 2>/dev/null | head -n1 || true)"
if [ -n "$ts_ip" ]; then break; fi
sleep 5
done
test -n "$ts_ip"
printf '%s\\n' "$ts_ip" > /var/lib/crabbox/tailscale-ipv4
printf '%s\\n' ${shellQuote(config.tailscaleHostname)} > /var/lib/crabbox/tailscale-hostname
if tailscale status --json >/var/lib/crabbox/tailscale-status.json 2>/dev/null; then
jq -r '.Self.DNSName // empty' /var/lib/crabbox/tailscale-status.json > /var/lib/crabbox/tailscale-fqdn || true
fi
chown ${shellQuote(`${sshUser}:${sshUser}`)} /var/lib/crabbox/tailscale-* || true
chmod 0640 /var/lib/crabbox/tailscale-* || true`;
}
function psQuote(value: string): string {
return `'${value.replaceAll("'", "''")}'`;
}

View File

@ -6,6 +6,10 @@ export interface LeaseConfig {
windowsMode: WindowsMode;
desktop: boolean;
browser: boolean;
tailscale: boolean;
tailscaleTags: string[];
tailscaleHostname: string;
tailscaleAuthKey: string;
profile: string;
class: string;
serverType: string;
@ -82,6 +86,10 @@ export function leaseConfig(input: LeaseRequest): LeaseConfig {
windowsMode,
desktop: input.desktop ?? false,
browser: input.browser ?? false,
tailscale: input.tailscale ?? false,
tailscaleTags: normalizeTailscaleTags(input.tailscaleTags ?? ["tag:crabbox"]),
tailscaleHostname: input.tailscaleHostname ?? "",
tailscaleAuthKey: "",
profile: input.profile ?? "default",
class: machineClass,
serverType,
@ -125,6 +133,14 @@ function unsupportedManagedTargetMessage(provider: Provider, target: TargetOS):
return `brokered ${provider} managed provisioning supports target=linux only`;
}
export function normalizeTailscaleTags(values: string[]): string[] {
return uniqueStrings(
values
.map((value) => value.trim().toLowerCase())
.filter((value) => /^tag:[a-z0-9_-]{1,63}$/.test(value)),
);
}
function normalizeTarget(value: string): TargetOS {
const normalized = value.trim().toLowerCase();
if (normalized === "" || normalized === "linux" || normalized === "ubuntu") {

View File

@ -6,6 +6,13 @@ import { errorMessage, json, pathParts, readJson, requestOwner } from "./http";
import { githubAuthRoute, githubPortalLogin, githubPortalLogout } from "./oauth";
import { portalError, portalHome, portalVNC } from "./portal";
import { leaseSlugFromID, normalizeLeaseSlug, slugWithCollisionSuffix } from "./slug";
import {
createTailscaleAuthKey,
renderTailscaleHostname,
tailscaleAllowed,
tailscaleDefaultTags,
validateTailscaleTags,
} from "./tailscale";
import type {
Env,
LeaseRecord,
@ -22,6 +29,7 @@ import type {
RunRecord,
TestFailure,
TestResultSummary,
TailscaleMetadata,
} from "./types";
import { costLimits, enforceCostLimits, leaseCost, requestOrg, usageSummary } from "./usage";
@ -175,6 +183,10 @@ export class FleetDurableObject implements DurableObject {
org,
leases,
);
const tailscaleError = await this.prepareTailscaleConfig(config, input, leaseID, slug);
if (tailscaleError) {
return tailscaleError;
}
const provider = this.provider(config.provider, config.awsRegion);
const providerHourlyUSD = await provider
.hourlyPriceUSD(config.serverType, config)
@ -228,6 +240,14 @@ export class FleetDurableObject implements DurableObject {
if (config.target === "windows") {
record.windowsMode = config.windowsMode;
}
if (config.tailscale) {
record.tailscale = {
enabled: true,
hostname: config.tailscaleHostname,
tags: config.tailscaleTags,
state: "requested",
};
}
const limitError = enforceCostLimits(leases, record, costLimits(this.env), now);
if (limitError) {
return json({ error: "cost_limit_exceeded", message: limitError }, { status: 429 });
@ -294,12 +314,73 @@ export class FleetDurableObject implements DurableObject {
await this.scheduleAlarm();
return json({ lease });
}
if (method === "POST" && action === "tailscale") {
const lease = await this.resolveLease(leaseID, request, false);
if (!lease) {
return notFound();
}
const input = await readJson<Partial<TailscaleMetadata>>(request);
lease.tailscale = mergeTailscaleMetadata(lease.tailscale, input);
lease.updatedAt = new Date().toISOString();
await this.putLease(lease);
return json({ lease });
}
if (method === "POST" && action === "release") {
return this.releaseLease(request, leaseID, false);
}
return json({ error: "not_found" }, { status: 404 });
}
private async prepareTailscaleConfig(
config: ReturnType<typeof leaseConfig>,
input: LeaseRequest,
leaseID: string,
slug: string,
): Promise<Response | undefined> {
if (!config.tailscale) {
return undefined;
}
if (config.target !== "linux") {
return json(
{
error: "unsupported_target",
message: "brokered Tailscale provisioning currently supports managed Linux leases only",
},
{ status: 400 },
);
}
if (!tailscaleAllowed(this.env)) {
return json(
{ error: "tailscale_disabled", message: "Tailscale is disabled for this coordinator" },
{ status: 403 },
);
}
try {
config.tailscaleTags = validateTailscaleTags(
input.tailscaleTags ?? config.tailscaleTags,
tailscaleDefaultTags(this.env),
);
config.tailscaleHostname = renderTailscaleHostname(
input.tailscaleHostname || config.tailscaleHostname || "crabbox-{slug}",
leaseID,
slug,
config.provider,
);
config.tailscaleAuthKey = await createTailscaleAuthKey(this.env, {
hostname: config.tailscaleHostname,
tags: config.tailscaleTags,
description: `crabbox ${leaseID} ${slug}`,
});
} catch (error) {
const message = errorMessage(error);
if (message.includes("tags not allowed") || message.includes("requires at least one")) {
return json({ error: "invalid_tailscale_tags", message }, { status: 400 });
}
return json({ error: "tailscale_unavailable", message }, { status: 502 });
}
return undefined;
}
private async releaseLease(request: Request, leaseID: string, admin: boolean): Promise<Response> {
const lease = await this.resolveLease(leaseID, request, admin);
if (!lease) {
@ -1128,6 +1209,47 @@ function notFound(): Response {
return json({ error: "not_found" }, { status: 404 });
}
function mergeTailscaleMetadata(
current: TailscaleMetadata | undefined,
input: Partial<TailscaleMetadata>,
): TailscaleMetadata {
const tags = Array.isArray(input.tags)
? input.tags.map((tag) => tag.trim().toLowerCase()).filter(Boolean)
: (current?.tags ?? []);
const merged: TailscaleMetadata = {
enabled: input.enabled ?? current?.enabled ?? true,
tags,
state:
input.state === "ready" || input.state === "failed" || input.state === "requested"
? input.state
: (current?.state ?? "requested"),
};
const hostname = nonSecretString(input.hostname) || current?.hostname;
const fqdn = nonSecretString(input.fqdn) || current?.fqdn;
const ipv4 = nonSecretString(input.ipv4) || current?.ipv4;
const error = nonSecretString(input.error) || current?.error;
if (hostname) {
merged.hostname = hostname;
}
if (fqdn) {
merged.fqdn = fqdn;
}
if (ipv4) {
merged.ipv4 = ipv4;
}
if (error) {
merged.error = error;
}
if (merged.state !== "failed") {
delete merged.error;
}
return merged;
}
function nonSecretString(value: unknown): string {
return typeof value === "string" ? value.trim().slice(0, 256) : "";
}
function webVNCLeaseError(lease: LeaseRecord): string {
if (lease.state !== "active") {
return "lease is not active";

View File

@ -41,6 +41,12 @@ export function leaseProviderLabels(
if (config.browser) {
labels["browser"] = "true";
}
if (config.tailscale) {
labels["tailscale"] = "true";
labels["tailscale_state"] = "requested";
labels["tailscale_hostname"] = config.tailscaleHostname;
labels["tailscale_tags"] = config.tailscaleTags.join(",");
}
return sanitizeLabels({ ...labels, ...extra });
}

134
worker/src/tailscale.ts Normal file
View File

@ -0,0 +1,134 @@
import type { Env } from "./types";
export interface TailscaleKeyRequest {
hostname: string;
tags: string[];
description: string;
}
export function tailscaleAllowed(env: Env): boolean {
return env.CRABBOX_TAILSCALE_ENABLED !== "0";
}
export function tailscaleDefaultTags(env: Env): string[] {
return normalizeTags((env.CRABBOX_TAILSCALE_TAGS ?? "tag:crabbox").split(","));
}
export function validateTailscaleTags(requested: string[], allowed: string[]): string[] {
const tags = normalizeTags(requested.length > 0 ? requested : allowed);
const allowedSet = new Set(allowed);
const denied = tags.filter((tag) => !allowedSet.has(tag));
if (denied.length > 0) {
throw new Error(`tailscale tags not allowed: ${denied.join(",")}`);
}
if (tags.length === 0) {
throw new Error("tailscale requires at least one allowed tag");
}
return tags;
}
export function renderTailscaleHostname(
template: string,
leaseID: string,
slug: string,
provider: string,
): string {
const replacements: Record<string, string> = {
"{id}": leaseID.replaceAll("_", "-"),
"{slug}": slug,
"{provider}": provider,
};
let value = template.trim() || "crabbox-{slug}";
for (const [key, replacement] of Object.entries(replacements)) {
value = value.replaceAll(key, replacement);
}
value = value
.toLowerCase()
.replaceAll(/[^a-z0-9-]/g, "-")
.replaceAll(/-+/g, "-")
.replaceAll(/^-+|-+$/g, "")
.slice(0, 63);
return value || `crabbox-${leaseID.replaceAll("_", "-")}`.slice(0, 63);
}
export async function createTailscaleAuthKey(
env: Env,
request: TailscaleKeyRequest,
): Promise<string> {
const clientID = env.CRABBOX_TAILSCALE_CLIENT_ID;
const clientSecret = env.CRABBOX_TAILSCALE_CLIENT_SECRET;
if (!clientID || !clientSecret) {
throw new Error("tailscale OAuth secrets are not configured");
}
const token = await tailscaleOAuthToken(clientID, clientSecret, request.tags);
const tailnet = env.CRABBOX_TAILSCALE_TAILNET?.trim() || "-";
const response = await fetch(
`https://api.tailscale.com/api/v2/tailnet/${encodeURIComponent(tailnet)}/keys`,
{
method: "POST",
headers: {
authorization: `Bearer ${token}`,
"content-type": "application/json",
},
body: JSON.stringify({
capabilities: {
devices: {
create: {
reusable: false,
ephemeral: true,
preauthorized: true,
tags: request.tags,
},
},
},
expirySeconds: 600,
description: request.description,
}),
},
);
const text = await response.text();
if (!response.ok) {
throw new Error(`tailscale create auth key: http ${response.status}: ${trimBody(text)}`);
}
const data = JSON.parse(text) as { key?: string };
if (!data.key) {
throw new Error("tailscale create auth key returned no key");
}
return data.key;
}
async function tailscaleOAuthToken(
clientID: string,
clientSecret: string,
tags: string[],
): Promise<string> {
const body = new URLSearchParams();
body.set("client_id", clientID);
body.set("client_secret", clientSecret);
body.set("scope", "auth_keys");
body.set("tags", tags.join(" "));
const response = await fetch("https://api.tailscale.com/api/v2/oauth/token", {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body,
});
const text = await response.text();
if (!response.ok) {
throw new Error(`tailscale oauth token: http ${response.status}: ${trimBody(text)}`);
}
const data = JSON.parse(text) as { access_token?: string };
if (!data.access_token) {
throw new Error("tailscale oauth token returned no access token");
}
return data.access_token;
}
function normalizeTags(values: string[]): string[] {
return [...new Set(values.map((value) => value.trim().toLowerCase()).filter(Boolean))].filter(
(tag) => /^tag:[a-z0-9_-]{1,63}$/.test(tag),
);
}
function trimBody(value: string): string {
return value.replaceAll(/\s+/g, " ").trim().slice(0, 500);
}

View File

@ -33,6 +33,11 @@ export interface Env {
CRABBOX_MAX_MONTHLY_USD?: string;
CRABBOX_MAX_MONTHLY_USD_PER_OWNER?: string;
CRABBOX_MAX_MONTHLY_USD_PER_ORG?: string;
CRABBOX_TAILSCALE_ENABLED?: string;
CRABBOX_TAILSCALE_CLIENT_ID?: string;
CRABBOX_TAILSCALE_CLIENT_SECRET?: string;
CRABBOX_TAILSCALE_TAILNET?: string;
CRABBOX_TAILSCALE_TAGS?: string;
}
export interface LeaseRequest {
@ -45,6 +50,9 @@ export interface LeaseRequest {
windowsMode?: WindowsMode;
desktop?: boolean;
browser?: boolean;
tailscale?: boolean;
tailscaleTags?: string[];
tailscaleHostname?: string;
profile?: string;
class?: string;
serverType?: string;
@ -89,6 +97,7 @@ export interface LeaseRecord {
windowsMode?: WindowsMode;
desktop?: boolean;
browser?: boolean;
tailscale?: TailscaleMetadata;
cloudID: string;
region?: string;
owner: string;
@ -120,6 +129,16 @@ export interface LeaseRecord {
endedAt?: string;
}
export interface TailscaleMetadata {
enabled: boolean;
hostname?: string;
fqdn?: string;
ipv4?: string;
tags?: string[];
state?: "requested" | "ready" | "failed";
error?: string;
}
export interface ProvisioningAttempt {
serverType: string;
market?: string;

View File

@ -9,6 +9,10 @@ const config: LeaseConfig = {
windowsMode: "normal",
desktop: false,
browser: false,
tailscale: false,
tailscaleTags: ["tag:crabbox"],
tailscaleHostname: "",
tailscaleAuthKey: "",
profile: "project-check",
class: "standard",
serverType: "c7a.8xlarge",
@ -96,6 +100,30 @@ describe("cloud-init bootstrap", () => {
expect(got).toContain('"$BROWSER" --version >/dev/null');
});
it("adds Tailscale setup only when requested", () => {
const plain = cloudInit(config);
expect(plain).not.toContain("tailscale up");
const got = cloudInit({
...config,
sshUser: "runner",
tailscale: true,
tailscaleTags: ["tag:crabbox"],
tailscaleHostname: "crabbox-blue-lobster",
tailscaleAuthKey: "tskey-secret",
});
expect(got).toContain("https://tailscale.com/install.sh");
expect(got).toContain("install -d -m 0750 -o 'runner' -g 'runner' /var/lib/crabbox");
expect(got).toContain(
"tailscale up --auth-key=\"$TS_AUTHKEY\" --hostname='crabbox-blue-lobster' --advertise-tags='tag:crabbox'",
);
expect(got).toContain(
"printf '%s\\n' 'crabbox-blue-lobster' > /var/lib/crabbox/tailscale-hostname",
);
expect(got).toContain("chown 'runner:runner' /var/lib/crabbox/tailscale-* || true");
expect(got).toContain("test -s /var/lib/crabbox/tailscale-ipv4");
expect(got).toContain("grep -Eq '^100\\.' /var/lib/crabbox/tailscale-ipv4");
});
it("builds Windows EC2Launch user data for managed VNC", () => {
const input = {
...config,

View File

@ -80,6 +80,19 @@ describe("lease config", () => {
expect(config.browser).toBe(true);
});
it("preserves Tailscale lease capability requests", () => {
const config = leaseConfig({
sshPublicKey: "ssh-ed25519 test",
tailscale: true,
tailscaleTags: ["tag:Crabbox", "tag:ci", "invalid"],
tailscaleHostname: "crabbox-blue-lobster",
});
expect(config.tailscale).toBe(true);
expect(config.tailscaleTags).toEqual(["tag:crabbox", "tag:ci"]);
expect(config.tailscaleHostname).toBe("crabbox-blue-lobster");
expect(config.tailscaleAuthKey).toBe("");
});
it("uses AWS defaults when requested", () => {
const config = leaseConfig({ provider: "aws", sshPublicKey: "ssh-ed25519 test" });
expect(config.serverType).toBe("c7a.48xlarge");

View File

@ -9,6 +9,10 @@ import {
} from "../src/fleet";
import type { Env, LeaseRecord, ProvisioningAttempt, RunRecord } from "../src/types";
afterEach(() => {
vi.unstubAllGlobals();
});
class MemoryStorage {
private readonly values = new Map<string, unknown>();
@ -95,6 +99,124 @@ describe("fleet lease identity and idle", () => {
expect(found.lease.slug).toBe("blue-lobster");
});
it("mints brokered Tailscale keys, records non-secret metadata, and accepts readiness updates", async () => {
const storage = new MemoryStorage();
let providerConfig:
| {
tailscale?: boolean;
tailscaleAuthKey?: string;
tailscaleHostname?: string;
tailscaleTags?: string[];
}
| undefined;
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "https://api.tailscale.com/api/v2/oauth/token") {
return jsonResponse({ access_token: "oauth-token" });
}
if (url === "https://api.tailscale.com/api/v2/tailnet/-/keys") {
return jsonResponse({ key: "tskey-oneoff" });
}
return jsonResponse({ message: `unexpected ${url}` }, 500);
}),
);
const fleet = testFleet(
storage,
{
hetzner: fakeProvider((config) => {
providerConfig = config;
}),
},
{
CRABBOX_TAILSCALE_CLIENT_ID: "client-id",
CRABBOX_TAILSCALE_CLIENT_SECRET: "client-secret",
CRABBOX_TAILSCALE_TAGS: "tag:crabbox,tag:ci",
},
);
const create = await fleet.fetch(
request("POST", "/v1/leases", {
headers: {
"x-crabbox-owner": "peter@example.com",
"x-crabbox-org": "openclaw",
},
body: {
leaseID: "cbx_abcdef123456",
slug: "Blue Lobster",
provider: "hetzner",
tailscale: true,
tailscaleTags: ["tag:ci"],
tailscaleHostname: "crabbox-{slug}",
sshPublicKey: "ssh-ed25519 test",
},
}),
);
expect(create.status).toBe(201);
const { lease } = (await create.json()) as { lease: LeaseRecord };
expect(lease.tailscale).toEqual({
enabled: true,
hostname: "crabbox-blue-lobster",
tags: ["tag:ci"],
state: "requested",
});
expect(JSON.stringify(lease)).not.toContain("tskey-oneoff");
expect(providerConfig).toMatchObject({
tailscale: true,
tailscaleAuthKey: "tskey-oneoff",
tailscaleHostname: "crabbox-blue-lobster",
tailscaleTags: ["tag:ci"],
});
const update = await fleet.fetch(
request("POST", "/v1/leases/blue-lobster/tailscale", {
headers: {
"x-crabbox-owner": "peter@example.com",
"x-crabbox-org": "openclaw",
},
body: {
enabled: true,
hostname: "crabbox-blue-lobster",
fqdn: "crabbox-blue-lobster.example.ts.net",
ipv4: "100.64.0.10",
state: "ready",
},
}),
);
expect(update.status).toBe(200);
const updated = (await update.json()) as { lease: LeaseRecord };
expect(updated.lease.tailscale?.ipv4).toBe("100.64.0.10");
expect(updated.lease.tailscale?.state).toBe("ready");
});
it("rejects brokered Tailscale tags outside the coordinator allowlist", async () => {
const fleet = testFleet(
new MemoryStorage(),
{ hetzner: fakeProvider() },
{
CRABBOX_TAILSCALE_CLIENT_ID: "client-id",
CRABBOX_TAILSCALE_CLIENT_SECRET: "client-secret",
CRABBOX_TAILSCALE_TAGS: "tag:crabbox",
},
);
const create = await fleet.fetch(
request("POST", "/v1/leases", {
body: {
leaseID: "cbx_abcdef123456",
provider: "hetzner",
tailscale: true,
tailscaleTags: ["tag:prod"],
sshPublicKey: "ssh-ed25519 test",
},
}),
);
expect(create.status).toBe(400);
await expect(create.json()).resolves.toMatchObject({
error: "invalid_tailscale_tags",
message: "tailscale tags not allowed: tag:prod",
});
});
it("passes the Cloudflare request source IP as AWS SSH ingress CIDR", async () => {
let awsCIDRs: string[] = [];
const fleet = testFleet(new MemoryStorage(), {
@ -1245,16 +1367,26 @@ function jsonResponse(body: unknown, status = 200): Response {
});
}
function testFleet(storage = new MemoryStorage(), providers = {}): FleetDurableObject {
function testFleet(
storage = new MemoryStorage(),
providers = {},
env: Partial<Env> = {},
): FleetDurableObject {
return new FleetDurableObject(
{ storage } as unknown as DurableObjectState,
{ CRABBOX_DEFAULT_ORG: "default-org" } as Env,
{ CRABBOX_DEFAULT_ORG: "default-org", ...env } as Env,
providers,
);
}
function fakeProvider(
onCreate?: (config: { awsSSHCIDRs: string[] }) => void,
onCreate?: (config: {
awsSSHCIDRs: string[];
tailscale?: boolean;
tailscaleAuthKey?: string;
tailscaleHostname?: string;
tailscaleTags?: string[];
}) => void,
result: {
provider?: "hetzner" | "aws";
serverType?: string;

View File

@ -11,6 +11,10 @@ describe("provider labels", () => {
windowsMode: "normal",
desktop: false,
browser: false,
tailscale: false,
tailscaleTags: ["tag:crabbox"],
tailscaleHostname: "",
tailscaleAuthKey: "",
profile: "default",
class: "beast",
serverType: "c7a.48xlarge",
@ -62,6 +66,10 @@ describe("provider labels", () => {
windowsMode: "normal",
desktop: true,
browser: true,
tailscale: true,
tailscaleTags: ["tag:crabbox"],
tailscaleHostname: "crabbox-blue-lobster",
tailscaleAuthKey: "tskey-secret",
profile: "default",
class: "beast",
serverType: "c7a.48xlarge",
@ -98,5 +106,9 @@ describe("provider labels", () => {
);
expect(labels.desktop).toBe("true");
expect(labels.browser).toBe("true");
expect(labels.tailscale).toBe("true");
expect(labels.tailscale_hostname).toBe("crabbox-blue-lobster");
expect(labels.tailscale_tags).toBe("tag_crabbox");
expect(Object.values(labels).join(" ")).not.toContain("tskey-secret");
});
});