mirror of
https://github.com/hanzoai/vm.git
synced 2026-10-01 00:47:56 +00:00
feat(v0.3.0): custom minimal kernel, proxy-based networking, and landing page overhaul
This commit is contained in:
+11
-4
@@ -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
@@ -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"
|
||||
|
||||
@@ -4,6 +4,7 @@ members = [
|
||||
"crates/shuru-cli",
|
||||
"crates/shuru-darwin",
|
||||
"crates/shuru-proto",
|
||||
"crates/shuru-proxy",
|
||||
"crates/shuru-vm",
|
||||
"crates/shuru-guest",
|
||||
]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
] }
|
||||
|
||||
@@ -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::{
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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"");
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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(()))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
@@ -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"><!doctype html></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"><!DOCTYPE HTML>...</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 -> 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"><!DOCTYPE HTML>...</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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user