feat(v0.3.0): custom minimal kernel, proxy-based networking, and landing page overhaul

This commit is contained in:
harshdoesdev
2026-03-10 04:51:26 +05:30
parent a5b2d783a4
commit c2b046f1dd
37 changed files with 2571 additions and 378 deletions
+11 -4
View File
@@ -6,12 +6,19 @@
Boot time reduced from ~5s to ~1s by replacing the Debian cloud kernel with a custom minimal Linux 6.12.x kernel.
**What changed:**
- Custom kernel built from `kernel/shuru_defconfig` with all VirtIO drivers built-in (~8MB, no loadable modules)
- Simplified initramfs with no loadable modules
- Simplified initramfs with no module loading, no DHCP, no /dev/vda polling
- Quiet boot by default, use `--verbose` to see kernel output
- Re-enabled ext4 journal for data integrity
### Proxy-based networking
All guest network traffic now flows through a userspace proxy on the host. No NAT device, no direct internet access.
- Domain allowlists via `shuru.json`
- Secret injection: API keys stay on host, placeholder tokens swapped at proxy
- MITM TLS only when secrets need to be injected; blind-tunneled otherwise
- Fixed placeholder token collision with atomic counter
- Instance directory cleanup on error and PID reuse
**Note:** Existing checkpoints created with 0.2.x will continue to work.
Generated
+382 -10
View File
@@ -17,6 +17,12 @@ dependencies = [
"memchr",
]
[[package]]
name = "allocator-api2"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
[[package]]
name = "anstream"
version = "0.6.21"
@@ -73,12 +79,40 @@ version = "1.0.101"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea"
[[package]]
name = "aws-lc-rs"
version = "1.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94bffc006df10ac2a68c83692d734a465f8ee6c5b384d8545a636f81d858f4bf"
dependencies = [
"aws-lc-sys",
"zeroize",
]
[[package]]
name = "aws-lc-sys"
version = "0.38.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4321e568ed89bb5a7d291a7f37997c2c0df89809d7b6d12062c81ddb54aa782e"
dependencies = [
"cc",
"cmake",
"dunce",
"fs_extra",
]
[[package]]
name = "base64"
version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "bitflags"
version = "1.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]]
name = "bitflags"
version = "2.11.0"
@@ -94,6 +128,12 @@ dependencies = [
"objc2",
]
[[package]]
name = "byteorder"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "bytes"
version = "1.11.1"
@@ -107,6 +147,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2"
dependencies = [
"find-msvc-tools",
"jobserver",
"libc",
"shlex",
]
@@ -156,6 +198,15 @@ version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831"
[[package]]
name = "cmake"
version = "0.1.57"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d"
dependencies = [
"cc",
]
[[package]]
name = "colorchoice"
version = "1.0.4"
@@ -215,6 +266,47 @@ version = "0.8.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
[[package]]
name = "defmt"
version = "0.3.100"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0963443817029b2024136fc4dd07a5107eb8f977eaf18fcd1fdeb11306b64ad"
dependencies = [
"defmt 1.0.1",
]
[[package]]
name = "defmt"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "548d977b6da32fa1d1fda2876453da1e7df63ad0304c8b3dae4dbe7b96f39b78"
dependencies = [
"bitflags 1.3.2",
"defmt-macros",
]
[[package]]
name = "defmt-macros"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d4fc12a85bcf441cfe44344c4b72d58493178ce635338a3f3b78943aceb258e"
dependencies = [
"defmt-parser",
"proc-macro-error2",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "defmt-parser"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e"
dependencies = [
"thiserror",
]
[[package]]
name = "deranged"
version = "0.5.7"
@@ -230,7 +322,7 @@ version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
dependencies = [
"bitflags",
"bitflags 2.11.0",
"block2",
"libc",
"objc2",
@@ -256,6 +348,12 @@ dependencies = [
"litrs",
]
[[package]]
name = "dunce"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
[[package]]
name = "equivalent"
version = "1.0.2"
@@ -299,6 +397,12 @@ dependencies = [
"miniz_oxide",
]
[[package]]
name = "foldhash"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "form_urlencoded"
version = "1.2.2"
@@ -308,6 +412,12 @@ dependencies = [
"percent-encoding",
]
[[package]]
name = "fs_extra"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
[[package]]
name = "getrandom"
version = "0.2.17"
@@ -319,12 +429,54 @@ dependencies = [
"wasi",
]
[[package]]
name = "getrandom"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [
"cfg-if",
"libc",
"r-efi",
"wasip2",
]
[[package]]
name = "hash32"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606"
dependencies = [
"byteorder",
]
[[package]]
name = "hashbrown"
version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
dependencies = [
"allocator-api2",
"equivalent",
"foldhash",
]
[[package]]
name = "hashbrown"
version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
[[package]]
name = "heapless"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad"
dependencies = [
"hash32",
"stable_deref_trait",
]
[[package]]
name = "heck"
version = "0.5.0"
@@ -456,7 +608,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017"
dependencies = [
"equivalent",
"hashbrown",
"hashbrown 0.16.1",
]
[[package]]
@@ -471,6 +623,16 @@ version = "1.0.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
[[package]]
name = "jobserver"
version = "0.1.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33"
dependencies = [
"getrandom 0.3.4",
"libc",
]
[[package]]
name = "lazy_static"
version = "1.5.0"
@@ -489,7 +651,7 @@ version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616"
dependencies = [
"bitflags",
"bitflags 2.11.0",
"libc",
"redox_syscall",
]
@@ -518,6 +680,21 @@ version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
[[package]]
name = "lru"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f8cc7106155f10bdf99a6f379688f543ad6596a415375b36a59a054ceda1198"
dependencies = [
"hashbrown 0.15.5",
]
[[package]]
name = "managed"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ca88d725a0a943b096803bd34e73a4437208b6077654cc4ecb2947a5f91618d"
[[package]]
name = "matchers"
version = "0.2.0"
@@ -543,6 +720,17 @@ dependencies = [
"simd-adler32",
]
[[package]]
name = "mio"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc"
dependencies = [
"libc",
"wasi",
"windows-sys 0.61.2",
]
[[package]]
name = "nu-ansi-term"
version = "0.50.3"
@@ -573,7 +761,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
dependencies = [
"bitflags",
"bitflags 2.11.0",
"objc2",
"objc2-foundation",
]
@@ -584,7 +772,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
dependencies = [
"bitflags",
"bitflags 2.11.0",
"dispatch2",
"objc2",
]
@@ -601,7 +789,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
dependencies = [
"bitflags",
"bitflags 2.11.0",
"block2",
"libc",
"objc2",
@@ -614,7 +802,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8edb98625ad1f7dea2e82ab54d5bdf3f477e6645b550f4a7cced1818be9ff59d"
dependencies = [
"bitflags",
"bitflags 2.11.0",
"block2",
"dispatch2",
"objc2",
@@ -635,6 +823,16 @@ version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "pem"
version = "3.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be"
dependencies = [
"base64",
"serde_core",
]
[[package]]
name = "percent-encoding"
version = "2.3.2"
@@ -662,6 +860,28 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]]
name = "proc-macro-error-attr2"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5"
dependencies = [
"proc-macro2",
"quote",
]
[[package]]
name = "proc-macro-error2"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802"
dependencies = [
"proc-macro-error-attr2",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "proc-macro2"
version = "1.0.106"
@@ -680,13 +900,32 @@ dependencies = [
"proc-macro2",
]
[[package]]
name = "r-efi"
version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]]
name = "rcgen"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2"
dependencies = [
"pem",
"ring",
"rustls-pki-types",
"time",
"yasna",
]
[[package]]
name = "redox_syscall"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35985aa610addc02e24fc232012c86fd11f14111180f902b67e2d5331f8ebf2b"
dependencies = [
"bitflags",
"bitflags 2.11.0",
]
[[package]]
@@ -714,7 +953,7 @@ checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
dependencies = [
"cc",
"cfg-if",
"getrandom",
"getrandom 0.2.17",
"libc",
"untrusted",
"windows-sys 0.52.0",
@@ -726,7 +965,7 @@ version = "1.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34"
dependencies = [
"bitflags",
"bitflags 2.11.0",
"errno",
"libc",
"linux-raw-sys",
@@ -739,6 +978,7 @@ version = "0.23.36"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b"
dependencies = [
"aws-lc-rs",
"log",
"once_cell",
"ring",
@@ -763,6 +1003,7 @@ version = "0.103.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53"
dependencies = [
"aws-lc-rs",
"ring",
"rustls-pki-types",
"untrusted",
@@ -837,6 +1078,7 @@ dependencies = [
"libc",
"serde",
"serde_json",
"shuru-proxy",
"shuru-vm",
"tar",
"tracing",
@@ -874,6 +1116,23 @@ dependencies = [
"serde_json",
]
[[package]]
name = "shuru-proxy"
version = "0.1.0"
dependencies = [
"anyhow",
"libc",
"lru",
"rcgen",
"rustls",
"simple-dns",
"smoltcp",
"tokio",
"tokio-rustls",
"tracing",
"webpki-roots",
]
[[package]]
name = "shuru-vm"
version = "0.1.0"
@@ -893,12 +1152,45 @@ version = "0.3.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2"
[[package]]
name = "simple-dns"
version = "0.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dee851d0e5e7af3721faea1843e8015e820a234f81fda3dea9247e15bac9a86a"
dependencies = [
"bitflags 2.11.0",
]
[[package]]
name = "smallvec"
version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
[[package]]
name = "smoltcp"
version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dad095989c1533c1c266d9b1e8d70a1329dd3723c3edac6d03bbd67e7bf6f4bb"
dependencies = [
"bitflags 1.3.2",
"byteorder",
"cfg-if",
"defmt 0.3.100",
"heapless",
"managed",
]
[[package]]
name = "socket2"
version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
dependencies = [
"libc",
"windows-sys 0.61.2",
]
[[package]]
name = "stable_deref_trait"
version = "1.2.1"
@@ -950,6 +1242,26 @@ dependencies = [
"xattr",
]
[[package]]
name = "thiserror"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "thread_local"
version = "1.1.9"
@@ -1000,6 +1312,42 @@ dependencies = [
"zerovec",
]
[[package]]
name = "tokio"
version = "1.50.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d"
dependencies = [
"bytes",
"libc",
"mio",
"pin-project-lite",
"socket2",
"tokio-macros",
"windows-sys 0.61.2",
]
[[package]]
name = "tokio-macros"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[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 = "tracing"
version = "0.1.44"
@@ -1153,6 +1501,15 @@ version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "wasip2"
version = "1.0.2+wasi-0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5"
dependencies = [
"wit-bindgen",
]
[[package]]
name = "webpki-roots"
version = "1.0.6"
@@ -1250,6 +1607,12 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "wit-bindgen"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
[[package]]
name = "writeable"
version = "0.6.2"
@@ -1266,6 +1629,15 @@ dependencies = [
"rustix",
]
[[package]]
name = "yasna"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd"
dependencies = [
"time",
]
[[package]]
name = "yoke"
version = "0.8.1"
+1
View File
@@ -4,6 +4,7 @@ members = [
"crates/shuru-cli",
"crates/shuru-darwin",
"crates/shuru-proto",
"crates/shuru-proxy",
"crates/shuru-vm",
"crates/shuru-guest",
]
+14 -25
View File
@@ -101,10 +101,23 @@ Shuru loads `shuru.json` from the current directory (or `--config PATH`). All fi
"allow_net": true,
"ports": ["8080:80"],
"mounts": ["./src:/workspace", "./data:/data"],
"command": ["python", "script.py"]
"command": ["python", "script.py"],
"secrets": {
"API_KEY": {
"from": "OPENAI_API_KEY",
"hosts": ["api.openai.com"]
}
},
"network": {
"allow": ["api.openai.com", "registry.npmjs.org"]
}
}
```
When `secrets` are configured, the guest receives a random placeholder token (e.g. `$API_KEY=shuru_tok_...`). The proxy substitutes the real value only on HTTPS requests to the specified hosts. The real secret never enters the VM.
The `network.allow` list restricts which domains the guest can reach. Omit it to allow all domains.
## SDK
Use shuru programmatically from TypeScript with the [`@superhq/shuru`](https://www.npmjs.com/package/@superhq/shuru) package.
@@ -140,30 +153,6 @@ cp -r skills/shuru .claude/skills/shuru
Once installed, agents will use `shuru run` whenever they need sandboxed execution.
## Architecture
```
┌─────────────────────────────────────────────┐
│ macOS Host │
│ │
│ shuru-cli ──► shuru-vm ──► shuru-darwin │
│ │ (Virtualization.framework)
│ │ │
│ ┌────────┼─────────┐ │
│ │ vsock :1024 exec │ │
│ │ vsock :1025 fwd │ │
│ │ virtiofs mounts │ │
│ └────────┼─────────┘ │
├──────────────────┼──────────────────────────┤
│ Linux Guest │ │
│ ┌────────┴─────────┐ │
│ │ shuru-guest │ │
│ │ (PID 1 init) │ │
│ └──────────────────┘ │
│ Debian GNU/Linux 13 (trixie) │
└─────────────────────────────────────────────┘
```
## Changelog
See [CHANGELOG.md](CHANGELOG.md) for release notes and breaking changes.
+1
View File
@@ -9,6 +9,7 @@ path = "src/main.rs"
[dependencies]
shuru-vm = { path = "../shuru-vm" }
shuru-proxy = { path = "../shuru-proxy" }
clap = { version = "4", features = ["derive", "env"] }
anyhow = "1"
libc = "0.2"
+1 -1
View File
@@ -26,7 +26,7 @@ pub(crate) struct VmArgs {
#[arg(long, env = "SHURU_INITRD")]
pub initrd: Option<String>,
/// Allow network access (NAT)
/// Allow network access
#[arg(long)]
pub allow_net: bool,
+48
View File
@@ -1,3 +1,5 @@
use std::collections::HashMap;
use anyhow::{bail, Result};
use serde::Deserialize;
@@ -10,6 +12,52 @@ pub(crate) struct ShuruConfig {
pub ports: Option<Vec<String>>,
pub mounts: Option<Vec<String>>,
pub command: Option<Vec<String>>,
pub secrets: Option<HashMap<String, SecretEntry>>,
pub network: Option<NetworkEntry>,
}
/// A secret to inject via the proxy.
/// Example: `{ "from": "OPENAI_API_KEY", "hosts": ["api.openai.com"] }`
#[derive(Debug, Clone, Deserialize)]
pub(crate) struct SecretEntry {
/// Host environment variable containing the real value.
pub from: String,
/// Domains where this secret may be sent.
pub hosts: Vec<String>,
}
/// Network access policy.
#[derive(Debug, Clone, Default, Deserialize)]
pub(crate) struct NetworkEntry {
/// Allowed domain patterns. Empty or absent = allow all.
pub allow: Option<Vec<String>>,
}
impl ShuruConfig {
/// Convert config sections into a ProxyConfig for shuru-proxy.
pub fn to_proxy_config(&self) -> shuru_proxy::config::ProxyConfig {
let mut proxy = shuru_proxy::config::ProxyConfig::default();
if let Some(ref secrets) = self.secrets {
for (name, entry) in secrets {
proxy.secrets.insert(
name.clone(),
shuru_proxy::config::SecretConfig {
from: entry.from.clone(),
hosts: entry.hosts.clone(),
},
);
}
}
if let Some(ref network) = self.network {
if let Some(ref allow) = network.allow {
proxy.network.allow = allow.clone();
}
}
proxy
}
}
pub(crate) fn load_config(config_flag: Option<&str>) -> Result<ShuruConfig> {
+6 -6
View File
@@ -46,16 +46,16 @@ fn main() -> Result<()> {
let prepared = vm::prepare_vm(&vm, &cfg, from.as_deref())?;
let exit_code = if stdio {
stdio::run_stdio(&prepared)?
let result = if stdio {
stdio::run_stdio(&prepared)
} else if console {
run_console(&prepared)?
run_console(&prepared)
} else {
vm::run_command(&prepared, &command)?
vm::run_command(&prepared, &command)
};
let _ = std::fs::remove_dir_all(&prepared.instance_dir);
process::exit(exit_code);
process::exit(result?);
}
Commands::Init { force } => {
let data_dir = default_data_dir();
@@ -141,7 +141,7 @@ fn run_console(prepared: &vm::PreparedVm) -> Result<i32> {
prepared.cpus, prepared.memory, prepared.disk_size
);
let sandbox = vm::build_sandbox(prepared, true)?;
let sandbox = vm::build_sandbox(prepared, true, None)?;
eprintln!("shuru: VM created and validated successfully");
let state_rx = sandbox.state_channel();
+1 -1
View File
@@ -152,7 +152,7 @@ macro_rules! parse_params {
pub(crate) fn run_stdio(prepared: &PreparedVm) -> Result<i32> {
let mut out = io::stdout().lock();
let sandbox = vm::build_sandbox(prepared, false)?;
let sandbox = vm::build_sandbox(prepared, false, None)?;
sandbox.start()?;
let _fwd = if !prepared.forwards.is_empty() {
+59 -8
View File
@@ -37,7 +37,7 @@ pub(crate) struct PreparedVm {
pub cpus: usize,
pub memory: u64,
pub disk_size: u64,
pub allow_net: bool,
pub proxy_config: Option<shuru_proxy::config::ProxyConfig>,
pub verbose: bool,
pub forwards: Vec<PortMapping>,
pub mounts: Vec<MountConfig>,
@@ -54,6 +54,12 @@ pub(crate) fn prepare_vm(
let allow_net = vm.allow_net || cfg.allow_net.unwrap_or(false);
let verbose = vm.verbose;
let proxy_config = if allow_net {
Some(cfg.to_proxy_config())
} else {
None
};
// Merge port forwards: CLI flags + config file
let mut port_strs: Vec<&str> = vm.port.iter().map(|s| s.as_str()).collect();
if let Some(ref cfg_ports) = cfg.ports {
@@ -134,8 +140,9 @@ pub(crate) fn prepare_vm(
}
};
// Create per-instance working copy
// Create per-instance working copy (clean any stale dir from PID reuse)
let instance_dir = format!("{}/instances/{}", data_dir, std::process::id());
let _ = std::fs::remove_dir_all(&instance_dir);
std::fs::create_dir_all(&instance_dir)?;
let work_rootfs = format!("{}/rootfs.ext4", instance_dir);
if verbose {
@@ -179,23 +186,30 @@ pub(crate) fn prepare_vm(
cpus,
memory,
disk_size,
allow_net,
proxy_config,
verbose,
forwards,
mounts,
})
}
pub(crate) fn build_sandbox(prepared: &PreparedVm, console: bool) -> Result<Sandbox> {
pub(crate) fn build_sandbox(
prepared: &PreparedVm,
console: bool,
network_fd: Option<i32>,
) -> Result<Sandbox> {
let mut builder = Sandbox::builder()
.kernel(&prepared.kernel_path)
.rootfs(&prepared.work_rootfs)
.cpus(prepared.cpus)
.memory_mb(prepared.memory)
.allow_net(prepared.allow_net)
.console(console)
.verbose(prepared.verbose);
if let Some(fd) = network_fd {
builder = builder.network_fd(fd);
}
if let Some(initrd) = &prepared.initrd_path {
builder = builder.initrd(initrd);
}
@@ -217,7 +231,21 @@ pub(crate) fn run_command(prepared: &PreparedVm, command: &[String]) -> Result<i
prepared.cpus, prepared.memory, prepared.disk_size
);
let sandbox = build_sandbox(prepared, false)?;
// Set up proxy networking if --allow-net
let (vm_fd, proxy_handle) = if let Some(ref proxy_config) = prepared.proxy_config {
let (vm_fd, host_fd) = shuru_proxy::create_socketpair()?;
let handle = shuru_proxy::start(host_fd, proxy_config.clone())?;
if prepared.verbose {
eprintln!("shuru: proxy started");
}
(Some(vm_fd), Some(handle))
} else {
(None, None)
};
let sandbox = build_sandbox(prepared, false, vm_fd)?;
if prepared.verbose {
eprintln!("shuru: VM created and validated successfully");
}
@@ -233,12 +261,35 @@ pub(crate) fn run_command(prepared: &PreparedVm, command: &[String]) -> Result<i
None
};
// Inject CA cert and secret placeholders when MITM is needed
let mut env = HashMap::new();
if let Some(ref handle) = proxy_handle {
if !handle.placeholders.is_empty() {
sandbox.write_file(
"/usr/local/share/ca-certificates/shuru-proxy.crt",
&handle.ca_cert_pem,
)?;
sandbox.exec(
&["update-ca-certificates", "--fresh"],
&mut std::io::sink(),
&mut std::io::sink(),
)?;
if prepared.verbose {
eprintln!("shuru: proxy CA certificate injected");
}
for (name, placeholder) in &handle.placeholders {
env.insert(name.clone(), placeholder.clone());
}
}
}
let exit_code = if std::io::stdin().is_terminal() {
sandbox.shell(command, &HashMap::new())?
sandbox.shell(command, &env)?
} else {
sandbox.exec(command, &mut std::io::stdout(), &mut std::io::stderr())?
sandbox.exec_with_env(command, &env, &mut std::io::stdout(), &mut std::io::stderr())?
};
drop(proxy_handle);
let _ = sandbox.stop();
Ok(exit_code)
}
+4 -1
View File
@@ -21,4 +21,7 @@ objc2-foundation = { version = "0.3", features = [
"NSObject",
"NSObjCRuntime",
] }
objc2-virtualization = { version = "0.3", features = ["dispatch2"] }
objc2-virtualization = { version = "0.3", features = [
"dispatch2",
"VZFileHandleNetworkDeviceAttachment",
] }
+2 -2
View File
@@ -6,7 +6,7 @@ mod configuration;
mod directory_sharing;
mod entropy;
mod memory;
mod network;
pub mod network;
mod serial;
mod socket;
mod storage;
@@ -19,7 +19,7 @@ pub use configuration::VirtualMachineConfiguration;
pub use directory_sharing::{SharedDirectory, VirtioFileSystemDevice};
pub use entropy::VirtioEntropyDevice;
pub use memory::VirtioMemoryBalloonDevice;
pub use network::{NATNetworkAttachment, MACAddress, VirtioNetworkDevice};
pub use network::{FileHandleNetworkAttachment, MACAddress, VirtioNetworkDevice};
pub use serial::{FileHandleSerialAttachment, VirtioConsoleSerialPort};
pub use socket::VirtioSocketDevice;
pub use storage::{
+33 -17
View File
@@ -1,27 +1,45 @@
use std::os::unix::io::RawFd;
use objc2::rc::Retained;
use objc2::AnyThread;
use objc2_foundation::NSFileHandle;
use objc2_virtualization::{
VZMACAddress, VZNATNetworkDeviceAttachment, VZNetworkDeviceAttachment,
VZFileHandleNetworkDeviceAttachment, VZMACAddress, VZNetworkDeviceAttachment,
VZNetworkDeviceConfiguration, VZVirtioNetworkDeviceConfiguration,
};
pub struct NATNetworkAttachment {
inner: Retained<VZNATNetworkDeviceAttachment>,
pub trait NetworkAttachment {
fn as_vz_attachment(&self) -> Retained<VZNetworkDeviceAttachment>;
}
impl NATNetworkAttachment {
pub fn new() -> Self {
NATNetworkAttachment {
inner: unsafe {
VZNATNetworkDeviceAttachment::init(VZNATNetworkDeviceAttachment::alloc())
},
pub struct FileHandleNetworkAttachment {
inner: Retained<VZFileHandleNetworkDeviceAttachment>,
}
impl FileHandleNetworkAttachment {
/// Creates a network attachment from a connected datagram socket fd.
/// The fd should be one end of `socketpair(AF_UNIX, SOCK_DGRAM)`.
/// VZ takes ownership of this fd (closes on dealloc).
pub fn new(fd: RawFd) -> Self {
unsafe {
let file_handle = NSFileHandle::initWithFileDescriptor_closeOnDealloc(
NSFileHandle::alloc(),
fd,
true,
);
FileHandleNetworkAttachment {
inner: VZFileHandleNetworkDeviceAttachment::initWithFileHandle(
VZFileHandleNetworkDeviceAttachment::alloc(),
&file_handle,
),
}
}
}
}
impl Default for NATNetworkAttachment {
fn default() -> Self {
Self::new()
impl NetworkAttachment for FileHandleNetworkAttachment {
fn as_vz_attachment(&self) -> Retained<VZNetworkDeviceAttachment> {
unsafe { Retained::cast_unchecked(self.inner.clone()) }
}
}
@@ -64,17 +82,15 @@ impl VirtioNetworkDevice {
}
}
pub fn new_with_attachment(attachment: &NATNetworkAttachment) -> Self {
pub fn new_with_attachment(attachment: &impl NetworkAttachment) -> Self {
let config = Self::new();
config.set_attachment(attachment);
config
}
pub fn set_attachment(&self, attachment: &NATNetworkAttachment) {
pub fn set_attachment(&self, attachment: &impl NetworkAttachment) {
unsafe {
let id: Retained<VZNetworkDeviceAttachment> =
Retained::cast_unchecked(attachment.inner.clone());
self.inner.setAttachment(Some(&id));
self.inner.setAttachment(Some(&attachment.as_vz_attachment()));
}
}
+2 -2
View File
@@ -144,7 +144,7 @@ mod guest {
}
// --- Networking setup ---
// DHCP is handled by udhcpc in the initramfs before switch_root.
// Network is configured by initramfs before switch_root (static IP for proxy).
// By the time we get here, eth0 already has an IP if --allow-net was used.
fn setup_networking() {
@@ -174,7 +174,7 @@ mod guest {
return;
}
// Check if eth0 already has an IP (configured by initramfs DHCP)
// Check if eth0 already has an IP (configured by initramfs)
let has_ip = {
let mut ifr: libc::ifreq = std::mem::zeroed();
std::ptr::copy_nonoverlapping(
+24
View File
@@ -0,0 +1,24 @@
[package]
name = "shuru-proxy"
version = "0.1.0"
edition = "2021"
description = "Transparent network proxy for shuru microVMs"
[dependencies]
smoltcp = { version = "0.12", default-features = false, features = [
"alloc",
"medium-ethernet",
"proto-ipv4",
"socket-tcp",
"socket-udp",
] }
tokio = { version = "1", features = ["rt-multi-thread", "net", "io-util", "sync", "macros", "time"] }
tokio-rustls = "0.26"
rustls = { version = "0.23", default-features = false, features = ["aws_lc_rs", "logging", "tls12"] }
rcgen = "0.13"
webpki-roots = "1"
simple-dns = "0.9"
lru = "0.14"
anyhow = "1"
tracing = "0.1"
libc = "0.2"
+93
View File
@@ -0,0 +1,93 @@
use std::collections::HashMap;
/// Configuration for the proxy engine.
#[derive(Debug, Clone, Default)]
pub struct ProxyConfig {
/// Secrets to inject. Key is the env var name visible to the guest.
/// The guest gets a random placeholder token; the proxy substitutes
/// the real value only when the request targets an allowed host.
pub secrets: HashMap<String, SecretConfig>,
/// Network access rules.
pub network: NetworkConfig,
}
/// A secret that the proxy injects into HTTP requests.
#[derive(Debug, Clone)]
pub struct SecretConfig {
/// Host environment variable to read the real value from.
pub from: String,
/// Domain patterns where this secret may be sent (e.g., "api.openai.com").
/// The proxy only substitutes the placeholder on requests to these hosts.
pub hosts: Vec<String>,
}
/// Network access policy.
#[derive(Debug, Clone, Default)]
pub struct NetworkConfig {
/// Allowed domain patterns. Empty = allow all.
/// Supports wildcards: "*.openai.com", "registry.npmjs.org".
pub allow: Vec<String>,
}
impl ProxyConfig {
/// Check if a domain is allowed by the network policy.
/// Empty allowlist means all domains are allowed.
pub fn is_domain_allowed(&self, domain: &str) -> bool {
if self.network.allow.is_empty() {
return true;
}
self.network
.allow
.iter()
.any(|pattern| domain_matches(pattern, domain))
}
/// Get all secret placeholder→real value mappings for a given domain.
pub fn secrets_for_domain(
&self,
domain: &str,
placeholders: &HashMap<String, String>,
) -> Vec<(String, String)> {
let mut result = Vec::new();
for (name, secret) in &self.secrets {
if secret
.hosts
.iter()
.any(|pattern| domain_matches(pattern, domain))
{
if let Some(placeholder) = placeholders.get(name) {
if let Ok(real_value) = std::env::var(&secret.from) {
result.push((placeholder.clone(), real_value));
}
}
}
}
result
}
}
/// Simple wildcard domain matching.
/// "*.example.com" matches "api.example.com" but not "example.com".
/// "example.com" matches exactly "example.com".
fn domain_matches(pattern: &str, domain: &str) -> bool {
if let Some(suffix) = pattern.strip_prefix("*.") {
domain.ends_with(suffix) && domain.len() > suffix.len() && domain.as_bytes()[domain.len() - suffix.len() - 1] == b'.'
} else {
pattern == domain
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_domain_matching() {
assert!(domain_matches("example.com", "example.com"));
assert!(!domain_matches("example.com", "api.example.com"));
assert!(domain_matches("*.example.com", "api.example.com"));
assert!(domain_matches("*.example.com", "deep.api.example.com"));
assert!(!domain_matches("*.example.com", "example.com"));
assert!(!domain_matches("*.example.com", "notexample.com"));
}
}
+122
View File
@@ -0,0 +1,122 @@
use std::os::unix::io::RawFd;
use smoltcp::phy::{self, Checksum, Device, DeviceCapabilities, Medium};
use smoltcp::time::Instant;
const MTU: usize = 1514; // 14-byte Ethernet header + 1500-byte IP payload
/// smoltcp Device backed by a Unix datagram socketpair fd.
///
/// One end of the socketpair is given to VZFileHandleNetworkDeviceAttachment
/// (the VM side). This Device reads/writes the other end (the host side),
/// giving us raw L2 Ethernet frames from/to the guest.
pub struct VZDevice {
fd: RawFd,
recv_buf: Vec<u8>,
/// Frame pre-read by `try_recv()`, waiting to be consumed by smoltcp.
pending_rx: Option<Vec<u8>>,
}
impl VZDevice {
pub fn new(fd: RawFd) -> Self {
VZDevice {
fd,
recv_buf: vec![0u8; MTU + 64], // slack for oversized frames
pending_rx: None,
}
}
/// Attempt to read a frame from the socketpair (non-blocking).
/// Call this before `Interface::poll()` so we can inspect the frame
/// (e.g. to detect TCP SYN and dynamically add listening sockets).
pub fn try_recv(&mut self) {
if self.pending_rx.is_some() {
return; // already have a pending frame
}
let n = unsafe {
libc::recv(
self.fd,
self.recv_buf.as_mut_ptr() as *mut libc::c_void,
self.recv_buf.len(),
libc::MSG_DONTWAIT,
)
};
if n > 0 {
self.pending_rx = Some(self.recv_buf[..n as usize].to_vec());
}
}
/// Peek at the pending frame without consuming it.
pub fn peek_frame(&self) -> Option<&[u8]> {
self.pending_rx.as_deref()
}
}
pub struct VZRxToken {
buffer: Vec<u8>,
}
impl phy::RxToken for VZRxToken {
fn consume<R, F>(self, f: F) -> R
where
F: FnOnce(&[u8]) -> R,
{
f(&self.buffer)
}
}
pub struct VZTxToken {
fd: RawFd,
}
impl phy::TxToken for VZTxToken {
fn consume<R, F>(self, len: usize, f: F) -> R
where
F: FnOnce(&mut [u8]) -> R,
{
let mut buffer = vec![0u8; len];
let result = f(&mut buffer);
let sent = unsafe {
libc::send(
self.fd,
buffer.as_ptr() as *const libc::c_void,
buffer.len(),
0,
)
};
if sent < 0 {
tracing::warn!("TX {len} bytes failed: sent={sent}");
}
result
}
}
impl Device for VZDevice {
type RxToken<'a> = VZRxToken;
type TxToken<'a> = VZTxToken;
fn receive(&mut self, _timestamp: Instant) -> Option<(Self::RxToken<'_>, Self::TxToken<'_>)> {
let buffer = self.pending_rx.take()?;
Some((VZRxToken { buffer }, VZTxToken { fd: self.fd }))
}
fn transmit(&mut self, _timestamp: Instant) -> Option<Self::TxToken<'_>> {
Some(VZTxToken { fd: self.fd })
}
fn capabilities(&self) -> DeviceCapabilities {
let mut caps = DeviceCapabilities::default();
caps.medium = Medium::Ethernet;
caps.max_transmission_unit = MTU;
// The guest VirtIO NIC offloads checksum calculation, so incoming
// frames may have partial/invalid checksums. Tell smoltcp to only
// generate checksums on TX (for the guest to verify), not verify on RX.
caps.checksum.ipv4 = Checksum::Tx;
caps.checksum.tcp = Checksum::Tx;
caps.checksum.udp = Checksum::Tx;
caps.checksum.icmpv4 = Checksum::Tx;
caps
}
}
+82
View File
@@ -0,0 +1,82 @@
use std::net::UdpSocket;
use simple_dns::Packet;
use smoltcp::wire::IpEndpoint;
use tokio::sync::mpsc;
use tracing::debug;
use crate::config::ProxyConfig;
use crate::stack::StackCommand;
/// Handle a DNS query from the guest.
///
/// Resolves the query on the host and sends the response back via the stack.
pub async fn handle_dns_query(
src: IpEndpoint,
payload: Vec<u8>,
cmd_tx: mpsc::UnboundedSender<StackCommand>,
config: &ProxyConfig,
) {
let response = match resolve_query(&payload, &config).await {
Ok(resp) => resp,
Err(e) => {
debug!("DNS resolution failed: {e}");
return;
}
};
let _ = cmd_tx.send(StackCommand::DnsResponse {
dst: src,
payload: response,
});
}
async fn resolve_query(query_bytes: &[u8], config: &ProxyConfig) -> anyhow::Result<Vec<u8>> {
let query = Packet::parse(query_bytes)?;
let question = query
.questions
.first()
.ok_or_else(|| anyhow::anyhow!("empty DNS query"))?;
let qname = question.qname.to_string();
let domain = qname.trim_end_matches('.');
debug!("DNS query: {domain}");
if !config.is_domain_allowed(domain) {
debug!("DNS blocked: {domain}");
return build_refused_response(query_bytes);
}
// Resolve on the host by forwarding to system resolver
let response_bytes = tokio::task::spawn_blocking({
let query = query_bytes.to_vec();
move || forward_to_system_resolver(&query)
})
.await??;
Ok(response_bytes)
}
/// Forward a raw DNS query to the system resolver and return the raw response.
fn forward_to_system_resolver(query: &[u8]) -> anyhow::Result<Vec<u8>> {
let sock = UdpSocket::bind("0.0.0.0:0")?;
sock.set_read_timeout(Some(std::time::Duration::from_secs(5)))?;
sock.send_to(query, "8.8.8.8:53")?;
let mut buf = [0u8; 4096];
let n = sock.recv(&mut buf)?;
Ok(buf[..n].to_vec())
}
/// Build a REFUSED response for blocked domains.
fn build_refused_response(query_bytes: &[u8]) -> anyhow::Result<Vec<u8>> {
let mut response = query_bytes.to_vec();
if response.len() < 12 {
return Err(anyhow::anyhow!("query too short"));
}
// Set QR=1 (response), keep opcode, set RCODE=5 (REFUSED)
response[2] |= 0x80;
response[3] = (response[3] & 0xF0) | 0x05;
Ok(response)
}
+133
View File
@@ -0,0 +1,133 @@
pub mod config;
mod device;
mod dns;
mod proxy;
mod stack;
mod stream;
mod tls;
pub use config::ProxyConfig;
use std::collections::HashMap;
use std::os::unix::io::RawFd;
use proxy::ProxyEngine;
use stack::NetworkStack;
use tls::CertificateAuthority;
use tokio::sync::mpsc;
use tracing::info;
/// Handle to a running proxy. Shuts down on drop.
pub struct ProxyHandle {
_stack_thread: std::thread::JoinHandle<()>,
_runtime_thread: std::thread::JoinHandle<()>,
/// Placeholder tokens generated for secrets. Key = env var name, Value = placeholder.
pub placeholders: HashMap<String, String>,
/// CA certificate in PEM format (for injecting into guest trust store).
pub ca_cert_pem: Vec<u8>,
}
/// Generate a unique placeholder token for a secret.
fn generate_placeholder() -> String {
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
static COUNTER: AtomicU64 = AtomicU64::new(0);
let ts = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos() as u64;
let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
format!("shuru_tok_{:016x}{:04x}", ts, seq)
}
/// Create a Unix datagram socketpair for VZFileHandleNetworkDeviceAttachment.
/// Returns (vm_fd, host_fd). The vm_fd goes to VZ, host_fd goes to the proxy.
pub fn create_socketpair() -> anyhow::Result<(RawFd, RawFd)> {
let mut fds = [0i32; 2];
let ret = unsafe { libc::socketpair(libc::AF_UNIX, libc::SOCK_DGRAM, 0, fds.as_mut_ptr()) };
if ret != 0 {
return Err(anyhow::anyhow!(
"socketpair failed: {}",
std::io::Error::last_os_error()
));
}
let host_fd = fds[1];
// Apple recommends SO_RCVBUF >= 2x SO_SNDBUF for VZFileHandleNetworkDeviceAttachment
unsafe {
let sndbuf: libc::c_int = 1024 * 1024;
let rcvbuf: libc::c_int = 4 * 1024 * 1024;
libc::setsockopt(
host_fd,
libc::SOL_SOCKET,
libc::SO_SNDBUF,
&sndbuf as *const _ as _,
std::mem::size_of::<libc::c_int>() as _,
);
libc::setsockopt(
host_fd,
libc::SOL_SOCKET,
libc::SO_RCVBUF,
&rcvbuf as *const _ as _,
std::mem::size_of::<libc::c_int>() as _,
);
}
Ok((fds[0], fds[1]))
}
/// Start the proxy engine. Returns a handle that keeps it running.
///
/// - `host_fd`: the host end of the socketpair (raw L2 Ethernet frames)
/// - `config`: proxy configuration (secrets, network rules)
pub fn start(host_fd: RawFd, config: ProxyConfig) -> anyhow::Result<ProxyHandle> {
// Install rustls crypto provider (process-wide, idempotent)
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
let ca = CertificateAuthority::new()?;
let ca_cert_pem = ca.ca_cert_pem();
// Generate placeholder tokens for each secret
let mut placeholders = HashMap::new();
for name in config.secrets.keys() {
placeholders.insert(name.clone(), generate_placeholder());
}
let (event_tx, event_rx) = mpsc::unbounded_channel();
let (cmd_tx, cmd_rx) = mpsc::unbounded_channel();
let stack_thread = std::thread::Builder::new()
.name("shuru-netstack".into())
.spawn(move || {
let mut stack = NetworkStack::new(host_fd, event_tx, cmd_rx);
stack.run();
})?;
let proxy_config = config;
let proxy_placeholders = placeholders.clone();
let runtime_thread = std::thread::Builder::new()
.name("shuru-proxy".into())
.spawn(move || {
let rt = tokio::runtime::Builder::new_multi_thread()
.worker_threads(2)
.enable_all()
.build()
.expect("failed to create tokio runtime for proxy");
rt.block_on(async move {
let mut engine =
ProxyEngine::new(proxy_config, event_rx, cmd_tx, ca, proxy_placeholders);
engine.run().await;
});
})?;
info!("proxy started");
Ok(ProxyHandle {
_stack_thread: stack_thread,
_runtime_thread: runtime_thread,
placeholders,
ca_cert_pem,
})
}
+446
View File
@@ -0,0 +1,446 @@
use std::collections::HashMap;
use std::net::SocketAddr;
use std::sync::Arc;
use rustls::pki_types::ServerName;
use rustls::RootCertStore;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
use tokio::sync::mpsc;
use tokio_rustls::TlsConnector;
use tracing::{debug, info};
use crate::config::ProxyConfig;
use crate::dns;
use crate::stack::{ConnectionId, StackCommand, StackEvent, TcpConnection};
use crate::stream::ChannelStream;
use crate::tls::CertificateAuthority;
/// The async proxy engine.
///
/// Receives events from the smoltcp NetworkStack and proxies TCP connections
/// to the real internet, with optional MITM for secret injection.
pub struct ProxyEngine {
config: Arc<ProxyConfig>,
event_rx: mpsc::UnboundedReceiver<StackEvent>,
cmd_tx: mpsc::UnboundedSender<StackCommand>,
connections: HashMap<ConnectionId, mpsc::UnboundedSender<Vec<u8>>>,
placeholders: Arc<HashMap<String, String>>,
ca: Arc<tokio::sync::Mutex<CertificateAuthority>>,
upstream_tls: TlsConnector,
}
impl ProxyEngine {
pub fn new(
config: ProxyConfig,
event_rx: mpsc::UnboundedReceiver<StackEvent>,
cmd_tx: mpsc::UnboundedSender<StackCommand>,
ca: CertificateAuthority,
placeholders: HashMap<String, String>,
) -> Self {
let root_store = RootCertStore {
roots: webpki_roots::TLS_SERVER_ROOTS.into(),
};
let client_config = rustls::ClientConfig::builder()
.with_root_certificates(root_store)
.with_no_client_auth();
let upstream_tls = TlsConnector::from(Arc::new(client_config));
ProxyEngine {
config: Arc::new(config),
event_rx,
cmd_tx,
connections: HashMap::new(),
placeholders: Arc::new(placeholders),
ca: Arc::new(tokio::sync::Mutex::new(ca)),
upstream_tls,
}
}
/// Run the proxy event loop.
pub async fn run(&mut self) {
info!("proxy engine started");
while let Some(event) = self.event_rx.recv().await {
match event {
StackEvent::NewConnection(conn) => {
self.handle_new_connection(conn);
}
StackEvent::Data { id, payload } => {
if let Some(tx) = self.connections.get(&id) {
if tx.send(payload).is_err() {
self.connections.remove(&id);
}
}
}
StackEvent::Closed { id } => {
self.connections.remove(&id);
}
StackEvent::DnsQuery { src, payload } => {
let cmd_tx = self.cmd_tx.clone();
let config = self.config.clone();
tokio::spawn(async move {
dns::handle_dns_query(src, payload, cmd_tx, &config).await;
});
}
}
}
}
fn handle_new_connection(&mut self, conn: TcpConnection) {
let (data_tx, data_rx) = mpsc::unbounded_channel();
self.connections.insert(conn.id, data_tx);
let cmd_tx = self.cmd_tx.clone();
let config = self.config.clone();
let ca = self.ca.clone();
let placeholders = self.placeholders.clone();
let upstream_tls = self.upstream_tls.clone();
tokio::spawn(async move {
if let Err(e) = handle_connection(
conn.id,
conn.dst,
data_rx,
cmd_tx,
&config,
ca,
&placeholders,
upstream_tls,
)
.await
{
debug!("connection to {} ended: {e}", conn.dst);
}
});
}
}
/// Handle a single proxied TCP connection.
async fn handle_connection(
id: ConnectionId,
dst: SocketAddr,
mut data_rx: mpsc::UnboundedReceiver<Vec<u8>>,
cmd_tx: mpsc::UnboundedSender<StackCommand>,
config: &ProxyConfig,
ca: Arc<tokio::sync::Mutex<CertificateAuthority>>,
placeholders: &HashMap<String, String>,
upstream_tls: TlsConnector,
) -> anyhow::Result<()> {
let is_tls = dst.port() == 443;
if is_tls {
// Buffer data until we have a complete TLS ClientHello record.
// The ClientHello may span multiple TCP segments.
let mut tls_buf = data_rx
.recv()
.await
.ok_or_else(|| anyhow::anyhow!("connection closed before data"))?;
// TLS record header: type(1) + version(2) + length(2) = 5 bytes
// Keep reading until we have the full record
while tls_buf.len() >= 5 {
let record_len = u16::from_be_bytes([tls_buf[3], tls_buf[4]]) as usize;
if tls_buf.len() >= 5 + record_len {
break; // have the complete record
}
match data_rx.recv().await {
Some(chunk) => tls_buf.extend_from_slice(&chunk),
None => break, // connection closed
}
}
let sni = extract_sni(&tls_buf);
debug!("TLS to {dst}, SNI: {sni:?}");
if let Some(domain) = sni {
let substitutions = config.secrets_for_domain(&domain, placeholders);
if !substitutions.is_empty() {
debug!("MITM: {domain}");
return handle_mitm(
id,
dst,
domain,
tls_buf,
data_rx,
cmd_tx,
ca,
substitutions,
upstream_tls,
)
.await;
}
}
// Blind tunnel: forward the buffered data and relay the rest
debug!("blind tunnel to {dst}");
let upstream = TcpStream::connect(dst).await?;
let (mut upstream_rd, mut upstream_wr) = upstream.into_split();
// Send the buffered TLS data
upstream_wr.write_all(&tls_buf).await?;
return blind_relay(id, &mut upstream_rd, &mut upstream_wr, data_rx, cmd_tx).await;
}
// Non-TLS: blind tunnel
debug!("TCP tunnel to {dst}");
let upstream = TcpStream::connect(dst).await?;
let (mut upstream_rd, mut upstream_wr) = upstream.into_split();
blind_relay(id, &mut upstream_rd, &mut upstream_wr, data_rx, cmd_tx).await
}
/// Blind bidirectional relay (no inspection).
async fn blind_relay(
id: ConnectionId,
upstream_rd: &mut tokio::net::tcp::OwnedReadHalf,
upstream_wr: &mut tokio::net::tcp::OwnedWriteHalf,
mut data_rx: mpsc::UnboundedReceiver<Vec<u8>>,
cmd_tx: mpsc::UnboundedSender<StackCommand>,
) -> anyhow::Result<()> {
let cmd_tx_clone = cmd_tx.clone();
let upstream_to_guest = async {
let mut buf = vec![0u8; 65536];
loop {
match upstream_rd.read(&mut buf).await {
Ok(0) | Err(_) => break,
Ok(n) => {
if cmd_tx_clone
.send(StackCommand::Send {
id,
payload: buf[..n].to_vec(),
})
.is_err()
{
break;
}
}
}
}
};
let guest_to_upstream = async {
while let Some(payload) = data_rx.recv().await {
if upstream_wr.write_all(&payload).await.is_err() {
break;
}
}
};
tokio::select! {
_ = upstream_to_guest => {},
_ = guest_to_upstream => {},
}
let _ = cmd_tx.send(StackCommand::Close { id });
Ok(())
}
/// MITM: terminate TLS on both sides, relay with secret substitution.
async fn handle_mitm(
id: ConnectionId,
dst: SocketAddr,
domain: String,
first_chunk: Vec<u8>,
data_rx: mpsc::UnboundedReceiver<Vec<u8>>,
cmd_tx: mpsc::UnboundedSender<StackCommand>,
ca: Arc<tokio::sync::Mutex<CertificateAuthority>>,
substitutions: Vec<(String, String)>,
upstream_tls: TlsConnector,
) -> anyhow::Result<()> {
// Get fake cert for this domain
let acceptor = {
let mut ca = ca.lock().await;
ca.acceptor_for_domain(&domain)?
};
// Wrap guest data channel as AsyncRead+AsyncWrite
let mut guest_stream = ChannelStream::new(id, data_rx, cmd_tx.clone());
guest_stream.prepend(first_chunk);
// TLS handshake with guest (fake cert)
let mut guest_tls = acceptor.accept(guest_stream).await?;
// Open real TLS connection to upstream
let upstream_tcp = TcpStream::connect(dst).await?;
let server_name = ServerName::try_from(domain.clone())?;
let mut upstream_tls_stream = upstream_tls.connect(server_name, upstream_tcp).await?;
if substitutions.is_empty() {
// No substitutions needed, just relay
tokio::io::copy_bidirectional(&mut guest_tls, &mut upstream_tls_stream).await?;
} else {
// Relay with secret substitution
let (mut guest_rd, mut guest_wr) = tokio::io::split(guest_tls);
let (mut upstream_rd, mut upstream_wr) = tokio::io::split(upstream_tls_stream);
let subs = substitutions.clone();
let guest_to_upstream = async move {
let mut buf = vec![0u8; 65536];
loop {
match guest_rd.read(&mut buf).await {
Ok(0) | Err(_) => break,
Ok(n) => {
let mut data = buf[..n].to_vec();
// Replace placeholder tokens with real values
for (placeholder, real_value) in &subs {
data = replace_bytes(&data, placeholder.as_bytes(), real_value.as_bytes());
}
if upstream_wr.write_all(&data).await.is_err() {
break;
}
}
}
}
};
let upstream_to_guest = async move {
let mut buf = vec![0u8; 65536];
loop {
match upstream_rd.read(&mut buf).await {
Ok(0) | Err(_) => break,
Ok(n) => {
if guest_wr.write_all(&buf[..n]).await.is_err() {
break;
}
}
}
}
};
tokio::select! {
_ = guest_to_upstream => {},
_ = upstream_to_guest => {},
}
}
let _ = cmd_tx.send(StackCommand::Close { id });
Ok(())
}
/// Replace all occurrences of `from` with `to` in a byte slice.
fn replace_bytes(data: &[u8], from: &[u8], to: &[u8]) -> Vec<u8> {
if from.is_empty() || data.len() < from.len() {
return data.to_vec();
}
let mut result = Vec::with_capacity(data.len());
let mut i = 0;
while i <= data.len() - from.len() {
if &data[i..i + from.len()] == from {
result.extend_from_slice(to);
i += from.len();
} else {
result.push(data[i]);
i += 1;
}
}
// Append remaining bytes that can't contain the pattern
result.extend_from_slice(&data[i..]);
result
}
/// Extract SNI from a TLS ClientHello.
pub fn extract_sni(data: &[u8]) -> Option<String> {
if data.len() < 5 || data[0] != 0x16 {
return None;
}
let record_len = u16::from_be_bytes([data[3], data[4]]) as usize;
if data.len() < 5 + record_len {
return None;
}
let hs = &data[5..];
if hs.is_empty() || hs[0] != 0x01 {
return None;
}
if hs.len() < 38 {
return None;
}
let mut pos = 38;
// Session ID
if pos >= hs.len() {
return None;
}
let session_id_len = hs[pos] as usize;
pos += 1 + session_id_len;
// Cipher suites
if pos + 2 > hs.len() {
return None;
}
let cs_len = u16::from_be_bytes([hs[pos], hs[pos + 1]]) as usize;
pos += 2 + cs_len;
// Compression methods
if pos >= hs.len() {
return None;
}
let cm_len = hs[pos] as usize;
pos += 1 + cm_len;
// Extensions
if pos + 2 > hs.len() {
return None;
}
let ext_len = u16::from_be_bytes([hs[pos], hs[pos + 1]]) as usize;
pos += 2;
let ext_end = pos + ext_len;
while pos + 4 <= ext_end && pos + 4 <= hs.len() {
let ext_type = u16::from_be_bytes([hs[pos], hs[pos + 1]]);
let ext_data_len = u16::from_be_bytes([hs[pos + 2], hs[pos + 3]]) as usize;
pos += 4;
if ext_type == 0x0000 {
if ext_data_len >= 5 && pos + ext_data_len <= hs.len() {
let name_type = hs[pos + 2];
if name_type == 0x00 {
let name_len = u16::from_be_bytes([hs[pos + 3], hs[pos + 4]]) as usize;
if pos + 5 + name_len <= hs.len() {
return String::from_utf8(hs[pos + 5..pos + 5 + name_len].to_vec()).ok();
}
}
}
return None;
}
pos += ext_data_len;
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_extract_sni_none_for_non_tls() {
assert_eq!(extract_sni(b"GET / HTTP/1.1\r\n"), None);
assert_eq!(extract_sni(&[]), None);
}
#[test]
fn test_replace_bytes() {
assert_eq!(
replace_bytes(b"hello world", b"world", b"rust"),
b"hello rust"
);
assert_eq!(
replace_bytes(
b"key=shuru_tok_abc123&other=val",
b"shuru_tok_abc123",
b"real_secret"
),
b"key=real_secret&other=val"
);
assert_eq!(replace_bytes(b"no match", b"xyz", b"abc"), b"no match");
assert_eq!(replace_bytes(b"", b"x", b"y"), b"");
}
}
+322
View File
@@ -0,0 +1,322 @@
use std::collections::HashMap;
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
use std::os::unix::io::RawFd;
use smoltcp::iface::{Config, Interface, SocketHandle, SocketSet};
use smoltcp::socket::tcp::{self, Socket as TcpSocket};
use smoltcp::socket::udp::{self, Socket as UdpSocket};
use smoltcp::time::Instant;
use smoltcp::wire::{
EthernetAddress, EthernetFrame, HardwareAddress, IpAddress, IpCidr, IpEndpoint,
IpListenEndpoint, Ipv4Address, Ipv4Packet, TcpPacket,
};
use tokio::sync::mpsc;
use tracing::{debug, warn};
use crate::device::VZDevice;
/// Gateway IP inside the virtual network (host-side smoltcp).
pub const GATEWAY_IP: Ipv4Address = Ipv4Address::new(10, 0, 0, 1);
const PREFIX_LEN: u8 = 24;
/// Gateway MAC address (locally administered).
const GATEWAY_MAC: EthernetAddress = EthernetAddress([0x02, 0x00, 0x00, 0x00, 0x00, 0x01]);
const TCP_RX_BUF_SIZE: usize = 65536;
const TCP_TX_BUF_SIZE: usize = 65536;
/// A new TCP connection from the guest, ready to be proxied.
pub struct TcpConnection {
pub id: ConnectionId,
pub dst: SocketAddr,
}
/// Opaque handle to a guest-side TCP connection inside smoltcp.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct ConnectionId(SocketHandle);
/// Events from the network stack to the proxy engine.
pub enum StackEvent {
/// A new TCP connection was established from the guest.
NewConnection(TcpConnection),
/// Data received from the guest on an established connection.
Data { id: ConnectionId, payload: Vec<u8> },
/// Guest closed the connection.
Closed { id: ConnectionId },
/// A DNS query arrived on UDP port 53.
DnsQuery { src: IpEndpoint, payload: Vec<u8> },
}
/// Commands from the proxy engine back to the network stack.
pub enum StackCommand {
/// Send data to the guest on an established connection.
Send { id: ConnectionId, payload: Vec<u8> },
/// Close a connection from the host side.
Close { id: ConnectionId },
/// Send a DNS response back to the guest.
DnsResponse { dst: IpEndpoint, payload: Vec<u8> },
}
/// The smoltcp-based network stack.
///
/// Runs on a dedicated thread, polling the VZDevice and smoltcp interface.
/// Communicates with the async proxy engine via channels.
pub struct NetworkStack {
device: VZDevice,
iface: Interface,
sockets: SocketSet<'static>,
dns_handle: SocketHandle,
connections: HashMap<SocketHandle, SocketAddr>,
listening: HashMap<(Ipv4Address, u16), SocketHandle>,
event_tx: mpsc::UnboundedSender<StackEvent>,
cmd_rx: mpsc::UnboundedReceiver<StackCommand>,
}
impl NetworkStack {
pub fn new(
host_fd: RawFd,
event_tx: mpsc::UnboundedSender<StackEvent>,
cmd_rx: mpsc::UnboundedReceiver<StackCommand>,
) -> Self {
let mut device = VZDevice::new(host_fd);
let config = Config::new(HardwareAddress::Ethernet(GATEWAY_MAC));
let mut iface = Interface::new(config, &mut device, Self::now());
iface.update_ip_addrs(|addrs| {
addrs
.push(IpCidr::new(IpAddress::Ipv4(GATEWAY_IP), PREFIX_LEN))
.unwrap();
});
iface.set_any_ip(true);
iface
.routes_mut()
.add_default_ipv4_route(GATEWAY_IP)
.unwrap();
let mut sockets = SocketSet::new(vec![]);
// DNS socket: listen on gateway IP, port 53
let udp_rx = udp::PacketBuffer::new(vec![udp::PacketMetadata::EMPTY; 16], vec![0u8; 4096]);
let udp_tx = udp::PacketBuffer::new(vec![udp::PacketMetadata::EMPTY; 16], vec![0u8; 4096]);
let mut dns_socket = UdpSocket::new(udp_rx, udp_tx);
dns_socket
.bind(IpListenEndpoint {
addr: Some(IpAddress::Ipv4(GATEWAY_IP)),
port: 53,
})
.expect("bind DNS socket");
let dns_handle = sockets.add(dns_socket);
NetworkStack {
device,
iface,
sockets,
dns_handle,
connections: HashMap::new(),
listening: HashMap::new(),
event_tx,
cmd_rx,
}
}
/// Run the poll loop. Blocks the current thread.
pub fn run(&mut self) {
loop {
self.process_commands();
// Pre-read a frame and inspect it for TCP SYN
self.device.try_recv();
self.inspect_pending_frame();
self.iface
.poll(Self::now(), &mut self.device, &mut self.sockets);
self.poll_tcp_sockets();
self.poll_dns_socket();
let delay = self
.iface
.poll_delay(Self::now(), &self.sockets)
.map(|d| {
let micros = d.total_micros();
if micros == 0 {
std::time::Duration::from_millis(1)
} else {
std::time::Duration::from_micros(micros.min(50_000))
}
})
.unwrap_or(std::time::Duration::from_millis(1));
std::thread::sleep(delay);
}
}
fn process_commands(&mut self) {
while let Ok(cmd) = self.cmd_rx.try_recv() {
match cmd {
StackCommand::Send { id, payload } => {
let socket = self.sockets.get_mut::<TcpSocket>(id.0);
if socket.can_send() {
if let Err(e) = socket.send_slice(&payload) {
warn!("failed to send to guest: {e}");
}
}
}
StackCommand::Close { id } => {
let socket = self.sockets.get_mut::<TcpSocket>(id.0);
socket.close();
self.connections.remove(&id.0);
}
StackCommand::DnsResponse { dst, payload } => {
let socket = self.sockets.get_mut::<UdpSocket>(self.dns_handle);
if let Err(e) = socket.send_slice(&payload, dst) {
warn!("failed to send DNS response: {e}");
}
}
}
}
}
fn inspect_pending_frame(&mut self) {
// Extract SYN destination from the pending frame without copying the
// entire frame — only borrow self.device immutably for parsing, then
// release the borrow before mutating self.listening/self.sockets.
let (dst_ip, dst_port) = match self.device.peek_frame() {
Some(frame) => match Self::parse_syn_dst(frame) {
Some(pair) => pair,
None => return,
},
None => return,
};
if self.listening.contains_key(&(dst_ip, dst_port)) {
return;
}
let dst_addr = SocketAddr::new(
IpAddr::V4(Ipv4Addr::from(dst_ip.octets())),
dst_port,
);
if self.connections.values().any(|a| *a == dst_addr) {
return;
}
debug!("SYN → {}:{}, adding listener", dst_ip, dst_port);
let rx_buf = tcp::SocketBuffer::new(vec![0u8; TCP_RX_BUF_SIZE]);
let tx_buf = tcp::SocketBuffer::new(vec![0u8; TCP_TX_BUF_SIZE]);
let mut socket = TcpSocket::new(rx_buf, tx_buf);
socket
.listen(IpListenEndpoint {
addr: Some(IpAddress::Ipv4(dst_ip)),
port: dst_port,
})
.expect("listen on dynamic port");
let handle = self.sockets.add(socket);
self.listening.insert((dst_ip, dst_port), handle);
}
/// Parse a TCP SYN's destination from a raw Ethernet frame.
/// Returns None if the frame is not a TCP SYN (without ACK).
fn parse_syn_dst(frame: &[u8]) -> Option<(Ipv4Address, u16)> {
let eth = EthernetFrame::new_checked(frame).ok()?;
if eth.ethertype() != smoltcp::wire::EthernetProtocol::Ipv4 {
return None;
}
let ipv4 = Ipv4Packet::new_checked(eth.payload()).ok()?;
if ipv4.next_header() != smoltcp::wire::IpProtocol::Tcp {
return None;
}
let tcp = TcpPacket::new_checked(ipv4.payload()).ok()?;
if !tcp.syn() || tcp.ack() {
return None;
}
Some((ipv4.dst_addr(), tcp.dst_port()))
}
fn poll_tcp_sockets(&mut self) {
let handles: Vec<SocketHandle> = self
.listening
.values()
.copied()
.chain(self.connections.keys().copied())
.collect();
for handle in handles {
let socket = self.sockets.get_mut::<TcpSocket>(handle);
// LISTEN → ESTABLISHED transition
if socket.is_active() && !self.connections.contains_key(&handle) {
// local_endpoint() is the destination the guest was trying to reach
// (because any_ip=true, smoltcp accepted it as local)
if let Some(local) = socket.local_endpoint() {
let ipv4 = match local.addr {
IpAddress::Ipv4(ip) => ip,
};
self.listening.remove(&(ipv4, local.port));
let actual_dst = SocketAddr::new(
IpAddr::V4(Ipv4Addr::from(ipv4.octets())),
local.port,
);
self.connections.insert(handle, actual_dst);
let _ = self.event_tx.send(StackEvent::NewConnection(TcpConnection {
id: ConnectionId(handle),
dst: actual_dst,
}));
}
}
// Read data from established connections
if self.connections.contains_key(&handle) && socket.can_recv() {
let mut buf = vec![0u8; TCP_RX_BUF_SIZE];
match socket.recv_slice(&mut buf) {
Ok(n) if n > 0 => {
buf.truncate(n);
let _ = self.event_tx.send(StackEvent::Data {
id: ConnectionId(handle),
payload: buf,
});
}
_ => {}
}
}
// Detect closed connections
if self.connections.contains_key(&handle)
&& !socket.is_open()
&& !socket.may_recv()
&& !socket.may_send()
{
self.connections.remove(&handle);
let _ = self.event_tx.send(StackEvent::Closed {
id: ConnectionId(handle),
});
self.sockets.remove(handle);
}
}
}
fn poll_dns_socket(&mut self) {
let socket = self.sockets.get_mut::<UdpSocket>(self.dns_handle);
let mut buf = [0u8; 4096];
while socket.can_recv() {
match socket.recv_slice(&mut buf) {
Ok((n, meta)) => {
let _ = self.event_tx.send(StackEvent::DnsQuery {
src: meta.endpoint,
payload: buf[..n].to_vec(),
});
}
Err(_) => break,
}
}
}
fn now() -> Instant {
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap();
Instant::from_micros(ts.as_micros() as i64)
}
}
+113
View File
@@ -0,0 +1,113 @@
use std::io;
use std::pin::Pin;
use std::task::{Context, Poll};
use tokio::io::{AsyncRead, AsyncWrite, ReadBuf};
use tokio::sync::mpsc;
use crate::stack::{ConnectionId, StackCommand};
/// AsyncRead + AsyncWrite adapter over smoltcp channel-based data flow.
///
/// Allows tokio-rustls (and any other async I/O) to work with data
/// arriving from the smoltcp NetworkStack via channels.
pub struct ChannelStream {
id: ConnectionId,
data_rx: mpsc::UnboundedReceiver<Vec<u8>>,
cmd_tx: mpsc::UnboundedSender<StackCommand>,
read_buf: Vec<u8>,
read_pos: usize,
}
impl ChannelStream {
pub fn new(
id: ConnectionId,
data_rx: mpsc::UnboundedReceiver<Vec<u8>>,
cmd_tx: mpsc::UnboundedSender<StackCommand>,
) -> Self {
ChannelStream {
id,
data_rx,
cmd_tx,
read_buf: Vec::new(),
read_pos: 0,
}
}
/// Prepend data to the read buffer (e.g., a ClientHello already consumed).
pub fn prepend(&mut self, data: Vec<u8>) {
if self.read_pos < self.read_buf.len() {
// There's still unread data — prepend before it
let remaining = self.read_buf[self.read_pos..].to_vec();
self.read_buf = data;
self.read_buf.extend_from_slice(&remaining);
} else {
self.read_buf = data;
}
self.read_pos = 0;
}
}
impl AsyncRead for ChannelStream {
fn poll_read(
mut self: Pin<&mut Self>,
cx: &mut Context<'_>,
buf: &mut ReadBuf<'_>,
) -> Poll<io::Result<()>> {
// Drain buffered data first
if self.read_pos < self.read_buf.len() {
let available = &self.read_buf[self.read_pos..];
let n = available.len().min(buf.remaining());
buf.put_slice(&available[..n]);
self.read_pos += n;
if self.read_pos >= self.read_buf.len() {
self.read_buf.clear();
self.read_pos = 0;
}
return Poll::Ready(Ok(()));
}
// Receive from channel
match self.data_rx.poll_recv(cx) {
Poll::Ready(Some(data)) => {
let n = data.len().min(buf.remaining());
buf.put_slice(&data[..n]);
if n < data.len() {
self.read_buf = data;
self.read_pos = n;
}
Poll::Ready(Ok(()))
}
Poll::Ready(None) => Poll::Ready(Ok(())), // EOF
Poll::Pending => Poll::Pending,
}
}
}
impl AsyncWrite for ChannelStream {
fn poll_write(
self: Pin<&mut Self>,
_cx: &mut Context<'_>,
buf: &[u8],
) -> Poll<io::Result<usize>> {
match self.cmd_tx.send(StackCommand::Send {
id: self.id,
payload: buf.to_vec(),
}) {
Ok(()) => Poll::Ready(Ok(buf.len())),
Err(_) => Poll::Ready(Err(io::Error::new(
io::ErrorKind::BrokenPipe,
"stack channel closed",
))),
}
}
fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<io::Result<()>> {
Poll::Ready(Ok(()))
}
fn poll_shutdown(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<io::Result<()>> {
let _ = self.cmd_tx.send(StackCommand::Close { id: self.id });
Poll::Ready(Ok(()))
}
}
+87
View File
@@ -0,0 +1,87 @@
use std::sync::Arc;
use lru::LruCache;
use rcgen::{
CertificateParams, DistinguishedName, DnType, IsCa, KeyPair, KeyUsagePurpose,
BasicConstraints, Certificate,
};
use rustls::pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer};
use rustls::ServerConfig;
use tokio_rustls::TlsAcceptor;
/// Manages a root CA and generates per-domain TLS certificates for MITM.
pub struct CertificateAuthority {
ca_cert: Certificate,
ca_key: KeyPair,
cache: LruCache<String, Arc<ServerConfig>>,
}
impl CertificateAuthority {
/// Generate a new self-signed root CA.
pub fn new() -> anyhow::Result<Self> {
let mut params = CertificateParams::default();
params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
params.key_usages.push(KeyUsagePurpose::KeyCertSign);
params.key_usages.push(KeyUsagePurpose::CrlSign);
params.distinguished_name = DistinguishedName::new();
params
.distinguished_name
.push(DnType::CommonName, "Shuru Proxy CA");
params
.distinguished_name
.push(DnType::OrganizationName, "Shuru");
let key = KeyPair::generate()?;
let cert = params.self_signed(&key)?;
Ok(CertificateAuthority {
ca_cert: cert,
ca_key: key,
cache: LruCache::new(std::num::NonZeroUsize::new(256).unwrap()),
})
}
/// Get the CA certificate in PEM format (for injecting into the guest trust store).
pub fn ca_cert_pem(&self) -> Vec<u8> {
self.ca_cert.pem().into_bytes()
}
/// Get or create a TLS ServerConfig for the given domain.
pub fn server_config_for_domain(&mut self, domain: &str) -> anyhow::Result<Arc<ServerConfig>> {
if let Some(config) = self.cache.get(domain) {
return Ok(config.clone());
}
let config = self.generate_server_config(domain)?;
let config = Arc::new(config);
self.cache.put(domain.to_string(), config.clone());
Ok(config)
}
/// Get a TlsAcceptor for the given domain.
pub fn acceptor_for_domain(&mut self, domain: &str) -> anyhow::Result<TlsAcceptor> {
let config = self.server_config_for_domain(domain)?;
Ok(TlsAcceptor::from(config))
}
fn generate_server_config(&self, domain: &str) -> anyhow::Result<ServerConfig> {
let mut params = CertificateParams::new(vec![domain.to_string()])?;
params.distinguished_name = DistinguishedName::new();
params
.distinguished_name
.push(DnType::CommonName, domain);
let key = KeyPair::generate()?;
let cert = params.signed_by(&key, &self.ca_cert, &self.ca_key)?;
let cert_der = CertificateDer::from(cert.der().to_vec());
let ca_cert_der = CertificateDer::from(self.ca_cert.der().to_vec());
let key_der = PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(key.serialize_der()));
let config = ServerConfig::builder()
.with_no_client_auth()
.with_single_cert(vec![cert_der, ca_cert_der], key_der)?;
Ok(config)
}
}
+19 -8
View File
@@ -10,6 +10,7 @@ use anyhow::{bail, Context, Result};
use crossbeam_channel::Receiver;
use shuru_darwin::terminal;
use shuru_darwin::network::FileHandleNetworkAttachment;
use shuru_darwin::*;
use shuru_proto::{
@@ -36,7 +37,7 @@ pub struct VmConfigBuilder {
memory_mb: u64,
console: bool,
verbose: bool,
allow_net: bool,
network_fd: Option<i32>,
mounts: Vec<MountConfig>,
}
@@ -50,7 +51,7 @@ impl VmConfigBuilder {
memory_mb: 2048,
console: true,
verbose: false,
allow_net: false,
network_fd: None,
mounts: Vec::new(),
}
}
@@ -95,9 +96,9 @@ impl VmConfigBuilder {
self
}
/// Enable network access (NAT). Disabled by default for sandboxing.
pub fn allow_net(mut self, enabled: bool) -> Self {
self.allow_net = enabled;
/// Attach a network device via a socketpair fd for proxy-based networking.
pub fn network_fd(mut self, fd: i32) -> Self {
self.network_fd = Some(fd);
self
}
@@ -156,8 +157,8 @@ impl VmConfigBuilder {
let block_device = VirtioBlockDevice::new(&disk_attachment);
config.set_storage_devices(&[&block_device]);
if self.allow_net {
let net_attachment = NATNetworkAttachment::new();
if let Some(fd) = self.network_fd {
let net_attachment = FileHandleNetworkAttachment::new(fd);
let net_device = VirtioNetworkDevice::new_with_attachment(&net_attachment);
net_device.set_mac_address(&MACAddress::random_local());
config.set_network_devices(&[net_device]);
@@ -270,6 +271,16 @@ impl Sandbox {
argv: &[impl AsRef<str>],
stdout: &mut impl Write,
stderr: &mut impl Write,
) -> Result<i32> {
self.exec_with_env(argv, &HashMap::new(), stdout, stderr)
}
pub fn exec_with_env(
&self,
argv: &[impl AsRef<str>],
env: &HashMap<String, String>,
stdout: &mut impl Write,
stderr: &mut impl Write,
) -> Result<i32> {
let stream = self.connect_vsock()?;
let mut writer = stream.try_clone()?;
@@ -279,7 +290,7 @@ impl Sandbox {
let req = ExecRequest {
argv: argv.iter().map(|s| s.as_ref().to_string()).collect(),
env: HashMap::new(),
env: env.clone(),
tty: None,
rows: None,
cols: None,
+4
View File
@@ -82,6 +82,10 @@ Save the VM's disk state and stop the VM. To continue working, call `Sandbox.sta
Stop the VM without saving. All changes are discarded.
### Secrets and network policy
Secret injection and domain allowlists are configured via `shuru.json` in the working directory. See the [CLI docs](https://github.com/superhq-ai/shuru#config-file) for details.
## Requirements
- macOS 14+ on Apple Silicon
+7 -1
View File
@@ -54,7 +54,8 @@ if [ ! -f "$INITRAMFS_PATH" ]; then
mkdir -p /initramfs/proc /initramfs/dev /initramfs/newroot
cp /bin/busybox /initramfs/bin/busybox
for cmd in sh mount umount switch_root cp chmod echo; do
mkdir -p /initramfs/etc
for cmd in sh mount umount switch_root cp chmod echo ifconfig route cat; do
ln -sf busybox "/initramfs/bin/${cmd}"
done
@@ -73,6 +74,11 @@ mount -t devtmpfs none /dev
mount -t ext4 /dev/vda /newroot
cp /bin/shuru-init /newroot/usr/bin/shuru-init
chmod 755 /newroot/usr/bin/shuru-init
if ifconfig eth0 up 2>/dev/null; then
ifconfig eth0 10.0.0.2 netmask 255.255.255.0 up
route add default gw 10.0.0.1
echo "nameserver 10.0.0.1" > /newroot/etc/resolv.conf
fi
umount /proc
exec switch_root /newroot /usr/bin/shuru-init
INITEOF
+12 -3
View File
@@ -160,14 +160,23 @@ Place `shuru.json` in the project root to avoid repeating flags:
"cpus": 2,
"memory": 2048,
"disk_size": 4096,
"allow_net": false,
"allow_net": true,
"ports": ["8080:80"],
"mounts": ["./src:/workspace"],
"command": ["/bin/sh", "-c", "cd /workspace && sh"]
"command": ["/bin/sh", "-c", "cd /workspace && sh"],
"secrets": {
"API_KEY": {
"from": "OPENAI_API_KEY",
"hosts": ["api.openai.com"]
}
},
"network": {
"allow": ["api.openai.com", "registry.npmjs.org"]
}
}
```
CLI flags override config values. See [references/config.md](references/config.md) for all fields.
CLI flags override config values. When `secrets` are configured, the guest receives placeholder tokens and the proxy substitutes real values on HTTPS requests to allowed hosts. See [references/config.md](references/config.md) for all fields.
## Important Constraints
+51 -6
View File
@@ -13,6 +13,8 @@ Place `shuru.json` in the project root (or pass `--config <path>`). All fields a
| `ports` | string[] | [] | Port forwards, `"HOST:GUEST"` format |
| `mounts` | string[] | [] | Directory mounts, `"HOST:GUEST"` format |
| `command` | string[] | ["/bin/sh"] | Default command to run |
| `secrets` | object | {} | Secrets to inject via proxy (see below) |
| `network` | object | {} | Network access policy (see below) |
## Resolution Order
@@ -24,6 +26,44 @@ CLI flag > shuru.json > default
For example, `shuru run --cpus 4` with `{"cpus": 2}` in shuru.json uses 4 CPUs.
## Secrets
Secrets let the guest use API keys without exposing the real values. The guest receives a random placeholder token; the proxy substitutes the real value only on HTTPS requests to allowed hosts.
```json
{
"allow_net": true,
"secrets": {
"API_KEY": {
"from": "OPENAI_API_KEY",
"hosts": ["api.openai.com"]
}
}
}
```
- `from`: host environment variable containing the real value
- `hosts`: domains where the proxy will substitute the placeholder with the real value
The guest sees `$API_KEY=shuru_tok_...`. The real secret never enters the VM.
## Network Policy
Restrict which domains the guest can reach:
```json
{
"allow_net": true,
"network": {
"allow": ["api.openai.com", "registry.npmjs.org", "*.github.com"]
}
}
```
- Empty or absent `allow` list means all domains are allowed.
- Supports wildcards: `*.example.com` matches `api.example.com` but not `example.com`.
- DNS queries for blocked domains return REFUSED.
## Example
```json
@@ -34,12 +74,17 @@ For example, `shuru run --cpus 4` with `{"cpus": 2}` in shuru.json uses 4 CPUs.
"allow_net": true,
"ports": ["3000:3000", "8080:80"],
"mounts": [".:/workspace"],
"command": ["/bin/sh", "-c", "cd /workspace && sh"]
"command": ["/bin/sh", "-c", "cd /workspace && sh"],
"secrets": {
"API_KEY": {
"from": "OPENAI_API_KEY",
"hosts": ["api.openai.com"]
}
},
"network": {
"allow": ["api.openai.com", "registry.npmjs.org"]
}
}
```
With this config, `shuru run` is equivalent to:
```bash
shuru run --cpus 4 --memory 4096 --disk-size 8192 --allow-net -p 3000:3000 -p 8080:80 --mount .:/workspace -- /bin/sh -c 'cd /workspace && sh'
```
With this config, `shuru run` boots a VM that can only reach `api.openai.com` and `registry.npmjs.org`, with the OpenAI API key injected securely via the proxy.
+34 -4
View File
@@ -2,6 +2,17 @@
Networking is **off by default**. Pass `--allow-net` to enable it.
## How It Works
All guest network traffic goes through a userspace proxy on the host (no NAT, no direct internet access). The proxy:
- Resolves DNS on the host and relays responses
- Tunnels TCP connections (HTTP and HTTPS) to the real internet
- Optionally performs MITM on HTTPS to inject secrets (only when `secrets` are configured)
- Enforces domain allowlists when `network.allow` is set
ICMP (ping) is not supported — only TCP traffic is proxied.
## Enabling Network Access
```bash
@@ -16,15 +27,34 @@ Or set it in `shuru.json`:
}
```
## Domain Allowlist
Restrict which domains the guest can reach:
```json
{
"allow_net": true,
"network": {
"allow": ["api.openai.com", "registry.npmjs.org", "*.github.com"]
}
}
```
DNS queries for blocked domains return REFUSED. Omit `network.allow` to allow all domains.
## Secret Injection
See [config.md](config.md#secrets) for details on injecting API keys via the proxy.
## Port Forwarding
Forward host ports to guest ports with `-p HOST:GUEST`:
Forward host ports to guest ports with `-p HOST:GUEST`. Port forwarding uses vsock and works **without** `--allow-net`:
```bash
# Forward host 8080 to guest 80
shuru run --allow-net -p 8080:80 -- nginx -g 'daemon off;'
shuru run -p 8080:80 -- python3 -m http.server 80
# Multiple ports
# With networking too
shuru run --allow-net -p 3000:3000 -p 5432:5432 -- sh -c 'start-services.sh'
```
@@ -42,7 +72,7 @@ CLI `-p` flags are merged with config ports (not replaced).
## Without Networking
When `--allow-net` is not set, the VM has no network interface. DNS resolution, HTTP requests, and package installs will fail. This is the intended default for maximum isolation.
When `--allow-net` is not set, the VM has no network device. DNS resolution, HTTP requests, and package installs will fail. This is the intended default for maximum isolation.
To install packages, either:
1. Use `--allow-net` during the run
+30 -16
View File
@@ -1,33 +1,47 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 300 250" fill="none">
<rect width="300" height="250" fill="#0a0a0a"/>
<!-- Gear 1 (large) — rotates clockwise -->
<!-- Gear 1 (large) — cx=130 cy=110 r=35, rotates clockwise -->
<g>
<animateTransform attributeName="transform" type="rotate" from="0 130 110" to="360 130 110" dur="20s" repeatCount="indefinite"/>
<circle cx="130" cy="110" r="35" stroke="#525252" stroke-width="1" fill="none"/>
<circle cx="130" cy="110" r="25" stroke="#1e1e1e" stroke-width="0.8" fill="none"/>
<!-- Gear teeth (large) -->
<line x1="130" y1="72" x2="130" y2="78" stroke="#525252" stroke-width="3"/>
<line x1="130" y1="142" x2="130" y2="148" stroke="#525252" stroke-width="3"/>
<line x1="92" y1="110" x2="98" y2="110" stroke="#525252" stroke-width="3"/>
<line x1="162" y1="110" x2="168" y2="110" stroke="#525252" stroke-width="3"/>
<line x1="105" y1="85" x2="109" y2="89" stroke="#525252" stroke-width="3"/>
<line x1="151" y1="131" x2="155" y2="135" stroke="#525252" stroke-width="3"/>
<line x1="105" y1="135" x2="109" y2="131" stroke="#525252" stroke-width="3"/>
<line x1="151" y1="85" x2="155" y2="89" stroke="#525252" stroke-width="3"/>
<!-- Cardinal teeth (centered on circle edge, 6px long) -->
<line x1="130" y1="72" x2="130" y2="78" stroke="#525252" stroke-width="4"/>
<line x1="130" y1="142" x2="130" y2="148" stroke="#525252" stroke-width="4"/>
<line x1="92" y1="110" x2="98" y2="110" stroke="#525252" stroke-width="4"/>
<line x1="162" y1="110" x2="168" y2="110" stroke="#525252" stroke-width="4"/>
<!-- Diagonal teeth (45°: edge at ±24.75, tooth ±2.12 along radial) -->
<!-- 45° upper-right -->
<line x1="152.63" y1="87.37" x2="156.87" y2="83.13" stroke="#525252" stroke-width="4"/>
<!-- 135° upper-left -->
<line x1="107.37" y1="87.37" x2="103.13" y2="83.13" stroke="#525252" stroke-width="4"/>
<!-- 225° lower-left -->
<line x1="107.37" y1="132.63" x2="103.13" y2="136.87" stroke="#525252" stroke-width="4"/>
<!-- 315° lower-right -->
<line x1="152.63" y1="132.63" x2="156.87" y2="136.87" stroke="#525252" stroke-width="4"/>
</g>
<circle cx="130" cy="110" r="8" stroke="#f59e0b" stroke-width="1" fill="#f59e0b" fill-opacity="0.1"/>
<!-- Gear 2 (small, meshed) — rotates counter-clockwise -->
<!-- Gear 2 (small) — cx=190 cy=80 r=22, rotates counter-clockwise -->
<g>
<animateTransform attributeName="transform" type="rotate" from="0 190 80" to="-360 190 80" dur="12.6s" repeatCount="indefinite"/>
<circle cx="190" cy="80" r="22" stroke="#525252" stroke-width="1" fill="none"/>
<circle cx="190" cy="80" r="14" stroke="#1e1e1e" stroke-width="0.8" fill="none"/>
<!-- Gear teeth (small) -->
<line x1="190" y1="56" x2="190" y2="60" stroke="#525252" stroke-width="2.5"/>
<line x1="190" y1="100" x2="190" y2="104" stroke="#525252" stroke-width="2.5"/>
<line x1="166" y1="80" x2="170" y2="80" stroke="#525252" stroke-width="2.5"/>
<line x1="210" y1="80" x2="214" y2="80" stroke="#525252" stroke-width="2.5"/>
<!-- Cardinal teeth (centered on circle edge, 6px long) -->
<line x1="190" y1="55" x2="190" y2="61" stroke="#525252" stroke-width="3"/>
<line x1="190" y1="99" x2="190" y2="105" stroke="#525252" stroke-width="3"/>
<line x1="165" y1="80" x2="171" y2="80" stroke="#525252" stroke-width="3"/>
<line x1="209" y1="80" x2="215" y2="80" stroke="#525252" stroke-width="3"/>
<!-- Diagonal teeth (45°: edge at ±15.56, tooth ±2.12 along radial) -->
<!-- 45° upper-right -->
<line x1="203.44" y1="66.56" x2="207.68" y2="62.32" stroke="#525252" stroke-width="3"/>
<!-- 135° upper-left -->
<line x1="176.56" y1="66.56" x2="172.32" y2="62.32" stroke="#525252" stroke-width="3"/>
<!-- 225° lower-left -->
<line x1="176.56" y1="93.44" x2="172.32" y2="97.68" stroke="#525252" stroke-width="3"/>
<!-- 315° lower-right -->
<line x1="203.44" y1="93.44" x2="207.68" y2="97.68" stroke="#525252" stroke-width="3"/>
</g>
<circle cx="190" cy="80" r="5" stroke="#f59e0b" stroke-width="1" fill="#f59e0b" fill-opacity="0.1"/>

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

+96 -187
View File
@@ -1,3 +1,41 @@
---
import TabbedSection from "./TabbedSection.astro";
const tabs = [
{
id: "ephemeral-runs",
label: "feature",
title: "Ephemeral runs",
description: "Boot a fresh VM with a single command. Changes vanish on exit.",
},
{
id: "networking",
title: "Opt-in networking",
description: "Sandboxes are offline by default. Enable networking with one flag.",
},
{
id: "resources",
title: "Configurable resources",
description: "Set CPUs, memory, and disk size per run or in a config file.",
},
{
id: "mounts",
title: "Directory mounts",
description: "Share host directories into the VM. Writes stay in a tmpfs overlay.",
},
{
id: "checkpoints",
title: "Checkpoint & restore",
description: "Save environments as snapshots. Branch and reuse across runs.",
},
{
id: "port-forwarding",
title: "Port forwarding",
description: "Expose guest ports to the host. Works without network access.",
},
];
---
<section class="relative border-x border-border">
<span class="section-label">CLI</span>
@@ -8,200 +46,71 @@
<span class="text-muted">Run, checkpoint, restore.</span>
</h2>
<div class="mt-12 grid gap-0 border border-border md:grid-cols-[1fr_1.6fr]">
<!-- Feature tabs (left) -->
<div class="border-b border-border md:border-b-0 md:border-r" id="cli-tabs">
<a
href="#ephemeral-runs"
class="cli-tab block border-b border-border p-5 md:p-6"
data-hash="ephemeral-runs"
>
<div class="mb-1 font-mono text-[10px] uppercase tracking-widest text-dim">feature</div>
<h3 class="text-sm font-semibold">Ephemeral runs</h3>
<p class="mt-1 text-xs leading-relaxed text-muted">
Boot a fresh VM with a single command. Changes vanish on exit.
</p>
</a>
<a
href="#networking"
class="cli-tab block border-b border-border p-5 md:p-6"
data-hash="networking"
>
<h3 class="text-sm font-semibold">Opt-in networking</h3>
<p class="mt-1 text-xs leading-relaxed text-muted">
Sandboxes are offline by default. Enable NAT with one flag.
</p>
</a>
<a
href="#resources"
class="cli-tab block border-b border-border p-5 md:p-6"
data-hash="resources"
>
<h3 class="text-sm font-semibold">Configurable resources</h3>
<p class="mt-1 text-xs leading-relaxed text-muted">
Set CPUs, memory, and disk size per run or in a config file.
</p>
</a>
<a
href="#mounts"
class="cli-tab block border-b border-border p-5 md:p-6"
data-hash="mounts"
>
<h3 class="text-sm font-semibold">Directory mounts</h3>
<p class="mt-1 text-xs leading-relaxed text-muted">
Share host directories into the VM. Writes stay in a tmpfs overlay.
</p>
</a>
<a
href="#checkpoints"
class="cli-tab block border-b border-border p-5 md:p-6"
data-hash="checkpoints"
>
<h3 class="text-sm font-semibold">Checkpoint & restore</h3>
<p class="mt-1 text-xs leading-relaxed text-muted">
Save environments as snapshots. Branch and reuse across runs.
</p>
</a>
<a
href="#port-forwarding"
class="cli-tab block p-5 md:p-6"
data-hash="port-forwarding"
>
<h3 class="text-sm font-semibold">Port forwarding</h3>
<p class="mt-1 text-xs leading-relaxed text-muted">
Expose guest ports to the host. Works without network access.
</p>
</a>
</div>
<!-- Terminal (right) -->
<div class="flex flex-col">
<div class="flex items-center gap-2 border-b border-border px-4 py-2.5">
<span class="h-2.5 w-2.5 border border-[#ff5f57] bg-[#ff5f57]/20"></span>
<span class="h-2.5 w-2.5 border border-[#febc2e] bg-[#febc2e]/20"></span>
<span class="h-2.5 w-2.5 border border-[#28c840] bg-[#28c840]/20"></span>
<span class="ml-2 font-mono text-[10px] text-dim">terminal</span>
<div class="mt-12">
<TabbedSection id="cli" tabs={tabs} terminalTitle="terminal" useHash>
<div class="tabbed-panel p-5 font-mono text-xs leading-[1.8] md:p-6 md:text-sm" data-panel="ephemeral-runs">
<p><span class="text-dim">$</span> <span class="text-text">shuru run -- echo "hello from the sandbox"</span></p>
<p class="text-amber">hello from the sandbox</p>
<br />
<p><span class="text-dim">$</span> <span class="text-text">shuru run -- cat /etc/os-release | head -1</span></p>
<p class="text-amber">NAME="Debian GNU/Linux"</p>
<br />
<p class="text-muted"># VM boots, runs, and tears down — nothing persists.</p>
</div>
<div class="relative flex-1" id="cli-terminal">
<div class="cli-panel p-5 font-mono text-xs leading-[1.8] md:p-6 md:text-sm" data-hash="ephemeral-runs">
<p><span class="text-dim">$</span> <span class="text-text">shuru run -- echo "hello from the sandbox"</span></p>
<p class="text-amber">hello from the sandbox</p>
<br />
<p><span class="text-dim">$</span> <span class="text-text">shuru run -- cat /etc/os-release | head -1</span></p>
<p class="text-amber">NAME="Debian GNU/Linux"</p>
<br />
<p class="text-muted"># VM boots, runs, and tears down — nothing persists.</p>
</div>
<div class="cli-panel p-5 font-mono text-xs leading-[1.8] md:p-6 md:text-sm" data-hash="networking">
<p><span class="text-dim">$</span> <span class="text-text">shuru run -- ping -c1 8.8.8.8</span></p>
<p class="text-red-400">ping: sendto: Operation not permitted</p>
<br />
<p><span class="text-dim">$</span> <span class="text-text">shuru run --allow-net -- apt-get install -y python3</span></p>
<p class="text-muted">Reading package lists...</p>
<p class="text-amber">Setting up python3 ... done</p>
</div>
<div class="tabbed-panel p-5 font-mono text-xs leading-[1.8] md:p-6 md:text-sm" data-panel="networking">
<p><span class="text-dim">$</span> <span class="text-text">shuru run -- curl https://example.com</span></p>
<p class="text-red-400">curl: (6) Could not resolve host: example.com</p>
<br />
<p><span class="text-dim">$</span> <span class="text-text">shuru run --allow-net -- curl -s https://example.com | head -1</span></p>
<p class="text-amber">&lt;!doctype html&gt;</p>
<br />
<p class="text-muted"># Sandboxes are offline by default. All traffic goes through a proxy.</p>
</div>
<div class="cli-panel p-5 font-mono text-xs leading-[1.8] md:p-6 md:text-sm" data-hash="resources">
<p><span class="text-dim">$</span> <span class="text-text">shuru run --cpus 4 --memory 4096 -- free -m | head -2</span></p>
<p class="text-muted"> total used free</p>
<p class="text-amber">Mem: 4096 38 4002</p>
<br />
<p><span class="text-dim">$</span> <span class="text-text">shuru run --disk-size 2048 -- df -h /</span></p>
<p class="text-amber">Filesystem Size Used Avail Use% Mounted on</p>
<p class="text-amber">/dev/vda 2.0G 18M 1.9G 1% /</p>
</div>
<div class="tabbed-panel p-5 font-mono text-xs leading-[1.8] md:p-6 md:text-sm" data-panel="resources">
<p><span class="text-dim">$</span> <span class="text-text">shuru run --cpus 4 --memory 4096 -- free -m | head -2</span></p>
<p class="text-muted"> total used free</p>
<p class="text-amber">Mem: 4096 38 4002</p>
<br />
<p><span class="text-dim">$</span> <span class="text-text">shuru run --disk-size 2048 -- df -h /</span></p>
<p class="text-amber">Filesystem Size Used Avail Use% Mounted on</p>
<p class="text-amber">/dev/vda 2.0G 18M 1.9G 1% /</p>
</div>
<div class="cli-panel p-5 font-mono text-xs leading-[1.8] md:p-6 md:text-sm" data-hash="mounts">
<p><span class="text-dim">$</span> <span class="text-text">shuru run --mount ./src:/workspace -- ls /workspace</span></p>
<p class="text-amber">main.py utils.py tests/</p>
<br />
<p><span class="text-dim">$</span> <span class="text-text">shuru run --mount ./src:/workspace -- sh -c 'echo "new" > /workspace/tmp && cat /workspace/tmp'</span></p>
<p class="text-amber">new</p>
<br />
<p class="text-muted"># Host directory is untouched. Guest writes live in a tmpfs overlay.</p>
</div>
<div class="tabbed-panel p-5 font-mono text-xs leading-[1.8] md:p-6 md:text-sm" data-panel="mounts">
<p><span class="text-dim">$</span> <span class="text-text">shuru run --mount ./src:/workspace -- ls /workspace</span></p>
<p class="text-amber">main.py utils.py tests/</p>
<br />
<p><span class="text-dim">$</span> <span class="text-text">shuru run --mount ./src:/workspace -- sh -c 'echo "new" > /workspace/tmp && cat /workspace/tmp'</span></p>
<p class="text-amber">new</p>
<br />
<p class="text-muted"># Host directory is untouched. Guest writes live in a tmpfs overlay.</p>
</div>
<div class="cli-panel p-5 font-mono text-xs leading-[1.8] md:p-6 md:text-sm" data-hash="checkpoints">
<div class="tabbed-panel p-5 font-mono text-xs leading-[1.8] md:p-6 md:text-sm" data-panel="checkpoints">
<p><span class="text-dim">$</span> <span class="text-text">shuru checkpoint create myenv --allow-net -- sh -c 'apt-get install -y nodejs npm'</span></p>
<p class="text-amber">shuru: checkpoint 'myenv' saved</p>
<br />
<p><span class="text-dim">$</span> <span class="text-text">shuru run --from myenv -- node -e 'console.log("ready")'</span></p>
<p class="text-amber">ready</p>
<br />
<p class="text-muted"># Restore any checkpoint instantly. Branch and reuse.</p>
</div>
<div class="cli-panel p-5 font-mono text-xs leading-[1.8] md:p-6 md:text-sm" data-hash="port-forwarding">
<p><span class="text-dim">$</span> <span class="text-text">shuru checkpoint create py --allow-net -- apt-get install -y python3</span></p>
<p class="text-amber">shuru: checkpoint 'py' saved</p>
<br />
<p><span class="text-dim">$</span> <span class="text-text">shuru run --from py -p 8080:8000 -- python3 -m http.server 8000</span></p>
<p class="text-amber">shuru: forwarding 127.0.0.1:8080 -> guest:8000</p>
<br />
<p><span class="text-dim">$</span> <span class="text-text">curl http://127.0.0.1:8080/</span></p>
<p class="text-amber">&lt;!DOCTYPE HTML&gt;...</p>
<br />
<p class="text-muted"># No --allow-net needed. Tunneled over vsock.</p>
</div>
<p class="text-amber">shuru: checkpoint 'myenv' saved</p>
<br />
<p><span class="text-dim">$</span> <span class="text-text">shuru run --from myenv -- node -e 'console.log("ready")'</span></p>
<p class="text-amber">ready</p>
<br />
<p class="text-muted"># Restore any checkpoint instantly. Branch and reuse.</p>
</div>
</div>
<div class="tabbed-panel p-5 font-mono text-xs leading-[1.8] md:p-6 md:text-sm" data-panel="port-forwarding">
<p><span class="text-dim">$</span> <span class="text-text">shuru checkpoint create py --allow-net -- apt-get install -y python3</span></p>
<p class="text-amber">shuru: checkpoint 'py' saved</p>
<br />
<p><span class="text-dim">$</span> <span class="text-text">shuru run --from py -p 8080:8000 -- python3 -m http.server 8000</span></p>
<p class="text-amber">shuru: forwarding 127.0.0.1:8080 -&gt; guest:8000</p>
<br />
<p><span class="text-dim">$</span> <span class="text-text">curl http://127.0.0.1:8080/</span></p>
<p class="text-amber">&lt;!DOCTYPE HTML&gt;...</p>
<br />
<p class="text-muted"># No --allow-net needed. Tunneled over vsock.</p>
</div>
</TabbedSection>
</div>
</div>
</section>
<style>
.cli-tab {
cursor: pointer;
transition: background-color 0.15s ease, border-color 0.15s ease;
border-left: 2px solid transparent;
text-decoration: none;
color: inherit;
}
.cli-tab:hover {
background: rgba(255, 255, 255, 0.02);
}
.cli-tab.active {
border-left-color: var(--color-amber, #f59e0b);
background: rgba(255, 255, 255, 0.03);
}
.cli-panel {
display: none;
animation: panelFade 0.2s ease;
}
.cli-panel.active {
display: block;
}
@keyframes panelFade {
from { opacity: 0; }
to { opacity: 1; }
}
</style>
<script>
const VALID_HASHES = ['ephemeral-runs', 'networking', 'resources', 'mounts', 'checkpoints', 'port-forwarding'];
const DEFAULT_HASH = 'ephemeral-runs';
function activate(hash: string) {
if (!VALID_HASHES.includes(hash)) hash = DEFAULT_HASH;
document.querySelectorAll('.cli-tab').forEach((t) => t.classList.remove('active'));
document.querySelectorAll('.cli-panel').forEach((p) => p.classList.remove('active'));
document.querySelector(`.cli-tab[data-hash="${hash}"]`)?.classList.add('active');
document.querySelector(`.cli-panel[data-hash="${hash}"]`)?.classList.add('active');
}
// Activate on load from current hash
activate(window.location.hash.replace('#', '') || DEFAULT_HASH);
// Respond to back/forward navigation
window.addEventListener('hashchange', () => {
activate(window.location.hash.replace('#', '') || DEFAULT_HASH);
});
</script>
@@ -0,0 +1,139 @@
---
import CopyButton from "./CopyButton.astro";
import TabbedSection from "./TabbedSection.astro";
const configs: Record<string, string> = {
"secret-injection": JSON.stringify({
secrets: { API_KEY: { from: "OPENAI_API_KEY", hosts: ["api.openai.com"] } },
network: { allow: ["api.openai.com"] },
}, null, 2),
"domain-allowlist": JSON.stringify({
network: { allow: ["registry.npmjs.org", "github.com", "*.githubusercontent.com"] },
}, null, 2),
"multi-provider": JSON.stringify({
secrets: {
OPENAI_KEY: { from: "OPENAI_API_KEY", hosts: ["api.openai.com"] },
ANTHROPIC_KEY: { from: "ANTHROPIC_API_KEY", hosts: ["api.anthropic.com"] },
},
network: { allow: ["api.openai.com", "api.anthropic.com"] },
}, null, 2),
"full-config": JSON.stringify({
cpus: 4, memory: 4096, mounts: ["./src:/workspace"],
secrets: { API_KEY: { from: "OPENAI_API_KEY", hosts: ["api.openai.com"] } },
network: { allow: ["api.openai.com"] },
}, null, 2),
};
const tabs = [
{
id: "secret-injection",
label: "config",
title: "Secret injection",
description: "API key stays on host. Guest gets a placeholder token.",
},
{
id: "domain-allowlist",
title: "Domain allowlist",
description: "Lock down which domains the agent can reach.",
},
{
id: "multi-provider",
title: "Multi-provider",
description: "Multiple API keys for different services.",
},
{
id: "full-config",
title: "Full config",
description: "Resources, mounts, secrets, and network together.",
},
];
---
<section class="relative border-x border-border">
<span class="section-label">Config</span>
<div class="border-b border-dashed border-border px-8 pt-16 pb-16 md:px-16 md:pt-24 md:pb-24">
<h2 class="text-2xl font-semibold tracking-tight md:text-4xl lg:text-5xl">
Configure per project.
<br />
<span class="text-muted">One file, full control.</span>
</h2>
<p class="mt-6 max-w-2xl text-sm leading-relaxed text-muted">
Define allowed domains, secrets, and resources in a single <span class="font-mono text-text">shuru.json</span> file.
</p>
<div class="mt-12">
<TabbedSection id="cfg" tabs={tabs} terminalTitle="shuru.json">
<!-- Secret injection -->
<div class="tabbed-panel relative p-5 font-mono text-xs leading-[1.8] md:p-6 md:text-sm" data-panel="secret-injection">
<CopyButton text={configs["secret-injection"]} />
<p class="text-text">{"{"}</p>
<p class="text-text pl-4">"secrets": {"{"}</p>
<p class="text-text pl-8">"API_KEY": {"{"}</p>
<p class="text-amber bg-amber/5 -mx-2 px-2 pl-12">"from": "OPENAI_API_KEY",</p>
<p class="text-amber bg-amber/5 -mx-2 px-2 pl-12">"hosts": ["api.openai.com"]</p>
<p class="text-text pl-8">{"}"}</p>
<p class="text-text pl-4">{"}"},</p>
<p class="text-text pl-4">"network": {"{"}</p>
<p class="text-amber bg-amber/5 -mx-2 px-2 pl-8">"allow": ["api.openai.com"]</p>
<p class="text-text pl-4">{"}"}</p>
<p class="text-text">{"}"}</p>
</div>
<!-- Domain allowlist -->
<div class="tabbed-panel relative p-5 font-mono text-xs leading-[1.8] md:p-6 md:text-sm" data-panel="domain-allowlist">
<CopyButton text={configs["domain-allowlist"]} />
<p class="text-text">{"{"}</p>
<p class="text-text pl-4">"network": {"{"}</p>
<p class="text-amber bg-amber/5 -mx-2 px-2 pl-8">"allow": [</p>
<p class="text-amber bg-amber/5 -mx-2 px-2 pl-12">"registry.npmjs.org",</p>
<p class="text-amber bg-amber/5 -mx-2 px-2 pl-12">"github.com",</p>
<p class="text-amber bg-amber/5 -mx-2 px-2 pl-12">"*.githubusercontent.com"</p>
<p class="text-amber bg-amber/5 -mx-2 px-2 pl-8">]</p>
<p class="text-text pl-4">{"}"}</p>
<p class="text-text">{"}"}</p>
</div>
<!-- Multi-provider -->
<div class="tabbed-panel relative p-5 font-mono text-xs leading-[1.8] md:p-6 md:text-sm" data-panel="multi-provider">
<CopyButton text={configs["multi-provider"]} />
<p class="text-text">{"{"}</p>
<p class="text-text pl-4">"secrets": {"{"}</p>
<p class="text-text pl-8">"OPENAI_KEY": {"{"}</p>
<p class="text-amber bg-amber/5 -mx-2 px-2 pl-12">"from": "OPENAI_API_KEY",</p>
<p class="text-amber bg-amber/5 -mx-2 px-2 pl-12">"hosts": ["api.openai.com"]</p>
<p class="text-text pl-8">{"}"},</p>
<p class="text-text pl-8">"ANTHROPIC_KEY": {"{"}</p>
<p class="text-amber bg-amber/5 -mx-2 px-2 pl-12">"from": "ANTHROPIC_API_KEY",</p>
<p class="text-amber bg-amber/5 -mx-2 px-2 pl-12">"hosts": ["api.anthropic.com"]</p>
<p class="text-text pl-8">{"}"}</p>
<p class="text-text pl-4">{"}"},</p>
<p class="text-text pl-4">"network": {"{"}</p>
<p class="text-amber bg-amber/5 -mx-2 px-2 pl-8">"allow": ["api.openai.com", "api.anthropic.com"]</p>
<p class="text-text pl-4">{"}"}</p>
<p class="text-text">{"}"}</p>
</div>
<!-- Full config -->
<div class="tabbed-panel relative p-5 font-mono text-xs leading-[1.8] md:p-6 md:text-sm" data-panel="full-config">
<CopyButton text={configs["full-config"]} />
<p class="text-text">{"{"}</p>
<p class="text-amber bg-amber/5 -mx-2 px-2 pl-4">"cpus": 4,</p>
<p class="text-amber bg-amber/5 -mx-2 px-2 pl-4">"memory": 4096,</p>
<p class="text-amber bg-amber/5 -mx-2 px-2 pl-4">"mounts": ["./src:/workspace"],</p>
<p class="text-text pl-4">"secrets": {"{"}</p>
<p class="text-text pl-8">"API_KEY": {"{"}</p>
<p class="text-text pl-12">"from": "OPENAI_API_KEY",</p>
<p class="text-text pl-12">"hosts": ["api.openai.com"]</p>
<p class="text-text pl-8">{"}"}</p>
<p class="text-text pl-4">{"}"},</p>
<p class="text-text pl-4">"network": {"{"}</p>
<p class="text-text pl-8">"allow": ["api.openai.com"]</p>
<p class="text-text pl-4">{"}"}</p>
<p class="text-text">{"}"}</p>
</div>
</TabbedSection>
</div>
</div>
</section>
@@ -0,0 +1,21 @@
---
interface Props {
text: string;
}
const { text } = Astro.props;
---
<button
class="copy-btn absolute top-3 right-3 flex h-7 w-7 items-center justify-center border border-border text-dim transition-colors hover:border-muted hover:text-muted"
data-cmd={text}
aria-label="Copy to clipboard"
>
<svg class="copy-icon h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<rect x="9" y="9" width="13" height="13" rx="0" />
<path d="M5 15H4a1 1 0 01-1-1V4a1 1 0 011-1h10a1 1 0 011 1v1" />
</svg>
<svg class="check-icon hidden h-3.5 w-3.5 text-amber" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path d="M5 13l4 4L19 7" />
</svg>
</button>
@@ -1,3 +1,7 @@
---
import CopyButton from "./CopyButton.astro";
---
<section id="get-started" class="relative border-x border-border">
<span class="section-label">Install</span>
@@ -24,19 +28,7 @@
</div>
<div class="install-panel active relative p-5 font-mono text-xs leading-[1.8] md:p-6 md:text-sm" data-tab="install-homebrew">
<button
class="copy-btn absolute top-4 right-4 flex h-7 w-7 items-center justify-center border border-border text-dim transition-colors hover:border-muted hover:text-muted"
data-cmd="brew tap superhq-ai/tap && brew install shuru"
aria-label="Copy install command"
>
<svg class="copy-icon h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<rect x="9" y="9" width="13" height="13" rx="0" />
<path d="M5 15H4a1 1 0 01-1-1V4a1 1 0 011-1h10a1 1 0 011 1v1" />
</svg>
<svg class="check-icon hidden h-3.5 w-3.5 text-amber" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path d="M5 13l4 4L19 7" />
</svg>
</button>
<CopyButton text="brew tap superhq-ai/tap && brew install shuru" />
<p class="text-dim"># Install</p>
<p>
<span class="text-dim">$</span>
@@ -51,19 +43,7 @@
</div>
<div class="install-panel relative p-5 font-mono text-xs leading-[1.8] md:p-6 md:text-sm" data-tab="install-script">
<button
class="copy-btn absolute top-4 right-4 flex h-7 w-7 items-center justify-center border border-border text-dim transition-colors hover:border-muted hover:text-muted"
data-cmd="curl -fsSL https://shuru.run/install.sh | sh"
aria-label="Copy install command"
>
<svg class="copy-icon h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<rect x="9" y="9" width="13" height="13" rx="0" />
<path d="M5 15H4a1 1 0 01-1-1V4a1 1 0 011-1h10a1 1 0 011 1v1" />
</svg>
<svg class="check-icon hidden h-3.5 w-3.5 text-amber" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path d="M5 13l4 4L19 7" />
</svg>
</button>
<CopyButton text="curl -fsSL https://shuru.run/install.sh | sh" />
<p class="text-dim"># Install</p>
<p>
<span class="text-dim">$</span>
+9 -50
View File
@@ -1,3 +1,7 @@
---
import CopyButton from "./CopyButton.astro";
---
<section class="relative border-x border-border">
<span class="section-label">Skill</span>
@@ -13,43 +17,10 @@
Once installed, AI agents automatically use <span class="font-mono text-text">shuru run</span> whenever they need sandboxed execution — no prompting required.
</p>
<div class="mt-12 grid gap-0 border border-border md:grid-cols-2">
<!-- Install method 1 -->
<div class="border-b border-border p-6 md:border-b-0 md:border-r md:p-8">
<div class="mb-3 font-mono text-[10px] uppercase tracking-widest text-dim">01</div>
<h3 class="text-base font-semibold">Skills CLI</h3>
<p class="mt-2 text-sm leading-relaxed text-muted">Install with one command. Works across Claude Code, Cursor, Copilot, and more.</p>
<div class="mt-5 border border-border">
<div class="flex items-center gap-2 border-b border-border px-4 py-2">
<span class="h-2.5 w-2.5 border border-[#ff5f57] bg-[#ff5f57]/20"></span>
<span class="h-2.5 w-2.5 border border-[#febc2e] bg-[#febc2e]/20"></span>
<span class="h-2.5 w-2.5 border border-[#28c840] bg-[#28c840]/20"></span>
<span class="ml-2 font-mono text-[10px] text-dim">terminal</span>
</div>
<div class="relative p-4 font-mono text-xs leading-[1.8] md:text-sm">
<button
class="copy-btn absolute top-3 right-3 flex h-7 w-7 items-center justify-center border border-border text-dim transition-colors hover:border-muted hover:text-muted"
data-cmd="npx skills add superhq-ai/shuru"
aria-label="Copy command"
>
<svg class="copy-icon h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<rect x="9" y="9" width="13" height="13" rx="0" />
<path d="M5 15H4a1 1 0 01-1-1V4a1 1 0 011-1h10a1 1 0 011 1v1" />
</svg>
<svg class="check-icon hidden h-3.5 w-3.5 text-amber" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path d="M5 13l4 4L19 7" />
</svg>
</button>
<p><span class="text-dim">$</span> <span class="text-text">npx skills add superhq-ai/shuru</span></p>
</div>
</div>
</div>
<!-- Install method 2 -->
<div class="mt-12 border border-border">
<div class="p-6 md:p-8">
<div class="mb-3 font-mono text-[10px] uppercase tracking-widest text-dim">02</div>
<h3 class="text-base font-semibold">Manual</h3>
<p class="mt-2 text-sm leading-relaxed text-muted">Copy the skill into your project or home directory for Claude Code.</p>
<h3 class="text-base font-semibold">Install with one command</h3>
<p class="mt-2 text-sm leading-relaxed text-muted">Works across Claude Code, Cursor, Copilot, and more.</p>
<div class="mt-5 border border-border">
<div class="flex items-center gap-2 border-b border-border px-4 py-2">
<span class="h-2.5 w-2.5 border border-[#ff5f57] bg-[#ff5f57]/20"></span>
@@ -58,20 +29,8 @@
<span class="ml-2 font-mono text-[10px] text-dim">terminal</span>
</div>
<div class="relative p-4 font-mono text-xs leading-[1.8] md:text-sm">
<button
class="copy-btn absolute top-3 right-3 flex h-7 w-7 items-center justify-center border border-border text-dim transition-colors hover:border-muted hover:text-muted"
data-cmd="cp -r skills/shuru .claude/skills/shuru"
aria-label="Copy command"
>
<svg class="copy-icon h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<rect x="9" y="9" width="13" height="13" rx="0" />
<path d="M5 15H4a1 1 0 01-1-1V4a1 1 0 011-1h10a1 1 0 011 1v1" />
</svg>
<svg class="check-icon hidden h-3.5 w-3.5 text-amber" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
<path d="M5 13l4 4L19 7" />
</svg>
</button>
<p><span class="text-dim">$</span> <span class="text-text">cp -r skills/shuru .claude/skills/shuru</span></p>
<CopyButton text="npx skills add superhq-ai/shuru" />
<p><span class="text-dim">$</span> <span class="text-text">npx skills add superhq-ai/shuru</span></p>
</div>
</div>
</div>
@@ -0,0 +1,154 @@
---
interface Tab {
id: string;
label?: string;
title: string;
description: string;
}
interface Props {
id: string;
tabs: Tab[];
terminalTitle: string;
defaultTab?: string;
useHash?: boolean;
}
const { id, tabs, terminalTitle, defaultTab, useHash = false } = Astro.props;
const activeId = defaultTab || tabs[0]?.id;
---
<div class="grid gap-0 border border-border md:grid-cols-[1fr_1.6fr]" data-tabbed-section={id} data-default={activeId} data-use-hash={useHash ? "true" : "false"}>
<!-- Tabs (left) -->
<div class="border-b border-border md:border-b-0 md:border-r">
{tabs.map((tab, i) => (
<a
href={`#${tab.id}`}
class:list={[
"tabbed-tab block p-5 md:p-6",
i < tabs.length - 1 && "border-b border-border",
]}
data-tab={tab.id}
>
{tab.label && <div class="mb-1 font-mono text-[10px] uppercase tracking-widest text-dim">{tab.label}</div>}
<h3 class="text-sm font-semibold">{tab.title}</h3>
<p class="mt-1 text-xs leading-relaxed text-muted">{tab.description}</p>
</a>
))}
</div>
<!-- Content (right) -->
<div class="flex flex-col">
<div class="flex items-center gap-2 border-b border-border px-4 py-2.5">
<span class="h-2.5 w-2.5 border border-[#ff5f57] bg-[#ff5f57]/20"></span>
<span class="h-2.5 w-2.5 border border-[#febc2e] bg-[#febc2e]/20"></span>
<span class="h-2.5 w-2.5 border border-[#28c840] bg-[#28c840]/20"></span>
<span class="ml-2 font-mono text-[10px] text-dim">{terminalTitle}</span>
</div>
<div class="relative flex-1">
<slot />
</div>
</div>
</div>
<style>
.tabbed-tab {
cursor: pointer;
transition: background-color 0.15s ease, border-color 0.15s ease;
border-left: 2px solid transparent;
text-decoration: none;
color: inherit;
}
.tabbed-tab:hover {
background: rgba(255, 255, 255, 0.02);
}
.tabbed-tab.active {
border-left-color: var(--color-amber, #f59e0b);
background: rgba(255, 255, 255, 0.03);
}
:global(.tabbed-panel) {
display: none;
animation: tabbedFade 0.2s ease;
}
:global(.tabbed-panel.active) {
display: block;
}
@keyframes tabbedFade {
from { opacity: 0; }
to { opacity: 1; }
}
</style>
<script>
function initTabbedSections() {
document.querySelectorAll<HTMLElement>('[data-tabbed-section]').forEach((section) => {
const id = section.dataset.tabbedSection!;
const defaultTab = section.dataset.default!;
const useHash = section.dataset.useHash === 'true';
const tabs = section.querySelectorAll<HTMLElement>('.tabbed-tab');
const panels = section.querySelectorAll<HTMLElement>('.tabbed-panel');
// Set min-height to tallest panel to prevent layout shift
const container = section.querySelector<HTMLElement>('.relative.flex-1');
if (container) {
let maxH = 0;
panels.forEach((p) => {
p.style.display = 'block';
p.style.visibility = 'hidden';
p.style.position = 'absolute';
maxH = Math.max(maxH, p.offsetHeight);
p.style.display = '';
p.style.visibility = '';
p.style.position = '';
});
if (maxH > 0) container.style.minHeight = `${maxH}px`;
}
function activate(tabId: string) {
tabs.forEach((t) => t.classList.remove('active'));
panels.forEach((p) => p.classList.remove('active'));
section.querySelector(`.tabbed-tab[data-tab="${tabId}"]`)?.classList.add('active');
section.querySelector(`.tabbed-panel[data-panel="${tabId}"]`)?.classList.add('active');
}
// Initial activation
if (useHash) {
const hash = window.location.hash.replace('#', '');
const validIds = Array.from(tabs).map((t) => t.dataset.tab);
activate(validIds.includes(hash) ? hash : defaultTab);
} else {
activate(defaultTab);
}
// Tab clicks
tabs.forEach((tab) => {
tab.addEventListener('click', (e) => {
e.preventDefault();
const tabId = tab.dataset.tab;
if (tabId) {
activate(tabId);
if (useHash) history.replaceState(null, '', `#${tabId}`);
}
});
});
// Hash navigation (only for hash-enabled sections)
if (useHash) {
window.addEventListener('hashchange', () => {
const hash = window.location.hash.replace('#', '');
const validIds = Array.from(tabs).map((t) => t.dataset.tab);
if (validIds.includes(hash)) activate(hash);
});
}
});
}
initTabbedSections();
</script>
+2
View File
@@ -1,5 +1,6 @@
---
import CliSection from "../components/landing/CliSection.astro";
import ConfigSection from "../components/landing/ConfigSection.astro";
import Footer from "../components/landing/Footer.astro";
import FeaturesSection from "../components/landing/FeaturesSection.astro";
import GetStartedSection from "../components/landing/GetStartedSection.astro";
@@ -19,6 +20,7 @@ import Layout from "../layouts/Layout.astro";
<FeaturesSection />
<CliSection />
<UseCasesSection />
<ConfigSection />
<SkillSection />
<GetStartedSection />
</main>