Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
466 changes: 447 additions & 19 deletions Cargo.lock

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ sha2 = "0.11"
ratatui = { version = "0.30", features = ["unstable-rendered-line-info"] }
crossterm = "0.29"
glob = "0.3"
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] }

[dev-dependencies]
assert_cmd = "2.2"
Expand Down
129 changes: 129 additions & 0 deletions README.md

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions dockerfiles/base/default.dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
77 changes: 77 additions & 0 deletions dockerfiles/features/tailscale.dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# 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' \
'# Warn on an untagged node: it uses your personal tailnet identity and,' \
'# with a non-ephemeral key, will not auto-remove. Minted keys are always tagged.' \
'if tailscale --socket="$SOCKET" status --json | jq -e "((.Self.Tags // []) | length) == 0" >/dev/null 2>&1; then' \
' echo "tsk: WARNING - this sandbox node is UNTAGGED and uses your personal tailnet identity."' \
' echo "tsk: Use a tagged, ephemeral auth key or configure key minting so nodes are"' \
' echo "tsk: tagged and auto-remove. See the README Tailscale section."' \
'fi' \
> /usr/local/bin/tsk-tailscale-up && \
chmod 0755 /usr/local/bin/tsk-tailscale-up
USER agent
2 changes: 1 addition & 1 deletion docs/docker-builds.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
29 changes: 29 additions & 0 deletions docs/network-isolation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<pid>/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.
Expand Down
Loading