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:
parent
9ffc78e003
commit
4d15c24a7f
@ -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
|
||||
|
||||
18
README.md
18
README.md
@ -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
|
||||
|
||||
23
docs/cli.md
23
docs/cli.md
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
```
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
158
docs/features/tailscale.md
Normal 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)
|
||||
@ -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`.
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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`
|
||||
|
||||
@ -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)))
|
||||
|
||||
@ -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`
|
||||
}
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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{
|
||||
|
||||
@ -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
335
internal/cli/network.go
Normal 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
|
||||
}
|
||||
49
internal/cli/network_test.go
Normal file
49
internal/cli/network_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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("'", "''")}'`;
|
||||
}
|
||||
|
||||
@ -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") {
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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
134
worker/src/tailscale.ts
Normal 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);
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user