diff --git a/Cargo.lock b/Cargo.lock index aae551d..07be9d8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -62,7 +62,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -73,7 +73,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -150,7 +150,7 @@ dependencies = [ "polling", "rustix", "slab", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -208,7 +208,7 @@ dependencies = [ "rustix", "signal-hook-registry", "slab", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -427,6 +427,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + [[package]] name = "chrono" version = "0.4.44" @@ -851,7 +862,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -1075,8 +1086,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -1098,10 +1111,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 6.0.0", + "rand_core 0.10.1", "wasip2", "wasip3", + "wasm-bindgen", ] [[package]] @@ -1277,19 +1293,38 @@ dependencies = [ "winapi", ] +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + [[package]] name = "hyper-util" version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ + "base64", "bytes", "futures-channel", "futures-util", "http", "http-body", "hyper", + "ipnet", "libc", + "percent-encoding", "pin-project-lite", "socket2", "tokio", @@ -1484,6 +1519,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + [[package]] name = "is-terminal" version = "0.4.17" @@ -1492,7 +1533,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -1532,6 +1573,8 @@ version = "0.3.92" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cc4c90f45aa2e6eacbe8645f77fdea542ac97a494bcd117a67df9ff4d611f995" dependencies = [ + "cfg-if", + "futures-util", "once_cell", "wasm-bindgen", ] @@ -1669,6 +1712,12 @@ dependencies = [ "hashbrown 0.16.1", ] +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "mac-notification-sys" version = "0.6.12" @@ -1727,7 +1776,7 @@ dependencies = [ "libc", "log", "wasi", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -1736,7 +1785,7 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ffa00dec017b5b1a8b7cf5e2c008bfda1aa7e0697ac1508b491fdf2622fb4d8" dependencies = [ - "rand", + "rand 0.8.5", ] [[package]] @@ -2013,7 +2062,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" dependencies = [ "phf_shared", - "rand", + "rand 0.8.5", ] [[package]] @@ -2084,7 +2133,7 @@ dependencies = [ "hermit-abi", "pin-project-lite", "rustix", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -2184,6 +2233,62 @@ dependencies = [ "memchr", ] +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" +dependencies = [ + "bytes", + "getrandom 0.4.2", + "lru-slab", + "rand 0.10.2", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + [[package]] name = "quote" version = "1.0.45" @@ -2213,7 +2318,18 @@ checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ "libc", "rand_chacha", - "rand_core", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.2", + "rand_core 0.10.1", ] [[package]] @@ -2223,7 +2339,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.6.4", ] [[package]] @@ -2235,6 +2351,21 @@ dependencies = [ "getrandom 0.2.17", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + [[package]] name = "ratatui" version = "0.30.0" @@ -2367,6 +2498,58 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rsqlite-vfs" version = "0.1.0" @@ -2426,6 +2609,12 @@ dependencies = [ "walkdir", ] +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + [[package]] name = "rustc_version" version = "0.4.1" @@ -2445,7 +2634,42 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", ] [[package]] @@ -2640,7 +2864,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -2694,6 +2918,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "1.0.109" @@ -2716,6 +2946,15 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + [[package]] name = "synstructure" version = "0.13.2" @@ -2760,7 +2999,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -2913,6 +3152,21 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "tokio" version = "1.50.0" @@ -2927,7 +3181,7 @@ dependencies = [ "signal-hook-registry", "socket2", "tokio-macros", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -2941,6 +3195,16 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "tokio-stream" version = "0.1.18" @@ -3016,6 +3280,45 @@ version = "1.1.0+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d282ade6016312faf3e41e57ebbba0c073e4056dab1232ab1cb624199648f8ed" +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.11.0", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + [[package]] name = "tower-service" version = "0.3.3" @@ -3082,6 +3385,7 @@ dependencies = [ "notify-rust", "predicates", "ratatui", + "reqwest", "rusqlite", "rust-embed", "serde", @@ -3116,7 +3420,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -3154,6 +3458,12 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "url" version = "2.5.8" @@ -3277,6 +3587,16 @@ dependencies = [ "wasm-bindgen-shared", ] +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d1faf851e778dfa54db7cd438b70758eba9755cb47403f3496edd7c8fc212f0" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "wasm-bindgen-macro" version = "0.2.115" @@ -3343,6 +3663,35 @@ dependencies = [ "semver", ] +[[package]] +name = "web-sys" +version = "0.3.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84cde8507f4d7cfcb1185b8cb5890c494ffea65edbe1ba82cfd63661c805ed94" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "wezterm-bidi" version = "0.2.3" @@ -3437,7 +3786,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -3585,6 +3934,15 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -3594,6 +3952,22 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + [[package]] name = "windows-threading" version = "0.1.0" @@ -3612,6 +3986,54 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + [[package]] name = "winnow" version = "0.7.15" @@ -3785,7 +4207,7 @@ dependencies = [ "tracing", "uds_windows", "uuid", - "windows-sys", + "windows-sys 0.61.2", "winnow 0.7.15", "zbus_macros", "zbus_names", @@ -3859,6 +4281,12 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + [[package]] name = "zerotrie" version = "0.2.3" diff --git a/Cargo.toml b/Cargo.toml index 779d1f3..22395bf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/README.md b/README.md index 7810447..8b2e009 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,132 @@ 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: let tsk mint a fresh ephemeral, tagged key per task --- +# Provide EITHER a PAT or an OAuth client (OAuth takes precedence if both set). +# tailscale_api_key_env = "TS_API_KEY" # env var holding a PAT (tskey-api-...) +# tailscale_api_key_file = "~/.config/tsk/ts-api-key" +# tailscale_oauth_client_id = "k123..." # OAuth client id +# tailscale_oauth_secret_env = "TS_OAUTH_SECRET" # env var holding the OAuth secret +# tailscale_oauth_secret_file = "~/.config/tsk/ts-oauth-secret" +# tailscale_tailnet = "-" # default "-" = credential's default tailnet +# tailscale_tags = ["tag:tsk-sandbox"] # tags for minted keys/nodes (default) +``` + +#### Recommended auth-key setup (least privilege) + +The config example above shows the recommended setup: configure a Tailscale API credential and let tsk mint a fresh key per task (see "Cleanup of sandbox nodes" below). If you'd rather not grant tsk API access, you can instead bring your own key. + +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 a few minutes after the task ends (no stale `tsk-*` nodes piling up on your tailnet). Letting tsk mint the key per task (above) is the more robust way to guarantee this, since a manually-created key only stays ephemeral if you remember to set that flag every time you regenerate it. +- **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.) + +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" +``` + +**Cleanup of sandbox nodes.** Each sandbox joins as a `tsk-` node. To keep the admin console from filling up with old nodes, the node must be **ephemeral** — Tailscale then auto-removes it a few minutes after the sandbox stops. The most robust way is to let tsk **mint the key**: configure a Tailscale API credential (a one-click **personal access token**, or an **OAuth client** for non-expiring, tightly-scoped access, via `tailscale_api_key_env` / `tailscale_oauth_client_id`) and tsk creates a fresh single-use, ephemeral, tagged key per task — so nodes are always tagged (`tag:tsk-sandbox` by default) and always auto-remove, with no way to misconfigure them. There's no default env var for the mint credential — minting only kicks in once you configure one of the fields above. If you instead bring your own key (`TS_AUTHKEY` / `tailscale_auth_key_file`), you are responsible for making it **ephemeral and tagged**; a non-ephemeral or untagged key leaves nodes behind and attributes them to your personal identity (the sandbox prints a warning when it detects an untagged node). A manually-generated reusable key remains a practical fallback, but minting is the robust, hands-off option. + +#### Scope the sandbox tag with a least-privilege ACL + +The sandboxed agent is untrusted and holds `NET_ADMIN`, so **your Tailscale ACL and the `tag:tsk-sandbox` tag are the real security boundary — not tsk's flags.** Before your first Tailscale task, define the tag and scope it tightly. + +Tailscale ACLs are **default-deny**: a freshly-tagged `tag:tsk-sandbox` node can reach *nothing* on your tailnet until a rule grants it. The failure mode to guard against is the opposite — an existing **broad** rule (`src: ["*"]`, or an `autogroup` that covers the tag) silently handing every sandbox your whole tailnet. So the job is: grant the tag only the few hosts/ports an agent needs, and make sure no wildcard rule already covers it. + +**Scope by tag, never by hostname.** Write rules against `tag:tsk-sandbox`, not against the `tsk-` node name. Tailscale ACLs cannot match a hostname prefix like `tsk-*` at all — `src`/`dst` accept tags, users, groups, autogroups, hosts, and IPs, not name globs. The hostname is also only cosmetic and **is modifiable**: it defaults to `tsk-` but you can override it per task with `tailscale_hostname` (names are sanitized to a DNS-safe form). The **tag**, by contrast, is applied by tsk to every minted key and can't be changed by the sandbox, so it's the only reliable anchor — a renamed node keeps its tag and therefore its ACL scope. (If you bring your own key instead of minting, *you* are responsible for tagging it; an untagged node has no tag to scope, which is why tsk warns about it.) + +A minimal, default-deny policy (HuJSON, Tailscale admin console → Access Controls): + +```jsonc +{ + "tagOwners": { + // Who may mint/assign keys for the sandbox tag. A PAT's user must be an + // owner to mint with a PAT; an OAuth client is granted this tag when you + // create it, and the tag must be defined here either way. + "tag:tsk-sandbox": ["autogroup:admin"], + }, + + "acls": [ + // Grant the sandbox ONLY what agents actually need — enumerate dst + // host:port explicitly. Everything else is denied by default. + { + "action": "accept", + "src": ["tag:tsk-sandbox"], + "dst": ["tag:staging-db:5432", "tag:internal-api:443"], + }, + + // ...your other rules... + // ⚠️ Audit every broad rule: a line like + // { "action": "accept", "src": ["*"], "dst": ["*:*"] } + // (or any autogroup that includes tagged nodes) would give every sandbox + // your entire tailnet. Exclude tag:tsk-sandbox from such rules. + ], + + // Do NOT grant the sandbox tag SSH access unless you specifically intend to. + "ssh": [], +} +``` + +**Best-practices checklist:** + +- **Always tag, never personal.** Use `tag:tsk-sandbox` (or your own tag via `tailscale_tags`); an untagged key gives the sandbox your full personal tailnet access. +- **Default-deny, enumerate up.** Grant the tag only specific `dst` `host:port`s. Don't reuse a broad "allow internal" rule for it. +- **Prefer minting.** A minted key is ephemeral, single-use, and always tagged, so nodes auto-remove and can't be misconfigured. For bring-your-own keys, set an **ephemeral, tagged** key with a sensible expiry and rotate it. +- **Scope the mint credential.** Give the **OAuth client** only the `auth_keys` scope plus ownership of `tag:tsk-sandbox`; give a **PAT** a short expiry and rotate it. The credential stays on the trusted host, never in the sandbox. +- **Keep routes off.** Leave `tailscale_accept_routes` at its default `false` unless a task genuinely needs a whole subnet — accepted routes bypass the Squid allowlist. +- **No SSH/exit-node for the tag.** tsk already rejects exit-node/route-advertising flags; don't re-add reachability through your ACL. + +**Quick start:** + +1. Add `tag:tsk-sandbox` to `tagOwners` and a narrow `acls` grant (above). +2. Create a mint credential: a **PAT** (admin console → *Settings → Keys → Generate access token*) or an **OAuth client** scoped to `auth_keys` + `tag:tsk-sandbox`. +3. Expose it and point tsk at it — `export TS_API_KEY=tskey-api-...` with `tailscale_api_key_env = "TS_API_KEY"` (or the `tailscale_oauth_*` fields). +4. Run a task: `tsk run --tailscale -t feat -n sync-schema -p "Sync the staging schema"`. + +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..b9e8806 --- /dev/null +++ b/dockerfiles/features/tailscale.dockerfile @@ -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 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/docs/superpowers/plans/2026-07-31-tailscale-node-cleanup.md b/docs/superpowers/plans/2026-07-31-tailscale-node-cleanup.md new file mode 100644 index 0000000..17fdff7 --- /dev/null +++ b/docs/superpowers/plans/2026-07-31-tailscale-node-cleanup.md @@ -0,0 +1,848 @@ +# Tailscale Sandbox Node Cleanup Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Stop the Tailscale admin console from accumulating leftover `tsk-*` nodes by minting a fresh ephemeral, tagged auth key per sandbox (opt-in, PAT or OAuth), while keeping the existing bring-your-own-key path with an untagged-node warning. + +**Architecture:** When a Tailscale API mint credential is configured, tsk calls the Tailscale API at container start to mint a single-use, ephemeral, tagged auth key for that one task; the ephemeral node auto-removes from the tailnet shortly after the sandbox stops. When no mint credential is configured, tsk falls back to today's `TS_AUTHKEY`/key-file resolution and the container prints a warning if the joined node is untagged. The mint credential lives on the host and is never passed into the container or written to the task's config snapshot. + +**Tech Stack:** Rust, tokio (async), reqwest (new HTTP dependency, rustls-tls), serde_json, Docker/Podman via bollard, jq (already in the base image) for the in-container tag check. + +## Global Constraints + +- Language: Rust. Use `just test` (runs `cargo test -q`), `just format` (`cargo fmt`), `just lint` (`cargo clippy --all-targets -- -D warnings` and `cargo clippy -- -D warnings`). +- No `#[allow(dead_code)]` directives. No `unsafe` blocks. +- Conventional commits: `feat:` for the user-facing minting capability, `docs:` for documentation, `test:` for test-only additions. +- New dependency: `reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] }` — rustls (not OpenSSL) to avoid a system libssl dependency. +- Default tag for minted keys/nodes: `tag:tsk-sandbox`. +- Minting is **opt-in**: only attempted when `tailscale_oauth_client_id`, `tailscale_api_key_env`, or `tailscale_api_key_file` is set. There is **no** default env var for the mint credential (unlike `tailscale_auth_key_env`, which defaults to `TS_AUTHKEY`). +- Secrets (PAT, OAuth secret) are read live from the host at container start; only their *references* (env-var names, file paths) may enter the config snapshot. +- Minting happens at container start (in `DockerManager::run_task_container`), not at task creation — consistent with how `resolve_tailscale_auth_key` already reads the key at start. +- Existing bring-your-own-key behavior (`resolve_tailscale_auth_key`) must remain unchanged and is the fallback. + +--- + +### Task 1: Add Tailscale mint config fields + +Adds the seven new config fields to both `SharedConfig` (optional, user-facing) and `ResolvedConfig` (resolved), plus the layering-merge logic and `Default`. No accessors or consumers yet — those land in Task 2 — so this task stays clippy-clean on its own (struct fields are consumed by the serde derive and the merge loop). + +**Files:** +- Modify: `src/context/tsk_config.rs` — `SharedConfig` (struct ends at `:325`), `resolve_config` merge block (Tailscale merges around `:159-179`), `ResolvedConfig` (ends `:405`), `ResolvedConfig::default` (`tailscale_*` defaults around `:431-437`). +- Test: `src/context/tsk_config.rs` (inline `#[cfg(test)]` module — this is an allowed exception to the AppContext test rule per CLAUDE.md: "Tests in `src/context/*` that are directly testing TskEnv or TskConfig functionality"). + +**Interfaces:** +- Produces (on `SharedConfig`, all `Option`, `pub`): `tailscale_api_key_env: Option`, `tailscale_api_key_file: Option`, `tailscale_oauth_client_id: Option`, `tailscale_oauth_secret_env: Option`, `tailscale_oauth_secret_file: Option`, `tailscale_tailnet: Option`, `tailscale_tags: Option>`. +- Produces (on `ResolvedConfig`, same names/types, `pub`, each `#[serde(default)]`). + +- [ ] **Step 1: Write the failing test** + +Add to the `#[cfg(test)]` module in `src/context/tsk_config.rs`: + +This mirrors the existing `test_tailscale_config_layering` (`:2038`) exactly: write a `tsk.toml`, load it with the module's `load_config(dir)` helper, and resolve with `resolve_config(project, None, None)` (three args). + +```rust +#[test] +fn test_tailscale_mint_config_layering() { + let temp_dir = tempfile::TempDir::new().unwrap(); + let config_dir = temp_dir.path(); + + let toml_content = r#" +[defaults] +tailscale = true +tailscale_oauth_client_id = "kABC123" +tailscale_oauth_secret_env = "TS_OAUTH_SECRET" +tailscale_tailnet = "example.com" +tailscale_tags = ["tag:tsk-sandbox"] +"#; + std::fs::write(config_dir.join("tsk.toml"), toml_content).unwrap(); + let config = load_config(config_dir); + + let resolved = config.resolve_config("any-project", None, None); + assert_eq!( + resolved.tailscale_oauth_client_id.as_deref(), + Some("kABC123") + ); + assert_eq!( + resolved.tailscale_oauth_secret_env.as_deref(), + Some("TS_OAUTH_SECRET") + ); + assert_eq!(resolved.tailscale_tailnet.as_deref(), Some("example.com")); + assert_eq!( + resolved.tailscale_tags, + Some(vec!["tag:tsk-sandbox".to_string()]) + ); + // Unset PAT fields stay None. + assert!(resolved.tailscale_api_key_env.is_none()); + assert!(resolved.tailscale_api_key_file.is_none()); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cargo test -q test_tailscale_mint_config_layering` +Expected: FAIL to compile — `SharedConfig`/`ResolvedConfig` have no `tailscale_oauth_client_id` field. + +- [ ] **Step 3: Add fields to `SharedConfig`** + +In `src/context/tsk_config.rs`, immediately after the `tailscale_up_args` field in `SharedConfig` (`:324`): + +```rust + /// Name of the environment variable holding a Tailscale API access token + /// (PAT) used to mint a per-task ephemeral auth key. Setting this (or + /// `tailscale_api_key_file` / `tailscale_oauth_client_id`) opts into minting. + pub tailscale_api_key_env: Option, + /// Path to a file containing a Tailscale API access token (PAT), `~`-expanded. + pub tailscale_api_key_file: Option, + /// OAuth client ID used to mint per-task ephemeral auth keys. Takes + /// precedence over the PAT fields when set. + pub tailscale_oauth_client_id: Option, + /// Name of the environment variable holding the OAuth client secret. + pub tailscale_oauth_secret_env: Option, + /// Path to a file containing the OAuth client secret, `~`-expanded. + pub tailscale_oauth_secret_file: Option, + /// Tailnet to mint keys in (default: `-`, the credential's default tailnet). + pub tailscale_tailnet: Option, + /// Tags applied to minted keys/nodes (default: `["tag:tsk-sandbox"]`). + pub tailscale_tags: Option>, +``` + +- [ ] **Step 4: Add fields to `ResolvedConfig`** + +Immediately after the `tailscale_up_args` field in `ResolvedConfig` (`:404`): + +```rust + /// Env var holding a Tailscale API access token (PAT) for minting. + #[serde(default)] + pub tailscale_api_key_env: Option, + /// File holding a Tailscale API access token (PAT) for minting. + #[serde(default)] + pub tailscale_api_key_file: Option, + /// OAuth client ID for minting per-task ephemeral auth keys. + #[serde(default)] + pub tailscale_oauth_client_id: Option, + /// Env var holding the OAuth client secret. + #[serde(default)] + pub tailscale_oauth_secret_env: Option, + /// File holding the OAuth client secret. + #[serde(default)] + pub tailscale_oauth_secret_file: Option, + /// Tailnet to mint keys in (default resolved via accessor to `-`). + #[serde(default)] + pub tailscale_tailnet: Option, + /// Tags applied to minted keys/nodes (default resolved via accessor). + #[serde(default)] + pub tailscale_tags: Option>, +``` + +- [ ] **Step 5: Add defaults to `ResolvedConfig::default`** + +In the `impl Default for ResolvedConfig` block, immediately after `tailscale_up_args: None,` (`:437`): + +```rust + tailscale_api_key_env: None, + tailscale_api_key_file: None, + tailscale_oauth_client_id: None, + tailscale_oauth_secret_env: None, + tailscale_oauth_secret_file: None, + tailscale_tailnet: None, + tailscale_tags: None, +``` + +- [ ] **Step 6: Add merge logic in `resolve_config`** + +In the Tailscale merge block, immediately after the `tailscale_up_args` merge (`:177-179`): + +```rust + if let Some(ref v) = config.tailscale_api_key_env { + resolved.tailscale_api_key_env = Some(v.clone()); + } + if let Some(ref v) = config.tailscale_api_key_file { + resolved.tailscale_api_key_file = Some(v.clone()); + } + if let Some(ref v) = config.tailscale_oauth_client_id { + resolved.tailscale_oauth_client_id = Some(v.clone()); + } + if let Some(ref v) = config.tailscale_oauth_secret_env { + resolved.tailscale_oauth_secret_env = Some(v.clone()); + } + if let Some(ref v) = config.tailscale_oauth_secret_file { + resolved.tailscale_oauth_secret_file = Some(v.clone()); + } + if let Some(ref v) = config.tailscale_tailnet { + resolved.tailscale_tailnet = Some(v.clone()); + } + if let Some(ref v) = config.tailscale_tags { + resolved.tailscale_tags = Some(v.clone()); + } +``` + +Note: this merge loop runs once per config layer (defaults, then project override), so the highest-priority `Some` wins for scalars, and `tailscale_tags` is replaced wholesale by the highest-priority layer that sets it (not combined) — the intended behavior for tags. + +- [ ] **Step 7: Run test to verify it passes** + +Run: `cargo test -q test_tailscale_mint_config_layering` +Expected: PASS + +- [ ] **Step 8: Verify existing config tests still pass** + +Run: `cargo test -q --lib context::tsk_config` +Expected: PASS (all existing Tailscale/config tests green) + +- [ ] **Step 9: Commit** + +```bash +git add src/context/tsk_config.rs +git commit -m "feat: add Tailscale mint credential config fields" +``` + +--- + +### Task 2: Mint an ephemeral tagged key per task + +Adds the `reqwest` dependency, a new `src/tailscale.rs` module with the mint logic (pure request-building/response-parsing plus the async glue), the `ResolvedConfig` accessors, and wires minting into `run_task_container` behind the mint-credential check. This is the atomic "minting works" deliverable — config accessors, module, and wiring all reference each other, so they land together and the task ends clippy-clean. + +**Files:** +- Modify: `Cargo.toml` — add `reqwest`. +- Create: `src/tailscale.rs` — mint module. +- Modify: `src/main.rs:9` area — add `mod tailscale;` alongside `mod docker;`. +- Modify: `src/context/tsk_config.rs` — add three accessors on `ResolvedConfig` near `tailscale_auth_key_env_var` (`:496`). +- Modify: `src/docker/mod.rs:934-938` — branch to minting when a mint credential is configured. +- Test: inline `#[cfg(test)]` in `src/tailscale.rs`; accessor tests inline in `src/context/tsk_config.rs`. + +**Interfaces:** +- Consumes (from Task 1): the seven `ResolvedConfig` `tailscale_*` mint fields; `crate::context::tsk_config::expand_tilde` (existing `pub fn`, used at `src/docker/mod.rs:144`); `crate::context::ResolvedConfig`. +- Produces (in `crate::tailscale`, all `pub`): + - `const DEFAULT_TAILSCALE_TAG: &str = "tag:tsk-sandbox";` + - `enum MintCredential { Pat(String), OAuth { client_id: String, secret: String } }` + - `fn resolve_mint_credential(resolved: &ResolvedConfig, env_lookup: impl Fn(&str) -> Option) -> Result, String>` + - `fn build_mint_request_body(tags: &[String], expiry_secs: u64, description: &str) -> serde_json::Value` + - `fn parse_mint_response(body: &str) -> Result` + - `fn parse_oauth_token_response(body: &str) -> Result` + - `async fn mint_tailscale_auth_key(resolved: &ResolvedConfig, task_id: &str) -> Result` +- Produces (on `ResolvedConfig`, `pub`): `fn has_tailscale_mint_credential(&self) -> bool`, `fn tailscale_tailnet(&self) -> &str`, `fn tailscale_tags(&self) -> Vec`. + +- [ ] **Step 1: Add the reqwest dependency** + +In `Cargo.toml` `[dependencies]`, add: + +```toml +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] } +``` + +Run: `cargo build` — Expected: compiles (new dep downloads/builds). + +- [ ] **Step 2: Write the failing tests for the pure mint helpers** + +Create `src/tailscale.rs` with just the test module first (so the test names exist and fail to compile against the missing functions): + +```rust +//! Host-side Tailscale auth-key minting. +//! +//! When a mint credential (PAT or OAuth client) is configured, tsk mints a +//! fresh single-use, ephemeral, tagged auth key per task via the Tailscale API +//! instead of reusing a long-lived key. Ephemeral nodes auto-remove from the +//! tailnet shortly after the sandbox stops, so the admin console does not +//! accumulate `tsk-*` devices. + +#[cfg(test)] +mod tests { + use super::*; + use crate::context::ResolvedConfig; + + #[test] + fn test_build_mint_request_body_requests_ephemeral_tagged_single_use() { + let tags = vec!["tag:tsk-sandbox".to_string()]; + let body = build_mint_request_body(&tags, 300, "tsk sandbox tsk-abc123"); + let create = &body["capabilities"]["devices"]["create"]; + assert_eq!(create["reusable"], serde_json::json!(false)); + assert_eq!(create["ephemeral"], serde_json::json!(true)); + assert_eq!(create["preauthorized"], serde_json::json!(true)); + assert_eq!(create["tags"], serde_json::json!(["tag:tsk-sandbox"])); + assert_eq!(body["expirySeconds"], serde_json::json!(300)); + assert_eq!(body["description"], serde_json::json!("tsk sandbox tsk-abc123")); + } + + #[test] + fn test_parse_mint_response_extracts_key() { + let body = r#"{"id":"k1","key":"tskey-auth-xyz","expires":"2026-01-01T00:00:00Z"}"#; + assert_eq!(parse_mint_response(body).unwrap(), "tskey-auth-xyz"); + } + + #[test] + fn test_parse_mint_response_missing_key_errors() { + let body = r#"{"id":"k1"}"#; + assert!(parse_mint_response(body).is_err()); + } + + #[test] + fn test_parse_mint_response_invalid_json_errors() { + assert!(parse_mint_response("not json").is_err()); + } + + #[test] + fn test_parse_oauth_token_response_extracts_access_token() { + let body = r#"{"access_token":"tskey-api-abc","token_type":"Bearer","expires_in":3600}"#; + assert_eq!(parse_oauth_token_response(body).unwrap(), "tskey-api-abc"); + } + + #[test] + fn test_parse_oauth_token_response_missing_token_errors() { + assert!(parse_oauth_token_response(r#"{"token_type":"Bearer"}"#).is_err()); + } + + #[test] + fn test_resolve_mint_credential_none_when_unconfigured() { + let resolved = ResolvedConfig::default(); + let cred = resolve_mint_credential(&resolved, |_| None).unwrap(); + assert!(cred.is_none()); + } + + #[test] + fn test_resolve_mint_credential_pat_from_env() { + let resolved = ResolvedConfig { + tailscale_api_key_env: Some("TS_API_KEY".to_string()), + ..Default::default() + }; + let cred = resolve_mint_credential(&resolved, |name| { + (name == "TS_API_KEY").then(|| "tskey-api-pat".to_string()) + }) + .unwrap(); + assert_eq!(cred, Some(MintCredential::Pat("tskey-api-pat".to_string()))); + } + + #[test] + fn test_resolve_mint_credential_oauth_takes_precedence() { + let resolved = ResolvedConfig { + tailscale_api_key_env: Some("TS_API_KEY".to_string()), + tailscale_oauth_client_id: Some("kABC".to_string()), + tailscale_oauth_secret_env: Some("TS_OAUTH_SECRET".to_string()), + ..Default::default() + }; + let cred = resolve_mint_credential(&resolved, |name| match name { + "TS_API_KEY" => Some("tskey-api-pat".to_string()), + "TS_OAUTH_SECRET" => Some("secret-val".to_string()), + _ => None, + }) + .unwrap(); + assert_eq!( + cred, + Some(MintCredential::OAuth { + client_id: "kABC".to_string(), + secret: "secret-val".to_string(), + }) + ); + } + + #[test] + fn test_resolve_mint_credential_oauth_missing_secret_errors() { + let resolved = ResolvedConfig { + tailscale_oauth_client_id: Some("kABC".to_string()), + tailscale_oauth_secret_env: Some("TS_OAUTH_SECRET".to_string()), + ..Default::default() + }; + let err = resolve_mint_credential(&resolved, |_| None).unwrap_err(); + assert!(err.contains("OAuth secret"), "unexpected error: {err}"); + } +} +``` + +- [ ] **Step 3: Run tests to verify they fail** + +Run: `cargo test -q --lib tailscale` +Expected: FAIL to compile — functions/enum not defined. + +- [ ] **Step 4: Implement the module (above the test module)** + +Insert this between the module doc comment and the `#[cfg(test)]` block in `src/tailscale.rs`: + +```rust +use crate::context::ResolvedConfig; +use crate::context::tsk_config::expand_tilde; + +/// Default tag applied to minted keys/nodes. Tagging is what scopes a sandbox's +/// tailnet access to its ACL instead of the operator's personal identity. +pub const DEFAULT_TAILSCALE_TAG: &str = "tag:tsk-sandbox"; + +const TAILSCALE_API_BASE: &str = "https://api.tailscale.com/api/v2"; +const TAILSCALE_OAUTH_TOKEN_URL: &str = "https://api.tailscale.com/api/v2/oauth/token"; +/// Minted keys are consumed immediately at container start; a short expiry +/// limits the blast radius if a key leaks before it is used. +const MINT_KEY_EXPIRY_SECS: u64 = 300; + +/// A resolved credential for the Tailscale API. +#[derive(Debug, Clone, PartialEq)] +pub enum MintCredential { + /// Personal access token, used directly as a bearer token. + Pat(String), + /// OAuth client credentials, exchanged for a short-lived access token. + OAuth { client_id: String, secret: String }, +} + +/// Resolves the configured mint credential, reading secrets from the +/// environment (via `env_lookup`) or from files. Returns `Ok(None)` when no +/// mint credential is configured, so the caller falls back to a bring-your-own +/// key. OAuth takes precedence over a PAT when a client id is set. +/// +/// Mirrors `crate::docker::resolve_tailscale_auth_key`: env access is injected +/// for testability; files are read directly. +pub fn resolve_mint_credential( + resolved: &ResolvedConfig, + env_lookup: impl Fn(&str) -> Option, +) -> Result, String> { + if let Some(ref client_id) = resolved.tailscale_oauth_client_id { + let secret = read_secret( + resolved.tailscale_oauth_secret_env.as_deref(), + resolved.tailscale_oauth_secret_file.as_deref(), + &env_lookup, + )? + .ok_or_else(|| { + "tailscale_oauth_client_id is set but no OAuth secret was found. Set \ + tailscale_oauth_secret_env or tailscale_oauth_secret_file." + .to_string() + })?; + return Ok(Some(MintCredential::OAuth { + client_id: client_id.clone(), + secret, + })); + } + + if resolved.tailscale_api_key_env.is_some() || resolved.tailscale_api_key_file.is_some() { + let pat = read_secret( + resolved.tailscale_api_key_env.as_deref(), + resolved.tailscale_api_key_file.as_deref(), + &env_lookup, + )? + .ok_or_else(|| { + "A Tailscale API key source is configured but no key was found. Set the \ + tailscale_api_key_env variable or tailscale_api_key_file contents." + .to_string() + })?; + return Ok(Some(MintCredential::Pat(pat))); + } + + Ok(None) +} + +/// Reads a secret from an env var (by name) then a file, trimming whitespace. +/// Returns `Ok(None)` when no source is configured or the env var is unset/blank; +/// a configured-but-empty file is an error. +fn read_secret( + env_var: Option<&str>, + file: Option<&str>, + env_lookup: &impl Fn(&str) -> Option, +) -> Result, String> { + if let Some(name) = env_var { + if let Some(val) = env_lookup(name) { + let val = val.trim().to_string(); + if !val.is_empty() { + return Ok(Some(val)); + } + } + } + if let Some(path) = file { + let path = expand_tilde(path); + let contents = std::fs::read_to_string(&path).map_err(|e| { + format!("Failed to read Tailscale secret file '{}': {e}", path.display()) + })?; + let val = contents.trim().to_string(); + if val.is_empty() { + return Err(format!("Tailscale secret file '{}' is empty", path.display())); + } + return Ok(Some(val)); + } + Ok(None) +} + +/// Builds the JSON body for `POST /tailnet/{tailnet}/keys`, requesting a +/// single-use, ephemeral, pre-authorized, tagged key. +pub fn build_mint_request_body( + tags: &[String], + expiry_secs: u64, + description: &str, +) -> serde_json::Value { + serde_json::json!({ + "capabilities": { + "devices": { + "create": { + "reusable": false, + "ephemeral": true, + "preauthorized": true, + "tags": tags, + } + } + }, + "expirySeconds": expiry_secs, + "description": description, + }) +} + +/// Extracts the minted auth key from a `POST .../keys` response body. +pub fn parse_mint_response(body: &str) -> Result { + let json: serde_json::Value = serde_json::from_str(body) + .map_err(|e| format!("Tailscale mint response was not valid JSON: {e}"))?; + json.get("key") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()) + .ok_or_else(|| format!("Tailscale mint response had no `key` field: {body}")) +} + +/// Extracts the access token from an OAuth `POST /oauth/token` response body. +pub fn parse_oauth_token_response(body: &str) -> Result { + let json: serde_json::Value = serde_json::from_str(body) + .map_err(|e| format!("Tailscale OAuth response was not valid JSON: {e}"))?; + json.get("access_token") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()) + .ok_or_else(|| format!("Tailscale OAuth response had no `access_token`: {body}")) +} + +/// Mints a fresh ephemeral, tagged auth key for `task_id` using the configured +/// credential, reading secrets from the process environment. For OAuth it first +/// exchanges the client credentials for a short-lived access token. +/// +/// Network-facing glue; request/response shaping is unit-tested via +/// [`build_mint_request_body`], [`parse_mint_response`], and +/// [`parse_oauth_token_response`]. +pub async fn mint_tailscale_auth_key( + resolved: &ResolvedConfig, + task_id: &str, +) -> Result { + let credential = resolve_mint_credential(resolved, |name| std::env::var(name).ok())? + .ok_or_else(|| "No Tailscale mint credential configured".to_string())?; + + let tags = resolved.tailscale_tags(); + if tags.is_empty() { + return Err("tailscale_tags must not be empty when minting: Tailscale requires \ + minted keys to be tagged." + .to_string()); + } + + let client = reqwest::Client::new(); + + let bearer = match credential { + MintCredential::Pat(pat) => pat, + MintCredential::OAuth { client_id, secret } => { + let resp = client + .post(TAILSCALE_OAUTH_TOKEN_URL) + .form(&[ + ("client_id", client_id.as_str()), + ("client_secret", secret.as_str()), + ]) + .send() + .await + .map_err(|e| format!("Tailscale OAuth token request failed: {e}"))?; + let status = resp.status(); + let text = resp.text().await.unwrap_or_default(); + if !status.is_success() { + return Err(format!("Tailscale OAuth token request returned {status}: {text}")); + } + parse_oauth_token_response(&text)? + } + }; + + let body = build_mint_request_body( + &tags, + MINT_KEY_EXPIRY_SECS, + &format!("tsk sandbox tsk-{task_id}"), + ); + let url = format!( + "{TAILSCALE_API_BASE}/tailnet/{}/keys", + resolved.tailscale_tailnet() + ); + let resp = client + .post(&url) + .bearer_auth(&bearer) + .json(&body) + .send() + .await + .map_err(|e| format!("Tailscale key mint request failed: {e}"))?; + let status = resp.status(); + let text = resp.text().await.unwrap_or_default(); + if !status.is_success() { + return Err(format!( + "Tailscale key mint returned {status}: {text}. Check the credential's scopes \ + (needs auth_keys write) and that it owns the tags {tags:?}." + )); + } + parse_mint_response(&text) +} +``` + +- [ ] **Step 5: Declare the module** + +In `src/main.rs`, next to `mod docker;` (`:9`), add in alphabetical position: + +```rust +mod tailscale; +``` + +- [ ] **Step 6: Add the `ResolvedConfig` accessors** + +In `src/context/tsk_config.rs`, immediately after the `tailscale_auth_key_env_var` method (ends around `:499`): + +```rust + /// Whether a Tailscale API mint credential (PAT or OAuth) is configured. + /// When true, tsk mints a per-task ephemeral key instead of using a + /// bring-your-own key. + pub fn has_tailscale_mint_credential(&self) -> bool { + self.tailscale_oauth_client_id.is_some() + || self.tailscale_api_key_env.is_some() + || self.tailscale_api_key_file.is_some() + } + + /// Tailnet to mint keys in (default `-`, the credential's default tailnet). + pub fn tailscale_tailnet(&self) -> &str { + self.tailscale_tailnet.as_deref().unwrap_or("-") + } + + /// Tags applied to minted keys/nodes (default `["tag:tsk-sandbox"]`). + pub fn tailscale_tags(&self) -> Vec { + self.tailscale_tags + .clone() + .unwrap_or_else(|| vec![crate::tailscale::DEFAULT_TAILSCALE_TAG.to_string()]) + } +``` + +- [ ] **Step 7: Add accessor tests** + +In the `#[cfg(test)]` module of `src/context/tsk_config.rs`: + +```rust +#[test] +fn test_has_tailscale_mint_credential() { + let none = ResolvedConfig::default(); + assert!(!none.has_tailscale_mint_credential()); + + let pat = ResolvedConfig { + tailscale_api_key_env: Some("TS_API_KEY".to_string()), + ..Default::default() + }; + assert!(pat.has_tailscale_mint_credential()); + + let oauth = ResolvedConfig { + tailscale_oauth_client_id: Some("kABC".to_string()), + ..Default::default() + }; + assert!(oauth.has_tailscale_mint_credential()); +} + +#[test] +fn test_tailscale_tailnet_and_tags_defaults() { + let resolved = ResolvedConfig::default(); + assert_eq!(resolved.tailscale_tailnet(), "-"); + assert_eq!(resolved.tailscale_tags(), vec!["tag:tsk-sandbox".to_string()]); + + let custom = ResolvedConfig { + tailscale_tailnet: Some("example.com".to_string()), + tailscale_tags: Some(vec!["tag:ci".to_string()]), + ..Default::default() + }; + assert_eq!(custom.tailscale_tailnet(), "example.com"); + assert_eq!(custom.tailscale_tags(), vec!["tag:ci".to_string()]); +} +``` + +- [ ] **Step 8: Wire minting into `run_task_container`** + +In `src/docker/mod.rs`, replace the auth-key resolution block (`:934-938`): + +```rust + let tailscale_auth_key = if resolved.tailscale { + Some(resolve_tailscale_auth_key(&resolved)?) + } else { + None + }; +``` + +with: + +```rust + let tailscale_auth_key = if resolved.tailscale { + if resolved.has_tailscale_mint_credential() { + // Mint a fresh ephemeral, tagged key so the node auto-removes + // from the tailnet after the sandbox stops. + Some(crate::tailscale::mint_tailscale_auth_key(&resolved, &task.id).await?) + } else { + Some(resolve_tailscale_auth_key(&resolved)?) + } + } else { + None + }; +``` + +- [ ] **Step 9: Run the new tests and the suite** + +Run: `cargo test -q --lib tailscale` — Expected: all mint helper + resolve tests PASS. +Run: `cargo test -q --lib context::tsk_config` — Expected: accessor tests PASS. +Run: `cargo test -q` — Expected: full suite green. + +- [ ] **Step 10: Lint clean** + +Run: `just lint` +Expected: no warnings (all new items are reachable from the non-test build via the wiring in `run_task_container`). + +- [ ] **Step 11: Commit** + +```bash +git add Cargo.toml Cargo.lock src/tailscale.rs src/main.rs src/context/tsk_config.rs src/docker/mod.rs +git commit -m "feat: mint per-task ephemeral tagged Tailscale keys" +``` + +--- + +### Task 3: Warn when a bring-your-own node is untagged + +Adds an in-container warning to the Tailscale startup script: after the node joins, if `tailscale status --json` shows the node has no tags, print a `tsk:` warning that it is using the operator's personal identity and (for non-ephemeral keys) will not auto-remove. Zero extra credential; only relevant on the fallback path (minted nodes are always tagged). jq is already in the base image (`dockerfiles/base/default.dockerfile:21`). + +**Files:** +- Modify: `dockerfiles/features/tailscale.dockerfile` — the `printf '%s\n' ... > /usr/local/bin/tsk-tailscale-up` startup-script heredoc, right after the final `tailscale --socket="$SOCKET" status` line. + +**Interfaces:** +- Consumes: nothing from other tasks (self-contained shell change). +- Produces: no Rust surface. + +- [ ] **Step 1: Verify the tag-check predicate against sample JSON (the test)** + +The predicate must exit 0 (warn) for an untagged node and non-zero (no warn) for a tagged node. Verify on the host (jq is installed locally): + +Run: +```bash +echo '{"Self":{"Tags":null}}' | jq -e '((.Self.Tags // []) | length) == 0'; echo "untagged exit=$?" +echo '{"Self":{"Tags":["tag:tsk-sandbox"]}}' | jq -e '((.Self.Tags // []) | length) == 0'; echo "tagged exit=$?" +echo '{"Self":{}}' | jq -e '((.Self.Tags // []) | length) == 0'; echo "missing exit=$?" +``` +Expected: `untagged exit=0`, `tagged exit=1`, `missing exit=0`. + +- [ ] **Step 2: Add the warning to the startup script** + +In `dockerfiles/features/tailscale.dockerfile`, the startup script is generated by a `printf '%s\n' '' '' ... > /usr/local/bin/tsk-tailscale-up` command where each line is a single-quoted argument. Immediately after the existing final line argument `'tailscale --socket="$SOCKET" status' \`, insert these argument lines (note: use **double quotes** around the jq filter so it survives the single-quoted printf argument — the filter contains no `$`, so nothing is interpolated at container runtime): + +``` + '# 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' \ +``` + +Do not use apostrophes in the echo text — an apostrophe would terminate the single-quoted printf argument. (The lines above are apostrophe-free.) + +- [ ] **Step 3: Verify the generated script is well-formed** + +Because building the image needs Docker and network, statically verify the edited script block instead: confirm the new lines are balanced single-quoted `printf` args and contain no stray apostrophes. + +Run: +```bash +grep -n "UNTAGGED\|Self.Tags\|status --json | jq" dockerfiles/features/tailscale.dockerfile +``` +Expected: the four/eight new lines appear, each wrapped as a `'...' \` printf argument. + +Optional (only if Docker is available): `tsk docker build --dry-run` succeeds and the emitted Dockerfile contains the warning lines. + +- [ ] **Step 4: Commit** + +```bash +git add dockerfiles/features/tailscale.dockerfile +git commit -m "feat: warn when a bring-your-own Tailscale node is untagged" +``` + +--- + +### Task 4: Document minting and update config reference + +Updates the README Tailscale section and config reference so minting is the recommended path (PAT vs OAuth trade-off), ephemeral+tagged is stated as the requirement for the bring-your-own fallback, and any implication that a plain reusable key auto-cleans is removed. + +**Files:** +- Modify: `README.md` — the `### Tailscale (optional)` section and the `tsk.toml` Tailscale config example. + +**Interfaces:** +- Consumes: field names and defaults from Tasks 1-2 (`tailscale_api_key_env`, `tailscale_api_key_file`, `tailscale_oauth_client_id`, `tailscale_oauth_secret_env`, `tailscale_oauth_secret_file`, `tailscale_tailnet`, `tailscale_tags`; default tag `tag:tsk-sandbox`; default tailnet `-`). +- Produces: no code surface. + +- [ ] **Step 1: Update the config example** + +In `README.md`, in the `[project.my-service]` Tailscale block (under `### Tailscale (optional)`), add after the existing `tailscale_auth_key_*` lines: + +```toml +# --- Recommended: let tsk mint a fresh ephemeral, tagged key per task --- +# Provide EITHER a PAT or an OAuth client (OAuth takes precedence if both set). +# tailscale_api_key_env = "TS_API_KEY" # env var holding a PAT (tskey-api-...) +# tailscale_api_key_file = "~/.config/tsk/ts-api-key" +# tailscale_oauth_client_id = "k123..." # OAuth client id +# tailscale_oauth_secret_env = "TS_OAUTH_SECRET" # env var holding the OAuth secret +# tailscale_oauth_secret_file = "~/.config/tsk/ts-oauth-secret" +# tailscale_tailnet = "-" # default "-" = credential's default tailnet +# tailscale_tags = ["tag:tsk-sandbox"] # tags for minted keys/nodes (default) +``` + +- [ ] **Step 2: Rewrite the cleanup guidance prose** + +In the same section, add a paragraph after the "Recommended auth-key setup" guidance: + +> **Cleanup of sandbox nodes.** Each sandbox joins as a `tsk-` node. To keep the admin console from filling up with old nodes, the node must be **ephemeral** — Tailscale then auto-removes it a few minutes after the sandbox stops. The most robust way is to let tsk **mint the key**: configure a Tailscale API credential (a one-click **personal access token**, or an **OAuth client** for non-expiring, tightly-scoped access via `tailscale_api_key_env` / `tailscale_oauth_client_id`) and tsk creates a fresh single-use, ephemeral, tagged key per task — so nodes are always tagged (`tag:tsk-sandbox` by default) and always auto-remove, with no way to misconfigure them. If you instead bring your own key (`TS_AUTHKEY` / `tailscale_auth_key_file`), you are responsible for making it **ephemeral and tagged**; a non-ephemeral or untagged key leaves nodes behind and attributes them to your personal identity (the sandbox will print a warning when it detects an untagged node). tsk does not mint via short-lived per-task OAuth keys beyond this — a reusable ephemeral key or a minting credential is the practical setup today. + +Adjust wording to match the surrounding README voice; remove any existing sentence implying a plain reusable key cleans itself up. + +- [ ] **Step 3: Verify the docs** + +Run: +```bash +grep -n "tailscale_api_key_env\|tailscale_oauth_client_id\|Cleanup of sandbox nodes\|tag:tsk-sandbox" README.md +cargo run -- --help > /dev/null +``` +Expected: the new fields and cleanup paragraph are present; `--help` still runs cleanly. + +- [ ] **Step 4: Commit** + +```bash +git add README.md +git commit -m "docs: document Tailscale key minting and node cleanup" +``` + +--- + +### Task 5: Final verification + +Runs the project's full gate to confirm the whole change is format-clean, lint-clean, and green before hand-off. + +**Files:** none (verification only). + +- [ ] **Step 1: Format** + +Run: `just format` +Expected: no diff (or apply and re-commit formatting with `chore: cargo fmt` if any). + +- [ ] **Step 2: Lint** + +Run: `just lint` +Expected: no warnings across `--all-targets` and the default target. + +- [ ] **Step 3: Test** + +Run: `just test` +Expected: full suite green. + +- [ ] **Step 4: Smoke-test the CLI** + +Run: `cargo run -- --help > /dev/null` +Expected: exits 0. + +- [ ] **Step 5: Commit any fixups** + +```bash +git add -A +git commit -m "chore: formatting and lint fixups for Tailscale node cleanup" || echo "nothing to commit" +``` + +--- + +## Notes for the implementer + +- **Why minting is opt-in and has no default env var:** `tailscale_auth_key_env` defaults to `TS_AUTHKEY`, but the mint credential must be explicitly configured (`tailscale_api_key_env` / `tailscale_oauth_client_id`). This prevents an unrelated `TS_API_KEY` in the environment from silently switching a user onto the minting path. +- **Why the ephemeral property does the cleanup, not a teardown hook:** ephemeral nodes auto-remove after going offline, so cleanup survives tsk crashing or the host rebooting mid-task. This is why the design deliberately has no host-side device-delete call or periodic sweep. +- **The mint call runs on the host**, so it does not interact with the container's Squid allowlist. The container still needs `.tailscale.com` / `.tailscale.io` allowlisted for `tailscaled`, which the existing `tailscale_squid_conf` already handles — no change needed there. +- **Pre-existing leftover nodes** are out of scope for the code; they are deleted manually once (already done during design). diff --git a/docs/superpowers/specs/2026-07-31-tailscale-node-cleanup-design.md b/docs/superpowers/specs/2026-07-31-tailscale-node-cleanup-design.md new file mode 100644 index 0000000..24187ac --- /dev/null +++ b/docs/superpowers/specs/2026-07-31-tailscale-node-cleanup-design.md @@ -0,0 +1,177 @@ +# Tailscale sandbox node cleanup — design + +**Date:** 2026-07-31 +**Status:** Approved design, pending implementation plan +**Branch:** `feat/tailscale-support` (the Tailscale feature is unshipped — not in `main`, not in any tag; latest release is v0.10.8, so there is no backward-compat obligation for the Tailscale path) + +## Problem + +The just-added Tailscale support (`22887dd`) joins each sandbox to the tailnet as a +node named `tsk-` but **never removes it**. The in-container startup script +runs `tailscale up --authkey ...` with no `--ephemeral` (which does not exist as an +`up` flag anyway) and there is no teardown step. Whether a node disappears depends +entirely on whether the auth key is *ephemeral*. + +Observed: the console accumulated 6 `tsk-*` nodes, all **non-ephemeral** and +**untagged** — attributed to the operator's personal identity. Non-ephemeral means +Tailscale never auto-removes them; untagged means each sandbox held the operator's +full personal tailnet access (the exact footgun the README warns against). + +Root cause: a hand-made key can be misconfigured (non-ephemeral and/or untagged), and +nothing in tsk detects or corrects it. + +## Key technical constraints (established during design) + +- **Ephemerality is a property of the auth key**, fixed at key-creation time. There is + **no `tailscale up --ephemeral` flag** — tsk cannot force a node ephemeral from + inside the container. +- **Ephemeral nodes auto-remove** a few minutes after they go offline (container + stops). This is the cleanup mechanism, and it is robust even if tsk crashes or the + host reboots mid-task — nothing host-side needs to fire. +- **Key minting requires the Tailscale API** (`POST /tailnet/{tailnet}/keys`). The + `tailscale` CLI has **no key-creation command** (it can join, show status, and + `logout`, but not create keys or delete other devices). +- The mint API accepts **either** a Personal Access Token (PAT) **or** an OAuth + client — both are bearer credentials against the same endpoint. +- OAuth-minted keys **must** be tagged and the OAuth client must own the tag — + tagging is structurally enforced for OAuth. + +## Approach + +Ephemeral-first, minting optional, no OAuth mandate. Two layers: + +### Layer 1 — Primary: mint a fresh ephemeral tagged key per task (opt-in) + +If the operator configures a Tailscale API credential (PAT **or** OAuth client), +tsk mints a **single-use, ephemeral, tagged** auth key per sandbox at container +start, passes it in as the join key, and discards it. + +- The node is **always ephemeral** → auto-removes on disconnect (solves the leftover + machines problem). +- The node is **always tagged** (`tag:tsk-sandbox` by default) → no more + personal-identity nodes; sandbox access is bounded by the tag's ACL, not the + operator's identity. +- The only durable secret is the API credential, which lives on the **host** (trusted) + and is **never** passed into the container. Only the short-lived minted key enters + the container — and the existing `unset TS_AUTHKEY` + `exec` handling already keeps + the in-container agent from recovering it. + +Credential types supported (operator picks one): + +- **PAT** — used directly as a bearer token. One-click to create; expires (≤90 days, + operator rotates); carries the creating user's permissions. +- **OAuth client** — `POST /oauth/token` (client-credentials grant) to obtain a + short-lived access token, then mint. Non-expiring, tightly scopable + (`auth_keys` + owner of the tag). More setup. + +### Layer 2 — Fallback: bring-your-own key (existing path, retained) + +For operators who do not want to hand tsk an API credential, the existing +`TS_AUTHKEY` / `tailscale_auth_key_file` path remains. Cleanup then depends on the +operator supplying an **ephemeral, tagged** key (documented as required). + +To catch misconfiguration without any extra credential: the container startup script +already runs `tailscale status` after join. Parse `tailscale status --json` for +`Self.Tags`; if empty, print a clear `tsk:` warning that the node is untagged (using +the operator's personal identity) and — because ephemerality can't be reliably +detected client-side — a one-line reminder that the node only auto-cleans if the key +is ephemeral. Warning only; never fails the task. + +### De-scoped (deliberately not building) + +- **Per-task device-delete API call** and **periodic device sweep** — unnecessary once + every node is ephemeral. Adds host-side bookkeeping and a delete scope for no gain + over auto-removal. +- **Mint-only / replace-the-bring-your-own path** — the fallback costs little and + serves no-API operators. +- **The 6 existing leftover nodes** — one-time manual deletion in the console; nothing + new accumulates going forward. + +## Config surface + +New fields, mirroring the existing `tailscale_auth_key_env` / `tailscale_auth_key_file` +naming (secrets referenced by env-var name or file path, never inlined): + +```toml +[project.my-service] +tailscale = true + +# --- Layer 1: minting (opt-in). Provide PAT *or* OAuth, not both. --- +# PAT: +tailscale_api_key_env = "TS_API_KEY" # env var holding a PAT +# tailscale_api_key_file = "~/.config/tsk/ts-api-key" +# OAuth client: +# tailscale_oauth_client_id = "k123..." # client id (not secret) +# tailscale_oauth_secret_env = "TS_OAUTH_SECRET" +# tailscale_oauth_secret_file = "~/.config/tsk/ts-oauth-secret" + +tailscale_tailnet = "-" # default "-" = credential's default tailnet +tailscale_tags = ["tag:tsk-sandbox"] # tags for minted keys/nodes + +# --- Layer 2: bring-your-own key (fallback, unchanged) --- +# tailscale_auth_key_env = "TS_AUTHKEY" +# tailscale_auth_key_file = "~/.config/tsk/ts-authkey" +``` + +Resolution precedence at container start (host side): +1. If a mint credential is configured (PAT or OAuth) → mint an ephemeral tagged key. +2. Else fall back to `tailscale_auth_key_env` / `tailscale_auth_key_file`. +3. Else the existing "no key found" error. + +## Components / where the code lives + +- **`src/docker/mod.rs` — key resolution.** Extend the current + `resolve_tailscale_auth_key` path: when a mint credential is present, call the new + minter and return its key; otherwise keep today's env/file behavior. Minting happens + at **container start** (fresh, short-lived key matched to container lifetime), not at + task creation — consistent with today's "read the key at start" model. Only the + credential *references* (env names, tailnet, tags) live in the config snapshot; the + secret itself is read live from the host. +- **New minter unit** (e.g. `src/tailscale/mint.rs` or a submodule of `docker`): + one clear job — given a resolved credential + tailnet + tags + node description, + return a fresh key string. Internally: optional OAuth token exchange, then + `POST /tailnet/{tailnet}/keys` with + `capabilities.devices.create = { reusable:false, ephemeral:true, preauthorized:true, tags:[...] }` + and a short `expirySeconds` (e.g. 300 — the key only needs to be valid at join). + Injectable HTTP + credential lookup so it is unit-testable without network. +- **`dockerfiles/features/tailscale.dockerfile` — startup script.** After the existing + `tailscale status`, add the untagged-node warning (parse `--json`, check + `Self.Tags`). No behavior change to the join itself. +- **`src/context/tsk_config.rs` — config.** Add the new fields + accessors alongside + the existing Tailscale config, following the layered-resolution pattern. +- **`Cargo.toml` — HTTP client.** No HTTP client is currently a direct dependency; add + one for the mint call (prefer `reqwest` with `rustls-tls` to avoid an OpenSSL + system dep). Confirm during implementation whether a transitive client can be reused + before adding a new dependency. + +## Error handling + +- Mint failure (bad/expired credential, network, unreachable API) → fail the task with + a clear, actionable error, consistent with the existing "join within 60s or fail" + behavior. Do not silently fall back to the bring-your-own key when a mint credential + was explicitly configured — a misconfigured credential should surface, not be masked. +- Missing tag in bring-your-own mode → warning only (never fails). +- The mint call is an outbound HTTPS request **from the host** to `api.tailscale.com`, + so it does not interact with the container's Squid allowlist. (The container still + needs `.tailscale.com` / `.tailscale.io` allowlisted for `tailscaled`, already + handled.) + +## Testing + +- **Minter unit tests** (real logic, injected HTTP): OAuth token-exchange path; PAT + path; correct request body (ephemeral/tagged/non-reusable); error surfaced on + non-2xx; tailnet defaulting to `-`. +- **Config tests**: precedence (mint credential wins over bring-your-own), env-vs-file + resolution, tag/tailnet defaults, snapshot round-trip. +- **Startup-script warning**: unit-test the `Self.Tags` parsing that drives the warning + (reuse the existing `parse_tailnet_aliases` JSON-parsing test style), keeping the + container hermetic under `cfg(test)`. +- Prefer real implementations over mocks per repo conventions; the only mock boundary + is the outbound HTTP call. + +## Docs + +Update the README Tailscale section: minting as the recommended path (PAT vs OAuth +trade-off), ephemeral+tagged as the requirement for the fallback path, and remove any +implication that a plain reusable key auto-cleans. Note the one-time manual deletion of +pre-existing nodes. 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..d02f6cf 100644 --- a/src/context/tsk_config.rs +++ b/src/context/tsk_config.rs @@ -156,6 +156,48 @@ 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()); + } + if let Some(ref v) = config.tailscale_api_key_env { + resolved.tailscale_api_key_env = Some(v.clone()); + } + if let Some(ref v) = config.tailscale_api_key_file { + resolved.tailscale_api_key_file = Some(v.clone()); + } + if let Some(ref v) = config.tailscale_oauth_client_id { + resolved.tailscale_oauth_client_id = Some(v.clone()); + } + if let Some(ref v) = config.tailscale_oauth_secret_env { + resolved.tailscale_oauth_secret_env = Some(v.clone()); + } + if let Some(ref v) = config.tailscale_oauth_secret_file { + resolved.tailscale_oauth_secret_file = Some(v.clone()); + } + if let Some(ref v) = config.tailscale_tailnet { + resolved.tailscale_tailnet = Some(v.clone()); + } + if let Some(ref v) = config.tailscale_tags { + resolved.tailscale_tags = Some(v.clone()); + } // host_ports: combine, deduplicate for &port in &config.host_ports { @@ -276,6 +318,48 @@ 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, + /// Name of the environment variable holding a Tailscale API access token + /// (PAT) used to mint a per-task ephemeral auth key. Setting this (or + /// `tailscale_api_key_file` / `tailscale_oauth_client_id`) opts into minting. + pub tailscale_api_key_env: Option, + /// Path to a file containing a Tailscale API access token (PAT), `~`-expanded. + pub tailscale_api_key_file: Option, + /// OAuth client ID used to mint per-task ephemeral auth keys. Takes + /// precedence over the PAT fields when set. + pub tailscale_oauth_client_id: Option, + /// Name of the environment variable holding the OAuth client secret. + pub tailscale_oauth_secret_env: Option, + /// Path to a file containing the OAuth client secret, `~`-expanded. + pub tailscale_oauth_secret_file: Option, + /// Tailnet to mint keys in (default: `-`, the credential's default tailnet). + pub tailscale_tailnet: Option, + /// Tags applied to minted keys/nodes (default: `["tag:tsk-sandbox"]`). + pub tailscale_tags: Option>, } /// Per-stack configuration (e.g., custom Dockerfile setup commands) @@ -335,6 +419,53 @@ 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, + /// Env var holding a Tailscale API access token (PAT) for minting. + #[serde(default)] + pub tailscale_api_key_env: Option, + /// File holding a Tailscale API access token (PAT) for minting. + #[serde(default)] + pub tailscale_api_key_file: Option, + /// OAuth client ID for minting per-task ephemeral auth keys. + #[serde(default)] + pub tailscale_oauth_client_id: Option, + /// Env var holding the OAuth client secret. + #[serde(default)] + pub tailscale_oauth_secret_env: Option, + /// File holding the OAuth client secret. + #[serde(default)] + pub tailscale_oauth_secret_file: Option, + /// Tailnet to mint keys in (default resolved via accessor to `-`). + #[serde(default)] + pub tailscale_tailnet: Option, + /// Tags applied to minted keys/nodes (default resolved via accessor). + #[serde(default)] + pub tailscale_tags: Option>, +} + +/// serde default for `bool` fields that default to `true`. +fn default_true() -> bool { + true } impl Default for ResolvedConfig { @@ -356,6 +487,20 @@ 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, + tailscale_api_key_env: None, + tailscale_api_key_file: None, + tailscale_oauth_client_id: None, + tailscale_oauth_secret_env: None, + tailscale_oauth_secret_file: None, + tailscale_tailnet: None, + tailscale_tags: None, } } } @@ -393,15 +538,183 @@ 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) + } + + /// Whether a Tailscale API mint credential (PAT or OAuth) is configured. + /// When true, tsk mints a per-task ephemeral key instead of using a + /// bring-your-own key. + pub fn has_tailscale_mint_credential(&self) -> bool { + self.tailscale_oauth_client_id.is_some() + || self.tailscale_oauth_secret_env.is_some() + || self.tailscale_oauth_secret_file.is_some() + || self.tailscale_api_key_env.is_some() + || self.tailscale_api_key_file.is_some() + } + + /// Tailnet to mint keys in (default `-`, the credential's default tailnet). + pub fn tailscale_tailnet(&self) -> &str { + self.tailscale_tailnet.as_deref().unwrap_or("-") + } + + /// Tags applied to minted keys/nodes (default `["tag:tsk-sandbox"]`). + pub fn tailscale_tags(&self) -> Vec { + self.tailscale_tags + .clone() + .unwrap_or_else(|| vec![crate::tailscale::DEFAULT_TAILSCALE_TAG.to_string()]) + } + + /// 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. /// Used to determine proxy container identity via fingerprinting. #[derive(Debug, Clone, Default)] @@ -573,7 +886,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 +2072,20 @@ 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()), + tailscale_api_key_env: Some("TS_API_KEY".to_string()), + tailscale_api_key_file: Some("~/.config/tsk/ts-api-key".to_string()), + tailscale_oauth_client_id: Some("kABC123".to_string()), + tailscale_oauth_secret_env: Some("TS_OAUTH_SECRET".to_string()), + tailscale_oauth_secret_file: Some("~/.config/tsk/ts-oauth-secret".to_string()), + tailscale_tailnet: Some("example.com".to_string()), + tailscale_tags: Some(vec!["tag:tsk-sandbox".to_string()]), }; let json = serde_json::to_string(&config).unwrap(); @@ -1786,6 +2113,283 @@ 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())); + assert_eq!( + deserialized.tailscale_api_key_env, + Some("TS_API_KEY".to_string()) + ); + assert_eq!( + deserialized.tailscale_api_key_file, + Some("~/.config/tsk/ts-api-key".to_string()) + ); + assert_eq!( + deserialized.tailscale_oauth_client_id, + Some("kABC123".to_string()) + ); + assert_eq!( + deserialized.tailscale_oauth_secret_env, + Some("TS_OAUTH_SECRET".to_string()) + ); + assert_eq!( + deserialized.tailscale_oauth_secret_file, + Some("~/.config/tsk/ts-oauth-secret".to_string()) + ); + assert_eq!( + deserialized.tailscale_tailnet, + Some("example.com".to_string()) + ); + assert_eq!( + deserialized.tailscale_tags, + Some(vec!["tag:tsk-sandbox".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_tailscale_mint_config_layering() { + let temp_dir = tempfile::TempDir::new().unwrap(); + let config_dir = temp_dir.path(); + + let toml_content = r#" +[defaults] +tailscale = true +tailscale_oauth_client_id = "kABC123" +tailscale_oauth_secret_env = "TS_OAUTH_SECRET" +tailscale_tailnet = "example.com" +tailscale_tags = ["tag:tsk-sandbox"] +"#; + std::fs::write(config_dir.join("tsk.toml"), toml_content).unwrap(); + let config = load_config(config_dir); + + let resolved = config.resolve_config("any-project", None, None); + assert_eq!( + resolved.tailscale_oauth_client_id.as_deref(), + Some("kABC123") + ); + assert_eq!( + resolved.tailscale_oauth_secret_env.as_deref(), + Some("TS_OAUTH_SECRET") + ); + assert_eq!(resolved.tailscale_tailnet.as_deref(), Some("example.com")); + assert_eq!( + resolved.tailscale_tags, + Some(vec!["tag:tsk-sandbox".to_string()]) + ); + // Unset PAT fields stay None. + assert!(resolved.tailscale_api_key_env.is_none()); + assert!(resolved.tailscale_api_key_file.is_none()); + } + + #[test] + fn test_has_tailscale_mint_credential() { + let none = ResolvedConfig::default(); + assert!(!none.has_tailscale_mint_credential()); + + let pat = ResolvedConfig { + tailscale_api_key_env: Some("TS_API_KEY".to_string()), + ..Default::default() + }; + assert!(pat.has_tailscale_mint_credential()); + + let oauth = ResolvedConfig { + tailscale_oauth_client_id: Some("kABC".to_string()), + ..Default::default() + }; + assert!(oauth.has_tailscale_mint_credential()); + + let oauth_secret_only = ResolvedConfig { + tailscale_oauth_secret_env: Some("TS_OAUTH_SECRET".to_string()), + ..Default::default() + }; + assert!(oauth_secret_only.has_tailscale_mint_credential()); + } + + #[test] + fn test_tailscale_tailnet_and_tags_defaults() { + let resolved = ResolvedConfig::default(); + assert_eq!(resolved.tailscale_tailnet(), "-"); + assert_eq!( + resolved.tailscale_tags(), + vec!["tag:tsk-sandbox".to_string()] + ); + + let custom = ResolvedConfig { + tailscale_tailnet: Some("example.com".to_string()), + tailscale_tags: Some(vec!["tag:ci".to_string()]), + ..Default::default() + }; + assert_eq!(custom.tailscale_tailnet(), "example.com"); + assert_eq!(custom.tailscale_tags(), vec!["tag:ci".to_string()]); + } + + #[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..2c80d64 100644 --- a/src/docker/mod.rs +++ b/src/docker/mod.rs @@ -99,6 +99,194 @@ 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