diff --git a/Cargo.lock b/Cargo.lock index fb23e04..a53d52b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,36 +2,6 @@ # It is not intended for manual editing. version = 4 -[[package]] -name = "adler2" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" - -[[package]] -name = "aho-corasick" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" -dependencies = [ - "memchr", -] - -[[package]] -name = "alloc-no-stdlib" -version = "2.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" - -[[package]] -name = "alloc-stdlib" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" -dependencies = [ - "alloc-no-stdlib", -] - [[package]] name = "anstream" version = "1.0.0" @@ -127,29 +97,6 @@ dependencies = [ "syn", ] -[[package]] -name = "async-compression" -version = "0.4.41" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0f9ee0f6e02ffd7ad5816e9464499fba7b3effd01123b515c41d1697c43dad1" -dependencies = [ - "compression-codecs", - "compression-core", - "pin-project-lite", - "tokio", -] - -[[package]] -name = "async-lock" -version = "3.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" -dependencies = [ - "event-listener", - "event-listener-strategy", - "pin-project-lite", -] - [[package]] name = "atomic-waker" version = "1.1.2" @@ -183,15 +130,6 @@ version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" -[[package]] -name = "block-buffer" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" -dependencies = [ - "generic-array", -] - [[package]] name = "bollard" version = "0.21.0" @@ -235,50 +173,12 @@ dependencies = [ "serde_repr", ] -[[package]] -name = "brotli" -version = "8.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" -dependencies = [ - "alloc-no-stdlib", - "alloc-stdlib", - "brotli-decompressor", -] - -[[package]] -name = "brotli-decompressor" -version = "5.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" -dependencies = [ - "alloc-no-stdlib", - "alloc-stdlib", -] - -[[package]] -name = "bstr" -version = "1.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" -dependencies = [ - "memchr", - "regex-automata", - "serde", -] - [[package]] name = "bumpalo" version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" -[[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" @@ -292,8 +192,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7a4d3ec6524d28a329fc53654bbadc9bdd7b0431f5d65f1a56ffb28a1ee5283" dependencies = [ "find-msvc-tools", - "jobserver", - "libc", "shlex", ] @@ -356,85 +254,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] -name = "compression-codecs" -version = "0.4.37" +name = "core-foundation" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb7b51a7d9c967fc26773061ba86150f19c50c0d65c887cb1fbe295fd16619b7" -dependencies = [ - "brotli", - "compression-core", - "flate2", - "memchr", - "zstd", - "zstd-safe", -] - -[[package]] -name = "compression-core" -version = "0.4.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75984efb6ed102a0d42db99afb6c1948f0380d1d91808d5529916e6c08b49d8d" - -[[package]] -name = "concurrent-queue" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" -dependencies = [ - "crossbeam-utils", -] - -[[package]] -name = "cpufeatures" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" dependencies = [ + "core-foundation-sys", "libc", ] [[package]] -name = "crc32fast" -version = "1.5.0" +name = "core-foundation-sys" +version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "crossbeam-channel" -version = "0.5.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" -dependencies = [ - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-epoch" -version = "0.9.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" -dependencies = [ - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-utils" -version = "0.8.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" - -[[package]] -name = "crypto-common" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" -dependencies = [ - "generic-array", - "typenum", -] +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" [[package]] name = "data-encoding" @@ -465,16 +298,6 @@ dependencies = [ "powerfmt", ] -[[package]] -name = "digest" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" -dependencies = [ - "block-buffer", - "crypto-common", -] - [[package]] name = "displaydoc" version = "0.2.5" @@ -502,27 +325,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "event-listener" -version = "5.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" -dependencies = [ - "concurrent-queue", - "parking", - "pin-project-lite", -] - -[[package]] -name = "event-listener-strategy" -version = "0.5.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" -dependencies = [ - "event-listener", - "pin-project-lite", -] - [[package]] name = "fastrand" version = "2.4.0" @@ -545,16 +347,6 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" -[[package]] -name = "flate2" -version = "1.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" -dependencies = [ - "crc32fast", - "miniz_oxide", -] - [[package]] name = "foldhash" version = "0.1.5" @@ -570,21 +362,6 @@ dependencies = [ "percent-encoding", ] -[[package]] -name = "futures" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" -dependencies = [ - "futures-channel", - "futures-core", - "futures-executor", - "futures-io", - "futures-sink", - "futures-task", - "futures-util", -] - [[package]] name = "futures-channel" version = "0.3.32" @@ -592,7 +369,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", - "futures-sink", ] [[package]] @@ -601,23 +377,6 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" -[[package]] -name = "futures-executor" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" -dependencies = [ - "futures-core", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-io" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" - [[package]] name = "futures-macro" version = "0.3.32" @@ -647,42 +406,13 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ - "futures-channel", "futures-core", - "futures-io", "futures-macro", - "futures-sink", "futures-task", - "memchr", "pin-project-lite", "slab", ] -[[package]] -name = "generator" -version = "0.8.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52f04ae4152da20c76fe800fa48659201d5cf627c5149ca0b707b69d7eef6cf9" -dependencies = [ - "cc", - "cfg-if", - "libc", - "log", - "rustversion", - "windows-link", - "windows-result", -] - -[[package]] -name = "generic-array" -version = "0.14.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" -dependencies = [ - "typenum", - "version_check", -] - [[package]] name = "getrandom" version = "0.2.17" @@ -694,18 +424,6 @@ 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 5.3.0", - "wasip2", -] - [[package]] name = "getrandom" version = "0.4.2" @@ -714,7 +432,7 @@ checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", "libc", - "r-efi 6.0.0", + "r-efi", "wasip2", "wasip3", ] @@ -802,34 +520,6 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" -[[package]] -name = "hudsucker" -version = "0.22.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8bb9d62508d54891fe529dc3a3e169aa7938b89898ba5ab0431ac5bafe66a249" -dependencies = [ - "async-compression", - "bstr", - "futures", - "http", - "http-body-util", - "hyper", - "hyper-rustls", - "hyper-tungstenite", - "hyper-util", - "moka", - "rand", - "rcgen", - "thiserror 1.0.69", - "time", - "tokio", - "tokio-graceful", - "tokio-rustls", - "tokio-tungstenite", - "tokio-util", - "tracing", -] - [[package]] name = "hyper" version = "1.9.0" @@ -878,26 +568,11 @@ dependencies = [ "hyper-util", "log", "rustls", + "rustls-native-certs", "rustls-pki-types", "tokio", "tokio-rustls", "tower-service", - "webpki-roots 0.26.11", -] - -[[package]] -name = "hyper-tungstenite" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a343d17fe7885302ed7252767dc7bb83609a874b6ff581142241ec4b73957ad" -dependencies = [ - "http-body-util", - "hyper", - "hyper-util", - "pin-project-lite", - "tokio", - "tokio-tungstenite", - "tungstenite", ] [[package]] @@ -1109,16 +784,6 @@ dependencies = [ "jiff-tzdb", ] -[[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 = "js-sys" version = "0.3.94" @@ -1159,46 +824,12 @@ version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" -[[package]] -name = "lock_api" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" -dependencies = [ - "scopeguard", -] - [[package]] name = "log" version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" -[[package]] -name = "loom" -version = "0.7.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca" -dependencies = [ - "cfg-if", - "generator", - "pin-utils", - "scoped-tls", - "serde", - "serde_json", - "tracing", - "tracing-subscriber", -] - -[[package]] -name = "matchers" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" -dependencies = [ - "regex-automata", -] - [[package]] name = "memchr" version = "2.8.0" @@ -1211,16 +842,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" -[[package]] -name = "miniz_oxide" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" -dependencies = [ - "adler2", - "simd-adler32", -] - [[package]] name = "mio" version = "1.2.0" @@ -1232,26 +853,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "moka" -version = "0.12.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "957228ad12042ee839f93c8f257b62b4c0ab5eaae1d4fa60de53b27c9d7c5046" -dependencies = [ - "async-lock", - "crossbeam-channel", - "crossbeam-epoch", - "crossbeam-utils", - "equivalent", - "event-listener", - "futures-util", - "parking_lot", - "portable-atomic", - "smallvec", - "tagptr", - "uuid", -] - [[package]] name = "nix" version = "0.29.0" @@ -1274,15 +875,6 @@ dependencies = [ "minimal-lexical", ] -[[package]] -name = "nu-ansi-term" -version = "0.50.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" -dependencies = [ - "windows-sys 0.61.2", -] - [[package]] name = "num-bigint" version = "0.4.6" @@ -1339,33 +931,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[package]] -name = "parking" -version = "2.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" - -[[package]] -name = "parking_lot" -version = "0.12.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" -dependencies = [ - "lock_api", - "parking_lot_core", -] - -[[package]] -name = "parking_lot_core" -version = "0.9.12" +name = "openssl-probe" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" -dependencies = [ - "cfg-if", - "libc", - "redox_syscall", - "smallvec", - "windows-link", -] +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" [[package]] name = "pathdiff" @@ -1395,18 +964,6 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" -[[package]] -name = "pin-utils" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" - -[[package]] -name = "pkg-config" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" - [[package]] name = "plain" version = "0.2.3" @@ -1480,12 +1037,6 @@ 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 = "r-efi" version = "6.0.0" @@ -1536,32 +1087,6 @@ dependencies = [ "yasna", ] -[[package]] -name = "redox_syscall" -version = "0.5.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" -dependencies = [ - "bitflags", -] - -[[package]] -name = "regex-automata" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" -dependencies = [ - "aho-corasick", - "memchr", - "regex-syntax", -] - -[[package]] -name = "regex-syntax" -version = "0.8.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" - [[package]] name = "ring" version = "0.17.14" @@ -1612,6 +1137,28 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-native-certs" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5bfb394eeed242e909609f56089eecfe5fda225042e8b171791b9c95f5931e5" +dependencies = [ + "openssl-probe", + "rustls-pemfile", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "rustls-pki-types" version = "1.14.0" @@ -1677,13 +1224,19 @@ dependencies = [ "clap", "futures-util", "goblin", - "hudsucker", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", "jiff", "libc", "nix", "pathdiff", "rand", "rand_chacha", + "rcgen", + "rustls", + "rustls-pemfile", "serde", "serde_json", "syscalls", @@ -1691,6 +1244,7 @@ dependencies = [ "tempfile", "thiserror 2.0.18", "tokio", + "tokio-rustls", "toml", "uuid", "walkdir", @@ -1707,16 +1261,13 @@ dependencies = [ ] [[package]] -name = "scoped-tls" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" - -[[package]] -name = "scopeguard" -version = "1.2.0" +name = "schannel" +version = "0.1.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] [[package]] name = "scroll" @@ -1738,6 +1289,29 @@ dependencies = [ "syn", ] +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "semver" version = "1.0.28" @@ -1819,48 +1393,12 @@ dependencies = [ "serde", ] -[[package]] -name = "sha1" -version = "0.10.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" -dependencies = [ - "cfg-if", - "cpufeatures", - "digest", -] - -[[package]] -name = "sharded-slab" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" -dependencies = [ - "lazy_static", -] - [[package]] name = "shlex" version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" -[[package]] -name = "signal-hook-registry" -version = "1.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" -dependencies = [ - "errno", - "libc", -] - -[[package]] -name = "simd-adler32" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" - [[package]] name = "slab" version = "0.4.12" @@ -1929,12 +1467,6 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "81c645a4de0d803ced6ef0388a2646aa1ef8467173b5d59a2c33c88de4ab76e7" -[[package]] -name = "tagptr" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" - [[package]] name = "tar" version = "0.4.46" @@ -1999,15 +1531,6 @@ dependencies = [ "syn", ] -[[package]] -name = "thread_local" -version = "1.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" -dependencies = [ - "cfg-if", -] - [[package]] name = "time" version = "0.3.47" @@ -2059,25 +1582,11 @@ dependencies = [ "libc", "mio", "pin-project-lite", - "signal-hook-registry", "socket2", "tokio-macros", "windows-sys 0.61.2", ] -[[package]] -name = "tokio-graceful" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "627ba4daa4cbce14740603401c895e72d47ecd86690a18e3f0841266e9340de7" -dependencies = [ - "loom", - "pin-project-lite", - "slab", - "tokio", - "tracing", -] - [[package]] name = "tokio-macros" version = "2.7.0" @@ -2100,22 +1609,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "tokio-tungstenite" -version = "0.21.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c83b561d025642014097b66e6c1bb422783339e0909e4429cde4749d1990bc38" -dependencies = [ - "futures-util", - "log", - "rustls", - "rustls-pki-types", - "tokio", - "tokio-rustls", - "tungstenite", - "webpki-roots 0.26.11", -] - [[package]] name = "tokio-util" version = "0.7.18" @@ -2182,23 +1675,10 @@ version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ - "log", "pin-project-lite", - "tracing-attributes", "tracing-core", ] -[[package]] -name = "tracing-attributes" -version = "0.1.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "tracing-core" version = "0.1.36" @@ -2206,36 +1686,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", - "valuable", -] - -[[package]] -name = "tracing-log" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" -dependencies = [ - "log", - "once_cell", - "tracing-core", -] - -[[package]] -name = "tracing-subscriber" -version = "0.3.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" -dependencies = [ - "matchers", - "nu-ansi-term", - "once_cell", - "regex-automata", - "sharded-slab", - "smallvec", - "thread_local", - "tracing", - "tracing-core", - "tracing-log", ] [[package]] @@ -2244,33 +1694,6 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" -[[package]] -name = "tungstenite" -version = "0.21.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ef1a641ea34f399a848dea702823bbecfb4c486f911735368f1f137cb8257e1" -dependencies = [ - "byteorder", - "bytes", - "data-encoding", - "http", - "httparse", - "log", - "rand", - "rustls", - "rustls-pki-types", - "sha1", - "thiserror 1.0.69", - "url", - "utf-8", -] - -[[package]] -name = "typenum" -version = "1.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" - [[package]] name = "unicode-ident" version = "1.0.24" @@ -2301,12 +1724,6 @@ dependencies = [ "serde", ] -[[package]] -name = "utf-8" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" - [[package]] name = "utf8_iter" version = "1.0.4" @@ -2330,18 +1747,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "valuable" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" - -[[package]] -name = "version_check" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" - [[package]] name = "walkdir" version = "2.5.0" @@ -2464,24 +1869,6 @@ dependencies = [ "semver", ] -[[package]] -name = "webpki-roots" -version = "0.26.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" -dependencies = [ - "webpki-roots 1.0.6", -] - -[[package]] -name = "webpki-roots" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" -dependencies = [ - "rustls-pki-types", -] - [[package]] name = "winapi" version = "0.3.9" @@ -2519,15 +1906,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" -[[package]] -name = "windows-result" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" -dependencies = [ - "windows-link", -] - [[package]] name = "windows-sys" version = "0.52.0" @@ -2858,31 +2236,3 @@ name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" - -[[package]] -name = "zstd" -version = "0.13.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" -dependencies = [ - "zstd-safe", -] - -[[package]] -name = "zstd-safe" -version = "7.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" -dependencies = [ - "zstd-sys", -] - -[[package]] -name = "zstd-sys" -version = "2.0.16+zstd.1.5.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" -dependencies = [ - "cc", - "pkg-config", -] diff --git a/README.md b/README.md index 829f11a..329137c 100644 --- a/README.md +++ b/README.md @@ -131,9 +131,23 @@ sandlock run \ --http-deny "* */admin/*" \ -r /usr -r /lib -r /etc -- python3 agent.py -# HTTPS MITM with user-provided CA (enables ACL on port 443) -# Generate a CA, add the cert to the sandbox's trust store -# (e.g. /etc/ssl/certs/), then pass both files here. +# HTTPS MITM, zero-config: sandlock generates an ephemeral CA and splices it +# into the trust bundle(s) you name. No openssl, no manual install. +sandlock run \ + --http-allow "POST api.openai.com/v1/*" \ + --http-inject-ca /etc/ssl/certs/ca-certificates.crt \ + -r /usr -r /lib -r /etc -- python3 agent.py + +# Node and other runtimes with a compiled-in CA list: export the cert and +# wire the runtime's own env var. +sandlock run \ + --http-allow "POST api.example.com/*" \ + --http-inject-ca /etc/ssl/certs/ca-certificates.crt \ + --http-ca-out /tmp/sandlock-ca.pem \ + --env NODE_EXTRA_CA_CERTS=/tmp/sandlock-ca.pem \ + -r /usr -r /lib -r /etc -- node agent.js + +# HTTPS MITM with your own CA (still supported) sandlock run \ --http-allow "POST api.openai.com/v1/*" \ --http-ca ca.pem --http-key ca-key.pem \ @@ -627,12 +641,16 @@ matching ports through a transparent proxy. Each rule with a concrete host auto-extends `--net-allow` with `host:80` (and `host:443` when `--http-ca` is set) so the proxy's intercept ports are reachable; wildcard hosts auto-add `:80` / `:443` (any IP). All auto-added -entries are TCP. HTTPS MITM is opt-in: pass `--http-ca ` and -`--http-key ` for a CA *you generate* and trust inside the -sandbox (typically install the cert into the workload's -`/etc/ssl/certs/`). Without `--http-ca`, port 443 is not intercepted -— `--net-allow host:443` permits raw TLS to the host with no content -inspection. +entries are TCP. HTTPS MITM is enabled two ways: pass `--http-ca ` +and `--http-key ` to bring your own CA, or pass `--http-inject-ca +` to have sandlock generate an ephemeral CA (private key in +memory only) and splice its public cert into each named trust bundle at +open time, so the workload trusts the proxy with no manual install. For +runtimes with a compiled-in CA store such as Node, `--http-ca-out +` writes the public cert so you can point the runtime's own env +var at it (e.g. `NODE_EXTRA_CA_CERTS`). Without any of these, port 443 +is not intercepted: `--net-allow host:443` permits raw TLS to the host +with no content inspection. **Bind.** `--net-bind ` is independent from `--net-allow` and governs server-side `bind()`. Landlock enforces it (TCP only); diff --git a/crates/sandlock-cli/src/main.rs b/crates/sandlock-cli/src/main.rs index 74acce8..b61a194 100644 --- a/crates/sandlock-cli/src/main.rs +++ b/crates/sandlock-cli/src/main.rs @@ -343,6 +343,8 @@ async fn run_command(args: RunArgs) -> Result { // HTTP MITM material if let Some(ref ca) = base.http_ca { b = b.http_ca(ca); } if let Some(ref key) = base.http_key { b = b.http_key(key); } + for p in &base.http_inject_ca { b = b.http_inject_ca(p); } + if let Some(ref out) = base.http_ca_out { b = b.http_ca_out(out); } // Filesystem extras if let Some(ref path) = base.chroot { b = b.chroot(path); } if let Some(ref path) = base.fs_storage { b = b.fs_storage(path); } @@ -391,6 +393,8 @@ async fn run_command(args: RunArgs) -> Result { for port in &pb.http_ports { builder = builder.http_port(*port); } if let Some(ref ca) = pb.http_ca { builder = builder.http_ca(ca); } if let Some(ref key) = pb.http_key { builder = builder.http_key(key); } + for p in &pb.http_inject_ca { builder = builder.http_inject_ca(p); } + if let Some(ref out) = pb.http_ca_out { builder = builder.http_ca_out(out); } if pb.port_remap { builder = builder.port_remap(true); } if pb.no_randomize_memory { builder = builder.no_randomize_memory(true); } if pb.no_huge_pages { builder = builder.no_huge_pages(true); } diff --git a/crates/sandlock-core/Cargo.toml b/crates/sandlock-core/Cargo.toml index 65e5824..67c232b 100644 --- a/crates/sandlock-core/Cargo.toml +++ b/crates/sandlock-core/Cargo.toml @@ -25,7 +25,13 @@ walkdir = "2" toml = "0.8" jiff = "0.2" pathdiff = "0.2" -hudsucker = "0.22" +tokio-rustls = "0.25" +rustls = "0.22" +rcgen = { version = "0.13", features = ["pem", "x509-parser"] } +hyper = { version = "1", features = ["server", "client", "http1"] } +hyper-util = { version = "0.1", features = ["client-legacy", "http1", "tokio"] } +http-body-util = "0.1" +hyper-rustls = "0.26" tar = "0.4" clap = { version = "4", features = ["derive"], optional = true } bollard = "0.21" @@ -38,3 +44,4 @@ cli = ["dep:clap"] [dev-dependencies] tokio = { version = "1", features = ["rt-multi-thread", "macros", "test-util"] } tempfile = "3" +rustls-pemfile = "2" diff --git a/crates/sandlock-core/src/ca_inject.rs b/crates/sandlock-core/src/ca_inject.rs new file mode 100644 index 0000000..8332828 --- /dev/null +++ b/crates/sandlock-core/src/ca_inject.rs @@ -0,0 +1,82 @@ +// Trust-store injection: splice the active MITM CA into user-declared trust +// bundles at openat time. The child's open is intercepted before the kernel +// performs it; we read the child's real file via /proc//root, append the +// CA PEM, and inject the combined bytes as a sealed memfd. Landlock is never +// consulted for the intercepted open (the syscall result is our memfd). + +use std::os::unix::io::RawFd; +use std::path::{Path, PathBuf}; + +use crate::seccomp::notif::NotifAction; +use crate::sys::structs::SeccompNotif; + +/// Append `ca_pem` to `original` bundle contents, ensuring a newline between +/// them so the concatenation is a valid multi-cert PEM file. +pub(crate) fn combine_bundle(original: &[u8], ca_pem: &[u8]) -> Vec { + let mut out = Vec::with_capacity(original.len() + ca_pem.len() + 1); + out.extend_from_slice(original); + if !original.is_empty() && !original.ends_with(b"\n") { + out.push(b'\n'); + } + out.extend_from_slice(ca_pem); + out +} + +/// True if `resolved` exactly matches one of the user-declared inject paths. +pub(crate) fn path_matches(resolved: &Path, inject_paths: &[PathBuf]) -> bool { + inject_paths.iter().any(|p| p == resolved) +} + +/// Intercept an open-family syscall targeting a declared trust bundle and +/// return a memfd containing the original bundle plus the active CA. +/// +/// Returns `None` (fall through to the kernel) when: the syscall is not an +/// open variant, the path is not a declared bundle, or the child's file +/// cannot be read host-side. Falling through is safe: it just lets the +/// normal open proceed, subject to the rest of the policy. +pub(crate) fn handle_ca_inject_open( + notif: &SeccompNotif, + inject_paths: &[PathBuf], + ca_pem: &[u8], + notif_fd: RawFd, +) -> Option { + let resolved = crate::procfs::resolve_open_target(notif, notif_fd)?; + if !path_matches(&resolved, inject_paths) { + return None; + } + // Read the file as the child sees it (chroot/COW aware) via /proc//root. + let child_view = format!("/proc/{}/root{}", notif.pid, resolved.to_str()?); + let original = std::fs::read(&child_view).ok()?; + let combined = combine_bundle(&original, ca_pem); + Some(NotifAction::inject_bytes(&combined)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn combine_inserts_newline_when_missing() { + let out = combine_bundle(b"AAA", b"BBB\n"); + assert_eq!(out, b"AAA\nBBB\n"); + } + + #[test] + fn combine_no_extra_newline_when_present() { + let out = combine_bundle(b"AAA\n", b"BBB\n"); + assert_eq!(out, b"AAA\nBBB\n"); + } + + #[test] + fn combine_empty_original() { + let out = combine_bundle(b"", b"BBB\n"); + assert_eq!(out, b"BBB\n"); + } + + #[test] + fn path_matches_exact_only() { + let paths = vec![PathBuf::from("/etc/ssl/certs/ca-certificates.crt")]; + assert!(path_matches(Path::new("/etc/ssl/certs/ca-certificates.crt"), &paths)); + assert!(!path_matches(Path::new("/etc/ssl/certs/other.crt"), &paths)); + } +} diff --git a/crates/sandlock-core/src/http_acl.rs b/crates/sandlock-core/src/http_acl.rs index d4d9054..72f41c4 100644 --- a/crates/sandlock-core/src/http_acl.rs +++ b/crates/sandlock-core/src/http_acl.rs @@ -1,302 +1,80 @@ -use std::collections::HashMap; -use std::net::{IpAddr, SocketAddr}; use std::path::Path; -use std::sync::Arc; -use hudsucker::certificate_authority::RcgenAuthority; -use hudsucker::hyper::{Request, Response, StatusCode}; -use hudsucker::rcgen::{CertificateParams, KeyPair}; -use hudsucker::{Body, HttpContext, HttpHandler, Proxy, RequestOrResponse}; -use tokio::net::TcpListener; -use tokio::sync::oneshot; - -use crate::http::{http_acl_check, HttpRule}; - -/// Shared map from proxy client address to the original destination IP -/// that the sandboxed process tried to connect to. Written by the seccomp -/// supervisor on redirect, read by the proxy handler to verify the Host header. -pub type OrigDestMap = Arc>>; - -/// TTL-based DNS cache entry. -struct DnsCacheEntry { - ips: Vec, - expires: std::time::Instant, -} - -/// ACL-enforcing HTTP handler for hudsucker. -#[derive(Clone)] -struct AclHandler { - allow_rules: Arc>, - deny_rules: Arc>, - /// Map of client_addr → original destination IP, populated by supervisor. - orig_dest: OrigDestMap, - /// DNS resolution cache: hostname → resolved IPs with TTL. - dns_cache: Arc>>, -} - -/// DNS cache TTL — resolved IPs are reused for this duration. -const DNS_CACHE_TTL: std::time::Duration = std::time::Duration::from_secs(30); - -impl AclHandler { - /// Resolve a hostname with caching. Returns cached IPs if fresh, - /// otherwise performs a lookup and caches the result. - async fn resolve_cached(&self, host: &str) -> Option> { - // Check cache first. - { - let cache = self.dns_cache.lock().await; - if let Some(entry) = cache.get(host) { - if entry.expires > std::time::Instant::now() { - return Some(entry.ips.clone()); - } - } - } - - // Cache miss or expired — resolve. - let lookup = format!("{}:0", host); - let resolved = tokio::net::lookup_host(&lookup).await.ok()?; - let ips: Vec = resolved.map(|sa| sa.ip()).collect(); - - // Store in cache. - let mut cache = self.dns_cache.lock().await; - cache.insert( - host.to_string(), - DnsCacheEntry { - ips: ips.clone(), - expires: std::time::Instant::now() + DNS_CACHE_TTL, - }, - ); - Some(ips) - } - - /// Verify that the claimed host resolves to the original destination IP. - /// Returns true if verification passes or is not applicable. - async fn verify_host(&self, client_addr: &SocketAddr, claimed_host: &str) -> bool { - // Look up the original dest IP recorded by the supervisor. - let orig_ip = { - let map = self.orig_dest.read().unwrap_or_else(|e| e.into_inner()); - map.get(client_addr).copied() - }; - - let orig_ip = match orig_ip { - Some(ip) => ip, - // No mapping: this can happen for non-redirected connections - // (e.g. non-intercepted ports) or if the supervisor hasn't - // recorded it yet. Since we write the mapping before connect(), - // absence here means the connection was not redirected — allow. - None => return true, - }; - - // If the claimed host is already an IP, compare directly. - if let Ok(ip) = claimed_host.parse::() { - return ip == orig_ip; - } - - // Resolve the claimed hostname (with caching) and check if any result matches. - match self.resolve_cached(claimed_host).await { - Some(ips) => ips.iter().any(|ip| *ip == orig_ip), - // DNS failure for the claimed host — deny. - None => false, - } - } -} - -impl HttpHandler for AclHandler { - async fn handle_request( - &mut self, - ctx: &HttpContext, - req: Request, - ) -> RequestOrResponse { - let method = req.method().as_str().to_string(); - - // Extract host from URI authority or Host header. - let host = req - .uri() - .host() - .map(|h| h.to_string()) - .or_else(|| { - req.headers() - .get("host") - .and_then(|v| v.to_str().ok()) - .map(|h| { - // Strip port from host header if present. - h.split(':').next().unwrap_or(h).to_string() - }) - }) - .unwrap_or_default(); - - let path = req.uri().path().to_string(); - - // Verify the Host header matches the original destination IP to - // prevent spoofing (e.g. Host: allowed.com while connecting to evil.com). - if !self.verify_host(&ctx.client_addr, &host).await { - // Clean up the mapping to prevent memory leaks on blocked requests. - if let Ok(mut map) = self.orig_dest.write() { - map.remove(&ctx.client_addr); - } - return Response::builder() - .status(StatusCode::FORBIDDEN) - .body(Body::from("Blocked by sandlock: Host header does not match connection destination")) - .expect("failed to build 403 response") - .into(); - } - - // Clean up the mapping now that verification passed. - if let Ok(mut map) = self.orig_dest.write() { - map.remove(&ctx.client_addr); - } - - if http_acl_check(&self.allow_rules, &self.deny_rules, &method, &host, &path) { - // For transparent proxying, the client sends relative URIs - // (e.g. "GET /path"). hudsucker needs an absolute URI to know - // where to forward. Reconstruct it from the Host header. - let mut req = req; - if req.uri().authority().is_none() { - let host_port = req - .headers() - .get("host") - .and_then(|v| v.to_str().ok()) - .unwrap_or_default() - .to_string(); - if !host_port.is_empty() { - if let Ok(uri) = format!("http://{}{}", host_port, req.uri().path_and_query().map(|pq| pq.as_str()).unwrap_or("/")).parse() { - *req.uri_mut() = uri; - } - } - } - req.into() - } else { - Response::builder() - .status(StatusCode::FORBIDDEN) - .body(Body::from("Blocked by sandlock HTTP ACL policy")) - .expect("failed to build 403 response") - .into() - } - } -} - -/// Handle returned by [`spawn_http_acl_proxy`]. -pub struct HttpAclProxyHandle { - /// Local address the proxy is listening on. - pub addr: SocketAddr, - /// Shared map for the supervisor to record original destination IPs. - pub orig_dest: OrigDestMap, - /// Send to this channel to trigger graceful proxy shutdown. - shutdown_tx: Option>, -} - -impl Drop for HttpAclProxyHandle { - fn drop(&mut self) { - if let Some(tx) = self.shutdown_tx.take() { - let _ = tx.send(()); - } - } -} +use rcgen::{CertificateParams, KeyPair}; /// Pre-generated dummy CA for HTTP-only mode, avoiding per-spawn keygen cost. -fn dummy_ca() -> std::io::Result<(KeyPair, hudsucker::rcgen::Certificate)> { - use hudsucker::rcgen::{BasicConstraints, IsCa}; +fn dummy_ca() -> std::io::Result<(KeyPair, rcgen::Certificate)> { + use rcgen::{BasicConstraints, DnType, IsCa}; let kp = KeyPair::generate().map_err(|e| { std::io::Error::new(std::io::ErrorKind::Other, format!("keygen failed: {e}")) })?; let mut params = CertificateParams::default(); params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained); + // A distinct subject DN is required: leaf certs minted under this CA must + // have a subject that differs from their issuer, otherwise an empty-DN leaf + // looks self-signed (subject == issuer) and clients reject it. + params.distinguished_name.push(DnType::CommonName, "sandlock MITM CA"); let cert = params.self_signed(&kp).map_err(|e| { std::io::Error::new(std::io::ErrorKind::Other, format!("self-sign failed: {e}")) })?; Ok((kp, cert)) } -static DUMMY_CA: std::sync::LazyLock, Vec)>> = - std::sync::LazyLock::new(|| { - let (kp, cert) = dummy_ca()?; - Ok((kp.serialize_pem().into_bytes(), cert.pem().into_bytes())) - }); +/// In-memory CA material (public cert + private key, PEM-encoded). +pub struct CaMaterial { + pub cert_pem: String, + pub key_pem: String, +} -/// Spawn a hudsucker-based HTTP ACL proxy. +/// Resolve the CA used for HTTPS MITM. /// -/// If `ca_cert` and `ca_key` are provided, the proxy also intercepts HTTPS -/// traffic via MITM using the given CA. Otherwise, only plaintext HTTP -/// (port 80) is intercepted. -pub async fn spawn_http_acl_proxy( - allow: Vec, - deny: Vec, +/// - Both `ca_cert` and `ca_key` set: load them from disk (bring-your-own). +/// - Neither set but `generate` is true: generate an ephemeral in-memory CA. +/// The private key never touches disk. +/// - Otherwise: `None` (HTTP-only; the proxy serves plaintext and does not +/// intercept TLS). +pub fn resolve_ca( ca_cert: Option<&Path>, ca_key: Option<&Path>, -) -> std::io::Result { - // Load CA for HTTPS MITM if provided. - - let (key_pair, cert) = if let (Some(cert_path), Some(key_path)) = (ca_cert, ca_key) { - let key_pem = std::fs::read_to_string(key_path).map_err(|e| { - std::io::Error::new(e.kind(), format!("failed to read --http-key {:?}: {e}", key_path)) - })?; + generate: bool, +) -> std::io::Result> { + if let (Some(cert_path), Some(key_path)) = (ca_cert, ca_key) { let cert_pem = std::fs::read_to_string(cert_path).map_err(|e| { std::io::Error::new(e.kind(), format!("failed to read --http-ca {:?}: {e}", cert_path)) })?; - let kp = KeyPair::from_pem(&key_pem).map_err(|e| { - std::io::Error::new(std::io::ErrorKind::InvalidData, format!("invalid CA key: {e}")) - })?; - let params = CertificateParams::from_ca_cert_pem(&cert_pem).map_err(|e| { - std::io::Error::new(std::io::ErrorKind::InvalidData, format!("invalid CA cert: {e}")) - })?; - let cert = params.self_signed(&kp).map_err(|e| { - std::io::Error::new(std::io::ErrorKind::InvalidData, format!("CA cert error: {e}")) - })?; - (kp, cert) - } else { - // HTTP-only mode — reuse a lazily-generated dummy CA to avoid - // expensive keygen on every spawn. - let (key_pem, cert_pem) = DUMMY_CA.as_ref().map_err(|e| { - std::io::Error::new(e.kind(), format!("dummy CA init failed: {e}")) - })?; - let kp = KeyPair::from_pem(std::str::from_utf8(key_pem).unwrap()).map_err(|e| { - std::io::Error::new(std::io::ErrorKind::Other, format!("dummy CA key: {e}")) - })?; - let params = CertificateParams::from_ca_cert_pem(std::str::from_utf8(cert_pem).unwrap()) - .map_err(|e| { - std::io::Error::new(std::io::ErrorKind::Other, format!("dummy CA cert: {e}")) - })?; - let cert = params.self_signed(&kp).map_err(|e| { - std::io::Error::new(std::io::ErrorKind::Other, format!("dummy CA sign: {e}")) + let key_pem = std::fs::read_to_string(key_path).map_err(|e| { + std::io::Error::new(e.kind(), format!("failed to read --http-key {:?}: {e}", key_path)) })?; - (kp, cert) - }; - - let ca = RcgenAuthority::new(key_pair, cert, 1_000); - - let orig_dest: OrigDestMap = Arc::new(std::sync::RwLock::new(HashMap::new())); - - let handler = AclHandler { - allow_rules: Arc::new(allow), - deny_rules: Arc::new(deny), - orig_dest: Arc::clone(&orig_dest), - dns_cache: Arc::new(tokio::sync::Mutex::new(HashMap::new())), - }; - - let listener = TcpListener::bind("127.0.0.1:0").await?; - let addr = listener.local_addr()?; - - let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>(); - - let proxy = Proxy::builder() - .with_listener(listener) - .with_rustls_client() - .with_ca(ca) - .with_http_handler(handler) - .with_graceful_shutdown(async { - let _ = shutdown_rx.await; - }) - .build(); + return Ok(Some(CaMaterial { cert_pem, key_pem })); + } + if generate { + let (kp, cert) = dummy_ca()?; + return Ok(Some(CaMaterial { + cert_pem: cert.pem(), + key_pem: kp.serialize_pem(), + })); + } + Ok(None) +} - tokio::spawn(async move { - if let Err(e) = proxy.start().await { - eprintln!("sandlock HTTP ACL proxy error: {e}"); - } - }); +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn resolve_ca_generate_produces_parseable_ca() { + let m = resolve_ca(None, None, true).unwrap().expect("should generate"); + let kp = KeyPair::from_pem(&m.key_pem).expect("key parses"); + let _ = CertificateParams::from_ca_cert_pem(&m.cert_pem) + .expect("cert parses") + .self_signed(&kp) + .expect("re-sign ok"); + assert!(m.cert_pem.contains("BEGIN CERTIFICATE")); + } - Ok(HttpAclProxyHandle { - addr, - orig_dest, - shutdown_tx: Some(shutdown_tx), - }) + #[test] + fn resolve_ca_none_without_generate() { + assert!(resolve_ca(None, None, false).unwrap().is_none()); + } } diff --git a/crates/sandlock-core/src/lib.rs b/crates/sandlock-core/src/lib.rs index e64f6ed..9ecfdc0 100644 --- a/crates/sandlock-core/src/lib.rs +++ b/crates/sandlock-core/src/lib.rs @@ -23,9 +23,11 @@ pub mod pipeline; pub mod policy_fn; pub mod image; pub mod fork; +pub(crate) mod ca_inject; pub(crate) mod chroot; pub mod dry_run; pub(crate) mod http_acl; +mod transparent_proxy; pub use error::SandlockError; pub use sys::structs::{SeccompData, SeccompNotif}; diff --git a/crates/sandlock-core/src/profile.rs b/crates/sandlock-core/src/profile.rs index 4b94431..23b9c91 100644 --- a/crates/sandlock-core/src/profile.rs +++ b/crates/sandlock-core/src/profile.rs @@ -33,6 +33,8 @@ pub struct ProfileInput { pub struct ConfigSection { pub http_ca: Option, pub http_key: Option, + pub http_inject_ca: Vec, + pub http_ca_out: Option, pub fs_storage: Option, pub workdir: Option, } @@ -131,6 +133,8 @@ pub fn parse_input(input: ProfileInput) -> Result<(Sandbox, ProgramSpec), Sandlo // [config] if let Some(p) = input.config.http_ca { b = b.http_ca(p); } if let Some(p) = input.config.http_key { b = b.http_key(p); } + for p in input.config.http_inject_ca { b = b.http_inject_ca(p); } + if let Some(p) = input.config.http_ca_out { b = b.http_ca_out(p); } if let Some(p) = input.config.fs_storage { b = b.fs_storage(p); } if let Some(p) = input.config.workdir { b = b.workdir(p); } @@ -332,6 +336,23 @@ mod tests { assert_eq!(policy.http_key.as_deref(), Some(std::path::Path::new("/tmp/ca.key"))); } + #[test] + fn parses_http_inject_ca_and_ca_out() { + let toml = r#" + [config] + http_inject_ca = ["/etc/ssl/certs/ca-certificates.crt"] + http_ca_out = "/tmp/ca.pem" + [http] + allow = ["GET example.com/*"] + [program] + exec = "/bin/true" + "#; + let input: ProfileInput = toml::from_str(toml).unwrap(); + let (policy, _prog) = parse_input(input).unwrap(); + assert_eq!(policy.http_inject_ca.len(), 1); + assert_eq!(policy.http_ca_out.as_deref(), Some(std::path::Path::new("/tmp/ca.pem"))); + } + #[test] fn syscalls_extra_allow_sysv_ipc_sets_vec() { let toml = r#" diff --git a/crates/sandlock-core/src/resource.rs b/crates/sandlock-core/src/resource.rs index e3b7e64..dd52790 100644 --- a/crates/sandlock-core/src/resource.rs +++ b/crates/sandlock-core/src/resource.rs @@ -654,6 +654,8 @@ mod tests { virtual_hostname: None, has_http_acl: false, virtual_etc_hosts: String::new(), + ca_inject_paths: Vec::new(), + ca_inject_pem: None, } } diff --git a/crates/sandlock-core/src/sandbox.rs b/crates/sandlock-core/src/sandbox.rs index dc613ae..93f669f 100644 --- a/crates/sandlock-core/src/sandbox.rs +++ b/crates/sandlock-core/src/sandbox.rs @@ -115,6 +115,8 @@ impl TryFrom<&Sandbox> for Confinement { if !sandbox.http_ports.is_empty() { unsupported.push("http_ports"); } if sandbox.http_ca.is_some() { unsupported.push("http_ca"); } if sandbox.http_key.is_some() { unsupported.push("http_key"); } + if !sandbox.http_inject_ca.is_empty() { unsupported.push("http_inject_ca"); } + if sandbox.http_ca_out.is_some() { unsupported.push("http_ca_out"); } if sandbox.max_memory.is_some() { unsupported.push("max_memory"); } if sandbox.max_processes != 64 { unsupported.push("max_processes"); } if sandbox.max_open_files.is_some() { unsupported.push("max_open_files"); } @@ -186,7 +188,7 @@ struct Runtime { stdout_pipe: Option, io_overrides: Option<(Option, Option, Option)>, extra_fds: Vec<(i32, i32)>, - http_acl_handle: Option, + http_acl_handle: Option, #[allow(clippy::type_complexity)] on_bind: Option) + Send + Sync>>, handlers: Vec<(i64, Arc)>, @@ -248,6 +250,11 @@ pub struct Sandbox { pub http_ca: Option, /// PEM CA key for HTTPS MITM. Required when http_ca is set. pub http_key: Option, + /// Trust-bundle paths to splice the MITM CA into (zero-config HTTPS). + pub http_inject_ca: Vec, + /// Path to write the active MITM CA public cert (PEM) for external trust + /// wiring (e.g. NODE_EXTRA_CA_CERTS). Never writes the private key. + pub http_ca_out: Option, // Resource limits pub max_memory: Option, @@ -360,6 +367,8 @@ impl Clone for Sandbox { http_ports: self.http_ports.clone(), http_ca: self.http_ca.clone(), http_key: self.http_key.clone(), + http_inject_ca: self.http_inject_ca.clone(), + http_ca_out: self.http_ca_out.clone(), max_memory: self.max_memory, max_processes: self.max_processes, max_open_files: self.max_open_files, @@ -1116,6 +1125,27 @@ impl Sandbox { // drop to "no confinement". let chroot_root = crate::chroot::resolve::resolve_chroot_root(self.chroot.as_deref())?; + // Each --http-inject-ca target must exist in the sandbox's view, or the + // CA cannot be spliced into it and TLS interception silently fails. A + // configured-but-missing trust bundle is a hard error, resolved through + // --fs-mount and chroot so the check matches the workload's view. + if !self.http_inject_ca.is_empty() { + let mounts = crate::chroot::resolve::resolve_chroot_mounts(&self.fs_mount); + for p in &self.http_inject_ca { + let host = resolve_sandbox_path_to_host(p, chroot_root.as_deref(), &mounts); + if !host.exists() { + return Err(SandboxRuntimeError::Child(format!( + "--http-inject-ca {:?} not found in the sandbox view (resolved to {:?}); \ + the CA cannot be injected into it. Point it at the trust bundle the \ + workload actually reads (e.g. /etc/ssl/certs/ca-certificates.crt, or \ + certifi's cacert.pem).", + p, host + )) + .into()); + } + } + } + let c_cmd: Vec = cmd .iter() .map(|s| CString::new(*s).map_err(|_| SandboxRuntimeError::Child("invalid command string".into()))) @@ -1139,13 +1169,42 @@ impl Sandbox { &resolved_net_allow.concrete_host_entries, ); + let mut ca_inject_pem: Option>> = None; if !self.http_allow.is_empty() || !self.http_deny.is_empty() { - let handle = crate::http_acl::spawn_http_acl_proxy( - self.http_allow.clone(), - self.http_deny.clone(), + // Generate an ephemeral CA when injection is requested without BYO. + let generate = !self.http_inject_ca.is_empty(); + let ca_material = crate::http_acl::resolve_ca( self.http_ca.as_deref(), self.http_key.as_deref(), - ).await.map_err(SandboxRuntimeError::Io)?; + generate, + ) + .map_err(SandboxRuntimeError::Io)?; + + // Export the public cert if requested. + if let (Some(out), Some(cm)) = (self.http_ca_out.as_deref(), ca_material.as_ref()) { + std::fs::write(out, cm.cert_pem.as_bytes()).map_err(SandboxRuntimeError::Io)?; + } + + // Keep the public cert for trust injection (only when paths declared). + if !self.http_inject_ca.is_empty() { + if let Some(cm) = ca_material.as_ref() { + ca_inject_pem = Some(std::sync::Arc::new(cm.cert_pem.clone().into_bytes())); + } + } + + let (cert_pem, key_pem) = match ca_material.as_ref() { + Some(cm) => (Some(cm.cert_pem.as_str()), Some(cm.key_pem.as_str())), + None => (None, None), + }; + + let handle = crate::transparent_proxy::spawn_transparent_proxy( + self.http_allow.clone(), + self.http_deny.clone(), + cert_pem, + key_pem, + ) + .await + .map_err(SandboxRuntimeError::Io)?; self.rt_mut().http_acl_handle = Some(handle); } @@ -1327,6 +1386,8 @@ impl Sandbox { virtual_hostname: Some(rt_name), has_http_acl: !self.http_allow.is_empty() || !self.http_deny.is_empty(), virtual_etc_hosts, + ca_inject_paths: self.http_inject_ca.clone(), + ca_inject_pem: ca_inject_pem.clone(), }; use rand::SeedableRng; @@ -1739,6 +1800,15 @@ pub struct SandboxBuilder { #[cfg_attr(feature = "cli", arg(long = "http-key", value_name = "PATH"))] pub http_key: Option, + /// Inject the MITM CA into these trust bundle paths (repeatable). Without + /// --http-ca this generates an ephemeral CA and intercepts port 443. + #[cfg_attr(feature = "cli", arg(long = "http-inject-ca", value_name = "PATH"))] + pub http_inject_ca: Vec, + + /// Write the active MITM CA public certificate (PEM) to this path. + #[cfg_attr(feature = "cli", arg(long = "http-ca-out", value_name = "PATH"))] + pub http_ca_out: Option, + // max_memory uses a string in the CLI (e.g. "512M"); not directly clap-friendly as ByteSize. #[cfg_attr(feature = "cli", clap(skip))] pub max_memory: Option, @@ -1879,6 +1949,8 @@ impl Clone for SandboxBuilder { http_ports: self.http_ports.clone(), http_ca: self.http_ca.clone(), http_key: self.http_key.clone(), + http_inject_ca: self.http_inject_ca.clone(), + http_ca_out: self.http_ca_out.clone(), max_memory: self.max_memory, max_processes: self.max_processes, max_open_files: self.max_open_files, @@ -1993,6 +2065,16 @@ impl SandboxBuilder { self } + pub fn http_inject_ca(mut self, path: impl Into) -> Self { + self.http_inject_ca.push(path.into()); + self + } + + pub fn http_ca_out(mut self, path: impl Into) -> Self { + self.http_ca_out = Some(path.into()); + self + } + pub fn max_memory(mut self, size: ByteSize) -> Self { self.max_memory = Some(size); self @@ -2186,6 +2268,24 @@ impl SandboxBuilder { )); } + // --http-inject-ca / --http-ca-out are meaningless without an HTTP ACL + // proxy to do MITM, which only spawns when http rules exist. + let has_http_rules = !self.http_allow.is_empty() || !self.http_deny.is_empty(); + if !self.http_inject_ca.is_empty() && !has_http_rules { + return Err(SandboxError::Invalid( + "--http-inject-ca requires --http-allow or --http-deny".into(), + )); + } + // --http-ca-out needs an actual CA to export (BYO or generated). + if self.http_ca_out.is_some() + && self.http_ca.is_none() + && self.http_inject_ca.is_empty() + { + return Err(SandboxError::Invalid( + "--http-ca-out requires --http-ca or --http-inject-ca".into(), + )); + } + // Parse HTTP rules (deferred from builder methods to propagate errors) let http_allow: Vec = self .http_allow @@ -2201,7 +2301,7 @@ impl SandboxBuilder { // Default HTTP intercept ports: 80 always, 443 when HTTPS CA is configured. let http_ports = if self.http_ports.is_empty() && (!http_allow.is_empty() || !http_deny.is_empty()) { let mut ports = vec![80]; - if self.http_ca.is_some() { + if self.http_ca.is_some() || !self.http_inject_ca.is_empty() { ports.push(443); } ports @@ -2236,6 +2336,8 @@ impl SandboxBuilder { http_ports, http_ca: self.http_ca, http_key: self.http_key, + http_inject_ca: self.http_inject_ca, + http_ca_out: self.http_ca_out, max_memory: self.max_memory, max_processes: self.max_processes.unwrap_or(64), max_open_files: self.max_open_files, @@ -2279,9 +2381,72 @@ impl SandboxBuilder { } } +/// Resolve a path as seen inside the sandbox to its host-side location, so its +/// existence can be checked before spawn. Honors `--fs-mount` (virtual:host) +/// mappings (which take precedence) and chroot. Used to validate +/// `--http-inject-ca` targets. +fn resolve_sandbox_path_to_host( + child_path: &std::path::Path, + chroot_root: Option<&std::path::Path>, + mounts: &[(std::path::PathBuf, std::path::PathBuf)], +) -> std::path::PathBuf { + for (virt, host) in mounts { + if let Ok(rest) = child_path.strip_prefix(virt) { + return host.join(rest); + } + } + if let Some(root) = chroot_root { + if let Ok(rest) = child_path.strip_prefix("/") { + return root.join(rest); + } + } + child_path.to_path_buf() +} + #[cfg(test)] mod tests { use super::*; + use std::path::{Path, PathBuf}; + + #[test] + fn resolve_sandbox_path_plain() { + let r = resolve_sandbox_path_to_host(Path::new("/etc/ssl/x.pem"), None, &[]); + assert_eq!(r, PathBuf::from("/etc/ssl/x.pem")); + } + + #[test] + fn resolve_sandbox_path_under_chroot() { + let r = resolve_sandbox_path_to_host( + Path::new("/etc/ssl/x.pem"), + Some(Path::new("/srv/root")), + &[], + ); + assert_eq!(r, PathBuf::from("/srv/root/etc/ssl/x.pem")); + } + + #[test] + fn resolve_sandbox_path_mount_takes_precedence() { + let mounts = vec![(PathBuf::from("/etc/ssl"), PathBuf::from("/host/ssl"))]; + let r = resolve_sandbox_path_to_host( + Path::new("/etc/ssl/x.pem"), + Some(Path::new("/srv/root")), + &mounts, + ); + assert_eq!(r, PathBuf::from("/host/ssl/x.pem")); + } + + #[tokio::test] + async fn inject_ca_nonexistent_path_errors_at_run() { + // Wildcard host rule avoids DNS; the missing inject path must error + // before any fork or network work. + let mut policy = Sandbox::builder() + .http_allow("GET */*") + .http_inject_ca("/definitely/not/here/sandlock-bundle.pem") + .build() + .unwrap(); + let res = policy.run(&["true"]).await; + assert!(res.is_err(), "expected error for missing --http-inject-ca path"); + } // --- SandboxBuilder integration --- @@ -2341,6 +2506,40 @@ mod tests { assert!(policy.http_key.is_some()); } + #[test] + fn inject_ca_adds_443_and_requires_http_rule() { + // No http rule -> error. + let err = Sandbox::builder() + .http_inject_ca("/etc/ssl/certs/ca-certificates.crt") + .build(); + assert!(err.is_err()); + + // With an http rule -> ok, and 443 is intercepted. + let policy = Sandbox::builder() + .http_allow("GET example.com/*") + .http_inject_ca("/etc/ssl/certs/ca-certificates.crt") + .build() + .unwrap(); + assert!(policy.http_ports.contains(&443)); + assert_eq!(policy.http_inject_ca.len(), 1); + } + + #[test] + fn http_ca_out_requires_trigger() { + let err = Sandbox::builder() + .http_allow("GET example.com/*") + .http_ca_out("/tmp/out.pem") + .build(); + assert!(err.is_err()); + + let ok = Sandbox::builder() + .http_allow("GET example.com/*") + .http_inject_ca("/etc/ssl/certs/ca-certificates.crt") + .http_ca_out("/tmp/out.pem") + .build(); + assert!(ok.is_ok()); + } + #[test] fn allows_sysv_ipc_reads_extra_allow_syscalls() { let p = Sandbox::builder() diff --git a/crates/sandlock-core/src/seccomp/dispatch.rs b/crates/sandlock-core/src/seccomp/dispatch.rs index c1ab630..e28c62a 100644 --- a/crates/sandlock-core/src/seccomp/dispatch.rs +++ b/crates/sandlock-core/src/seccomp/dispatch.rs @@ -443,6 +443,34 @@ pub(crate) fn build_dispatch_table( } } + // ------------------------------------------------------------------ + // CA injection: splice the active MITM CA into user-declared trust + // bundles. Registered before chroot/COW so the substituted memfd wins + // over a real open of the bundle file. Only active when MITM is on and + // the user declared at least one --http-inject-ca path. + // ------------------------------------------------------------------ + if let Some(ca_pem) = policy.ca_inject_pem.clone() { + if !policy.ca_inject_paths.is_empty() { + let inject_paths = std::sync::Arc::new(policy.ca_inject_paths.clone()); + for nr in open_family_syscalls() { + let ca_pem = std::sync::Arc::clone(&ca_pem); + let inject_paths = std::sync::Arc::clone(&inject_paths); + table.register(nr, move |cx: &HandlerCtx| { + let notif = cx.notif; + let notif_fd = cx.notif_fd; + let ca_pem = std::sync::Arc::clone(&ca_pem); + let inject_paths = std::sync::Arc::clone(&inject_paths); + async move { + crate::ca_inject::handle_ca_inject_open( + ¬if, &inject_paths, &ca_pem, notif_fd, + ) + .unwrap_or(NotifAction::Continue) + } + }); + } + } + } + // ------------------------------------------------------------------ // Chroot path interception (before COW) // ------------------------------------------------------------------ @@ -1072,6 +1100,8 @@ mod handler_tests { virtual_hostname: None, has_http_acl: false, virtual_etc_hosts: String::new(), + ca_inject_paths: Vec::new(), + ca_inject_pem: None, }), child_pidfd: None, notif_fd: -1, diff --git a/crates/sandlock-core/src/seccomp/notif.rs b/crates/sandlock-core/src/seccomp/notif.rs index 8c29664..4d0a41c 100644 --- a/crates/sandlock-core/src/seccomp/notif.rs +++ b/crates/sandlock-core/src/seccomp/notif.rs @@ -385,6 +385,11 @@ pub struct NotifPolicy { /// host's on-disk `/etc/hosts` never leaks in. The content is the /// loopback base plus any concrete hostnames resolved from `net_allow`. pub virtual_etc_hosts: String, + /// User-declared trust-bundle paths to splice the MITM CA into. + pub ca_inject_paths: Vec, + /// Active MITM CA public cert (PEM bytes) to inject. `Some` only when + /// HTTPS MITM is active (BYO or generated). + pub ca_inject_pem: Option>>, } // ============================================================ diff --git a/crates/sandlock-core/src/seccomp/state.rs b/crates/sandlock-core/src/seccomp/state.rs index 9eb1a9a..e8bb110 100644 --- a/crates/sandlock-core/src/seccomp/state.rs +++ b/crates/sandlock-core/src/seccomp/state.rs @@ -318,7 +318,7 @@ pub struct NetworkState { /// TCP ports to intercept and redirect to the HTTP ACL proxy. pub http_acl_ports: HashSet, /// Shared map for recording original destination IPs on proxy redirect. - pub http_acl_orig_dest: Option, + pub http_acl_orig_dest: Option, } impl NetworkState { diff --git a/crates/sandlock-core/src/transparent_proxy/mod.rs b/crates/sandlock-core/src/transparent_proxy/mod.rs new file mode 100644 index 0000000..7461427 --- /dev/null +++ b/crates/sandlock-core/src/transparent_proxy/mod.rs @@ -0,0 +1,225 @@ +mod service; +mod tls; +mod upstream; + +use std::net::SocketAddr; +use std::sync::Arc; + +use hyper::server::conn::http1; +use hyper::service::service_fn; +use hyper_util::rt::TokioIo; +use tokio::net::{TcpListener, TcpStream}; +use tokio::sync::oneshot; +use tokio_rustls::LazyConfigAcceptor; + +use crate::http::HttpRule; +use self::service::AclService; +use self::tls::CertSigner; +use self::upstream::Forwarder; + +pub(crate) use self::service::OrigDestMap; + +/// Handle returned by [`spawn_transparent_proxy`]. Dropping it shuts the proxy down. +pub(crate) struct HttpAclProxyHandle { + pub(crate) addr: SocketAddr, + pub(crate) orig_dest: OrigDestMap, + shutdown_tx: Option>, +} + +impl Drop for HttpAclProxyHandle { + fn drop(&mut self) { + if let Some(tx) = self.shutdown_tx.take() { + let _ = tx.send(()); + } + } +} + +/// A TLS record/handshake always begins with content-type 0x16 (handshake). +/// A plaintext HTTP request never does (it starts with an ASCII method letter). +fn is_tls_client_hello(first_byte: u8) -> bool { + first_byte == 0x16 +} + +/// Spawn the transparent HTTP/HTTPS ACL proxy. When `ca_cert_pem`/`ca_key_pem` +/// are provided, TLS connections are MITM-terminated with a per-SNI leaf cert +/// minted from that CA; otherwise TLS connections are closed (443 is only +/// redirected here when a CA is configured) and plaintext HTTP is served. +pub(crate) async fn spawn_transparent_proxy( + allow: Vec, + deny: Vec, + ca_cert_pem: Option<&str>, + ca_key_pem: Option<&str>, +) -> std::io::Result { + // rustls 0.22 builder() uses the ring provider directly; no provider install needed. + let orig_dest: OrigDestMap = + Arc::new(std::sync::RwLock::new(std::collections::HashMap::new())); + let forwarder = Forwarder::new()?; + let svc = AclService::new(allow, deny, Arc::clone(&orig_dest), forwarder); + + let signer = match (ca_cert_pem, ca_key_pem) { + (Some(c), Some(k)) => Some(Arc::new(CertSigner::new(c, k)?)), + _ => None, + }; + + let listener = TcpListener::bind("127.0.0.1:0").await?; + let addr = listener.local_addr()?; + let (shutdown_tx, mut shutdown_rx) = oneshot::channel::<()>(); + + tokio::spawn(async move { + loop { + let accept = tokio::select! { + _ = &mut shutdown_rx => break, + r = listener.accept() => r, + }; + let (stream, peer) = match accept { + Ok(v) => v, + Err(_) => continue, + }; + let svc = svc.clone(); + let signer = signer.clone(); + tokio::spawn(async move { + let _ = serve_conn(stream, peer, svc, signer).await; + }); + } + }); + + Ok(HttpAclProxyHandle { + addr, + orig_dest, + shutdown_tx: Some(shutdown_tx), + }) +} + +async fn serve_conn( + stream: TcpStream, + peer: SocketAddr, + svc: AclService, + signer: Option>, +) -> Result<(), Box> { + let mut first = [0u8; 1]; + let n = stream.peek(&mut first).await?; + if n == 0 { + return Ok(()); + } + + if is_tls_client_hello(first[0]) { + let signer = match signer { + Some(s) => s, + None => return Ok(()), // no CA: cannot MITM + }; + // LazyConfigAcceptor reads the ClientHello so we can choose a config by + // SNI before completing the handshake. First arg is a fresh Acceptor. + let acceptor = LazyConfigAcceptor::new(rustls::server::Acceptor::default(), stream); + let start = acceptor.await?; + let sni = match start.client_hello().server_name() { + Some(s) => s.to_string(), + None => return Ok(()), // fail closed: no SNI + }; + let cfg = signer.server_config_for(&sni)?; + let tls = start.into_stream(cfg).await?; + serve_http(TokioIo::new(tls), peer, svc, "https").await + } else { + serve_http(TokioIo::new(stream), peer, svc, "http").await + } +} + +async fn serve_http( + io: I, + peer: SocketAddr, + svc: AclService, + scheme: &'static str, +) -> Result<(), Box> +where + I: hyper::rt::Read + hyper::rt::Write + Unpin + 'static, +{ + let service = service_fn(move |req| { + let svc = svc.clone(); + async move { Ok::<_, std::convert::Infallible>(svc.handle(peer, scheme, req).await) } + }); + http1::Builder::new().serve_connection(io, service).await?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn classifies_tls_vs_plaintext() { + assert!(is_tls_client_hello(0x16)); + assert!(!is_tls_client_hello(b'G')); // "GET ..." + assert!(!is_tls_client_hello(b'P')); // "POST ..." + } + + // Extract the first certificate DER from a PEM string. + fn first_cert_der(pem: &str) -> rustls::pki_types::CertificateDer<'static> { + let mut rd = std::io::BufReader::new(pem.as_bytes()); + let der = rustls_pemfile::certs(&mut rd) + .next() + .expect("at least one CERTIFICATE block") + .expect("valid certificate"); + der + } + + // Hermetic proof of the MITM path, no upstream network: + // 1. the proxy terminates TLS, + // 2. a client trusting the generated CA completes the handshake against + // the proxy's per-SNI minted leaf, + // 3. a request that does not match the ACL gets HTTP 403 with the + // sandlock body (the deny path never contacts an upstream). + #[tokio::test] + async fn https_mitm_denies_disallowed_request() { + use std::sync::Arc; + use std::time::Duration; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use tokio::net::TcpStream; + use tokio_rustls::TlsConnector; + + // Ephemeral CA, plus an allow rule that will NOT match our request. + let ca = crate::http_acl::resolve_ca(None, None, true) + .expect("resolve_ca ok") + .expect("ephemeral CA generated"); + let allow = vec![crate::http::HttpRule::parse("GET allowed.test/*").expect("rule parses")]; + let handle = super::spawn_transparent_proxy(allow, vec![], Some(&ca.cert_pem), Some(&ca.key_pem)) + .await + .expect("proxy spawns"); + let addr = handle.addr; + + // rustls client that trusts only the generated CA. + let mut roots = rustls::RootCertStore::empty(); + roots.add(first_cert_der(&ca.cert_pem)).expect("add CA root"); + let client_cfg = rustls::ClientConfig::builder() + .with_root_certificates(roots) + .with_no_client_auth(); + let connector = TlsConnector::from(Arc::new(client_cfg)); + + // Connect and complete the TLS handshake with SNI "denied.test". A + // successful handshake proves termination plus trust of the minted leaf. + let tcp = TcpStream::connect(addr).await.expect("tcp connect"); + let server_name = rustls::pki_types::ServerName::try_from("denied.test") + .expect("valid server name"); + let mut tls = connector + .connect(server_name, tcp) + .await + .expect("TLS handshake against minted leaf"); + + // A disallowed request: GET denied.test/secret. + tls.write_all( + b"GET /secret HTTP/1.1\r\nHost: denied.test\r\nConnection: close\r\n\r\n", + ) + .await + .expect("write request"); + + let mut buf = Vec::new(); + tokio::time::timeout(Duration::from_secs(5), tls.read_to_end(&mut buf)) + .await + .expect("response within timeout") + .expect("read response"); + let resp = String::from_utf8_lossy(&buf); + + assert!(resp.starts_with("HTTP/1.1 403"), "expected 403, got: {resp}"); + assert!( + resp.contains("Blocked by sandlock HTTP ACL policy"), + "body: {resp}" + ); + } +} diff --git a/crates/sandlock-core/src/transparent_proxy/service.rs b/crates/sandlock-core/src/transparent_proxy/service.rs new file mode 100644 index 0000000..4835502 --- /dev/null +++ b/crates/sandlock-core/src/transparent_proxy/service.rs @@ -0,0 +1,167 @@ +// Per-request handling shared by the plaintext and TLS-terminated paths: +// reconstruct the absolute URL, verify the claimed host against the orig-dest +// IP, apply the HTTP ACL, and forward allowed requests upstream. + +use std::collections::HashMap; +use std::net::{IpAddr, SocketAddr}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use http_body_util::combinators::BoxBody; +use http_body_util::{BodyExt, Full}; +use hyper::body::Bytes; +use hyper::{Request, Response, StatusCode}; +use tokio::sync::Mutex; + +use super::upstream::{box_incoming, Forwarder}; +use crate::http::{http_acl_check, HttpRule}; + +type BoxError = Box; +pub(crate) type OrigDestMap = Arc>>; + +const DNS_CACHE_TTL: Duration = Duration::from_secs(30); + +struct DnsEntry { + ips: Vec, + expires: Instant, +} + +/// Shared, cloneable state for the request handler. +#[derive(Clone)] +pub(crate) struct AclService { + pub(crate) allow: Arc>, + pub(crate) deny: Arc>, + pub(crate) orig_dest: OrigDestMap, + pub(crate) forwarder: Forwarder, + dns_cache: Arc>>, +} + +impl AclService { + pub(crate) fn new( + allow: Vec, + deny: Vec, + orig_dest: OrigDestMap, + forwarder: Forwarder, + ) -> Self { + Self { + allow: Arc::new(allow), + deny: Arc::new(deny), + orig_dest, + forwarder, + dns_cache: Arc::new(Mutex::new(HashMap::new())), + } + } + + async fn resolve_cached(&self, host: &str) -> Option> { + { + let cache = self.dns_cache.lock().await; + if let Some(e) = cache.get(host) { + if e.expires > Instant::now() { + return Some(e.ips.clone()); + } + } + } + let resolved = tokio::net::lookup_host(format!("{host}:0")).await.ok()?; + let ips: Vec = resolved.map(|sa| sa.ip()).collect(); + let mut cache = self.dns_cache.lock().await; + cache.insert( + host.to_string(), + DnsEntry { + ips: ips.clone(), + expires: Instant::now() + DNS_CACHE_TTL, + }, + ); + Some(ips) + } + + async fn verify_host(&self, client_addr: &SocketAddr, claimed_host: &str) -> bool { + let orig_ip = { + let map = self.orig_dest.read().unwrap_or_else(|e| e.into_inner()); + map.get(client_addr).copied() + }; + let orig_ip = match orig_ip { + Some(ip) => ip, + None => return true, + }; + if let Ok(ip) = claimed_host.parse::() { + return ip == orig_ip; + } + match self.resolve_cached(claimed_host).await { + Some(ips) => ips.iter().any(|ip| *ip == orig_ip), + None => false, + } + } + + /// Handle one request. `scheme` is "https" for the MITM path, "http" for plaintext. + pub(crate) async fn handle( + &self, + client_addr: SocketAddr, + scheme: &str, + req: Request, + ) -> Response> { + let method = req.method().as_str().to_string(); + let host = req + .uri() + .host() + .map(|h| h.to_string()) + .or_else(|| { + req.headers() + .get("host") + .and_then(|v| v.to_str().ok()) + .map(|h| h.split(':').next().unwrap_or(h).to_string()) + }) + .unwrap_or_default(); + let path = req.uri().path().to_string(); + + if !self.verify_host(&client_addr, &host).await { + if let Ok(mut m) = self.orig_dest.write() { + m.remove(&client_addr); + } + return text_response( + StatusCode::FORBIDDEN, + "Blocked by sandlock: Host header does not match connection destination", + ); + } + if let Ok(mut m) = self.orig_dest.write() { + m.remove(&client_addr); + } + + if !http_acl_check(&self.allow, &self.deny, &method, &host, &path) { + return text_response(StatusCode::FORBIDDEN, "Blocked by sandlock HTTP ACL policy"); + } + + // Rebuild an absolute-URI request for the upstream client. + let host_hdr = req + .headers() + .get("host") + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()) + .unwrap_or_else(|| host.clone()); + let pq = req + .uri() + .path_and_query() + .map(|p| p.as_str()) + .unwrap_or("/") + .to_string(); + let uri: hyper::Uri = match format!("{scheme}://{host_hdr}{pq}").parse() { + Ok(u) => u, + Err(_) => return text_response(StatusCode::BAD_GATEWAY, "bad upstream URI"), + }; + + let (mut parts, body) = req.into_parts(); + parts.uri = uri; + let out_req = Request::from_parts(parts, box_incoming(body)); + + match self.forwarder.forward(out_req).await { + Ok(resp) => resp, + Err(_) => text_response(StatusCode::BAD_GATEWAY, "upstream error"), + } + } +} + +fn text_response(status: StatusCode, msg: &str) -> Response> { + Response::builder() + .status(status) + .body(Full::new(Bytes::from(msg.to_string())).map_err(|e| match e {}).boxed()) + .expect("response build") +} diff --git a/crates/sandlock-core/src/transparent_proxy/tls.rs b/crates/sandlock-core/src/transparent_proxy/tls.rs new file mode 100644 index 0000000..58b1021 --- /dev/null +++ b/crates/sandlock-core/src/transparent_proxy/tls.rs @@ -0,0 +1,100 @@ +// Mints and caches per-SNI leaf certificates signed by the active MITM CA, +// producing rustls ServerConfigs for transparent TLS termination. + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +use rcgen::{Certificate, CertificateParams, DnType, KeyPair}; +use rustls::pki_types::PrivateKeyDer; +use rustls::ServerConfig; + +/// Holds the CA cert + key and a per-SNI cache of rustls server configs. +pub(crate) struct CertSigner { + ca_cert: Certificate, + ca_key: KeyPair, + cache: Mutex>>, +} + +impl CertSigner { + /// Build from the active CA's PEM (cert + key). + pub(crate) fn new(ca_cert_pem: &str, ca_key_pem: &str) -> std::io::Result { + let ca_key = KeyPair::from_pem(ca_key_pem).map_err(|e| { + std::io::Error::new(std::io::ErrorKind::InvalidData, format!("CA key: {e}")) + })?; + let ca_params = CertificateParams::from_ca_cert_pem(ca_cert_pem).map_err(|e| { + std::io::Error::new(std::io::ErrorKind::InvalidData, format!("CA cert: {e}")) + })?; + // self_signed consumes the params and rebuilds the CA Certificate object, + // which we keep as the issuer and to include in the served chain. + let ca_cert = ca_params.self_signed(&ca_key).map_err(|e| { + std::io::Error::new(std::io::ErrorKind::InvalidData, format!("CA rebuild: {e}")) + })?; + Ok(Self { ca_cert, ca_key, cache: Mutex::new(HashMap::new()) }) + } + + /// Mint (or cache-hit) a ServerConfig presenting a leaf cert for `sni`. + pub(crate) fn server_config_for(&self, sni: &str) -> std::io::Result> { + if let Some(cfg) = self.cache.lock().unwrap().get(sni) { + return Ok(Arc::clone(cfg)); + } + let leaf_key = KeyPair::generate().map_err(|e| { + std::io::Error::new(std::io::ErrorKind::Other, format!("leaf keygen: {e}")) + })?; + // new(vec![sni]) sets subject_alt_names to a single DnsName(sni) entry. + let mut params = CertificateParams::new(vec![sni.to_string()]).map_err(|e| { + std::io::Error::new(std::io::ErrorKind::InvalidInput, format!("leaf params: {e}")) + })?; + // Give the leaf a subject CN distinct from the CA's subject, so the leaf + // is not mistaken for self-signed (subject == issuer) by clients. + params.distinguished_name.push(DnType::CommonName, sni); + // signed_by(public_key, issuer_cert, issuer_key): leaf public key is the + // leaf KeyPair (impl PublicKeyData), signed by the CA cert + CA key. + let leaf = params.signed_by(&leaf_key, &self.ca_cert, &self.ca_key).map_err(|e| { + std::io::Error::new(std::io::ErrorKind::Other, format!("leaf sign: {e}")) + })?; + + // Present only the leaf; the CA is the trust anchor in the client's store. + let chain = vec![leaf.der().clone()]; + // rcgen serialize_der() returns PKCS#8 DER. + let key_der = PrivateKeyDer::Pkcs8(leaf_key.serialize_der().into()); + + let mut cfg = ServerConfig::builder() + .with_no_client_auth() + .with_single_cert(chain, key_der) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, format!("server cfg: {e}")))?; + cfg.alpn_protocols = vec![b"http/1.1".to_vec()]; + let cfg = Arc::new(cfg); + self.cache.lock().unwrap().insert(sni.to_string(), Arc::clone(&cfg)); + Ok(cfg) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_ca() -> (String, String) { + // rustls 0.22 ServerConfig::builder() uses crypto::ring::default_provider() + // directly (no process-wide install needed, unlike rustls 0.23). + let m = crate::http_acl::resolve_ca(None, None, true).unwrap().unwrap(); + (m.cert_pem, m.key_pem) + } + + #[test] + fn mints_distinct_configs_per_sni() { + let (cert, key) = test_ca(); + let signer = CertSigner::new(&cert, &key).unwrap(); + let a = signer.server_config_for("api.openai.com").unwrap(); + let b = signer.server_config_for("example.com").unwrap(); + assert!(!Arc::ptr_eq(&a, &b)); + } + + #[test] + fn caches_by_sni() { + let (cert, key) = test_ca(); + let signer = CertSigner::new(&cert, &key).unwrap(); + let a = signer.server_config_for("example.com").unwrap(); + let b = signer.server_config_for("example.com").unwrap(); + assert!(Arc::ptr_eq(&a, &b)); + } +} diff --git a/crates/sandlock-core/src/transparent_proxy/upstream.rs b/crates/sandlock-core/src/transparent_proxy/upstream.rs new file mode 100644 index 0000000..07ef647 --- /dev/null +++ b/crates/sandlock-core/src/transparent_proxy/upstream.rs @@ -0,0 +1,50 @@ +// Forwards an allowed request to the real upstream server: HTTPS via rustls +// validating the host's real system roots, or plaintext HTTP. Returns the +// upstream response with a boxed body. + +use http_body_util::combinators::BoxBody; +use http_body_util::BodyExt; +use hyper::body::{Bytes, Incoming}; +use hyper::{Request, Response}; +use hyper_util::client::legacy::Client; +use hyper_util::rt::TokioExecutor; + +type BoxError = Box; + +/// Shared upstream client. Built once, cloned cheaply. +#[derive(Clone)] +pub(crate) struct Forwarder { + https: Client< + hyper_rustls::HttpsConnector, + BoxBody, + >, +} + +impl Forwarder { + pub(crate) fn new() -> std::io::Result { + // with_native_roots validates upstream against the host's system trust + // store (default feature native-tokio). Returns io::Result. + let connector = hyper_rustls::HttpsConnectorBuilder::new() + .with_native_roots()? + .https_or_http() + .enable_http1() + .build(); + let https = Client::builder(TokioExecutor::new()).build(connector); + Ok(Self { https }) + } + + /// Forward `req` (which must already carry an absolute URI with scheme/host) + /// and return the upstream response with a boxed body. + pub(crate) async fn forward( + &self, + req: Request>, + ) -> Result>, BoxError> { + let resp = self.https.request(req).await?; + Ok(resp.map(|b| b.map_err(|e| Box::new(e) as BoxError).boxed())) + } +} + +/// Adapt a hyper `Incoming` body to the boxed body type used end to end. +pub(crate) fn box_incoming(body: Incoming) -> BoxBody { + body.map_err(|e| Box::new(e) as BoxError).boxed() +} diff --git a/crates/sandlock-core/tests/integration.rs b/crates/sandlock-core/tests/integration.rs index 5c90f88..331299f 100644 --- a/crates/sandlock-core/tests/integration.rs +++ b/crates/sandlock-core/tests/integration.rs @@ -57,3 +57,6 @@ mod test_http_acl; #[path = "integration/test_handlers.rs"] mod test_handlers; + +#[path = "integration/test_http_inject_ca.rs"] +mod test_http_inject_ca; diff --git a/crates/sandlock-core/tests/integration/test_http_inject_ca.rs b/crates/sandlock-core/tests/integration/test_http_inject_ca.rs new file mode 100644 index 0000000..e959ed8 --- /dev/null +++ b/crates/sandlock-core/tests/integration/test_http_inject_ca.rs @@ -0,0 +1,54 @@ +// Verifies http_inject_ca splices the generated CA into a declared trust +// bundle as seen by the workload. When the sandbox has HTTP ACL rules plus an +// inject-ca path, the seccomp openat handler returns a memfd containing the +// original file contents followed by the CA public cert. Running `cat` on the +// declared bundle therefore prints the sentinel original content plus a +// BEGIN CERTIFICATE block. Requires a Landlock-capable kernel. + +use sandlock_core::Sandbox; + +#[tokio::test] +async fn inject_ca_appends_to_declared_bundle() { + let dir = std::env::temp_dir().join(format!( + "sandlock-test-inject-ca-{}", + std::process::id() + )); + std::fs::create_dir_all(&dir).unwrap(); + let bundle = dir.join("bundle.pem"); + std::fs::write(&bundle, b"ORIGINAL-BUNDLE\n").unwrap(); + let bundle_str = bundle.to_str().unwrap().to_string(); + + let policy = Sandbox::builder() + .fs_read("/usr") + .fs_read("/lib") + .fs_read_if_exists("/lib64") + .fs_read("/bin") + .fs_read("/etc") + .fs_read("/proc") + .fs_read("/dev") + .fs_read(dir.to_str().unwrap()) + .http_allow("GET */*") + .http_inject_ca(bundle_str.clone()) + .build() + .unwrap(); + + let result = policy + .clone() + .with_name("test") + .run(&["cat", &bundle_str]) + .await + .unwrap(); + assert!(result.success(), "cat should succeed, exit={:?}", result.code()); + + let out = String::from_utf8_lossy(result.stdout.as_deref().unwrap_or_default()); + assert!( + out.contains("ORIGINAL-BUNDLE"), + "original bundle content preserved, got: {out}" + ); + assert!( + out.contains("BEGIN CERTIFICATE"), + "CA cert appended to bundle, got: {out}" + ); + + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/sandlock-ffi/include/sandlock.h b/crates/sandlock-ffi/include/sandlock.h index 86385d2..c8be237 100644 --- a/crates/sandlock-ffi/include/sandlock.h +++ b/crates/sandlock-ffi/include/sandlock.h @@ -440,6 +440,19 @@ sandlock_builder_t *sandlock_sandbox_builder_http_ca(sandlock_builder_t *b, cons */ sandlock_builder_t *sandlock_sandbox_builder_http_key(sandlock_builder_t *b, const char *path); +/** + * # Safety + * `b` and `path` must be valid pointers. + */ +sandlock_builder_t *sandlock_sandbox_builder_http_inject_ca(sandlock_builder_t *b, + const char *path); + +/** + * # Safety + * `b` and `path` must be valid pointers. + */ +sandlock_builder_t *sandlock_sandbox_builder_http_ca_out(sandlock_builder_t *b, const char *path); + /** * # Safety * `b` must be a valid builder pointer. diff --git a/crates/sandlock-ffi/src/lib.rs b/crates/sandlock-ffi/src/lib.rs index ca9526a..681b36d 100644 --- a/crates/sandlock-ffi/src/lib.rs +++ b/crates/sandlock-ffi/src/lib.rs @@ -433,6 +433,32 @@ pub unsafe extern "C" fn sandlock_sandbox_builder_http_key( Box::into_raw(Box::new(builder.http_key(path))) } +/// # Safety +/// `b` and `path` must be valid pointers. +#[no_mangle] +pub unsafe extern "C" fn sandlock_sandbox_builder_http_inject_ca( + b: *mut SandboxBuilder, + path: *const c_char, +) -> *mut SandboxBuilder { + if b.is_null() || path.is_null() { return b; } + let path = CStr::from_ptr(path).to_str().unwrap_or(""); + let builder = *Box::from_raw(b); + Box::into_raw(Box::new(builder.http_inject_ca(path))) +} + +/// # Safety +/// `b` and `path` must be valid pointers. +#[no_mangle] +pub unsafe extern "C" fn sandlock_sandbox_builder_http_ca_out( + b: *mut SandboxBuilder, + path: *const c_char, +) -> *mut SandboxBuilder { + if b.is_null() || path.is_null() { return b; } + let path = CStr::from_ptr(path).to_str().unwrap_or(""); + let builder = *Box::from_raw(b); + Box::into_raw(Box::new(builder.http_ca_out(path))) +} + // ---------------------------------------------------------------- // Sandbox Builder — isolation & determinism // ---------------------------------------------------------------- diff --git a/docs/sandbox-reference.md b/docs/sandbox-reference.md index 02091a8..6ce1f30 100644 --- a/docs/sandbox-reference.md +++ b/docs/sandbox-reference.md @@ -64,10 +64,12 @@ sandbox = Sandbox( ```toml [config] -http_ca = "/path/to/ca.pem" -http_key = "/path/to/ca.key" -fs_storage = "/var/lib/sandlock" -workdir = "/opt/project" +http_ca = "/path/to/ca.pem" +http_key = "/path/to/ca.key" +http_inject_ca = ["/etc/ssl/certs/ca-certificates.crt"] +http_ca_out = "/tmp/sandlock-ca.pem" +fs_storage = "/var/lib/sandlock" +workdir = "/opt/project" [determinism] random_seed = 42 @@ -127,14 +129,25 @@ Top-level configuration for the supervisor and COW workspace. | ------------ | ----------- | ------------- | ------- | ---------------------------------------------------------------------------------------- | | `http_ca` | `http_ca` | `str \| None` | `None` | PEM CA certificate path for HTTPS MITM. When set, port `443` is added to `http_ports`. | | `http_key` | `http_key` | `str \| None` | `None` | PEM CA private key path. Required whenever `http_ca` is set. | +| `http_inject_ca` | `http_inject_ca` | `list[str]` | `[]` | Trust bundle paths to splice the active MITM CA's public cert into at open time. Without `http_ca`, generates an ephemeral CA (private key in memory only, never on disk) and intercepts port `443`. Requires at least one `http_allow` / `http_deny` rule. | +| `http_ca_out` | `http_ca_out` | `str \| None` | `None` | Writes the active CA's public certificate (PEM) to this path; never the private key. Requires at least one `http_allow` / `http_deny` rule. | | `fs_storage` | `fs_storage`| `str \| None` | `None` | Separate storage directory for the seccomp COW upper layer / deltas. | | `workdir` | `workdir` | `str \| None` | `None` | COW root directory. Controls which directory COW tracks; does **not** set the child's working directory. | -HTTPS interception is opt-in: without `http_ca`, port 443 is not -intercepted, and `net_allow host:443` permits raw TLS to the host with -no content inspection. When `http_ca` is set, the CA must be one the -caller has generated and installed into the sandbox's trust store -(typically `/etc/ssl/certs/`). +HTTPS interception is opt-in: without `http_ca` or `http_inject_ca`, +port 443 is not intercepted, and `net_allow host:443` permits raw TLS to +the host with no content inspection. When `http_ca` is set, the CA must +be one the caller has generated and installed into the sandbox's trust +store (typically `/etc/ssl/certs/`). Alternatively, `http_inject_ca` +generates an ephemeral CA (private key kept in memory, never written to +disk) and splices its public cert into each named trust bundle at open +time, so the workload trusts the proxy with no manual install. File +injection covers tools that read a trust file from disk (curl, git, +OpenSSL CLI, Go, Python stdlib ssl, and Python requests / httpx via +certifi's `cacert.pem` if you name that path). Runtimes with a +compiled-in CA list such as Node and Java are not reachable by file +injection; for those use `http_ca_out` to export the public cert and +point the runtime's own env var at it (e.g. `NODE_EXTRA_CA_CERTS`). ## `[determinism]` diff --git a/python/README.md b/python/README.md index add245a..1f81dec 100644 --- a/python/README.md +++ b/python/README.md @@ -127,6 +127,8 @@ denied by default. Block rules are checked first and take precedence. | `http_ports` | `list[int]` | `[80]` | TCP ports to intercept (443 added when `http_ca` is set) | | `http_ca` | `str \| None` | `None` | CA certificate for HTTPS MITM | | `http_key` | `str \| None` | `None` | CA private key for HTTPS MITM | +| `http_inject_ca` | `list[str]` | `[]` | Trust bundle paths to splice the active MITM CA's public cert into at open time. Without `http_ca`, generates an ephemeral CA (key in memory only) and intercepts port 443. Requires an `http_allow` / `http_deny` rule | +| `http_ca_out` | `str \| None` | `None` | Writes the active CA's public certificate (PEM) to this path; never the private key. Requires an `http_allow` / `http_deny` rule | Rule format: `"METHOD host/path"` where method and host can be `*` for wildcard, and path supports trailing `*` for prefix matching. Paths are diff --git a/python/src/sandlock/_profile.py b/python/src/sandlock/_profile.py index 7892861..fee630e 100644 --- a/python/src/sandlock/_profile.py +++ b/python/src/sandlock/_profile.py @@ -52,6 +52,8 @@ "config": { "http_ca": ("http_ca", str), "http_key": ("http_key", str), + "http_inject_ca": ("http_inject_ca", list), + "http_ca_out": ("http_ca_out", str), "fs_storage": ("fs_storage", str), "workdir": ("workdir", str), }, diff --git a/python/src/sandlock/_sdk.py b/python/src/sandlock/_sdk.py index 99d6f5d..7c0a6ce 100644 --- a/python/src/sandlock/_sdk.py +++ b/python/src/sandlock/_sdk.py @@ -94,6 +94,8 @@ def _builder_fn(name, *extra_args): _b_http_port = _builder_fn("sandlock_sandbox_builder_http_port", ctypes.c_uint16) _b_http_ca = _builder_fn("sandlock_sandbox_builder_http_ca", ctypes.c_char_p) _b_http_key = _builder_fn("sandlock_sandbox_builder_http_key", ctypes.c_char_p) +_b_http_inject_ca = _builder_fn("sandlock_sandbox_builder_http_inject_ca", ctypes.c_char_p) +_b_http_ca_out = _builder_fn("sandlock_sandbox_builder_http_ca_out", ctypes.c_char_p) _b_uid = _builder_fn("sandlock_sandbox_builder_uid", ctypes.c_uint32) _b_random_seed = _builder_fn("sandlock_sandbox_builder_random_seed", ctypes.c_uint64) _b_clean_env = _builder_fn("sandlock_sandbox_builder_clean_env", ctypes.c_bool) @@ -993,6 +995,10 @@ def _build_from_policy(policy: PolicyDataclass): b = _b_http_ca(b, _encode(str(policy.http_ca))) if policy.http_key: b = _b_http_key(b, _encode(str(policy.http_key))) + for path in (policy.http_inject_ca or []): + b = _b_http_inject_ca(b, _encode(str(path))) + if policy.http_ca_out: + b = _b_http_ca_out(b, _encode(str(policy.http_ca_out))) if policy.port_remap: b = _b_port_remap(b, True) diff --git a/python/src/sandlock/sandbox.py b/python/src/sandlock/sandbox.py index fc49fe3..dddce9f 100644 --- a/python/src/sandlock/sandbox.py +++ b/python/src/sandlock/sandbox.py @@ -200,6 +200,14 @@ class Sandbox: http_key: str | None = None """PEM CA private key path for HTTPS MITM. Required with http_ca.""" + http_inject_ca: Sequence[str] = field(default_factory=list) + """Trust bundle paths to splice the MITM CA into. Without http_ca this + generates an ephemeral CA and intercepts port 443.""" + + http_ca_out: str | None = None + """Path to write the active MITM CA public certificate (PEM). Never the + private key. Useful for NODE_EXTRA_CA_CERTS and similar.""" + # Resource limits max_memory: str | int | None = None """Memory limit. String like '512M' or int bytes.""" diff --git a/python/tests/test_http_inject_ca.py b/python/tests/test_http_inject_ca.py new file mode 100644 index 0000000..ead7d55 --- /dev/null +++ b/python/tests/test_http_inject_ca.py @@ -0,0 +1,16 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Tests for http_inject_ca / http_ca_out policy fields.""" + +from __future__ import annotations + +from sandlock import Sandbox + + +def test_policy_accepts_inject_ca_fields(): + p = Sandbox( + http_allow=["GET example.com/*"], + http_inject_ca=["/etc/ssl/certs/ca-certificates.crt"], + http_ca_out="/tmp/ca.pem", + ) + assert list(p.http_inject_ca) == ["/etc/ssl/certs/ca-certificates.crt"] + assert p.http_ca_out == "/tmp/ca.pem"