From 22887dd0dcdc7c6e0a4f3647afefc4c9444aea15 Mon Sep 17 00:00:00 2001 From: wbtracey <263428195+wbtracey@users.noreply.github.com> Date: Mon, 27 Jul 2026 23:29:02 -0700 Subject: [PATCH 1/5] feat: add opt-in Tailscale support for sandbox containers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sandboxes can join the user's tailnet so agents can reach private services (internal APIs, databases, staging boxes), while the Squid allowlist stays the default and only egress for everything else. Enable per-command with `--tailscale` (run/shell/add/retry) or `tailscale = true` in tsk.toml. Off by default. Security model: with Tailscale on, the enforced egress boundary for tailnet traffic is your Tailscale ACLs + the auth key's tags, not Squid — the sandboxed agent holds NET_ADMIN and the tailscaled socket, so tsk's config flags are defense-in-depth, not a hard boundary. Recommended: a reusable, ephemeral, tagged auth key (minted once, scoped by tag + ACL; reused across tasks). Non-tailnet egress stays locked to Squid in every path (no direct egress; fails closed). - Auth key is read on the host at container start ($TS_AUTHKEY / env var / key file), passed in to join, then `unset` and the agent `exec`'d so the in-container agent can't recover it from /proc//environ; it is never written to the task DB or image (it does live in Config.Env, readable via `docker inspect` on the host, for the container lifetime). - accept-routes is opt-in (`tailscale_accept_routes`, default false); isolation-weakening `tailscale_up_args` (--exit-node, --advertise-*, --accept-*, --netfilter-mode, incl. single-dash forms) are rejected at task creation; `--tailscale` requires network isolation. - Kernel mode (Docker + host /dev/net/tun) routes the tailnet transparently (tailnet in NO_PROXY, incl. IPv6 fd7a:115c:a1e0::/48); rootless Podman runs userspace mode via tailscaled's SOCKS5 proxy (ALL_PROXY). Docker Desktop on macOS (no host TUN) isn't supported. - Tailnet device names resolve via host-snapshotted /etc/hosts aliases (--add-host); opt out with `tailscale_host_aliases = false`. Device names only, not full MagicDNS. Docs: README.md "Tailscale (optional)" and docs/network-isolation.md. Co-Authored-By: Claude --- README.md | 59 ++ dockerfiles/base/default.dockerfile | 2 + dockerfiles/features/tailscale.dockerfile | 70 ++ docs/docker-builds.md | 2 +- docs/network-isolation.md | 29 + src/commands/retry.rs | 14 + src/commands/task_args.rs | 2 + src/context/tsk_config.rs | 405 +++++++++- src/docker/composer.rs | 56 ++ src/docker/mod.rs | 852 ++++++++++++++++++++-- src/main.rs | 24 + src/task_builder.rs | 165 +++++ src/task_manager.rs | 4 + 13 files changed, 1627 insertions(+), 57 deletions(-) create mode 100644 dockerfiles/features/tailscale.dockerfile diff --git a/README.md b/README.md index 7810447..82a2a69 100644 --- a/README.md +++ b/README.md @@ -196,6 +196,8 @@ Create, manage, and monitor tasks assigned to AI agents. - `tsk delete ...` - Delete one or more tasks - `tsk retry ...` - Retry one or more tasks +`run`, `shell`, `add`, and `retry` also accept `--tailscale` to join the sandbox to your tailnet (see [Tailscale](#tailscale-optional)). + ### Server Commands Manage the `tsk` server daemon for parallel task execution. The server automatically cleans up completed, failed, and cancelled tasks older than 7 days. @@ -272,6 +274,7 @@ privileged = false # Run containers in privileged mode (disables secur sudo = false # Enable passwordless sudo inside containers devices = [] # Device paths to expose (e.g., ["/dev/video0"]) git_town = false # Enable git-town parent branch tracking +tailscale = false # Join sandboxes to your Tailscale tailnet (see below) # Project-specific overrides (matches directory name) [project.my-go-service] @@ -397,6 +400,62 @@ Inline `squid_conf` takes priority over `squid_conf_path`. See the default [`tsk **Per-configuration proxy instances:** Tasks with different proxy configurations (different `host_ports` or `squid_conf`) automatically get separate proxy containers. Tasks with identical proxy config share the same proxy. Proxy containers are named `tsk-proxy-{fingerprint}` where the fingerprint is derived from the proxy configuration. +### Tailscale (optional) + +By default a sandbox reaches the internet only through the Squid allowlist. Enable Tailscale when an agent also needs to reach private services on your tailnet (an internal API, a database, a staging box). + +> **Read this first — enabling Tailscale changes the trust boundary.** With Tailscale off, a sandbox's egress is bounded by the Squid allowlist. With it **on**, the sandbox can reach anything your **Tailscale ACLs** permit its node to reach, over the tailnet, *without* passing through Squid. The Squid allowlist still governs all non-tailnet traffic, but it no longer governs the tailnet. The sandboxed agent is untrusted and holds `NET_ADMIN`, so treat **your Tailscale ACLs + the auth key's tags as the real security boundary** — not tsk's flags. Scope the key (below) accordingly. + +Enable it per command with `--tailscale` (available on `run`, `shell`, `add`, and `retry`) or in `tsk.toml`: + +```toml +[defaults] +tailscale = false # Off by default + +[project.my-service] +tailscale = true +tailscale_auth_key_env = "TS_AUTHKEY" # Env var holding the auth key (default) +# tailscale_auth_key_file = "~/.config/tsk/ts-authkey" # Or read it from a file +# tailscale_hostname = "my-service-sandbox" # Default: tsk- +# tailscale_accept_routes = true # Reach subnet-router routes (default false; see below) +# tailscale_host_aliases = false # Inject tailnet device names into /etc/hosts (default true) +# tailscale_up_args = "--ssh" # Extra `tailscale up` flags (isolation-weakening flags rejected) +``` + +#### Recommended auth-key setup (least privilege) + +In the Tailscale admin console, generate a **reusable, ephemeral, tagged** auth key (e.g. `tag:tsk-sandbox`) and write an ACL granting that tag access to only the specific hosts/ports an agent should reach. You mint this **once** and reuse it across every task — it is the primary control: + +- **Reusable** → one key authenticates every sandbox; you don't mint a key per task. +- **Ephemeral** → each sandbox node auto-removes itself when the task ends (no stale `tsk-*` nodes piling up on your tailnet). +- **Tagged + ACL-scoped** → the sandbox is limited to what the tag is allowed, *independently of your personal identity* — the tag is what does the scoping. An **untagged** key (reusable or not) gives every sandbox **your full personal tailnet access**, so always tag it. + +Set a sensible expiry on the key and rotate it like any other secret. (The key lives on the trusted host, not in the sandbox — see below.) For a fully hands-off setup you could instead mint a fresh short-lived key per task from a [Tailscale OAuth client](https://tailscale.com/kb/1215/oauth-clients); tsk doesn't do that yet, so a reusable key is the practical choice today. + +Supply the key from your environment (or a key file) — never commit one: + +```bash +export TS_AUTHKEY="tskey-auth-..." +tsk run --tailscale -t feat -n sync-schema -p "Sync the schema from the staging database" +``` + +How it works: + +- `tsk` installs `tailscale`/`tailscaled` in the sandbox image and starts them before the agent runs. If the tailnet cannot be joined within 60s the task fails rather than running without access. +- The auth key is read at container start from `$TS_AUTHKEY` (or the configured env var), falling back to `tailscale_auth_key_file`. It is passed to the container to join the tailnet, then the wrapper `unset`s it and `exec`s the agent so the **in-container agent** can't recover it from `/proc//environ`, and it is never written to the task database or image. It does remain in the container's `Config.Env` — readable by anyone who can `docker inspect` the container **on the host** — for the container's lifetime, so treat host access as trusted and scope the key with a tag + expiry (see above). +- Each sandbox joins as its own node named `tsk-` (override with `tailscale_hostname`; names are sanitized to a DNS-safe form). +- **Subnet routes are opt-in.** `tailscale_accept_routes` defaults to `false`, so the sandbox reaches only tailnet **nodes**, not the subnets a subnet-router advertises. Set it to `true` only when you intend the sandbox to reach a whole advertised subnet — those become reachable **over the tailnet, bypassing Squid**. +- **Exit nodes / route advertisement are not supported for sandboxes.** `tailscale_up_args` is appended to `tailscale up`, but isolation-weakening flags (`--exit-node`, `--advertise-exit-node`, `--advertise-routes`, `--accept-routes`, `--accept-dns`, `--netfilter-mode`) are **rejected at task creation** — they would route the sandbox's traffic around the allowlist. +- The proxy allowlist is extended with Tailscale's control plane and relays (`.tailscale.com`, `.tailscale.io`) so `tailscaled` can connect; tailnet traffic (`100.64.0.0/10`, `*.ts.net`) bypasses the proxy. Tailscale tasks get their own proxy container because their proxy configuration differs. +- Containers keep their usual hardening, except that `NET_ADMIN` is granted so `tailscaled` can set up its interface and routes. +- **Kernel vs userspace mode.** On **Linux + Docker with a host `/dev/net/tun`** the container gets a real TUN device, so tailnet IPs are **transparently reachable** (`curl 100.x.y.z` just works; the tailnet is in `NO_PROXY`). (Kernel mode needs a Linux Docker host — Docker Desktop on macOS has no host `/dev/net/tun`, so use Podman there.) On **rootless Podman** (including macOS) a usable TUN device isn't available, so `tailscaled` runs in **userspace mode**. There tsk sets `ALL_PROXY=socks5h://localhost:1055` (the SOCKS5 proxy `tailscaled` exposes) and keeps the tailnet **out** of `NO_PROXY`, so tools that honor `ALL_PROXY` reach the tailnet while HTTP(S) to the internet still goes through Squid. **One caveat:** because internet HTTP(S) must keep flowing through Squid, `HTTP_PROXY` takes precedence over `ALL_PROXY` for `http(s)://` URLs, so a plain `curl http://rainier` is *not* transparently routed in userspace mode — reach tailnet HTTP services with an explicit `curl --socks5-hostname localhost:1055 http://rainier`. Non-HTTP tailnet services (SSH, Postgres, Redis, …) are reached transparently via `ALL_PROXY`. On Docker/Linux (kernel mode) everything, HTTP included, is transparent. The container log prints `using userspace networking` in this mode. +- **Reaching hosts by name.** tsk snapshots your host's `tailscale status` at task creation and injects each tailnet device's name→IP (short label + FQDN) into the container's `/etc/hosts` via `--add-host` (no in-container privilege needed); in userspace mode `tailscaled` resolves peer names too. So `ssh rainier`, `psql -h db …`, and other **non-HTTP** services work by short name. **HTTP(S) is the exception:** proxy bypass is decided on the *hostname string*, and a short label like `rainier` matches neither `.ts.net` nor the tailnet CIDRs, so `curl http://rainier` gets sent to Squid and denied — for HTTP, use the **FQDN** (`curl http://rainier..ts.net`, which matches `.ts.net`) or the `100.x` IP, or the explicit `--socks5-hostname` form in userspace mode. This is **device names only**, not full MagicDNS: split-DNS/custom domains, search domains, hosts *behind* a subnet router, and devices that join mid-task are **not** covered, and it's a point-in-time snapshot (tailnet IPs are stable, so that's rarely an issue). Set `tailscale_host_aliases = false` to keep the sandbox from learning your device names. Full MagicDNS isn't possible because the non-root agent can't manage `/etc/resolv.conf`. +- `--tailscale` **requires network isolation** to be on (it depends on the isolated-network topology); enabling it with `--no-network-isolation` is rejected at task creation. +- **Rootless Podman note:** the iptables defense-in-depth layer (in the proxy container) is Docker-only. Under rootless Podman a Tailscale sandbox relies solely on the Squid allowlist and your Tailscale ACLs — there is no netfilter backstop. +- **Self-hosted control planes (Headscale) aren't supported** yet: the proxy allowlist only opens Tailscale's SaaS control/relay domains (`.tailscale.com`/`.tailscale.io`), so a custom `--login-server` can't be reached. Exit-node egress is also intentionally unsupported (blocked in `tailscale_up_args`). + +**Troubleshooting:** if a task fails at join, the log shows `tsk: tailscaled failed to start:` followed by `/tmp/tailscaled.log`. A missing/expired key surfaces as a `tailscale up` error; a control-plane it can't reach surfaces as the 60s timeout. + ## `tsk` Data Directory `tsk` uses the following directories for storing data while running tasks: diff --git a/dockerfiles/base/default.dockerfile b/dockerfiles/base/default.dockerfile index 00b0579..3c58508 100644 --- a/dockerfiles/base/default.dockerfile +++ b/dockerfiles/base/default.dockerfile @@ -99,6 +99,8 @@ RUN curl -LsSf https://astral.sh/uv/install.sh | sh {{{PROJECT}}} # End of Project layer +{{{TAILSCALE}}} + {{{SUDO}}} # Build arguments for git configuration diff --git a/dockerfiles/features/tailscale.dockerfile b/dockerfiles/features/tailscale.dockerfile new file mode 100644 index 0000000..bb22deb --- /dev/null +++ b/dockerfiles/features/tailscale.dockerfile @@ -0,0 +1,70 @@ +# Tailscale support (injected when `tailscale = true`) +# +# Installs the official static tailscale/tailscaled binaries and a small startup +# script that `tsk` runs before the agent command. tailscaled runs as the +# unprivileged `agent` user: +# - cap_net_admin is granted as a file capability so the TUN device can be set +# up without root (tsk adds NET_ADMIN back to the container for this). +# - If no TUN device is available the startup script falls back to Tailscale's +# userspace networking mode. +USER root +# iptables + iproute2 let tailscaled program its netfilter chains and routing +# table in kernel/TUN mode (without them, `--accept-routes` silently no-ops). +RUN set -eux; \ + apt-get update; \ + apt-get install -y --no-install-recommends iptables iproute2; \ + rm -rf /var/lib/apt/lists/*; \ + arch="$(dpkg --print-architecture)"; \ + version="$(curl -fsSL 'https://pkgs.tailscale.com/stable/?mode=json' | jq -r .TarballsVersion)"; \ + curl -fsSL "https://pkgs.tailscale.com/stable/tailscale_${version}_${arch}.tgz" -o /tmp/tailscale.tgz; \ + tar -xzf /tmp/tailscale.tgz -C /tmp; \ + install -m 0755 "/tmp/tailscale_${version}_${arch}/tailscale" /usr/local/bin/tailscale; \ + install -m 0755 "/tmp/tailscale_${version}_${arch}/tailscaled" /usr/local/sbin/tailscaled; \ + rm -rf /tmp/tailscale.tgz "/tmp/tailscale_${version}_${arch}"; \ + setcap cap_net_admin+eip /usr/local/sbin/tailscaled; \ + mkdir -p /var/lib/tailscale /var/run/tailscale; \ + chown -R agent:agent /var/lib/tailscale /var/run/tailscale + +# Startup script: brings the sandbox onto the tailnet before the agent runs. +# TS_AUTHKEY, TSK_TAILSCALE_HOSTNAME, TSK_TAILSCALE_ACCEPT_ROUTES and +# TSK_TAILSCALE_UP_ARGS are supplied by tsk as container environment variables; +# the key is never baked into the image. +RUN printf '%s\n' \ + '#!/bin/sh' \ + '# Brings this sandbox onto the tailnet. Started by tsk when tailscale is enabled.' \ + 'set -eu' \ + ': "${TS_AUTHKEY:?TS_AUTHKEY is not set}"' \ + 'STATE_DIR=/var/lib/tailscale' \ + 'SOCKET=/var/run/tailscale/tailscaled.sock' \ + 'mkdir -p "$STATE_DIR" /var/run/tailscale' \ + 'if [ -w /dev/net/tun ]; then' \ + ' TUN_ARGS=""' \ + 'else' \ + ' echo "tsk: /dev/net/tun is unavailable, using userspace networking"' \ + ' # SOCKS5 (1055) and HTTP (1056) must be distinct ports — binding both to' \ + ' # the same port silently drops one. In userspace mode tsk sets ALL_PROXY' \ + ' # to the SOCKS5 listener so the tailnet is reachable.' \ + ' TUN_ARGS="--tun=userspace-networking --socks5-server=localhost:1055 --outbound-http-proxy-listen=localhost:1056"' \ + 'fi' \ + '# shellcheck disable=SC2086' \ + 'tailscaled --statedir="$STATE_DIR" --socket="$SOCKET" $TUN_ARGS >/tmp/tailscaled.log 2>&1 &' \ + 'i=0' \ + 'while [ ! -S "$SOCKET" ] && [ "$i" -lt 30 ]; do sleep 1; i=$((i + 1)); done' \ + 'if [ ! -S "$SOCKET" ]; then' \ + ' echo "tsk: tailscaled failed to start:"' \ + ' cat /tmp/tailscaled.log' \ + ' exit 1' \ + 'fi' \ + '# Subnet routes are opt-in (tsk sets TSK_TAILSCALE_ACCEPT_ROUTES): accepted' \ + '# routes are reachable over the tailnet, bypassing the Squid allowlist.' \ + 'ACCEPT_ROUTES=""' \ + '[ "${TSK_TAILSCALE_ACCEPT_ROUTES:-false}" = "true" ] && ACCEPT_ROUTES="--accept-routes"' \ + '# --timeout makes an unreachable control plane fail the task instead of hanging.' \ + '# shellcheck disable=SC2086' \ + 'tailscale --socket="$SOCKET" up --timeout=60s --authkey "$TS_AUTHKEY" \' \ + ' --hostname "${TSK_TAILSCALE_HOSTNAME:-tsk}" \' \ + ' --accept-dns=false $ACCEPT_ROUTES ${TSK_TAILSCALE_UP_ARGS:-}' \ + 'tailscale --socket="$SOCKET" status' \ + > /usr/local/bin/tsk-tailscale-up && \ + chmod 0755 /usr/local/bin/tsk-tailscale-up +USER agent diff --git a/docs/docker-builds.md b/docs/docker-builds.md index 8164576..d1f39c8 100644 --- a/docs/docker-builds.md +++ b/docs/docker-builds.md @@ -35,7 +35,7 @@ The foundation of all `tsk` containers (`base/default.dockerfile`): - Git configuration inherited from host user via build arguments - Build-time working directory set to `/workspace` (at runtime, `/workspace/{project_name}`) - Contains placeholders (`{{{STACK}}}`, `{{{PROJECT}}}`, `{{{AGENT}}}`) for layer composition -- Config-driven flags (e.g., `sudo = true`) may inject additional Dockerfile content between layers at build time +- Config-driven flags (e.g., `sudo = true`, `tailscale = true`) may inject additional Dockerfile content between layers at build time. Tailscale injects `features/tailscale.dockerfile`, which installs `tailscale`/`tailscaled` and the startup script that joins the tailnet. ### 2. Stack Layer Language-specific toolchains and runtimes: diff --git a/docs/network-isolation.md b/docs/network-isolation.md index 2715283..79bcd44 100644 --- a/docs/network-isolation.md +++ b/docs/network-isolation.md @@ -154,6 +154,35 @@ When network isolation is disabled: Use this flag when tasks require network access patterns that are incompatible with the proxy-based filtering, such as custom package registries, proprietary APIs not on the allowlist, or debugging network connectivity issues. +## Tailscale Access + +Tailscale support is opt-in via `--tailscale` or `tailscale = true` in `tsk.toml`. The sandbox joins your tailnet so agents can reach private services. + +```bash +export TS_AUTHKEY="tskey-auth-..." # reusable, ephemeral, tagged key (mint once) +tsk run --tailscale -p "Reproduce the bug against the staging database" +``` + +> **The trust boundary moves.** For non-tailnet traffic the Squid allowlist and the internal no-gateway topology stay exactly as described above. But **tailnet-bound traffic does not go through Squid** — it is governed entirely by your **Tailscale ACLs** and the auth key's tags. Enabling Tailscale therefore shifts egress control for tailnet destinations from tsk/Squid to Tailscale. Because the untrusted agent holds `NET_ADMIN` and can talk to `tailscaled`, tsk's config choices below are the *initial* posture, not an enforced boundary against a malicious agent — **the enforced boundary is your ACLs + a tagged, ephemeral auth key.** + +What changes when Tailscale is enabled: + +| Aspect | Change | +|-------------------|-------------------------------------------------------------------------------| +| **Proxy ACLs** | `.tailscale.com` / `.tailscale.io` on port 443 are allowed (tight `dstdomain` suffix match) so `tailscaled` can reach the control plane and DERP relays. Everything else still follows the allowlist. | +| **Proxy instance**| Tailscale tasks get their own `tsk-proxy-{fingerprint}` container, since their Squid configuration differs. | +| **Capabilities** | `NET_ADMIN` is granted (not dropped) so `tailscaled` can configure its interface and routes. All other dropped capabilities are unchanged. | +| **Devices / mode**| Linux + Docker gets a real `/dev/net/tun` → transparent kernel mode (tailnet in `NO_PROXY`). Rootless Podman can't provide a usable TUN → userspace mode: tsk sets `ALL_PROXY=socks5h://localhost:1055` and keeps the tailnet **out** of `NO_PROXY`, so the tailnet is reached via `tailscaled`'s SOCKS5 proxy while internet HTTP(S) still uses Squid (HTTP-to-tailnet needs an explicit `--socks5-hostname localhost:1055`; non-HTTP is transparent). | +| **Subnet routes** | Off by default. `tailscale_accept_routes = true` opts in; accepted routes are reachable **over the tailnet, bypassing Squid**. | +| **Extra up args** | `tailscale_up_args` is passed through, but isolation-weakening flags (`--exit-node`, `--advertise-*`, `--accept-routes`, `--accept-dns`, `--netfilter-mode`) are **rejected at task creation**. | +| **Auth key** | Passed to the container to join, then **`unset` + the agent `exec`'d** so the in-container agent can't recover it from `/proc//environ`. Never baked into an image or stored in the task DB. Still in `Config.Env` (readable via `docker inspect` on the host) for the container's lifetime → treat host access as trusted; use a reusable, ephemeral, tagged key with a sensible expiry. | +| **Host aliases** | tsk snapshots the host's `tailscale status` and injects tailnet device name→IP into `/etc/hosts` via `--add-host` (default on; `tailscale_host_aliases = false` to disable) so agents can reach devices by name. Device names only — not split-DNS/subnet-router names. The sandbox learns your device names/IPs; reachability is still ACL-gated. | +| **Proxy bypass** | *Kernel mode only:* `NO_PROXY` gains `100.64.0.0/10`, `fd7a:115c:a1e0::/48` (IPv6) and `.ts.net` so tailnet traffic goes over the tailnet, not through Squid. Userspace mode keeps the tailnet out of `NO_PROXY` and uses `ALL_PROXY` instead (see the mode row). | + +For **non-tailnet** traffic the agent container still has no route to the internet other than the proxy: outbound HTTP(S) is filtered by Squid and direct egress fails (verified — the internal no-gateway network holds even with `NET_ADMIN`). For **tailnet** traffic, reachability is governed by your Tailscale ACLs and the auth key's tags — scope the key (ephemeral, tagged) to limit what a sandbox can reach. + +Note the [Rootless Podman Limitations](#rootless-podman-limitations) below also apply: the iptables Firewall layer is Docker-only, so under rootless Podman a Tailscale sandbox relies solely on the Squid allowlist and your Tailscale ACLs — there is no netfilter backstop. + ## Rootless Podman Limitations When using rootless Podman as the container engine, the **Firewall** security layer (iptables in the proxy container) is unavailable. The Linux kernel's netfilter subsystem requires capabilities in the initial user namespace, which rootless containers cannot obtain. This is a kernel limitation, not a Podman or tsk bug. diff --git a/src/commands/retry.rs b/src/commands/retry.rs index 494c507..4619a78 100644 --- a/src/commands/retry.rs +++ b/src/commands/retry.rs @@ -16,6 +16,7 @@ pub struct RetryCommand { pub dind: Option, pub privileged: Option, pub sudo: Option, + pub tailscale: Option, pub devices: Vec, pub no_children: bool, pub from_cwd: bool, @@ -106,6 +107,7 @@ impl Command for RetryCommand { dind: self.dind, privileged: self.privileged, sudo: self.sudo, + tailscale: self.tailscale, devices: self.devices.clone(), repo_copy_source, }; @@ -255,6 +257,7 @@ mod tests { dind: None, privileged: None, sudo: None, + tailscale: None, devices: vec![], no_children: true, from_cwd: false, @@ -294,6 +297,7 @@ mod tests { dind: None, privileged: None, sudo: None, + tailscale: None, devices: vec![], no_children: true, from_cwd: false, @@ -332,6 +336,7 @@ mod tests { dind: None, privileged: None, sudo: None, + tailscale: None, devices: vec![], no_children: true, from_cwd: false, @@ -378,6 +383,7 @@ mod tests { dind: None, privileged: None, sudo: None, + tailscale: None, devices: vec![], no_children: true, from_cwd: false, @@ -424,6 +430,7 @@ mod tests { dind: None, privileged: None, sudo: None, + tailscale: None, devices: vec![], no_children: true, from_cwd: false, @@ -474,6 +481,7 @@ mod tests { dind: None, privileged: None, sudo: None, + tailscale: None, devices: vec![], no_children: true, from_cwd: false, @@ -561,6 +569,7 @@ mod tests { dind: None, privileged: None, sudo: None, + tailscale: None, devices: vec![], no_children: true, from_cwd: false, @@ -652,6 +661,7 @@ mod tests { dind: None, privileged: None, sudo: None, + tailscale: None, devices: vec![], no_children: false, from_cwd: false, @@ -778,6 +788,7 @@ mod tests { dind: None, privileged: None, sudo: None, + tailscale: None, devices: vec![], no_children: true, from_cwd: false, @@ -815,6 +826,7 @@ mod tests { dind: None, privileged: None, sudo: None, + tailscale: None, devices: vec![], no_children: true, from_cwd: true, @@ -912,6 +924,7 @@ mod tests { from_cwd: false, privileged: None, sudo: None, + tailscale: None, devices: vec![], }; @@ -951,6 +964,7 @@ mod tests { from_cwd: true, privileged: None, sudo: None, + tailscale: None, devices: vec![], }; diff --git a/src/commands/task_args.rs b/src/commands/task_args.rs index 9e00cb0..28225c7 100644 --- a/src/commands/task_args.rs +++ b/src/commands/task_args.rs @@ -46,6 +46,7 @@ pub struct TaskArgs { pub dind: bool, pub privileged: bool, pub sudo: bool, + pub tailscale: bool, pub devices: Vec, pub branch: Option, } @@ -136,6 +137,7 @@ impl TaskArgs { .repo_copy_source(worktree_source) .privileged(if self.privileged { Some(true) } else { None }) .sudo(if self.sudo { Some(true) } else { None }) + .tailscale(if self.tailscale { Some(true) } else { None }) .devices(self.devices.clone()) .branch(self.branch.clone()) } diff --git a/src/context/tsk_config.rs b/src/context/tsk_config.rs index d5ba5f6..df8eebc 100644 --- a/src/context/tsk_config.rs +++ b/src/context/tsk_config.rs @@ -156,6 +156,27 @@ impl TskConfig { if let Some(ref squid_conf) = config.squid_conf { resolved.squid_conf = Some(squid_conf.clone()); } + if let Some(tailscale) = config.tailscale { + resolved.tailscale = tailscale; + } + if let Some(ref env_var) = config.tailscale_auth_key_env { + resolved.tailscale_auth_key_env = Some(env_var.clone()); + } + if let Some(ref key_file) = config.tailscale_auth_key_file { + resolved.tailscale_auth_key_file = Some(key_file.clone()); + } + if let Some(ref hostname) = config.tailscale_hostname { + resolved.tailscale_hostname = Some(hostname.clone()); + } + if let Some(accept_routes) = config.tailscale_accept_routes { + resolved.tailscale_accept_routes = accept_routes; + } + if let Some(host_aliases) = config.tailscale_host_aliases { + resolved.tailscale_host_aliases = host_aliases; + } + if let Some(ref up_args) = config.tailscale_up_args { + resolved.tailscale_up_args = Some(up_args.clone()); + } // host_ports: combine, deduplicate for &port in &config.host_ports { @@ -276,6 +297,31 @@ pub struct SharedConfig { pub squid_conf: Option, /// Path to a Squid proxy configuration file pub squid_conf_path: Option, + /// Join containers to the user's Tailscale tailnet (opt-in, default: false) + pub tailscale: Option, + /// Name of the environment variable holding the Tailscale auth key + /// (default: `TS_AUTHKEY`) + pub tailscale_auth_key_env: Option, + /// Path to a file containing the Tailscale auth key (supports `~` expansion) + pub tailscale_auth_key_file: Option, + /// Hostname to register on the tailnet (default: `tsk-`) + pub tailscale_hostname: Option, + /// Accept subnet routes advertised on the tailnet (default: false). + /// + /// Off by default because accepted routes are reachable from the sandbox + /// *over the tailnet, bypassing the Squid allowlist*. Only enable it when + /// you intend the sandbox to reach subnets behind a tailnet subnet router. + pub tailscale_accept_routes: Option, + /// Populate `/etc/hosts` with tailnet device name→IP aliases (default: true). + /// + /// tsk snapshots the peers visible to the host's `tailscale status` at task + /// creation so agents can reach tailnet devices by name, not just IP. Turn + /// off to keep the sandbox from learning your tailnet's device names/IPs. + pub tailscale_host_aliases: Option, + /// Extra arguments appended to `tailscale up`. Isolation-weakening flags + /// (e.g. `--exit-node`, `--advertise-routes`) are rejected — see + /// [`validate_tailscale_up_args`]. + pub tailscale_up_args: Option, } /// Per-stack configuration (e.g., custom Dockerfile setup commands) @@ -335,6 +381,32 @@ pub struct ResolvedConfig { pub env: Vec, /// Resolved Squid proxy configuration content pub squid_conf: Option, + /// Join containers to the user's Tailscale tailnet (default: false) + #[serde(default)] + pub tailscale: bool, + /// Name of the environment variable holding the Tailscale auth key + #[serde(default)] + pub tailscale_auth_key_env: Option, + /// Path to a file containing the Tailscale auth key + #[serde(default)] + pub tailscale_auth_key_file: Option, + /// Hostname to register on the tailnet + #[serde(default)] + pub tailscale_hostname: Option, + /// Accept subnet routes advertised on the tailnet (default: false) + #[serde(default)] + pub tailscale_accept_routes: bool, + /// Populate `/etc/hosts` with tailnet device name→IP aliases (default: true) + #[serde(default = "default_true")] + pub tailscale_host_aliases: bool, + /// Extra arguments appended to `tailscale up` + #[serde(default)] + pub tailscale_up_args: Option, +} + +/// serde default for `bool` fields that default to `true`. +fn default_true() -> bool { + true } impl Default for ResolvedConfig { @@ -356,6 +428,13 @@ impl Default for ResolvedConfig { volumes: Vec::new(), env: Vec::new(), squid_conf: None, + tailscale: false, + tailscale_auth_key_env: None, + tailscale_auth_key_file: None, + tailscale_hostname: None, + tailscale_accept_routes: false, + tailscale_host_aliases: true, + tailscale_up_args: None, } } } @@ -393,13 +472,154 @@ impl ResolvedConfig { !self.host_ports.is_empty() } - /// Extract proxy-specific configuration for fingerprinting and proxy management + /// Extract proxy-specific configuration for fingerprinting and proxy management. + /// + /// When Tailscale is enabled, the Squid configuration is extended with rules + /// allowing Tailscale's control plane and relays so `tailscaled` can come up + /// inside the sandbox. This changes the proxy fingerprint, so Tailscale tasks + /// get their own proxy container. pub fn proxy_config(&self) -> ResolvedProxyConfig { + let squid_conf = if self.tailscale { + Some(tailscale_squid_conf(self.squid_conf.as_deref())) + } else { + self.squid_conf.clone() + }; ResolvedProxyConfig { host_ports: self.host_ports.clone(), - squid_conf: self.squid_conf.clone(), + squid_conf, + } + } + + /// Name of the environment variable that holds the Tailscale auth key. + /// + /// Defaults to [`DEFAULT_TAILSCALE_AUTH_KEY_ENV`] when unset. + pub fn tailscale_auth_key_env_var(&self) -> &str { + self.tailscale_auth_key_env + .as_deref() + .unwrap_or(DEFAULT_TAILSCALE_AUTH_KEY_ENV) + } + + /// Hostname the sandbox registers on the tailnet. + /// + /// Defaults to `tsk-` so each sandbox is identifiable on the tailnet. + /// The result is sanitized to a DNS-label-safe form so it matches what + /// Tailscale actually registers (task IDs may contain `_`/uppercase, which + /// Tailscale silently rewrites — leaving logs and the tailnet out of sync). + pub fn tailscale_hostname_for(&self, task_id: &str) -> String { + let raw = self + .tailscale_hostname + .clone() + .unwrap_or_else(|| format!("tsk-{task_id}")); + sanitize_tailscale_hostname(&raw) + } +} + +/// Sanitizes a string into a DNS-label-safe tailnet hostname so it matches what +/// Tailscale actually registers: lowercase ASCII alphanumerics kept, every other +/// character folded to `-`, runs of `-` collapsed, leading/trailing dashes +/// trimmed, and truncated to the 63-char DNS-label limit. Falls back to `tsk` if +/// nothing usable remains. +fn sanitize_tailscale_hostname(name: &str) -> String { + let mut out = String::with_capacity(name.len().min(63)); + for c in name.chars() { + let mapped = if c.is_ascii_alphanumeric() { + c.to_ascii_lowercase() + } else { + '-' + }; + // Collapse runs of dashes. + if mapped == '-' && out.ends_with('-') { + continue; + } + out.push(mapped); + } + let trimmed = out.trim_matches('-'); + let capped: String = trimmed.chars().take(63).collect(); + let capped = capped.trim_end_matches('-'); + if capped.is_empty() { + "tsk".to_string() + } else { + capped.to_string() + } +} + +/// Flags that must not be passed through `tailscale_up_args` because they would +/// weaken or bypass the sandbox's network isolation. `--accept-routes` has its +/// own typed config field (`tailscale_accept_routes`) and must not be set here. +/// Stored without leading dashes — Tailscale's Go flag parser treats `-flag` and +/// `--flag` identically, so matching is done on the bare name. +/// +/// This is a best-effort operator footgun-guard, not a security boundary: the +/// agent owns the `tailscaled` socket and can reconfigure the tailnet at runtime +/// regardless (see docs/network-isolation.md). The real boundary is your +/// Tailscale ACLs + a tagged, ephemeral key. +pub const DENIED_TAILSCALE_UP_FLAGS: &[&str] = &[ + "exit-node", + "exit-node-allow-lan-access", + "advertise-exit-node", + "advertise-routes", + "accept-routes", + "accept-dns", + "netfilter-mode", +]; + +/// Validates operator-supplied `tailscale up` arguments, rejecting flags that +/// would weaken the sandbox's isolation (exit nodes, route advertisement, +/// re-enabling DNS, etc.). Accepts `--flag value`, `--flag=value`, and the +/// single-dash `-flag` spellings Tailscale's flag parser also honors. +pub fn validate_tailscale_up_args(args: &str) -> Result<(), String> { + for token in args.split_whitespace() { + if !token.starts_with('-') { + continue; + } + // Normalize: drop leading dashes and any `=value`, so `-exit-node=x`, + // `--exit-node x`, and `--exit-node` all reduce to `exit-node`. + let bare = token.trim_start_matches('-').split('=').next().unwrap_or(""); + if DENIED_TAILSCALE_UP_FLAGS.contains(&bare) { + return Err(format!( + "tailscale_up_args may not contain `{token}`: it would weaken the sandbox's \ + network isolation. Use the `tailscale_accept_routes` config field to reach \ + subnet routes; exit nodes and route advertisement are not supported for sandboxes." + )); } } + Ok(()) +} + +/// Default environment variable consulted for the Tailscale auth key. +pub const DEFAULT_TAILSCALE_AUTH_KEY_ENV: &str = "TS_AUTHKEY"; + +/// Squid rules that allow Tailscale's control plane, log service, and DERP relays. +/// +/// `tailscaled` reaches these over HTTPS through the sandbox proxy, which is the +/// only route out of the agent's internal network. +const TAILSCALE_SQUID_RULES: &str = "\ +# --- tsk: added because tailscale is enabled --- +acl tsk_tailscale_domains dstdomain .tailscale.com .tailscale.io +acl tsk_tailscale_ports port 443 +http_access allow tsk_tailscale_domains tsk_tailscale_ports +# --- end tsk tailscale rules --- +"; + +/// Builds the Squid configuration used when Tailscale is enabled. +/// +/// The Tailscale allow rules are **prepended** to the base configuration +/// (`base`, or the built-in default when `None`): Squid evaluates `http_access` +/// rules in order and the default configuration ends with `http_access deny all`, +/// so appended rules would never be reached. +fn tailscale_squid_conf(base: Option<&str>) -> String { + let base = base + .map(|conf| conf.to_string()) + .unwrap_or_else(default_squid_conf); + format!("{TAILSCALE_SQUID_RULES}\n{base}") +} + +/// Returns the built-in Squid configuration embedded in the binary. +fn default_squid_conf() -> String { + crate::assets::embedded::get_dockerfile_file("tsk-proxy", "squid.conf") + .ok() + .and_then(|bytes| String::from_utf8(bytes).ok()) + .unwrap_or_default() } /// Proxy-specific configuration extracted from ResolvedConfig. @@ -573,7 +793,7 @@ fn try_read_squid_conf(path: &Path) -> Option { } /// Expand leading `~` or `~/` in a path string to the user's home directory. -fn expand_tilde(path: &str) -> PathBuf { +pub(crate) fn expand_tilde(path: &str) -> PathBuf { if path == "~" { if let Ok(home) = env::var("HOME").or_else(|_| env::var("USERPROFILE")) { return PathBuf::from(home); @@ -1759,6 +1979,13 @@ setup = "RUN pip install numpy" value: "postgres://localhost/db".to_string(), }], squid_conf: Some("http_port 3128".to_string()), + tailscale: true, + tailscale_auth_key_env: Some("MY_TS_KEY".to_string()), + tailscale_auth_key_file: Some("~/.config/tsk/ts-authkey".to_string()), + tailscale_hostname: Some("sandbox".to_string()), + tailscale_accept_routes: true, + tailscale_host_aliases: true, + tailscale_up_args: Some("--ssh".to_string()), }; let json = serde_json::to_string(&config).unwrap(); @@ -1786,6 +2013,178 @@ setup = "RUN pip install numpy" assert_eq!(deserialized.env.len(), 1); assert_eq!(deserialized.env[0].name, "DB_URL"); assert_eq!(deserialized.squid_conf, Some("http_port 3128".to_string())); + assert!(deserialized.tailscale); + assert_eq!(deserialized.tailscale_auth_key_env_var(), "MY_TS_KEY"); + assert_eq!( + deserialized.tailscale_auth_key_file, + Some("~/.config/tsk/ts-authkey".to_string()) + ); + assert_eq!(deserialized.tailscale_hostname_for("abc123"), "sandbox"); + assert_eq!(deserialized.tailscale_up_args, Some("--ssh".to_string())); + + // Snapshots written before Tailscale support deserialize with it off + let legacy: ResolvedConfig = serde_json::from_str( + r#"{"agent":"claude","stack":"rust","dind":false,"memory_gb":12.0,"cpu":8, + "git_town":false,"host_ports":[],"setup":null,"stack_config":{}, + "agent_config":{},"volumes":[],"env":[],"squid_conf":null}"#, + ) + .unwrap(); + assert!(!legacy.tailscale); + assert_eq!(legacy.tailscale_auth_key_env_var(), "TS_AUTHKEY"); + assert_eq!(legacy.tailscale_hostname_for("abc123"), "tsk-abc123"); + } + + #[test] + fn test_tailscale_config_layering() { + let temp_dir = tempfile::TempDir::new().unwrap(); + let config_dir = temp_dir.path(); + + let toml_content = r#" +[defaults] +tailscale = true +tailscale_auth_key_env = "MY_TS_KEY" +tailscale_auth_key_file = "~/.config/tsk/ts-authkey" +tailscale_accept_routes = true + +[project.private-app] +tailscale_hostname = "private-app-sandbox" +tailscale_up_args = "--ssh" + +[project.public-app] +tailscale = false +"#; + std::fs::write(config_dir.join("tsk.toml"), toml_content).unwrap(); + let config = load_config(config_dir); + + let resolved = config.resolve_config("private-app", None, None); + assert!(resolved.tailscale, "defaults.tailscale should propagate"); + assert_eq!(resolved.tailscale_auth_key_env_var(), "MY_TS_KEY"); + assert_eq!( + resolved.tailscale_auth_key_file, + Some("~/.config/tsk/ts-authkey".to_string()), + "defaults.tailscale_auth_key_file should propagate" + ); + assert!( + resolved.tailscale_accept_routes, + "defaults.tailscale_accept_routes should propagate" + ); + assert_eq!( + resolved.tailscale_hostname_for("abc123"), + "private-app-sandbox" + ); + assert_eq!(resolved.tailscale_up_args, Some("--ssh".to_string())); + + // Project config can turn Tailscale back off + let resolved = config.resolve_config("public-app", None, None); + assert!(!resolved.tailscale); + + // Off by default with no configuration at all + let default_resolved = TskConfig::default().resolve_config("any", None, None); + assert!(!default_resolved.tailscale); + assert!( + !default_resolved.tailscale_accept_routes, + "accept_routes must default to false" + ); + assert_eq!(default_resolved.tailscale_auth_key_env_var(), "TS_AUTHKEY"); + assert_eq!( + default_resolved.tailscale_hostname_for("abc123"), + "tsk-abc123" + ); + } + + #[test] + fn test_validate_tailscale_up_args() { + // Benign flags pass, including empty + assert!(validate_tailscale_up_args("").is_ok()); + assert!(validate_tailscale_up_args("--ssh").is_ok()); + assert!(validate_tailscale_up_args("--advertise-tags=tag:ci --ssh").is_ok()); + + // Isolation-weakening flags are rejected in `--flag=x`, `--flag x`, AND + // single-dash `-flag` forms (Tailscale's parser treats -/-- alike). + for bad in [ + "--exit-node=100.64.0.1", + "--exit-node 100.64.0.1", + "-exit-node=100.64.0.1", + "-accept-routes", + "--advertise-routes=10.0.0.0/8", + "-advertise-routes=10.0.0.0/8", + "--advertise-exit-node", + "--accept-routes", + "--accept-dns=true", + "--netfilter-mode=off", + "--ssh --exit-node=x", + ] { + let err = validate_tailscale_up_args(bad).unwrap_err(); + assert!(err.contains("network isolation"), "{bad} -> {err}"); + } + } + + #[test] + fn test_expand_tilde() { + use std::path::PathBuf; + if let Ok(home) = env::var("HOME").or_else(|_| env::var("USERPROFILE")) { + assert_eq!(expand_tilde("~"), PathBuf::from(&home)); + assert_eq!( + expand_tilde("~/ts-authkey"), + PathBuf::from(&home).join("ts-authkey") + ); + } + // Non-tilde paths pass through unchanged + assert_eq!(expand_tilde("/abs/path"), PathBuf::from("/abs/path")); + assert_eq!(expand_tilde("rel/path"), PathBuf::from("rel/path")); + } + + #[test] + fn test_tailscale_hostname_sanitized() { + // Task IDs may contain `_`/uppercase; Tailscale rewrites those, so tsk + // sanitizes to match what actually registers on the tailnet. + let cfg = ResolvedConfig::default(); + assert_eq!(cfg.tailscale_hostname_for("Ab_9Xy"), "tsk-ab-9xy"); + // Custom hostnames are sanitized too. + let custom = ResolvedConfig { + tailscale_hostname: Some("My_Box!".to_string()), + ..Default::default() + }; + assert_eq!(custom.tailscale_hostname_for("zzz"), "my-box"); + } + + #[test] + fn test_proxy_config_allows_tailscale_domains_when_enabled() { + let disabled = ResolvedConfig::default(); + assert!(disabled.proxy_config().squid_conf.is_none()); + + // With no custom squid.conf, the built-in config is extended + let enabled = ResolvedConfig { + tailscale: true, + ..Default::default() + }; + let conf = enabled.proxy_config().squid_conf.unwrap(); + assert!(conf.contains("http_access allow tsk_tailscale_domains")); + assert!(conf.contains(".tailscale.com")); + assert!( + conf.contains("http_port 3128"), + "built-in squid.conf should still be present" + ); + + // Custom configuration is preserved, with the allow rules ahead of it so + // they are evaluated before any deny rule + let custom = ResolvedConfig { + tailscale: true, + squid_conf: Some("http_port 3128\nhttp_access deny all".to_string()), + ..Default::default() + }; + let conf = custom.proxy_config().squid_conf.unwrap(); + assert!(conf.ends_with("http_port 3128\nhttp_access deny all")); + assert!( + conf.find("tsk_tailscale_domains").unwrap() + < conf.find("http_access deny all").unwrap() + ); + + // Tailscale tasks get their own proxy container + assert_ne!( + enabled.proxy_config().fingerprint(), + disabled.proxy_config().fingerprint() + ); } #[test] diff --git a/src/docker/composer.rs b/src/docker/composer.rs index 95f8eb7..7614077 100644 --- a/src/docker/composer.rs +++ b/src/docker/composer.rs @@ -119,9 +119,32 @@ impl DockerComposer { }; vars.insert("SUDO".to_string(), sudo_content); + let tailscale_content = match resolved_config { + Some(config) if config.tailscale => Self::tailscale_layer(), + _ => String::new(), + }; + vars.insert("TAILSCALE".to_string(), tailscale_content); + vars } + /// Dockerfile content that installs Tailscale and its startup script. + /// + /// The snippet is compiled into the binary, so the error arm is a + /// can't-happen guard. If it ever fired, the image would build **without** + /// the startup script and the task would then fail at container start + /// (`tsk-tailscale-up: not found`) — i.e. the failure moves from build time + /// to run time; it does not silently run the agent without the tailnet. + fn tailscale_layer() -> String { + match crate::assets::embedded::get_dockerfile("features/tailscale").map(String::from_utf8) { + Ok(Ok(content)) => content, + _ => { + eprintln!("Warning: Failed to load the embedded Tailscale Docker layer"); + String::new() + } + } + } + /// Extract build arguments from Dockerfile content fn extract_build_args(&self, dockerfile_content: &str) -> Result> { let mut build_args = HashSet::new(); @@ -360,6 +383,39 @@ RUN echo "Hello" ); } + #[test] + fn test_compose_with_tailscale_injects_layer() { + let composer = create_test_composer(); + let config = crate::docker::layers::DockerImageConfig::new( + "default".to_string(), + "claude".to_string(), + "default".to_string(), + ); + + let resolved = ResolvedConfig { + tailscale: true, + ..Default::default() + }; + + let composed = composer.compose(&config, None, Some(&resolved)).unwrap(); + assert!( + composed.dockerfile_content.contains("tailscaled"), + "Tailscale layer should install tailscaled" + ); + assert!( + composed + .dockerfile_content + .contains("/usr/local/bin/tsk-tailscale-up"), + "Tailscale layer should install the startup script" + ); + + // Disabled by default: no Tailscale content in the Dockerfile + let composed = composer + .compose(&config, None, Some(&ResolvedConfig::default())) + .unwrap(); + assert!(!composed.dockerfile_content.contains("tailscale")); + } + #[test] fn test_compose_without_sudo_has_no_sudoers() { let composer = create_test_composer(); diff --git a/src/docker/mod.rs b/src/docker/mod.rs index ae83837..11a7642 100644 --- a/src/docker/mod.rs +++ b/src/docker/mod.rs @@ -99,6 +99,195 @@ fn cgroup_controller_available(controller: &str) -> bool { true } +/// Path to the Tailscale startup script installed by the tailscale Docker layer. +const TAILSCALE_STARTUP_SCRIPT: &str = "/usr/local/bin/tsk-tailscale-up"; +/// TUN device `tailscaled` uses for kernel networking mode. +const TUN_DEVICE_PATH: &str = "/dev/net/tun"; +/// Destinations that must bypass the Squid proxy when Tailscale is enabled: +/// the tailnet CGNAT range (IPv4), the tailnet ULA range (IPv6), and MagicDNS +/// names. +const TAILSCALE_NO_PROXY: &str = "100.64.0.0/10,fd7a:115c:a1e0::/48,.ts.net"; +/// SOCKS5 endpoint `tailscaled` exposes in userspace mode (no TUN device), used +/// as `ALL_PROXY` so the tailnet is reachable when kernel routing isn't. Uses +/// `socks5h://` so `tailscaled` resolves tailnet peer names from its netmap +/// (MagicDNS-off notwithstanding); `/etc/hosts` aliases cover the kernel-mode +/// path where no proxy is in play. +const TAILSCALE_USERSPACE_SOCKS5: &str = "socks5h://localhost:1055"; + +/// Resolves the Tailscale auth key for a task from the configured sources. +/// +/// The key is looked up in the environment variable named by +/// `tailscale_auth_key_env` (default `TS_AUTHKEY`), falling back to the contents +/// of `tailscale_auth_key_file`. Keys are read at container start so they are +/// never written to the task's config snapshot. +pub(crate) fn resolve_tailscale_auth_key( + resolved: &crate::context::ResolvedConfig, +) -> Result { + resolve_tailscale_auth_key_with(resolved, |name| std::env::var(name).ok()) +} + +/// [`resolve_tailscale_auth_key`] with an injectable environment lookup. +fn resolve_tailscale_auth_key_with( + resolved: &crate::context::ResolvedConfig, + env_lookup: impl Fn(&str) -> Option, +) -> Result { + let env_var = resolved.tailscale_auth_key_env_var(); + + if let Some(key) = env_lookup(env_var) { + let key = key.trim().to_string(); + if !key.is_empty() { + return Ok(key); + } + } + + if let Some(ref path) = resolved.tailscale_auth_key_file { + let path = crate::context::tsk_config::expand_tilde(path); + let contents = std::fs::read_to_string(&path).map_err(|e| { + format!( + "Failed to read tailscale_auth_key_file '{}': {e}", + path.display() + ) + })?; + let key = contents.trim().to_string(); + if key.is_empty() { + return Err(format!( + "tailscale_auth_key_file '{}' is empty", + path.display() + )); + } + return Ok(key); + } + + Err(format!( + "Tailscale is enabled but no auth key was found. Set ${env_var} or set \ + tailscale_auth_key_file in tsk.toml." + )) +} + +/// Collects tailnet device name→IP aliases from the host's `tailscale status`, +/// as Docker `ExtraHosts` entries (`name:ip`) written into the container's +/// `/etc/hosts` at creation. This lets a non-root agent reach tailnet devices +/// by name without any in-container privilege. Returns empty if the `tailscale` +/// CLI is missing or errors (non-fatal — names just won't resolve). Skipped +/// under `cfg(test)` so unit tests stay hermetic. +fn tailnet_host_aliases() -> Vec { + if cfg!(test) { + return Vec::new(); + } + let output = match std::process::Command::new("tailscale") + .args(["status", "--json"]) + .output() + { + Ok(o) if o.status.success() => o.stdout, + _ => return Vec::new(), + }; + match serde_json::from_slice::(&output) { + Ok(json) => parse_tailnet_aliases(&json), + Err(_) => Vec::new(), + } +} + +/// Parses `tailscale status --json` into `name:ip` ExtraHosts entries, emitting +/// both the FQDN and the short host label for each node (Self + Peers). +fn parse_tailnet_aliases(json: &serde_json::Value) -> Vec { + fn push_node(aliases: &mut Vec, node: &serde_json::Value) { + let ip = node + .get("TailscaleIPs") + .and_then(|v| v.as_array()) + .and_then(|a| a.first()) + .and_then(|v| v.as_str()); + let dns = node.get("DNSName").and_then(|v| v.as_str()); + if let (Some(ip), Some(dns)) = (ip, dns) { + let fqdn = dns.trim_end_matches('.'); + // Defensive: names come from peer-controlled data, so skip anything + // that isn't a clean DNS name with a parseable IP — a stray `:` or + // space would otherwise produce a malformed `name:ip` ExtraHosts line + // that Docker rejects, failing container creation. + if fqdn.is_empty() + || ip.parse::().is_err() + || !fqdn + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'.' || b == b'-') + { + return; + } + aliases.push(format!("{fqdn}:{ip}")); + let short = fqdn.split('.').next().unwrap_or(fqdn); + if short != fqdn && !short.is_empty() { + aliases.push(format!("{short}:{ip}")); + } + } + } + + let mut aliases = Vec::new(); + if let Some(self_node) = json.get("Self") { + push_node(&mut aliases, self_node); + } + if let Some(peers) = json.get("Peer").and_then(|v| v.as_object()) { + for peer in peers.values() { + push_node(&mut aliases, peer); + } + } + aliases +} + +/// Assembles the container's `ExtraHosts`: the proxy container mapping (when +/// network isolation is on) followed by any tailnet device aliases. Returns +/// `None` when there are no entries so the runtime keeps its default `/etc/hosts`. +fn build_extra_hosts(proxy_entry: Option, tailnet_aliases: Vec) -> Option> { + let mut hosts = Vec::new(); + if let Some(entry) = proxy_entry { + hosts.push(entry); + } + hosts.extend(tailnet_aliases); + if hosts.is_empty() { + None + } else { + Some(hosts) + } +} + +/// Prefixes a container command with the Tailscale startup script. +/// +/// The sandbox joins the tailnet before the agent starts; if the script fails +/// the container exits rather than running the agent without tailnet access. +/// After the join, `TS_AUTHKEY` is `unset` and the agent command is **`exec`'d**, +/// which replaces the process image *after* the unset so the key is cleared from +/// the kernel-visible `/proc//environ`, not just glibc's in-memory `environ`. +/// (A plain `unset` without the exec would leave the key readable in a lingering +/// PID-1 shell whenever the agent command is a pipeline, which it normally is.) +/// The key still lives in the container's `Config.Env` — visible to anyone who +/// can `docker inspect` the container on the host — for the container's lifetime; +/// what this prevents is the *in-container agent* recovering it. +/// An empty command (image default) is returned unchanged. +fn with_tailscale_startup(command: Vec) -> Vec { + if command.is_empty() { + return command; + } + + let prefix = + format!("{TAILSCALE_STARTUP_SCRIPT} || exit 1\nunset TS_AUTHKEY TSK_TAILSCALE_UP_ARGS"); + + // Always `exec` the (quoted) agent command — including the `sh -c