diff --git a/CHANGELOG.md b/CHANGELOG.md index ef3e7068..6163cb1b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,41 @@ ## Unreleased +## [0.8.0-beta.5] - 2026-07-16 + +### Added + +- pishoo builds inherited per-account DHTTP home trees and compiles them into a + static service pipeline for reload execution. + +### Changed + +- Gateway configuration parsing now uses role-aware typed domains, sealed + inherited trees, and typed compound STUN server values. +- Reload snapshots and service plans now enforce exhaustive, immutable parser + and transport contracts before worker activation. + +### Fixed + +- Cascaded and inherited configuration queries preserve typed identity and + registry contracts, including native OS paths for SQLite databases. +- Slash-directory proxy requests redirect before fallback routing is evaluated. +- IP-family parse errors compile cleanly with the release packaging nightly toolchain. + +### Dependencies + +- Release manifests now target `dhttp` v0.6.0-beta.4, including + `dhttp-access` v0.4.0-beta.2 and `dhttp-home` v0.5.0-beta.1 through + the facade; discovery and transport dependencies target `dyns` + v0.7.0-beta.2, `h3x` v0.6.0-beta.4, `dquic` v0.7.0-beta.4, and + `dshell` v0.6.0-beta.3. + +### Components + +- `gateway` v0.8.0-beta.5 +- `pishoo` v0.8.0-beta.5 +- `pishoo-common` v0.5.1-1 (unchanged) + ## [0.8.0-beta.4] - 2026-07-09 ### Added diff --git a/Cargo.lock b/Cargo.lock index a7f0a24a..1884c6a5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -40,15 +40,6 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" -[[package]] -name = "android-build" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9fc9904ad2ad097c3c1cfe2eacaaf0fc24710936fa9ed941cb310b7c6ed2ab7" -dependencies = [ - "windows-sys 0.52.0", -] - [[package]] name = "android_system_properties" version = "0.1.5" @@ -125,9 +116,9 @@ dependencies = [ [[package]] name = "arrayvec" -version = "0.7.7" +version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f02882884d3e1bc524fb12c79f107f6ad0e1cfd498c536ffb494301740995dfe" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" [[package]] name = "asn1-rs" @@ -153,7 +144,7 @@ checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", "synstructure", ] @@ -165,7 +156,7 @@ checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -211,7 +202,7 @@ checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -222,7 +213,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -348,14 +339,14 @@ checksum = "3ca6739863c590881f038d033a146c51ddae239186a4327014839fd864f44ed5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] name = "bitflags" -version = "2.13.0" +version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" dependencies = [ "serde_core", ] @@ -421,7 +412,7 @@ dependencies = [ "proc-macro2", "quote", "rustversion", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -445,7 +436,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -459,9 +450,9 @@ dependencies = [ [[package]] name = "bstr" -version = "1.12.3" +version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cee35f73844aa3014bb606320a6c1f010249dbdf43342fe54b5a4f6a8ed4b79" +checksum = "1f7dc094d718f2e1c1559ad110e27eeaae14a5465d3d56dd6dbd793079fbd530" dependencies = [ "memchr", "serde_core", @@ -515,7 +506,7 @@ checksum = "89385e82b5d1821d2219e0b095efa2cc1f246cbf99080f3be46a1a85c0d392d9" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -526,18 +517,18 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.12.0" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" dependencies = [ "serde", ] [[package]] name = "cc" -version = "1.2.65" +version = "1.2.67" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" +checksum = "e17dd265a7d0f31ef544e1b20e03add05d3b45b491b633b10d67145d2acc1a38" dependencies = [ "find-msvc-tools", "shlex", @@ -588,9 +579,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.6.1" +version = "4.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +checksum = "dd059f9da4f5c36b3787f65d38ccaab1cc315f07b01f89abc8359ee6a8205011" dependencies = [ "clap_builder", "clap_derive", @@ -598,9 +589,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.6.0" +version = "4.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b" dependencies = [ "anstream", "anstyle", @@ -617,7 +608,7 @@ dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -799,27 +790,27 @@ dependencies = [ [[package]] name = "crossbeam-channel" -version = "0.5.15" +version = "0.5.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-queue" -version = "0.3.12" +version = "0.3.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +checksum = "803d13fb3b09d88be9f4dbc29062c66b19bf7170867ceb746d2a8689bf6c7a26" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-utils" -version = "0.8.21" +version = "0.8.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" [[package]] name = "crypto-common" @@ -871,7 +862,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -884,7 +875,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -895,7 +886,7 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ "darling_core 0.20.11", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -906,7 +897,7 @@ checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ "darling_core 0.23.0", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -981,7 +972,7 @@ dependencies = [ "darling 0.20.11", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -991,7 +982,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" dependencies = [ "derive_builder_core", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -1013,21 +1004,22 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn 2.0.118", + "syn 2.0.119", "unicode-xid", ] [[package]] name = "dhttp" -version = "0.5.0-beta.3" +version = "0.6.0-beta.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dedfca271d296f9c0d9fee052d337495aacb4d1b49eb4b29f09af9cd446757b1" +checksum = "63d2eb9b7468efa85ff15f00c6e876cee46c2b3c018664b7158b407955d6e931" dependencies = [ "bon", "bytes", "dhttp-access", "dhttp-home", "dhttp-identity", + "dhttp-log", "dyns", "futures", "h3x", @@ -1042,9 +1034,9 @@ dependencies = [ [[package]] name = "dhttp-access" -version = "0.4.0-beta.1" +version = "0.4.0-beta.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbcd9d2805ace5bcac3c112e14b9df13d02f38f2a8a77897acb1cec536fe0715" +checksum = "de04ba6e93d5e47b75d8381377a5d710dac52baed5790c56ec93a4e31f7e2756" dependencies = [ "chrono", "derive_more", @@ -1064,9 +1056,9 @@ dependencies = [ [[package]] name = "dhttp-home" -version = "0.4.0-beta.1" +version = "0.5.0-beta.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3af7727cd3f09f4f81bb928c3f7e77587a1a622796413bcbb8348e9a51b24f49" +checksum = "5e64e413db3551f260e158d18dcd73906dbebf938399dd3bcb8a286e612fa0ee" dependencies = [ "dhttp-identity", "dirs", @@ -1095,6 +1087,20 @@ dependencies = [ "x509-parser", ] +[[package]] +name = "dhttp-log" +version = "0.1.0-beta.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77e7070b6a8ead2399f5d8f959cd8c8b10ccd769651ed67e0b36d54cc4bce35e" +dependencies = [ + "chrono", + "dhttp-identity", + "http", + "sha2 0.10.9", + "snafu", + "x509-parser", +] + [[package]] name = "digest" version = "0.10.7" @@ -1159,7 +1165,7 @@ checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -1181,9 +1187,9 @@ checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" [[package]] name = "dquic" -version = "0.7.0-beta.2" +version = "0.7.0-beta.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2945f40d972d12cf06777eddf20b7104378d5a614dcca56800286cff370f489" +checksum = "02b6fd0cd4d134f74c5da30d9ca745a8875e56b139892a37f7806aeac150fb32" dependencies = [ "arc-swap", "dashmap", @@ -1199,9 +1205,9 @@ dependencies = [ [[package]] name = "dshell" -version = "0.6.0-beta.2" +version = "0.6.0-beta.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "024bc3e3dbed799db8ab771e51851a741de22c1caba2c82f98ef557a5be34d5a" +checksum = "218694a1b4424c69b9b3b5c7416cfceb6c39e35b20968caef61bf9b3c953f921" dependencies = [ "base64 0.22.1", "bytes", @@ -1229,9 +1235,9 @@ checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" [[package]] name = "dyns" -version = "0.6.0-beta.3" +version = "0.7.0-beta.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd8916a250117bdecaa9aa721465c646da282dc41523361b63c8cd01693c6869" +checksum = "7186611a2d3156f9de8a836624c8da907e59bb0f198f74b4cf5b675cae17b68d" dependencies = [ "base64 0.22.1", "bitfield-struct", @@ -1247,7 +1253,7 @@ dependencies = [ "http-body-util", "libc", "nom 8.0.0", - "rand 0.10.1", + "rand 0.10.2", "reqwest", "ring", "rustls", @@ -1288,7 +1294,7 @@ dependencies = [ "once_cell", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -1481,7 +1487,7 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -1515,7 +1521,7 @@ dependencies = [ [[package]] name = "gateway" -version = "0.8.0-beta.4" +version = "0.8.0-beta.5" dependencies = [ "async-compression", "axum", @@ -1529,6 +1535,7 @@ dependencies = [ "glob", "h3x", "http", + "http-body", "http-body-util", "hyper", "hyper-util", @@ -1540,6 +1547,7 @@ dependencies = [ "rustls", "rustls-pemfile", "serde", + "serde_json", "snafu", "socket2", "tokio", @@ -1631,7 +1639,7 @@ checksum = "6cf442baaabe4213ce7d1239afc26c039180b6456da2cededa316ae2c8a77a77" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -1642,9 +1650,9 @@ checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" [[package]] name = "globset" -version = "0.4.18" +version = "0.4.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" +checksum = "e47d37d2ae4464254884b60ab7071be2b876a9c35b696bd018ddcc76847309cd" dependencies = [ "aho-corasick", "bstr", @@ -1674,9 +1682,9 @@ dependencies = [ [[package]] name = "h3x" -version = "0.6.0-beta.3" +version = "0.6.0-beta.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7e242a82f0df4f9c7a8a83440a9943468ff99bd3a4f004c47cc717bb155a7a3" +checksum = "54d7e49e7fe31d63059488ac6635d663ba53a8211657faae18d5940757e034ae" dependencies = [ "arc-swap", "async-channel", @@ -1835,9 +1843,9 @@ dependencies = [ [[package]] name = "http-body" -version = "1.0.1" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" dependencies = [ "bytes", "http", @@ -1845,9 +1853,9 @@ dependencies = [ [[package]] name = "http-body-util" -version = "0.1.3" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" dependencies = [ "bytes", "futures-core", @@ -1870,9 +1878,9 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "humantime" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" +checksum = "15cdd26707701c53297e2fa6afb323d55fbc1d0810c3aec078ae3ef0424c3c15" [[package]] name = "hybrid-array" @@ -2122,7 +2130,7 @@ checksum = "c727f80bfa4a6c6e2508d2f05b6f4bfce242030bd88ed15ae5331c5b5d30fba7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -2224,7 +2232,7 @@ dependencies = [ "quote", "rustc_version", "simd_cesu8", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -2252,7 +2260,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" dependencies = [ "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -2387,9 +2395,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.8.2" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "memoffset" @@ -2424,9 +2432,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" dependencies = [ "libc", "wasi", @@ -2450,7 +2458,7 @@ checksum = "4568f25ccbd45ab5d5603dc34318c1ec56b117531781260002151b8530a9f931" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -2518,19 +2526,6 @@ dependencies = [ "log", ] -[[package]] -name = "netwatcher" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a73c134b9d8ca27ba209646851858cd112fb543b6ecf48019c64b29c91b3b18a" -dependencies = [ - "android-build", - "jni 0.22.4", - "ndk-context", - "nix 0.31.3", - "windows", -] - [[package]] name = "nix" version = "0.29.0" @@ -2593,9 +2588,9 @@ dependencies = [ [[package]] name = "num-bigint" -version = "0.4.6" +version = "0.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" dependencies = [ "num-integer", "num-traits", @@ -2612,7 +2607,7 @@ dependencies = [ "num-integer", "num-iter", "num-traits", - "rand 0.8.6", + "rand 0.8.7", "smallvec", "zeroize", ] @@ -2634,11 +2629,10 @@ dependencies = [ [[package]] name = "num-iter" -version = "0.1.45" +version = "0.1.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" dependencies = [ - "autocfg", "num-integer", "num-traits", ] @@ -2810,7 +2804,7 @@ dependencies = [ "proc-macro2", "proc-macro2-diagnostics", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -2947,7 +2941,7 @@ checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -2958,7 +2952,7 @@ checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "pishoo" -version = "0.8.0-beta.4" +version = "0.8.0-beta.5" dependencies = [ "axum", "clap", @@ -3024,9 +3018,9 @@ checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" [[package]] name = "plist" -version = "1.9.0" +version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1" +checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85" dependencies = [ "base64 0.22.1", "indexmap 2.14.0", @@ -3080,7 +3074,7 @@ dependencies = [ "proc-macro2", "quote", "regex", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -3157,7 +3151,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -3188,7 +3182,7 @@ dependencies = [ "proc-macro-error-attr2", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -3208,7 +3202,7 @@ checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", "version_check", "yansi", ] @@ -3233,7 +3227,7 @@ dependencies = [ "itertools", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -3282,7 +3276,7 @@ checksum = "7347867d0a7e1208d93b46767be83e2b8f978c3dad35f775ac8d8847551d6fe1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -3299,7 +3293,7 @@ dependencies = [ "getset", "nom 8.0.0", "qmacro", - "rand 0.10.1", + "rand 0.10.2", "rustls", "serde", "smallvec", @@ -3316,7 +3310,7 @@ checksum = "92ff2c0dd91cf0e4cacca9e3b1ab8c2bd4bff93c17ba39f509e53f238a1fda42" dependencies = [ "qbase", "qevent", - "rand 0.10.1", + "rand 0.10.2", "thiserror 2.0.18", "tokio", "tracing", @@ -3324,9 +3318,9 @@ dependencies = [ [[package]] name = "qconnection" -version = "0.7.0-beta.1" +version = "0.8.0-beta.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bea5f6581e4d228dfe4c2a973c137f2a405d639ed0cdbdd75fe3baf8d642db89" +checksum = "bd0e696c1bd3b8525f744848368e57f54f7be45464763b07e72bd4692d4a6065" dependencies = [ "bytes", "dashmap", @@ -3383,9 +3377,9 @@ dependencies = [ [[package]] name = "qinterface" -version = "0.6.0" +version = "0.7.0-beta.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3d6859183d5d4547470fd3ea641d700b79e416269f229b03b0c8709e2c98575" +checksum = "ba79cb72588e962cd079efe8fb301d2dc8182edf4d495222c8fa93f0e2d31341" dependencies = [ "bytes", "dashmap", @@ -3393,7 +3387,6 @@ dependencies = [ "futures", "http", "netdev", - "netwatcher", "parking_lot", "qbase", "qevent", @@ -3414,7 +3407,7 @@ dependencies = [ "darling 0.23.0", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -3446,9 +3439,9 @@ dependencies = [ [[package]] name = "qtraversal" -version = "0.7.0-beta.2" +version = "0.7.0-beta.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16fc8219fe16d92319e4ccd1c41ea87661a27abfacc4d40e77273b55fa384cf9" +checksum = "854dbea8936703533d83456b6431bcad3996cde0b12f255e9cc582d925dee7aa" dependencies = [ "bon", "bytes", @@ -3459,7 +3452,7 @@ dependencies = [ "qevent", "qinterface", "qresolve", - "rand 0.10.1", + "rand 0.10.2", "smallvec", "snafu", "thiserror 2.0.18", @@ -3470,9 +3463,9 @@ dependencies = [ [[package]] name = "qudp" -version = "0.6.0" +version = "0.7.0-beta.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ce9009cdd2de1ef116833beeccf1a5426a0a8f426ee836fe6c05cba4322b915" +checksum = "85ad34a609083ce5fd9f6972d6d6ec7d5fb1fa2af9a87d6b7f19ec72117a5a50" dependencies = [ "bytes", "cfg-if", @@ -3487,9 +3480,9 @@ dependencies = [ [[package]] name = "quick-xml" -version = "0.39.4" +version = "0.41.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" dependencies = [ "memchr", ] @@ -3523,18 +3516,18 @@ checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" [[package]] name = "rancor" -version = "0.1.1" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a063ea72381527c2a0561da9c80000ef822bdd7c3241b1cc1b12100e3df081ee" +checksum = "daff8b7b3ccf5f7ba270b3e7a0a4d4c701c5797e38dec27c7e2c3dbb830fed1c" dependencies = [ "ptr_meta 0.3.1", ] [[package]] name = "rand" -version = "0.8.6" +version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" dependencies = [ "libc", "rand_chacha 0.3.1", @@ -3543,9 +3536,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.9.4" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" dependencies = [ "rand_chacha 0.9.0", "rand_core 0.9.5", @@ -3553,9 +3546,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.10.1" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ "chacha20", "getrandom 0.4.3", @@ -3666,14 +3659,14 @@ checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] name = "regex" -version = "1.12.4" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" dependencies = [ "aho-corasick", "memchr", @@ -3683,9 +3676,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.14" +version = "0.4.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" dependencies = [ "aho-corasick", "memchr", @@ -3708,7 +3701,7 @@ dependencies = [ "byteorder", "bytes", "futures", - "rand 0.9.4", + "rand 0.9.5", "remoc_macro", "serde", "tokio", @@ -3725,7 +3718,7 @@ checksum = "d89479d9d87f65ef573faf0167dd0a9f40d3a63fd95e7a2935d662fa57dbc30d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -3739,9 +3732,9 @@ dependencies = [ [[package]] name = "rend" -version = "0.5.3" +version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cadadef317c2f20755a64d7fdc48f9e7178ee6b0e1f7fce33fa60f1d68a276e6" +checksum = "663ba70707f96e871406fe10d68128412e619b06d1d47cb91c3a4c6501176240" dependencies = [ "bytecheck 0.8.2", ] @@ -3820,9 +3813,9 @@ dependencies = [ [[package]] name = "rkyv" -version = "0.8.16" +version = "0.8.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73389e0c99e664f919275ab5b5b0471391fe9a8de61e1dff9b1eaf56a90f16e3" +checksum = "815cc8a37159a463064825246cadb07961e25cd9885908606f6d08a98d8f8874" dependencies = [ "bytecheck 0.8.2", "bytes", @@ -3831,8 +3824,8 @@ dependencies = [ "munge", "ptr_meta 0.3.1", "rancor", - "rend 0.5.3", - "rkyv_derive 0.8.16", + "rend 0.5.4", + "rkyv_derive 0.8.17", "smallvec", "tinyvec", "uuid", @@ -3851,13 +3844,13 @@ dependencies = [ [[package]] name = "rkyv_derive" -version = "0.8.16" +version = "0.8.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d2ed0b54125315fb36bd021e82d314d1c126548f871634b483f46b31d13cac6" +checksum = "c0ed1a78a1b19d184b0daa629dd9a024573173ec7d485b287cb369fb3607cc1c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -3890,7 +3883,7 @@ dependencies = [ "borsh", "bytes", "num-traits", - "rand 0.8.6", + "rand 0.8.7", "rkyv 0.7.46", "serde", "serde_json", @@ -3930,9 +3923,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.41" +version = "0.23.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" +checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138" dependencies = [ "log", "once_cell", @@ -4013,9 +4006,9 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.22" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" [[package]] name = "ryu" @@ -4081,7 +4074,7 @@ dependencies = [ "proc-macro-error2", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -4144,7 +4137,7 @@ dependencies = [ "proc-macro2", "quote", "sea-bae", - "syn 2.0.118", + "syn 2.0.119", "unicode-ident", ] @@ -4207,7 +4200,7 @@ dependencies = [ "heck 0.4.1", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", "thiserror 2.0.18", ] @@ -4233,7 +4226,7 @@ dependencies = [ "heck 0.4.1", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -4298,7 +4291,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -4364,14 +4357,14 @@ dependencies = [ "darling 0.23.0", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] name = "sha1" -version = "0.10.6" +version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" dependencies = [ "cfg-if", "cpufeatures 0.2.17", @@ -4437,15 +4430,15 @@ dependencies = [ [[package]] name = "simd-adler32" -version = "0.3.9" +version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" [[package]] name = "simd_cesu8" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" dependencies = [ "rustc_version", "simdutf8", @@ -4480,7 +4473,7 @@ checksum = "5e7ac95ddfb355a82e23e3bdce91b646b847307bb481b1f7bc7f44942461d571" dependencies = [ "either", "paste", - "rkyv 0.8.16", + "rkyv 0.8.17", "serde", "smallvec", ] @@ -4513,14 +4506,14 @@ dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] name = "socket2" -version = "0.6.4" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", "windows-sys 0.61.2", @@ -4528,9 +4521,9 @@ dependencies = [ [[package]] name = "spin" -version = "0.9.8" +version = "0.9.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" dependencies = [ "lock_api", ] @@ -4609,7 +4602,7 @@ dependencies = [ "quote", "sqlx-core", "sqlx-macros-core", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -4632,7 +4625,7 @@ dependencies = [ "sqlx-mysql", "sqlx-postgres", "sqlx-sqlite", - "syn 2.0.118", + "syn 2.0.119", "tokio", "url", ] @@ -4668,7 +4661,7 @@ dependencies = [ "memchr", "once_cell", "percent-encoding", - "rand 0.8.6", + "rand 0.8.7", "rsa", "rust_decimal", "serde", @@ -4712,7 +4705,7 @@ dependencies = [ "memchr", "num-bigint", "once_cell", - "rand 0.8.6", + "rand 0.8.7", "rust_decimal", "serde", "serde_json", @@ -4814,9 +4807,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.118" +version = "2.0.119" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" dependencies = [ "proc-macro2", "quote", @@ -4840,7 +4833,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -4896,7 +4889,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -4907,14 +4900,14 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] name = "thread_local" -version = "1.1.9" +version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" dependencies = [ "cfg-if", ] @@ -4963,9 +4956,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.11.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" dependencies = [ "tinyvec_macros", ] @@ -5002,7 +4995,7 @@ checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -5042,9 +5035,9 @@ dependencies = [ [[package]] name = "toml" -version = "1.1.2+spec-1.1.0" +version = "1.1.3+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +checksum = "53c96ecdfa941c8fc4fcaed14f99ada8ebed502eef533015095a07e3301d4c3c" dependencies = [ "indexmap 2.14.0", "serde_core", @@ -5066,9 +5059,9 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.25.12+spec-1.1.0" +version = "0.25.13+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" dependencies = [ "indexmap 2.14.0", "toml_datetime", @@ -5087,9 +5080,9 @@ dependencies = [ [[package]] name = "toml_writer" -version = "1.1.1+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" [[package]] name = "tonic" @@ -5213,7 +5206,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -5342,9 +5335,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.23.4" +version = "1.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53" +checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" dependencies = [ "getrandom 0.4.3", "js-sys", @@ -5453,7 +5446,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", "wasm-bindgen-shared", ] @@ -5550,27 +5543,6 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" -[[package]] -name = "windows" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" -dependencies = [ - "windows-collections", - "windows-core", - "windows-future", - "windows-numerics", -] - -[[package]] -name = "windows-collections" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" -dependencies = [ - "windows-core", -] - [[package]] name = "windows-core" version = "0.62.2" @@ -5584,17 +5556,6 @@ dependencies = [ "windows-strings", ] -[[package]] -name = "windows-future" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" -dependencies = [ - "windows-core", - "windows-link", - "windows-threading", -] - [[package]] name = "windows-implement" version = "0.60.2" @@ -5603,7 +5564,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -5614,7 +5575,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -5623,16 +5584,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" -[[package]] -name = "windows-numerics" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" -dependencies = [ - "windows-core", - "windows-link", -] - [[package]] name = "windows-registry" version = "0.6.1" @@ -5744,15 +5695,6 @@ dependencies = [ "windows_x86_64_msvc 0.52.6", ] -[[package]] -name = "windows-threading" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" -dependencies = [ - "windows-link", -] - [[package]] name = "windows_aarch64_gnullvm" version = "0.42.2" @@ -5887,9 +5829,9 @@ checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] name = "winnow" -version = "1.0.3" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" dependencies = [ "memchr", ] @@ -5968,28 +5910,28 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", "synstructure", ] [[package]] name = "zerocopy" -version = "0.8.52" +version = "0.8.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" +checksum = "b7cbbc0a705a0fd05cc3676525980d2bf5a9bc4adac6d6475209a7887cf59d19" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.52" +version = "0.8.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" +checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -6009,7 +5951,7 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", "synstructure", ] @@ -6049,11 +5991,11 @@ checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] name = "zmij" -version = "1.0.21" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml index 6cd61799..6004b0cd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -48,7 +48,7 @@ tower = { version = "0.5", default-features = false, features = ["util"] } tower-service = "0.3" # --- QUIC Implementation --- -h3x = { version = "0.6.0-beta.3" } +h3x = { version = "0.6.0-beta.4" } # --- IPC / RPC --- remoc = { version = "0.18", default-features = false, features = [ @@ -60,7 +60,7 @@ remoc = { version = "0.18", default-features = false, features = [ ] } # --- DShell --- -dshell = { version = "0.6.0-beta.2" } +dshell = { version = "0.6.0-beta.3" } # --- Security & TLS --- getrandom = "0.4" @@ -89,7 +89,7 @@ form_urlencoded = "1" percent-encoding = "2" socket2 = "0.6" x509-parser = "0.18" -dhttp = { version = "0.5.0-beta.3" } +dhttp = { version = "0.6.0-beta.4" } # --- System Interaction --- libc = "0.2" diff --git a/gateway/Cargo.toml b/gateway/Cargo.toml index 30f788ec..b8a7745d 100644 --- a/gateway/Cargo.toml +++ b/gateway/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "gateway" -version = "0.8.0-beta.4" +version = "0.8.0-beta.5" edition = "2024" license = "Apache-2.0" @@ -27,6 +27,7 @@ bytes = { workspace = true } http = { workspace = true } url = { workspace = true } http-body-util = { workspace = true } +http-body = { workspace = true } hyper = { workspace = true } hyper-util = { workspace = true } axum = { workspace = true } @@ -57,6 +58,9 @@ async-compression = { version = "0.4", features = ["tokio", "gzip"] } chrono = "0.4" tracing-appender = { workspace = true } +[dev-dependencies] +serde_json = { workspace = true } + # --- Platform Specific Dependencies --- [target.'cfg(any(target_os = "android", target_env = "ohos"))'.dependencies] pnet = "0.35.0" diff --git a/gateway/examples/forward.rs b/gateway/examples/forward.rs index 1dbbdefc..9257f1a9 100644 --- a/gateway/examples/forward.rs +++ b/gateway/examples/forward.rs @@ -2,7 +2,7 @@ use std::sync::Arc; use dhttp::endpoint::Endpoint; use gateway::{forward, parse}; -use snafu::{FromString, OptionExt, ResultExt, Whatever, whatever}; +use snafu::{FromString, ResultExt, Whatever, whatever}; use tokio::task::JoinSet; use tracing::Instrument; @@ -27,14 +27,8 @@ async fn main() -> Result<(), Whatever> { std::process::exit(1); }; let config_file = std::path::Path::new(config_file); - let registry = parse::default_registry(); - let config = match parse::load_config_file( - config_file, - ®istry, - parse::registry::BuildOptions::default(), - ) - .await - { + let mut parser = parse::TypedConfigParser::new(); + let config = match parse::load_root_config_file(&mut parser, config_file, None).await { Ok(config) => config, Err(failure) => { tracing::error!( @@ -51,14 +45,7 @@ async fn main() -> Result<(), Whatever> { // TODO 对于绑定到 [::]:0 的监听, 应该进行特殊操作, 每个 server 都单独绑定到 不同端口 上 - let pishoo = config - .root - .children("pishoo") - .ok() - .and_then(|pishoo| pishoo.first()) - .whatever_context("no pishoo block found")?; - - let proxies = pishoo.children_optional("proxy").to_vec(); + let proxies = config.pishoo().forward_proxies(); if proxies.is_empty() { whatever!("no proxy found in pishoo configuration"); } @@ -73,7 +60,7 @@ async fn main() -> Result<(), Whatever> { let mut handler = JoinSet::new(); - for proxy in proxies { + for proxy in proxies.iter().cloned() { let span = tracing::info_span!("forward_example_proxy"); let client = client.clone(); handler.spawn( diff --git a/gateway/src/command.rs b/gateway/src/command.rs index f99365ec..511a9d87 100644 --- a/gateway/src/command.rs +++ b/gateway/src/command.rs @@ -1,7 +1,3 @@ -use std::sync::Arc; - -use crate::parse::{document::ConfigNode, types::StringList}; - pub(crate) mod acl; pub(crate) mod file; pub(crate) mod header; @@ -11,22 +7,9 @@ pub use file::IndexError; pub(crate) use file::index; pub(crate) use header::{add_header, content_type, proxy_set_header}; -pub(crate) fn acl(node: &Arc) -> acl::Acl { - let allow_vec = node - .get::("allow") - .ok() - .flatten() - .map(|allow| allow.0.clone()) - .unwrap_or_default(); - let deny_vec = node - .get::("deny") - .ok() - .flatten() - .map(|deny| deny.0.clone()) - .unwrap_or_default(); - - let allow = acl::parse_host_matches(&allow_vec); - let deny = acl::parse_host_matches(&deny_vec); +pub(crate) fn acl(allow: &[String], deny: &[String]) -> acl::Acl { + let allow = acl::parse_host_matches(allow); + let deny = acl::parse_host_matches(deny); acl::Acl::new(allow, deny) } diff --git a/gateway/src/command/file.rs b/gateway/src/command/file.rs index 6b0390ac..251a0d69 100644 --- a/gateway/src/command/file.rs +++ b/gateway/src/command/file.rs @@ -7,7 +7,7 @@ use std::{ use snafu::{ResultExt, Snafu}; use tokio::fs::File; -use crate::parse::{document::ConfigNode, types::StringList}; +use crate::parse::config::LocationConfig; #[derive(Debug, Snafu)] #[snafu(visibility(pub(crate)))] @@ -87,7 +87,7 @@ pub(crate) fn safe_relative_path(path: &str) -> Result { /// Returns an `io::Error` if the path doesn't exist, if it's a directory without a /// suitable index file, or if file/metadata operations fail. pub(crate) async fn index( - node: &Arc, + node: &Arc, file_path: impl Into, ) -> Result<(File, u64, PathBuf), IndexError> { let file_path = file_path.into(); @@ -116,9 +116,7 @@ pub(crate) async fn index( let base_dir_path = file_path.clone(); let index_files = node - .get::("index") - .ok() - .flatten() + .index() .map(|index| index.0.clone()) .ok_or(IndexError::MissingIndexFiles)?; @@ -153,25 +151,10 @@ mod tests { }; use super::*; - use crate::parse::{ - document::ConfigNode, - registry::context, - source::{SourceId, SourceSpan}, - types::StringList, - value::TypedValue, - }; + use crate::parse::tests::parse_location; - fn node_with_indexes(indexes: Vec<&str>) -> Arc { - let span = SourceSpan::new(SourceId(0), 0, 0); - let mut node = ConfigNode::new(context::LOCATION, None, span); - node.insert_slot( - "index", - TypedValue::new( - StringList(indexes.into_iter().map(str::to_owned).collect()), - span, - ), - ); - Arc::new(node) + fn node_with_indexes(indexes: Vec<&str>) -> Arc { + parse_location(&format!("index {};", indexes.join(" "))).unwrap() } fn temp_dir(name: &str) -> PathBuf { diff --git a/gateway/src/command/header.rs b/gateway/src/command/header.rs index 4a81d6e2..e2e0fdbd 100644 --- a/gateway/src/command/header.rs +++ b/gateway/src/command/header.rs @@ -4,18 +4,16 @@ use http::{HeaderValue, Request, header, response::Parts}; use super::variables; use crate::parse::{ - document::ConfigNode, - types::{DefaultType, HeaderRule, HeaderRules, MimeTypes, ProxyPass}, + config::LocationConfig, + types::{HeaderRule, HeaderRules}, }; -pub(crate) fn proxy_set_header(node: &Arc, req: Request) -> Request { +pub(crate) fn proxy_set_header(node: &Arc, req: Request) -> Request { let (mut parts, body) = req.into_parts(); // 默认将 Host 变更为 proxy_pass target let proxy_host = node - .get::("proxy_pass") - .ok() - .flatten() + .proxy_pass() .map(|proxy_pass| proxy_pass.proxy_host.clone()); if let Some(host) = proxy_host { parts.headers.insert( @@ -30,7 +28,7 @@ pub(crate) fn proxy_set_header(node: &Arc, req: Request) -> Re .headers .insert(header::CONNECTION, HeaderValue::from_static("close")); // 遍历 proxy_set_header 中的记录, 匹配 Header, 设置支持的字段 - let proxy_set_header = header_rules(node, "proxy_set_header"); + let proxy_set_header = header_rules(node.proxy_set_headers()); for HeaderRule { name, @@ -57,8 +55,8 @@ pub(crate) fn proxy_set_header(node: &Arc, req: Request) -> Re /// /// * `node` - A config node potentially containing header configurations under the key "add_header". /// * `parts` - A mutable reference to `http::response::Parts` where headers will be added. -pub(crate) fn add_header(node: &Arc, parts: &mut Parts) { - let add_header = header_rules(node, "add_header"); +pub(crate) fn add_header(node: &Arc, parts: &mut Parts) { + let add_header = header_rules(node.add_headers()); for HeaderRule { name, @@ -73,28 +71,20 @@ pub(crate) fn add_header(node: &Arc, parts: &mut Parts) { } /// Determines and sets the "Content-Type" header for a given file path based on configuration. -pub(crate) fn content_type(node: &Arc, parts: &mut Parts, file_path: &Path) { - let mime_types = node.inherited::("types").ok().flatten(); - let default_type = node.inherited::("default_type").ok().flatten(); +pub(crate) fn content_type(node: &Arc, parts: &mut Parts, file_path: &Path) { + let mime_types = node.http().types().effective().as_ref(); + let default_type = node.http().default_type().effective().as_ref(); if let Some(mime_types) = mime_types - && let Some(content_type) = infer_content_type( - file_path, - &mime_types.0, - default_type.as_ref().map(|v| &v.0), - ) + && let Some(content_type) = + infer_content_type(file_path, &mime_types.0, default_type.map(|v| &v.0)) { parts.headers.insert("Content-Type", content_type.clone()); } } -fn header_rules(node: &ConfigNode, name: &str) -> Vec { - node.get_all::(name) - .ok() - .into_iter() - .flatten() - .flat_map(|headers| headers.0.clone()) - .collect() +fn header_rules(rules: &[HeaderRules]) -> Vec { + rules.iter().flat_map(|headers| headers.0.clone()).collect() } /// Infers the `Content-Type` `HeaderValue` for a given file path based on its extension. @@ -116,32 +106,14 @@ fn infer_content_type<'a>( #[cfg(test)] mod tests { use super::*; - use crate::parse::{ - document::ConfigNode, - registry::context, - source::{SourceId, SourceSpan}, - value::TypedValue, - }; + use crate::parse::tests::parse_location; #[test] fn proxy_set_header_defaults_host_to_proxy_host_with_port() { - let span = SourceSpan::new(SourceId(0), 0, 0); - let mut node = ConfigNode::new(context::LOCATION, None, span); - node.insert_slot( - "proxy_pass", - TypedValue::new( - ProxyPass { - raw: "http://backend.example.com:8080/base/".to_string(), - uri: "http://backend.example.com:8080/base/".parse().unwrap(), - proxy_host: "backend.example.com:8080".to_string(), - explicit_path_and_query: Some("/base/".to_string()), - }, - span, - ), - ); + let node = parse_location("proxy_pass http://backend.example.com:8080/base/;").unwrap(); let req = http::Request::builder().uri("/echo").body(()).unwrap(); - let req = proxy_set_header(&Arc::new(node), req); + let req = proxy_set_header(&node, req); assert_eq!( req.headers()[http::header::HOST], "backend.example.com:8080" diff --git a/gateway/src/control_plane.rs b/gateway/src/control_plane.rs index 07a37e79..f1fe58da 100644 --- a/gateway/src/control_plane.rs +++ b/gateway/src/control_plane.rs @@ -50,8 +50,6 @@ pub trait ProvideListener: Send + Sync { type Listener: quic::Listen; /// Error type for [`listener()`](Self::listener) operations. type ListenError: std::error::Error + Send + Sync; - /// Error type for [`rebuild_listener()`](Self::rebuild_listener) operations. - type RebuildError: std::error::Error + Send + Sync; /// Request the control plane to create a QUIC listener for the given /// server configuration. @@ -59,17 +57,6 @@ pub trait ProvideListener: Send + Sync { &self, request: ListenRequest, ) -> impl Future> + Send + '_; - - /// Atomically replace a previously acquired listener with one matching the - /// new request. The previous listener is consumed; implementations are - /// responsible for any necessary teardown of the old listener as part of - /// the rebuild critical section so the server name is never observed - /// vacant. - fn rebuild_listener( - &self, - old: Self::Listener, - request: ListenRequest, - ) -> impl Future> + Send + '_; } // --------------------------------------------------------------------------- diff --git a/gateway/src/forward.rs b/gateway/src/forward.rs index c38e8ddc..08c7222f 100644 --- a/gateway/src/forward.rs +++ b/gateway/src/forward.rs @@ -13,7 +13,7 @@ use http::{Method, StatusCode}; use http_body_util::{BodyExt, Empty, Full, combinators::UnsyncBoxBody}; use hyper::{Request, Response, server::conn::http1, service::service_fn, upgrade::OnUpgrade}; use hyper_util::rt::tokio::TokioIo; -use snafu::{OptionExt, Report, ResultExt, Snafu}; +use snafu::{Report, ResultExt, Snafu}; use tokio::{ io, net::{TcpListener, TcpStream}, @@ -25,7 +25,6 @@ use crate::{ command, error::{BoxError, Result, Whatever}, forward, - parse::{document::ConfigNode, types::SocketAddrs}, }; mod normal; @@ -37,6 +36,13 @@ use task_scope::ForwardTaskScope; #[allow(dead_code)] pub static ALPN: &[u8] = b"h3"; +#[derive(Clone, Debug)] +pub struct ForwardConfig { + pub listen: SocketAddr, + pub allow: Vec, + pub deny: Vec, +} + type BoxResponse = Response>; #[derive(Debug, Snafu)] @@ -76,20 +82,14 @@ fn configure_tcp_keepalive(stream: &TcpStream) { /// # Returns /// * `Result<(SocketAddr, impl Future)>` - The address and server task pub async fn serve( - node: Arc, + config: ForwardConfig, client: Arc, ) -> Result<( SocketAddr, impl Future> + Send + 'static, )> { tracing::info!("starting forward proxy server"); - let listen = node - .require::("listen") - .whatever_context::<_, Whatever>("failed to read forward proxy listen directive")?; - let addr = *listen - .0 - .first() - .whatever_context::<_, Whatever>("missing forward proxy listen address")?; + let addr = config.listen; let (listener, local_addr) = async { let listener = TcpListener::bind(addr).await?; @@ -102,7 +102,7 @@ pub async fn serve( info!(%local_addr, "listening on http endpoint"); // 访问权限控制 - let acl = Arc::new(command::acl(&node)); + let acl = Arc::new(command::acl(&config.allow, &config.deny)); let semaphore = Arc::new(Semaphore::new(1024)); let task_scope = ForwardTaskScope::new(); let task_spawner = task_scope.spawner(); diff --git a/gateway/src/parse.rs b/gateway/src/parse.rs index dc46b0ee..1bf151c4 100644 --- a/gateway/src/parse.rs +++ b/gateway/src/parse.rs @@ -1,137 +1,114 @@ -use std::{path::Path, sync::Arc}; +use std::path::Path; -use dhttp::home::identity::IdentityProfile; -use snafu::ResultExt; +use dhttp::home::{DhttpHome, identity::IdentityProfile}; pub mod ast; +pub mod build; pub mod builtin; +pub mod config; +pub mod decode; pub mod diagnostic; -pub mod document; +pub mod domain; pub mod error; pub mod grammar; pub mod include; pub mod normalize; pub mod pattern; -pub mod registry; pub mod source; pub mod types; -pub mod value; #[cfg(test)] -mod tests; +pub(crate) mod tests; + +pub use build::{ + BuildTypedConfigError, IdentityServerCandidate, ParsedPishooConfig, ServerConfigCandidate, + TypedConfigParser, +}; pub(crate) type Result = std::result::Result; -pub fn default_registry() -> registry::ConfigRegistry { - let mut registry = registry::ConfigRegistry::new(); - builtin::register_gateway_directives(&mut registry); - registry +pub async fn load_root_config_file( + parser: &mut TypedConfigParser, + path: &Path, + home: Option<&DhttpHome>, +) -> Result { + let text = read_config(path).await?; + parser.parse_root(&text, path, home) } -pub fn parse_config_str_for_test( - text: &str, -) -> Result { - let registry = default_registry(); - load_config_text(text, None, ®istry, registry::BuildOptions::default()) +pub async fn load_worker_config_file( + parser: &mut TypedConfigParser, + path: &Path, + home: &DhttpHome, + root: &config::RootWorkerDefaultsSnapshot, +) -> Result, error::ConfigLoadFailure> { + let Some(text) = read_optional_config(path).await? else { + return Ok(None); + }; + parser.parse_worker(&text, path, home, root).map(Some) } -pub fn parse_server_config_str_for_test( - text: &str, - identity_profile: &IdentityProfile, -) -> Result { - let registry = default_registry(); - let home_path = identity_profile - .path() - .parent() - .map(Path::to_path_buf) - .unwrap_or_else(|| identity_profile.path().to_path_buf()); - let home = dhttp::home::DhttpHome::new(home_path); - load_config_text( - text, - Some(identity_profile.path()), - ®istry, - registry::BuildOptions { - dhttp_home: Some(&home), - identity_profile: Some(identity_profile), - }, - ) +pub async fn load_identity_config_file( + parser: &mut TypedConfigParser, + profile: IdentityProfile, + root: &config::RootWorkerDefaultsSnapshot, +) -> Result, error::ConfigLoadFailure> { + let path = profile.server_conf_path(); + let Some(text) = read_optional_config(&path).await? else { + return Ok(None); + }; + parser.parse_identity(&text, &path, profile, root).map(Some) } -pub fn load_config_text( - text: &str, - root: Option<&Path>, - registry: ®istry::ConfigRegistry, - options: registry::BuildOptions<'_>, -) -> Result { - load_config_text_inner(text, None, root, registry, options) +async fn read_config(path: &Path) -> Result { + tokio::fs::read_to_string(path) + .await + .map_err(|source| error::ConfigLoadFailure::read(path, source)) } -fn load_config_text_inner( - text: &str, - source_path: Option<&Path>, - root: Option<&Path>, - registry: ®istry::ConfigRegistry, - options: registry::BuildOptions<'_>, -) -> Result { - let mut source_map = source::SourceMap::default(); - let source_id = source_map.add_source( - source_path.map(Path::to_path_buf), - Arc::from(text), - root.map(Path::to_path_buf), - None, - ); +async fn read_optional_config(path: &Path) -> Result, error::ConfigLoadFailure> { + match tokio::fs::read_to_string(path).await { + Ok(text) => Ok(Some(text)), + Err(source) if source.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(source) => Err(error::ConfigLoadFailure::read(path, source)), + } +} - let directives = match grammar::parse_source(text, source_id) - .context(error::load_config_error::ParseFileSnafu { source_id }) - { - Ok(directives) => directives, - Err(error) => { - return Err(error::ConfigLoadFailure { - error, - source_map: Arc::new(source_map), - }); - } - }; +#[cfg(test)] +pub(crate) struct TestConfigFailure { + pub(crate) error: TestConfigError, +} - let directives = match include::expand_includes(directives, &mut source_map, root) - .context(error::load_config_error::ResolveIncludeSnafu) - { - Ok(directives) => directives, - Err(error) => { - return Err(error::ConfigLoadFailure { - error, - source_map: Arc::new(source_map), - }); - } - }; +#[cfg(test)] +#[derive(Debug)] +pub(crate) struct TestConfigError(Box); - let source_map = Arc::new(source_map); - match registry - .build(Arc::clone(&source_map), directives, options) - .context(error::load_config_error::BuildDocumentSnafu) - { - Ok(document) => Ok(document), - Err(error) => Err(error::ConfigLoadFailure { error, source_map }), +#[cfg(test)] +impl std::fmt::Display for TestConfigError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.fmt(formatter) } } -#[tracing::instrument(level = "info", skip(registry), fields(path = %path.display()))] -pub async fn load_config_file( - path: &Path, - registry: ®istry::ConfigRegistry, - options: registry::BuildOptions<'_>, -) -> Result { - let text = match tokio::fs::read_to_string(path).await { - Ok(text) => text, - Err(source) => { - return Err(error::ConfigLoadFailure { - error: error::LoadConfigError::ReadSource { - path: path.to_path_buf(), - source, - }, - source_map: Arc::new(source::SourceMap::default()), - }); - } - }; - load_config_text_inner(&text, Some(path), path.parent(), registry, options) +#[cfg(test)] +impl std::error::Error for TestConfigError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + Some(self.0.as_ref()) + } +} + +#[cfg(test)] +pub(crate) fn parse_config_str_for_test(text: &str) -> Result<(), TestConfigFailure> { + let parsed = TypedConfigParser::new() + .parse_root(text, Path::new("/tmp/pishoo.conf"), None) + .map_err(|error| TestConfigFailure { + error: TestConfigError(Box::new(error)), + })?; + let (_, candidates) = parsed.into_parts(); + for candidate in candidates { + candidate.into_result().map_err(|error| TestConfigFailure { + error: TestConfigError(Box::new(error)), + })?; + } + Ok(()) } diff --git a/gateway/src/parse/build.rs b/gateway/src/parse/build.rs new file mode 100644 index 00000000..219802d7 --- /dev/null +++ b/gateway/src/parse/build.rs @@ -0,0 +1,833 @@ +use std::{path::Path, sync::Arc}; + +use dhttp::home::{DhttpHome, identity::IdentityProfile}; +use http::{HeaderName, HeaderValue}; +use snafu::{ResultExt, Snafu}; + +use super::{ + ast::{AstBody, AstDirective}, + config::{ + AccessLogDirective, Cascaded, ConfigOrigin, EffectiveHttpConfig, LocationConfig, + OriginScope, PishooConfig, PreparedProxyTlsPaths, RootWorkerDefaultsSnapshot, ServerConfig, + ServerIdentity, + }, + decode::{ConfigContext, DirectiveInput, DirectiveValue}, + domain::{ConfigDocumentIdAllocator, ConfigSourceSpan, ResolvedConfigPath}, + error::{ConfigLoadFailure, LoadConfigError}, + source::{ConfigDocumentSourceMap, SourceMap, SourceSpan}, + types::{ + AccessRulesUri, BoolConfig, DefaultType, GzipCompLevel, GzipMinLength, HeaderRule, + HeaderRules, MimeTypes, ProxyPass, ServerNames, StringList, + }, +}; + +#[derive(Debug, Snafu)] +#[snafu(module)] +pub enum BuildTypedConfigError { + #[snafu(display("unknown directive `{name}` in {context}"))] + UnknownDirective { + name: String, + context: &'static str, + span: SourceSpan, + }, + #[snafu(display("directive `{name}` has the wrong shape"))] + Shape { name: String, span: SourceSpan }, + #[snafu(display("duplicate directive `{name}`"))] + Duplicate { + name: String, + first: SourceSpan, + duplicate: SourceSpan, + }, + #[snafu(display("failed to parse directive `{name}`"))] + Directive { + name: String, + span: SourceSpan, + source: Box, + }, + #[snafu(display("missing required directive `{name}`"))] + Missing { + name: &'static str, + span: SourceSpan, + }, + #[snafu(display("configuration must contain exactly one pishoo block"))] + PishooCardinality { span: SourceSpan }, + #[snafu(display("worker configuration cannot contain server blocks"))] + WorkerServer { span: SourceSpan }, + #[snafu(display("server certificate and private key must be configured together"))] + ServerTlsPair { span: SourceSpan }, + #[snafu(display("standalone server requires certificate and private key"))] + StandaloneTls { span: SourceSpan }, + #[snafu(display("implicit profile TLS requires exactly one server_name"))] + AmbiguousProfile { span: SourceSpan }, + #[snafu(display("identity server must bind exactly its profile name"))] + IdentityName { span: SourceSpan }, + #[snafu(display("identity server cannot override profile TLS paths"))] + IdentityTls { span: SourceSpan }, + #[snafu(display("proxy certificate and private key must be configured together"))] + ProxyTlsPair { span: SourceSpan }, + #[snafu(display("proxy_pass cannot include a URI part in a regex location"))] + RegexProxyUri { span: SourceSpan }, + #[snafu(display("access_log default is invalid for a direct server"))] + DirectDefaultAccessLog { span: SourceSpan }, + #[snafu(display("access_log expects exactly one argument"))] + AccessLogArgument { span: SourceSpan }, + #[snafu(display("forward proxy requires exactly one listen address"))] + ForwardListen { span: SourceSpan }, +} + +#[derive(Debug)] +pub struct ServerConfigCandidate { + source: ConfigSourceSpan, + result: Result, +} +impl ServerConfigCandidate { + pub const fn source(&self) -> ConfigSourceSpan { + self.source + } + pub fn result(&self) -> &Result { + &self.result + } + pub fn into_result(self) -> Result { + self.result + } +} + +#[derive(Debug)] +pub struct ParsedPishooConfig { + pishoo: PishooConfig, + servers: Box<[ServerConfigCandidate]>, +} +impl ParsedPishooConfig { + pub fn pishoo(&self) -> &PishooConfig { + &self.pishoo + } + pub fn servers(&self) -> &[ServerConfigCandidate] { + &self.servers + } + pub fn into_parts(self) -> (PishooConfig, Box<[ServerConfigCandidate]>) { + (self.pishoo, self.servers) + } +} + +#[derive(Debug)] +pub struct IdentityServerCandidate { + profile: IdentityProfile, + result: Result, +} +impl IdentityServerCandidate { + pub fn profile(&self) -> &IdentityProfile { + &self.profile + } + pub fn result(&self) -> &Result { + &self.result + } + pub fn into_parts(self) -> (IdentityProfile, Result) { + (self.profile, self.result) + } +} + +#[derive(Default)] +struct LocalHttp { + access_rules: Option>, + gzip: Option>, + gzip_vary: Option>, + gzip_min_length: Option>, + gzip_comp_level: Option>, + gzip_types: Option>, + default_type: Option>, + types: Option>, + access_log: Option>, +} + +struct Assignment { + value: T, + span: SourceSpan, +} + +impl Assignment { + fn new(value: T, span: SourceSpan) -> Self { + Self { value, span } + } + + fn map(self, map: impl FnOnce(T) -> U) -> Assignment { + Assignment::new(map(self.value), self.span) + } +} + +pub struct TypedConfigParser { + ids: ConfigDocumentIdAllocator, +} +impl Default for TypedConfigParser { + fn default() -> Self { + Self::new() + } +} +impl TypedConfigParser { + pub fn new() -> Self { + Self { + ids: ConfigDocumentIdAllocator::new(), + } + } + + pub fn parse_root( + &mut self, + text: &str, + path: &Path, + home: Option<&DhttpHome>, + ) -> Result { + let (sources, directives) = self.syntax(text, path)?; + self.build_pishoo( + &sources, + &directives, + builtin_http(), + OriginScope::RootPishoo, + home, + false, + ) + .map_err(|source| failure(source, sources)) + } + + pub fn parse_worker( + &mut self, + text: &str, + path: &Path, + home: &DhttpHome, + root: &RootWorkerDefaultsSnapshot, + ) -> Result { + let (sources, directives) = self.syntax(text, path)?; + self.build_pishoo( + &sources, + &directives, + root.http().clone(), + OriginScope::WorkerPishoo, + Some(home), + true, + ) + .map_err(|source| failure(source, sources)) + } + + pub fn parse_identity( + &mut self, + text: &str, + path: &Path, + profile: IdentityProfile, + defaults: &RootWorkerDefaultsSnapshot, + ) -> Result { + let (sources, directives) = self.syntax(text, path)?; + let source = directives + .first() + .map_or(SourceSpan::new(super::source::SourceId(0), 0, 0), |d| { + d.span + }); + if directives.len() != 1 || directives[0].name.value != "server" { + return Err(failure( + BuildTypedConfigError::Missing { + name: "server", + span: source, + }, + sources, + )); + } + let result = build_server( + &directives[0], + &sources, + defaults.http(), + Some(&profile), + None, + ); + Ok(IdentityServerCandidate { profile, result }) + } + + fn syntax( + &mut self, + text: &str, + path: &Path, + ) -> Result<(Arc, Vec), ConfigLoadFailure> { + let document_id = self.ids.allocate().map_err(|source| ConfigLoadFailure { + error: LoadConfigError::DocumentId { source }, + source_map: Arc::new(SourceMap::default()), + document_id: None, + })?; + let mut source_map = SourceMap::default(); + let source_id = source_map.add_source( + Some(path.to_path_buf()), + Arc::from(text), + path.parent().map(Path::to_path_buf), + None, + ); + let directives = match super::grammar::parse_source(text, source_id) + .context(super::error::load_config_error::ParseFileSnafu { source_id }) + { + Ok(directives) => directives, + Err(error) => { + return Err(ConfigLoadFailure { + error, + source_map: Arc::new(source_map), + document_id: Some(document_id), + }); + } + }; + let directives = + match super::include::expand_includes(directives, &mut source_map, path.parent()) + .context(super::error::load_config_error::ResolveIncludeSnafu) + { + Ok(directives) => directives, + Err(error) => { + return Err(ConfigLoadFailure { + error, + source_map: Arc::new(source_map), + document_id: Some(document_id), + }); + } + }; + Ok(( + Arc::new(ConfigDocumentSourceMap::new( + document_id, + Arc::new(source_map), + )), + directives, + )) + } + + fn build_pishoo( + &self, + sources: &Arc, + directives: &[AstDirective], + parent: EffectiveHttpConfig, + scope: OriginScope, + home: Option<&DhttpHome>, + worker: bool, + ) -> Result { + if directives.len() != 1 || directives[0].name.value != "pishoo" { + return Err(BuildTypedConfigError::PishooCardinality { + span: directives + .first() + .map_or(SourceSpan::new(super::source::SourceId(0), 0, 0), |d| { + d.span + }), + }); + } + let pishoo = &directives[0]; + let children = block(pishoo)?; + let mut pid = None; + let mut workers = None; + let mut groups = None; + let mut http = LocalHttp::default(); + let mut seen = std::collections::HashMap::new(); + for directive in children { + if directive.name.value == "server" { + if worker { + return Err(BuildTypedConfigError::WorkerServer { + span: directive.span, + }); + } + continue; + } + if directive.name.value == "proxy" { + if worker { + return Err(BuildTypedConfigError::UnknownDirective { + name: "proxy".to_owned(), + context: "worker pishoo", + span: directive.span, + }); + } + continue; + } + reject_duplicate(&mut seen, directive)?; + match directive.name.value.as_str() { + "pid" => pid = Some(parse(directive, ConfigContext::Pishoo, sources)?), + "workers" => workers = Some(parse(directive, ConfigContext::Pishoo, sources)?), + "groups" => groups = Some(parse(directive, ConfigContext::Pishoo, sources)?), + name if parse_http(name, directive, ConfigContext::Pishoo, sources, &mut http)? => { + } + name => { + return Err(BuildTypedConfigError::UnknownDirective { + name: name.to_owned(), + context: "pishoo", + span: directive.span, + }); + } + } + } + let effective = overlay_http(&parent, http, scope, sources.source_map()); + // Server candidates are rebuilt after all PISHOO directives are known, so textual order is irrelevant. + let forward_proxies = children + .iter() + .filter(|d| d.name.value == "proxy") + .map(|directive| build_forward_proxy(directive, sources)) + .collect::, _>>()?; + let servers = children + .iter() + .filter(|d| d.name.value == "server") + .map(|directive| { + let source = sources.config_span(directive.span); + ServerConfigCandidate { + source, + result: build_server(directive, sources, &effective, None, home), + } + }) + .collect(); + Ok(ParsedPishooConfig { + pishoo: PishooConfig::new( + sources.config_span(pishoo.span), + pid, + workers, + groups, + effective, + forward_proxies, + ), + servers, + }) + } +} + +fn failure( + source: BuildTypedConfigError, + sources: Arc, +) -> ConfigLoadFailure { + ConfigLoadFailure { + error: LoadConfigError::BuildTyped { source }, + source_map: Arc::clone(sources.source_map_arc()), + document_id: Some(sources.document_id()), + } +} + +fn block(directive: &AstDirective) -> Result<&[AstDirective], BuildTypedConfigError> { + match &directive.body { + AstBody::Block { children, .. } => Ok(children), + _ => Err(BuildTypedConfigError::Shape { + name: directive.name.value.clone(), + span: directive.span, + }), + } +} +fn leaf(directive: &AstDirective) -> Result<(), BuildTypedConfigError> { + if directive.is_leaf() { + Ok(()) + } else { + Err(BuildTypedConfigError::Shape { + name: directive.name.value.clone(), + span: directive.span, + }) + } +} +fn parse( + directive: &AstDirective, + context: ConfigContext, + sources: &ConfigDocumentSourceMap, +) -> Result +where + T: DirectiveValue, + for<'a, 'b> T: TryFrom<&'a DirectiveInput<'b>, Error = ::Error>, +{ + if !matches!(directive.name.value.as_str(), "types" | "stun_server") { + leaf(directive)?; + } + T::try_from(&DirectiveInput { + directive, + context, + source_map: sources.source_map(), + }) + .map_err(|source| BuildTypedConfigError::Directive { + name: directive.name.value.clone(), + span: directive.span, + source: Box::new(source), + }) +} +fn reject_duplicate( + seen: &mut std::collections::HashMap, + directive: &AstDirective, +) -> Result<(), BuildTypedConfigError> { + if matches!( + directive.name.value.as_str(), + "listen" | "location" | "stun_server" | "add_header" | "proxy_set_header" | "ssh_ssl_user" + ) { + return Ok(()); + } + if let Some(first) = seen.insert(directive.name.value.clone(), directive.span) { + return Err(BuildTypedConfigError::Duplicate { + name: directive.name.value.clone(), + first, + duplicate: directive.span, + }); + } + Ok(()) +} + +fn builtin(value: T) -> Cascaded { + Cascaded::new(value, vec![ConfigOrigin::builtin()].into_boxed_slice()) +} +fn builtin_http() -> EffectiveHttpConfig { + EffectiveHttpConfig::new( + builtin(None), + builtin(BoolConfig(false)), + builtin(BoolConfig(false)), + builtin(GzipMinLength(20)), + builtin(GzipCompLevel(1)), + builtin(StringList(Vec::new())), + builtin(None), + builtin(None), + builtin(AccessLogDirective::Off), + ) +} +fn cascade( + parent: &Cascaded, + local: Option>, + scope: OriginScope, + sources: &SourceMap, +) -> Cascaded { + match local { + Some(assignment) => { + let mut lineage = parent.lineage().to_vec(); + lineage.push(ConfigOrigin::source(scope, sources, assignment.span)); + Cascaded::new(assignment.value, lineage.into_boxed_slice()) + } + None => parent.clone(), + } +} +fn overlay_http( + parent: &EffectiveHttpConfig, + local: LocalHttp, + scope: OriginScope, + sources: &SourceMap, +) -> EffectiveHttpConfig { + EffectiveHttpConfig::new( + cascade( + parent.access_rules(), + local.access_rules.map(|value| value.map(Some)), + scope, + sources, + ), + cascade(parent.gzip(), local.gzip, scope, sources), + cascade(parent.gzip_vary(), local.gzip_vary, scope, sources), + cascade( + parent.gzip_min_length(), + local.gzip_min_length, + scope, + sources, + ), + cascade( + parent.gzip_comp_level(), + local.gzip_comp_level, + scope, + sources, + ), + cascade(parent.gzip_types(), local.gzip_types, scope, sources), + cascade( + parent.default_type(), + local.default_type.map(|value| value.map(Some)), + scope, + sources, + ), + cascade( + parent.types(), + local.types.map(|value| value.map(Some)), + scope, + sources, + ), + cascade(parent.access_log(), local.access_log, scope, sources), + ) +} +fn parse_http( + name: &str, + d: &AstDirective, + c: ConfigContext, + s: &ConfigDocumentSourceMap, + h: &mut LocalHttp, +) -> Result { + match name { + "access_rules" => h.access_rules = Some(parse_assignment(d, c, s)?), + "gzip" => h.gzip = Some(parse_assignment(d, c, s)?), + "gzip_vary" => h.gzip_vary = Some(parse_assignment(d, c, s)?), + "gzip_min_length" => h.gzip_min_length = Some(parse_assignment(d, c, s)?), + "gzip_comp_level" => h.gzip_comp_level = Some(parse_assignment(d, c, s)?), + "gzip_types" => h.gzip_types = Some(parse_assignment(d, c, s)?), + "default_type" => h.default_type = Some(parse_assignment(d, c, s)?), + "types" => h.types = Some(parse_assignment(d, c, s)?), + "access_log" => { + h.access_log = Some(Assignment::new(parse_access_log(d, c, s)?, d.span)); + } + _ => return Ok(false), + } + Ok(true) +} + +fn parse_assignment( + directive: &AstDirective, + context: ConfigContext, + sources: &ConfigDocumentSourceMap, +) -> Result, BuildTypedConfigError> +where + T: DirectiveValue, + for<'a, 'b> T: TryFrom<&'a DirectiveInput<'b>, Error = ::Error>, +{ + Ok(Assignment::new( + parse(directive, context, sources)?, + directive.span, + )) +} +fn parse_access_log( + d: &AstDirective, + c: ConfigContext, + s: &ConfigDocumentSourceMap, +) -> Result { + leaf(d)?; + if d.args.len() != 1 { + return Err(BuildTypedConfigError::AccessLogArgument { span: d.span }); + } + Ok(match d.args[0].value.as_str() { + "off" => AccessLogDirective::Off, + "default" => AccessLogDirective::ProfileDefault, + _ => AccessLogDirective::Resolved(parse::(d, c, s)?), + }) +} + +fn build_forward_proxy( + d: &AstDirective, + s: &ConfigDocumentSourceMap, +) -> Result { + let children = block(d)?; + let mut seen = std::collections::HashMap::new(); + let mut listen = None; + let mut allow = Vec::new(); + let mut deny = Vec::new(); + for directive in children { + reject_duplicate(&mut seen, directive)?; + match directive.name.value.as_str() { + "listen" => { + let addresses: super::types::SocketAddrs = + parse(directive, ConfigContext::Pishoo, s)?; + if addresses.0.len() != 1 { + return Err(BuildTypedConfigError::ForwardListen { + span: directive.span, + }); + } + listen = addresses.0.into_iter().next(); + } + "allow" => allow = parse::(directive, ConfigContext::Pishoo, s)?.0, + "deny" => deny = parse::(directive, ConfigContext::Pishoo, s)?.0, + name => { + return Err(BuildTypedConfigError::UnknownDirective { + name: name.to_owned(), + context: "proxy", + span: directive.span, + }); + } + } + } + let Some(listen) = listen else { + return Err(BuildTypedConfigError::ForwardListen { span: d.span }); + }; + Ok(crate::forward::ForwardConfig { + listen, + allow, + deny, + }) +} + +fn build_server( + d: &AstDirective, + s: &ConfigDocumentSourceMap, + parent: &EffectiveHttpConfig, + profile: Option<&IdentityProfile>, + home: Option<&DhttpHome>, +) -> Result { + let children = block(d)?; + let mut seen = std::collections::HashMap::new(); + let mut listens = Vec::new(); + let mut names = None; + let mut resolver = None; + let mut cert = None; + let mut key = None; + let mut relay = None; + let mut stun = None; + let mut stun_servers = Vec::new(); + let mut http = LocalHttp::default(); + let mut locations = Vec::new(); + for x in children { + reject_duplicate(&mut seen, x)?; + match x.name.value.as_str() { + "listen" => listens.push(parse(x, ConfigContext::Server, s)?), + "server_name" => names = Some(parse::(x, ConfigContext::Server, s)?), + "dns" => resolver = Some(parse(x, ConfigContext::Server, s)?), + "ssl_certificate" => cert = Some(parse(x, ConfigContext::Server, s)?), + "ssl_certificate_key" => key = Some(parse(x, ConfigContext::Server, s)?), + "relay" => relay = Some(parse(x, ConfigContext::Server, s)?), + "stun" => stun = Some(parse(x, ConfigContext::Server, s)?), + "stun_server" => stun_servers.push(parse(x, ConfigContext::Server, s)?), + "location" => locations.push(x), + name if parse_http(name, x, ConfigContext::Server, s, &mut http)? => {} + name => { + return Err(BuildTypedConfigError::UnknownDirective { + name: name.to_owned(), + context: "server", + span: x.span, + }); + } + } + } + if listens.is_empty() { + return Err(BuildTypedConfigError::Missing { + name: "listen", + span: d.span, + }); + } + let names = names + .map(|v| v.0.into_iter().map(|v| v.name).collect::>()) + .unwrap_or_else(|| { + profile + .map(|p| vec![p.name().clone()].into_boxed_slice()) + .unwrap_or_default() + }); + let identity = if let Some(p) = profile { + if names.as_ref() != [p.name().clone()] { + return Err(BuildTypedConfigError::IdentityName { span: d.span }); + } + if cert.is_some() || key.is_some() { + return Err(BuildTypedConfigError::IdentityTls { span: d.span }); + } + ServerIdentity::Profile(p.clone()) + } else { + match (cert, key) { + (Some(c), Some(k)) => ServerIdentity::Direct { + certificate: c, + private_key: k, + }, + (None, None) => { + let Some(home) = home else { + return Err(BuildTypedConfigError::StandaloneTls { span: d.span }); + }; + if names.len() != 1 { + return Err(BuildTypedConfigError::AmbiguousProfile { span: d.span }); + } + ServerIdentity::Profile(home.identity_profile(names[0].clone())) + } + _ => return Err(BuildTypedConfigError::ServerTlsPair { span: d.span }), + } + }; + let parent = if matches!(identity, ServerIdentity::Profile(_)) + && parent.access_log().lineage().len() == 1 + { + parent + .clone() + .with_access_log(builtin(AccessLogDirective::ProfileDefault)) + } else { + parent.clone() + }; + let effective = overlay_http(&parent, http, OriginScope::Server, s.source_map()); + if matches!(identity, ServerIdentity::Direct { .. }) + && matches!( + effective.access_log().effective(), + AccessLogDirective::ProfileDefault + ) + { + return Err(BuildTypedConfigError::DirectDefaultAccessLog { span: d.span }); + } + let locations = locations + .into_iter() + .map(|x| build_location(x, s, &effective)) + .collect::, _>>()?; + Ok(ServerConfig::new( + identity, + s.config_span(d.span), + names, + listens.into_boxed_slice(), + resolver, + effective, + relay, + stun, + stun_servers.into_boxed_slice(), + locations, + )) +} + +fn build_location( + d: &AstDirective, + s: &ConfigDocumentSourceMap, + parent: &EffectiveHttpConfig, +) -> Result { + let matcher = super::pattern::parse_spanned_pattern(&d.args).map_err(|source| { + BuildTypedConfigError::Directive { + name: d.name.value.clone(), + span: d.span, + source: Box::new(source), + } + })?; + let children = block(d)?; + let mut seen = std::collections::HashMap::new(); + let mut root = None; + let mut alias = None; + let mut index = None; + let mut add = Vec::new(); + let mut set = Vec::new(); + let mut pass = None; + let mut cert = None; + let mut key = None; + let mut trusted = None; + let mut login = None; + let mut users = Vec::new(); + let mut deny = None; + let mut http = LocalHttp::default(); + for x in children { + reject_duplicate(&mut seen, x)?; + match x.name.value.as_str() { + "root" => root = Some(parse(x, ConfigContext::Location, s)?), + "alias" => alias = Some(parse(x, ConfigContext::Location, s)?), + "index" => index = Some(parse(x, ConfigContext::Location, s)?), + "add_header" => add.push(parse(x, ConfigContext::Location, s)?), + "proxy_set_header" => set.push(parse(x, ConfigContext::Location, s)?), + "proxy_pass" => pass = Some(parse(x, ConfigContext::Location, s)?), + "proxy_ssl_certificate" => cert = Some(parse(x, ConfigContext::Location, s)?), + "proxy_ssl_certificate_key" => key = Some(parse(x, ConfigContext::Location, s)?), + "proxy_ssl_trusted_certificate" => { + trusted = Some(parse(x, ConfigContext::Location, s)?) + } + "ssh_login" => login = Some(parse(x, ConfigContext::Location, s)?), + "ssh_ssl_user" => users.push(parse(x, ConfigContext::Location, s)?), + "ssh_deny" => deny = Some(parse(x, ConfigContext::Location, s)?), + name if parse_http(name, x, ConfigContext::Location, s, &mut http)? => {} + name => { + return Err(BuildTypedConfigError::UnknownDirective { + name: name.to_owned(), + context: "location", + span: x.span, + }); + } + } + } + let proxy_tls = match (cert, key) { + (Some(c), Some(k)) => Some(PreparedProxyTlsPaths::new(Some(c), Some(k), trusted)), + (None, None) => { + trusted.map(|trusted| PreparedProxyTlsPaths::new(None, None, Some(trusted))) + } + _ => return Err(BuildTypedConfigError::ProxyTlsPair { span: d.span }), + }; + if matches!( + matcher, + super::pattern::Pattern::Regex(_) | super::pattern::Pattern::CRegex(_) + ) && pass.as_ref().is_some_and(ProxyPass::has_explicit_uri) + { + return Err(BuildTypedConfigError::RegexProxyUri { span: d.span }); + } + add.push(HeaderRules(vec![HeaderRule { + name: HeaderName::from_static("server"), + value: HeaderValue::from_static("pishoo"), + always: true, + }])); + let effective = overlay_http(parent, http, OriginScope::Location, s.source_map()); + Ok(LocationConfig::new( + s.config_span(d.span), + matcher, + effective, + root, + alias, + index, + add.into_boxed_slice(), + set.into_boxed_slice(), + pass, + proxy_tls, + login, + users.into_boxed_slice(), + deny, + )) +} diff --git a/gateway/src/parse/builtin.rs b/gateway/src/parse/builtin.rs index 97811c29..16d9d9ee 100644 --- a/gateway/src/parse/builtin.rs +++ b/gateway/src/parse/builtin.rs @@ -1,20 +1,6 @@ pub mod access; pub mod core; pub mod http; -pub mod location; pub mod net; -pub mod pishoo; -pub mod proxy; -pub mod server; pub mod ssh; pub mod stun; - -use crate::parse::registry::ConfigRegistry; - -pub fn register_gateway_directives(registry: &mut ConfigRegistry) { - pishoo::register(registry); - server::register(registry); - location::register(registry); - proxy::register(registry); - stun::register(registry); -} diff --git a/gateway/src/parse/builtin/access.rs b/gateway/src/parse/builtin/access.rs index d7d07850..5ecbb8bc 100644 --- a/gateway/src/parse/builtin/access.rs +++ b/gateway/src/parse/builtin/access.rs @@ -1,13 +1,13 @@ -use std::path::PathBuf; +use std::path::{Path, PathBuf}; -use snafu::{ResultExt, Snafu, ensure}; +use snafu::{ResultExt, Snafu}; use crate::parse::{ builtin::core::{first_arg_span, only_arg}, + decode::{DirectiveInput, DirectiveValue}, normalize, - registry::{DirectiveInput, DirectiveValue}, source::SourceSpan, - types::AccessRulesUri, + types::{AccessRulesUri, AccessRulesUriValidationError}, }; #[derive(Debug, Snafu)] @@ -24,15 +24,18 @@ pub enum AccessRulesUriError { span: SourceSpan, source: url::ParseError, }, - #[snafu(display("unsupported access_rules uri scheme `{scheme}`"))] - UnsupportedScheme { span: SourceSpan, scheme: String }, - #[snafu(display("unsupported sqlite access_rules uri form"))] - UnsupportedSqliteForm { span: SourceSpan }, #[snafu(display("failed to resolve relative access_rules sqlite path"))] ResolveRelativePath { span: SourceSpan, source: normalize::NormalizeDirectiveValueError, }, + #[snafu(display("failed to encode the access_rules sqlite path as a URL"))] + PathUrl { span: SourceSpan, path: PathBuf }, + #[snafu(display("invalid access_rules uri domain"))] + Domain { + span: SourceSpan, + source: AccessRulesUriValidationError, + }, } impl DirectiveValue for AccessRulesUri { @@ -56,36 +59,43 @@ impl<'input, 'directive> TryFrom<&'input DirectiveInput<'directive>> for AccessR }; let uri = url::Url::parse(&arg.value) .context(access_rules_uri_error::UriSnafu { span: arg.span })?; - ensure!( - uri.scheme() == "sqlite", - access_rules_uri_error::UnsupportedSchemeSnafu { - span: arg.span, - scheme: uri.scheme().to_owned(), - } - ); - ensure!( - uri.host_str().is_none() - && uri.username().is_empty() - && uri.password().is_none() - && uri.port().is_none() - && uri.fragment().is_none(), - access_rules_uri_error::UnsupportedSqliteFormSnafu { span: arg.span } - ); - - let path = PathBuf::from(uri.path()); - let normalized_path = normalize::normalize_path(&path, arg.span, input.source_map) + let path = AccessRulesUri::decoded_sqlite_path(&uri) + .context(access_rules_uri_error::DomainSnafu { span: arg.span })?; + let use_empty_authority = !path.is_absolute() || uri.as_str().starts_with("sqlite://"); + let path = normalize::normalize_path(&path, arg.span, input.source_map) .context(access_rules_uri_error::ResolveRelativePathSnafu { span: arg.span })?; + let query = uri.query().map(str::to_owned); + let uri = encode_sqlite_path(&path, query.as_deref(), use_empty_authority, arg.span)?; + AccessRulesUri::try_from(uri) + .context(access_rules_uri_error::DomainSnafu { span: arg.span }) + } +} - let mut normalized = format!("sqlite://{}", normalized_path.display()); - if let Some(query) = uri.query() { - normalized.push('?'); - normalized.push_str(query); - } - - let uri = url::Url::parse(&normalized) - .context(access_rules_uri_error::UriSnafu { span: arg.span })?; - Ok(Self(uri)) +fn encode_sqlite_path( + path: &Path, + query: Option<&str>, + use_empty_authority: bool, + span: SourceSpan, +) -> Result { + let path_url = url::Url::from_file_path(path).map_err(|()| AccessRulesUriError::PathUrl { + span, + path: path.to_owned(), + })?; + if path_url.host_str().is_some() { + return Err(AccessRulesUriError::PathUrl { + span, + path: path.to_owned(), + }); } + let root = if use_empty_authority { + "sqlite:///" + } else { + "sqlite:/" + }; + let mut uri = url::Url::parse(root).context(access_rules_uri_error::UriSnafu { span })?; + uri.set_path(path_url.path()); + uri.set_query(query); + Ok(uri) } #[cfg(test)] @@ -105,7 +115,7 @@ mod tests { } #[tokio::test] - async fn parse_access_rules_relative_sqlite_is_resolved_against_source_file() { + async fn parse_access_rules_relative_sqlite_preserves_os_path_bytes() { let dir = std::env::temp_dir().join(format!( "gateway-relative-access-rules-{}-{}", std::process::id(), @@ -117,30 +127,59 @@ mod tests { std::fs::create_dir_all(dir.join("db")).expect("create db dir"); std::fs::write(dir.join("server.crt"), "dummy cert").expect("write cert"); std::fs::write(dir.join("server.key"), "dummy key").expect("write key"); - std::fs::write( - dir.join("server.conf"), - "server {\n listen all 5378;\n server_name example.com;\n ssl_certificate ./server.crt;\n ssl_certificate_key ./server.key;\n access_rules sqlite:./db/access.db?mode=ro;\n}\n", - ) - .expect("write config"); - - let registry = crate::parse::default_registry(); - let parsed = crate::parse::load_config_file( - &dir.join("server.conf"), - ®istry, - crate::parse::registry::BuildOptions::default(), - ) - .await - .expect("config should load"); - - let server = parsed.root.children("server").expect("server children")[0].clone(); - assert_eq!( - server - .require::("access_rules") - .expect("access_rules should be typed") - .0 - .as_str(), - format!("sqlite://{}?mode=ro", dir.join("db/access.db").display()) - ); + let mut cases = vec![("sqlite:a:b.db?mode=ro%23strict", dir.join("a:b.db"))]; + #[cfg(unix)] + { + use std::os::unix::ffi::OsStringExt; + + cases.extend([ + (r"sqlite:a\b.db?mode=ro%23strict", dir.join(r"a\b.db")), + ("sqlite:a%5Cb.db?mode=ro%23strict", dir.join(r"a\b.db")), + ( + "sqlite:a%3Fb%23c%FF.db?mode=ro%23strict", + dir.join(std::ffi::OsString::from_vec(b"a?b#c\xff.db".to_vec())), + ), + ]); + } + #[cfg(not(unix))] + cases.push(("sqlite:a%3Fb%23c.db?mode=ro%23strict", dir.join("a?b#c.db"))); + let servers = cases + .iter() + .enumerate() + .map(|(index, (access_rules, _))| { + format!( + "server {{\n listen all {};\n server_name example-{index}.com;\n ssl_certificate ./server.crt;\n ssl_certificate_key ./server.key;\n access_rules {access_rules};\n}}\n", + 5378 + index, + ) + }) + .collect::(); + let config = format!("pishoo {{ {servers} }}"); + std::fs::write(dir.join("server.conf"), config).expect("write config"); + + let text = std::fs::read_to_string(dir.join("server.conf")).unwrap(); + let parsed = crate::parse::TypedConfigParser::new() + .parse_root(&text, &dir.join("server.conf"), None) + .expect("config should load"); + + assert_eq!(parsed.servers().len(), cases.len()); + for (server, (_, expected_path)) in parsed.servers().iter().zip(cases) { + let uri = &server + .result() + .as_ref() + .unwrap() + .http() + .access_rules() + .effective() + .as_ref() + .unwrap() + .0; + assert_eq!( + crate::parse::types::AccessRulesUri::decoded_sqlite_path(uri) + .expect("access_rules path should decode"), + expected_path, + ); + assert_eq!(uri.query(), Some("mode=ro%23strict")); + } } #[test] diff --git a/gateway/src/parse/builtin/core.rs b/gateway/src/parse/builtin/core.rs index d17abf32..98f97596 100644 --- a/gateway/src/parse/builtin/core.rs +++ b/gateway/src/parse/builtin/core.rs @@ -1,12 +1,14 @@ -use std::{convert::Infallible, path::PathBuf}; +use std::path::PathBuf; -use snafu::{Snafu, ensure}; +use snafu::{ResultExt, Snafu, ensure}; use crate::parse::{ ast::{AstBody, AstDirective, Spanned}, - registry::{DirectiveInput, DirectiveValue}, + decode::{DirectiveInput, DirectiveValue}, + domain::ResolvedConfigPath, + normalize::NormalizeDirectiveValueError, source::SourceSpan, - types::{BoolConfig, PathConfig, StringConfig, StringList}, + types::{BoolConfig, GzipTypesValidationError, PathConfig, StringConfig, StringList}, }; pub fn only_arg(directive: &AstDirective) -> Option<&Spanned> { @@ -65,20 +67,39 @@ impl<'input, 'directive> TryFrom<&'input DirectiveInput<'directive>> for StringC } } +#[derive(Debug, Snafu)] +#[snafu(module)] +pub enum StringListError { + #[snafu(display("invalid gzip_types directive value"))] + GzipTypes { + span: SourceSpan, + source: GzipTypesValidationError, + }, +} + impl DirectiveValue for StringList { - type Error = Infallible; -} - -impl<'input, 'directive> From<&'input DirectiveInput<'directive>> for StringList { - fn from(input: &'input DirectiveInput<'directive>) -> Self { - Self( - input - .directive - .args - .iter() - .map(|arg| arg.value.clone()) - .collect(), - ) + type Error = StringListError; +} + +impl<'input, 'directive> TryFrom<&'input DirectiveInput<'directive>> for StringList { + type Error = StringListError; + + fn try_from(input: &'input DirectiveInput<'directive>) -> Result { + let values = input + .directive + .args + .iter() + .map(|arg| arg.value.clone()) + .collect(); + if input.directive.name.value == "gzip_types" { + return StringList::checked_gzip_types(values).map_err(|source| { + StringListError::GzipTypes { + span: input.directive.span, + source, + } + }); + } + Ok(Self(values)) } } @@ -162,6 +183,50 @@ impl<'input, 'directive> TryFrom<&'input DirectiveInput<'directive>> for PathCon } } +#[derive(Debug, Snafu)] +#[snafu(module)] +pub enum ResolvedConfigPathDirectiveError { + #[snafu(display("invalid resolved path directive argument count"))] + InvalidArgumentCount { + span: SourceSpan, + expected: &'static str, + actual: usize, + }, + #[snafu(display("failed to resolve configuration path"))] + Resolve { + span: SourceSpan, + source: NormalizeDirectiveValueError, + }, +} + +impl DirectiveValue for ResolvedConfigPath { + type Error = ResolvedConfigPathDirectiveError; + + fn span(input: &DirectiveInput<'_>) -> SourceSpan { + first_arg_span(input) + } +} + +impl<'input, 'directive> TryFrom<&'input DirectiveInput<'directive>> for ResolvedConfigPath { + type Error = ResolvedConfigPathDirectiveError; + + fn try_from(input: &'input DirectiveInput<'directive>) -> Result { + let Some(arg) = only_arg(input.directive) else { + return Err(ResolvedConfigPathDirectiveError::InvalidArgumentCount { + span: input.directive.span, + expected: "1", + actual: input.directive.args.len(), + }); + }; + crate::parse::normalize::resolve_config_path( + std::path::Path::new(&arg.value), + arg.span, + input.source_map, + ) + .context(resolved_config_path_directive_error::ResolveSnafu { span: arg.span }) + } +} + #[cfg(test)] mod tests { use crate::parse::tests::*; diff --git a/gateway/src/parse/builtin/http.rs b/gateway/src/parse/builtin/http.rs index e9463051..190ada9f 100644 --- a/gateway/src/parse/builtin/http.rs +++ b/gateway/src/parse/builtin/http.rs @@ -1,14 +1,13 @@ -use std::collections::HashMap; - use http::{HeaderName, HeaderValue, Uri}; use snafu::{OptionExt, ResultExt, Snafu, ensure}; use crate::parse::{ builtin::core::{block_children, first_arg_span, only_arg}, - registry::{DirectiveInput, DirectiveValue}, + decode::{DirectiveInput, DirectiveValue}, source::SourceSpan, types::{ - DefaultType, GzipCompLevel, GzipMinLength, HeaderRule, HeaderRules, MimeTypes, ProxyPass, + DefaultType, GzipCompLevel, GzipMinLength, HeaderRule, HeaderRules, MimeTypes, + MimeTypesValidationError, ProxyPass, }, }; @@ -47,9 +46,9 @@ impl<'input, 'directive> TryFrom<&'input DirectiveInput<'directive>> for Default actual: input.directive.args.len(), }); }; - let value = HeaderValue::from_str(&arg.value) + let value = DefaultType::checked_from_bytes(arg.value.as_bytes()) .context(default_type_error::HeaderValueSnafu { span: arg.span })?; - Ok(Self(value)) + Ok(value) } } @@ -92,7 +91,7 @@ impl<'input, 'directive> TryFrom<&'input DirectiveInput<'directive>> for GzipMin .value .parse::() .context(gzip_min_length_error::UnsignedIntegerSnafu { span: arg.span })?; - Ok(Self(value)) + Ok(Self::checked(value)) } } @@ -135,7 +134,7 @@ impl<'input, 'directive> TryFrom<&'input DirectiveInput<'directive>> for GzipCom .value .parse::() .context(gzip_comp_level_error::SignedIntegerSnafu { span: arg.span })?; - Ok(Self(value)) + Ok(Self::checked(value)) } } @@ -291,10 +290,10 @@ impl<'input, 'directive> TryFrom<&'input DirectiveInput<'directive>> for HeaderR pub enum MimeTypesError { #[snafu(display("types directive must be a block"))] InvalidBlock { span: SourceSpan }, - #[snafu(display("invalid MIME type header value"))] - HeaderValue { + #[snafu(display("invalid types directive entries"))] + Entries { span: SourceSpan, - source: http::header::InvalidHeaderValue, + source: MimeTypesValidationError, }, } @@ -311,39 +310,37 @@ impl<'input, 'directive> TryFrom<&'input DirectiveInput<'directive>> for MimeTyp span: input.directive.span, }); }; - let mut map = HashMap::new(); + let mut entries = Vec::new(); for directive in children { - let value = HeaderValue::from_str(&directive.name.value).context( - mime_types_error::HeaderValueSnafu { - span: directive.name.span, - }, - )?; for arg in &directive.args { - map.insert(arg.value.clone(), value.clone()); + entries.push((arg.value.clone(), directive.name.value.as_bytes().to_vec())); } } - Ok(Self(map)) + MimeTypes::checked_from_bytes(entries).context(mime_types_error::EntriesSnafu { + span: input.directive.span, + }) } } #[cfg(test)] mod tests { - use crate::parse::{ - tests::{ - assert_error_chain_display_single_line, build_proxy_conf, build_server_conf, - cleanup_temp_files, create_temp_file, first_pishoo, first_server, parse_doc, - }, - types::{DefaultType, GzipCompLevel, GzipMinLength, ProxyPass}, + use crate::parse::tests::{ + assert_error_chain_display_single_line, build_proxy_conf, build_server_conf, + cleanup_temp_files, create_temp_file, first_location, parse_root, }; #[test] fn parse_default_type_accepts_single_header_value() { let conf = "pishoo { default_type text/html; }"; - let pishoo = first_pishoo(&parse_doc(conf)); - let value = pishoo - .require::("default_type") - .expect("default_type should be typed") + let parsed = parse_root(conf).unwrap(); + let value = parsed + .pishoo() + .http() + .default_type() + .effective() + .as_ref() + .unwrap() .0 .clone(); @@ -369,25 +366,12 @@ mod tests { #[test] fn parse_numeric_and_identity_directives_keep_typed_values() { - let conf = "pishoo { gzip_min_length 1100; gzip_comp_level 6; proxy { listen 127.0.0.1:8080; client_name example.com; } }"; - - let document = parse_doc(conf); - let pishoo = first_pishoo(&document); + let conf = "pishoo { gzip_min_length 1100; gzip_comp_level 6; }"; + let parsed = parse_root(conf).unwrap(); + let pishoo = parsed.pishoo(); - assert_eq!( - pishoo - .require::("gzip_min_length") - .expect("gzip_min_length should be typed") - .0, - 1100 - ); - assert_eq!( - pishoo - .require::("gzip_comp_level") - .expect("gzip_comp_level should be typed") - .0, - 6 - ); + assert_eq!(pishoo.http().gzip_min_length().effective().0, 1100); + assert_eq!(pishoo.http().gzip_comp_level().effective().0, 6); } #[test] @@ -420,13 +404,8 @@ mod tests { "location /api { proxy_pass http://backend.example.com; }", ); - let location = first_server(&parse_doc(&conf)) - .children("location") - .expect("location exists")[0] - .clone(); - let proxy_pass = location - .require::("proxy_pass") - .expect("proxy_pass"); + let location = first_location(&conf).unwrap(); + let proxy_pass = location.proxy_pass().unwrap(); assert_eq!(proxy_pass.proxy_host, "backend.example.com"); assert!(!proxy_pass.has_explicit_uri()); @@ -445,13 +424,8 @@ mod tests { "location /api { proxy_pass http://backend.example.com/; }", ); - let location = first_server(&parse_doc(&conf)) - .children("location") - .expect("location exists")[0] - .clone(); - let proxy_pass = location - .require::("proxy_pass") - .expect("proxy_pass"); + let location = first_location(&conf).unwrap(); + let proxy_pass = location.proxy_pass().unwrap(); assert!(proxy_pass.has_explicit_uri()); assert_eq!(proxy_pass.explicit_path_and_query(), Some("/")); @@ -469,13 +443,8 @@ mod tests { "location /api { proxy_pass https://backend.example.com:8443/base/?v=1; }", ); - let location = first_server(&parse_doc(&conf)) - .children("location") - .expect("location exists")[0] - .clone(); - let proxy_pass = location - .require::("proxy_pass") - .expect("proxy_pass"); + let location = first_location(&conf).unwrap(); + let proxy_pass = location.proxy_pass().unwrap(); assert_eq!(proxy_pass.proxy_host, "backend.example.com:8443"); assert!(proxy_pass.has_explicit_uri()); @@ -495,33 +464,17 @@ mod tests { "location /api { proxy_pass http://backend.example.com; }", ); - let server = first_server(&parse_doc(&conf)); - let location = server.children("location").expect("location exists")[0].clone(); + let location = first_location(&conf).unwrap(); - assert_eq!( - location - .require::("proxy_pass") - .expect("proxy_pass should be parsed") - .scheme_str(), - "http" - ); + assert_eq!(location.proxy_pass().unwrap().scheme_str(), "http"); let conf_https = build_server_conf( &cert, &key, "location /api { proxy_pass https://backend.example.com; }", ); - let location_https = first_server(&parse_doc(&conf_https)) - .children("location") - .expect("location exists")[0] - .clone(); - assert_eq!( - location_https - .require::("proxy_pass") - .expect("proxy_pass should be parsed") - .scheme_str(), - "https" - ); + let location_https = first_location(&conf_https).unwrap(); + assert_eq!(location_https.proxy_pass().unwrap().scheme_str(), "https"); cleanup_temp_files(&[&cert, &key]); } diff --git a/gateway/src/parse/builtin/location.rs b/gateway/src/parse/builtin/location.rs deleted file mode 100644 index 4a56dce9..00000000 --- a/gateway/src/parse/builtin/location.rs +++ /dev/null @@ -1,594 +0,0 @@ -use http::{HeaderName, HeaderValue}; -use snafu::{Snafu, ensure}; - -use crate::parse::{ - document::ConfigNode, - pattern::{ParsePatternError, Pattern}, - registry::{ - BuildOptions, ConfigRegistry, DirectiveInput, DirectiveSpec, DirectiveValue, MergePolicy, - context, - }, - source::SourceSpan, - types::{ - BoolConfig, DefaultType, GzipCompLevel, GzipMinLength, HeaderRule, HeaderRules, MimeTypes, - PathConfig, ProxyPass, SshLoginMethods, SshSslUsers, StringList, - }, - value::TypedValue, -}; - -#[derive(Debug, Snafu)] -#[snafu(module)] -pub enum FinalizeLocationError { - #[snafu(display( - "proxy_ssl_certificate and proxy_ssl_certificate_key must be configured together" - ))] - ProxyTlsPair { span: SourceSpan }, - #[snafu(display("proxy_pass cannot include a uri part inside regex location"))] - ProxyPassUriInRegexLocation { span: SourceSpan }, -} - -impl DirectiveValue for Pattern { - type Error = ParsePatternError; -} - -impl<'input, 'directive> TryFrom<&'input DirectiveInput<'directive>> for Pattern { - type Error = ParsePatternError; - - fn try_from(input: &'input DirectiveInput<'directive>) -> Result { - crate::parse::pattern::parse_spanned_pattern(&input.directive.args) - } -} - -pub fn register(registry: &mut ConfigRegistry) { - registry.register_context(crate::parse::registry::ContextSpec { - key: context::LOCATION, - finalize: Some(finalize_location), - }); - registry.register_directive( - context::SERVER, - DirectiveSpec::context_payload::( - "location", - vec![context::SERVER], - context::LOCATION, - MergePolicy::Append, - ), - ); - register_leaf::(registry, "root", MergePolicy::RejectDuplicate); - register_leaf::(registry, "alias", MergePolicy::RejectDuplicate); - register_leaf::(registry, "gzip", MergePolicy::RejectDuplicate); - register_leaf::(registry, "gzip_vary", MergePolicy::RejectDuplicate); - register_leaf::(registry, "gzip_min_length", MergePolicy::RejectDuplicate); - register_leaf::(registry, "gzip_comp_level", MergePolicy::RejectDuplicate); - register_leaf::(registry, "gzip_types", MergePolicy::RejectDuplicate); - register_leaf::(registry, "index", MergePolicy::RejectDuplicate); - register_leaf::(registry, "add_header", MergePolicy::Append); - register_leaf::(registry, "proxy_set_header", MergePolicy::Append); - register_leaf::(registry, "proxy_pass", MergePolicy::RejectDuplicate); - register_leaf::( - registry, - "proxy_ssl_certificate", - MergePolicy::RejectDuplicate, - ); - register_leaf::( - registry, - "proxy_ssl_certificate_key", - MergePolicy::RejectDuplicate, - ); - register_leaf::( - registry, - "proxy_ssl_trusted_certificate", - MergePolicy::RejectDuplicate, - ); - register_leaf::(registry, "ssh_login", MergePolicy::RejectDuplicate); - register_leaf::(registry, "ssh_ssl_user", MergePolicy::Append); - register_leaf::(registry, "ssh_deny", MergePolicy::RejectDuplicate); - register_leaf::(registry, "default_type", MergePolicy::RejectDuplicate); - registry.register_directive( - context::LOCATION, - DirectiveSpec::raw_value::( - "types", - vec![context::LOCATION], - MergePolicy::RejectDuplicate, - ), - ); -} - -fn register_leaf(registry: &mut ConfigRegistry, name: &'static str, merge: MergePolicy) -where - T: crate::parse::registry::DirectiveValue, - for<'input, 'directive> T: TryFrom< - &'input crate::parse::registry::DirectiveInput<'directive>, - Error = ::Error, - >, -{ - registry.register_directive( - context::LOCATION, - DirectiveSpec::leaf_value::(name, vec![context::LOCATION], merge), - ); -} - -fn finalize_location( - node: &mut ConfigNode, - _options: &BuildOptions<'_>, -) -> Result<(), Box> { - let pattern = node - .payload::()? - .expect("location node should contain a pattern payload"); - let proxy_pass = node.get::("proxy_pass")?; - let has_cert = node.get::("proxy_ssl_certificate")?.is_some(); - let has_key = node - .get::("proxy_ssl_certificate_key")? - .is_some(); - ensure!( - !matches!(pattern.as_ref(), Pattern::Regex(_) | Pattern::CRegex(_)) - || proxy_pass - .as_ref() - .is_none_or(|proxy_pass| !proxy_pass.has_explicit_uri()), - finalize_location_error::ProxyPassUriInRegexLocationSnafu { span: node.span } - ); - ensure!( - has_cert == has_key, - finalize_location_error::ProxyTlsPairSnafu { span: node.span } - ); - let server_header = HeaderRules(vec![HeaderRule { - name: HeaderName::from_static("server"), - value: HeaderValue::from_static("pishoo"), - always: true, - }]); - node.insert_slot("add_header", TypedValue::new(server_header, node.span)); - Ok(()) -} - -#[cfg(test)] -mod tests { - use std::path::PathBuf; - - use crate::parse::{ - pattern::Pattern, - tests::{ - build_proxy_conf, build_server_conf, cleanup_temp_files, create_temp_file, - first_server, parse_doc, - }, - types::{BoolConfig, DefaultType, HeaderRules, PathConfig, ProxyPass, StringList}, - }; - - #[test] - fn parse_path_list_directives_keep_path_config() { - let cert = create_temp_file("proxy_path_cert"); - let key = create_temp_file("proxy_path_key"); - let conf = build_proxy_conf(&cert, &key, "root /var/www/site;"); - - let server = first_server(&parse_doc(&conf)); - let location = server.children("location").expect("location should exist")[0].clone(); - - assert_eq!( - location - .require::("root") - .expect("root should be typed") - .0, - PathBuf::from("/var/www/site") - ); - - cleanup_temp_files(&[&cert, &key]); - } - - #[test] - fn parse_default_type_in_location_context() { - let cert = create_temp_file("loc_default_type_cert"); - let key = create_temp_file("loc_default_type_key"); - let conf = build_server_conf( - &cert, - &key, - "location / { root /tmp; default_type text/plain; }", - ); - - let location = first_server(&parse_doc(&conf)) - .children("location") - .expect("location exists")[0] - .clone(); - - assert_eq!( - location - .require::("default_type") - .expect("default_type should be typed") - .0 - .to_str() - .unwrap(), - "text/plain" - ); - - cleanup_temp_files(&[&cert, &key]); - } - - #[test] - fn parse_location_alias_index_and_ssh_deny_and_gzip_types() { - let cert = create_temp_file("loc_alias_index_cert"); - let key = create_temp_file("loc_alias_index_key"); - let conf = build_server_conf( - &cert, - &key, - "location /assets { alias /mnt/assets; index index.html index.txt; ssh_deny ip1 ip2; gzip on; gzip_vary off; gzip_types txt css; }", - ); - - let location = first_server(&parse_doc(&conf)) - .children("location") - .expect("location exists")[0] - .clone(); - - assert_eq!( - location - .require::("alias") - .expect("alias should be typed") - .0, - PathBuf::from("/mnt/assets") - ); - assert_eq!( - location - .require::("index") - .expect("index should be typed") - .0, - vec!["index.html", "index.txt"], - ); - assert_eq!( - location - .require::("ssh_deny") - .expect("ssh_deny should be typed") - .0, - vec!["ip1", "ip2"] - ); - assert!(location.require::("gzip").expect("gzip").0); - assert!( - !location - .require::("gzip_vary") - .expect("gzip_vary") - .0 - ); - assert_eq!( - location - .require::("gzip_types") - .expect("gzip_types should be typed") - .0, - vec!["txt", "css"], - ); - - cleanup_temp_files(&[&cert, &key]); - } - - #[test] - fn parse_location_pattern_variants_from_context_payload() { - let cert = create_temp_file("pattern_cert"); - let key = create_temp_file("pattern_key"); - let conf = build_server_conf( - &cert, - &key, - "location / { root /tmp; }\nlocation = /login { root /var; }\nlocation ~ \\.(gif|png)$ { root /var/www; }", - ); - - let server = first_server(&parse_doc(&conf)); - let locations = server.children("location").expect("location should exist"); - - let common = locations[0] - .payload::() - .expect("common payload") - .expect("common location payload"); - let exact = locations[1] - .payload::() - .expect("exact payload") - .expect("exact location payload"); - let regex = locations[2] - .payload::() - .expect("regex payload") - .expect("regex location payload"); - - assert!(matches!(common.as_ref(), Pattern::Common)); - assert!(matches!(exact.as_ref(), Pattern::Exact(_))); - assert!(matches!(regex.as_ref(), Pattern::Regex(_))); - - cleanup_temp_files(&[&cert, &key]); - } - - #[test] - fn parse_location_pattern_rejects_invalid_argument_count() { - let cert = create_temp_file("pattern_invalid_cert"); - let key = create_temp_file("pattern_invalid_key"); - let conf = build_server_conf(&cert, &key, "location /foo bar baz { root /tmp; }"); - - let failure = crate::parse::parse_config_str_for_test(&conf) - .expect_err("pattern with invalid arg count should fail"); - - assert!( - snafu::Report::from_error(&failure.error) - .to_string() - .contains("number of location args must be 1 or 2") - ); - - cleanup_temp_files(&[&cert, &key]); - } - - #[test] - fn parse_https_proxy_with_optional_trusted_certificate() { - let cert = create_temp_file("https_cert"); - let key = create_temp_file("https_key"); - let trusted = create_temp_file("trusted_ca"); - let conf = build_proxy_conf( - &cert, - &key, - &format!( - "proxy_pass https://backend.example.com; proxy_ssl_trusted_certificate {};", - trusted.display() - ), - ); - - let document = parse_doc(&conf); - let location = first_server(&document) - .children("location") - .expect("location exists")[0] - .clone(); - - assert!( - location - .require::("proxy_pass") - .is_ok() - ); - assert!( - location - .require::("proxy_ssl_trusted_certificate") - .is_ok() - ); - - cleanup_temp_files(&[&cert, &key, &trusted]); - } - - #[test] - fn parse_proxy_ssl_certificate_requires_matching_key() { - let cert = create_temp_file("pair_cert"); - let key = create_temp_file("pair_key"); - let client_cert = create_temp_file("client_cert"); - let conf = build_proxy_conf( - &cert, - &key, - &format!( - "proxy_pass https://backend.example.com; proxy_ssl_certificate {};", - client_cert.display() - ), - ); - - let failure = crate::parse::parse_config_str_for_test(&conf) - .expect_err("missing proxy key should fail"); - - assert!( - snafu::Report::from_error(&failure.error) - .to_string() - .contains("failed to finalize configuration context") - ); - - cleanup_temp_files(&[&cert, &key, &client_cert]); - } - - #[test] - fn parse_http_proxy_allows_proxy_ssl_directives() { - let cert = create_temp_file("http_cert"); - let key = create_temp_file("http_key"); - let client_cert = create_temp_file("http_client_cert"); - let client_key = create_temp_file("http_client_key"); - let trusted = create_temp_file("http_trusted"); - let conf = build_proxy_conf( - &cert, - &key, - &format!( - "proxy_pass http://backend.example.com; proxy_ssl_certificate {}; proxy_ssl_certificate_key {}; proxy_ssl_trusted_certificate {};", - client_cert.display(), - client_key.display(), - trusted.display() - ), - ); - - let document = parse_doc(&conf); - let location = first_server(&document) - .children("location") - .expect("location exists")[0] - .clone(); - - assert!( - location - .require::("proxy_ssl_certificate") - .is_ok() - ); - assert!( - location - .require::("proxy_ssl_certificate_key") - .is_ok() - ); - - cleanup_temp_files(&[&cert, &key, &client_cert, &client_key, &trusted]); - } - - #[test] - fn parse_regex_location_rejects_proxy_pass_with_explicit_uri() { - let cert = create_temp_file("regex_proxy_uri_cert"); - let key = create_temp_file("regex_proxy_uri_key"); - let conf = build_server_conf( - &cert, - &key, - r"location ~ \.php$ { proxy_pass http://backend.example.com/base/; }", - ); - - let failure = crate::parse::parse_config_str_for_test(&conf) - .expect_err("regex location proxy_pass with uri should fail"); - let report = snafu::Report::from_error(&failure.error).to_string(); - assert!(report.contains("proxy_pass cannot include a uri part inside regex location")); - - cleanup_temp_files(&[&cert, &key]); - } - - #[test] - fn parse_regex_location_allows_proxy_pass_without_explicit_uri() { - let cert = create_temp_file("regex_proxy_no_uri_cert"); - let key = create_temp_file("regex_proxy_no_uri_key"); - let conf = build_server_conf( - &cert, - &key, - r"location ~ \.php$ { proxy_pass http://backend.example.com; }", - ); - - let location = first_server(&parse_doc(&conf)) - .children("location") - .expect("location exists")[0] - .clone(); - let proxy_pass = location - .require::("proxy_pass") - .expect("proxy_pass should be typed"); - assert!(!proxy_pass.has_explicit_uri()); - - cleanup_temp_files(&[&cert, &key]); - } - - #[test] - fn parse_header_rules_accepts_add_header_and_proxy_set_header() { - let cert = create_temp_file("header_rules_accept_cert"); - let key = create_temp_file("header_rules_accept_key"); - let conf = build_proxy_conf( - &cert, - &key, - "proxy_set_header X-Trace test; add_header X-Custom yes always;", - ); - - let location = first_server(&parse_doc(&conf)) - .children("location") - .expect("location exists")[0] - .clone(); - - let proxy_set = location - .require::("proxy_set_header") - .expect("proxy_set_header should be typed"); - assert_eq!(proxy_set.0.len(), 1); - assert_eq!(proxy_set.0[0].name.as_str(), "x-trace"); - assert_eq!( - proxy_set.0[0] - .value - .to_str() - .expect("header value should be valid"), - "test" - ); - assert!(proxy_set.0[0].always); - - let add_header_rules = location - .get_all::("add_header") - .expect("add_header values should be typed"); - let header_names: Vec<_> = add_header_rules - .iter() - .flat_map(|entry| entry.0.iter()) - .map(|rule| rule.name.to_string()) - .collect(); - assert!( - header_names - .iter() - .any(|name| name.eq_ignore_ascii_case("x-custom")) - ); - assert!( - header_names - .iter() - .any(|name| name.eq_ignore_ascii_case("server")) - ); - - cleanup_temp_files(&[&cert, &key]); - } - - #[test] - fn parse_header_rules_rejects_invalid_always_marker() { - let cert = create_temp_file("header_rules_always_cert"); - let key = create_temp_file("header_rules_always_key"); - let conf = build_proxy_conf(&cert, &key, "add_header X-Trace test sometimes;"); - - let failure = crate::parse::parse_config_str_for_test(&conf) - .expect_err("invalid header add_marker should fail"); - - assert!( - snafu::Report::from_error(&failure.error) - .to_string() - .contains("invalid add_header always marker") - ); - - cleanup_temp_files(&[&cert, &key]); - } - - #[tokio::test] - async fn parse_location_root_is_relative_to_source_file() { - let dir = std::env::temp_dir().join(format!( - "gateway-relative-root-{}-{}", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .expect("clock after epoch") - .as_nanos(), - )); - std::fs::create_dir_all(&dir).expect("create temp dir"); - std::fs::write(dir.join("server.crt"), "dummy cert").expect("write cert"); - std::fs::write(dir.join("server.key"), "dummy key").expect("write key"); - std::fs::write( - dir.join("server.conf"), - "server {\n listen all 5378;\n server_name example.com;\n ssl_certificate ./server.crt;\n ssl_certificate_key ./server.key;\n location / { root .; }\n}\n", - ) - .expect("write config"); - - let registry = crate::parse::default_registry(); - let parsed = crate::parse::load_config_file( - &dir.join("server.conf"), - ®istry, - crate::parse::registry::BuildOptions::default(), - ) - .await - .expect("config should load"); - - let server = parsed.root.children("server").expect("server children")[0].clone(); - let location = server.children("location").expect("location children")[0].clone(); - assert_eq!( - location - .require::("root") - .expect("root should be typed") - .0, - dir - ); - } - - #[tokio::test] - async fn parse_included_location_root_is_relative_to_included_file() { - let dir = std::env::temp_dir().join(format!( - "gateway-relative-include-root-{}-{}", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .expect("clock after epoch") - .as_nanos(), - )); - std::fs::create_dir_all(dir.join("sites")).expect("create sites dir"); - std::fs::write(dir.join("server.crt"), "dummy cert").expect("write cert"); - std::fs::write(dir.join("server.key"), "dummy key").expect("write key"); - std::fs::write( - dir.join("server.conf"), - "server {\n listen all 5378;\n server_name example.com;\n ssl_certificate ./server.crt;\n ssl_certificate_key ./server.key;\n include sites/static.conf;\n}\n", - ) - .expect("write root config"); - std::fs::write(dir.join("sites/static.conf"), "location / { root .; }\n") - .expect("write included config"); - - let registry = crate::parse::default_registry(); - let parsed = crate::parse::load_config_file( - &dir.join("server.conf"), - ®istry, - crate::parse::registry::BuildOptions::default(), - ) - .await - .expect("config should load"); - - let server = parsed.root.children("server").expect("server children")[0].clone(); - let location = server.children("location").expect("location children")[0].clone(); - assert_eq!( - location - .require::("root") - .expect("root should be typed") - .0, - dir.join("sites") - ); - } -} diff --git a/gateway/src/parse/builtin/net.rs b/gateway/src/parse/builtin/net.rs index 623cc577..4e933c2e 100644 --- a/gateway/src/parse/builtin/net.rs +++ b/gateway/src/parse/builtin/net.rs @@ -5,7 +5,7 @@ use snafu::{ResultExt, Snafu}; use crate::parse::{ builtin::core::{first_arg_span, only_arg}, - registry::{DirectiveInput, DirectiveValue}, + decode::{DirectiveInput, DirectiveValue}, source::SourceSpan, types::{ ClientNameConfig, IfaceRange, IpFamilies, ListenConfig, Listens, ResolverConfig, @@ -318,19 +318,29 @@ fn ip_families_from_value(value: &str, span: SourceSpan) -> Result Result { + let text = format!("listen {value};"); + let directives = crate::parse::grammar::parse_source(&text, SourceId(0)).unwrap(); + let source_map = SourceMap::default(); + SocketAddrs::try_from(&DirectiveInput { + directive: &directives[0], + context: ConfigContext::Pishoo, + source_map: &source_map, + }) + } #[test] fn parse_socket_addrs_keeps_multiple_addresses() { - let conf = - "pishoo { proxy { listen 127.0.0.1:8080,127.0.0.2:8081; allow any; deny none; } }"; - - let document = parse_doc(conf); - let pishoo = document.root.children("pishoo").expect("pishoo children")[0].clone(); - let proxy = pishoo.children("proxy").expect("proxy should exist")[0].clone(); - assert_eq!( - proxy.require::("listen").unwrap().0, + parse_socket_addrs("127.0.0.1:8080,127.0.0.2:8081") + .unwrap() + .0, vec![ "127.0.0.1:8080" .parse() @@ -344,31 +354,13 @@ mod tests { #[test] fn parse_socket_addrs_rejects_invalid_address() { - let conf = "pishoo { proxy { listen not-a-socket; } }"; - - let failure = crate::parse::parse_config_str_for_test(conf) - .expect_err("invalid socket addr should fail"); - - assert!( - snafu::Report::from_error(&failure.error) - .to_string() - .contains("failed to parse directive `listen`") - ); + assert!(parse_socket_addrs("not-a-socket").is_err()); } #[test] fn parse_address_directives_keep_socket_addrs_type() { - let conf = "pishoo { proxy { listen 127.0.0.1:8080; } }"; - - let document = parse_doc(conf); - let pishoo = document.root.children("pishoo").expect("pishoo children")[0].clone(); - let proxy = pishoo.children("proxy").expect("proxy should exist")[0].clone(); - assert_eq!( - proxy - .require::("listen") - .expect("proxy listen should be typed") - .0, + parse_socket_addrs("127.0.0.1:8080").unwrap().0, vec!["127.0.0.1:8080".parse().expect("address should parse")] ); } diff --git a/gateway/src/parse/builtin/pishoo.rs b/gateway/src/parse/builtin/pishoo.rs deleted file mode 100644 index 6f226150..00000000 --- a/gateway/src/parse/builtin/pishoo.rs +++ /dev/null @@ -1,171 +0,0 @@ -use crate::parse::{ - registry::{ConfigRegistry, DirectiveSpec, MergePolicy, context}, - types::{ - AccessRulesUri, BoolConfig, DefaultType, GzipCompLevel, GzipMinLength, MimeTypes, - PathConfig, StringList, - }, -}; - -pub fn register(registry: &mut ConfigRegistry) { - registry.register_context(crate::parse::registry::ContextSpec { - key: context::PISHOO, - finalize: None, - }); - registry.register_directive( - context::ROOT, - DirectiveSpec::context_empty( - "pishoo", - vec![context::ROOT], - context::PISHOO, - MergePolicy::Append, - ), - ); - register_leaf::(registry, "pid"); - register_leaf::(registry, "workers"); - register_leaf::(registry, "groups"); - register_leaf::(registry, "access_rules"); - register_leaf::(registry, "gzip"); - register_leaf::(registry, "gzip_vary"); - register_leaf::(registry, "gzip_min_length"); - register_leaf::(registry, "gzip_comp_level"); - register_leaf::(registry, "gzip_types"); - register_leaf::(registry, "default_type"); - registry.register_directive( - context::PISHOO, - DirectiveSpec::raw_value::( - "types", - vec![context::PISHOO], - MergePolicy::RejectDuplicate, - ), - ); -} - -fn register_leaf(registry: &mut ConfigRegistry, name: &'static str) -where - T: crate::parse::registry::DirectiveValue, - for<'input, 'directive> T: TryFrom< - &'input crate::parse::registry::DirectiveInput<'directive>, - Error = ::Error, - >, -{ - registry.register_directive( - context::PISHOO, - DirectiveSpec::leaf_value::(name, vec![context::PISHOO], MergePolicy::RejectDuplicate), - ); -} - -#[cfg(test)] -mod tests { - use std::path::PathBuf; - - use crate::parse::{ - tests::{first_pishoo, parse_doc}, - types::{AccessRulesUri, MimeTypes, PathConfig, StringList}, - }; - - #[test] - fn parse_pishoo_groups_collects_all_values() { - let pishoo = first_pishoo(&parse_doc("pishoo { groups admin viewer api; }")); - - let groups = pishoo - .require::("groups") - .expect("groups should be typed"); - assert_eq!(groups.0, vec!["admin", "viewer", "api"]); - } - - #[test] - fn parse_pishoo_gzip_types_collects_extensions() { - let pishoo = first_pishoo(&parse_doc("pishoo { gzip_types txt css js; }")); - - let gzip_types = pishoo - .require::("gzip_types") - .expect("gzip_types should be typed"); - assert_eq!(gzip_types.0, vec!["txt", "css", "js"]); - } - - #[test] - fn parse_pishoo_types_block_parses_mime_types() { - let pishoo = first_pishoo(&parse_doc( - "pishoo { types { text/plain txt; application/json json; } }", - )); - - let types = pishoo - .require::("types") - .expect("types should be typed") - .0 - .clone(); - - assert_eq!(types.get("txt").unwrap().to_str().unwrap(), "text/plain"); - assert_eq!( - types.get("json").unwrap().to_str().unwrap(), - "application/json" - ); - } - - #[test] - fn parse_string_list_directive_collects_all_values() { - let conf = "pishoo { workers admin viewer api; }"; - let pishoo = first_pishoo(&parse_doc(conf)); - - let workers = pishoo - .require::("workers") - .expect("workers should be typed"); - assert_eq!(workers.0, vec!["admin", "viewer", "api"]); - } - - #[test] - fn parse_pid_and_access_rules_keep_domain_types() { - let pishoo = first_pishoo(&parse_doc( - "pishoo { pid /tmp/pishoo-test.pid; access_rules sqlite:///tmp/rules.db?mode=ro; }", - )); - - assert_eq!( - pishoo - .require::("pid") - .expect("pid should be typed") - .0, - PathBuf::from("/tmp/pishoo-test.pid") - ); - assert_eq!( - pishoo - .require::("access_rules") - .expect("access_rules should be typed") - .0 - .as_str(), - "sqlite:///tmp/rules.db?mode=ro" - ); - } - - #[tokio::test] - async fn parse_relative_pid_uses_root_config_dir() { - let dir = std::env::temp_dir().join(format!( - "gateway-relative-pid-{}-{}", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .expect("clock after epoch") - .as_nanos(), - )); - std::fs::create_dir_all(dir.join("run")).expect("create run dir"); - std::fs::write(dir.join("pishoo.conf"), "pishoo { pid ./run/pishoo.pid; }") - .expect("write config"); - - let registry = crate::parse::default_registry(); - let parsed = crate::parse::load_config_file( - &dir.join("pishoo.conf"), - ®istry, - crate::parse::registry::BuildOptions::default(), - ) - .await - .expect("config should load"); - - let pishoo = crate::parse::tests::first_pishoo(&parsed); - assert_eq!( - pishoo - .require::("pid") - .expect("pid should be typed") - .0, - dir.join("run/pishoo.pid") - ); - } -} diff --git a/gateway/src/parse/builtin/proxy.rs b/gateway/src/parse/builtin/proxy.rs deleted file mode 100644 index cb90a4da..00000000 --- a/gateway/src/parse/builtin/proxy.rs +++ /dev/null @@ -1,244 +0,0 @@ -use snafu::{Snafu, ensure}; - -use crate::parse::{ - document::ConfigNode, - registry::{BuildOptions, ConfigRegistry, DirectiveSpec, MergePolicy, context}, - source::SourceSpan, - types::{ - ClientNameConfig, DefaultType, MimeTypes, PathConfig, ResolverConfig, SocketAddrs, - StringList, - }, -}; - -#[derive(Debug, Snafu)] -#[snafu(module)] -pub enum FinalizeProxyError { - #[snafu(display("missing listen directive in proxy context"))] - MissingListen { span: SourceSpan }, -} - -pub fn register(registry: &mut ConfigRegistry) { - registry.register_context(crate::parse::registry::ContextSpec { - key: context::PROXY, - finalize: Some(finalize_proxy), - }); - registry.register_directive( - context::PISHOO, - DirectiveSpec::context_empty( - "proxy", - vec![context::PISHOO], - context::PROXY, - MergePolicy::Append, - ), - ); - register_leaf::(registry, "listen"); - register_leaf::(registry, "client_name"); - register_leaf::(registry, "dns"); - register_leaf::(registry, "ssl_certificate"); - register_leaf::(registry, "ssl_certificate_key"); - register_leaf::(registry, "allow"); - register_leaf::(registry, "deny"); - register_leaf::(registry, "default_type"); - registry.register_directive( - context::PROXY, - DirectiveSpec::raw_value::( - "types", - vec![context::PROXY], - MergePolicy::RejectDuplicate, - ), - ); -} - -fn register_leaf(registry: &mut ConfigRegistry, name: &'static str) -where - T: crate::parse::registry::DirectiveValue, - for<'input, 'directive> T: TryFrom< - &'input crate::parse::registry::DirectiveInput<'directive>, - Error = ::Error, - >, -{ - registry.register_directive( - context::PROXY, - DirectiveSpec::leaf_value::(name, vec![context::PROXY], MergePolicy::RejectDuplicate), - ); -} - -fn finalize_proxy( - node: &mut ConfigNode, - _options: &BuildOptions<'_>, -) -> Result<(), Box> { - ensure!( - !node.get_all_untyped("listen").is_empty(), - finalize_proxy_error::MissingListenSnafu { span: node.span } - ); - Ok(()) -} - -#[cfg(test)] -mod tests { - use crate::parse::{ - tests::{cleanup_temp_files, create_temp_file, first_pishoo, parse_doc}, - types::{ClientNameConfig, DefaultType, MimeTypes, ResolverConfig, StringList}, - }; - - #[test] - fn parse_client_name_directive_and_rejects_invalid_name() { - let cert = create_temp_file("client_name_cert"); - let key = create_temp_file("client_name_key"); - let conf = format!( - "pishoo {{ server {{ listen all 5378; server_name example.com; ssl_certificate {}; ssl_certificate_key {}; }} proxy {{ listen 127.0.0.1:8080; client_name good.example.com; }} }}", - cert.display(), - key.display() - ); - - let proxy = first_pishoo(&parse_doc(&conf)) - .children("proxy") - .expect("proxy should exist")[0] - .clone(); - - assert_eq!( - proxy - .require::("client_name") - .expect("client_name should be typed") - .0 - .as_partial(), - "good.example.com" - ); - - cleanup_temp_files(&[&cert, &key]); - } - - #[test] - fn parse_client_name_rejects_invalid_value() { - let conf = "pishoo { proxy { listen 127.0.0.1:8080; client_name not host@@; } }"; - - let failure = crate::parse::parse_config_str_for_test(conf) - .expect_err("invalid client_name should fail"); - - let report = snafu::Report::from_error(&failure.error).to_string(); - assert!( - report.contains("invalid client_name directive value") - || report.contains("invalid dhttp name") - || report.contains("failed to parse directive `client_name`") - ); - } - - #[test] - fn parse_default_type_in_proxy_context() { - let conf = "pishoo { proxy { listen 127.0.0.1:8080; default_type text/plain; } }"; - - let proxy = first_pishoo(&parse_doc(conf)) - .children("proxy") - .expect("proxy exists")[0] - .clone(); - - assert_eq!( - proxy - .require::("default_type") - .expect("default_type should be typed") - .0 - .to_str() - .unwrap(), - "text/plain" - ); - } - - #[test] - fn parse_mime_types_in_proxy_context() { - let conf = - "pishoo { proxy { listen 127.0.0.1:8080; types { text/plain txt; text/css css; } } }"; - - let proxy = first_pishoo(&parse_doc(conf)) - .children("proxy") - .expect("proxy exists")[0] - .clone(); - - let types = proxy - .require::("types") - .expect("types should be typed") - .0 - .clone(); - - assert_eq!(types.get("txt").unwrap().to_str().unwrap(), "text/plain"); - assert_eq!(types.get("css").unwrap().to_str().unwrap(), "text/css"); - } - - #[test] - fn parse_proxy_allow_and_deny_collects_values() { - let conf = "pishoo { proxy { listen 127.0.0.1:8080; allow admin viewer; deny none; } }"; - - let proxy = first_pishoo(&parse_doc(conf)) - .children("proxy") - .expect("proxy exists")[0] - .clone(); - - assert_eq!( - proxy - .require::("allow") - .expect("allow should be typed") - .0, - vec!["admin", "viewer"] - ); - assert_eq!( - proxy - .require::("deny") - .expect("deny should be typed") - .0, - vec!["none"] - ); - } - - #[test] - fn parse_proxy_dns_directive_accepts_h3_uri() { - let conf = - "pishoo { proxy { listen 127.0.0.1:8080; dns h3 https://dns.genmeta.net/dns-query; } }"; - - let proxy = first_pishoo(&parse_doc(conf)) - .children("proxy") - .expect("proxy exists")[0] - .clone(); - - assert_eq!( - proxy - .require::("dns") - .expect("dns should be typed") - .0 - .to_string(), - "https://dns.genmeta.net/dns-query" - ); - } - - #[test] - fn parse_proxy_ssl_certificate_and_key_path_types() { - let client_cert = create_temp_file("proxy_client_ssl_cert"); - let client_key = create_temp_file("proxy_client_ssl_key"); - - let conf = format!( - "pishoo {{ proxy {{ listen 127.0.0.1:8080; ssl_certificate {}; ssl_certificate_key {}; }} }}", - client_cert.display(), - client_key.display() - ); - - let proxy = first_pishoo(&parse_doc(&conf)) - .children("proxy") - .expect("proxy exists")[0] - .clone(); - - assert_eq!( - proxy - .require::("ssl_certificate") - .expect("proxy ssl certificate should be typed") - .0, - client_cert.clone() - ); - assert_eq!( - proxy - .require::("ssl_certificate_key") - .expect("proxy ssl certificate key should be typed") - .0, - client_key.clone() - ); - - cleanup_temp_files(&[&client_cert, &client_key]); - } -} diff --git a/gateway/src/parse/builtin/server.rs b/gateway/src/parse/builtin/server.rs deleted file mode 100644 index b9e14970..00000000 --- a/gateway/src/parse/builtin/server.rs +++ /dev/null @@ -1,667 +0,0 @@ -use snafu::{OptionExt, Snafu, ensure}; - -use crate::parse::{ - document::ConfigNode, - registry::{BuildOptions, ConfigRegistry, ContextKey, DirectiveSpec, MergePolicy, context}, - source::SourceSpan, - types::{ - AccessRulesUri, BoolConfig, DefaultType, GzipCompLevel, GzipMinLength, ListenConfig, - MimeTypes, PathConfig, ResolverConfig, ServerName, ServerNames, StringList, - }, - value::TypedValue, -}; - -#[derive(Debug, Snafu)] -#[snafu(module)] -pub enum FinalizeServerError { - #[snafu(display("missing listen directive in server context"))] - MissingListen { span: SourceSpan }, - #[snafu(display("missing ssl_certificate directive in server context"))] - MissingCertificate { span: SourceSpan }, - #[snafu(display("missing ssl_certificate_key directive in server context"))] - MissingCertificateKey { span: SourceSpan }, -} - -pub fn register(registry: &mut ConfigRegistry) { - registry.register_context(crate::parse::registry::ContextSpec { - key: context::SERVER, - finalize: Some(finalize_server), - }); - registry.register_directive(context::ROOT, server_block(context::ROOT)); - registry.register_directive(context::PISHOO, server_block(context::PISHOO)); - register_server_leaf::(registry, "listen", MergePolicy::Append); - register_server_leaf::(registry, "server_name", MergePolicy::RejectDuplicate); - register_server_leaf::(registry, "dns", MergePolicy::RejectDuplicate); - register_server_leaf::(registry, "gzip", MergePolicy::RejectDuplicate); - register_server_leaf::(registry, "gzip_vary", MergePolicy::RejectDuplicate); - register_server_leaf::( - registry, - "gzip_min_length", - MergePolicy::RejectDuplicate, - ); - register_server_leaf::( - registry, - "gzip_comp_level", - MergePolicy::RejectDuplicate, - ); - register_server_leaf::(registry, "gzip_types", MergePolicy::RejectDuplicate); - register_server_leaf::(registry, "ssl_certificate", MergePolicy::RejectDuplicate); - register_server_leaf::( - registry, - "ssl_certificate_key", - MergePolicy::RejectDuplicate, - ); - register_server_leaf::(registry, "default_type", MergePolicy::RejectDuplicate); - register_server_leaf::(registry, "access_rules", MergePolicy::RejectDuplicate); - register_server_leaf::(registry, "relay", MergePolicy::RejectDuplicate); - register_server_leaf::(registry, "stun", MergePolicy::RejectDuplicate); - registry.register_directive( - context::SERVER, - DirectiveSpec::raw_value::( - "types", - vec![context::SERVER], - MergePolicy::RejectDuplicate, - ), - ); -} - -fn server_block(parent: ContextKey) -> DirectiveSpec { - DirectiveSpec::context_empty("server", vec![parent], context::SERVER, MergePolicy::Append) -} - -fn register_server_leaf(registry: &mut ConfigRegistry, name: &'static str, merge: MergePolicy) -where - T: crate::parse::registry::DirectiveValue, - for<'input, 'directive> T: TryFrom< - &'input crate::parse::registry::DirectiveInput<'directive>, - Error = ::Error, - >, -{ - registry.register_directive( - context::SERVER, - DirectiveSpec::leaf_value::(name, vec![context::SERVER], merge), - ); -} - -fn finalize_server( - node: &mut ConfigNode, - options: &BuildOptions<'_>, -) -> Result<(), Box> { - ensure!( - !node.get_all::("listen")?.is_empty(), - finalize_server_error::MissingListenSnafu { span: node.span } - ); - if let Some(identity_profile) = options.identity_profile - && node.get::("server_name")?.is_none() - { - node.insert_slot( - "server_name", - TypedValue::new( - ServerNames(vec![ServerName { - name: identity_profile.name().to_owned(), - }]), - node.span, - ), - ); - } - - let has_cert = node.get::("ssl_certificate")?.is_some(); - let has_key = node.get::("ssl_certificate_key")?.is_some(); - match (has_cert, has_key, options.has_dhttp_home_context()) { - (true, true, _) => Ok(()), - (false, false, true) => Ok(()), - (false, _, _) => { - node.get::("ssl_certificate")? - .context(finalize_server_error::MissingCertificateSnafu { span: node.span })?; - Ok(()) - } - (_, false, _) => { - node.get::("ssl_certificate_key")? - .context(finalize_server_error::MissingCertificateKeySnafu { span: node.span })?; - Ok(()) - } - } -} - -#[cfg(test)] -mod tests { - use crate::parse::{ - tests::{ - assert_error_chain_display_single_line, build_server_conf, cleanup_temp_files, - create_temp_file, first_server, parse_doc, - }, - types::{ - AccessRulesUri, BoolConfig, DefaultType, GzipCompLevel, ListenConfig, MimeTypes, - PathConfig, ResolverConfig, ServerNames, - }, - }; - - #[test] - fn parse_server_rejects_legacy_server_id_directive() { - let cert = create_temp_file("legacy_server_id_cert"); - let key = create_temp_file("legacy_server_id_key"); - let conf = format!( - "pishoo {{ server {{ listen all 5378; server_name example.com; server_id 1; ssl_certificate {}; ssl_certificate_key {}; }} }}", - cert.display(), - key.display() - ); - - let error = crate::parse::parse_config_str_for_test(&conf) - .expect_err("legacy server_id directive must be rejected"); - - let report = snafu::Report::from_error(&error).to_string(); - assert!( - report.contains("server_id"), - "error report should name the rejected directive: {report}" - ); - assert_error_chain_display_single_line(&error); - cleanup_temp_files(&[&cert, &key]); - } - - #[test] - fn parse_bool_directives_accept_on_off_values() { - let cert = create_temp_file("bool_directive_cert"); - let key = create_temp_file("bool_directive_key"); - let conf = build_server_conf(&cert, &key, "gzip on; gzip_vary off; relay on; stun off;"); - - let server = first_server(&parse_doc(&conf)); - - assert!(server.require::("gzip").unwrap().0); - assert!(!server.require::("gzip_vary").unwrap().0); - assert!(server.require::("relay").unwrap().0); - assert!(!server.require::("stun").unwrap().0); - - cleanup_temp_files(&[&cert, &key]); - } - - #[test] - fn parse_server_path_directives_preserve_path_values() { - let cert = create_temp_file("server_path_cert"); - let key = create_temp_file("server_path_key"); - let conf = build_server_conf(&cert, &key, ""); - - let server = first_server(&parse_doc(&conf)); - - assert_eq!( - server - .require::("ssl_certificate") - .expect("ssl_certificate should be typed") - .0, - cert.clone() - ); - assert_eq!( - server - .require::("ssl_certificate_key") - .expect("ssl_certificate_key should be typed") - .0, - key.clone() - ); - - cleanup_temp_files(&[&cert, &key]); - } - - fn unique_test_dir(label: &str) -> std::path::PathBuf { - let nanos = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .expect("clock after epoch") - .as_nanos(); - std::env::temp_dir().join(format!("pishoo-{label}-{nanos}")) - } - - fn first_root_server( - document: &crate::parse::document::ConfigDocument, - ) -> std::sync::Arc { - document.root.children("server").expect("server children")[0].clone() - } - - #[test] - fn identity_profile_server_conf_can_omit_name_and_tls_with_home_context() { - let home_path = unique_test_dir("identity-profile-server-conf"); - let home = dhttp::home::DhttpHome::new(home_path); - let name = dhttp::name::DhttpName::try_from("alice.dhttp.net".to_owned()).unwrap(); - let profile = home.identity_profile(name.clone()); - std::fs::create_dir_all(profile.ssl_dir()).expect("create ssl dir"); - - let document = crate::parse::load_config_text( - "server { listen all 443; location / { root .; } }", - Some(profile.path()), - &crate::parse::default_registry(), - crate::parse::registry::BuildOptions { - dhttp_home: Some(&home), - identity_profile: Some(&profile), - }, - ) - .expect("identity service config should parse"); - - let server = first_root_server(&document); - let names = server.require::("server_name").unwrap(); - assert_eq!(names.0[0].name, name); - assert!( - server - .get::("ssl_certificate") - .unwrap() - .is_none() - ); - assert!( - server - .get::("ssl_certificate_key") - .unwrap() - .is_none() - ); - } - - #[test] - fn explicit_config_still_requires_tls_paths() { - let failure = crate::parse::parse_config_str_for_test( - "pishoo { server { listen all 443; server_name alice.dhttp.net; } }", - ) - .expect_err("standalone config must reject missing tls paths"); - - let report = snafu::Report::from_error(&failure.error).to_string(); - assert!(report.contains("missing ssl_certificate directive in server context")); - } - - #[test] - fn parse_server_accepts_default_type_and_access_rules() { - let cert = create_temp_file("server_ctx_access_cert"); - let key = create_temp_file("server_ctx_access_key"); - let conf = build_server_conf( - &cert, - &key, - "default_type application/octet-stream; access_rules sqlite:///tmp/access.db;", - ); - - let server = first_server(&parse_doc(&conf)); - - assert_eq!( - server - .require::("default_type") - .expect("default_type should be typed") - .0 - .to_str() - .unwrap(), - "application/octet-stream" - ); - assert_eq!( - server - .require::("access_rules") - .expect("access_rules should be typed") - .0 - .as_str(), - "sqlite:///tmp/access.db" - ); - - cleanup_temp_files(&[&cert, &key]); - } - - #[test] - fn parse_server_accepts_gzip_comp_level() { - let cert = create_temp_file("server_gzip_comp_level_cert"); - let key = create_temp_file("server_gzip_comp_level_key"); - let conf = build_server_conf(&cert, &key, "gzip_comp_level 6;"); - - let server = first_server(&parse_doc(&conf)); - - assert_eq!( - server - .require::("gzip_comp_level") - .expect("gzip_comp_level should be typed") - .0, - 6 - ); - - cleanup_temp_files(&[&cert, &key]); - } - - #[test] - fn parse_resolver_config_rejects_deprecated_kind() { - let cert = create_temp_file("resolver_deprecated_cert"); - let key = create_temp_file("resolver_deprecated_key"); - let conf = build_server_conf(&cert, &key, "dns udp https://dns.example.com/dns-query;"); - - let failure = crate::parse::parse_config_str_for_test(&conf) - .expect_err("deprecated dns resolver kind should fail"); - - assert!( - snafu::Report::from_error(&failure.error) - .to_string() - .contains("deprecated resolver kind `udp`") - ); - - cleanup_temp_files(&[&cert, &key]); - } - - #[test] - fn parse_resolver_config_rejects_unsupported_kind() { - let cert = create_temp_file("resolver_unsupported_cert"); - let key = create_temp_file("resolver_unsupported_key"); - let conf = build_server_conf(&cert, &key, "dns tcp https://dns.example.com/dns-query;"); - - let failure = crate::parse::parse_config_str_for_test(&conf) - .expect_err("unsupported dns resolver kind should fail"); - - assert!( - snafu::Report::from_error(&failure.error) - .to_string() - .contains("unsupported resolver kind `tcp`") - ); - - cleanup_temp_files(&[&cert, &key]); - } - - #[test] - fn parse_resolver_config_rejects_invalid_uri() { - let cert = create_temp_file("resolver_invalid_uri_cert"); - let key = create_temp_file("resolver_invalid_uri_key"); - let conf = build_server_conf(&cert, &key, "dns h3 http://[::1;"); - - let failure = crate::parse::parse_config_str_for_test(&conf) - .expect_err("resolver URI should parse fail"); - - assert!( - snafu::Report::from_error(&failure.error) - .to_string() - .contains("invalid dns resolver uri directive value") - ); - - cleanup_temp_files(&[&cert, &key]); - } - - #[test] - fn parse_dns_resolver_and_publisher() { - let cert = create_temp_file("dns_cert"); - let key = create_temp_file("dns_key"); - let conf = format!( - "pishoo {{ server {{ listen all 5378; server_name example.com; dns h3 https://dns.example.com/dns-query; ssl_certificate {}; ssl_certificate_key {}; }} }}", - cert.display(), - key.display() - ); - - let document = parse_doc(&conf); - let server = first_server(&document); - let resolver = server - .require::("dns") - .expect("dns should exist"); - - assert_eq!(resolver.0.to_string(), "https://dns.example.com/dns-query"); - - cleanup_temp_files(&[&cert, &key]); - } - - #[test] - fn parse_server_names_with_multiple_values() { - let cert = create_temp_file("server_names_cert"); - let key = create_temp_file("server_names_key"); - let conf = format!( - "pishoo {{ server {{ listen all 5378; server_name main.example.com backup.example.com; ssl_certificate {}; ssl_certificate_key {}; }} }}", - cert.display(), - key.display() - ); - - let document = parse_doc(&conf); - let server = first_server(&document); - let names = server - .require::("server_name") - .expect("server_name should be typed") - .0 - .clone(); - - assert_eq!(names.len(), 2); - assert_eq!(names[0].name.as_partial(), "main.example.com"); - assert_eq!(names[1].name.as_partial(), "backup.example.com"); - - cleanup_temp_files(&[&cert, &key]); - } - - #[test] - fn parse_server_names_rejects_invalid_value() { - let cert = create_temp_file("server_names_invalid_cert"); - let key = create_temp_file("server_names_invalid_key"); - let conf = build_server_conf(&cert, &key, "server_name invalid_host@@; "); - - let failure = crate::parse::parse_config_str_for_test(&conf) - .expect_err("invalid server_name should fail"); - - assert!( - snafu::Report::from_error(&failure.error) - .to_string() - .contains("invalid server_name directive value") - ); - - cleanup_temp_files(&[&cert, &key]); - } - - #[test] - fn parse_listen_config_with_interface_only() { - let cert = create_temp_file("listen_interface_only_cert"); - let key = create_temp_file("listen_interface_only_key"); - let conf = format!( - "pishoo {{ server {{ listen all; server_name example.com; ssl_certificate {}; ssl_certificate_key {}; }} }}", - cert.display(), - key.display() - ); - - let server = first_server(&parse_doc(&conf)); - let listen = server - .require::("listen") - .expect("listen should be typed") - .0[0] - .clone(); - assert_eq!(listen.port, 0); - assert_eq!(listen.range, crate::parse::types::IfaceRange::All); - - cleanup_temp_files(&[&cert, &key]); - } - - #[test] - fn parse_listen_config_with_address_and_families() { - let cert = create_temp_file("listen_address_cert"); - let key = create_temp_file("listen_address_key"); - let conf = format!( - "pishoo {{ server {{ listen 127.0.0.1:443,127.0.0.2:8443; server_name example.com; ssl_certificate {}; ssl_certificate_key {}; }} }}", - cert.display(), - key.display() - ); - - let server = first_server(&parse_doc(&conf)); - let listen = server - .require::("listen") - .expect("listen should be typed"); - - assert_eq!(listen.0.len(), 1); - let only = &listen.0[0]; - assert_eq!(only.port, 0); - assert!(only.specific_addrs.is_some()); - - cleanup_temp_files(&[&cert, &key]); - } - - #[test] - fn parse_listen_config_with_interface_and_port() { - let cert = create_temp_file("listen_iface_port_cert"); - let key = create_temp_file("listen_iface_port_key"); - let conf = format!( - "pishoo {{ server {{ listen all 8443; server_name example.com; ssl_certificate {}; ssl_certificate_key {}; }} }}", - cert.display(), - key.display() - ); - - let server = first_server(&parse_doc(&conf)); - let listen = server - .require::("listen") - .expect("listen should be typed") - .0[0] - .clone(); - assert_eq!(listen.port, 8443); - - cleanup_temp_files(&[&cert, &key]); - } - - #[test] - fn parse_listen_config_rejects_invalid_family() { - let cert = create_temp_file("listen_bad_family_cert"); - let key = create_temp_file("listen_bad_family_key"); - let conf = build_server_conf(&cert, &key, "listen all ipv9 8443;"); - - let failure = - crate::parse::parse_config_str_for_test(&conf).expect_err("invalid family should fail"); - - assert!( - snafu::Report::from_error(&failure.error) - .to_string() - .contains("invalid listen ip family") - ); - - cleanup_temp_files(&[&cert, &key]); - } - - #[test] - fn parse_listen_config_with_interface_and_family() { - let cert = create_temp_file("listen_iface_family_cert"); - let key = create_temp_file("listen_iface_family_key"); - let conf = format!( - "pishoo {{ server {{ listen eth0 v4only; server_name example.com; ssl_certificate {}; ssl_certificate_key {}; }} }}", - cert.display(), - key.display() - ); - - let server = first_server(&parse_doc(&conf)); - let listen = server - .require::("listen") - .expect("listen should be typed") - .0[0] - .clone(); - - assert_eq!(listen.port, 0); - assert_eq!(listen.families, crate::parse::types::IpFamilies::V4); - assert_eq!( - listen.range, - crate::parse::types::IfaceRange::Exact("eth0".to_owned()) - ); - - cleanup_temp_files(&[&cert, &key]); - } - - #[test] - fn parse_listen_config_with_interface_family_and_port() { - let cert = create_temp_file("listen_iface_family_port_cert"); - let key = create_temp_file("listen_iface_family_port_key"); - let conf = format!( - "pishoo {{ server {{ listen eth0 v6only 9443; server_name example.com; ssl_certificate {}; ssl_certificate_key {}; }} }}", - cert.display(), - key.display() - ); - - let server = first_server(&parse_doc(&conf)); - let listen = server - .require::("listen") - .expect("listen should be typed") - .0[0] - .clone(); - - assert_eq!(listen.port, 9443); - assert_eq!(listen.families, crate::parse::types::IpFamilies::V6); - assert_eq!( - listen.range, - crate::parse::types::IfaceRange::Exact("eth0".to_owned()) - ); - - cleanup_temp_files(&[&cert, &key]); - } - - #[test] - fn parse_listen_config_rejects_too_many_arguments() { - let cert = create_temp_file("listen_too_many_args_cert"); - let key = create_temp_file("listen_too_many_args_key"); - let conf = build_server_conf(&cert, &key, "listen all v4only 443 extra;"); - - let failure = crate::parse::parse_config_str_for_test(&conf) - .expect_err("too many listen args should fail"); - - assert!( - snafu::Report::from_error(&failure.error) - .to_string() - .contains("invalid listen directive argument count") - ); - - cleanup_temp_files(&[&cert, &key]); - } - - #[test] - fn parse_gzip_comp_level_rejects_invalid_value() { - let conf = "pishoo { server { listen all 5378; server_name example.com; ssl_certificate /tmp/server.pem; ssl_certificate_key /tmp/key.pem; gzip_comp_level bad; } }"; - - let failure = crate::parse::parse_config_str_for_test(conf) - .expect_err("invalid gzip_comp_level should fail"); - - assert!( - snafu::Report::from_error(&failure.error) - .to_string() - .contains("failed to parse directive `gzip_comp_level`") - ); - } - - #[test] - fn parse_mime_types_directive_parses_block() { - let cert = create_temp_file("mime_types_cert"); - let key = create_temp_file("mime_types_key"); - let conf = build_server_conf( - &cert, - &key, - "types { text/plain txt; application/json json; }", - ); - - let server = first_server(&parse_doc(&conf)); - let types = server - .require::("types") - .expect("types should be typed") - .0 - .clone(); - - assert_eq!(types.get("txt").unwrap().to_str().unwrap(), "text/plain"); - assert_eq!( - types.get("json").unwrap().to_str().unwrap(), - "application/json" - ); - - cleanup_temp_files(&[&cert, &key]); - } - - #[test] - fn parse_mime_types_rejects_non_block_form() { - let cert = create_temp_file("mime_types_invalid_cert"); - let key = create_temp_file("mime_types_invalid_key"); - let conf = build_server_conf(&cert, &key, "types text/plain txt;"); - - let failure = crate::parse::parse_config_str_for_test(&conf) - .expect_err("mime type map requires block form"); - - assert!( - snafu::Report::from_error(&failure.error) - .to_string() - .contains("types directive must be a block") - ); - - cleanup_temp_files(&[&cert, &key]); - } - - #[test] - fn parse_gzip_numeric_directives_reject_invalid_values() { - let cert = create_temp_file("gzip_numeric_cert"); - let key = create_temp_file("gzip_numeric_key"); - let conf = format!( - "pishoo {{ server {{ listen all 5378; server_name example.com; gzip_min_length invalid; ssl_certificate {}; ssl_certificate_key {}; }} }}", - cert.display(), - key.display() - ); - - let failure = crate::parse::parse_config_str_for_test(&conf) - .expect_err("invalid gzip number should fail"); - let report = snafu::Report::from_error(&failure.error).to_string(); - - assert!(report.contains("failed to parse directive `gzip_min_length`")); - assert_error_chain_display_single_line(&failure.error); - - cleanup_temp_files(&[&cert, &key]); - } -} diff --git a/gateway/src/parse/builtin/ssh.rs b/gateway/src/parse/builtin/ssh.rs index bf8f95a0..0491a21f 100644 --- a/gateway/src/parse/builtin/ssh.rs +++ b/gateway/src/parse/builtin/ssh.rs @@ -1,7 +1,7 @@ use snafu::Snafu; use crate::parse::{ - registry::{DirectiveInput, DirectiveValue}, + decode::{DirectiveInput, DirectiveValue}, source::SourceSpan, types::{SshLoginMethods, SshSslUser, SshSslUsers}, }; @@ -78,9 +78,8 @@ impl<'input, 'directive> TryFrom<&'input DirectiveInput<'directive>> for SshSslU #[cfg(test)] mod tests { - use crate::parse::{ - tests::{build_proxy_conf, cleanup_temp_files, create_temp_file, first_server, parse_doc}, - types::{SshLoginMethods, SshSslUsers}, + use crate::parse::tests::{ + build_proxy_conf, cleanup_temp_files, create_temp_file, first_location, }; #[test] @@ -89,16 +88,8 @@ mod tests { let key = create_temp_file("ssh_ssl_user_key"); let conf = build_proxy_conf(&cert, &key, "ssh_ssl_user alice aliceroot;"); - let location = first_server(&parse_doc(&conf)) - .children("location") - .expect("location exists")[0] - .clone(); - - let users = location - .require::("ssh_ssl_user") - .expect("ssh_ssl_user should be typed") - .0 - .clone(); + let location = first_location(&conf).unwrap(); + let users = &location.ssh_users()[0].0; assert_eq!(users[0].name, "alice"); assert_eq!(users[0].user, "aliceroot"); @@ -155,19 +146,9 @@ mod tests { let key = create_temp_file("ssh_login_key"); let conf = build_proxy_conf(&cert, &key, "ssh_login ssl;"); - let document = parse_doc(&conf); - let location = first_server(&document) - .children("location") - .expect("location exists")[0] - .clone(); - - assert_eq!( - location - .require::("ssh_login") - .expect("ssh_login should be typed") - .0, - vec!["ssl".to_owned()] - ); + let location = first_location(&conf).unwrap(); + + assert_eq!(location.ssh_login().unwrap().0, vec!["ssl".to_owned()]); cleanup_temp_files(&[&cert, &key]); } diff --git a/gateway/src/parse/builtin/stun.rs b/gateway/src/parse/builtin/stun.rs index 5f64fee3..2b1a6b22 100644 --- a/gateway/src/parse/builtin/stun.rs +++ b/gateway/src/parse/builtin/stun.rs @@ -3,12 +3,14 @@ use std::net::SocketAddr; use snafu::{ResultExt, Snafu}; use crate::parse::{ - builtin::core::{first_arg_span, only_arg}, - registry::{ - ConfigRegistry, DirectiveInput, DirectiveSpec, DirectiveValue, MergePolicy, context, + ast::AstBody, + builtin::{ + core::{first_arg_span, only_arg}, + net::SocketAddrsError, }, + decode::{DirectiveInput, DirectiveValue}, source::SourceSpan, - types::{SocketAddrs, StunBindConfigValue, StunChangePort}, + types::{SocketAddrs, StunBindConfigValue, StunChangePort, StunServerConfigValue}, }; #[derive(Debug, Snafu)] @@ -141,193 +143,186 @@ impl<'input, 'directive> TryFrom<&'input DirectiveInput<'directive>> for StunCha } } -pub fn register(registry: &mut ConfigRegistry) { - registry.register_context(crate::parse::registry::ContextSpec { - key: context::STUN_SERVER, - finalize: None, - }); - registry.register_directive( - context::SERVER, - DirectiveSpec::context_empty( - "stun_server", - vec![context::SERVER], - context::STUN_SERVER, - MergePolicy::Append, - ), - ); - registry.register_directive( - context::STUN_SERVER, - DirectiveSpec::leaf_value::( - "bind", - vec![context::STUN_SERVER], - MergePolicy::Append, - ), - ); - for name in ["outer_addr", "change_addr"] { - registry.register_directive( - context::STUN_SERVER, - DirectiveSpec::leaf_value::( - name, - vec![context::STUN_SERVER], - MergePolicy::RejectDuplicate, - ), - ); +#[derive(Debug, Snafu)] +#[snafu(module)] +pub enum StunServerConfigValueError { + #[snafu(display("stun_server must use block form"))] + ExpectedBlock { span: SourceSpan }, + #[snafu(display("unknown stun_server child directive `{name}`"))] + UnknownChild { name: String, span: SourceSpan }, + #[snafu(display("stun_server child directive `{name}` must use leaf form"))] + ExpectedLeaf { name: String, span: SourceSpan }, + #[snafu(display("duplicate stun_server fallback directive `{name}`"))] + DuplicateFallback { name: String, span: SourceSpan }, + #[snafu(display("invalid stun_server bind directive"))] + Bind { source: StunBindConfigValueError }, + #[snafu(display("invalid stun_server address fallback"))] + Address { source: SocketAddrsError }, + #[snafu(display("invalid stun_server change_port fallback"))] + ChangePort { source: StunChangePortError }, +} + +impl DirectiveValue for StunServerConfigValue { + type Error = StunServerConfigValueError; +} + +impl<'input, 'directive> TryFrom<&'input DirectiveInput<'directive>> for StunServerConfigValue { + type Error = StunServerConfigValueError; + + fn try_from(input: &'input DirectiveInput<'directive>) -> Result { + let AstBody::Block { children, .. } = &input.directive.body else { + return stun_server_config_value_error::ExpectedBlockSnafu { + span: input.directive.span, + } + .fail(); + }; + let mut binds = Vec::new(); + let mut outer_addr = None; + let mut change_addr = None; + let mut change_port = None; + for child in children { + if !child.is_leaf() { + return stun_server_config_value_error::ExpectedLeafSnafu { + name: child.name.value.clone(), + span: child.span, + } + .fail(); + } + let child_input = DirectiveInput { + directive: child, + context: input.context, + source_map: input.source_map, + }; + match child.name.value.as_str() { + "bind" => binds.push( + StunBindConfigValue::try_from(&child_input) + .context(stun_server_config_value_error::BindSnafu)?, + ), + "outer_addr" => { + if outer_addr.is_some() { + return stun_server_config_value_error::DuplicateFallbackSnafu { + name: child.name.value.clone(), + span: child.span, + } + .fail(); + } + outer_addr = SocketAddrs::try_from(&child_input) + .context(stun_server_config_value_error::AddressSnafu)? + .0 + .first() + .copied(); + } + "change_addr" => { + if change_addr.is_some() { + return stun_server_config_value_error::DuplicateFallbackSnafu { + name: child.name.value.clone(), + span: child.span, + } + .fail(); + } + change_addr = SocketAddrs::try_from(&child_input) + .context(stun_server_config_value_error::AddressSnafu)? + .0 + .first() + .copied(); + } + "change_port" => { + if change_port.is_some() { + return stun_server_config_value_error::DuplicateFallbackSnafu { + name: child.name.value.clone(), + span: child.span, + } + .fail(); + } + change_port = Some( + StunChangePort::try_from(&child_input) + .context(stun_server_config_value_error::ChangePortSnafu)? + .0, + ); + } + name => { + return stun_server_config_value_error::UnknownChildSnafu { + name: name.to_owned(), + span: child.name.span, + } + .fail(); + } + } + } + for bind in &mut binds { + bind.outer_addr = bind.outer_addr.or(outer_addr); + bind.change_addr = bind.change_addr.or(change_addr); + bind.change_port = bind.change_port.or(change_port); + } + Ok(Self { + binds: binds.into_boxed_slice(), + }) } - registry.register_directive( - context::STUN_SERVER, - DirectiveSpec::leaf_value::( - "change_port", - vec![context::STUN_SERVER], - MergePolicy::RejectDuplicate, - ), - ); } #[cfg(test)] mod tests { - use crate::parse::{ - tests::{ - assert_error_chain_display_single_line, cleanup_temp_files, create_temp_file, - first_server, parse_doc, - }, - types::{SocketAddrs, StunBindConfigValue, StunChangePort}, + use crate::parse::tests::{ + build_server_conf, cleanup_temp_files, create_temp_file, first_server, }; - #[test] - fn parse_stun_bind_and_ports() { - let cert = create_temp_file("stun_bind_cert"); - let key = create_temp_file("stun_bind_key"); - let conf = format!( - "pishoo {{ server {{ listen all 5378; server_name example.com; ssl_certificate {}; ssl_certificate_key {}; stun_server {{ bind 10.0.0.1:20000 outer_addr 10.0.0.2:20001 change_addr 10.0.0.3:20002 change_port 3478; }} }} }}", - cert.display(), - key.display() - ); - - let server = first_server(&parse_doc(&conf)); - let stun = server - .children("stun_server") - .expect("stun_server should exist")[0] - .clone(); - let bind = stun.require::("bind").unwrap(); - assert_eq!( - bind.bind, - "10.0.0.1:20000".parse::().unwrap() - ); - assert_eq!( - bind.outer_addr.unwrap(), - "10.0.0.2:20001".parse::().unwrap() - ); - assert_eq!( - bind.change_addr.unwrap(), - "10.0.0.3:20002".parse::().unwrap() - ); - assert_eq!(bind.change_port.unwrap(), 3478); - + fn parsed_server(stun: &str) -> crate::parse::config::ServerConfig { + let cert = create_temp_file("stun_compound_cert"); + let key = create_temp_file("stun_compound_key"); + let text = build_server_conf(&cert, &key, stun); + let server = first_server(&text).unwrap(); cleanup_temp_files(&[&cert, &key]); + server } #[test] - fn parse_stun_bind_config_rejects_unknown_option() { - let cert = create_temp_file("stun_bind_unknown_cert"); - let key = create_temp_file("stun_bind_unknown_key"); - let conf = format!( - "pishoo {{ server {{ listen all 5378; server_name example.com; ssl_certificate {}; ssl_certificate_key {}; stun_server {{ bind 10.0.0.1:20000 bad 10.0.0.1:20001; }} }} }}", - cert.display(), - key.display() + fn stun_compounds_preserve_block_and_bind_order() { + let server = parsed_server( + "stun_server { bind 127.0.0.1:1000; bind 127.0.0.1:1001; } stun_server { bind 127.0.0.1:2000; }", ); - - let failure = crate::parse::parse_config_str_for_test(&conf) - .expect_err("unknown bind option should fail"); - - assert!( - snafu::Report::from_error(&failure.error) - .to_string() - .contains("invalid stun_server bind directive option") + let addresses = server + .stun_servers() + .iter() + .flat_map(|value| value.binds.iter().map(|bind| bind.bind)) + .collect::>(); + assert_eq!( + addresses, + [ + "127.0.0.1:1000".parse().unwrap(), + "127.0.0.1:1001".parse().unwrap(), + "127.0.0.1:2000".parse().unwrap(), + ] ); - - cleanup_temp_files(&[&cert, &key]); } #[test] - fn parse_stun_change_port_accepts_valid_port() { - let cert = create_temp_file("stun_port_valid_cert"); - let key = create_temp_file("stun_port_valid_key"); - let conf = format!( - "pishoo {{ server {{ listen all 5378; server_name example.com; ssl_certificate {}; ssl_certificate_key {}; stun_server {{ bind 127.0.0.1:20000; change_port 3478; }} }} }}", - cert.display(), - key.display() + fn stun_bind_explicit_values_override_block_fallbacks() { + let server = parsed_server( + "stun_server { outer_addr 127.0.0.1:3000; change_port 4000; bind 127.0.0.1:1000 outer_addr 127.0.0.1:3001 change_port 4001; }", ); - - let server = first_server(&parse_doc(&conf)); - let stun = server - .children("stun_server") - .expect("stun_server should exist")[0] - .clone(); - assert_eq!( - stun.require::("change_port").unwrap().0, - 3478 - ); - - cleanup_temp_files(&[&cert, &key]); + let bind = &server.stun_servers()[0].binds[0]; + assert_eq!(bind.outer_addr, Some("127.0.0.1:3001".parse().unwrap())); + assert_eq!(bind.change_port, Some(4001)); } #[test] - fn parse_stun_server_accepts_outer_addr_and_change_addr() { - let cert = create_temp_file("stun_addr_cert"); - let key = create_temp_file("stun_addr_key"); - let conf = format!( - "pishoo {{ server {{ listen all 5378; server_name example.com; ssl_certificate {}; ssl_certificate_key {}; stun_server {{ bind 127.0.0.1:20000; outer_addr 127.0.0.1:20001; change_addr 127.0.0.1:20002; }} }} }}", - cert.display(), - key.display() - ); - - let server = first_server(&parse_doc(&conf)); - let stun = server - .children("stun_server") - .expect("stun_server should exist")[0] - .clone(); - - assert_eq!( - stun.require::("outer_addr") - .expect("outer_addr should be typed") - .0, - vec![ - "127.0.0.1:20001" - .parse::() - .expect("outer address should parse") - ] - ); - assert_eq!( - stun.require::("change_addr") - .expect("change_addr should be typed") - .0, - vec![ - "127.0.0.1:20002" - .parse::() - .expect("change address should parse") - ] - ); - - cleanup_temp_files(&[&cert, &key]); + fn empty_stun_server_block_remains_a_noop_compound() { + let server = parsed_server("stun_server {}"); + let values = server.stun_servers(); + assert_eq!(values.len(), 1); + assert!(values[0].binds.is_empty()); } #[test] - fn parse_stun_change_port_rejects_invalid_value() { - let cert = create_temp_file("stun_port_cert"); - let key = create_temp_file("stun_port_key"); - let conf = format!( - "pishoo {{ server {{ listen all 5378; server_name example.com; ssl_certificate {}; ssl_certificate_key {}; stun_server {{ bind 127.0.0.1:20000; change_port invalid; }} }} }}", - cert.display(), - key.display() + fn stun_server_leaf_form_is_rejected() { + let failure = crate::parse::parse_config_str_for_test( + "pishoo { server { listen all 1; server_name x; ssl_certificate /tmp/c; ssl_certificate_key /tmp/k; stun_server value; } }", + ) + .unwrap_err(); + assert!( + snafu::Report::from_error(&failure.error) + .to_string() + .contains("stun_server must use block form") ); - - let failure = crate::parse::parse_config_str_for_test(&conf) - .expect_err("invalid change_port should fail"); - let report = snafu::Report::from_error(&failure.error).to_string(); - - assert!(report.contains("failed to parse directive `change_port`")); - assert_error_chain_display_single_line(&failure.error); - - cleanup_temp_files(&[&cert, &key]); } } diff --git a/gateway/src/parse/config.rs b/gateway/src/parse/config.rs new file mode 100644 index 00000000..57f4411a --- /dev/null +++ b/gateway/src/parse/config.rs @@ -0,0 +1,603 @@ +use std::path::{Path, PathBuf}; + +use dhttp::{home::identity::IdentityProfile, name::DhttpName}; +use snafu::Snafu; + +use super::{ + domain::{ConfigSourceSpan, ResolvedConfigPath}, + pattern::Pattern, + source::{SourceMap, SourceSpan}, + types::{ + AccessRulesUri, BoolConfig, DefaultType, GzipCompLevel, GzipMinLength, HeaderRules, + ListenConfig, MimeTypes, ProxyPass, ResolverConfig, SshLoginMethods, SshSslUsers, + StringList, StunServerConfigValue, + }, +}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] +pub enum OriginScope { + Builtin, + RootPishoo, + WorkerPishoo, + Server, + Location, +} + +#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct ConfigOrigin { + scope: OriginScope, + path: Option, + line: Option, + column: Option, +} + +impl ConfigOrigin { + pub(crate) fn builtin() -> Self { + Self::new(OriginScope::Builtin, None) + } + pub(crate) fn source(scope: OriginScope, sources: &SourceMap, span: SourceSpan) -> Self { + let location = sources.line_column(span); + Self { + scope, + path: sources.path_for_span(span).map(Path::to_path_buf), + line: location.and_then(|location| u32::try_from(location.line).ok()), + column: location.and_then(|location| u32::try_from(location.column).ok()), + } + } + fn new(scope: OriginScope, path: Option) -> Self { + Self { + scope, + path, + line: None, + column: None, + } + } + pub const fn scope(&self) -> OriginScope { + self.scope + } + pub fn path(&self) -> Option<&Path> { + self.path.as_deref() + } + pub const fn line(&self) -> Option { + self.line + } + pub const fn column(&self) -> Option { + self.column + } +} + +#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct Cascaded { + effective: T, + lineage: Box<[ConfigOrigin]>, +} + +impl Cascaded { + pub(crate) fn new(effective: T, lineage: Box<[ConfigOrigin]>) -> Self { + Self { effective, lineage } + } + pub fn effective(&self) -> &T { + &self.effective + } + pub fn lineage(&self) -> &[ConfigOrigin] { + &self.lineage + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum AccessLogDirective { + Off, + ProfileDefault, + Resolved(ResolvedConfigPath), +} + +impl AccessLogDirective { + pub fn resolved_path(&self) -> Option<&Path> { + match self { + Self::Resolved(path) => Some(path.as_ref()), + _ => None, + } + } + + pub fn materialize( + &self, + identity: &ServerIdentity, + ) -> Result { + match (self, identity) { + (Self::Off, _) => Ok(ResolvedAccessLogConfig::Disabled), + (Self::Resolved(path), _) => Ok(ResolvedAccessLogConfig::Enabled(path.clone())), + (Self::ProfileDefault, ServerIdentity::Profile(profile)) => { + let path = ResolvedConfigPath::try_from(profile.access_log_path()) + .map_err(|source| MaterializeAccessLogError::InvalidProfilePath { source })?; + Ok(ResolvedAccessLogConfig::Enabled(path)) + } + (Self::ProfileDefault, ServerIdentity::Direct { .. }) => { + Err(MaterializeAccessLogError::DirectProfileDefault) + } + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum ResolvedAccessLogConfig { + Disabled, + Enabled(ResolvedConfigPath), +} + +#[derive(Debug, Snafu)] +pub enum MaterializeAccessLogError { + #[snafu(display("direct server cannot use the identity-profile access log default"))] + DirectProfileDefault, + #[snafu(display("identity-profile access log path is invalid"))] + InvalidProfilePath { + source: super::domain::ResolvedConfigPathError, + }, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct EffectiveHttpConfig { + access_rules: Cascaded>, + gzip: Cascaded, + gzip_vary: Cascaded, + gzip_min_length: Cascaded, + gzip_comp_level: Cascaded, + gzip_types: Cascaded, + default_type: Cascaded>, + types: Cascaded>, + access_log: Cascaded, +} + +impl EffectiveHttpConfig { + #[allow(clippy::too_many_arguments)] + pub(crate) fn new( + access_rules: Cascaded>, + gzip: Cascaded, + gzip_vary: Cascaded, + gzip_min_length: Cascaded, + gzip_comp_level: Cascaded, + gzip_types: Cascaded, + default_type: Cascaded>, + types: Cascaded>, + access_log: Cascaded, + ) -> Self { + Self { + access_rules, + gzip, + gzip_vary, + gzip_min_length, + gzip_comp_level, + gzip_types, + default_type, + types, + access_log, + } + } + pub fn access_rules(&self) -> &Cascaded> { + &self.access_rules + } + pub fn gzip(&self) -> &Cascaded { + &self.gzip + } + pub fn gzip_vary(&self) -> &Cascaded { + &self.gzip_vary + } + pub fn gzip_min_length(&self) -> &Cascaded { + &self.gzip_min_length + } + pub fn gzip_comp_level(&self) -> &Cascaded { + &self.gzip_comp_level + } + pub fn gzip_types(&self) -> &Cascaded { + &self.gzip_types + } + pub fn default_type(&self) -> &Cascaded> { + &self.default_type + } + pub fn types(&self) -> &Cascaded> { + &self.types + } + pub fn access_log(&self) -> &Cascaded { + &self.access_log + } + pub(crate) fn with_access_log(mut self, access_log: Cascaded) -> Self { + self.access_log = access_log; + self + } +} + +#[derive(Clone, Debug)] +pub struct PishooConfig { + source: ConfigSourceSpan, + pid: Option, + workers: Option, + groups: Option, + http: EffectiveHttpConfig, + forward_proxies: Box<[crate::forward::ForwardConfig]>, +} + +impl PishooConfig { + pub(crate) fn new( + source: ConfigSourceSpan, + pid: Option, + workers: Option, + groups: Option, + http: EffectiveHttpConfig, + forward_proxies: Box<[crate::forward::ForwardConfig]>, + ) -> Self { + Self { + source, + pid, + workers, + groups, + http, + forward_proxies, + } + } + pub const fn source(&self) -> ConfigSourceSpan { + self.source + } + pub fn pid(&self) -> Option<&ResolvedConfigPath> { + self.pid.as_ref() + } + pub fn workers(&self) -> Option<&StringList> { + self.workers.as_ref() + } + pub fn groups(&self) -> Option<&StringList> { + self.groups.as_ref() + } + pub fn http(&self) -> &EffectiveHttpConfig { + &self.http + } + pub fn forward_proxies(&self) -> &[crate::forward::ForwardConfig] { + &self.forward_proxies + } + pub fn worker_defaults(&self) -> RootWorkerDefaultsSnapshot { + RootWorkerDefaultsSnapshot::from_http(self.http.clone()) + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RootWorkerDefaultsSnapshot { + http: EffectiveHttpConfig, +} +impl RootWorkerDefaultsSnapshot { + pub(crate) fn from_http(http: EffectiveHttpConfig) -> Self { + Self { http } + } + pub fn http(&self) -> &EffectiveHttpConfig { + &self.http + } +} + +#[derive(serde::Serialize, serde::Deserialize)] +struct CascadedWire { + effective: T, + lineage: Box<[ConfigOrigin]>, +} +#[derive(serde::Serialize, serde::Deserialize)] +struct RootWorkerDefaultsWire { + access_rules: CascadedWire>, + gzip: CascadedWire, + gzip_vary: CascadedWire, + gzip_min_length: CascadedWire, + gzip_comp_level: CascadedWire, + gzip_types: CascadedWire>, + default_type: CascadedWire>>, + types: CascadedWire>, + access_log: CascadedWire, +} +type MimeTypesWire = Vec<(String, Vec)>; +#[derive(serde::Serialize, serde::Deserialize)] +enum AccessLogWire { + Off, + ProfileDefault, + Resolved(PathBuf), +} +fn encode(value: &Cascaded, f: impl FnOnce(&T) -> W) -> CascadedWire { + CascadedWire { + effective: f(value.effective()), + lineage: value.lineage().into(), + } +} +fn decode( + value: CascadedWire, + f: impl FnOnce(W) -> Result, +) -> Result, String> { + Ok(Cascaded::new(f(value.effective)?, value.lineage)) +} +impl serde::Serialize for RootWorkerDefaultsSnapshot { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + let h = &self.http; + RootWorkerDefaultsWire { + access_rules: encode(h.access_rules(), |v| { + v.as_ref().map(|v| v.0.as_str().to_owned()) + }), + gzip: encode(h.gzip(), |v| v.0), + gzip_vary: encode(h.gzip_vary(), |v| v.0), + gzip_min_length: encode(h.gzip_min_length(), |v| v.0), + gzip_comp_level: encode(h.gzip_comp_level(), |v| v.0), + gzip_types: encode(h.gzip_types(), |v| v.0.clone()), + default_type: encode(h.default_type(), |v| { + v.as_ref().map(|v| v.0.as_bytes().to_vec()) + }), + types: encode(h.types(), |v| { + v.as_ref().map(|v| { + v.0.iter() + .map(|(k, v)| (k.clone(), v.as_bytes().to_vec())) + .collect() + }) + }), + access_log: encode(h.access_log(), |v| match v { + AccessLogDirective::Off => AccessLogWire::Off, + AccessLogDirective::ProfileDefault => AccessLogWire::ProfileDefault, + AccessLogDirective::Resolved(p) => { + AccessLogWire::Resolved(p.as_ref().to_path_buf()) + } + }), + } + .serialize(serializer) + } +} +impl<'de> serde::Deserialize<'de> for RootWorkerDefaultsSnapshot { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + use serde::de::Error as _; + let w = RootWorkerDefaultsWire::deserialize(deserializer)?; + let access_rules = decode(w.access_rules, |v| { + v.map(|v| { + url::Url::parse(&v) + .map_err(|e| e.to_string()) + .and_then(|u| AccessRulesUri::try_from(u).map_err(|e| e.to_string())) + }) + .transpose() + }) + .map_err(D::Error::custom)?; + let gzip = decode(w.gzip, |v| Ok(BoolConfig(v))).map_err(D::Error::custom)?; + let gzip_vary = decode(w.gzip_vary, |v| Ok(BoolConfig(v))).map_err(D::Error::custom)?; + let gzip_min_length = decode(w.gzip_min_length, |v| Ok(GzipMinLength::checked(v))) + .map_err(D::Error::custom)?; + let gzip_comp_level = decode(w.gzip_comp_level, |v| Ok(GzipCompLevel::checked(v))) + .map_err(D::Error::custom)?; + let gzip_types = decode(w.gzip_types, |v| { + StringList::checked_gzip_types(v).map_err(|e| e.to_string()) + }) + .map_err(D::Error::custom)?; + let default_type = decode(w.default_type, |v| { + v.map(|v| DefaultType::checked_from_bytes(&v).map_err(|e| e.to_string())) + .transpose() + }) + .map_err(D::Error::custom)?; + let types = decode(w.types, |v| { + v.map(|v| MimeTypes::checked_from_bytes(v).map_err(|e| e.to_string())) + .transpose() + }) + .map_err(D::Error::custom)?; + let access_log = decode(w.access_log, |v| { + Ok(match v { + AccessLogWire::Off => AccessLogDirective::Off, + AccessLogWire::ProfileDefault => AccessLogDirective::ProfileDefault, + AccessLogWire::Resolved(p) => AccessLogDirective::Resolved( + ResolvedConfigPath::try_from(p).map_err(|e| e.to_string())?, + ), + }) + }) + .map_err(D::Error::custom)?; + Ok(Self::from_http(EffectiveHttpConfig::new( + access_rules, + gzip, + gzip_vary, + gzip_min_length, + gzip_comp_level, + gzip_types, + default_type, + types, + access_log, + ))) + } +} + +#[derive(Clone, Debug)] +pub enum ServerIdentity { + Direct { + certificate: ResolvedConfigPath, + private_key: ResolvedConfigPath, + }, + Profile(IdentityProfile), +} + +#[derive(Clone, Debug)] +pub struct ServerConfig { + identity: ServerIdentity, + source: ConfigSourceSpan, + names: Box<[DhttpName<'static>]>, + listens: Box<[ListenConfig]>, + resolver: Option, + http: EffectiveHttpConfig, + relay: Option, + stun: Option, + stun_servers: Box<[StunServerConfigValue]>, + locations: Box<[LocationConfig]>, +} + +impl ServerConfig { + #[allow(clippy::too_many_arguments)] + pub(crate) fn new( + identity: ServerIdentity, + source: ConfigSourceSpan, + names: Box<[DhttpName<'static>]>, + listens: Box<[ListenConfig]>, + resolver: Option, + http: EffectiveHttpConfig, + relay: Option, + stun: Option, + stun_servers: Box<[StunServerConfigValue]>, + locations: Box<[LocationConfig]>, + ) -> Self { + Self { + identity, + source, + names, + listens, + resolver, + http, + relay, + stun, + stun_servers, + locations, + } + } + pub fn identity(&self) -> &ServerIdentity { + &self.identity + } + pub const fn source(&self) -> ConfigSourceSpan { + self.source + } + pub fn names(&self) -> &[DhttpName<'static>] { + &self.names + } + pub fn listens(&self) -> &[ListenConfig] { + &self.listens + } + pub fn resolver(&self) -> Option<&ResolverConfig> { + self.resolver.as_ref() + } + pub fn http(&self) -> &EffectiveHttpConfig { + &self.http + } + pub fn relay(&self) -> Option<&BoolConfig> { + self.relay.as_ref() + } + pub fn stun(&self) -> Option<&BoolConfig> { + self.stun.as_ref() + } + pub fn stun_servers(&self) -> &[StunServerConfigValue] { + &self.stun_servers + } + pub fn locations(&self) -> &[LocationConfig] { + &self.locations + } +} + +#[derive(Clone, Debug)] +pub struct PreparedProxyTlsPaths { + certificate: Option, + private_key: Option, + trusted_certificate: Option, +} +impl PreparedProxyTlsPaths { + pub(crate) fn new( + certificate: Option, + private_key: Option, + trusted_certificate: Option, + ) -> Self { + Self { + certificate, + private_key, + trusted_certificate, + } + } + pub fn certificate(&self) -> Option<&ResolvedConfigPath> { + self.certificate.as_ref() + } + pub fn private_key(&self) -> Option<&ResolvedConfigPath> { + self.private_key.as_ref() + } + pub fn trusted_certificate(&self) -> Option<&ResolvedConfigPath> { + self.trusted_certificate.as_ref() + } +} + +#[derive(Clone, Debug)] +pub struct LocationConfig { + source: ConfigSourceSpan, + matcher: Pattern, + http: EffectiveHttpConfig, + root: Option, + alias: Option, + index: Option, + add_headers: Box<[HeaderRules]>, + proxy_set_headers: Box<[HeaderRules]>, + proxy_pass: Option, + proxy_tls: Option, + ssh_login: Option, + ssh_users: Box<[SshSslUsers]>, + ssh_deny: Option, +} +impl LocationConfig { + #[allow(clippy::too_many_arguments)] + pub(crate) fn new( + source: ConfigSourceSpan, + matcher: Pattern, + http: EffectiveHttpConfig, + root: Option, + alias: Option, + index: Option, + add_headers: Box<[HeaderRules]>, + proxy_set_headers: Box<[HeaderRules]>, + proxy_pass: Option, + proxy_tls: Option, + ssh_login: Option, + ssh_users: Box<[SshSslUsers]>, + ssh_deny: Option, + ) -> Self { + Self { + source, + matcher, + http, + root, + alias, + index, + add_headers, + proxy_set_headers, + proxy_pass, + proxy_tls, + ssh_login, + ssh_users, + ssh_deny, + } + } + pub const fn source(&self) -> ConfigSourceSpan { + self.source + } + pub fn matcher(&self) -> &Pattern { + &self.matcher + } + pub fn http(&self) -> &EffectiveHttpConfig { + &self.http + } + pub fn root(&self) -> Option<&ResolvedConfigPath> { + self.root.as_ref() + } + pub fn alias(&self) -> Option<&ResolvedConfigPath> { + self.alias.as_ref() + } + pub fn index(&self) -> Option<&StringList> { + self.index.as_ref() + } + pub fn add_headers(&self) -> &[HeaderRules] { + &self.add_headers + } + pub fn proxy_set_headers(&self) -> &[HeaderRules] { + &self.proxy_set_headers + } + pub fn proxy_pass(&self) -> Option<&ProxyPass> { + self.proxy_pass.as_ref() + } + pub fn proxy_tls(&self) -> Option<&PreparedProxyTlsPaths> { + self.proxy_tls.as_ref() + } + pub fn ssh_login(&self) -> Option<&SshLoginMethods> { + self.ssh_login.as_ref() + } + pub fn ssh_users(&self) -> &[SshSslUsers] { + &self.ssh_users + } + pub fn ssh_deny(&self) -> Option<&StringList> { + self.ssh_deny.as_ref() + } +} diff --git a/gateway/src/parse/decode.rs b/gateway/src/parse/decode.rs new file mode 100644 index 00000000..af21d49d --- /dev/null +++ b/gateway/src/parse/decode.rs @@ -0,0 +1,27 @@ +use std::error::Error; + +use super::{ + ast::AstDirective, + source::{SourceMap, SourceSpan}, +}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ConfigContext { + Pishoo, + Server, + Location, +} + +pub struct DirectiveInput<'a> { + pub directive: &'a AstDirective, + pub context: ConfigContext, + pub source_map: &'a SourceMap, +} + +pub trait DirectiveValue: Sized { + type Error: Error + Send + Sync + 'static; + + fn span(input: &DirectiveInput<'_>) -> SourceSpan { + input.directive.span + } +} diff --git a/gateway/src/parse/diagnostic.rs b/gateway/src/parse/diagnostic.rs index 26f7c5c3..aed421cf 100644 --- a/gateway/src/parse/diagnostic.rs +++ b/gateway/src/parse/diagnostic.rs @@ -99,24 +99,27 @@ fn find_span(error: &(dyn Error + 'static)) -> Option { crate::parse::grammar::ParseSyntaxError::Syntax { span, .. } => Some(*span), }; } - if let Some(error) = error.downcast_ref::() { - return match error { - crate::parse::error::BuildDocumentError::UnknownDirective { span, .. } - | crate::parse::error::BuildDocumentError::InvalidContext { span, .. } - | crate::parse::error::BuildDocumentError::InvalidDirectiveShape { span, .. } - | crate::parse::error::BuildDocumentError::DirectiveParse { span, .. } - | crate::parse::error::BuildDocumentError::NormalizeDirectiveValue { span, .. } => { - Some(*span) - } - crate::parse::error::BuildDocumentError::DuplicateDirective { duplicate, .. } => { - Some(*duplicate) - } - crate::parse::error::BuildDocumentError::MissingRequiredDirective { - context_span, - .. - } => Some(*context_span), - crate::parse::error::BuildDocumentError::FinalizeContext { span, .. } => Some(*span), - }; + if let Some(error) = error.downcast_ref::() { + use crate::parse::build::BuildTypedConfigError::*; + return Some(match error { + UnknownDirective { span, .. } + | Shape { span, .. } + | Directive { span, .. } + | Missing { span, .. } + | PishooCardinality { span } + | WorkerServer { span } + | ServerTlsPair { span } + | StandaloneTls { span } + | AmbiguousProfile { span } + | IdentityName { span } + | IdentityTls { span } + | ProxyTlsPair { span } + | RegexProxyUri { span } + | DirectDefaultAccessLog { span } + | AccessLogArgument { span } + | ForwardListen { span } => *span, + Duplicate { duplicate, .. } => *duplicate, + }); } if let Some(error) = error.downcast_ref::() { return match error { @@ -130,115 +133,5 @@ fn find_span(error: &(dyn Error + 'static)) -> Option { } }; } - if let Some(error) = error.downcast_ref::() { - return match error { - crate::parse::error::ConfigQueryError::MissingRequired { span, .. } - | crate::parse::error::ConfigQueryError::TypeMismatch { span, .. } - | crate::parse::error::ConfigQueryError::MultipleValues { span, .. } - | crate::parse::error::ConfigQueryError::MissingChild { span, .. } => Some(*span), - }; - } error.source().and_then(find_span) } - -#[cfg(test)] -mod tests { - use std::sync::Arc; - - use super::*; - use crate::parse::{ - error::BuildDocumentError, - source::{SourceMap, SourceSpan}, - }; - - #[test] - fn diagnostic_renders_source_line() { - let mut sources = SourceMap::default(); - let id = sources.add_source(None, Arc::from("pishoo {\n bad;\n}\n"), None, None); - let error = BuildDocumentError::UnknownDirective { - directive: "bad".to_owned(), - span: SourceSpan::new(id, 11, 14), - }; - let diagnostic = Diagnostic::new(&error, &sources).to_string(); - assert!(diagnostic.contains("bad;")); - assert!(diagnostic.contains('^')); - assert!(!error.to_string().contains('\n')); - } - - #[test] - fn diagnostic_finds_span_through_load_error_chain() { - let mut sources = SourceMap::default(); - let id = sources.add_source(None, Arc::from("include extra.conf;\n"), None, None); - let error = crate::parse::error::LoadConfigError::ResolveInclude { - source: crate::parse::error::ResolveIncludeError::InvalidArgumentCount { - span: SourceSpan::new(id, 0, 7), - count: 0, - }, - }; - - let diagnostic = Diagnostic::new(&error, &sources).to_string(); - - assert!(diagnostic.contains("include extra.conf;")); - assert!(diagnostic.contains('^')); - } - - #[test] - fn diagnostic_renders_syntax_error_location() { - let mut sources = SourceMap::default(); - let id = sources.add_source(None, Arc::from("pishoo {\n"), None, None); - let syntax = - crate::parse::grammar::parse_source("pishoo {\n", id).expect_err("syntax should fail"); - let error = crate::parse::error::LoadConfigError::ParseFile { - source_id: id, - source: syntax, - }; - - let diagnostic = Diagnostic::new(&error, &sources).to_string(); - - assert!(diagnostic.contains("pishoo {")); - assert!(diagnostic.contains('^')); - } - - #[test] - fn diagnostic_renders_included_source_syntax_error_location() { - let mut sources = SourceMap::default(); - let root = sources.add_source(None, Arc::from("include child.conf;\n"), None, None); - let child = sources.add_source( - None, - Arc::from("server {\n"), - None, - Some(crate::parse::source::IncludeTrace { - parent: root, - directive_span: SourceSpan::new(root, 0, 19), - }), - ); - let syntax = crate::parse::grammar::parse_source("server {\n", child) - .expect_err("syntax should fail"); - let error = crate::parse::error::LoadConfigError::ResolveInclude { - source: crate::parse::error::ResolveIncludeError::ParseSource { - source_id: child, - source: syntax, - }, - }; - - let diagnostic = Diagnostic::new(&error, &sources).to_string(); - - assert!(diagnostic.contains("server {")); - assert!(diagnostic.contains("included from")); - assert!(diagnostic.contains('^')); - } - - #[test] - fn diagnostic_contains_source_snippet_but_display_is_single_line() { - let conf = "pishoo { server { listen all 5378; server_name example.com; ssl_certificate /missing/cert.pem; ssl_certificate_key /missing/key.pem; location /api { proxy_pass ftp://backend.example.com; } } }"; - let failure = - crate::parse::parse_config_str_for_test(conf).expect_err("config should fail"); - let report = snafu::Report::from_error(&failure.error).to_string(); - let diagnostic = failure.diagnostic().to_string(); - - assert!(report.contains("unsupported proxy_pass uri scheme")); - assert!(!failure.error.to_string().contains('\n')); - assert!(diagnostic.contains("proxy_pass ftp://backend.example.com")); - assert!(diagnostic.contains("^")); - } -} diff --git a/gateway/src/parse/document.rs b/gateway/src/parse/document.rs deleted file mode 100644 index 65507988..00000000 --- a/gateway/src/parse/document.rs +++ /dev/null @@ -1,216 +0,0 @@ -use std::{ - collections::HashMap, - sync::{Arc, Weak}, -}; - -use tokio::sync::OnceCell; - -use crate::parse::{ - ast::Spanned, - error::{ConfigQueryError, config_query_error}, - registry::ContextKey, - source::{SourceMap, SourceSpan}, - value::{ConfigValue, TypedValue}, -}; - -#[derive(Debug)] -pub struct ConfigDocument { - pub source_map: Arc, - pub root: Arc, -} - -#[derive(Debug)] -pub struct ConfigNode { - pub context: ContextKey, - pub name: Option>, - pub span: SourceSpan, - pub payload: Option, - slots: HashMap<&'static str, ConfigSlot>, - children: HashMap<&'static str, Vec>>, - parent: OnceCell>>, -} - -#[derive(Debug, Clone)] -pub struct ConfigSlot { - pub values: Vec, -} - -impl ConfigDocument { - pub fn new(source_map: Arc, root: Arc) -> Self { - Self { source_map, root } - } -} - -impl ConfigNode { - pub fn new(context: ContextKey, name: Option>, span: SourceSpan) -> Self { - Self { - context, - name, - span, - payload: None, - slots: HashMap::new(), - children: HashMap::new(), - parent: OnceCell::new(), - } - } - - pub fn insert_slot(&mut self, name: &'static str, value: TypedValue) { - self.slots - .entry(name) - .or_insert_with(|| ConfigSlot { values: Vec::new() }) - .values - .push(value); - } - - pub fn replace_slot(&mut self, name: &'static str, value: TypedValue) { - self.slots.insert( - name, - ConfigSlot { - values: vec![value], - }, - ); - } - - pub fn get_all_untyped(&self, name: &str) -> &[TypedValue] { - self.slots - .get(name) - .map(|slot| slot.values.as_slice()) - .unwrap_or(&[]) - } - - pub fn insert_child(&mut self, name: &'static str, child: Arc) { - self.children.entry(name).or_default().push(child); - } - - pub fn child_groups(&self) -> impl Iterator]> { - self.children.values().map(Vec::as_slice) - } - - pub fn set_payload(&mut self, payload: TypedValue) { - self.payload = Some(payload); - } - - pub fn set_parent(&self, parent: Option>) { - self.parent - .set(parent) - .expect("parent link set multiple times for config node"); - } - - pub fn parent(&self) -> Option> { - self.parent - .get() - .and_then(|parent| parent.as_ref()) - .and_then(Weak::upgrade) - } - - pub fn get(&self, name: &str) -> Result>, ConfigQueryError> - where - T: ConfigValue, - { - let Some(slot) = self.slots.get(name) else { - return Ok(None); - }; - if slot.values.len() > 1 { - return config_query_error::MultipleValuesSnafu { - directive: name.to_owned(), - span: slot.values[1].span(), - } - .fail(); - } - let value = &slot.values[0]; - value.downcast::().map(Some).ok_or_else(|| { - config_query_error::TypeMismatchSnafu { - directive: name.to_owned(), - expected: std::any::type_name::(), - actual: value.type_name(), - span: value.span(), - } - .build() - }) - } - - pub fn require(&self, name: &str) -> Result, ConfigQueryError> - where - T: ConfigValue, - { - self.get(name)?.ok_or_else(|| { - config_query_error::MissingRequiredSnafu { - directive: name.to_owned(), - span: self.span, - } - .build() - }) - } - - pub fn get_all(&self, name: &str) -> Result>, ConfigQueryError> - where - T: ConfigValue, - { - let Some(slot) = self.slots.get(name) else { - return Ok(Vec::new()); - }; - slot.values - .iter() - .map(|value| { - value.downcast::().ok_or_else(|| { - config_query_error::TypeMismatchSnafu { - directive: name.to_owned(), - expected: std::any::type_name::(), - actual: value.type_name(), - span: value.span(), - } - .build() - }) - }) - .collect() - } - - pub fn inherited(&self, name: &str) -> Result>, ConfigQueryError> - where - T: ConfigValue, - { - if let Some(value) = self.get(name)? { - return Ok(Some(value)); - } - let Some(parent) = self.parent() else { - return Ok(None); - }; - parent.inherited(name) - } - - pub fn children(&self, name: &str) -> Result<&[Arc], ConfigQueryError> { - self.children.get(name).map(Vec::as_slice).ok_or_else(|| { - config_query_error::MissingChildSnafu { - directive: name.to_owned(), - span: self.span, - } - .build() - }) - } - - pub fn children_optional(&self, name: &str) -> &[Arc] { - self.children.get(name).map(Vec::as_slice).unwrap_or(&[]) - } - - pub fn payload(&self) -> Result>, ConfigQueryError> - where - T: ConfigValue, - { - let Some(payload) = &self.payload else { - return Ok(None); - }; - payload.downcast::().map(Some).ok_or_else(|| { - config_query_error::TypeMismatchSnafu { - directive: self - .name - .as_ref() - .map(|name| name.value.clone()) - .unwrap_or_else(|| "".to_owned()), - expected: std::any::type_name::(), - actual: payload.type_name(), - span: payload.span(), - } - .build() - }) - } -} diff --git a/gateway/src/parse/domain.rs b/gateway/src/parse/domain.rs new file mode 100644 index 00000000..beab806c --- /dev/null +++ b/gateway/src/parse/domain.rs @@ -0,0 +1,154 @@ +use std::{ + fmt, + path::{Path, PathBuf}, +}; + +use snafu::{ResultExt, Snafu}; + +use crate::parse::source::SourceSpan; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct ConfigDocumentId(u32); + +impl ConfigDocumentId { + pub(crate) fn try_from_index(index: u64) -> Result { + let index = + u32::try_from(index).context(config_document_id_error::IndexOverflowSnafu { index })?; + Ok(Self(index)) + } +} + +pub(crate) struct ConfigDocumentIdAllocator { + next_index: u64, +} + +impl ConfigDocumentIdAllocator { + pub(crate) fn new() -> Self { + Self { next_index: 0 } + } + + #[cfg(test)] + pub(crate) fn with_next_index(next_index: u64) -> Self { + Self { next_index } + } + + pub(crate) fn allocate(&mut self) -> Result { + let document_id = ConfigDocumentId::try_from_index(self.next_index)?; + self.next_index = + self.next_index + .checked_add(1) + .ok_or(ConfigDocumentIdError::CounterOverflow { + index: self.next_index, + })?; + Ok(document_id) + } +} + +impl fmt::Display for ConfigDocumentId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "document:{}", self.0) + } +} + +#[derive(Debug, Snafu)] +#[snafu(module)] +pub enum ConfigDocumentIdError { + #[snafu(display("configuration document index exceeds the supported range"))] + IndexOverflow { + index: u64, + source: std::num::TryFromIntError, + }, + #[snafu(display("configuration document counter cannot advance past index {index}"))] + CounterOverflow { index: u64 }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct ConfigSourceSpan { + document_id: ConfigDocumentId, + span: SourceSpan, +} + +impl ConfigSourceSpan { + pub(crate) fn new(document_id: ConfigDocumentId, span: SourceSpan) -> Self { + Self { document_id, span } + } + + pub fn document_id(&self) -> ConfigDocumentId { + self.document_id + } + + pub fn start(&self) -> usize { + self.span.start + } + + pub fn end(&self) -> usize { + self.span.end + } + + pub fn is_empty(&self) -> bool { + self.span.is_empty() + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct ResolvedConfigPath(PathBuf); + +impl TryFrom for ResolvedConfigPath { + type Error = ResolvedConfigPathError; + + fn try_from(path: PathBuf) -> Result { + if !path.is_absolute() { + return Err(ResolvedConfigPathError::Relative { path }); + } + if path.as_os_str().as_encoded_bytes().contains(&0) { + return Err(ResolvedConfigPathError::Nul { path }); + } + Ok(Self(path)) + } +} + +impl AsRef for ResolvedConfigPath { + fn as_ref(&self) -> &Path { + &self.0 + } +} + +impl From for PathBuf { + fn from(path: ResolvedConfigPath) -> Self { + path.0 + } +} + +#[derive(Debug, Snafu)] +#[snafu(module)] +pub enum ResolvedConfigPathError { + #[snafu(display("resolved configuration path must be absolute"))] + Relative { path: PathBuf }, + #[snafu(display("resolved configuration path contains a NUL byte"))] + Nul { path: PathBuf }, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn document_id_allocator_errors_after_allocating_maximum_id() { + let maximum_index = u64::from(u32::MAX); + let mut allocator = ConfigDocumentIdAllocator::with_next_index(maximum_index); + + assert_eq!( + allocator.allocate().expect("maximum id should allocate"), + ConfigDocumentId::try_from_index(maximum_index) + .expect("maximum index should be a valid document id") + ); + let error = allocator + .allocate() + .expect_err("allocator must not reuse the maximum id"); + assert!(matches!( + error, + ConfigDocumentIdError::IndexOverflow { index, .. } + if index == maximum_index + 1 + )); + } +} diff --git a/gateway/src/parse/error.rs b/gateway/src/parse/error.rs index 0f9549c1..a677bff9 100644 --- a/gateway/src/parse/error.rs +++ b/gateway/src/parse/error.rs @@ -1,8 +1,12 @@ -use std::{path::PathBuf, sync::Arc}; +use std::{ + path::{Path, PathBuf}, + sync::Arc, +}; use snafu::Snafu; use crate::parse::{ + domain::{ConfigDocumentId, ConfigDocumentIdError}, grammar::ParseSyntaxError, source::{SourceId, SourceMap, SourceSpan}, }; @@ -10,23 +14,24 @@ use crate::parse::{ #[derive(Debug, Snafu)] #[snafu(module, visibility(pub(crate)))] pub enum LoadConfigError { + #[snafu(display("failed to allocate configuration document identity"))] + DocumentId { source: ConfigDocumentIdError }, #[snafu(display("failed to read configuration source"))] ReadSource { path: PathBuf, source: std::io::Error, }, - #[snafu(display("failed to parse configuration file"))] ParseFile { source_id: SourceId, source: ParseSyntaxError, }, - #[snafu(display("failed to resolve configuration includes"))] ResolveInclude { source: ResolveIncludeError }, - - #[snafu(display("failed to build configuration document"))] - BuildDocument { source: BuildDocumentError }, + #[snafu(display("failed to build typed configuration"))] + BuildTyped { + source: crate::parse::build::BuildTypedConfigError, + }, } #[derive(Debug, Snafu)] @@ -66,114 +71,34 @@ pub enum ResolveIncludeError { }, } -#[derive(Debug, Snafu)] -#[snafu(module, visibility(pub(crate)))] -pub enum BuildDocumentError { - #[snafu(display("unknown directive `{directive}`"))] - UnknownDirective { directive: String, span: SourceSpan }, - - #[snafu(display("directive `{directive}` is not valid in this context"))] - InvalidContext { - directive: String, - context: &'static str, - span: SourceSpan, - }, - - #[snafu(display("invalid directive shape for `{directive}`"))] - InvalidDirectiveShape { directive: String, span: SourceSpan }, - - #[snafu(display("failed to parse directive `{directive}`"))] - DirectiveParse { - directive: String, - span: SourceSpan, - source: Box, - }, - - #[snafu(display("duplicate directive `{directive}`"))] - DuplicateDirective { - directive: String, - first: SourceSpan, - duplicate: SourceSpan, - }, - - #[snafu(display("failed to normalize directive `{directive}`"))] - NormalizeDirectiveValue { - directive: String, - span: SourceSpan, - source: crate::parse::normalize::NormalizeDirectiveValueError, - }, - - #[snafu(display("missing required directive `{directive}`"))] - MissingRequiredDirective { - directive: &'static str, - context_span: SourceSpan, - }, - - #[snafu(display("failed to finalize configuration context"))] - FinalizeContext { - context: &'static str, - span: SourceSpan, - source: Box, - }, -} - -#[derive(Debug, Snafu)] -#[snafu(module, visibility(pub(crate)))] -pub enum ConfigQueryError { - #[snafu(display("missing required directive `{directive}`"))] - MissingRequired { directive: String, span: SourceSpan }, - - #[snafu(display("directive `{directive}` has unexpected value type"))] - TypeMismatch { - directive: String, - expected: &'static str, - actual: &'static str, - span: SourceSpan, - }, - - #[snafu(display("directive `{directive}` has multiple values"))] - MultipleValues { directive: String, span: SourceSpan }, - - #[snafu(display("missing child directive `{directive}`"))] - MissingChild { directive: String, span: SourceSpan }, -} - #[derive(Debug)] pub struct ConfigLoadFailure { pub error: LoadConfigError, pub source_map: Arc, + pub(crate) document_id: Option, } - impl std::fmt::Display for ConfigLoadFailure { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "failed to load configuration") } } - impl std::error::Error for ConfigLoadFailure { fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { Some(&self.error) } } - -#[cfg(test)] -mod tests { - use super::*; - use crate::parse::source::{SourceId, SourceMap, SourceSpan}; - - #[test] - fn config_load_failure_display_describes_only_wrapper() { - let failure = ConfigLoadFailure { - error: LoadConfigError::ResolveInclude { - source: ResolveIncludeError::InvalidArgumentCount { - span: SourceSpan::new(SourceId(0), 0, 7), - count: 0, - }, +impl ConfigLoadFailure { + pub fn document_id(&self) -> Option { + self.document_id + } + pub(crate) fn read(path: &Path, source: std::io::Error) -> Self { + Self { + error: LoadConfigError::ReadSource { + path: path.to_path_buf(), + source, }, source_map: Arc::new(SourceMap::default()), - }; - - assert_eq!(failure.to_string(), "failed to load configuration"); - assert!(std::error::Error::source(&failure).is_some()); + document_id: None, + } } } diff --git a/gateway/src/parse/normalize.rs b/gateway/src/parse/normalize.rs index 81c1b85f..9c678830 100644 --- a/gateway/src/parse/normalize.rs +++ b/gateway/src/parse/normalize.rs @@ -1,11 +1,10 @@ use std::path::{Path, PathBuf}; -use snafu::{OptionExt, Snafu}; +use snafu::{OptionExt, ResultExt, Snafu}; use crate::parse::{ + domain::{ResolvedConfigPath, ResolvedConfigPathError}, source::{SourceMap, SourceSpan}, - types::PathConfig, - value::TypedValue, }; #[derive(Debug, Snafu)] @@ -13,32 +12,34 @@ use crate::parse::{ pub enum NormalizeDirectiveValueError { #[snafu(display("relative path requires a configuration file base directory"))] MissingBaseDir { span: SourceSpan }, + #[snafu(display("configuration path is not a resolved absolute path"))] + InvalidResolvedPath { + span: SourceSpan, + source: ResolvedConfigPathError, + }, } -pub fn normalize_path( +pub(crate) fn resolve_config_path( path: &Path, span: SourceSpan, source_map: &SourceMap, -) -> Result { - if path.is_absolute() { - return Ok(path.components().collect()); - } - - let base_dir = source_map - .base_dir_for_span(span) - .context(normalize_directive_value_error::MissingBaseDirSnafu { span })?; - Ok(base_dir.join(path).components().collect()) +) -> Result { + let path: PathBuf = if path.is_absolute() { + path.components().collect() + } else { + let base_dir = source_map + .base_dir_for_span(span) + .context(normalize_directive_value_error::MissingBaseDirSnafu { span })?; + base_dir.join(path).components().collect() + }; + ResolvedConfigPath::try_from(path) + .context(normalize_directive_value_error::InvalidResolvedPathSnafu { span }) } -pub fn normalize_slot_value( - value: TypedValue, +pub(crate) fn normalize_path( + path: &Path, + span: SourceSpan, source_map: &SourceMap, -) -> Result { - let span = value.span(); - if let Some(path) = value.downcast::() { - let normalized = normalize_path(&path.0, span, source_map)?; - return Ok(TypedValue::new(PathConfig(normalized), span)); - } - - Ok(value) +) -> Result { + resolve_config_path(path, span, source_map).map(Into::into) } diff --git a/gateway/src/parse/registry.rs b/gateway/src/parse/registry.rs deleted file mode 100644 index 83361e77..00000000 --- a/gateway/src/parse/registry.rs +++ /dev/null @@ -1,396 +0,0 @@ -use std::{collections::HashMap, error::Error, sync::Arc}; - -use snafu::{OptionExt, ResultExt}; - -use crate::parse::{ - ast::{AstBody, AstDirective}, - document::{ConfigDocument, ConfigNode}, - error::{BuildDocumentError, build_document_error}, - normalize, - source::{SourceMap, SourceSpan}, - value::{ConfigValue, TypedValue}, -}; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub struct ContextKey(pub &'static str); - -pub mod context { - use super::ContextKey; - - pub const ROOT: ContextKey = ContextKey("gateway.root"); - pub const PISHOO: ContextKey = ContextKey("gateway.pishoo"); - pub const SERVER: ContextKey = ContextKey("gateway.server"); - pub const LOCATION: ContextKey = ContextKey("gateway.location"); - pub const PROXY: ContextKey = ContextKey("gateway.proxy"); - pub const STUN_SERVER: ContextKey = ContextKey("gateway.stun_server"); -} - -#[derive(Default)] -pub struct ConfigRegistry { - contexts: HashMap, - directives: HashMap<(ContextKey, &'static str), DirectiveSpec>, -} - -pub struct ContextSpec { - pub key: ContextKey, - pub finalize: Option, -} - -pub struct DirectiveSpec { - pub name: &'static str, - pub allowed_in: Vec, - pub shape: DirectiveShape, - parser: DirectiveParserFn, - pub merge: MergePolicy, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum DirectiveShape { - Leaf, - ContextBlock { - child_context: ContextKey, - payload: PayloadMode, - }, - RawBlock, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum PayloadMode { - None, - Parser, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum MergePolicy { - RejectDuplicate, - LastWins, - Append, - InheritIfMissing, - MergeWithParent, -} - -type DirectiveParserFn = - fn(&DirectiveInput<'_>) -> Result>; -pub type ContextFinalizeFn = - fn(&mut ConfigNode, &BuildOptions<'_>) -> Result<(), Box>; - -pub trait DirectiveValue: ConfigValue + Sized { - type Error: Error + Send + Sync + 'static; - - fn span(input: &DirectiveInput<'_>) -> SourceSpan { - input.directive.span - } -} - -pub struct DirectiveInput<'a> { - pub directive: &'a AstDirective, - pub context: ContextKey, - pub source_map: &'a SourceMap, -} - -pub enum ParsedDirective { - Slot(TypedValue), - Payload(TypedValue), - Empty, -} - -impl DirectiveSpec { - pub fn leaf_value( - name: &'static str, - allowed_in: Vec, - merge: MergePolicy, - ) -> Self - where - T: DirectiveValue, - for<'input, 'directive> T: - TryFrom<&'input DirectiveInput<'directive>, Error = ::Error>, - { - Self { - name, - allowed_in, - shape: DirectiveShape::Leaf, - parser: slot_value_parser::, - merge, - } - } - - pub fn raw_value(name: &'static str, allowed_in: Vec, merge: MergePolicy) -> Self - where - T: DirectiveValue, - for<'input, 'directive> T: - TryFrom<&'input DirectiveInput<'directive>, Error = ::Error>, - { - Self { - name, - allowed_in, - shape: DirectiveShape::RawBlock, - parser: slot_value_parser::, - merge, - } - } - - pub fn context_empty( - name: &'static str, - allowed_in: Vec, - child_context: ContextKey, - merge: MergePolicy, - ) -> Self { - Self { - name, - allowed_in, - shape: DirectiveShape::ContextBlock { - child_context, - payload: PayloadMode::None, - }, - parser: empty_parser, - merge, - } - } - - pub fn context_payload( - name: &'static str, - allowed_in: Vec, - child_context: ContextKey, - merge: MergePolicy, - ) -> Self - where - T: DirectiveValue, - for<'input, 'directive> T: - TryFrom<&'input DirectiveInput<'directive>, Error = ::Error>, - { - Self { - name, - allowed_in, - shape: DirectiveShape::ContextBlock { - child_context, - payload: PayloadMode::Parser, - }, - parser: payload_value_parser::, - merge, - } - } -} - -fn slot_value_parser( - input: &DirectiveInput<'_>, -) -> Result> -where - T: DirectiveValue, - for<'input, 'directive> T: - TryFrom<&'input DirectiveInput<'directive>, Error = ::Error>, -{ - let span = T::span(input); - match T::try_from(input) { - Ok(value) => Ok(ParsedDirective::Slot(TypedValue::new(value, span))), - Err(source) => Err(Box::new(source)), - } -} - -fn payload_value_parser( - input: &DirectiveInput<'_>, -) -> Result> -where - T: DirectiveValue, - for<'input, 'directive> T: - TryFrom<&'input DirectiveInput<'directive>, Error = ::Error>, -{ - let span = T::span(input); - match T::try_from(input) { - Ok(value) => Ok(ParsedDirective::Payload(TypedValue::new(value, span))), - Err(source) => Err(Box::new(source)), - } -} - -fn empty_parser( - _input: &DirectiveInput<'_>, -) -> Result> { - Ok(ParsedDirective::Empty) -} - -#[derive(Debug, Clone, Copy, Default)] -pub struct BuildOptions<'a> { - pub dhttp_home: Option<&'a dhttp::home::DhttpHome>, - pub identity_profile: Option<&'a dhttp::home::identity::IdentityProfile>, -} - -impl BuildOptions<'_> { - pub fn has_dhttp_home_context(&self) -> bool { - self.dhttp_home.is_some() || self.identity_profile.is_some() - } -} - -impl ConfigRegistry { - pub fn new() -> Self { - Self::default() - } - - pub fn register_context(&mut self, spec: ContextSpec) { - self.contexts.insert(spec.key, spec); - } - - pub fn register_directive(&mut self, context: ContextKey, spec: DirectiveSpec) { - self.directives.insert((context, spec.name), spec); - } - - pub fn build( - &self, - source_map: Arc, - directives: Vec, - options: BuildOptions<'_>, - ) -> Result { - let span = directives - .first() - .map(|directive| directive.span) - .unwrap_or(SourceSpan { - source_id: crate::parse::source::SourceId(0), - start: 0, - end: 0, - }); - let mut root = ConfigNode::new(context::ROOT, None, span); - self.build_into( - source_map.as_ref(), - &mut root, - context::ROOT, - directives, - &options, - )?; - let root = Arc::new(root); - put_parent_recursively(&root, None); - Ok(ConfigDocument::new(source_map, root)) - } - - fn build_into( - &self, - source_map: &SourceMap, - node: &mut ConfigNode, - context: ContextKey, - directives: Vec, - options: &BuildOptions<'_>, - ) -> Result<(), BuildDocumentError> { - for directive in directives { - let directive_name = directive.name.value.clone(); - let spec = self - .directives - .get(&(context, directive_name.as_str())) - .with_context(|| build_document_error::UnknownDirectiveSnafu { - directive: directive_name.clone(), - span: directive.name.span, - })?; - apply_shape(&directive, spec)?; - let parsed = (spec.parser)(&DirectiveInput { - directive: &directive, - context, - source_map, - }) - .context(build_document_error::DirectiveParseSnafu { - directive: directive_name.clone(), - span: directive.span, - })?; - match spec.shape { - DirectiveShape::Leaf | DirectiveShape::RawBlock => { - if let ParsedDirective::Slot(value) = parsed { - insert_slot(node, spec, value, source_map)?; - } - } - DirectiveShape::ContextBlock { - child_context, - payload, - } => { - let mut child = ConfigNode::new( - child_context, - Some(directive.name.clone()), - directive.span, - ); - if matches!(payload, PayloadMode::Parser) - && let ParsedDirective::Payload(value) = parsed - { - child.set_payload(value); - } - let AstBody::Block { children, .. } = directive.body else { - unreachable!("shape checked block before child build"); - }; - self.build_into(source_map, &mut child, child_context, children, options)?; - self.finalize(&mut child, child_context, options)?; - node.insert_child(spec.name, Arc::new(child)); - } - } - } - self.finalize(node, context, options)?; - Ok(()) - } - - fn finalize( - &self, - node: &mut ConfigNode, - context: ContextKey, - options: &BuildOptions<'_>, - ) -> Result<(), BuildDocumentError> { - if let Some(spec) = self.contexts.get(&context) - && let Some(finalize) = spec.finalize - { - finalize(node, options).context(build_document_error::FinalizeContextSnafu { - context: context.0, - span: node.span, - })?; - } - Ok(()) - } -} - -fn apply_shape(directive: &AstDirective, spec: &DirectiveSpec) -> Result<(), BuildDocumentError> { - match (&directive.body, spec.shape) { - (AstBody::Leaf { .. }, DirectiveShape::Leaf | DirectiveShape::RawBlock) => Ok(()), - (AstBody::Block { .. }, DirectiveShape::ContextBlock { .. } | DirectiveShape::RawBlock) => { - Ok(()) - } - _ => build_document_error::InvalidDirectiveShapeSnafu { - directive: directive.name.value.clone(), - span: directive.span, - } - .fail(), - } -} - -fn insert_slot( - node: &mut ConfigNode, - spec: &DirectiveSpec, - value: TypedValue, - source_map: &SourceMap, -) -> Result<(), BuildDocumentError> { - let span = value.span(); - let value = normalize::normalize_slot_value(value, source_map).context( - build_document_error::NormalizeDirectiveValueSnafu { - directive: spec.name.to_owned(), - span, - }, - )?; - - match spec.merge { - MergePolicy::RejectDuplicate if !node.get_all_untyped(spec.name).is_empty() => { - let first = node.get_all_untyped(spec.name)[0].span(); - build_document_error::DuplicateDirectiveSnafu { - directive: spec.name.to_owned(), - first, - duplicate: value.span(), - } - .fail() - } - MergePolicy::LastWins => { - node.replace_slot(spec.name, value); - Ok(()) - } - _ => { - node.insert_slot(spec.name, value); - Ok(()) - } - } -} - -fn put_parent_recursively(node: &Arc, parent: Option<&Arc>) { - node.set_parent(parent.map(Arc::downgrade)); - for children in node.child_groups() { - for child in children { - put_parent_recursively(child, Some(node)); - } - } -} diff --git a/gateway/src/parse/source.rs b/gateway/src/parse/source.rs index f322886c..b0aca1ec 100644 --- a/gateway/src/parse/source.rs +++ b/gateway/src/parse/source.rs @@ -4,6 +4,8 @@ use std::{ sync::Arc, }; +use crate::parse::domain::{ConfigDocumentId, ConfigSourceSpan}; + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct SourceId(pub(crate) u32); @@ -49,6 +51,12 @@ pub struct SourceMap { sources: Vec, } +#[derive(Debug)] +pub(crate) struct ConfigDocumentSourceMap { + document_id: ConfigDocumentId, + source_map: Arc, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct LineColumn { pub line: usize, @@ -110,6 +118,35 @@ impl SourceMap { .unwrap_or_else(|| source.text.len()); Some(source.text[start..end].trim_end_matches(['\r', '\n'])) } + + pub fn path_for_span(&self, span: SourceSpan) -> Option<&Path> { + self.get(span.source_id)?.path.as_deref() + } +} + +impl ConfigDocumentSourceMap { + pub(crate) fn new(document_id: ConfigDocumentId, source_map: Arc) -> Self { + Self { + document_id, + source_map, + } + } + + pub(crate) fn document_id(&self) -> ConfigDocumentId { + self.document_id + } + + pub(crate) fn config_span(&self, span: SourceSpan) -> ConfigSourceSpan { + ConfigSourceSpan::new(self.document_id, span) + } + + pub(crate) fn source_map(&self) -> &SourceMap { + &self.source_map + } + + pub(crate) fn source_map_arc(&self) -> &Arc { + &self.source_map + } } impl fmt::Display for SourceId { diff --git a/gateway/src/parse/tests.rs b/gateway/src/parse/tests.rs index 2381316c..b779ef58 100644 --- a/gateway/src/parse/tests.rs +++ b/gateway/src/parse/tests.rs @@ -1,22 +1,101 @@ use std::{ + error::Error, path::{Path, PathBuf}, - sync::{ - Arc, - atomic::{AtomicU64, Ordering}, + sync::Arc, +}; + +use super::{ + ParsedPishooConfig, TypedConfigParser, + config::{ + AccessLogDirective, LocationConfig, OriginScope, ResolvedAccessLogConfig, ServerConfig, }, + error::ConfigLoadFailure, }; -use crate::parse::document::{ConfigDocument, ConfigNode}; +pub(crate) fn parse_root(text: &str) -> Result { + TypedConfigParser::new().parse_root(text, Path::new("/tmp/pishoo.conf"), None) +} + +pub(crate) fn parse_direct_server(extra: &str) -> Result { + let text = format!( + "pishoo {{ server {{ listen all 5378; server_name example.com; \ + ssl_certificate /tmp/server.crt; ssl_certificate_key /tmp/server.key; {extra} }} }}" + ); + let parsed = + parse_root(&text).map_err(|error| snafu::Report::from_error(&error).to_string())?; + match parsed + .servers() + .first() + .expect("fixture has one server") + .result() + { + Ok(server) => Ok(server.clone()), + Err(error) => Err(snafu::Report::from_error(error).to_string()), + } +} + +pub(crate) fn first_server(text: &str) -> Result { + let parsed = parse_root(text).map_err(|error| snafu::Report::from_error(&error).to_string())?; + parsed + .into_parts() + .1 + .into_vec() + .into_iter() + .next() + .expect("fixture has one server") + .into_result() + .map_err(|error| snafu::Report::from_error(&error).to_string()) +} + +pub(crate) fn first_location(text: &str) -> Result { + first_server(text)? + .locations() + .first() + .cloned() + .ok_or_else(|| "fixture has no location".to_owned()) +} + +pub(crate) fn parse_location(body: &str) -> Result, String> { + parse_location_pattern("/", body) +} + +pub(crate) fn parse_location_pattern( + pattern: &str, + body: &str, +) -> Result, String> { + let server = parse_direct_server(&format!("location {pattern} {{ {body} }}"))?; + Ok(Arc::new( + server + .locations() + .first() + .expect("fixture has one location") + .clone(), + )) +} + +pub(crate) fn build_server_conf(cert: &Path, key: &Path, body: &str) -> String { + format!( + "pishoo {{ server {{ listen all 5378; server_name example.com; \ + ssl_certificate {}; ssl_certificate_key {}; {body} }} }}", + cert.display(), + key.display(), + ) +} -static NEXT_TEMP_ID: AtomicU64 = AtomicU64::new(0); +pub(crate) fn build_proxy_conf(cert: &Path, key: &Path, body: &str) -> String { + build_server_conf(cert, key, &format!("location / {{ {body} }}")) +} -pub(crate) fn create_temp_file(prefix: &str) -> PathBuf { +pub(crate) fn create_temp_file(name: &str) -> PathBuf { let path = std::env::temp_dir().join(format!( - "gateway_{prefix}_{}_{}.pem", + "gateway-{name}-{}-{}", std::process::id(), - NEXT_TEMP_ID.fetch_add(1, Ordering::Relaxed) + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock after epoch") + .as_nanos(), )); - std::fs::write(&path, "dummy").expect("write temp config fixture"); + std::fs::write(&path, b"fixture").expect("create temporary fixture"); path } @@ -26,67 +105,208 @@ pub(crate) fn cleanup_temp_files(paths: &[&Path]) { } } -pub(crate) fn parse_doc(conf: &str) -> ConfigDocument { - crate::parse::parse_config_str_for_test(conf).expect("config should parse") +pub(crate) fn assert_error_chain_display_single_line(error: &(dyn Error + 'static)) { + let mut current = Some(error); + while let Some(error) = current { + assert!(!error.to_string().contains('\n')); + current = error.source(); + } } -pub(crate) fn first_pishoo(document: &ConfigDocument) -> Arc { - document.root.children("pishoo").expect("pishoo children")[0].clone() +#[test] +fn concrete_tree_computes_full_lineage_during_construction() { + let root = TypedConfigParser::new() + .parse_root("pishoo { gzip off; }", Path::new("/tmp/root.conf"), None) + .unwrap(); + let root_defaults = root.pishoo().worker_defaults(); + let home = dhttp::home::DhttpHome::for_user_home_dir(PathBuf::from("/tmp/home")); + let worker = TypedConfigParser::new() + .parse_worker( + "pishoo { gzip on; }", + Path::new("/tmp/home/.config/dhttp/pishoo.conf"), + &home, + &root_defaults, + ) + .unwrap(); + let worker_defaults = worker.pishoo().worker_defaults(); + let profile = home.identity_profile("example.com".parse().unwrap()); + let identity = TypedConfigParser::new() + .parse_identity( + "server { listen all 443; location / { gzip off; } }", + Path::new("/tmp/home/.config/dhttp/identity/example.com/server.conf"), + profile, + &worker_defaults, + ) + .unwrap(); + let server = identity.result().as_ref().unwrap(); + let gzip = server.locations()[0].http().gzip(); + + assert!(!gzip.effective().0); + assert_eq!( + gzip.lineage() + .iter() + .map(|origin| origin.scope()) + .collect::>(), + [ + OriginScope::Builtin, + OriginScope::RootPishoo, + OriginScope::WorkerPishoo, + OriginScope::Location, + ] + ); } -pub(crate) fn first_server(document: &ConfigDocument) -> Arc { - first_pishoo(document) - .children("server") - .expect("server children")[0] - .clone() +#[test] +fn lineage_points_to_the_explicit_directive() { + let parsed = TypedConfigParser::new() + .parse_root( + "pishoo {\n gzip on;\n}", + Path::new("/etc/pishoo/pishoo.conf"), + None, + ) + .unwrap(); + let origin = parsed.pishoo().http().gzip().lineage().last().unwrap(); + + assert_eq!(origin.path(), Some(Path::new("/etc/pishoo/pishoo.conf"))); + assert_eq!(origin.line(), Some(2)); + assert_eq!(origin.column(), Some(5)); } -pub(crate) fn build_server_conf( - server_cert: &Path, - server_key: &Path, - server_body: &str, -) -> String { - format!( - "pishoo {{ server {{ listen all 5378; server_name example.com; ssl_certificate {}; ssl_certificate_key {}; {} }} }}", - server_cert.display(), - server_key.display(), - server_body +#[test] +fn direct_server_semantic_failure_is_isolated_as_candidate() { + let parsed = parse_root( + "pishoo { server { listen all 443; server_name bad.example; } \ + server { listen all 444; server_name good.example; ssl_certificate /tmp/c; ssl_certificate_key /tmp/k; } }", ) + .unwrap(); + + assert!(parsed.servers()[0].result().is_err()); + assert!(parsed.servers()[1].result().is_ok()); } -pub(crate) fn build_proxy_conf( - server_cert: &Path, - server_key: &Path, - location_body: &str, -) -> String { - format!( - r#" -pishoo {{ - server {{ - listen all 5378; - server_name example.com; - ssl_certificate {}; - ssl_certificate_key {}; - location /api {{ - {} - }} - }} -}} -"#, - server_cert.display(), - server_key.display(), - location_body +#[test] +fn duplicate_scalar_directive_is_rejected() { + let failure = parse_root("pishoo { gzip on; gzip off; }").unwrap_err(); + assert!( + snafu::Report::from_error(&failure) + .to_string() + .contains("duplicate directive `gzip`") + ); +} + +#[test] +fn server_accepts_multiple_location_blocks() { + let server = parse_direct_server("location / {} location /files {}").unwrap(); + + assert_eq!(server.locations().len(), 2); +} + +#[test] +fn root_snapshot_checked_serde_round_trip_preserves_every_field() { + let parsed = parse_root( + "pishoo { gzip on; gzip_vary on; gzip_min_length 91; gzip_comp_level 4; \ + gzip_types text/plain application/json; default_type application/octet-stream; \ + access_log /tmp/access.log; }", ) + .unwrap(); + let expected = parsed.pishoo().worker_defaults(); + let bytes = serde_json::to_vec(&expected).unwrap(); + let actual = serde_json::from_slice(&bytes).unwrap(); + + assert_eq!(expected, actual); } -pub(crate) fn assert_error_chain_display_single_line(error: &(dyn std::error::Error + 'static)) { - let mut current = Some(error); - while let Some(error) = current { - assert!( - !error.to_string().contains('\n'), - "error display should be single-line: {}", - error - ); - current = error.source(); - } +#[test] +fn access_log_defaults_follow_server_identity_ownership() { + let direct = parse_direct_server("").unwrap(); + assert_eq!( + direct.http().access_log().effective(), + &AccessLogDirective::Off + ); + + let home = dhttp::home::DhttpHome::for_user_home_dir(PathBuf::from("/tmp/alice")); + let profile = home.identity_profile("alice.dhttp.net".parse().unwrap()); + let defaults = TypedConfigParser::new() + .parse_root( + "pishoo {}", + Path::new("/etc/pishoo/pishoo.conf"), + Some(&home), + ) + .unwrap() + .pishoo() + .worker_defaults(); + let identity = TypedConfigParser::new() + .parse_identity( + "server { listen all 443; location / {} }", + &profile.server_conf_path(), + profile.clone(), + &defaults, + ) + .unwrap(); + let server = identity.result().as_ref().unwrap(); + + assert_eq!( + server.http().access_log().effective(), + &AccessLogDirective::ProfileDefault + ); + assert_eq!( + server + .http() + .access_log() + .effective() + .materialize(server.identity()) + .unwrap(), + ResolvedAccessLogConfig::Enabled( + super::domain::ResolvedConfigPath::try_from(profile.access_log_path()).unwrap() + ) + ); +} + +#[test] +fn location_access_log_override_keeps_location_lineage() { + let server = first_server( + "pishoo { access_log /tmp/root.log; server { listen all 443; \ + server_name example.com; ssl_certificate /tmp/c; ssl_certificate_key /tmp/k; \ + location /private { access_log off; } } }", + ) + .unwrap(); + let access_log = server.locations()[0].http().access_log(); + + assert_eq!(access_log.effective(), &AccessLogDirective::Off); + assert_eq!( + access_log.lineage().last().unwrap().scope(), + OriginScope::Location + ); +} + +#[test] +fn root_access_log_path_survives_worker_snapshot_without_rebasing() { + let home = dhttp::home::DhttpHome::for_user_home_dir(PathBuf::from("/home/alice")); + let root = TypedConfigParser::new() + .parse_root( + "pishoo { access_log logs/access.log; }", + Path::new("/etc/pishoo/pishoo.conf"), + Some(&home), + ) + .unwrap() + .pishoo() + .worker_defaults(); + let worker = TypedConfigParser::new() + .parse_worker( + "pishoo {}", + Path::new("/home/alice/.config/dhttp/pishoo.conf"), + &home, + &root, + ) + .unwrap(); + + assert_eq!( + worker + .pishoo() + .http() + .access_log() + .effective() + .resolved_path(), + Some(Path::new("/etc/pishoo/logs/access.log")) + ); } diff --git a/gateway/src/parse/types.rs b/gateway/src/parse/types.rs index 8a511498..7d793050 100644 --- a/gateway/src/parse/types.rs +++ b/gateway/src/parse/types.rs @@ -10,54 +10,19 @@ pub struct ServerName { pub name: DhttpName<'static>, } -// TLS identity configuration for clients/servers that need certificates #[derive(Debug, Clone, PartialEq, Eq)] -pub struct ServerIdentity { - pub cert_path: PathBuf, - pub key_path: PathBuf, - pub server_name: DhttpName<'static>, -} - -pub fn server_identity( - node: &crate::parse::document::ConfigNode, - server_name: DhttpName<'static>, -) -> Option { - let cert_path = node.get::("ssl_certificate").ok().flatten()?; - let key_path = node - .get::("ssl_certificate_key") - .ok() - .flatten()?; - Some(ServerIdentity { - cert_path: cert_path.0.clone(), - key_path: key_path.0.clone(), - server_name, - }) -} - -pub fn optional_server_identity( - node: &crate::parse::document::ConfigNode, - server_name_key: &str, -) -> Option { - let server_name = node - .get::(server_name_key) - .ok() - .flatten()?; - server_identity(node, server_name.0.clone()) -} - -#[derive(Debug, Clone)] pub struct BoolConfig(pub bool); #[derive(Debug, Clone)] pub struct StringConfig(pub String); -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct StringList(pub Vec); #[derive(Debug, Clone)] pub struct PathConfig(pub std::path::PathBuf); -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct AccessRulesUri(pub url::Url); #[derive(Debug, Clone, PartialEq, Eq)] @@ -119,18 +84,147 @@ pub struct HeaderRule { #[derive(Debug, Clone)] pub struct HeaderRules(pub Vec); -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct MimeTypes(pub std::collections::HashMap); -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct DefaultType(pub http::HeaderValue); -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct GzipMinLength(pub u64); -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct GzipCompLevel(pub i32); +#[derive(Debug, Snafu)] +#[snafu(module)] +pub enum AccessRulesUriValidationError { + #[snafu(display("unsupported access_rules uri scheme `{scheme}`"))] + UnsupportedScheme { scheme: String }, + #[snafu(display("unsupported sqlite access_rules uri form"))] + UnsupportedSqliteForm, + #[snafu(display("access_rules sqlite path must be absolute"))] + RelativePath, + #[snafu(display("access_rules sqlite path contains a NUL byte"))] + NulPath, + #[snafu(display("access_rules sqlite path is not valid UTF-8 on this platform"))] + InvalidPathEncoding, +} + +impl TryFrom for AccessRulesUri { + type Error = AccessRulesUriValidationError; + + fn try_from(uri: url::Url) -> Result { + let path = Self::decoded_sqlite_path(&uri)?; + if !path.is_absolute() { + return Err(AccessRulesUriValidationError::RelativePath); + } + Ok(Self(uri)) + } +} + +impl AccessRulesUri { + pub(crate) fn decoded_sqlite_path( + uri: &url::Url, + ) -> Result { + if uri.scheme() != "sqlite" { + return Err(AccessRulesUriValidationError::UnsupportedScheme { + scheme: uri.scheme().to_owned(), + }); + } + if uri.host_str().is_some() + || !uri.username().is_empty() + || uri.password().is_some() + || uri.port().is_some() + || uri.fragment().is_some() + { + return Err(AccessRulesUriValidationError::UnsupportedSqliteForm); + } + let path = percent_encoding::percent_decode_str(uri.path()).collect::>(); + if path.contains(&0) { + return Err(AccessRulesUriValidationError::NulPath); + } + #[cfg(unix)] + { + use std::os::unix::ffi::OsStringExt; + Ok(std::ffi::OsString::from_vec(path).into()) + } + #[cfg(not(unix))] + { + String::from_utf8(path) + .map(PathBuf::from) + .map_err(|_| AccessRulesUriValidationError::InvalidPathEncoding) + } + } +} + +#[derive(Debug, Snafu)] +#[snafu(module)] +pub enum GzipTypesValidationError { + #[snafu(display("gzip_types contains an empty MIME token"))] + EmptyToken, +} + +impl StringList { + pub fn checked_gzip_types(values: Vec) -> Result { + if values.iter().any(String::is_empty) { + return Err(GzipTypesValidationError::EmptyToken); + } + Ok(Self(values)) + } +} + +impl GzipMinLength { + pub const fn checked(value: u64) -> Self { + Self(value) + } +} + +impl GzipCompLevel { + pub const fn checked(value: i32) -> Self { + Self(value) + } +} + +impl DefaultType { + pub fn checked_from_bytes(value: &[u8]) -> Result { + http::HeaderValue::from_bytes(value).map(Self) + } +} + +#[derive(Debug, Snafu)] +#[snafu(module)] +pub enum MimeTypesValidationError { + #[snafu(display("MIME extension must not be empty"))] + EmptyExtension, + #[snafu(display("duplicate MIME extension `{extension}`"))] + DuplicateExtension { extension: String }, + #[snafu(display("invalid MIME type header value"))] + HeaderValue { + source: http::header::InvalidHeaderValue, + }, +} + +impl MimeTypes { + pub fn checked_from_bytes(entries: I) -> Result + where + I: IntoIterator)>, + { + let mut values = std::collections::HashMap::new(); + for (extension, value) in entries { + if extension.is_empty() { + return Err(MimeTypesValidationError::EmptyExtension); + } + let value = http::HeaderValue::from_bytes(&value) + .map_err(|source| MimeTypesValidationError::HeaderValue { source })?; + if values.insert(extension.clone(), value).is_some() { + return Err(MimeTypesValidationError::DuplicateExtension { extension }); + } + } + Ok(Self(values)) + } +} + #[derive(Debug, Clone)] pub struct SshLoginMethods(pub Vec); @@ -143,7 +237,7 @@ pub struct SshSslUser { #[derive(Debug, Clone)] pub struct SshSslUsers(pub Vec); -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct StunBindConfigValue { pub bind: std::net::SocketAddr, pub outer_addr: Option, @@ -151,9 +245,14 @@ pub struct StunBindConfigValue { pub change_port: Option, } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct StunChangePort(pub u16); +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct StunServerConfigValue { + pub binds: Box<[StunBindConfigValue]>, +} + #[derive(Debug, Clone, Default, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] pub enum IpFamilies { V4, @@ -170,7 +269,9 @@ impl FromStr for IpFamilies { "v4only" => Ok(IpFamilies::V4), "v6only" => Ok(IpFamilies::V6), "dual" => Ok(IpFamilies::Dual), - _ => whatever!("invalid ip families: {s}, expected `v4only`, `v6only` or `dual`"), + _ => { + whatever!("invalid ip families: {s}, expected `v4only`, `v6only` or `dual`"); + } } } } @@ -234,6 +335,11 @@ pub struct Listens { pub enum ListenBindPatternError { #[snafu(display("unsupported listen iface range `{range}`"))] UnsupportedIfaceRange { range: IfaceRange }, + #[snafu(display("failed to construct generated bind pattern `{input}`"))] + GeneratedBindPattern { + input: String, + source: peg::error::ParseError, + }, } impl Listens { @@ -247,17 +353,17 @@ impl Listens { } pub fn try_to_bind_patterns(&self) -> Result, ListenBindPatternError> { - fn parse_pattern(input: String) -> BindPattern { + fn parse_pattern(input: String) -> Result { input .parse() - .expect("generated bind pattern should be valid") + .map_err(|source| ListenBindPatternError::GeneratedBindPattern { input, source }) } if let Some(specific_addrs) = &self.specific_addrs { - return Ok(specific_addrs + return specific_addrs .iter() .map(|addr| parse_pattern(format!("inet://{addr}"))) - .collect()); + .collect(); } let host = match &self.range { @@ -266,12 +372,14 @@ impl Listens { IfaceRange::Internal => { return Ok(match self.families { IpFamilies::V4 => { - vec![parse_pattern(format!("inet://127.0.0.1:{}", self.port))] + vec![parse_pattern(format!("inet://127.0.0.1:{}", self.port))?] + } + IpFamilies::V6 => { + vec![parse_pattern(format!("inet://[::1]:{}", self.port))?] } - IpFamilies::V6 => vec![parse_pattern(format!("inet://[::1]:{}", self.port))], IpFamilies::Dual => vec![ - parse_pattern(format!("inet://127.0.0.1:{}", self.port)), - parse_pattern(format!("inet://[::1]:{}", self.port)), + parse_pattern(format!("inet://127.0.0.1:{}", self.port))?, + parse_pattern(format!("inet://[::1]:{}", self.port))?, ], }); } @@ -292,7 +400,7 @@ impl Listens { Ok(vec![parse_pattern(format!( "iface://{family_prefix}{host}:{}", self.port - ))]) + ))?]) } } diff --git a/gateway/src/parse/value.rs b/gateway/src/parse/value.rs deleted file mode 100644 index 1a42b9ca..00000000 --- a/gateway/src/parse/value.rs +++ /dev/null @@ -1,60 +0,0 @@ -use std::{ - any::{Any, TypeId}, - fmt, - sync::Arc, -}; - -use crate::parse::source::SourceSpan; - -pub trait ConfigValue: fmt::Debug + Send + Sync + 'static {} - -impl ConfigValue for T where T: fmt::Debug + Send + Sync + 'static {} - -#[derive(Clone)] -pub struct TypedValue { - value: Arc, - type_id: TypeId, - type_name: &'static str, - span: SourceSpan, -} - -impl TypedValue { - pub fn new(value: T, span: SourceSpan) -> Self - where - T: ConfigValue, - { - Self { - value: Arc::new(value), - type_id: TypeId::of::(), - type_name: std::any::type_name::(), - span, - } - } - - pub fn downcast(&self) -> Option> - where - T: ConfigValue, - { - if self.type_id != TypeId::of::() { - return None; - } - Arc::clone(&self.value).downcast::().ok() - } - - pub fn type_name(&self) -> &'static str { - self.type_name - } - - pub fn span(&self) -> SourceSpan { - self.span - } -} - -impl fmt::Debug for TypedValue { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("TypedValue") - .field("type_name", &self.type_name) - .field("span", &self.span) - .finish_non_exhaustive() - } -} diff --git a/gateway/src/reverse/access_log.rs b/gateway/src/reverse/access_log.rs index da542ebc..cb74d0af 100644 --- a/gateway/src/reverse/access_log.rs +++ b/gateway/src/reverse/access_log.rs @@ -1,69 +1,177 @@ -use std::{io::Write, net::SocketAddr}; +mod body; + +use std::{net::SocketAddr, sync::Arc}; use axum::{ + body::Body, extract::{Request, State}, middleware::Next, response::Response, }; -use chrono::Local; -use http::HeaderValue; +use body::{AccessLogBody, AccessRecordSeed}; +use dhttp::log::access::{ + AccessLogRecord, AccessRequestTarget, BodyBytesEmitted, ClientAddress, OptionalReferer, + OptionalUserAgent, +}; + +use super::log::AccessLogOutput; -use super::log::AccessLogWriter; +#[derive(Clone, Debug)] +pub enum ActiveAccessLog { + Disabled, + Enabled(Arc), +} + +impl ActiveAccessLog { + pub fn from_output(output: Option>) -> Self { + match output { + Some(output) => Self::Enabled(output), + None => Self::Disabled, + } + } +} -/// Shared state for the access log middleware. -#[derive(Clone)] +#[derive(Clone, Debug)] pub struct AccessLogState { - pub writer: AccessLogWriter, + pub server: ActiveAccessLog, } -/// Axum middleware that logs every request/response in Combined Log Format. pub async fn access_log( State(state): State, request: Request, next: Next, ) -> Response { - // Capture cheap-to-clone / Copy values before the request is consumed. - let method = request.method().clone(); - let uri = request.uri().clone(); - let version = request.version(); - let user_agent: Option = request.headers().get("user-agent").cloned(); - let referer: Option = request.headers().get("referer").cloned(); - let client_addr: Option = request.extensions().get::().copied(); - + let seed = RequestSeed::capture(&request); let response = next.run(request).await; - - let status = response.status().as_u16(); - let body_size = response - .headers() - .get(http::header::CONTENT_LENGTH) - .and_then(|v| v.to_str().ok()) - .and_then(|v| v.parse::().ok()) - .unwrap_or(0); - - // Format the complete CLF line directly into the writer (one write_all). - let time_local = Local::now().format("%d/%b/%Y:%H:%M:%S %z"); - let ua = user_agent - .as_ref() - .and_then(|v| v.to_str().ok()) - .unwrap_or("-"); - let rf = referer - .as_ref() - .and_then(|v| v.to_str().ok()) - .unwrap_or("-"); - - // Best-effort: silently drop if the channel is full. - let mut writer = state.writer.clone(); - let _ = match client_addr { - Some(addr) => writeln!( - writer, - "{} - - [{time_local}] \"{method} {uri} {version:?}\" {status} {body_size} \"{rf}\" \"{ua}\"", - addr.ip() - ), - None => writeln!( - writer, - "- - - [{time_local}] \"{method} {uri} {version:?}\" {status} {body_size} \"{rf}\" \"{ua}\"" - ), + let active = response + .extensions() + .get::() + .cloned() + .unwrap_or(state.server); + let ActiveAccessLog::Enabled(output) = active else { + return response; }; - response + let (parts, body) = response.into_parts(); + let record = seed.complete(parts.status); + if record.has_no_body() { + output.write(&record.finish(BodyBytesEmitted::ZERO)); + return Response::from_parts(parts, body); + } + + Response::from_parts(parts, Body::new(AccessLogBody::new(body, output, record))) +} + +struct RequestSeed { + client: ClientAddress, + method: http::Method, + target: AccessRequestTarget, + version: http::Version, + referer: OptionalReferer, + user_agent: OptionalUserAgent, +} + +impl RequestSeed { + fn capture(request: &Request) -> Self { + let client = request + .extensions() + .get::() + .map_or(ClientAddress::Unknown, |address| { + ClientAddress::Ip(address.ip()) + }); + Self { + client, + method: request.method().clone(), + target: AccessRequestTarget::from(request.uri()), + version: request.version(), + referer: OptionalReferer::from(request.headers()), + user_agent: OptionalUserAgent::from(request.headers()), + } + } + + fn complete(self, status: http::StatusCode) -> AccessRecordSeed { + AccessRecordSeed { + client: self.client, + method: self.method, + target: self.target, + version: self.version, + referer: self.referer, + user_agent: self.user_agent, + status, + } + } +} + +impl AccessRecordSeed { + fn has_no_body(&self) -> bool { + self.method == http::Method::HEAD + || self.status.is_informational() + || self.status == http::StatusCode::NO_CONTENT + || self.status == http::StatusCode::NOT_MODIFIED + } + + fn finish(self, body_bytes: BodyBytesEmitted) -> AccessLogRecord { + AccessLogRecord { + completed_at: chrono::Local::now().fixed_offset(), + client: self.client, + method: self.method, + target: self.target, + version: self.version, + status: self.status, + body_bytes, + referer: self.referer, + user_agent: self.user_agent, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn record(method: http::Method, status: http::StatusCode) -> AccessRecordSeed { + RequestSeed::capture( + &Request::builder() + .method(method) + .uri("/private?token=secret") + .body(Body::empty()) + .unwrap(), + ) + .complete(status) + } + + #[test] + fn request_capture_discards_query_and_unallowlisted_headers() { + let mut request = Request::builder() + .uri("/private?token=secret") + .header(http::header::AUTHORIZATION, "Bearer secret") + .header(http::header::COOKIE, "session=secret") + .body(Body::empty()) + .unwrap(); + request + .extensions_mut() + .insert(SocketAddr::from(([127, 0, 0, 1], 443))); + + let record = RequestSeed::capture(&request) + .complete(http::StatusCode::OK) + .finish(BodyBytesEmitted::ZERO); + + assert_eq!(record.target.path(), Some("/private")); + assert_eq!(record.client, ClientAddress::Ip([127, 0, 0, 1].into())); + assert_eq!(record.referer.value(), None); + assert_eq!(record.user_agent.value(), None); + } + + #[test] + fn head_and_bodyless_statuses_finalize_without_observing_a_body() { + assert!(record(http::Method::HEAD, http::StatusCode::OK).has_no_body()); + for status in [ + http::StatusCode::CONTINUE, + http::StatusCode::NO_CONTENT, + http::StatusCode::NOT_MODIFIED, + ] { + assert!(record(http::Method::GET, status).has_no_body()); + } + assert!(!record(http::Method::GET, http::StatusCode::OK).has_no_body()); + } } diff --git a/gateway/src/reverse/access_log/body.rs b/gateway/src/reverse/access_log/body.rs new file mode 100644 index 00000000..9d21603f --- /dev/null +++ b/gateway/src/reverse/access_log/body.rs @@ -0,0 +1,215 @@ +use std::{ + pin::Pin, + sync::Arc, + task::{Context, Poll}, +}; + +use bytes::Buf; +use dhttp::log::access::{ + AccessRequestTarget, BodyBytesEmitted, ClientAddress, OptionalReferer, OptionalUserAgent, +}; +use http_body::{Body, Frame}; + +use crate::reverse::log::AccessLogOutput; + +pub(super) struct AccessRecordSeed { + pub client: ClientAddress, + pub method: http::Method, + pub target: AccessRequestTarget, + pub version: http::Version, + pub referer: OptionalReferer, + pub user_agent: OptionalUserAgent, + pub status: http::StatusCode, +} + +pub(super) struct AccessLogBody { + body: B, + output: Arc, + seed: Option, + bytes: BodyBytesEmitted, +} + +impl AccessLogBody { + pub fn new(body: B, output: Arc, seed: AccessRecordSeed) -> Self { + Self { + body, + output, + seed: Some(seed), + bytes: BodyBytesEmitted::ZERO, + } + } + + fn finalize(&mut self) { + let Some(seed) = self.seed.take() else { return }; + self.output.write(&seed.finish(self.bytes)); + } +} + +impl Body for AccessLogBody +where + B: Body + Unpin, +{ + type Data = B::Data; + type Error = B::Error; + + fn poll_frame( + mut self: Pin<&mut Self>, + context: &mut Context<'_>, + ) -> Poll, Self::Error>>> { + let frame = match Pin::new(&mut self.body).poll_frame(context) { + Poll::Pending => return Poll::Pending, + Poll::Ready(frame) => frame, + }; + + match &frame { + Some(Ok(frame)) => { + if let Some(data) = frame.data_ref() { + match self.bytes.checked_add(data.remaining()) { + Some(bytes) => self.bytes = bytes, + None => { + tracing::warn!("access log response body byte count overflowed"); + self.seed = None; + } + } + } + } + Some(Err(_)) | None => self.finalize(), + } + Poll::Ready(frame) + } + + fn is_end_stream(&self) -> bool { + self.body.is_end_stream() + } + + fn size_hint(&self) -> http_body::SizeHint { + self.body.size_hint() + } +} + +impl Drop for AccessLogBody { + fn drop(&mut self) { + self.finalize(); + } +} + +#[cfg(test)] +mod tests { + use std::{path::PathBuf, time::Duration}; + + use bytes::Bytes; + use futures::{StreamExt, stream}; + use http_body_util::{BodyExt, StreamBody}; + + use super::*; + use crate::parse::domain::ResolvedConfigPath; + + struct TempLog(PathBuf); + + impl TempLog { + fn new() -> Self { + Self(std::env::temp_dir().join(format!( + "gateway-access-body-{}-{}.log", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + ))) + } + + fn output(&self) -> Arc { + Arc::new( + AccessLogOutput::open(ResolvedConfigPath::try_from(self.0.clone()).unwrap()) + .unwrap(), + ) + } + + fn wait_for_line(&self) -> String { + for _ in 0..100 { + if let Ok(contents) = std::fs::read_to_string(&self.0) + && contents.ends_with('\n') + { + return contents; + } + std::thread::sleep(Duration::from_millis(5)); + } + panic!("access record was not delivered to {}", self.0.display()); + } + } + + impl Drop for TempLog { + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.0); + } + } + + fn seed() -> AccessRecordSeed { + AccessRecordSeed { + client: ClientAddress::Unknown, + method: http::Method::GET, + target: "/body".parse().unwrap(), + version: http::Version::HTTP_3, + referer: OptionalReferer::default(), + user_agent: OptionalUserAgent::default(), + status: http::StatusCode::OK, + } + } + + #[tokio::test] + async fn body_error_and_drop_record_actual_data_once() { + let log = TempLog::new(); + let output = log.output(); + let frames = stream::iter([ + Ok(Frame::data(Bytes::from_static(b"abc"))), + Err(std::io::Error::other("boom")), + ]); + let mut body = std::pin::pin!(AccessLogBody::new(StreamBody::new(frames), output, seed(),)); + + assert_eq!( + body.frame().await.unwrap().unwrap().into_data().unwrap(), + Bytes::from_static(b"abc") + ); + assert!(body.frame().await.unwrap().is_err()); + let contents = log.wait_for_line(); + assert_eq!(contents.lines().count(), 1); + assert!(contents.contains(" 200 3 "), "{contents}"); + } + + #[tokio::test] + async fn normal_end_counts_only_data_frames_once() { + let log = TempLog::new(); + let output = log.output(); + let frames = stream::iter([ + Ok::<_, std::io::Error>(Frame::data(Bytes::from_static(b"ab"))), + Ok(Frame::trailers(http::HeaderMap::new())), + Ok(Frame::data(Bytes::from_static(b"c"))), + ]); + let body = AccessLogBody::new(StreamBody::new(frames), output, seed()); + + let collected = body.collect().await.unwrap(); + assert_eq!(collected.to_bytes(), Bytes::from_static(b"abc")); + + let contents = log.wait_for_line(); + assert_eq!(contents.lines().count(), 1); + assert!(contents.contains(" 200 3 "), "{contents}"); + } + + #[tokio::test] + async fn dropping_a_pending_body_records_only_emitted_data() { + let log = TempLog::new(); + let output = log.output(); + let frames = stream::iter([Ok::<_, std::io::Error>(Frame::data(Bytes::from_static( + b"ab", + )))]) + .chain(stream::pending()); + let mut body = Box::pin(AccessLogBody::new(StreamBody::new(frames), output, seed())); + + assert!(body.frame().await.unwrap().is_ok()); + drop(body); + + let contents = log.wait_for_line(); + assert_eq!(contents.lines().count(), 1); + assert!(contents.contains(" 200 2 "), "{contents}"); + } +} diff --git a/gateway/src/reverse/file.rs b/gateway/src/reverse/file.rs index b2a14e97..a403a0fb 100644 --- a/gateway/src/reverse/file.rs +++ b/gateway/src/reverse/file.rs @@ -10,7 +10,7 @@ use tracing::{debug, error, info, warn}; use crate::{ command::{self, IndexError, content_type, index}, - parse::{document::ConfigNode, types::PathConfig}, + parse::config::LocationConfig, reverse::location::LocationMatch, }; @@ -35,10 +35,10 @@ pub async fn file_handle( ) -> impl IntoResponse { let location = &loc.location; - let file_path = if let Some(alias) = location.get::("alias").ok().flatten() { - static_file_path(&alias.0, &loc.remaining) - } else if let Some(root) = location.get::("root").ok().flatten() { - static_file_path(&root.0, req.uri().path()) + let file_path = if let Some(alias) = location.alias() { + static_file_path(alias.as_ref(), &loc.remaining) + } else if let Some(root) = location.root() { + static_file_path(root.as_ref(), req.uri().path()) } else { unreachable!("file_handle requires root or alias") }; @@ -75,7 +75,7 @@ fn static_file_path(base: &Path, requested: &str) -> Result, + location: Arc, file_path: &Path, uri: &http::Uri, accept_encoding: Option<&str>, diff --git a/gateway/src/reverse/gzip.rs b/gateway/src/reverse/gzip.rs index 093a87e0..d1ebce7c 100644 --- a/gateway/src/reverse/gzip.rs +++ b/gateway/src/reverse/gzip.rs @@ -4,10 +4,7 @@ use async_compression::{Level, tokio::bufread::GzipEncoder}; use http::response::Parts; use tokio_util::io::ReaderStream; -use crate::parse::{ - document::ConfigNode, - types::{BoolConfig, GzipCompLevel, GzipMinLength, StringList}, -}; +use crate::parse::config::LocationConfig; /// Gzip 压缩配置,从 location 节点中提取 pub struct GzipConfig { @@ -21,40 +18,17 @@ pub struct GzipConfig { impl GzipConfig { /// 从 location 配置和请求头中提取 gzip 配置 - pub fn from_location(location: &Arc, accept_encoding: Option<&str>) -> Self { + pub fn from_location(location: &Arc, accept_encoding: Option<&str>) -> Self { let accept_gzip = accept_encoding.map(|v| v.contains("gzip")).unwrap_or(false); Self { - enabled: location - .inherited::("gzip") - .ok() - .flatten() - .map(|value| value.0) - .unwrap_or(false), + enabled: location.http().gzip().effective().0, accept_gzip, - vary: location - .inherited::("gzip_vary") - .ok() - .flatten() - .map(|value| value.0) - .unwrap_or(false), - min_length: location - .inherited::("gzip_min_length") - .ok() - .flatten() - .map(|value| value.0) - .unwrap_or(20), - comp_level: location - .inherited::("gzip_comp_level") - .ok() - .flatten() - .map(|value| value.0) - .unwrap_or(1), - types: location - .inherited::("gzip_types") - .ok() - .flatten() - .map(|value| value.0.clone()), + vary: location.http().gzip_vary().effective().0, + min_length: location.http().gzip_min_length().effective().0, + comp_level: location.http().gzip_comp_level().effective().0, + types: Some(location.http().gzip_types().effective().0.clone()) + .filter(|types| !types.is_empty()), } } @@ -113,7 +87,7 @@ impl GzipConfig { /// /// Returns a new response with the body wrapped in gzip, or the original response unchanged. pub fn compress_response( - location: &Arc, + location: &Arc, accept_encoding: Option<&str>, response: http::Response, ) -> http::Response { @@ -150,7 +124,7 @@ pub fn compress_response( /// /// Returns the response with appropriate headers and optionally compressed body. pub fn compress_file_response( - location: &Arc, + location: &Arc, accept_encoding: Option<&str>, mut parts: http::response::Parts, file: tokio::fs::File, diff --git a/gateway/src/reverse/location.rs b/gateway/src/reverse/location.rs index 4685b479..7239f954 100644 --- a/gateway/src/reverse/location.rs +++ b/gateway/src/reverse/location.rs @@ -1,6 +1,37 @@ use std::sync::Arc; -use crate::parse::{document::ConfigNode, pattern::Pattern}; +use crate::{ + parse::{config::LocationConfig, pattern::Pattern}, + reverse::access_log::ActiveAccessLog, +}; + +#[derive(Clone, Debug)] +pub struct ConfiguredLocation { + config: Arc, + access_log: ActiveAccessLog, +} + +impl ConfiguredLocation { + pub fn new(config: Arc, access_log: ActiveAccessLog) -> Self { + Self { config, access_log } + } + + pub fn access_log(&self) -> &ActiveAccessLog { + &self.access_log + } + + pub fn config(&self) -> &Arc { + &self.config + } +} + +impl std::ops::Deref for ConfiguredLocation { + type Target = LocationConfig; + + fn deref(&self) -> &Self::Target { + &self.config + } +} /// Result of matching a request path against configured location blocks. /// @@ -10,7 +41,8 @@ use crate::parse::{document::ConfigNode, pattern::Pattern}; #[derive(Clone, Debug)] pub struct LocationMatch { /// The matched location configuration node. - pub location: Arc, + pub location: Arc, + pub access_log: ActiveAccessLog, /// The portion of the path that was matched by the pattern. /// /// For prefix patterns, this is the pattern string itself (e.g. "/ssh/"). @@ -28,18 +60,13 @@ pub struct LocationMatch { impl LocationMatch { /// Extract the `Pattern` from the location node. pub fn pattern(&self) -> Pattern { - self.location - .payload::() - .expect("location payload type should be a pattern") - .expect("location node should contain a pattern payload") - .as_ref() - .clone() + self.location.matcher().clone() } } /// Match a request path against all configured location blocks, following /// nginx's location selection order. -pub fn match_location(locations: &[Arc], path: &str) -> Option { +pub fn match_location(locations: &[Arc], path: &str) -> Option { tracing::debug!("all locations {:#?}, path: {:?}", locations, path); let mut exact: Option = None; @@ -48,13 +75,7 @@ pub fn match_location(locations: &[Arc], path: &str) -> Option() - .ok() - .flatten() - .expect("location node should contain a pattern payload"); - - match pattern.as_ref() { + match location.matcher() { Pattern::Exact(expected) if path == expected => { exact = Some(build_location_match(location, expected.clone(), path)); break; @@ -95,35 +116,30 @@ pub fn match_location(locations: &[Arc], path: &str) -> Option() - .ok() - .flatten() - .expect("location node should contain a pattern payload"); - if let Ok(Some(matched)) = pattern.try_match(path) { + if let Ok(Some(matched)) = location.matcher().try_match(path) { return Some(build_location_match(&location, matched.to_owned(), path)); } } longest_prefix } -fn build_location_match(location: &Arc, matched: String, path: &str) -> LocationMatch { +fn build_location_match( + location: &Arc, + matched: String, + path: &str, +) -> LocationMatch { let remaining = compute_remaining(location, path, &matched); LocationMatch { - location: Arc::clone(location), + location: Arc::clone(location.config()), + access_log: location.access_log().clone(), matched, remaining, } } /// Compute the remaining path suffix after the matched portion. -fn compute_remaining(location: &ConfigNode, path: &str, matched: &str) -> String { - let pattern = location - .payload::() - .expect("location payload type should be a pattern") - .expect("location node should contain a pattern payload"); - - match pattern.as_ref() { +fn compute_remaining(location: &ConfiguredLocation, path: &str, matched: &str) -> String { + match location.matcher() { // For prefix-style patterns, the matched string IS the prefix — strip it. Pattern::Exact(_) | Pattern::Prefix(_) | Pattern::NormalPrefix(_) | Pattern::Common => { path.strip_prefix(matched).unwrap_or("").to_string() @@ -136,20 +152,22 @@ fn compute_remaining(location: &ConfigNode, path: &str, matched: &str) -> String #[cfg(test)] mod tests { - use regex::Regex; - use super::*; - use crate::parse::{ - registry::context, - source::{SourceId, SourceSpan}, - value::TypedValue, - }; - - fn make_location(pattern: Pattern) -> Arc { - let span = SourceSpan::new(SourceId(0), 0, 0); - let mut node = ConfigNode::new(context::LOCATION, None, span); - node.set_payload(TypedValue::new(pattern, span)); - Arc::new(node) + use crate::parse::tests::parse_location_pattern; + + fn make_location(pattern: Pattern) -> Arc { + let syntax = match &pattern { + Pattern::Exact(value) => format!("= {value}"), + Pattern::Prefix(value) => format!("^~ {value}"), + Pattern::NormalPrefix(value) => value.clone(), + Pattern::Regex(value) => format!("~ '{}';", value.as_str()), + Pattern::CRegex(value) => format!("~* '{}';", value.as_str()), + Pattern::Common => "/".to_owned(), + }; + Arc::new(ConfiguredLocation::new( + parse_location_pattern(syntax.trim_end_matches(';'), "").unwrap(), + ActiveAccessLog::Disabled, + )) } #[test] @@ -182,7 +200,7 @@ mod tests { #[test] fn test_regex_match_remaining_is_full_path() { let locations = vec![make_location(Pattern::Regex( - Regex::new(r"\.(jpg|gif)$").unwrap(), + regex::Regex::new(r"\.(jpg|gif)$").unwrap(), ))]; let m = match_location(&locations, "/images/cat.jpg").unwrap(); diff --git a/gateway/src/reverse/log.rs b/gateway/src/reverse/log.rs index fec35e61..f8bde040 100644 --- a/gateway/src/reverse/log.rs +++ b/gateway/src/reverse/log.rs @@ -1,69 +1,195 @@ -use std::{io::Write, path::PathBuf, sync::Arc}; +use std::{ + io::Write, + path::{Path, PathBuf}, + sync::Arc, +}; -use dhttp::home::identity::IdentityProfile; +use dhttp::log::access::{AccessLogRecord, DefaultAccessFormatter}; +use snafu::{Report, ResultExt, Snafu}; use tracing_appender::{ non_blocking::{NonBlocking, WorkerGuard}, rolling::{RollingFileAppender, Rotation}, }; -/// A non-blocking, clone-able access log writer backed by -/// `tracing_appender::non_blocking`. -/// -/// Created once at service startup per identity; shared across all requests -/// for that identity via axum middleware state. The internal [`WorkerGuard`] -/// is held via `Arc` so that clones keep the background I/O thread alive -/// until the last reference is dropped. +use crate::parse::domain::ResolvedConfigPath; + +#[derive(Debug, Snafu)] +#[snafu(module(open_access_log_output_error))] +pub enum OpenAccessLogOutputError { + #[snafu(display("access log path `{}` has no file name", path.display()))] + MissingFileName { path: PathBuf }, + #[snafu(display("access log file name at `{}` is not UTF-8", path.display()))] + NonUtf8FileName { path: PathBuf }, + #[snafu(display("failed to create access log parent directory `{}`", path.display()))] + CreateParent { + path: PathBuf, + source: std::io::Error, + }, + #[snafu(display("failed to open access log output `{}`", path.display()))] + Open { + path: PathBuf, + source: tracing_appender::rolling::InitError, + }, +} + +/// One process-owned destination for fully formatted access records. +#[derive(Debug)] +pub struct AccessLogOutput { + path: ResolvedConfigPath, + writer: AccessLogWriter, +} + +impl AccessLogOutput { + pub fn open(path: ResolvedConfigPath) -> Result { + let writer = AccessLogWriter::new(path.as_ref())?; + Ok(Self { path, writer }) + } + + pub fn path(&self) -> &ResolvedConfigPath { + &self.path + } + + /// Formatting and delivery are deliberately best-effort after the output + /// has been acquired. A request response must not depend on log delivery. + pub fn write(&self, record: &AccessLogRecord) { + let formatted = match DefaultAccessFormatter::format(record) { + Ok(formatted) => formatted, + Err(error) => { + tracing::warn!( + path = %self.path.as_ref().display(), + error = %Report::from_error(&error), + "failed to format access log record" + ); + return; + } + }; + + let mut writer = self.writer.clone(); + if let Err(error) = writer.write_all(formatted.as_bytes()) { + tracing::warn!( + path = %self.path.as_ref().display(), + error = %Report::from_error(&error), + "failed to write access log record" + ); + } + } +} + +/// Cloneable non-blocking writer for one exact file path. #[derive(Clone, Debug)] pub struct AccessLogWriter { inner: NonBlocking, - /// Prevent the background thread from shutting down while any clone exists. _guard: Arc, } impl AccessLogWriter { - /// Create a new writer that appends to `{log_dir}/access.log`. - /// - /// The directory is created if it does not exist. - pub fn new(log_dir: PathBuf) -> std::io::Result { - std::fs::create_dir_all(&log_dir)?; - - let appender = - RollingFileAppender::new(Rotation::NEVER, log_dir, IdentityProfile::ACCESS_LOG_FILE); - let (non_blocking, guard) = tracing_appender::non_blocking(appender); + pub fn new(path: &Path) -> Result { + let parent = path.parent().unwrap_or_else(|| Path::new(".")); + let file_name = + path.file_name() + .ok_or_else(|| OpenAccessLogOutputError::MissingFileName { + path: path.to_path_buf(), + })?; + let file_name = + file_name + .to_str() + .ok_or_else(|| OpenAccessLogOutputError::NonUtf8FileName { + path: path.to_path_buf(), + })?; + std::fs::create_dir_all(parent) + .context(open_access_log_output_error::CreateParentSnafu { path: parent })?; + let appender = RollingFileAppender::builder() + .rotation(Rotation::NEVER) + .filename_prefix(file_name) + .build(parent) + .context(open_access_log_output_error::OpenSnafu { path })?; + let (inner, guard) = tracing_appender::non_blocking(appender); Ok(Self { - inner: non_blocking, + inner, _guard: Arc::new(guard), }) } - - /// Create a writer that discards all output. - /// - /// Used as a fallback when the log directory cannot be created. - pub fn disabled() -> Self { - let (non_blocking, guard) = tracing_appender::non_blocking(std::io::sink()); - Self { - inner: non_blocking, - _guard: Arc::new(guard), - } - } } impl Write for AccessLogWriter { - fn write(&mut self, buf: &[u8]) -> std::io::Result { - self.inner.write(buf) + fn write(&mut self, bytes: &[u8]) -> std::io::Result { + self.inner.write(bytes) } fn flush(&mut self) -> std::io::Result<()> { self.inner.flush() } - /// Write the entire buffer in a single call. - /// - /// A single `write_all` is required because [`NonBlocking`] sends each - /// call as an independent message; splitting across two calls may - /// interleave with other writers. - fn write_all(&mut self, buf: &[u8]) -> std::io::Result<()> { - self.inner.write_all(buf) + fn write_all(&mut self, bytes: &[u8]) -> std::io::Result<()> { + self.inner.write_all(bytes) + } +} + +#[cfg(test)] +mod tests { + use std::{net::Ipv4Addr, time::Duration}; + + use chrono::Local; + use dhttp::log::access::{ + AccessRequestTarget, BodyBytesEmitted, ClientAddress, OptionalReferer, OptionalUserAgent, + }; + + use super::*; + + struct TempDir(PathBuf); + + impl TempDir { + fn new() -> Self { + let path = std::env::temp_dir().join(format!( + "gateway-access-output-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&path).unwrap(); + Self(path) + } + } + + impl Drop for TempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + fn sample_record() -> AccessLogRecord { + AccessLogRecord { + completed_at: Local::now().fixed_offset(), + client: ClientAddress::Ip(Ipv4Addr::LOCALHOST.into()), + method: http::Method::GET, + target: "/health".parse::().unwrap(), + version: http::Version::HTTP_3, + status: http::StatusCode::OK, + body_bytes: BodyBytesEmitted::from(2), + referer: OptionalReferer::default(), + user_agent: OptionalUserAgent::default(), + } + } + + #[test] + fn writer_opens_the_exact_resolved_file() { + let temp = TempDir::new(); + let path = temp.0.join("nested/custom.log"); + let resolved = ResolvedConfigPath::try_from(path.clone()).unwrap(); + let output = AccessLogOutput::open(resolved).unwrap(); + output.write(&sample_record()); + drop(output); + + for _ in 0..100 { + if path.exists() { + break; + } + std::thread::sleep(Duration::from_millis(5)); + } + assert!(path.exists()); + assert!(!temp.0.join("nested/access.log").exists()); } } diff --git a/gateway/src/reverse/proxy.rs b/gateway/src/reverse/proxy.rs index cae172e3..39dc73f8 100644 --- a/gateway/src/reverse/proxy.rs +++ b/gateway/src/reverse/proxy.rs @@ -61,11 +61,11 @@ async fn proxy_inner( // add custom response headers command::add_header(location, &mut parts); - if let Ok(Some(proxy_pass)) = location.get::("proxy_pass") - && let Some(public_base) = public_base_for_proxy_redirect(loc, &proxy_pass) + if let Some(proxy_pass) = location.proxy_pass() + && let Some(public_base) = public_base_for_proxy_redirect(loc, proxy_pass) && let Some(public_origin) = public_origin.as_deref() { - rewrite_proxy_redirect_default(&proxy_pass, public_origin, public_base, &mut parts.headers); + rewrite_proxy_redirect_default(proxy_pass, public_origin, public_base, &mut parts.headers); } let resp = http::Response::from_parts(parts, body); @@ -86,15 +86,15 @@ fn pass( async move { let (parts, body) = req.into_parts(); let proxy_pass = location - .require::("proxy_pass") - .whatever_context::<_, Whatever>("failed to read proxy_pass directive")?; + .proxy_pass() + .expect("proxy handler requires proxy_pass"); tracing::debug!(proxy_pass = %proxy_pass.raw, "resolved proxy_pass target"); let normalized = super::request_uri::normalize_request_uri(&parts.uri) .whatever_context::<_, Whatever>("failed to normalize request uri")?; let target = - super::request_uri::build_upstream_request_target(&proxy_pass, &loc, &normalized) + super::request_uri::build_upstream_request_target(proxy_pass, &loc, &normalized) .whatever_context::<_, Whatever>("failed to build proxy upstream request target")?; tracing::info!(target, "proxying request to upstream path and query"); diff --git a/gateway/src/reverse/request_uri.rs b/gateway/src/reverse/request_uri.rs index 8a097309..32ce748e 100644 --- a/gateway/src/reverse/request_uri.rs +++ b/gateway/src/reverse/request_uri.rs @@ -177,22 +177,22 @@ fn prepend_public_origin(public_origin: Option<&str>, target: String) -> String mod tests { use super::*; use crate::{ - parse::{ - pattern::Pattern, - registry::context, - source::{SourceId, SourceSpan}, - types::ProxyPass, - value::TypedValue, - }, + parse::{pattern::Pattern, tests::parse_location_pattern, types::ProxyPass}, reverse::location::LocationMatch, }; fn make_location_match(pattern: Pattern, matched: &str, remaining: &str) -> LocationMatch { - let span = SourceSpan::new(SourceId(0), 0, 0); - let mut node = crate::parse::document::ConfigNode::new(context::LOCATION, None, span); - node.set_payload(TypedValue::new(pattern, span)); + let syntax = match pattern { + Pattern::Exact(value) => format!("= {value}"), + Pattern::Prefix(value) => format!("^~ {value}"), + Pattern::NormalPrefix(value) => value, + Pattern::Regex(value) => format!("~ '{}'", value.as_str()), + Pattern::CRegex(value) => format!("~* '{}'", value.as_str()), + Pattern::Common => "/".to_owned(), + }; LocationMatch { - location: std::sync::Arc::new(node), + location: parse_location_pattern(&syntax, "").unwrap(), + access_log: crate::reverse::access_log::ActiveAccessLog::Disabled, matched: matched.to_owned(), remaining: remaining.to_owned(), } diff --git a/gateway/src/reverse/router.rs b/gateway/src/reverse/router.rs index 5211db61..2708f793 100644 --- a/gateway/src/reverse/router.rs +++ b/gateway/src/reverse/router.rs @@ -10,15 +10,10 @@ use http::{StatusCode, header}; #[cfg(feature = "sshd")] use tokio_util::sync::CancellationToken; -use super::location::match_location; -#[cfg(feature = "sshd")] -use crate::parse::types::SshLoginMethods; -use crate::parse::{ - document::ConfigNode, - pattern::Pattern, - types::{PathConfig, ProxyPass}, +use super::{ + access_log::ActiveAccessLog, + location::{ConfiguredLocation, match_location}, }; - #[cfg(feature = "sshd")] pub trait DynTaskScope: Send + Sync { fn token(&self) -> CancellationToken; @@ -47,13 +42,22 @@ pub struct RouterState { /// handler (proxy, file, or sshd). #[derive(Clone)] pub struct NginxRouter { - locations: Vec>, + locations: Vec>, + server_access_log: ActiveAccessLog, state: RouterState, } impl NginxRouter { - pub fn new(locations: Vec>, state: RouterState) -> Self { - Self { locations, state } + pub fn new( + locations: Vec>, + server_access_log: ActiveAccessLog, + state: RouterState, + ) -> Self { + Self { + locations, + server_access_log, + state, + } } } @@ -68,6 +72,7 @@ impl tower_service::Service> for NginxRouter { fn call(&mut self, mut request: http::Request) -> Self::Future { let locations = self.locations.clone(); + let server_access_log = self.server_access_log.clone(); let state = self.state.clone(); Box::pin(async move { @@ -75,104 +80,208 @@ impl tower_service::Service> for NginxRouter { .expect("request uri should always normalize"); let public_origin = super::request_uri::request_public_origin(&request); - if !has_exact_match(&locations, &normalized.path) - && let Some(target) = find_proxy_prefix_slash_redirect( - &locations, - &normalized, - public_origin.as_deref(), - ) - { - return Ok( - (StatusCode::MOVED_PERMANENTLY, [(header::LOCATION, target)]).into_response(), - ); - } - let loc_match = match match_location(&locations, &normalized.path) { Some(m) => m, - None => return Ok(StatusCode::NOT_FOUND.into_response()), + None => { + let mut response = StatusCode::NOT_FOUND.into_response(); + response.extensions_mut().insert(server_access_log); + return Ok(response); + } }; + if let Some((target, redirect_access_log)) = proxy_prefix_slash_redirect( + &locations, + &loc_match, + &normalized, + public_origin.as_deref(), + ) { + let mut response = + (StatusCode::MOVED_PERMANENTLY, [(header::LOCATION, target)]).into_response(); + response.extensions_mut().insert(redirect_access_log); + return Ok(response); + } + + let active_access_log = loc_match.access_log.clone(); // Inject LocationMatch into request extensions for extractors request.extensions_mut().insert(loc_match.clone()); let location = &loc_match.location; - let response = if location - .get::("proxy_pass") - .ok() - .flatten() - .is_some() - { + let response = if location.proxy_pass().is_some() { Handler::call(super::proxy::proxy_handle, request, state).await - } else if location.get::("root").ok().flatten().is_some() - || location.get::("alias").ok().flatten().is_some() - { + } else if location.root().is_some() || location.alias().is_some() { Handler::call(super::file::file_handle, request, state).await } else { #[cfg(feature = "sshd")] - if location - .get::("ssh_login") - .ok() - .flatten() - .is_some() - { - return Ok(Handler::call(super::sshd::sshd_handle, request, state).await); + if location.ssh_login().is_some() { + let mut response = + Handler::call(super::sshd::sshd_handle, request, state).await; + response.extensions_mut().insert(active_access_log); + return Ok(response); } StatusCode::NOT_FOUND.into_response() }; + let mut response = response; + response.extensions_mut().insert(active_access_log); Ok(response) }) } } -fn has_exact_match(locations: &[Arc], path: &str) -> bool { - locations.iter().any(|location| { - location.payload::().ok().flatten().is_some_and( - |pattern| matches!(pattern.as_ref(), Pattern::Exact(expected) if expected == path), - ) - }) -} - -fn find_proxy_prefix_slash_redirect( - locations: &[Arc], +fn proxy_prefix_slash_redirect( + locations: &[Arc], + selected: &super::location::LocationMatch, normalized: &super::request_uri::NormalizedRequestUri, public_origin: Option<&str>, -) -> Option { - let mut best: Option<(usize, String)> = None; - - for location in locations { - let has_proxy = location - .get::("proxy_pass") - .ok() - .flatten() - .is_some(); - if !has_proxy { - continue; +) -> Option<(String, ActiveAccessLog)> { + match selected.pattern() { + crate::parse::pattern::Pattern::Exact(_) + | crate::parse::pattern::Pattern::Regex(_) + | crate::parse::pattern::Pattern::CRegex(_) => return None, + _ => {} + } + + locations + .iter() + .filter_map(|location| { + location.proxy_pass()?; + let prefix = match location.matcher() { + crate::parse::pattern::Pattern::Prefix(prefix) + | crate::parse::pattern::Pattern::NormalPrefix(prefix) => prefix, + _ => return None, + }; + let trimmed = prefix.strip_suffix('/')?; + if trimmed.len() <= selected.matched.len() { + return None; + } + let target = + super::request_uri::build_prefix_slash_redirect(prefix, normalized, public_origin)?; + Some((prefix.len(), target, location.access_log().clone())) + }) + .max_by_key(|(prefix_len, _, _)| *prefix_len) + .map(|(_, target, access_log)| (target, access_log)) +} + +#[cfg(test)] +mod tests { + use tower::ServiceExt; + + use super::*; + use crate::{ + parse::{ + domain::ResolvedConfigPath, + tests::{parse_location, parse_location_pattern}, + }, + reverse::{location::ConfiguredLocation, log::AccessLogOutput}, + }; + + fn router_state() -> RouterState { + RouterState { + #[cfg(feature = "sshd")] + session_spawner: Arc::new(DummySpawner), + #[cfg(feature = "sshd")] + task_scope: Arc::new(DummyScope), + } + } + + #[cfg(feature = "sshd")] + struct DummySpawner; + + #[cfg(feature = "sshd")] + impl crate::control_plane::DynSpawnSession for DummySpawner { + fn spawn_session<'a>( + &'a self, + _username: &'a str, + ) -> BoxFuture< + 'a, + Result< + crate::control_plane::SessionTransport, + Box, + >, + > { + Box::pin(std::future::pending()) } + } - let Some(pattern) = location.payload::().ok().flatten() else { - continue; - }; + #[cfg(feature = "sshd")] + struct DummyScope; - let prefix = match pattern.as_ref() { - Pattern::Prefix(prefix) | Pattern::NormalPrefix(prefix) => prefix, - _ => continue, - }; + #[cfg(feature = "sshd")] + impl DynTaskScope for DummyScope { + fn token(&self) -> CancellationToken { + CancellationToken::new() + } + + fn spawn(&self, _task: BoxFuture<'static, ()>) {} + } + + #[tokio::test] + async fn selected_location_hands_its_output_to_the_response() { + let path = std::env::temp_dir().join(format!( + "gateway-route-access-{}-{}.log", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let output = Arc::new( + AccessLogOutput::open(ResolvedConfigPath::try_from(path.clone()).unwrap()).unwrap(), + ); + let location = Arc::new(ConfiguredLocation::new( + parse_location("").unwrap(), + ActiveAccessLog::Enabled(output.clone()), + )); + let router = NginxRouter::new(vec![location], ActiveAccessLog::Disabled, router_state()); + + let response = router + .oneshot( + http::Request::builder() + .uri("/anything") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); - let Some(target) = - super::request_uri::build_prefix_slash_redirect(prefix, normalized, public_origin) + let Some(ActiveAccessLog::Enabled(selected)) = + response.extensions().get::() else { - continue; + panic!("selected route did not attach its access output"); }; - - if best - .as_ref() - .is_none_or(|(current_len, _)| prefix.len() > *current_len) - { - best = Some((prefix.len(), target)); - } + assert!(Arc::ptr_eq(selected, &output)); + drop(response); + drop(output); + let _ = std::fs::remove_file(path); } - best.map(|(_, target)| target) + #[tokio::test] + async fn proxy_prefix_redirect_is_checked_before_the_common_fallback() { + let api = Arc::new(ConfiguredLocation::new( + parse_location_pattern("/api/", "proxy_pass http://backend/;").unwrap(), + ActiveAccessLog::Disabled, + )); + let common = Arc::new(ConfiguredLocation::new( + parse_location("").unwrap(), + ActiveAccessLog::Disabled, + )); + let router = NginxRouter::new(vec![api, common], ActiveAccessLog::Disabled, router_state()); + + let response = router + .oneshot( + http::Request::builder() + .uri("https://frontend.example.com/api?x=1") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::MOVED_PERMANENTLY); + assert_eq!( + response.headers().get(header::LOCATION).unwrap(), + "https://frontend.example.com/api/?x=1" + ); + } } diff --git a/gateway/src/reverse/sshd.rs b/gateway/src/reverse/sshd.rs index e3726f71..595b5d25 100644 --- a/gateway/src/reverse/sshd.rs +++ b/gateway/src/reverse/sshd.rs @@ -18,7 +18,6 @@ use tracing::Instrument; use crate::{ control_plane::DynSpawnSession, - parse::types::StringList, reverse::{location::LocationMatch, router::RouterState}, }; @@ -109,9 +108,7 @@ pub async fn sshd_handle( let ssh_deny = loc .location - .get::("ssh_deny") - .ok() - .flatten() + .ssh_deny() .map(|value| value.0.clone()) .unwrap_or_default(); diff --git a/gateway/src/reverse/upstream_tls.rs b/gateway/src/reverse/upstream_tls.rs index d02fff0c..2b9ced31 100644 --- a/gateway/src/reverse/upstream_tls.rs +++ b/gateway/src/reverse/upstream_tls.rs @@ -15,10 +15,7 @@ use tokio::net::TcpStream; use tokio_rustls::{TlsConnector, client::TlsStream}; use tracing::debug; -use crate::{ - error::Whatever, - parse::{document::ConfigNode, types::PathConfig}, -}; +use crate::{error::Whatever, parse::config::LocationConfig}; type Result = std::result::Result; @@ -31,18 +28,25 @@ struct UpstreamTlsSettings { impl UpstreamTlsSettings { /// 从 location 配置节点中提取上游 TLS 所需的证书路径。 - fn from_location(location: &ConfigNode) -> Self { + fn from_location(location: &LocationConfig) -> Self { + let tls = location.proxy_tls(); Self { - client_cert: path_from_config(location, "proxy_ssl_certificate"), - client_key: path_from_config(location, "proxy_ssl_certificate_key"), - trusted_ca: path_from_config(location, "proxy_ssl_trusted_certificate"), + client_cert: tls + .and_then(|tls| tls.certificate()) + .map(|path| path.as_ref().to_path_buf()), + client_key: tls + .and_then(|tls| tls.private_key()) + .map(|path| path.as_ref().to_path_buf()), + trusted_ca: tls + .and_then(|tls| tls.trusted_certificate()) + .map(|p| p.as_ref().to_path_buf()), } } } /// 连接 HTTPS 上游,并在 TCP 连接之上完成 TLS 握手。 pub(super) async fn connect_https( - location: &ConfigNode, + location: &LocationConfig, proxy_pass: &Uri, ) -> Result> { // 先从 proxy_pass 中解析服务端主机名和端口,作为 TCP 连接与 SNI 的输入。 @@ -74,7 +78,7 @@ pub(super) async fn connect_https( } /// 构建上游 TLS 客户端配置,支持自定义 CA 和可选的双向 TLS 客户端证书。 -fn build_client_config(location: &ConfigNode) -> Result> { +fn build_client_config(location: &LocationConfig) -> Result> { let settings = UpstreamTlsSettings::from_location(location); // 客户端证书和私钥必须成对出现,否则无法完成双向 TLS 配置。 @@ -185,24 +189,12 @@ fn load_private_key(path: &Path) -> Result> { Ok(private_key) } -fn path_from_config(location: &ConfigNode, name: &str) -> Option { - location - .get::(name) - .ok() - .flatten() - .map(|path| path.0.clone()) -} - #[cfg(test)] mod tests { use std::{path::PathBuf, sync::Once}; use super::*; - use crate::parse::{ - registry::context, - source::{SourceId, SourceSpan}, - value::TypedValue, - }; + use crate::parse::tests::parse_location; static INSTALL_CRYPTO_PROVIDER: Once = Once::new(); @@ -220,13 +212,13 @@ mod tests { .join(relative) } - fn test_location(values: &[(&'static str, PathBuf)]) -> ConfigNode { - let span = SourceSpan::new(SourceId(0), 0, 0); - let mut location = ConfigNode::new(context::LOCATION, None, span); - for (name, path) in values { - location.insert_slot(name, TypedValue::new(PathConfig(path.clone()), span)); - } - location + fn test_location(values: &[(&'static str, PathBuf)]) -> LocationConfig { + let body = values + .iter() + .map(|(name, path)| format!("{name} {};", path.display())) + .collect::>() + .join(" "); + Arc::try_unwrap(parse_location(&body).unwrap()).expect("unique test location") } #[test] @@ -279,9 +271,9 @@ mod tests { #[test] fn test_build_client_config_rejects_incomplete_identity() { let client_cert = fixture_path("keychain/test.genmeta.net/test.genmeta.net.pem"); - let location = test_location(&[("proxy_ssl_certificate", client_cert)]); - let error = build_client_config(&location).expect_err("incomplete identity should fail"); + let error = parse_location(&format!("proxy_ssl_certificate {};", client_cert.display())) + .expect_err("incomplete identity should fail"); - assert!(error.to_string().contains("must be configured together")); + assert!(error.contains("must be configured together")); } } diff --git a/gateway/src/stun/config.rs b/gateway/src/stun/config.rs index e64f03ea..fa8e8a1d 100644 --- a/gateway/src/stun/config.rs +++ b/gateway/src/stun/config.rs @@ -1,22 +1,8 @@ -use std::{collections::HashSet, net::SocketAddr, sync::Arc}; +use std::{collections::HashSet, net::SocketAddr}; -use crate::parse::{ - document::ConfigNode, - types::{BoolConfig, SocketAddrs, StunBindConfigValue, StunChangePort}, -}; +use crate::parse::config::ServerConfig; /// 本节点的 STUN 运行时配置。 -/// -/// 这不是配置文件 AST 的原样映射,而是把 `server` 块里的以下配置归一化后的结果: -/// - `stun on|off;` -/// - `relay on|off;` -/// - 多个 `stun_server { ... }` -/// -/// 最终行为由内容决定: -/// - 配置了 `stun_server { ... }` → 走 configured 模式,独立绑定主/辅 socket -/// - 只有 `stun on;` → 走 dynamic 模式,等本地 listener 被判定为 `FullCone` 后寄生启动 -/// -/// `relay` 目前只是配置侧保留字段,本文件中的 STUN server 生命周期并不会直接使用它。 #[derive(Debug, Clone)] pub struct StunNodeConfig { /// 是否加入 forward 中转网络(默认 false) @@ -28,83 +14,40 @@ pub struct StunNodeConfig { /// 单个 bind 块的 STUN 配置 #[derive(Debug, Clone)] pub struct StunBindConfig { - /// 绑定地址 pub bind_address: SocketAddr, - /// 对外发布地址 pub outer_address: Option, - /// STUN `ChangedAddress` / `CHANGE_IP` 对应的完整替代地址(包含 IP 和端口) pub change_address: Option, - /// 辅助端口 pub change_port: Option, } impl StunNodeConfig { - /// 从 `server` 配置节点提取 STUN 相关配置。 - /// - /// 两种启用方式满足其一即可: - /// - `stun on;` - /// - 存在至少一个 `stun_server { ... }` - pub fn from_server_node(server: &Arc) -> Option { - let stun_enabled = server - .get::("stun") - .ok() - .flatten() - .map(|value| value.0) - .unwrap_or(false); - - let binds: Vec = server - .children_optional("stun_server") + /// Materializes STUN configuration exclusively from the sealed SERVER-local typed slots. + pub fn from_server(server: &ServerConfig) -> Option { + let stun_enabled = server.stun().is_some_and(|value| value.0); + let compounds = server.stun_servers(); + let binds = compounds .iter() - .flat_map(|node| { - let outer_address = first_addr(node, "outer_addr"); - let change_address = first_addr(node, "change_addr"); - let change_port = node - .get::("change_port") - .ok() - .flatten() - .map(|port| port.0); - - node.get_all::("bind") - .ok() - .into_iter() - .flatten() - .map(move |bind| StunBindConfig { - bind_address: bind.bind, - outer_address: bind.outer_addr.or(outer_address), - change_address: bind.change_addr.or(change_address), - change_port: bind.change_port.or(change_port), - }) + .flat_map(|compound| compound.binds.iter()) + .map(|bind| StunBindConfig { + bind_address: bind.bind, + outer_address: bind.outer_addr, + change_address: bind.change_addr, + change_port: bind.change_port, }) - .collect(); - + .collect::>(); if !stun_enabled && binds.is_empty() { return None; } - let relay = server - .get::("relay") - .ok() - .flatten() - .map(|value| value.0) - .unwrap_or(false); - + let relay = server.relay().is_some_and(|value| value.0); Some(Self { relay, binds }) } - /// 是否配置了 bind 块 pub fn has_configured_binds(&self) -> bool { !self.binds.is_empty() } - /// 收集 configured 模式下需要绑定的所有地址 pub fn configured_addrs(&self) -> HashSet { - self.binds.iter().map(|b| b.bind_address).collect() + self.binds.iter().map(|bind| bind.bind_address).collect() } } - -fn first_addr(node: &ConfigNode, name: &str) -> Option { - node.get::(name) - .ok() - .flatten() - .and_then(|addrs| addrs.0.first().copied()) -} diff --git a/gateway/tests/relative_static_root.rs b/gateway/tests/relative_static_root.rs index 0944dbae..4222413d 100644 --- a/gateway/tests/relative_static_root.rs +++ b/gateway/tests/relative_static_root.rs @@ -64,25 +64,35 @@ async fn router_serves_static_file_from_config_relative_root() { .expect("write index"); std::fs::write( dir.join("server.conf"), - "server {\n listen all 5378;\n server_name example.com;\n ssl_certificate ./fullchain.crt;\n ssl_certificate_key ./privkey.pem;\n location / { root .; index index.html; }\n}\n", + "pishoo { server {\n listen all 5378;\n server_name example.com;\n ssl_certificate ./fullchain.crt;\n ssl_certificate_key ./privkey.pem;\n location / { root .; index index.html; }\n} }\n", ) .expect("write config"); - let registry = parse::default_registry(); - let document = parse::load_config_file( - &dir.join("server.conf"), - ®istry, - parse::registry::BuildOptions::default(), - ) - .await - .expect("config should load"); - - let server = document.root.children("server").expect("server children")[0].clone(); + let text = std::fs::read_to_string(dir.join("server.conf")).unwrap(); + let parsed = parse::TypedConfigParser::new() + .parse_root(&text, &dir.join("server.conf"), None) + .expect("config should load"); + let server = parsed.servers()[0] + .result() + .as_ref() + .expect("server should build"); let locations = server - .children("location") - .expect("location children") - .to_vec(); - let router = NginxRouter::new(locations, dummy_router_state()); + .locations() + .iter() + .cloned() + .map(std::sync::Arc::new) + .map(|location| { + std::sync::Arc::new(gateway::reverse::location::ConfiguredLocation::new( + location, + gateway::reverse::access_log::ActiveAccessLog::Disabled, + )) + }) + .collect(); + let router = NginxRouter::new( + locations, + gateway::reverse::access_log::ActiveAccessLog::Disabled, + dummy_router_state(), + ); let response = router .oneshot( diff --git a/pishoo/Cargo.toml b/pishoo/Cargo.toml index f47a7807..6f2cf3ab 100644 --- a/pishoo/Cargo.toml +++ b/pishoo/Cargo.toml @@ -4,7 +4,7 @@ name = "pishoo" description = "modern, secure, QUIC-powered web/proxy engine" homepage = "https://www.dhttp.net" -version = "0.8.0-beta.4" +version = "0.8.0-beta.5" edition = "2024" license = "Apache-2.0" @@ -87,4 +87,4 @@ sqlx-sqlite = ["sea-orm-migration/sqlx-sqlite"] [dev-dependencies] rcgen = "0.14" -tokio = { workspace = true, features = ["rt-multi-thread", "macros"] } +tokio = { workspace = true, features = ["rt-multi-thread", "macros", "test-util"] } diff --git a/pishoo/src/bin/pishoo_worker.rs b/pishoo/src/bin/pishoo_worker.rs index c78d5254..212fbea4 100644 --- a/pishoo/src/bin/pishoo_worker.rs +++ b/pishoo/src/bin/pishoo_worker.rs @@ -10,13 +10,13 @@ use std::sync::Arc; -use dhttp::{h3x::ipc::transport::MuxChannel, home::DhttpHome}; +use dhttp::h3x::ipc::transport::MuxChannel; use gateway::error::Whatever; use pishoo::{ ipc::{WorkerBootstrap, WorkerHello}, worker::remote_plane::RemoteControlPlane, }; -use snafu::{OptionExt, Report, ResultExt}; +use snafu::{FromString, OptionExt, Report, ResultExt}; use tracing::Instrument; #[tokio::main] @@ -56,6 +56,8 @@ async fn main() -> Result<(), Whatever> { .await .whatever_context("failed to establish remoc transport")?; let worker_tasks = Arc::new(pishoo::hypervisor::task_scope::TaskScope::new()); + let transport_ended = tokio_util::sync::CancellationToken::new(); + let connection_ended = transport_ended.clone(); worker_tasks.spawn(|token| async move { tokio::select! { biased; @@ -67,6 +69,7 @@ async fn main() -> Result<(), Whatever> { "root remoc connection ended" ); } + connection_ended.cancel(); } } }); @@ -79,9 +82,9 @@ async fn main() -> Result<(), Whatever> { .whatever_context("root closed channel without sending bootstrap")?; tracing::debug!( - uid = bootstrap.uid, - username = %bootstrap.username, - home = %bootstrap.home.display(), + uid = bootstrap.account.uid().as_raw(), + username = %bootstrap.account.name(), + home = %bootstrap.account.login_home().display(), "bootstrap received" ); @@ -101,12 +104,14 @@ async fn main() -> Result<(), Whatever> { // Create the RemoteControlPlane from the bootstrap's ControlPlane client. // Pass FdTransfer so worker-side requests can reserve receiver-chosen FD IDs. - let plane = Arc::new(RemoteControlPlane::new( - bootstrap.control_plane, - fd_transfer, - )); - - let dhttp_home = DhttpHome::for_user_home_dir(bootstrap.home.clone()); + let WorkerBootstrap { + account, + root_defaults, + mut root_defaults_rx, + control_plane, + } = bootstrap; + let plane = Arc::new(RemoteControlPlane::new(control_plane, fd_transfer)); + let dhttp_home = account.dhttp_home().clone(); let mut term_signal = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) .whatever_context("failed to create SIGTERM listener")?; @@ -126,9 +131,19 @@ async fn main() -> Result<(), Whatever> { #[cfg(feature = "sshd")] task_scope: worker_tasks.clone(), }; - let mut runtime = - pishoo::service::runtime::WorkerRuntime::new(plane.clone(), dhttp_home, router_state); - runtime.start().await; + let mut runtime = pishoo::service::runtime::WorkerRuntime::new( + plane.clone(), + dhttp_home, + root_defaults, + router_state, + ); + if let Err(error) = runtime.start().await { + runtime.shutdown().await; + return Err(Whatever::with_source( + Box::new(error), + "initial worker configuration failed".to_owned(), + )); + } loop { tokio::select! { @@ -146,9 +161,46 @@ async fn main() -> Result<(), Whatever> { } _ = hup_signal.recv() => { tracing::info!(signal = "SIGHUP", "received reload signal"); - runtime.reload().await; + if let Err(error) = runtime.reload().await { + tracing::error!(error = %snafu::Report::from_error(&error), "worker reload failed; shutting down"); + runtime.shutdown().await; + return Err(Whatever::with_source( + Box::new(error), + "worker reload failed".to_owned(), + )); + } tracing::info!(signal = "SIGHUP", "reload complete"); } + changed = root_defaults_rx.changed() => { + if let Err(error) = changed { + tracing::error!(error = %error, "root defaults channel closed; shutting down worker"); + runtime.shutdown().await; + worker_tasks.cancel_and_wait().await; + return Err(Whatever::with_source(Box::new(error), "root defaults channel closed".to_owned())); + } + let defaults = root_defaults_rx + .borrow_and_update() + .whatever_context("failed to receive root defaults snapshot")? + .clone(); + tracing::info!("received root defaults reload"); + if let Err(error) = runtime.reload_with_root_defaults(defaults).await { + tracing::error!(error = %snafu::Report::from_error(&error), "root defaults reload failed; shutting down"); + runtime.shutdown().await; + worker_tasks.cancel_and_wait().await; + return Err(Whatever::with_source(Box::new(error), "root defaults reload failed".to_owned())); + } + tracing::info!(source = "root_defaults", "reload complete"); + } + _ = transport_ended.cancelled() => { + tracing::error!("root control-plane transport ended; shutting down worker"); + runtime.shutdown().await; + worker_tasks.cancel_and_wait().await; + return Err(Whatever::without_source("root control-plane transport ended".to_owned())); + } + name = runtime.wait_service_completion() => { + runtime.handle_service_exit(name).await; + tracing::warn!("worker server service exited; released its resources"); + } _ = usr1_signal.recv() => { tracing::info!(signal = "SIGUSR1", "received reopen signal"); } diff --git a/pishoo/src/config.rs b/pishoo/src/config.rs index b20a6ab1..83beeef2 100644 --- a/pishoo/src/config.rs +++ b/pishoo/src/config.rs @@ -1,100 +1,61 @@ -use std::{path::PathBuf, sync::Arc}; +use std::path::PathBuf; -use gateway::parse::{document::ConfigNode, error::ConfigQueryError, types::PathConfig}; -use snafu::{OptionExt, ResultExt, Snafu}; +use snafu::Snafu; -mod discovery; -pub mod entry; -pub mod root; +mod account; +pub(crate) mod plan; pub mod source; pub mod worker_target; -#[cfg(test)] -mod tests; - -pub use discovery::load_identity_servers; -pub use entry::{EntryConfig, parse_entry_config}; -pub use root::{RootConfig, parse_root_config}; -pub use source::{PishooConfigSource, ResolveConfigSourceError}; -pub use worker_target::{ - ResolvedWorkerTarget, WorkerDiff, WorkerTarget, compute_worker_diff, - resolve_entry_worker_targets, resolve_worker_targets, +pub use account::{WorkerAccount, WorkerDiff, WorkerRoster, compute_worker_diff}; +pub use plan::{ + GlobalPishooPlan, IdentityServerCandidate, WorkerHomePlan, load_global_pishoo_plan, + load_identity_server_candidates, load_worker_home_plan, }; +pub use source::{PishooConfigSource, ResolveConfigSourceError}; +pub use worker_target::{ResolvedWorkerTarget, WorkerTarget, resolve_worker_targets}; #[derive(Debug, Snafu)] pub enum ConfigError { - #[snafu(display("pishoo block not found in configuration"))] - MissingPishoo, - - #[snafu(display("invalid workers directive: expected string list"))] - InvalidWorkers, - - #[snafu(display("invalid pid directive: expected path"))] - InvalidPid, - - #[snafu(display("failed to read typed configuration value"))] - ConfigQuery { source: ConfigQueryError }, - - #[snafu(display("invalid groups directive: expected string list"))] - InvalidGroups, - #[snafu(display("worker username cannot be empty"))] EmptyWorkerName, - #[snafu(display("failed to resolve users in group `{group_name}`"))] GroupResolve { group_name: String, source: nix::Error, }, - #[snafu(display("group `{group_name}` not found"))] GroupNotFound { group_name: String }, - #[snafu(display("failed to enumerate users with primary group `{group_name}`"))] PrimaryGroupUserResolve { group_name: String, source: nix::errno::Errno, }, - #[snafu(display("failed to resolve macOS membership uuid for uid {uid}"))] MacosUserUuid { uid: u32, source: nix::errno::Errno }, - #[snafu(display("failed to resolve macOS membership uuid for gid {gid}"))] MacosGroupUuid { gid: u32, source: nix::errno::Errno }, - #[snafu(display("failed to check macOS group membership for uid {uid} in gid {gid}"))] MacosMembershipCheck { uid: u32, gid: u32, source: nix::errno::Errno, }, - #[snafu(display("failed to resolve user `{username}` via system passwd database"))] UserNotFound { username: String }, - #[snafu(display("failed to resolve user `{username}`"))] UserResolve { username: String, source: nix::Error, }, - #[snafu(display("resolved user `{username}` has no home directory"))] MissingHome { username: String }, } pub const PID_FILE_DEFAULT: &str = "/var/run/pishoo.pid"; - -fn first_pishoo_node(root: &Arc) -> Result, ConfigError> { - root.children("pishoo") - .ok() - .and_then(|nodes| nodes.first().cloned()) - .context(MissingPishooSnafu) -} - -fn parse_pid_file(pishoo: &Arc) -> Result { - Ok(pishoo - .get::("pid") - .context(ConfigQuerySnafu)? - .map(|pid_file| pid_file.0.clone()) - .unwrap_or_else(|| PathBuf::from(PID_FILE_DEFAULT))) +pub fn pid_path(config: &gateway::parse::config::PishooConfig) -> PathBuf { + config + .pid() + .map(|path| path.as_ref().to_path_buf()) + .unwrap_or_else(|| PID_FILE_DEFAULT.into()) } diff --git a/pishoo/src/config/account.rs b/pishoo/src/config/account.rs new file mode 100644 index 00000000..3745533b --- /dev/null +++ b/pishoo/src/config/account.rs @@ -0,0 +1,375 @@ +use std::{ + collections::{BTreeMap, HashMap}, + path::PathBuf, +}; + +use dhttp::home::DhttpHome; +use nix::unistd::{Gid, Uid}; +use serde::{Deserialize, Deserializer, Serialize, Serializer, de::Error as _}; +use snafu::{Snafu, ensure}; + +use super::ResolvedWorkerTarget; + +#[derive(Debug, Snafu)] +#[snafu(module)] +pub enum WorkerAccountError { + #[snafu(display("worker account name cannot be empty"))] + EmptyName, + #[snafu(display("worker login home must be absolute: {}", path.display()))] + RelativeLoginHome { path: PathBuf }, + #[snafu(display("worker dhttp home must be absolute: {}", path.display()))] + RelativeDhttpHome { path: PathBuf }, +} + +#[derive(Clone, Debug)] +pub struct WorkerAccount { + name: String, + uid: Uid, + primary_gid: Gid, + login_home: PathBuf, + dhttp_home: DhttpHome, +} + +impl WorkerAccount { + pub(crate) fn new( + name: String, + uid: Uid, + primary_gid: Gid, + login_home: PathBuf, + dhttp_home: DhttpHome, + ) -> Result { + ensure!(!name.is_empty(), worker_account_error::EmptyNameSnafu); + ensure!( + login_home.is_absolute(), + worker_account_error::RelativeLoginHomeSnafu { path: login_home } + ); + ensure!( + dhttp_home.as_path().is_absolute(), + worker_account_error::RelativeDhttpHomeSnafu { + path: dhttp_home.as_path().to_path_buf() + } + ); + Ok(Self { + name, + uid, + primary_gid, + login_home, + dhttp_home, + }) + } + + pub(crate) fn from_target( + target: &ResolvedWorkerTarget, + dhttp_home: DhttpHome, + ) -> Result { + Self::new( + target.name.clone(), + target.uid, + target.gid, + target.dir.clone(), + dhttp_home, + ) + } + + pub fn name(&self) -> &str { + &self.name + } + + pub const fn uid(&self) -> Uid { + self.uid + } + + pub const fn primary_gid(&self) -> Gid { + self.primary_gid + } + + pub fn login_home(&self) -> &std::path::Path { + &self.login_home + } + + pub fn dhttp_home(&self) -> &DhttpHome { + &self.dhttp_home + } +} + +impl PartialEq for WorkerAccount { + fn eq(&self, other: &Self) -> bool { + self.name == other.name + && self.uid == other.uid + && self.primary_gid == other.primary_gid + && self.login_home == other.login_home + && self.dhttp_home.as_path() == other.dhttp_home.as_path() + } +} + +impl Eq for WorkerAccount {} + +#[derive(Serialize, Deserialize)] +struct WorkerAccountWire { + name: String, + uid: u32, + primary_gid: u32, + login_home: PathBuf, + dhttp_home: PathBuf, +} + +impl Serialize for WorkerAccount { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + WorkerAccountWire { + name: self.name.clone(), + uid: self.uid.as_raw(), + primary_gid: self.primary_gid.as_raw(), + login_home: self.login_home.clone(), + dhttp_home: self.dhttp_home.as_path().to_path_buf(), + } + .serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for WorkerAccount { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let wire = WorkerAccountWire::deserialize(deserializer)?; + Self::new( + wire.name, + Uid::from_raw(wire.uid), + Gid::from_raw(wire.primary_gid), + wire.login_home, + DhttpHome::new(wire.dhttp_home), + ) + .map_err(D::Error::custom) + } +} + +pub(crate) fn select_worker_dhttp_home(target: &ResolvedWorkerTarget) -> DhttpHome { + DhttpHome::for_user_home_dir(target.dir.clone()) +} + +#[derive(Debug, Snafu)] +pub enum BuildWorkerRosterError { + #[snafu(display("duplicate worker uid {uid}"))] + DuplicateUid { uid: Uid }, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct WorkerRoster(BTreeMap); + +impl WorkerRoster { + pub(crate) fn new( + accounts: impl IntoIterator, + ) -> Result { + let mut roster = BTreeMap::new(); + for account in accounts { + let uid = account.uid().as_raw(); + if roster.insert(uid, account).is_some() { + return Err(BuildWorkerRosterError::DuplicateUid { + uid: Uid::from_raw(uid), + }); + } + } + Ok(Self(roster)) + } + + pub fn len(&self) -> usize { + self.0.len() + } + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } + pub fn to_vec(&self) -> Vec { + self.0.values().cloned().collect() + } +} + +#[derive(Debug)] +pub struct WorkerDiff { + pub unchanged: Vec, + pub added: Vec, + pub removed: Vec, + pub changed: Vec<(WorkerAccount, WorkerAccount)>, +} +pub fn compute_worker_diff(current: &[WorkerAccount], next: &[WorkerAccount]) -> WorkerDiff { + let current_map: HashMap<&str, &WorkerAccount> = + current.iter().map(|v| (v.name(), v)).collect(); + let next_map: HashMap<&str, &WorkerAccount> = next.iter().map(|v| (v.name(), v)).collect(); + let mut unchanged = Vec::new(); + let mut added = Vec::new(); + let mut changed = Vec::new(); + let mut removed = Vec::new(); + for n in next { + match current_map.get(n.name()) { + Some(c) if c.uid() == n.uid() => unchanged.push(n.clone()), + Some(c) => changed.push(((*c).clone(), n.clone())), + None => added.push(n.clone()), + } + } + for c in current { + if !next_map.contains_key(c.name()) { + removed.push(c.clone()) + } + } + WorkerDiff { + unchanged, + added, + removed, + changed, + } +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use dhttp::home::DhttpHome; + use nix::unistd::{Gid, Uid, User}; + + use super::{ + BuildWorkerRosterError, WorkerAccount, WorkerAccountError, WorkerRoster, + select_worker_dhttp_home, + }; + + fn account() -> WorkerAccount { + WorkerAccount::new( + "alice".to_owned(), + Uid::from_raw(1000), + Gid::from_raw(100), + PathBuf::from("/home/alice"), + DhttpHome::new(PathBuf::from("/srv/dhttp/alice")), + ) + .unwrap() + } + + #[test] + fn worker_account_rejects_empty_name() { + let error = WorkerAccount::new( + String::new(), + Uid::from_raw(1), + Gid::from_raw(2), + PathBuf::from("/home/alice"), + DhttpHome::new(PathBuf::from("/srv/dhttp/alice")), + ) + .unwrap_err(); + assert!(matches!(error, WorkerAccountError::EmptyName)); + } + + #[test] + fn worker_account_rejects_relative_dhttp_home() { + let error = WorkerAccount::new( + "alice".to_owned(), + Uid::from_raw(1), + Gid::from_raw(2), + PathBuf::from("/home/alice"), + DhttpHome::new(PathBuf::from("relative/.dhttp")), + ) + .unwrap_err(); + assert!(matches!( + error, + WorkerAccountError::RelativeDhttpHome { .. } + )); + } + + #[test] + fn worker_account_rejects_relative_login_home() { + let error = WorkerAccount::new( + "alice".to_owned(), + Uid::from_raw(1), + Gid::from_raw(2), + PathBuf::from("relative"), + DhttpHome::new(PathBuf::from("/srv/dhttp/alice")), + ) + .unwrap_err(); + assert!(matches!( + error, + WorkerAccountError::RelativeLoginHome { .. } + )); + } + + #[test] + fn worker_account_keeps_login_home_distinct_from_dhttp_home() { + let account = account(); + assert_eq!(account.login_home(), std::path::Path::new("/home/alice")); + assert_eq!( + account.dhttp_home().as_path(), + std::path::Path::new("/srv/dhttp/alice") + ); + } + + #[test] + fn worker_account_full_equality_includes_gid_and_both_homes() { + let original = account(); + let different_gid = WorkerAccount::new( + original.name().to_owned(), + original.uid(), + Gid::from_raw(101), + original.login_home().to_path_buf(), + original.dhttp_home().clone(), + ) + .unwrap(); + let different_login = WorkerAccount::new( + original.name().to_owned(), + original.uid(), + original.primary_gid(), + PathBuf::from("/different/login"), + original.dhttp_home().clone(), + ) + .unwrap(); + let different_dhttp = WorkerAccount::new( + original.name().to_owned(), + original.uid(), + original.primary_gid(), + original.login_home().to_path_buf(), + DhttpHome::new(PathBuf::from("/different/dhttp")), + ) + .unwrap(); + assert_ne!(original, different_gid); + assert_ne!(original, different_login); + assert_ne!(original, different_dhttp); + assert_eq!(original, original.clone()); + } + + #[test] + fn worker_roster_rejects_duplicate_uid() { + let original = account(); + let duplicate = WorkerAccount::new( + "bob".to_owned(), + original.uid(), + Gid::from_raw(999), + PathBuf::from("/home/bob"), + DhttpHome::new(PathBuf::from("/home/bob/.dhttp")), + ) + .unwrap(); + let error = WorkerRoster::new([original, duplicate]).unwrap_err(); + assert!(matches!(error, BuildWorkerRosterError::DuplicateUid { .. })); + } + + #[test] + fn worker_wire_preserves_root_selected_dhttp_home_distinct_from_login_home() { + let original = account(); + let encoded = serde_json::to_vec(&original).unwrap(); + let decoded: WorkerAccount = serde_json::from_slice(&encoded).unwrap(); + assert_eq!(decoded, original); + assert_ne!(decoded.login_home(), decoded.dhttp_home().as_path()); + } + + #[test] + fn default_worker_home_selection_uses_the_login_home_domain_entry_point() { + let target = User { + name: "alice".to_owned(), + passwd: std::ffi::CString::new("x").unwrap(), + uid: Uid::from_raw(1000), + gid: Gid::from_raw(100), + gecos: std::ffi::CString::new("").unwrap(), + dir: PathBuf::from("/home/alice"), + shell: PathBuf::from("/bin/sh"), + }; + assert_eq!( + select_worker_dhttp_home(&target).as_path(), + std::path::Path::new("/home/alice/.dhttp") + ); + } +} diff --git a/pishoo/src/config/discovery.rs b/pishoo/src/config/discovery.rs deleted file mode 100644 index b88e9ce8..00000000 --- a/pishoo/src/config/discovery.rs +++ /dev/null @@ -1,23 +0,0 @@ -use std::sync::Arc; - -use dhttp::home::{DhttpHome, identity::IdentityProfile}; -use gateway::parse::{document::ConfigNode, error::ConfigLoadFailure}; - -pub async fn load_identity_servers( - home: &DhttpHome, - identity_profile: &IdentityProfile, -) -> Result>, ConfigLoadFailure> { - let conf_path = identity_profile.server_conf_path(); - let registry = gateway::parse::default_registry(); - let parsed = gateway::parse::load_config_file( - &conf_path, - ®istry, - gateway::parse::registry::BuildOptions { - dhttp_home: Some(home), - identity_profile: Some(identity_profile), - }, - ) - .await?; - - Ok(parsed.root.children_optional("server").to_vec()) -} diff --git a/pishoo/src/config/entry.rs b/pishoo/src/config/entry.rs deleted file mode 100644 index 875c271a..00000000 --- a/pishoo/src/config/entry.rs +++ /dev/null @@ -1,59 +0,0 @@ -use std::{path::PathBuf, sync::Arc}; - -use gateway::parse::document::ConfigNode; - -use super::{ - ConfigError, first_pishoo_node, parse_pid_file, - worker_target::{ - AccountDirectory, SystemAccountDirectory, WorkerDiscoveryMode, WorkerTarget, - resolve_all_workers_with_directory, - }, -}; - -#[derive(Debug, Clone)] -pub struct EntryConfig { - pub pid_file: PathBuf, - pub workers: Vec, - pub config_services: Vec>, -} - -fn parse_config_services(pishoo: &Arc) -> Vec> { - pishoo.children_optional("server").to_vec() -} - -pub fn parse_entry_config(root: &Arc) -> Result { - parse_entry_config_with_mode(root, WorkerDiscoveryMode::ExplicitConfig) -} - -pub fn parse_entry_config_with_mode( - root: &Arc, - mode: WorkerDiscoveryMode, -) -> Result { - let directory = SystemAccountDirectory; - parse_entry_config_with_directory_and_mode(root, &directory, mode) -} - -#[cfg(test)] -pub(super) fn parse_entry_config_with_directory( - root: &Arc, - directory: &D, -) -> Result { - parse_entry_config_with_directory_and_mode(root, directory, WorkerDiscoveryMode::ExplicitConfig) -} - -pub(super) fn parse_entry_config_with_directory_and_mode( - root: &Arc, - directory: &D, - mode: WorkerDiscoveryMode, -) -> Result { - let pishoo = first_pishoo_node(root)?; - let pid_file = parse_pid_file(&pishoo)?; - let config_services = parse_config_services(&pishoo); - let workers = resolve_all_workers_with_directory(&pishoo, directory, mode)?; - - Ok(EntryConfig { - pid_file, - workers, - config_services, - }) -} diff --git a/pishoo/src/config/plan.rs b/pishoo/src/config/plan.rs new file mode 100644 index 00000000..1ed74924 --- /dev/null +++ b/pishoo/src/config/plan.rs @@ -0,0 +1,349 @@ +use dhttp::home::{ + DhttpHome, + identity::{IdentityProfile, ssl::IdentityProfileCandidateError}, +}; +use gateway::parse::{ + TypedConfigParser, + build::BuildTypedConfigError, + config::{PishooConfig, RootWorkerDefaultsSnapshot, ServerConfig}, + error::ConfigLoadFailure, +}; +use snafu::{ResultExt, Snafu}; + +use super::{ + ConfigError, PishooConfigSource, + account::{ + BuildWorkerRosterError, WorkerAccount, WorkerAccountError, WorkerRoster, + select_worker_dhttp_home, + }, + worker_target::{WorkerDiscoveryMode, resolve_worker_targets}, +}; + +#[derive(Debug, Snafu)] +#[snafu(module(load_global_pishoo_plan_error))] +pub enum LoadGlobalPishooPlanError { + #[snafu(display("failed to load root configuration"))] + Config { source: ConfigLoadFailure }, + #[snafu(display("failed to resolve worker directives"))] + WorkerDirectives { source: ConfigError }, + #[snafu(display("failed to resolve worker accounts"))] + WorkerAccounts { source: ConfigError }, + #[snafu(display("failed to construct worker account"))] + WorkerAccount { source: WorkerAccountError }, + #[snafu(display("failed to construct worker roster"))] + WorkerRoster { source: BuildWorkerRosterError }, +} + +#[derive(Debug)] +pub struct GlobalPishooPlan { + source: PishooConfigSource, + pishoo: PishooConfig, + worker_defaults: RootWorkerDefaultsSnapshot, + desired_workers: WorkerRoster, + direct_servers: Box<[gateway::parse::ServerConfigCandidate]>, +} +impl GlobalPishooPlan { + pub fn source(&self) -> &PishooConfigSource { + &self.source + } + pub fn home(&self) -> Option<&DhttpHome> { + self.source.dhttp_home() + } + pub fn pishoo(&self) -> &PishooConfig { + &self.pishoo + } + pub fn worker_defaults(&self) -> &RootWorkerDefaultsSnapshot { + &self.worker_defaults + } + pub fn desired_workers(&self) -> &WorkerRoster { + &self.desired_workers + } + pub fn direct_servers(&self) -> &[gateway::parse::ServerConfigCandidate] { + &self.direct_servers + } + pub fn into_parts( + self, + ) -> ( + Option, + RootWorkerDefaultsSnapshot, + WorkerRoster, + Box<[gateway::parse::ServerConfigCandidate]>, + ) { + ( + self.source.dhttp_home().cloned(), + self.worker_defaults, + self.desired_workers, + self.direct_servers, + ) + } +} + +pub async fn load_global_pishoo_plan( + source: &PishooConfigSource, +) -> Result { + let mut parser = TypedConfigParser::new(); + let parsed = gateway::parse::load_root_config_file( + &mut parser, + source.config_path(), + source.dhttp_home(), + ) + .await + .context(load_global_pishoo_plan_error::ConfigSnafu)?; + let (pishoo, direct_servers) = parsed.into_parts(); + let mode = if source.default_worker_groups_enabled() { + WorkerDiscoveryMode::DefaultGlobalHome + } else { + WorkerDiscoveryMode::ExplicitConfig + }; + let workers = resolve_all_workers_with_mode(&pishoo, mode) + .context(load_global_pishoo_plan_error::WorkerDirectivesSnafu)?; + let targets = resolve_worker_targets(&workers) + .context(load_global_pishoo_plan_error::WorkerAccountsSnafu)?; + let accounts = targets + .iter() + .map(|target| { + WorkerAccount::from_target(target, select_worker_dhttp_home(target)) + .context(load_global_pishoo_plan_error::WorkerAccountSnafu) + }) + .collect::, _>>()?; + let desired_workers = + WorkerRoster::new(accounts).context(load_global_pishoo_plan_error::WorkerRosterSnafu)?; + let worker_defaults = pishoo.worker_defaults(); + Ok(GlobalPishooPlan { + source: source.clone(), + pishoo, + worker_defaults, + desired_workers, + direct_servers, + }) +} + +fn resolve_all_workers_with_mode( + config: &PishooConfig, + mode: WorkerDiscoveryMode, +) -> Result, ConfigError> { + super::worker_target::resolve_all_workers_mode(config, mode) +} + +#[derive(Debug, Snafu)] +#[snafu(module(identity_server_error))] +pub enum IdentityServerError { + #[snafu(display("invalid identity profile candidate"))] + Profile { + source: IdentityProfileCandidateError, + }, + #[snafu(display("failed to load identity server configuration"))] + Config { source: ConfigLoadFailure }, + #[snafu(display("identity server configuration is invalid"))] + Server { source: BuildTypedConfigError }, +} + +#[derive(Debug)] +pub struct IdentityServerCandidate { + profile: Option, + result: Result, IdentityServerError>, +} +impl IdentityServerCandidate { + pub fn profile(&self) -> Option<&IdentityProfile> { + self.profile.as_ref() + } + pub fn result(&self) -> &Result, IdentityServerError> { + &self.result + } + pub fn into_parts( + self, + ) -> ( + Option, + Result, IdentityServerError>, + ) { + (self.profile, self.result) + } +} + +#[derive(Debug, Snafu)] +#[snafu(module(load_identity_server_candidates_error))] +pub enum LoadIdentityServerCandidatesError { + #[snafu(display("failed to enumerate identity profiles"))] + Enumerate { + source: dhttp::home::identity::ssl::ListIdentityProfilesError, + }, +} + +pub async fn load_identity_server_candidates( + home: &DhttpHome, + defaults: &RootWorkerDefaultsSnapshot, +) -> Result, LoadIdentityServerCandidatesError> { + let profiles = home + .identity_profile_candidates() + .await + .context(load_identity_server_candidates_error::EnumerateSnafu)?; + let mut parser = TypedConfigParser::new(); + let mut results = Vec::with_capacity(profiles.len()); + for profile in profiles.into_vec() { + match profile { + Err(source) => results.push(IdentityServerCandidate { + profile: None, + result: Err(IdentityServerError::Profile { source }), + }), + Ok(profile) => { + let loaded = gateway::parse::load_identity_config_file( + &mut parser, + profile.clone(), + defaults, + ) + .await; + let result = match loaded { + Ok(None) => Ok(None), + Ok(Some(candidate)) => candidate + .into_parts() + .1 + .map(Some) + .map_err(|source| IdentityServerError::Server { source }), + Err(source) => Err(IdentityServerError::Config { source }), + }; + results.push(IdentityServerCandidate { + profile: Some(profile), + result, + }); + } + } + } + Ok(results.into_boxed_slice()) +} + +#[derive(Debug)] +pub struct WorkerHomePlan { + account: WorkerAccount, + defaults: RootWorkerDefaultsSnapshot, + servers: Box<[IdentityServerCandidate]>, +} +impl WorkerHomePlan { + pub fn account(&self) -> &WorkerAccount { + &self.account + } + pub fn defaults(&self) -> &RootWorkerDefaultsSnapshot { + &self.defaults + } + pub fn servers(&self) -> &[IdentityServerCandidate] { + &self.servers + } +} + +#[derive(Debug, Snafu)] +#[snafu(module(load_worker_home_plan_error))] +pub enum LoadWorkerHomePlanError { + #[snafu(display("failed to load worker pishoo configuration"))] + Config { source: ConfigLoadFailure }, + #[snafu(display("failed to load worker identity candidates"))] + Identities { + source: LoadIdentityServerCandidatesError, + }, +} + +pub async fn load_worker_home_plan( + account: WorkerAccount, + root: RootWorkerDefaultsSnapshot, +) -> Result { + let home = account.dhttp_home(); + let path = home.join(PishooConfigSource::CONFIG_FILE_NAME); + let mut parser = TypedConfigParser::new(); + let defaults = match gateway::parse::load_worker_config_file(&mut parser, &path, home, &root) + .await + .context(load_worker_home_plan_error::ConfigSnafu)? + { + Some(parsed) => parsed.pishoo().worker_defaults(), + None => root, + }; + let servers = load_identity_server_candidates(home, &defaults) + .await + .context(load_worker_home_plan_error::IdentitiesSnafu)?; + Ok(WorkerHomePlan { + account, + defaults, + servers, + }) +} + +#[cfg(test)] +mod tests { + use std::path::{Path, PathBuf}; + + use dhttp::home::DhttpHome; + + use super::*; + + struct TempDir(PathBuf); + impl TempDir { + fn new(label: &str) -> Self { + let path = std::env::temp_dir().join(format!( + "pishoo-{label}-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(), + )); + std::fs::create_dir_all(&path).unwrap(); + Self(path) + } + fn path(&self) -> &Path { + &self.0 + } + } + impl Drop for TempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + #[tokio::test] + async fn snapshot_and_roster_come_from_one_valid_root_plan() { + let temp = TempDir::new("root-plan"); + let path = temp.path().join("pishoo.conf"); + std::fs::write(&path, "pishoo { gzip on; }").unwrap(); + let source = PishooConfigSource::explicit_at(path, temp.path()).unwrap(); + + let plan = load_global_pishoo_plan(&source).await.unwrap(); + + assert!(plan.worker_defaults().http().gzip().effective().0); + assert!(plan.desired_workers().is_empty()); + } + + #[tokio::test] + async fn bad_identity_server_does_not_fail_sibling_candidate() { + let temp = TempDir::new("identity-siblings"); + for (name, server) in [ + ("good.dhttp.net", "server { listen all 443; }"), + ("bad.dhttp.net", "server {"), + ] { + let profile = temp.path().join(name); + std::fs::create_dir_all(profile.join("ssl")).unwrap(); + std::fs::write(profile.join("server.conf"), server).unwrap(); + } + let home = DhttpHome::new(temp.path().to_path_buf()); + let defaults = gateway::parse::TypedConfigParser::new() + .parse_root("pishoo {}", &temp.path().join("root.conf"), Some(&home)) + .unwrap() + .pishoo() + .worker_defaults(); + + let children = load_identity_server_candidates(&home, &defaults) + .await + .unwrap(); + + assert_eq!( + children + .iter() + .filter(|child| child.result().is_ok()) + .count(), + 1 + ); + assert_eq!( + children + .iter() + .filter(|child| child.result().is_err()) + .count(), + 1 + ); + } +} diff --git a/pishoo/src/config/root.rs b/pishoo/src/config/root.rs deleted file mode 100644 index 28738ff9..00000000 --- a/pishoo/src/config/root.rs +++ /dev/null @@ -1,35 +0,0 @@ -use std::path::PathBuf; - -use gateway::parse::{document::ConfigNode, types::StringList}; -use snafu::ResultExt; - -use super::{ - ConfigError, first_pishoo_node, parse_pid_file, - worker_target::{WorkerTarget, resolve_all_workers}, -}; - -#[derive(Debug, Clone)] -pub struct RootConfig { - pub pid_file: PathBuf, - pub groups: Vec, - pub workers: Vec, -} - -pub fn parse_root_config(root: &std::sync::Arc) -> Result { - let pishoo = first_pishoo_node(root)?; - let pid_file = parse_pid_file(&pishoo)?; - - let groups = pishoo - .get::("groups") - .context(super::ConfigQuerySnafu)? - .map(|groups| groups.0.clone()) - .unwrap_or_default(); - - let workers = resolve_all_workers(&pishoo)?; - - Ok(RootConfig { - pid_file, - groups, - workers, - }) -} diff --git a/pishoo/src/config/source.rs b/pishoo/src/config/source.rs index 92cceb97..c7c317de 100644 --- a/pishoo/src/config/source.rs +++ b/pishoo/src/config/source.rs @@ -1,6 +1,7 @@ use std::path::{Path, PathBuf}; use dhttp::home::{DhttpHome, HomeScope}; +use gateway::parse::domain::{ResolvedConfigPath, ResolvedConfigPathError}; use snafu::{ResultExt, Snafu}; #[derive(Debug, Snafu)] @@ -10,70 +11,122 @@ pub enum ResolveConfigSourceError { GlobalHome { source: dhttp::home::LoadDhttpHomeError, }, + #[snafu(display("failed to resolve the current directory for pishoo configuration"))] + CurrentDirectory { source: std::io::Error }, + #[snafu(display("invalid pishoo configuration path {}", path.display()))] + ConfigPath { + path: PathBuf, + source: ResolvedConfigPathError, + }, + #[snafu(display("invalid global dhttp home path {}", path.display()))] + DhttpHome { + path: PathBuf, + source: ResolvedConfigPathError, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ConfigSourceKind { + GlobalHome, + ExplicitFile, } +/// A source selection whose home and configuration path were anchored together exactly once. +/// +/// The representation is deliberately opaque: callers cannot construct a relative source or pair +/// one global home with an unrelated configuration path. +/// +/// ```compile_fail +/// use pishoo::config::PishooConfigSource; +/// let _ = PishooConfigSource::ExplicitFile { config_path: "relative.conf".into() }; +/// ``` #[derive(Debug, Clone)] -pub enum PishooConfigSource { - GlobalHome { - home: DhttpHome, - config_path: PathBuf, - }, - ExplicitFile { - config_path: PathBuf, - }, +pub struct PishooConfigSource { + kind: ConfigSourceKind, + home: Option, + config_path: ResolvedConfigPath, } impl PishooConfigSource { pub const CONFIG_FILE_NAME: &'static str = "pishoo.conf"; pub fn resolve(config_file: Option) -> Result { + let current_dir = + std::env::current_dir().context(resolve_config_source_error::CurrentDirectorySnafu)?; + Self::resolve_at(config_file, ¤t_dir) + } + + fn resolve_at( + config_file: Option, + current_dir: &Path, + ) -> Result { match config_file { - Some(config_path) => Ok(Self::explicit(config_path)), + Some(config_path) => Self::explicit_at(config_path, current_dir), None => { let home = DhttpHome::load(HomeScope::Global) .context(resolve_config_source_error::GlobalHomeSnafu)?; - Ok(Self::from_global_home(home)) + Self::from_global_home_at(home, current_dir) } } } - pub fn from_global_home(home: DhttpHome) -> Self { - let config_path = home.join(Self::CONFIG_FILE_NAME); - Self::GlobalHome { home, config_path } + pub(crate) fn explicit_at( + config_path: PathBuf, + current_dir: &Path, + ) -> Result { + let config_path = anchor(config_path, current_dir); + let config_path = ResolvedConfigPath::try_from(config_path.clone()) + .context(resolve_config_source_error::ConfigPathSnafu { path: config_path })?; + Ok(Self { + kind: ConfigSourceKind::ExplicitFile, + home: None, + config_path, + }) } - pub fn explicit(config_path: PathBuf) -> Self { - Self::ExplicitFile { config_path } + pub(crate) fn from_global_home_at( + home: DhttpHome, + current_dir: &Path, + ) -> Result { + let home_path = anchor(home.as_path().to_path_buf(), current_dir); + let resolved_home = ResolvedConfigPath::try_from(home_path.clone()).context( + resolve_config_source_error::DhttpHomeSnafu { + path: home_path.clone(), + }, + )?; + let home = DhttpHome::new(resolved_home.as_ref().to_path_buf()); + let config_path = home.join(Self::CONFIG_FILE_NAME); + let config_path = ResolvedConfigPath::try_from(config_path.clone()) + .context(resolve_config_source_error::ConfigPathSnafu { path: config_path })?; + Ok(Self { + kind: ConfigSourceKind::GlobalHome, + home: Some(home), + config_path, + }) } pub fn config_path(&self) -> &Path { - match self { - Self::GlobalHome { config_path, .. } | Self::ExplicitFile { config_path } => { - config_path - } - } + self.config_path.as_ref() } pub fn dhttp_home(&self) -> Option<&DhttpHome> { - match self { - Self::GlobalHome { home, .. } => Some(home), - Self::ExplicitFile { .. } => None, - } - } - - pub fn build_options(&self) -> gateway::parse::registry::BuildOptions<'_> { - gateway::parse::registry::BuildOptions { - dhttp_home: self.dhttp_home(), - identity_profile: None, - } + self.home.as_ref() } pub fn default_worker_groups_enabled(&self) -> bool { - matches!(self, Self::GlobalHome { .. }) + self.kind == ConfigSourceKind::GlobalHome } pub fn load_identity_services(&self) -> bool { - matches!(self, Self::GlobalHome { .. }) + self.kind == ConfigSourceKind::GlobalHome + } +} + +fn anchor(path: PathBuf, current_dir: &Path) -> PathBuf { + if path.is_absolute() { + path + } else { + current_dir.join(path) } } @@ -87,7 +140,11 @@ mod tests { #[test] fn global_home_source_uses_home_pishoo_conf() { - let source = PishooConfigSource::from_global_home(home("/tmp/dhttp-global")); + let source = PishooConfigSource::from_global_home_at( + home("/tmp/dhttp-global"), + Path::new("/ignored"), + ) + .unwrap(); assert_eq!( source.config_path(), @@ -100,11 +157,53 @@ mod tests { #[test] fn explicit_file_source_has_no_home_context() { - let source = PishooConfigSource::explicit(PathBuf::from("/tmp/custom.conf")); + let source = PishooConfigSource::explicit_at( + PathBuf::from("/tmp/custom.conf"), + Path::new("/ignored"), + ) + .unwrap(); assert_eq!(source.config_path(), Path::new("/tmp/custom.conf")); assert!(source.dhttp_home().is_none()); assert!(!source.default_worker_groups_enabled()); assert!(!source.load_identity_services()); } + + #[test] + fn relative_explicit_source_is_anchored_once_to_absolute_path() { + let source = PishooConfigSource::explicit_at( + PathBuf::from("config/pishoo.conf"), + Path::new("/srv/pishoo"), + ) + .unwrap(); + assert_eq!( + source.config_path(), + Path::new("/srv/pishoo/config/pishoo.conf") + ); + } + + #[test] + fn relative_global_home_is_anchored_once_to_absolute_path() { + let source = + PishooConfigSource::from_global_home_at(home("state/dhttp"), Path::new("/srv/pishoo")) + .unwrap(); + assert_eq!( + source.dhttp_home().unwrap().as_path(), + Path::new("/srv/pishoo/state/dhttp") + ); + assert_eq!( + source.config_path(), + Path::new("/srv/pishoo/state/dhttp/pishoo.conf") + ); + } + + #[test] + fn reload_reuses_resolved_source_after_base_changes() { + let source = + PishooConfigSource::explicit_at(PathBuf::from("pishoo.conf"), Path::new("/first")) + .unwrap(); + let retained = source.clone(); + assert_eq!(retained.config_path(), Path::new("/first/pishoo.conf")); + assert_ne!(retained.config_path(), Path::new("/second/pishoo.conf")); + } } diff --git a/pishoo/src/config/tests.rs b/pishoo/src/config/tests.rs deleted file mode 100644 index 68f79f83..00000000 --- a/pishoo/src/config/tests.rs +++ /dev/null @@ -1,381 +0,0 @@ -use std::{ffi::CString, path::PathBuf}; - -use crate::config::{ - PID_FILE_DEFAULT, - entry::parse_entry_config, - root::parse_root_config, - worker_target::{ - AccountDirectory, AccountGroup, Gid, ResolvedWorkerTarget, Uid, WorkerTarget, - compute_worker_diff, resolve_worker_targets, - }, -}; - -fn create_temp_tls_files() -> (PathBuf, PathBuf) { - let base = std::env::temp_dir().join(format!( - "pishoo-config-test-{}", - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .expect("clock should be after epoch") - .as_nanos() - )); - std::fs::create_dir_all(&base).expect("create temp tls dir"); - let cert = base.join("server.pem"); - let key = base.join("server.key"); - std::fs::write(&cert, b"dummy cert").expect("write cert fixture"); - std::fs::write(&key, b"dummy key").expect("write key fixture"); - (cert, key) -} - -#[test] -fn invalid_workers_type_is_rejected() { - let failure = gateway::parse::parse_config_str_for_test("pishoo { workers { nested on; } }") - .expect_err("workers block should be rejected before config parse"); - assert!(!failure.error.to_string().contains('\n')); -} - -#[test] -fn extracts_pid_and_workers() { - let conf = "pishoo { pid /tmp/pishoo-test.pid; workers alice bob; }"; - let parsed = gateway::parse::parse_config_str_for_test(conf).expect("parse config"); - let root = parse_root_config(&parsed.root).expect("parse root config"); - assert_eq!(root.pid_file, PathBuf::from("/tmp/pishoo-test.pid")); - assert_eq!(root.workers.len(), 2); - assert_eq!(root.workers[0].username, "alice"); - assert_eq!(root.workers[1].username, "bob"); -} - -#[test] -fn parse_workers_from_root_config() { - let conf = "pishoo { workers alice bob; }"; - let parsed = gateway::parse::parse_config_str_for_test(conf).expect("parse config"); - let root = parse_root_config(&parsed.root).expect("parse root config"); - assert_eq!(root.pid_file, PathBuf::from(PID_FILE_DEFAULT)); - assert_eq!(root.workers.len(), 2); - assert_eq!(root.workers[0].username, "alice"); - assert_eq!(root.workers[1].username, "bob"); -} - -#[test] -fn resolve_existing_user_target() { - let me = nix::unistd::User::from_uid(nix::unistd::getuid()) - .expect("resolve current uid") - .expect("current user exists"); - let workers = vec![WorkerTarget { username: me.name }]; - let resolved = resolve_worker_targets(&workers).expect("resolve user target"); - assert_eq!(resolved.len(), 1); - assert!(!resolved[0].dir.as_os_str().is_empty()); -} - -#[test] -fn parse_entry_config_servers_only() { - let (cert, key) = create_temp_tls_files(); - let conf = format!( - "pishoo {{ server {{ listen all 443; server_name demo~; ssl_certificate {}; ssl_certificate_key {}; location / {{ root /tmp; }} }} }}", - cert.display(), - key.display() - ); - let parsed = gateway::parse::parse_config_str_for_test(&conf).expect("parse config"); - let entry = crate::config::entry::parse_entry_config_with_directory( - &parsed.root, - &FakeAccountDirectory::default(), - ) - .expect("parse entry config"); - assert!(entry.workers.is_empty()); - assert_eq!(entry.config_services.len(), 1); -} - -#[tokio::test] -async fn parse_entry_config_servers_only_from_file_config() { - let (cert, key) = create_temp_tls_files(); - let dir = cert.parent().expect("temp dir").to_path_buf(); - let conf_path = dir.join("pishoo.conf"); - std::fs::write( - &conf_path, - format!( - "pishoo {{ server {{ listen all 443; server_name demo~; ssl_certificate {}; ssl_certificate_key {}; location / {{ root .; }} }} }}", - cert.display(), - key.display(), - ), - ) - .expect("write config"); - - let registry = gateway::parse::default_registry(); - let parsed = gateway::parse::load_config_file( - &conf_path, - ®istry, - gateway::parse::registry::BuildOptions::default(), - ) - .await - .expect("parse config"); - - let entry = crate::config::entry::parse_entry_config_with_directory( - &parsed.root, - &FakeAccountDirectory::default(), - ) - .expect("parse entry config"); - assert!(entry.workers.is_empty()); - assert_eq!(entry.config_services.len(), 1); - - let location = entry.config_services[0] - .children("location") - .expect("location children")[0] - .clone(); - assert_eq!( - location - .require::("root") - .expect("root should be typed") - .0, - dir, - ); -} - -#[test] -fn parse_entry_config_workers_and_servers() { - let (cert, key) = create_temp_tls_files(); - let conf = format!( - "pishoo {{ workers alice; server {{ listen all 443; server_name demo~; ssl_certificate {}; ssl_certificate_key {}; location / {{ root /tmp; }} }} }}", - cert.display(), - key.display() - ); - let parsed = gateway::parse::parse_config_str_for_test(&conf).expect("parse config"); - let entry = parse_entry_config(&parsed.root).expect("parse entry config"); - assert_eq!(entry.workers.len(), 1); - assert_eq!(entry.config_services.len(), 1); -} - -#[derive(Default)] -struct FakeAccountDirectory { - groups: std::collections::HashMap, - group_members: std::collections::HashMap>, -} - -impl FakeAccountDirectory { - fn with_group_members(mut self, name: &str, gid: libc::gid_t, members: &[&str]) -> Self { - self.groups.insert( - name.to_string(), - AccountGroup { - name: name.to_string(), - gid: Gid::from_raw(gid), - members: Vec::new(), - }, - ); - self.group_members.insert( - name.to_string(), - members.iter().map(|member| (*member).to_string()).collect(), - ); - self - } -} - -impl AccountDirectory for FakeAccountDirectory { - fn group_by_name( - &self, - group_name: &str, - ) -> Result, crate::config::ConfigError> { - Ok(self.groups.get(group_name).cloned()) - } - - fn group_member_usernames( - &self, - group_name: &str, - ) -> Result>, crate::config::ConfigError> { - Ok(self.group_members.get(group_name).cloned()) - } -} - -#[cfg(not(target_os = "macos"))] -#[test] -fn default_global_mode_loads_pishoo_group_when_no_worker_directives() { - let directory = - FakeAccountDirectory::default().with_group_members("pishoo", 42, &["alice", "bob"]); - let parsed = gateway::parse::parse_config_str_for_test("pishoo { pid /tmp/pishoo.pid; }") - .expect("parse config"); - - let entry = crate::config::entry::parse_entry_config_with_directory_and_mode( - &parsed.root, - &directory, - crate::config::worker_target::WorkerDiscoveryMode::DefaultGlobalHome, - ) - .expect("parse entry config"); - - let names: Vec<_> = entry - .workers - .iter() - .map(|worker| worker.username.as_str()) - .collect(); - assert_eq!(names, ["alice", "bob"]); -} - -#[cfg(target_os = "macos")] -#[test] -fn default_global_mode_loads_www_group_when_no_worker_directives() { - let directory = - FakeAccountDirectory::default().with_group_members("_www", 70, &["alice", "bob"]); - let parsed = gateway::parse::parse_config_str_for_test("pishoo { pid /tmp/pishoo.pid; }") - .expect("parse config"); - - let entry = crate::config::entry::parse_entry_config_with_directory_and_mode( - &parsed.root, - &directory, - crate::config::worker_target::WorkerDiscoveryMode::DefaultGlobalHome, - ) - .expect("parse entry config"); - - let names: Vec<_> = entry - .workers - .iter() - .map(|worker| worker.username.as_str()) - .collect(); - assert_eq!(names, ["alice", "bob"]); -} - -#[test] -fn explicit_config_mode_does_not_load_default_pishoo_group() { - let directory = - FakeAccountDirectory::default().with_group_members("pishoo", 42, &["alice", "bob"]); - let parsed = gateway::parse::parse_config_str_for_test("pishoo { pid /tmp/pishoo.pid; }") - .expect("parse config"); - - let entry = crate::config::entry::parse_entry_config_with_directory_and_mode( - &parsed.root, - &directory, - crate::config::worker_target::WorkerDiscoveryMode::ExplicitConfig, - ) - .expect("parse entry config"); - - assert!(entry.workers.is_empty()); -} - -#[test] -fn default_global_mode_uses_default_group_even_with_config_servers() { - let (cert, key) = create_temp_tls_files(); - let conf = format!( - "pishoo {{ server {{ listen all 443; server_name demo~; ssl_certificate {}; ssl_certificate_key {}; location / {{ root /tmp; }} }} }}", - cert.display(), - key.display() - ); - let parsed = gateway::parse::parse_config_str_for_test(&conf).expect("parse config"); - let directory = FakeAccountDirectory::default().with_group_members("pishoo", 42, &["alice"]); - - let entry = crate::config::entry::parse_entry_config_with_directory_and_mode( - &parsed.root, - &directory, - crate::config::worker_target::WorkerDiscoveryMode::DefaultGlobalHome, - ) - .expect("parse entry config"); - - assert_eq!(entry.config_services.len(), 1); - assert_eq!(entry.workers.len(), 1); - assert_eq!(entry.workers[0].username, "alice"); -} - -#[test] -fn worker_diff_unchanged_ignores_order() { - let current = vec![ - ResolvedWorkerTarget { - uid: Uid::from_raw(1), - gid: Gid::from_raw(11), - name: "alice".to_string(), - passwd: CString::default(), - gecos: CString::default(), - dir: PathBuf::from("/tmp/alice"), - shell: PathBuf::new(), - }, - ResolvedWorkerTarget { - uid: Uid::from_raw(2), - gid: Gid::from_raw(22), - name: "bob".to_string(), - passwd: CString::default(), - gecos: CString::default(), - dir: PathBuf::from("/tmp/bob"), - shell: PathBuf::new(), - }, - ]; - let next = vec![ - ResolvedWorkerTarget { - uid: Uid::from_raw(2), - gid: Gid::from_raw(22), - name: "bob".to_string(), - passwd: CString::default(), - gecos: CString::default(), - dir: PathBuf::from("/tmp/bob"), - shell: PathBuf::new(), - }, - ResolvedWorkerTarget { - uid: Uid::from_raw(1), - gid: Gid::from_raw(11), - name: "alice".to_string(), - passwd: CString::default(), - gecos: CString::default(), - dir: PathBuf::from("/tmp/alice"), - shell: PathBuf::new(), - }, - ]; - - let diff = compute_worker_diff(¤t, &next); - assert_eq!(diff.unchanged.len(), 2); - assert!(diff.added.is_empty()); - assert!(diff.removed.is_empty()); - assert!(diff.changed.is_empty()); -} - -#[test] -fn worker_diff_detects_add_remove() { - let current = vec![ResolvedWorkerTarget { - uid: Uid::from_raw(1), - gid: Gid::from_raw(11), - name: "alice".to_string(), - passwd: CString::default(), - gecos: CString::default(), - dir: PathBuf::from("/tmp/alice"), - shell: PathBuf::new(), - }]; - let next = vec![ResolvedWorkerTarget { - uid: Uid::from_raw(2), - gid: Gid::from_raw(22), - name: "bob".to_string(), - passwd: CString::default(), - gecos: CString::default(), - dir: PathBuf::from("/tmp/bob"), - shell: PathBuf::new(), - }]; - - let diff = compute_worker_diff(¤t, &next); - assert!(diff.unchanged.is_empty()); - assert_eq!(diff.added.len(), 1); - assert_eq!(diff.added[0].name, "bob"); - assert_eq!(diff.removed.len(), 1); - assert_eq!(diff.removed[0].name, "alice"); - assert!(diff.changed.is_empty()); -} - -#[test] -fn worker_diff_detects_uid_change() { - let current = vec![ResolvedWorkerTarget { - uid: Uid::from_raw(1000), - gid: Gid::from_raw(1000), - name: "alice".to_string(), - passwd: CString::default(), - gecos: CString::default(), - dir: PathBuf::from("/tmp/alice"), - shell: PathBuf::new(), - }]; - let next = vec![ResolvedWorkerTarget { - uid: Uid::from_raw(1001), - gid: Gid::from_raw(1001), - name: "alice".to_string(), - passwd: CString::default(), - gecos: CString::default(), - dir: PathBuf::from("/tmp/alice"), - shell: PathBuf::new(), - }]; - - let diff = compute_worker_diff(¤t, &next); - assert!(diff.unchanged.is_empty()); - assert!(diff.added.is_empty()); - assert!(diff.removed.is_empty()); - assert_eq!(diff.changed.len(), 1); - assert_eq!(diff.changed[0].0.uid, Uid::from_raw(1000)); - assert_eq!(diff.changed[0].1.uid, Uid::from_raw(1001)); - assert_eq!(diff.changed[0].1.name, "alice"); -} diff --git a/pishoo/src/config/worker_target.rs b/pishoo/src/config/worker_target.rs index b77b216b..92668cb5 100644 --- a/pishoo/src/config/worker_target.rs +++ b/pishoo/src/config/worker_target.rs @@ -1,12 +1,10 @@ -use std::collections::HashMap; - -use gateway::parse::{document::ConfigNode, types::StringList}; +use gateway::parse::config::PishooConfig; pub use nix::unistd::{Gid, Uid, User as ResolvedWorkerTarget}; use snafu::{OptionExt, ResultExt}; use super::{ - ConfigError, ConfigQuerySnafu, EmptyWorkerNameSnafu, GroupNotFoundSnafu, GroupResolveSnafu, - MissingHomeSnafu, PrimaryGroupUserResolveSnafu, UserNotFoundSnafu, UserResolveSnafu, + ConfigError, EmptyWorkerNameSnafu, GroupNotFoundSnafu, GroupResolveSnafu, MissingHomeSnafu, + PrimaryGroupUserResolveSnafu, UserNotFoundSnafu, UserResolveSnafu, }; #[derive(Debug, Clone)] @@ -47,22 +45,17 @@ fn parse_worker_names(names: &[String]) -> Result, ConfigError .collect() } -fn parse_configured_workers(pishoo: &ConfigNode) -> Result, ConfigError> { - match pishoo - .get::("workers") - .context(ConfigQuerySnafu)? - { - Some(names) => parse_worker_names(&names.0), - None => Ok(Vec::new()), - } +fn parse_configured_workers(pishoo: &PishooConfig) -> Result, ConfigError> { + pishoo + .workers() + .map_or(Ok(Vec::new()), |names| parse_worker_names(&names.0)) } -fn parse_groups(pishoo: &ConfigNode) -> Result, ConfigError> { - Ok(pishoo - .get::("groups") - .context(ConfigQuerySnafu)? +fn parse_groups(pishoo: &PishooConfig) -> Vec { + pishoo + .groups() .map(|names| names.0.clone()) - .unwrap_or_default()) + .unwrap_or_default() } #[derive(Debug, Clone)] @@ -273,18 +266,20 @@ fn resolve_default_group_members( Ok(targets) } -pub(super) fn resolve_all_workers(pishoo: &ConfigNode) -> Result, ConfigError> { - let directory = SystemAccountDirectory; - resolve_all_workers_with_directory(pishoo, &directory, WorkerDiscoveryMode::ExplicitConfig) +pub(super) fn resolve_all_workers_mode( + pishoo: &PishooConfig, + mode: WorkerDiscoveryMode, +) -> Result, ConfigError> { + resolve_all_workers_with_directory(pishoo, &SystemAccountDirectory, mode) } pub(super) fn resolve_all_workers_with_directory( - pishoo: &ConfigNode, + pishoo: &PishooConfig, directory: &D, mode: WorkerDiscoveryMode, ) -> Result, ConfigError> { let explicit_workers = parse_configured_workers(pishoo)?; - let groups = parse_groups(pishoo)?; + let groups = parse_groups(pishoo); let group_members = if groups.is_empty() && explicit_workers.is_empty() && mode.default_groups_enabled() { @@ -306,12 +301,6 @@ pub(super) fn resolve_all_workers_with_directory( Ok(result) } -pub fn resolve_entry_worker_targets( - entry_config: &super::EntryConfig, -) -> Result, ConfigError> { - resolve_worker_targets(&entry_config.workers) -} - pub fn resolve_worker_targets( workers: &[WorkerTarget], ) -> Result, ConfigError> { @@ -334,214 +323,3 @@ pub fn resolve_worker_targets( } Ok(resolved) } - -/// Diff result describing which workers are unchanged, added, removed, or changed. -#[derive(Debug)] -pub struct WorkerDiff { - /// Workers present in both current and next with same (username, uid). - pub unchanged: Vec, - /// Workers present in next but not in current. - pub added: Vec, - /// Workers present in current but not in next. - pub removed: Vec, - /// Workers where username matches but uid changed — these need kill + respawn. - pub changed: Vec<(ResolvedWorkerTarget, ResolvedWorkerTarget)>, -} - -/// Compute a diff between current and next worker targets. -pub fn compute_worker_diff( - current: &[ResolvedWorkerTarget], - next: &[ResolvedWorkerTarget], -) -> WorkerDiff { - let current_map: HashMap<&str, &ResolvedWorkerTarget> = - current.iter().map(|t| (t.name.as_str(), t)).collect(); - let next_map: HashMap<&str, &ResolvedWorkerTarget> = - next.iter().map(|t| (t.name.as_str(), t)).collect(); - - let mut unchanged = Vec::new(); - let mut added = Vec::new(); - let mut changed = Vec::new(); - let mut removed = Vec::new(); - - for next_target in next { - match current_map.get(next_target.name.as_str()) { - Some(cur) if cur.uid == next_target.uid => { - unchanged.push(next_target.clone()); - } - Some(cur) => { - changed.push(((*cur).clone(), next_target.clone())); - } - None => { - added.push(next_target.clone()); - } - } - } - - for cur_target in current { - if !next_map.contains_key(cur_target.name.as_str()) { - removed.push(cur_target.clone()); - } - } - - WorkerDiff { - unchanged, - added, - removed, - changed, - } -} - -#[cfg(test)] -mod tests { - use std::collections::HashMap; - - use gateway::parse::document::ConfigNode; - - use super::*; - - #[derive(Default)] - struct FakeAccountDirectory { - groups: HashMap, - primary_users: HashMap>, - } - - impl FakeAccountDirectory { - fn with_group(mut self, name: &str, gid: libc::gid_t, members: &[&str]) -> Self { - self.groups.insert( - name.to_string(), - AccountGroup { - name: name.to_string(), - gid: Gid::from_raw(gid), - members: members.iter().map(|member| (*member).to_string()).collect(), - }, - ); - self - } - - fn with_primary_users(mut self, gid: libc::gid_t, users: &[&str]) -> Self { - self.primary_users - .insert(gid, users.iter().map(|user| (*user).to_string()).collect()); - self - } - } - - impl AccountDirectory for FakeAccountDirectory { - fn group_by_name(&self, group_name: &str) -> Result, ConfigError> { - Ok(self.groups.get(group_name).cloned()) - } - - fn primary_group_usernames( - &self, - _group_name: &str, - gid: Gid, - ) -> Result, ConfigError> { - Ok(self - .primary_users - .get(&gid.as_raw()) - .cloned() - .unwrap_or_default()) - } - } - - fn first_pishoo(conf: &str) -> std::sync::Arc { - let parsed = gateway::parse::parse_config_str_for_test(conf).expect("parse config"); - parsed - .root - .children("pishoo") - .expect("pishoo block should exist") - .first() - .expect("pishoo block should not be empty") - .clone() - } - - #[test] - fn default_group_missing_is_not_a_config_error() { - let pishoo = first_pishoo("pishoo { }"); - let directory = FakeAccountDirectory::default(); - - let workers = resolve_all_workers_with_directory( - &pishoo, - &directory, - WorkerDiscoveryMode::DefaultGlobalHome, - ) - .expect("missing default pishoo group should warn and continue"); - - assert!(workers.is_empty()); - } - - #[test] - fn explicit_group_missing_remains_a_config_error() { - let pishoo = first_pishoo("pishoo { groups pishoo; }"); - let directory = FakeAccountDirectory::default(); - - let error = resolve_all_workers_with_directory( - &pishoo, - &directory, - WorkerDiscoveryMode::ExplicitConfig, - ) - .expect_err("explicit missing group should fail"); - - assert_eq!(error.to_string(), "group `pishoo` not found"); - } - - #[test] - fn default_group_includes_primary_gid_users() { - let pishoo = first_pishoo("pishoo { }"); - let directory = FakeAccountDirectory::default() - .with_group("pishoo", 42, &["alice"]) - .with_primary_users(42, &["bob", "carol"]); - - let workers = resolve_all_workers_with_directory( - &pishoo, - &directory, - WorkerDiscoveryMode::DefaultGlobalHome, - ) - .expect("default group should resolve"); - - let usernames = workers - .iter() - .map(|worker| worker.username.as_str()) - .collect::>(); - assert_eq!(usernames, ["alice", "bob", "carol"]); - } - - #[test] - fn default_group_deduplicates_supplementary_and_primary_users() { - let pishoo = first_pishoo("pishoo { }"); - let directory = FakeAccountDirectory::default() - .with_group("pishoo", 42, &["alice", "bob"]) - .with_primary_users(42, &["bob", "carol"]); - - let workers = resolve_all_workers_with_directory( - &pishoo, - &directory, - WorkerDiscoveryMode::DefaultGlobalHome, - ) - .expect("default group should resolve"); - - let usernames = workers - .iter() - .map(|worker| worker.username.as_str()) - .collect::>(); - assert_eq!(usernames, ["alice", "bob", "carol"]); - } - - #[test] - fn explicit_workers_are_kept_before_default_group_users() { - let pishoo = first_pishoo("pishoo { workers zoe alice; }"); - let directory = FakeAccountDirectory::default().with_group("pishoo", 42, &["bob"]); - - let workers = resolve_all_workers_with_directory( - &pishoo, - &directory, - WorkerDiscoveryMode::ExplicitConfig, - ) - .expect("explicit workers should resolve without default group lookup"); - - let usernames = workers - .iter() - .map(|worker| worker.username.as_str()) - .collect::>(); - assert_eq!(usernames, ["zoe", "alice"]); - } -} diff --git a/pishoo/src/hypervisor/global_service.rs b/pishoo/src/hypervisor/global_service.rs index 01fb9ef7..ab0ad915 100644 --- a/pishoo/src/hypervisor/global_service.rs +++ b/pishoo/src/hypervisor/global_service.rs @@ -5,47 +5,14 @@ use std::sync::Arc; -use snafu::{ResultExt, Snafu}; - use crate::{ hypervisor::{in_process_plane::InProcessControlPlane, state::RootState}, service::{ runtime::RuntimeRegistry, - source::{PishooConfigServiceSource, PrepareContext, ServerSource}, + source::{PrepareContext, ServerSource, TypedServerSource}, }, }; -#[derive(Debug, Snafu)] -#[snafu(module)] -pub enum BuildGlobalSourcesError { - #[snafu(display("failed to load identity services"))] - IdentityServices { - source: crate::worker::config::BuildConfigError, - }, - #[snafu(display("failed to load pishoo config services"))] - ConfigServices { - source: crate::service::source::BuildConfigServiceSourcesError, - }, - #[snafu(display("failed to prepare global service context"))] - PrepareContext { - source: crate::service::source::BuildPrepareContextError, - }, -} - -#[derive(Debug, Snafu)] -#[snafu(module)] -pub enum SpawnGlobalServiceError { - #[snafu(display("failed to build global service sources"))] - Build { source: BuildGlobalSourcesError }, -} - -#[derive(Debug, Snafu)] -#[snafu(module)] -pub enum ReplaceGlobalServiceError { - #[snafu(display("failed to build global service sources"))] - Build { source: BuildGlobalSourcesError }, -} - /// Handle to a running global service, used for shutdown and replacement. pub struct GlobalServiceHandle { registry: Option>, @@ -58,6 +25,31 @@ impl GlobalServiceHandle { registry.shutdown().await; } } + + pub async fn wait_service_completion(&mut self) -> dhttp::name::DhttpName<'static> { + self.registry + .as_mut() + .expect("a live global service handle owns its registry") + .wait_service_completion() + .await + } + + pub async fn handle_service_exit(&mut self, name: dhttp::name::DhttpName<'static>) { + self.registry + .as_mut() + .expect("a live global service handle owns its registry") + .handle_service_exit(name) + .await; + } +} + +pub async fn wait_global_service_completion( + handle: &mut Option, +) -> dhttp::name::DhttpName<'static> { + match handle { + Some(handle) => handle.wait_service_completion().await, + None => std::future::pending().await, + } } impl Drop for GlobalServiceHandle { @@ -71,64 +63,47 @@ impl Drop for GlobalServiceHandle { } async fn build_global_sources( - source: &crate::config::PishooConfigSource, - entry_config: &crate::config::EntryConfig, + plan: &crate::config::GlobalPishooPlan, router_state: gateway::reverse::router::RouterState, -) -> Result<(Vec, PrepareContext), BuildGlobalSourcesError> { - let mut sources = Vec::new(); - let mut ctx = None; - - if source.load_identity_services() { - let home = source.dhttp_home().expect("global source has dhttp home"); - let identity_sources = crate::worker::config::load_identity_service_sources(home) - .await - .context(build_global_sources_error::IdentityServicesSnafu)?; - sources.extend( - identity_sources - .into_iter() - .map(ServerSource::IdentityService), - ); - } - - if !entry_config.config_services.is_empty() { - let (config_sources, config_ctx) = PishooConfigServiceSource::load_all( - &entry_config.config_services, - source.dhttp_home(), - router_state.clone(), - ) - .await - .context(build_global_sources_error::ConfigServicesSnafu)?; - sources.extend(config_sources); - ctx = Some(config_ctx); +) -> (Vec, PrepareContext) { + let mut configs = Vec::new(); + for candidate in plan.direct_servers() { + match candidate.result() { + Ok(server) => configs.push(Arc::new(server.clone())), + Err(error) => { + tracing::warn!(error = %snafu::Report::from_error(error), "direct server config rejected") + } + } } - - let ctx = match ctx { - Some(ctx) => ctx, - None => { - let Some(home) = source.dhttp_home() else { - return Ok(( - sources, - PrepareContext::load_config_service(None, router_state) - .await - .context(build_global_sources_error::PrepareContextSnafu)?, - )); - }; - PrepareContext::load_worker(home, router_state) - .await - .context(build_global_sources_error::PrepareContextSnafu)? + if let Some(home) = plan.home() { + match crate::config::load_identity_server_candidates(home, plan.worker_defaults()).await { + Ok(candidates) => { + for candidate in candidates.into_vec() { + let (profile, result) = candidate.into_parts(); + match result { + Ok(Some(server)) => configs.push(Arc::new(server)), + Ok(None) => {} + Err(error) => { + tracing::warn!(profile = ?profile.map(|p| p.name().to_string()), error = %snafu::Report::from_error(&error), "identity server config rejected") + } + } + } + } + Err(error) => tracing::warn!( + error = %snafu::Report::from_error(&error), + "global identity service discovery failed" + ), } - }; - - Ok((sources, ctx)) + } + TypedServerSource::load_all(configs, router_state).await } /// Spawn the global service from configuration. Returns `None` if no global /// services are configured. pub async fn spawn_global_service( state: &Arc, - source: &crate::config::PishooConfigSource, - entry_config: &crate::config::EntryConfig, -) -> Result, SpawnGlobalServiceError> { + plan: &crate::config::GlobalPishooPlan, +) -> Option { let plane = Arc::new(InProcessControlPlane::new(state.clone())); let router_state = gateway::reverse::router::RouterState { @@ -138,13 +113,11 @@ pub async fn spawn_global_service( task_scope: Arc::new(state.local_task_scope()), }; - let (sources, ctx) = build_global_sources(source, entry_config, router_state) - .await - .context(spawn_global_service_error::BuildSnafu)?; + let (sources, ctx) = build_global_sources(plan, router_state).await; if sources.is_empty() { tracing::debug!("no global services configured"); - return Ok(None); + return None; } let service_count = sources.len(); @@ -153,18 +126,17 @@ pub async fn spawn_global_service( tracing::info!(services = service_count, "global service started"); - Ok(Some(GlobalServiceHandle { + Some(GlobalServiceHandle { registry: Some(registry), - })) + }) } /// Replace the running global service with a freshly built one. pub async fn replace_global_service( state: &Arc, handle: &mut Option, - source: &crate::config::PishooConfigSource, - entry_config: &crate::config::EntryConfig, -) -> Result<(), ReplaceGlobalServiceError> { + plan: &crate::config::GlobalPishooPlan, +) { let plane = Arc::new(InProcessControlPlane::new(state.clone())); let router_state = gateway::reverse::router::RouterState { @@ -174,15 +146,13 @@ pub async fn replace_global_service( task_scope: Arc::new(state.local_task_scope()), }; - let (sources, ctx) = build_global_sources(source, entry_config, router_state) - .await - .context(replace_global_service_error::BuildSnafu)?; + let (sources, ctx) = build_global_sources(plan, router_state).await; if sources.is_empty() { if let Some(old) = handle.take() { old.shutdown().await; } - return Ok(()); + return; } if let Some(registry) = handle @@ -190,7 +160,7 @@ pub async fn replace_global_service( .and_then(|existing| existing.registry.as_mut()) { registry.apply_sources(sources, &ctx).await; - return Ok(()); + return; } let mut new_registry = RuntimeRegistry::new(plane); @@ -201,6 +171,4 @@ pub async fn replace_global_service( *handle = Some(GlobalServiceHandle { registry: Some(new_registry), }); - - Ok(()) } diff --git a/pishoo/src/hypervisor/in_process_plane.rs b/pishoo/src/hypervisor/in_process_plane.rs index be7cf064..4c58992b 100644 --- a/pishoo/src/hypervisor/in_process_plane.rs +++ b/pishoo/src/hypervisor/in_process_plane.rs @@ -26,7 +26,7 @@ use tokio_util::sync::CancellationToken; use crate::{ hypervisor::{ endpoint_factory, - state::{AcquireListenerError, RebuildListenerError, owner::Owner}, + state::{AcquireListenerError, owner::Owner}, }, listen::RegisteredEndpoint, }; @@ -255,22 +255,10 @@ fn send_local_session_signal(child_pid: Pid, signal: Signal) { impl gateway::control_plane::ProvideListener for InProcessControlPlane { type Listener = RegisteredEndpoint; type ListenError = AcquireListenerError; - type RebuildError = RebuildListenerError; async fn listener(&self, request: ListenRequest) -> Result { self.state.acquire_listener(Owner::Local, request).await } - - async fn rebuild_listener( - &self, - _old: Self::Listener, - request: ListenRequest, - ) -> Result { - // _old is consumed and dropped; root disarms its release guard when the - // rebuild transition starts, so the drop cannot release the - // replacement listener. - self.state.rebuild_listener(Owner::Local, request).await - } } impl gateway::control_plane::ProvideConnector for InProcessControlPlane { diff --git a/pishoo/src/hypervisor/ipc_server.rs b/pishoo/src/hypervisor/ipc_server.rs index 92d815be..ceb68938 100644 --- a/pishoo/src/hypervisor/ipc_server.rs +++ b/pishoo/src/hypervisor/ipc_server.rs @@ -34,8 +34,8 @@ use tracing::Instrument; use super::{endpoint_factory, state::RootState, task_scope::TaskScope}; use crate::{ - hypervisor::state::{AcquireListenerError, RebuildListenerError}, - ipc::{ConnectError, ListenError, RebuildListenError}, + hypervisor::state::AcquireListenerError, + ipc::{ConnectError, ListenError}, }; /// Per-worker [`ControlPlane`](crate::ipc::ControlPlane) implementation. @@ -146,64 +146,6 @@ impl crate::ipc::ControlPlane for WorkerControlPlane { Ok(self.wrap_listener(task_scope, server_name.clone(), adapter)) } - async fn rebuild_listener( - &self, - request: ListenRequest, - ) -> Result { - let server_name = request.identity.name().as_full().to_owned(); - let owner = self - .state - .owner_for_pid(self.caller_pid) - .await - .ok_or_else(|| RebuildListenError::Internal { - message: format!("unknown caller pid {}", self.caller_pid), - })?; - let task_scope = self - .state - .task_scope_for_owner(owner) - .await - .ok_or_else(|| RebuildListenError::Internal { - message: format!("unknown caller pid {}", self.caller_pid), - })?; - - let adapter = self - .state - .rebuild_listener(owner, request) - .await - .map_err(|error| { - let report = snafu::Report::from_error(&error).to_string(); - tracing::warn!( - caller_pid = %self.caller_pid, - %server_name, - error = %report, - "rebuild listener request failed" - ); - match error { - RebuildListenerError::NotOwner => RebuildListenError::NotOwner, - RebuildListenerError::ConflictedName => RebuildListenError::Conflict, - RebuildListenerError::TransitionStopped => RebuildListenError::Internal { - message: format!("failed to replace endpoint for `{server_name}`"), - }, - RebuildListenerError::Replacement { source } => match source { - AcquireListenerError::BuildBindPatterns { .. } => { - RebuildListenError::InvalidRequest { reason: report } - } - AcquireListenerError::DuplicateListen - | AcquireListenerError::ConflictedName => RebuildListenError::Conflict, - AcquireListenerError::BuildEndpoint { .. } - | AcquireListenerError::CreatePublisher { .. } - | AcquireListenerError::MissingPublisher - | AcquireListenerError::OwnerUnavailable - | AcquireListenerError::TransitionStopped => { - RebuildListenError::Replacement { reason: report } - } - }, - } - })?; - - Ok(self.wrap_listener(task_scope, server_name.clone(), adapter)) - } - async fn connector(&self, request: ConnectorRequest) -> Result { // Verify caller is a registered worker. let owner = self diff --git a/pishoo/src/hypervisor/process/batch.rs b/pishoo/src/hypervisor/process/batch.rs index 43db5f82..fd1e5667 100644 --- a/pishoo/src/hypervisor/process/batch.rs +++ b/pishoo/src/hypervisor/process/batch.rs @@ -48,7 +48,8 @@ pub(crate) fn worker_binary_path() -> PathBuf { /// from being spawned. pub async fn spawn_configured_workers( state: &Arc, - worker_targets: Vec, + worker_targets: Vec, + root_defaults: gateway::parse::config::RootWorkerDefaultsSnapshot, ) { if worker_targets.is_empty() { tracing::info!("no worker targets resolved"); @@ -60,14 +61,14 @@ pub async fn spawn_configured_workers( let worker_bin = worker_binary_path(); for target in &worker_targets { - match spawn_worker(&worker_bin, target, state.clone()).await { + match spawn_worker(&worker_bin, target, state.clone(), root_defaults.clone()).await { Ok(_) => spawned += 1, Err(error) => { // Worker spawn failures (fork/exec, IPC negotiation, hello // timeout) are per-user problems that must not bring down the // entire root process. Log and continue to the next worker. tracing::error!( - user = %target.name, + user = %target.name(), error = %Report::from_error(&error), "failed to spawn worker, skipping user" ); diff --git a/pishoo/src/hypervisor/process/spawn.rs b/pishoo/src/hypervisor/process/spawn.rs index 2527eb09..3fa0cfec 100644 --- a/pishoo/src/hypervisor/process/spawn.rs +++ b/pishoo/src/hypervisor/process/spawn.rs @@ -8,7 +8,7 @@ use snafu::{ResultExt, Snafu}; use tracing::Instrument; use crate::{ - config::ResolvedWorkerTarget, + config::WorkerAccount, hypervisor::{ ipc_server::WorkerControlPlane, state::{RootState, WorkerProcessError, WorkerStartupError, worker_startup_error}, @@ -41,27 +41,33 @@ pub enum SpawnWorkerError { /// 3. Schedule the startup handshake inside the worker task scope pub async fn spawn_worker( worker_bin: &std::path::Path, - target: &ResolvedWorkerTarget, + target: &WorkerAccount, state: Arc, + root_defaults: gateway::parse::config::RootWorkerDefaultsSnapshot, ) -> Result { let launched = crate::hypervisor::launcher::launch_worker( worker_bin, - target.uid, - target.gid, - &target.name, - &target.dir, + target.uid(), + target.primary_gid(), + target.name(), + target.login_home(), ) .context(spawn_worker_error::LaunchWorkerSnafu)?; let pid = launched.handle.pid(); let mux_fd = launched.mux_fd; + let (root_defaults_tx, root_defaults_rx) = remoc::rch::watch::channel(root_defaults.clone()); state - .register_worker(pid, target.uid, target.name.clone(), launched.handle) + .register_worker_with_defaults( + pid, + target.uid(), + target.name().to_owned(), + launched.handle, + root_defaults_tx, + ) .await; - let uid = target.uid.as_raw(); - let username = target.name.clone(); - let home = target.dir.clone(); + let account = target.clone(); let startup_state = state.clone(); let spawned_startup_task = state .spawn_worker_task(pid, move |token| { @@ -71,7 +77,7 @@ pub async fn spawn_worker( () = token.cancelled() => return, result = tokio::time::timeout( WORKER_STARTUP_TIMEOUT, - start_worker_ipc(pid, state.clone(), mux_fd, uid, username, home), + start_worker_ipc(pid, state.clone(), mux_fd, account, root_defaults, root_defaults_rx), ) => result, }; let error = match result { @@ -92,7 +98,7 @@ pub async fn spawn_worker( return Err(SpawnWorkerError::ScheduleStartup { pid }); } - tracing::info!(pid = %pid, username = %target.name, "worker launch scheduled"); + tracing::info!(pid = %pid, username = %target.name(), "worker launch scheduled"); Ok(SpawnedWorker { pid }) } @@ -101,9 +107,12 @@ async fn start_worker_ipc( pid: nix::unistd::Pid, state: Arc, mux_fd: OwnedFd, - uid: u32, - username: String, - home: std::path::PathBuf, + account: WorkerAccount, + root_defaults: gateway::parse::config::RootWorkerDefaultsSnapshot, + root_defaults_rx: remoc::rch::watch::Receiver< + gateway::parse::config::RootWorkerDefaultsSnapshot, + remoc::codec::Default, + >, ) -> Result<(), WorkerStartupError> { let mux = MuxChannel::from_fd(mux_fd).context(worker_startup_error::MuxChannelFromFdSnafu)?; let (sink, stream) = mux @@ -147,11 +156,11 @@ async fn start_worker_ipc( let rpc_impl = WorkerControlPlane::new(pid, state.clone(), fd_transfer); let (server, client) = crate::ipc::ControlPlaneServerShared::new(Arc::new(rpc_impl), 1); - let username_for_log = username.clone(); + let username_for_log = account.name().to_owned(); let bootstrap = WorkerBootstrap { - uid, - username, - home, + account, + root_defaults, + root_defaults_rx, control_plane: client, }; let server_state = state.clone(); diff --git a/pishoo/src/hypervisor/reload.rs b/pishoo/src/hypervisor/reload.rs index c0a47c20..cb0f7423 100644 --- a/pishoo/src/hypervisor/reload.rs +++ b/pishoo/src/hypervisor/reload.rs @@ -4,4 +4,4 @@ mod orchestrate; mod snapshot; pub use orchestrate::run_reload; -pub use snapshot::{RootReloadSnapshot, load_root_reload_snapshot}; +pub use snapshot::load_root_reload_snapshot; diff --git a/pishoo/src/hypervisor/reload/orchestrate.rs b/pishoo/src/hypervisor/reload/orchestrate.rs index 98bc0f0e..f6f95ed9 100644 --- a/pishoo/src/hypervisor/reload/orchestrate.rs +++ b/pishoo/src/hypervisor/reload/orchestrate.rs @@ -1,4 +1,4 @@ -//! Reload orchestration — SigHup handler logic. +//! Serialized root reload orchestration. use std::sync::Arc; @@ -7,164 +7,119 @@ use snafu::Report; use tracing::Instrument; use crate::{ - config::ResolvedWorkerTarget, + config::{WorkerAccount, WorkerRoster}, hypervisor::{ global_service::GlobalServiceHandle, state::{RootState, WorkerProcessError}, }, }; -/// Run the full reload sequence: -/// -/// 1. Preflight: load and validate the new configuration. -/// 2. Replace global services. -/// 3. Compute worker diff (unchanged / added / removed / changed). -/// 4. Kill removed + changed workers (parallel SIGTERM + grace). -/// 5. Scrub conflicted server names. -/// 6. Forward SIGHUP to unchanged workers. -/// 7. Spawn added + changed workers. -/// -/// On preflight or global-service failure, the reload is aborted and the -/// previous state is preserved. pub async fn run_reload( state: &Arc, config_source: &crate::config::PishooConfigSource, - current_worker_targets: &mut Vec, - global_service_handle: &mut Option, + current_workers: &mut Vec, + global_service: &mut Option, ) { tracing::info!("received reload signal"); - - let next_snapshot = match super::load_root_reload_snapshot(config_source).await { - Ok(snapshot) => snapshot, + let plan = match super::load_root_reload_snapshot(config_source).await { + Ok(plan) => plan, Err(error) => { - tracing::warn!( + tracing::error!( error = %Report::from_error(&error), path = %config_source.config_path().display(), - "reload preflight failed; keeping current root state" + "global pishoo reload failed; entering config-failed state" ); + enter_config_failed(state, current_workers, global_service).await; return; } }; - if let Err(error) = crate::hypervisor::global_service::replace_global_service( - state, - global_service_handle, - config_source, - &next_snapshot.entry_config, - ) - .await - { - tracing::warn!( - error = %Report::from_error(&error), - "failed to reload global services; keeping previous worker state" - ); - return; - } + state.clear_listener_poison().await; + let next_workers = plan.desired_workers().clone(); + let snapshot = plan.worker_defaults().clone(); - // Compute worker diff. - let diff = - crate::config::compute_worker_diff(current_worker_targets, &next_snapshot.worker_targets); + let worker_branch = reconcile_workers(state, current_workers, next_workers, snapshot); + let global_branch = + crate::hypervisor::global_service::replace_global_service(state, global_service, &plan); + tokio::join!(worker_branch, global_branch); + tracing::info!("reload complete"); +} - fn target_names(targets: &[ResolvedWorkerTarget]) -> Vec<&str> { - targets.iter().map(|t| t.name.as_str()).collect() +async fn enter_config_failed( + state: &Arc, + current_workers: &mut Vec, + global_service: &mut Option, +) { + if let Some(service) = global_service.take() { + service.shutdown().await; } - tracing::info!( - unchanged = ?target_names(&diff.unchanged), - added = ?target_names(&diff.added), - removed = ?target_names(&diff.removed), - changed = ?diff.changed.iter().map(|(_, new)| new.name.as_str()).collect::>(), - "reload diff" - ); - - state - .set_desired_workers(next_snapshot.worker_targets.clone()) - .await; + crate::hypervisor::shutdown::run_shutdown(state, Signal::SIGTERM).await; + current_workers.clear(); + state.wait_resource_transitions().await; +} - // Phase 1: Kill removed + changed workers (parallel). - if !diff.removed.is_empty() || !diff.changed.is_empty() { - let mut kill_tasks = tokio::task::JoinSet::new(); - for target in &diff.removed { - let state = state.clone(); - let uid = target.uid; - kill_tasks.spawn( - async move { - if let Some(pid) = state.pid_for_uid(uid).await { - let error = WorkerProcessError::ReloadRemoved; - // SIGTERM + 2s grace - state.send_signal_to_user(uid, Signal::SIGTERM).await; - if !state - .wait_worker_exit(pid, std::time::Duration::from_secs(2)) - .await - { - state.force_kill_worker(pid, &error).await; - state - .wait_worker_exit(pid, std::time::Duration::from_secs(2)) - .await; - } - state.cleanup_worker(pid, error).await; - } - } - .in_current_span(), - ); - } - for (old, _) in &diff.changed { - let state = state.clone(); - let uid = old.uid; - kill_tasks.spawn( - async move { - if let Some(pid) = state.pid_for_uid(uid).await { - let error = WorkerProcessError::ReloadChanged; - // SIGTERM + 2s grace - state.send_signal_to_user(uid, Signal::SIGTERM).await; - if !state - .wait_worker_exit(pid, std::time::Duration::from_secs(2)) - .await - { - state.force_kill_worker(pid, &error).await; - state - .wait_worker_exit(pid, std::time::Duration::from_secs(2)) - .await; - } - state.cleanup_worker(pid, error).await; - } +async fn reconcile_workers( + state: &Arc, + current_workers: &mut Vec, + next_roster: WorkerRoster, + snapshot: gateway::parse::config::RootWorkerDefaultsSnapshot, +) { + let next_workers = next_roster.to_vec(); + let diff = crate::config::compute_worker_diff(current_workers, &next_workers); + state.set_desired_workers(next_workers.clone()).await; + state.dispatch_worker_defaults(snapshot.clone()).await; + + let mut stopping = tokio::task::JoinSet::new(); + for target in diff + .removed + .iter() + .chain(diff.changed.iter().map(|(old, _)| old)) + { + let state = state.clone(); + let uid = target.uid(); + let reason = if diff.removed.iter().any(|removed| removed.uid() == uid) { + WorkerProcessError::ReloadRemoved + } else { + WorkerProcessError::ReloadChanged + }; + stopping.spawn( + async move { + let Some(pid) = state.pid_for_uid(uid).await else { + return; + }; + state.send_signal_to_user(uid, Signal::SIGTERM).await; + if !state + .wait_worker_exit(pid, std::time::Duration::from_secs(2)) + .await + { + state.force_kill_worker(pid, &reason).await; + state + .wait_worker_exit(pid, std::time::Duration::from_secs(2)) + .await; } - .in_current_span(), - ); - } - while kill_tasks.join_next().await.is_some() {} - } - - // Scrub conflicted names before forwarding reload to workers. - state.clear_listener_poison().await; - - // Phase 2: Forward SIGHUP to unchanged workers. - for target in &diff.unchanged { - state.send_signal_to_user(target.uid, Signal::SIGHUP).await; + state.cleanup_worker(pid, reason).await; + } + .in_current_span(), + ); } + while stopping.join_next().await.is_some() {} let mut missing_unchanged_workers = Vec::new(); for target in &diff.unchanged { - if state.pid_for_uid(target.uid).await.is_none() { + if state.pid_for_uid(target.uid()).await.is_none() { missing_unchanged_workers.push(target.clone()); } } - - let failed_desired_workers = state.take_failed_desired_workers().await; - - // Phase 3: Spawn added + changed workers, unchanged desired workers that - // were already cleaned up before this reload finished, and any desired - // workers parked in the failed registry from prior restartable failures. - let workers_to_spawn: Vec<_> = diff + let failed = state.take_failed_desired_workers().await; + let mut scheduled_uids = std::collections::HashSet::new(); + let to_spawn = diff .added .into_iter() - .chain(diff.changed.into_iter().map(|(_old, new)| new)) + .chain(diff.changed.into_iter().map(|(_, new)| new)) .chain(missing_unchanged_workers) - .chain(failed_desired_workers) - .collect(); - if !workers_to_spawn.is_empty() { - crate::hypervisor::process::spawn_configured_workers(state, workers_to_spawn).await; - } - - *current_worker_targets = next_snapshot.worker_targets; - tracing::info!("reload complete"); + .chain(failed) + .filter(|worker| scheduled_uids.insert(worker.uid())) + .collect::>(); + crate::hypervisor::process::spawn_configured_workers(state, to_spawn, snapshot).await; + *current_workers = next_workers; } diff --git a/pishoo/src/hypervisor/reload/snapshot.rs b/pishoo/src/hypervisor/reload/snapshot.rs index be106d30..062191e3 100644 --- a/pishoo/src/hypervisor/reload/snapshot.rs +++ b/pishoo/src/hypervisor/reload/snapshot.rs @@ -1,56 +1,7 @@ -//! Configuration reload helpers for the root process. +//! Root reload configuration phase. -use gateway::error::Whatever; -use snafu::{FromString, ResultExt}; - -/// Snapshot of root-level configuration loaded during a reload preflight. -pub struct RootReloadSnapshot { - pub entry_config: crate::config::EntryConfig, - pub worker_targets: Vec, -} - -/// Load and validate the root configuration from disk. -/// -/// Used during SIGHUP reload to preflight the new configuration before -/// applying any changes. Returns the parsed entry config and resolved -/// worker targets. pub async fn load_root_reload_snapshot( - config_source: &crate::config::PishooConfigSource, -) -> Result { - let registry = gateway::parse::default_registry(); - let config = match gateway::parse::load_config_file( - config_source.config_path(), - ®istry, - config_source.build_options(), - ) - .await - { - Ok(config) => config, - Err(failure) => { - tracing::warn!( - error = %snafu::Report::from_error(&failure.error), - diagnostic = %failure.diagnostic(), - "failed to reload configuration" - ); - return Err(Whatever::with_source( - Box::new(failure), - "failed to reload configuration".to_owned(), - )); - } - }; - let worker_mode = if config_source.default_worker_groups_enabled() { - crate::config::worker_target::WorkerDiscoveryMode::DefaultGlobalHome - } else { - crate::config::worker_target::WorkerDiscoveryMode::ExplicitConfig - }; - let entry_config = - crate::config::entry::parse_entry_config_with_mode(&config.root, worker_mode) - .whatever_context("failed to parse pishoo entry configuration")?; - let worker_targets = crate::config::resolve_entry_worker_targets(&entry_config) - .whatever_context("failed to resolve configured worker users during reload")?; - - Ok(RootReloadSnapshot { - entry_config, - worker_targets, - }) + source: &crate::config::PishooConfigSource, +) -> Result { + crate::config::load_global_pishoo_plan(source).await } diff --git a/pishoo/src/hypervisor/state.rs b/pishoo/src/hypervisor/state.rs index 0f429e39..ad1b09bf 100644 --- a/pishoo/src/hypervisor/state.rs +++ b/pishoo/src/hypervisor/state.rs @@ -80,19 +80,6 @@ pub enum ReleaseListenerError { NotOwner, } -#[derive(Debug, Snafu)] -#[snafu(module)] -pub enum RebuildListenerError { - #[snafu(display("listener is not owned by caller"))] - NotOwner, - #[snafu(display("server name conflicted"))] - ConflictedName, - #[snafu(display("failed to acquire replacement listener"))] - Replacement { source: AcquireListenerError }, - #[snafu(display("listener transition stopped before result delivery"))] - TransitionStopped, -} - #[derive(Debug, Snafu)] #[snafu(module, visibility(pub(crate)))] pub enum WorkerStartupError { @@ -171,7 +158,7 @@ pub struct WorkerFailure { } pub(super) struct FailedWorkerRecord { - pub(super) target: crate::config::ResolvedWorkerTarget, + pub(super) target: crate::config::WorkerAccount, pub(super) reason: String, } @@ -185,6 +172,11 @@ pub(super) struct WorkerProcessRecord { pub(super) tasks: TaskScope, /// Handle to the spawned worker process. pub(super) worker_handle: WorkerHandle, + /// Latest root defaults are delivered without an apply acknowledgement. + pub(super) root_defaults_tx: remoc::rch::watch::Sender< + gateway::parse::config::RootWorkerDefaultsSnapshot, + remoc::codec::Default, + >, } /// Summary produced by worker cleanup. @@ -206,7 +198,7 @@ pub(super) struct Inner { /// uid → pid mapping (one worker per uid). pub(super) users: HashMap, /// uid → desired worker target from the current root configuration. - pub(super) desired_workers: HashMap, + pub(super) desired_workers: HashMap, /// uid → failed worker target waiting for the next reload retry. pub(super) failed_workers: HashMap, /// Pending process-level failures reported by worker-scoped tasks. diff --git a/pishoo/src/hypervisor/state/listener_registry.rs b/pishoo/src/hypervisor/state/listener_registry.rs index f992b9a3..c7c705da 100644 --- a/pishoo/src/hypervisor/state/listener_registry.rs +++ b/pishoo/src/hypervisor/state/listener_registry.rs @@ -56,19 +56,6 @@ pub(super) enum ReleasePlan { Poisoned, } -#[derive(Debug)] -pub(super) enum RebuildPlan { - Rebuild { - resource: R, - guard: AsyncReleaseGuard, - done: Completion, - }, - Wait(Completion), - NotOwner, - NotFound, - Conflict, -} - #[derive(Debug)] pub(super) struct ListenerRegistry { entries: HashMap, ListenerSlot>, @@ -331,68 +318,6 @@ impl ListenerRegistry { } } - pub(super) fn plan_rebuild( - &mut self, - owner: Owner, - name: &DhttpName<'static>, - ) -> RebuildPlan { - match self.entries.remove(name) { - None => RebuildPlan::NotFound, - Some(ListenerSlot::Transition { - owner: existing_owner, - done, - }) => { - self.entries.insert( - name.clone(), - ListenerSlot::Transition { - owner: existing_owner, - done: done.clone(), - }, - ); - RebuildPlan::Wait(done) - } - Some(ListenerSlot::Active { - owner: existing_owner, - resource, - guard, - }) if existing_owner == owner => { - let done = Completion::new(); - self.entries.insert( - name.clone(), - ListenerSlot::Transition { - owner: existing_owner, - done: done.clone(), - }, - ); - RebuildPlan::Rebuild { - resource, - guard, - done, - } - } - Some(ListenerSlot::Active { - owner: existing_owner, - resource, - guard, - }) => { - self.entries.insert( - name.clone(), - ListenerSlot::Active { - owner: existing_owner, - resource, - guard, - }, - ); - RebuildPlan::NotOwner - } - Some(ListenerSlot::Poisoned { reason }) => { - self.entries - .insert(name.clone(), ListenerSlot::Poisoned { reason }); - RebuildPlan::Conflict - } - } - } - pub(super) fn clear_poisoned(&mut self) -> Vec> { let poisoned = self .entries diff --git a/pishoo/src/hypervisor/state/process_ops.rs b/pishoo/src/hypervisor/state/process_ops.rs index fa2e261f..586f9d3e 100644 --- a/pishoo/src/hypervisor/state/process_ops.rs +++ b/pishoo/src/hypervisor/state/process_ops.rs @@ -64,12 +64,16 @@ impl RootState { /// /// If another worker already holds the same UID, the old one is cleaned /// up first (uid-replaced). - pub async fn register_worker( + pub async fn register_worker_with_defaults( self: &Arc, pid: Pid, uid: Uid, username: String, worker_handle: WorkerHandle, + root_defaults_tx: remoc::rch::watch::Sender< + gateway::parse::config::RootWorkerDefaultsSnapshot, + remoc::codec::Default, + >, ) { let replaced_pid = { let inner = self.inner.lock().await; @@ -94,12 +98,47 @@ impl RootState { username: username.clone(), tasks: TaskScope::new(), worker_handle, + root_defaults_tx, }, ); inner.users.insert(uid, pid); tracing::debug!(pid = %pid, uid = uid.as_raw(), %username, "registered worker"); } + pub async fn dispatch_worker_defaults( + &self, + snapshot: gateway::parse::config::RootWorkerDefaultsSnapshot, + ) { + let inner = self.inner.lock().await; + for (pid, record) in &inner.processes { + if let Err(error) = record.root_defaults_tx.send(snapshot.clone()) { + tracing::warn!(pid = %pid, error = %error, "failed to dispatch worker defaults snapshot"); + } + } + } + + #[cfg(test)] + pub async fn register_worker( + self: &Arc, + pid: Pid, + uid: Uid, + username: String, + worker_handle: WorkerHandle, + ) { + let defaults = gateway::parse::TypedConfigParser::new() + .parse_root( + "pishoo {}", + std::path::Path::new("/tmp/test-root.conf"), + None, + ) + .unwrap() + .pishoo() + .worker_defaults(); + let (root_defaults_tx, _root_defaults_rx) = remoc::rch::watch::channel(defaults); + self.register_worker_with_defaults(pid, uid, username, worker_handle, root_defaults_tx) + .await; + } + /// Check whether a worker with the given PID is registered. pub async fn has_worker(&self, pid: Pid) -> bool { self.inner.lock().await.processes.contains_key(&pid) @@ -393,11 +432,11 @@ impl RootState { } } - pub async fn set_desired_workers(&self, targets: Vec) { + pub async fn set_desired_workers(&self, targets: Vec) { let mut inner = self.inner.lock().await; let desired: std::collections::HashMap<_, _> = targets .into_iter() - .map(|target| (target.uid, target)) + .map(|target| (target.uid(), target)) .collect(); inner .failed_workers @@ -408,7 +447,7 @@ impl RootState { /// Drain the set of failed-but-still-desired workers so the reload /// orchestrator can retry spawning them. After draining, the registry /// is empty until the next worker failure parks another entry. - pub async fn take_failed_desired_workers(&self) -> Vec { + pub async fn take_failed_desired_workers(&self) -> Vec { let mut inner = self.inner.lock().await; inner .failed_workers @@ -416,7 +455,7 @@ impl RootState { .map(|(uid, record)| { tracing::info!( uid = uid.as_raw(), - user = %record.target.name, + user = %record.target.name(), reason = %record.reason, "retrying failed worker on reload" ); @@ -429,10 +468,7 @@ impl RootState { self.inner.lock().await.desired_workers.clear(); } - pub async fn desired_worker_target( - &self, - uid: Uid, - ) -> Option { + pub async fn desired_worker_target(&self, uid: Uid) -> Option { self.inner.lock().await.desired_workers.get(&uid).cloned() } } diff --git a/pishoo/src/hypervisor/state/server_ops.rs b/pishoo/src/hypervisor/state/server_ops.rs index 2b2e9e6e..66365f30 100644 --- a/pishoo/src/hypervisor/state/server_ops.rs +++ b/pishoo/src/hypervisor/state/server_ops.rs @@ -10,11 +10,10 @@ use tokio_util::sync::CancellationToken; use tracing::Instrument; use super::{ - AcquireListenerError, ListenerResource, RebuildListenerError, ReleaseListenerError, RootState, + AcquireListenerError, ListenerResource, ReleaseListenerError, RootState, acquire_listener_error, - listener_registry::{AcquirePlan, RebuildPlan, ReleasePlan}, + listener_registry::{AcquirePlan, ReleasePlan}, owner::Owner, - rebuild_listener_error, }; use crate::{ hypervisor::{endpoint_factory, resource::AsyncReleaseGuard}, @@ -41,7 +40,6 @@ struct BuiltListener { } type AcquireListenerSender = oneshot::Sender>; -type RebuildListenerSender = oneshot::Sender>; struct AcquireListenerTransition { owner: Owner, @@ -52,16 +50,6 @@ struct AcquireListenerTransition { tx: AcquireListenerSender, } -struct RebuildListenerTransition { - owner: Owner, - server_name: DhttpName<'static>, - request: ListenRequest, - bind_patterns: Arc>, - old_resource: ListenerResource, - done: crate::hypervisor::resource::Completion, - tx: RebuildListenerSender, -} - impl RootState { // ----------------------------------------------------------------------- // Listener registry @@ -209,65 +197,6 @@ impl RootState { } } - /// Rebuild an owned listener without exposing a vacant interleaving window. - pub async fn rebuild_listener( - self: &Arc, - owner: Owner, - request: ListenRequest, - ) -> Result { - let server_name = self.listener_name(&request); - let bind_patterns = request - .bind - .iter() - .map(gateway::parse::types::Listens::try_to_bind_patterns) - .collect::, _>>() - .context(acquire_listener_error::BuildBindPatternsSnafu) - .context(rebuild_listener_error::ReplacementSnafu)? - .into_iter() - .flatten() - .collect::>(); - let bind_patterns = Arc::new(bind_patterns); - - loop { - let plan = { - let mut registry = self.listeners.write().await; - registry.plan_rebuild(owner, &server_name) - }; - - match plan { - RebuildPlan::Rebuild { - resource, - guard, - done, - } => { - guard.disarm(); - let (tx, rx) = oneshot::channel(); - self.spawn_rebuild_listener_transition(RebuildListenerTransition { - owner, - server_name, - request, - bind_patterns, - old_resource: resource, - done, - tx, - }); - return rx - .await - .unwrap_or(Err(RebuildListenerError::TransitionStopped)); - } - RebuildPlan::Wait(done) => done.wait().await, - RebuildPlan::NotOwner => return Err(RebuildListenerError::NotOwner), - RebuildPlan::NotFound => { - return self - .acquire_listener(owner, request) - .await - .context(rebuild_listener_error::ReplacementSnafu); - } - RebuildPlan::Conflict => return Err(RebuildListenerError::ConflictedName), - } - } - } - /// Remove all poisoned listener entries from the registry. pub async fn clear_listener_poison(&self) -> Vec> { let mut registry = self.listeners.write().await; @@ -388,57 +317,6 @@ impl RootState { ); } - fn spawn_rebuild_listener_transition(self: &Arc, transition: RebuildListenerTransition) { - let state = self.clone(); - self.spawn_resource_transition( - async move { - state.run_rebuild_listener_transition(transition).await; - } - .in_current_span(), - ); - } - - async fn run_rebuild_listener_transition( - self: Arc, - transition: RebuildListenerTransition, - ) { - self.destroy_listener_resource(transition.old_resource) - .await; - - let built = self - .build_listener_resource( - transition.owner, - &transition.server_name, - &transition.request, - transition.bind_patterns, - ) - .await; - match built { - Ok(built) => { - self.pause_listener_delivery_for_test().await; - self.commit_and_deliver_rebuilt_listener( - transition.owner, - transition.server_name, - transition.done, - built, - transition.tx, - ) - .await; - } - Err(error) => { - self.finish_listener_transition_vacant( - transition.owner, - &transition.server_name, - &transition.done, - ) - .await; - let _ = transition - .tx - .send(Err(RebuildListenerError::Replacement { source: error })); - } - } - } - async fn commit_and_deliver_acquired_listener( &self, owner: Owner, @@ -475,42 +353,6 @@ impl RootState { } } - async fn commit_and_deliver_rebuilt_listener( - &self, - owner: Owner, - server_name: DhttpName<'static>, - done: crate::hypervisor::resource::Completion, - built: BuiltListener, - tx: oneshot::Sender>, - ) { - let BuiltListener { - resource, - registered, - guard, - } = built; - match self - .commit_listener_transition_active( - owner, - server_name.clone(), - &done, - resource, - guard.clone(), - ) - .await - { - Ok(()) => { - self.deliver_rebuilt_listener(server_name, registered, tx) - .await; - } - Err(resource) => { - done.complete(); - self.destroy_uncommitted_listener(resource, registered, guard) - .await; - let _ = tx.send(Err(RebuildListenerError::ConflictedName)); - } - } - } - async fn commit_listener_transition_active( &self, owner: Owner, @@ -539,22 +381,6 @@ impl RootState { } } - async fn deliver_rebuilt_listener( - &self, - server_name: DhttpName<'static>, - registered: RegisteredEndpoint, - tx: oneshot::Sender>, - ) { - match tx.send(Ok(registered)) { - Ok(()) => {} - Err(Ok(registered)) => { - self.shutdown_undelivered_listener(server_name, registered) - .await; - } - Err(Err(_)) => {} - } - } - async fn shutdown_undelivered_listener( &self, server_name: DhttpName<'static>, diff --git a/pishoo/src/hypervisor/state/tests.rs b/pishoo/src/hypervisor/state/tests.rs index 47ad131f..8039b8f5 100644 --- a/pishoo/src/hypervisor/state/tests.rs +++ b/pishoo/src/hypervisor/state/tests.rs @@ -1,9 +1,6 @@ -use std::{ - ffi::CString, - sync::{ - Arc, - atomic::{AtomicBool, Ordering}, - }, +use std::sync::{ + Arc, + atomic::{AtomicBool, Ordering}, }; use dhttp::{ @@ -18,7 +15,7 @@ use gateway::{ control_plane::ListenRequest, parse::types::{IfaceRange, IpFamilies, Listens}, }; -use nix::unistd::{Pid, Uid}; +use nix::unistd::{Gid, Pid, Uid}; use super::{owner::Owner, *}; use crate::hypervisor::worker_handle::WorkerHandle; @@ -404,7 +401,7 @@ async fn test_cancelled_acquire_listener_destroys_built_endpoint() { } #[tokio::test] -async fn test_stale_registered_endpoint_drop_after_rebuild_does_not_release_replacement() { +async fn closed_listener_can_be_reacquired_by_the_same_owner() { let state = test_state(); let owner = Owner::Local; let server_name = "stale-rebuild.user.dhttp.net"; @@ -415,17 +412,20 @@ async fn test_stale_registered_endpoint_drop_after_rebuild_does_not_release_repl .expect("initial acquire should succeed"); assert!(is_active(&state, server_name).await); + dhttp::h3x::quic::Listen::shutdown(&old_listener) + .await + .unwrap(); let _replacement = state - .rebuild_listener(owner, test_request("stale-rebuild")) + .acquire_listener(owner, test_request("stale-rebuild")) .await - .expect("rebuild should succeed"); + .expect("reacquire should succeed after normal shutdown"); drop(old_listener); tokio::time::sleep(std::time::Duration::from_millis(50)).await; assert!( is_active(&state, server_name).await, - "dropping the consumed old handle must not release the replacement" + "dropping the stale old handle must not release the replacement" ); state @@ -705,15 +705,14 @@ async fn test_fail_worker_queues_typed_error_without_cleanup() { #[tokio::test] async fn test_desired_worker_targets_are_uid_keyed() { let state = test_state(); - let target = nix::unistd::User { - name: "restart-user".to_owned(), - passwd: CString::new("x").unwrap(), - uid: Uid::from_raw(7001), - gid: nix::unistd::Gid::from_raw(7001), - gecos: CString::new("").unwrap(), - dir: std::path::PathBuf::from("/home/restart-user"), - shell: std::path::PathBuf::from("/bin/sh"), - }; + let target = crate::config::WorkerAccount::new( + "restart-user".to_owned(), + Uid::from_raw(7001), + Gid::from_raw(7001), + std::path::PathBuf::from("/home/restart-user"), + dhttp::home::DhttpHome::for_user_home_dir("/home/restart-user"), + ) + .unwrap(); state.set_desired_workers(vec![target.clone()]).await; @@ -721,8 +720,8 @@ async fn test_desired_worker_targets_are_uid_keyed() { state .desired_worker_target(Uid::from_raw(7001)) .await - .map(|worker| worker.name), - Some(target.name) + .map(|worker| worker.name().to_owned()), + Some(target.name().to_owned()) ); assert!( state diff --git a/pishoo/src/ipc.rs b/pishoo/src/ipc.rs index 729fb254..8248992e 100644 --- a/pishoo/src/ipc.rs +++ b/pishoo/src/ipc.rs @@ -6,8 +6,6 @@ //! - [`WorkerBootstrap`] / [`WorkerHello`]: one-shot bootstrap handshake. //! - Error types for control plane operations. -use std::path::PathBuf; - use dhttp::h3x::ipc::quic::{IpcConnectClient, IpcListenClient}; use gateway::control_plane::{ConnectorRequest, ListenRequest}; use serde::{Deserialize, Serialize}; @@ -20,9 +18,12 @@ use snafu::Snafu; /// Sent from root to worker immediately after establishing the remoc channel. #[derive(Debug, Serialize, Deserialize)] pub struct WorkerBootstrap { - pub uid: u32, - pub username: String, - pub home: PathBuf, + pub account: crate::config::WorkerAccount, + pub root_defaults: gateway::parse::config::RootWorkerDefaultsSnapshot, + pub root_defaults_rx: remoc::rch::watch::Receiver< + gateway::parse::config::RootWorkerDefaultsSnapshot, + remoc::codec::Default, + >, /// RPC client for calling the root control plane. pub control_plane: ControlPlaneClient, } @@ -55,27 +56,6 @@ pub enum ListenError { Call { source: remoc::rtc::CallError }, } -/// Error returned by [`ControlPlane::rebuild_listener`]. -/// -/// Rebuild atomically replaces an owned listener so the server name is never -/// momentarily vacant during reload. -#[derive(Debug, Clone, Serialize, Deserialize, Snafu)] -#[snafu(module)] -pub enum RebuildListenError { - #[snafu(display("listener is not owned by this worker"))] - NotOwner, - #[snafu(display("server name conflicts with an existing listener"))] - Conflict, - #[snafu(display("replacement listener failed after old listener was destroyed: {reason}"))] - Replacement { reason: String }, - #[snafu(display("invalid rebuild request: {reason}"))] - InvalidRequest { reason: String }, - #[snafu(display("internal error: {message}"))] - Internal { message: String }, - #[snafu(transparent)] - Call { source: remoc::rtc::CallError }, -} - /// Error returned by [`ControlPlane::connect`]. #[derive(Debug, Clone, Serialize, Deserialize, Snafu)] #[snafu(module)] @@ -119,14 +99,6 @@ pub trait ControlPlane: Send + Sync { /// [`IpcListener`](dhttp::h3x::ipc::capability::listener::IpcListener) from. async fn listener(&self, request: ListenRequest) -> Result; - /// Atomically replace a previously acquired listener with one matching the - /// new request. The previous listener is destroyed by root as part of the - /// same critical section, so the server name is never observed vacant. - async fn rebuild_listener( - &self, - request: ListenRequest, - ) -> Result; - /// Request an outbound QUIC connector. /// /// Root creates the connector, wraps it in an IPC `ConnectAdapter`, and @@ -142,3 +114,30 @@ pub trait ControlPlane: Send + Sync { /// the local mux writer FIFO. async fn spawn_session(&self, username: String, fd_id: u64) -> Result; } + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + fn snapshot(label: &str) -> gateway::parse::config::RootWorkerDefaultsSnapshot { + let path = PathBuf::from(format!("/tmp/{label}.conf")); + gateway::parse::TypedConfigParser::new() + .parse_root("pishoo { gzip on; }", &path, None) + .unwrap() + .pishoo() + .worker_defaults() + } + + #[tokio::test] + async fn root_defaults_watch_coalesces_to_latest_snapshot() { + let (sender, mut receiver) = + remoc::rch::watch::channel::<_, remoc::codec::Default>(snapshot("root-a")); + sender.send(snapshot("root-b")).unwrap(); + sender.send(snapshot("root-c")).unwrap(); + + receiver.changed().await.unwrap(); + let actual = receiver.borrow_and_update().unwrap().clone(); + + assert_eq!(actual, snapshot("root-c")); + } +} diff --git a/pishoo/src/main.rs b/pishoo/src/main.rs index 0eef2ba0..b3e4f0f9 100644 --- a/pishoo/src/main.rs +++ b/pishoo/src/main.rs @@ -41,58 +41,41 @@ async fn main() -> Result<(), Whatever> { .whatever_context("failed to resolve pishoo configuration source")?; let config_file = config_source.config_path().to_path_buf(); - let registry = gateway::parse::default_registry(); - let config = match gateway::parse::load_config_file( - &config_file, - ®istry, - config_source.build_options(), - ) - .await - { - Ok(config) => config, - Err(failure) => { + let initial_plan = pishoo::config::load_global_pishoo_plan(&config_source).await; + if args.test_config || args.signal.is_some() { + let plan = initial_plan.map_err(|error| { tracing::error!( - error = %snafu::Report::from_error(&failure.error), - diagnostic = %failure.diagnostic(), + error = %snafu::Report::from_error(&error), "failed to load configuration" ); - return Err(Whatever::with_source( - Box::new(failure), - "failed to load configuration".to_owned(), - )); + Whatever::with_source(Box::new(error), "failed to load configuration".to_owned()) + })?; + let pid_file = pishoo::config::pid_path(plan.pishoo()); + if args.test_config { + tracing::info!(path = %config_file.display(), "configuration parsed successfully"); + return Ok(()); } - }; - - let worker_mode = if config_source.default_worker_groups_enabled() { - pishoo::config::worker_target::WorkerDiscoveryMode::DefaultGlobalHome - } else { - pishoo::config::worker_target::WorkerDiscoveryMode::ExplicitConfig - }; - let entry_config = - pishoo::config::entry::parse_entry_config_with_mode(&config.root, worker_mode) - .whatever_context("failed to parse pishoo entry configuration")?; - - if args.test_config { - tracing::info!( - path = %config_file.display(), - "configuration parsed successfully" - ); - return Ok(()); + return signal::send_signal(&pid_file, args.signal.expect("signal checked above")).await; } - let pid_file = entry_config.pid_file.clone(); - - if let Some(signal) = args.signal { - return signal::send_signal(&pid_file, signal).await; - } - - let current_entry_config = entry_config; - let mut current_worker_targets = - pishoo::config::resolve_entry_worker_targets(¤t_entry_config) - .whatever_context("failed to resolve configured worker users")?; + let plan = match initial_plan { + Ok(plan) => Some(plan), + Err(error) => { + tracing::error!( + error = %snafu::Report::from_error(&error), + "global pishoo configuration failed; starting in config-failed state" + ); + None + } + }; + let pid_file = plan.as_ref().map_or_else( + || PathBuf::from(pishoo::config::PID_FILE_DEFAULT), + |plan| pishoo::config::pid_path(plan.pishoo()), + ); + let mut current_worker_targets = Vec::new(); tracing::info!( - pid_file = %current_entry_config.pid_file.display(), + pid_file = %pid_file.display(), "pishoo starting" ); @@ -111,20 +94,21 @@ async fn main() -> Result<(), Whatever> { // Write PID file (root only) signal::init_pid_file(&pid_file).await?; - let mut global_service_handle = pishoo::hypervisor::global_service::spawn_global_service( - &state, - &config_source, - ¤t_entry_config, - ) - .await - .whatever_context("failed to spawn global service")?; - drop(current_entry_config); - - state - .set_desired_workers(current_worker_targets.clone()) - .await; - pishoo::hypervisor::process::spawn_configured_workers(&state, current_worker_targets.clone()) - .await; + let mut global_service_handle = None; + if let Some(plan) = plan { + current_worker_targets = plan.desired_workers().to_vec(); + state + .set_desired_workers(current_worker_targets.clone()) + .await; + let global_branch = pishoo::hypervisor::global_service::spawn_global_service(&state, &plan); + let worker_branch = pishoo::hypervisor::process::spawn_configured_workers( + &state, + current_worker_targets.clone(), + plan.worker_defaults().clone(), + ); + let (service, ()) = tokio::join!(global_branch, worker_branch); + global_service_handle = service; + } // Create signal handler once — reused across the main loop so that signals // arriving during reload are never lost. @@ -140,7 +124,20 @@ async fn main() -> Result<(), Whatever> { tracing::info!("pishoo ready"); loop { - let sig = signals.wait().await; + let sig = tokio::select! { + sig = signals.wait() => sig, + name = pishoo::hypervisor::global_service::wait_global_service_completion( + &mut global_service_handle, + ) => { + global_service_handle + .as_mut() + .expect("service completion requires a global service handle") + .handle_service_exit(name) + .await; + tracing::warn!("global server service exited; released its resources"); + continue; + } + }; match sig { signal::RootSignal::SigTerm diff --git a/pishoo/src/naming.rs b/pishoo/src/naming.rs index 53b66dda..24505a28 100644 --- a/pishoo/src/naming.rs +++ b/pishoo/src/naming.rs @@ -1,10 +1,5 @@ -use std::sync::Arc; - use dhttp::name::DhttpName; -use gateway::{ - error::Whatever, - parse::{document::ConfigNode, types::ServerName}, -}; +use gateway::{error::Whatever, parse::types::ServerName}; use snafu::ResultExt; pub fn canonicalize_genmeta_name(name: &str) -> Result, Whatever> { @@ -22,12 +17,6 @@ pub fn canonicalize_server_names(server_names: &[ServerName]) -> Result], -) -> Result>, Whatever> { - Ok(servers.to_vec()) -} - #[cfg(test)] mod tests { use super::*; diff --git a/pishoo/src/service.rs b/pishoo/src/service.rs index d2d2ec0e..ce5c3dff 100644 --- a/pishoo/src/service.rs +++ b/pishoo/src/service.rs @@ -9,6 +9,8 @@ //! - [`source`]: [`source::ServerSource`] enum and per-variant loaders. pub mod accept; +pub mod resource; pub mod runtime; +pub mod set; pub mod snapshot; pub mod source; diff --git a/pishoo/src/service/accept.rs b/pishoo/src/service/accept.rs index 0c82306a..864c60c0 100644 --- a/pishoo/src/service/accept.rs +++ b/pishoo/src/service/accept.rs @@ -1,10 +1,10 @@ -use std::{future::Future, pin::Pin}; +use std::time::Duration; -use snafu::{ResultExt, Snafu}; -use tokio::sync::oneshot; use tokio_util::{sync::CancellationToken, task::AbortOnDropHandle}; use tracing::Instrument; +pub const SERVICE_DRAIN_TIMEOUT: Duration = Duration::from_secs(5); + pub(crate) trait AcceptDriver: Send + Sync + 'static { fn drive( self: std::sync::Arc, @@ -13,272 +13,122 @@ pub(crate) trait AcceptDriver: Send + Sync + 'static { ) -> impl std::future::Future + Send; } -pub enum AcceptState { - Running { - shutdown: CancellationToken, - task: AbortOnDropHandle, - }, - Stopping { - receiver: oneshot::Receiver>, - task: AbortOnDropHandle<()>, - }, - Stopped { - listener: L, - }, - Taken, +pub enum DrainOutcome { + Returned(L), + Aborted, } -#[derive(Debug, Snafu)] -#[snafu(module)] -pub enum StopAcceptError { - #[snafu(display("accept task panicked or was cancelled"))] - Join { source: tokio::task::JoinError }, - #[snafu(display("accept stop transition ended before returning listener"))] - StopTaskLost { source: oneshot::error::RecvError }, - #[snafu(display("accept listener is not available"))] - Taken, +pub struct AcceptState { + shutdown: CancellationToken, + task: AbortOnDropHandle, } impl AcceptState where L: Send + 'static, { - pub(crate) fn start(listener: L, driver: std::sync::Arc) -> Self + pub(crate) fn start(listener: L, driver: std::sync::Arc, completed: C) -> Self where D: AcceptDriver, + C: FnOnce() + Send + 'static, { let shutdown = CancellationToken::new(); let task_shutdown = shutdown.clone(); let task = AbortOnDropHandle::new(tokio::spawn( - async move { driver.drive(listener, task_shutdown).await }.in_current_span(), + async move { + let listener = driver.drive(listener, task_shutdown).await; + completed(); + listener + } + .in_current_span(), )); - Self::Running { shutdown, task } + Self { shutdown, task } } - pub async fn stop(&mut self) -> Result<&mut L, StopAcceptError> { - loop { - match self { - Self::Running { .. } => { - let prev = std::mem::replace(self, Self::Taken); - let Self::Running { shutdown, task } = prev else { - unreachable!("matched running state") - }; - shutdown.cancel(); - let (tx, receiver) = oneshot::channel(); - let stop_task = AbortOnDropHandle::new(tokio::spawn( - async move { - let result = task.await; - let _ = tx.send(result); - } - .in_current_span(), - )); - *self = Self::Stopping { - receiver, - task: stop_task, - }; - } - Self::Stopping { receiver, .. } => { - let result = std::future::poll_fn(|cx| Pin::new(&mut *receiver).poll(cx)).await; - match result.context(stop_accept_error::StopTaskLostSnafu)? { - Ok(listener) => { - let prev = std::mem::replace(self, Self::Stopped { listener }); - drop(prev); - } - Err(source) => { - *self = Self::Taken; - return Err(StopAcceptError::Join { source }); - } - } - } - Self::Stopped { listener } => return Ok(listener), - Self::Taken => return Err(StopAcceptError::Taken), + pub async fn drain(mut self) -> DrainOutcome { + self.shutdown.cancel(); + match tokio::time::timeout(SERVICE_DRAIN_TIMEOUT, &mut self.task).await { + Ok(Ok(listener)) => DrainOutcome::Returned(listener), + Ok(Err(error)) => { + tracing::warn!(error = %error, "server service task ended without returning its listener"); + DrainOutcome::Aborted + } + Err(_) => { + self.task.abort(); + let _ = (&mut self.task).await; + tracing::warn!( + timeout_seconds = SERVICE_DRAIN_TIMEOUT.as_secs(), + "server service drain timed out; task aborted" + ); + DrainOutcome::Aborted } } } - pub async fn into_listener(mut self) -> Result { - self.take_listener().await - } - - pub async fn take_listener(&mut self) -> Result { - self.stop().await?; - let prev = std::mem::replace(self, Self::Taken); - match self { - Self::Taken => match prev { - Self::Stopped { listener } => Ok(listener), - _ => unreachable!("stop leaves Stopped before take"), - }, - _ => unreachable!("take leaves Taken"), - } + pub fn is_finished(&self) -> bool { + self.task.is_finished() } } #[cfg(test)] mod tests { - use std::{ - sync::{ - Arc, - atomic::{AtomicBool, Ordering}, - }, - time::Duration, + use std::sync::{ + Arc, + atomic::{AtomicBool, Ordering}, }; - use tokio::time::timeout; - use super::*; - struct FakeDriver { - entered: AtomicBool, - } - - struct FakeListener { - id: usize, - } - - struct PendingDriver; - - struct PausedStopDriver { - stop_started: tokio::sync::Notify, - resume: tokio::sync::Notify, - } - - struct DropSignalListener { - dropped: Option>, - } - - impl AcceptDriver for FakeDriver { - async fn drive( - self: Arc, - listener: FakeListener, - shutdown: CancellationToken, - ) -> FakeListener { - self.entered.store(true, Ordering::SeqCst); + struct ReturningDriver; + impl AcceptDriver for ReturningDriver { + async fn drive(self: Arc, listener: usize, shutdown: CancellationToken) -> usize { shutdown.cancelled().await; listener } } - impl AcceptDriver for PendingDriver { - async fn drive( - self: Arc, - listener: DropSignalListener, - _shutdown: CancellationToken, - ) -> DropSignalListener { - std::future::pending::<()>().await; - listener + struct PendingDriver { + dropped: Arc, + } + struct DropListener(Arc); + impl Drop for DropListener { + fn drop(&mut self) { + self.0.store(true, Ordering::SeqCst); } } - - impl AcceptDriver for PausedStopDriver { + impl AcceptDriver for PendingDriver { async fn drive( self: Arc, - listener: FakeListener, - shutdown: CancellationToken, - ) -> FakeListener { - shutdown.cancelled().await; - self.stop_started.notify_waiters(); - self.resume.notified().await; - listener - } - } - - impl Drop for DropSignalListener { - fn drop(&mut self) { - if let Some(dropped) = self.dropped.take() { - let _ = dropped.send(()); - } + listener: DropListener, + _shutdown: CancellationToken, + ) -> DropListener { + let _ = &self.dropped; + let _listener = listener; + std::future::pending().await } } #[tokio::test] - async fn stop_returns_listener_after_accept_task_shutdown() { - let driver = Arc::new(FakeDriver { - entered: AtomicBool::new(false), - }); - let mut state = AcceptState::start(FakeListener { id: 7 }, driver.clone()); - - // Yield once so the freshly-spawned task can be polled to its first - // await point; otherwise `entered` may still read false on a - // single-threaded runtime. - tokio::task::yield_now().await; - assert!(driver.entered.load(Ordering::SeqCst)); - - let listener = timeout(Duration::from_secs(1), state.stop()) - .await - .expect("stop should complete within timeout") - .expect("stop should return listener"); - - assert_eq!(listener.id, 7); - } - - #[tokio::test] - async fn into_listener_consumes_state() { - let driver = Arc::new(FakeDriver { - entered: AtomicBool::new(false), - }); - let state = AcceptState::start(FakeListener { id: 42 }, driver); - - let listener = timeout(Duration::from_secs(1), state.into_listener()) - .await - .expect("into_listener should complete within timeout") - .expect("into_listener should return listener"); - - assert_eq!(listener.id, 42); - } - - #[tokio::test] - async fn cancelled_stop_does_not_abort_accept_task() { - let driver = Arc::new(PausedStopDriver { - stop_started: tokio::sync::Notify::new(), - resume: tokio::sync::Notify::new(), - }); - let mut state = AcceptState::start(FakeListener { id: 9 }, driver.clone()); - - let mut stop = Box::pin(state.stop()); - tokio::select! { - () = driver.stop_started.notified() => {} - _ = &mut stop => panic!("stop completed before pause"), - } - drop(stop); - - driver.resume.notify_waiters(); - - let listener = timeout(Duration::from_secs(1), state.stop()) - .await - .expect("second stop should complete") - .expect("second stop should return listener"); - assert_eq!(listener.id, 9); + async fn graceful_drain_returns_listener() { + let state = AcceptState::start(7, Arc::new(ReturningDriver), || {}); + assert!(matches!(state.drain().await, DrainOutcome::Returned(7))); } - #[tokio::test] - async fn dropping_running_state_aborts_accept_task() { - let driver = Arc::new(PendingDriver); - let (dropped_tx, dropped_rx) = tokio::sync::oneshot::channel(); + #[tokio::test(start_paused = true)] + async fn drain_aborts_after_exactly_five_seconds() { + let dropped = Arc::new(AtomicBool::new(false)); let state = AcceptState::start( - DropSignalListener { - dropped: Some(dropped_tx), - }, - driver, + DropListener(dropped.clone()), + Arc::new(PendingDriver { + dropped: dropped.clone(), + }), + || {}, ); - - drop(state); - - timeout(Duration::from_secs(1), dropped_rx) - .await - .expect("listener should be dropped after abort") - .expect("drop signal sender should fire"); - } - - #[tokio::test] - async fn second_stop_after_completion_is_noop() { - let driver = Arc::new(FakeDriver { - entered: AtomicBool::new(false), - }); - let mut state = AcceptState::start(FakeListener { id: 1 }, driver); - - let first = state.stop().await.expect("first stop").id; - let second = state.stop().await.expect("second stop").id; - assert_eq!(first, 1); - assert_eq!(second, 1); + let drain = tokio::spawn(state.drain()); + tokio::time::advance(Duration::from_secs(4)).await; + assert!(!drain.is_finished()); + tokio::time::advance(Duration::from_secs(1)).await; + assert!(matches!(drain.await.unwrap(), DrainOutcome::Aborted)); + assert!(dropped.load(Ordering::SeqCst)); } } diff --git a/pishoo/src/service/resource.rs b/pishoo/src/service/resource.rs new file mode 100644 index 00000000..dbf59f68 --- /dev/null +++ b/pishoo/src/service/resource.rs @@ -0,0 +1,58 @@ +use std::sync::Arc; + +use gateway::{parse::config::ResolvedAccessLogConfig, reverse::log::AccessLogOutput}; + +use super::source::ListenerSpec; + +#[derive(Clone, Debug)] +pub struct AccessLogResourcePlan { + pub server: ResolvedAccessLogConfig, + pub locations: Box<[ResolvedAccessLogConfig]>, +} + +#[derive(Clone, Debug)] +pub struct AccessLogResources { + pub server: Option>, + pub locations: Box<[Option>]>, +} + +pub struct ServerResources { + listener: Option, + listener_spec: ListenerSpec, + access_logs: AccessLogResources, +} + +impl ServerResources { + pub fn new(listener: L, listener_spec: ListenerSpec, access_logs: AccessLogResources) -> Self { + Self { + listener: Some(listener), + listener_spec, + access_logs, + } + } + + pub fn listener_spec(&self) -> &ListenerSpec { + &self.listener_spec + } + + pub fn take_listener(&mut self) -> L { + self.listener + .take() + .expect("a stopped server owns its listener") + } + + pub fn put_listener(&mut self, listener: L) { + assert!( + self.listener.replace(listener).is_none(), + "listener returned exactly once" + ); + } + + pub fn replace_access_logs(&mut self, access_logs: AccessLogResources) { + self.access_logs = access_logs; + } + + pub fn access_logs(&self) -> &AccessLogResources { + &self.access_logs + } +} diff --git a/pishoo/src/service/runtime.rs b/pishoo/src/service/runtime.rs index e974b174..033c7219 100644 --- a/pishoo/src/service/runtime.rs +++ b/pishoo/src/service/runtime.rs @@ -1,303 +1,196 @@ -use std::{collections::HashMap, sync::Arc}; +use std::{collections::HashSet, sync::Arc}; -use dhttp::name::DhttpName; +use dhttp::{h3x::quic::Listen as _, name::DhttpName}; use gateway::control_plane::{ControlPlane, ProvideListener}; -use snafu::Report; +use snafu::{Report, ResultExt}; use super::{ - accept::AcceptState, - snapshot::ServerService, - source::{ListenerSpec, PrepareContext, PreparedServerUpdate, ServerFingerprint, ServerSource}, + accept::DrainOutcome, + resource::ServerResources, + set::{ResourceSet, ServerServiceHandle, ServiceSet}, + source::{PrepareContext, ServerSource}, }; -pub struct ServerRuntime

+pub struct RuntimeRegistry

where P: ProvideListener, { - name: DhttpName<'static>, - source: ServerSource, - listener_spec: ListenerSpec, - service: Arc, - accept: AcceptState, - fingerprint: ServerFingerprint, plane: Arc

, + resources: ResourceSet, + services: ServiceSet, } -pub enum ReloadServerOutcome { - KeptOldOnPrepareFailure, - SkippedAcceptNotRunning, - SwappedServiceOnReusedListener, - RebuiltListener, - StoppedAfterFatalRebuild, -} - -impl

ServerRuntime

+impl

RuntimeRegistry

where P: ProvideListener + Send + Sync + 'static, P::Listener: dhttp::h3x::quic::Listen + Send + 'static, ::Error: std::error::Error + Send + Sync + 'static, ::Connection: Send + 'static, - <::Connection as dhttp::h3x::quic::WithLocalAuthority>::LocalAuthority: - Send + Sync, - <::Connection as dhttp::h3x::quic::WithRemoteAuthority>::RemoteAuthority: - Send + Sync, + <::Connection as dhttp::h3x::quic::WithLocalAuthority>::LocalAuthority: Send + Sync, + <::Connection as dhttp::h3x::quic::WithRemoteAuthority>::RemoteAuthority: Send + Sync, { - pub fn start( - source: ServerSource, - prepared: PreparedServerUpdate, - plane: Arc

, - listener: P::Listener, - ) -> Self { - Self { - name: prepared.name, - source, - listener_spec: prepared.listener_spec, - service: prepared.service.clone(), - accept: AcceptState::start(listener, prepared.service), - fingerprint: prepared.fingerprint, - plane, - } + pub fn new(plane: Arc

) -> Self { + Self { plane, resources: ResourceSet::default(), services: ServiceSet::default() } } - pub fn name(&self) -> &DhttpName<'static> { - &self.name + pub async fn apply_sources(&mut self, sources: Vec, ctx: &PrepareContext) { + self.stop_finished_services().await; + let desired = sources.iter().map(|source| source.name().clone()).collect::>(); + let removed = self.resources.servers.keys().filter(|name| !desired.contains(*name)).cloned().collect::>(); + for name in removed { self.stop_server(&name).await; } + for source in sources { self.reconcile_server(source, ctx).await; } } - pub fn fingerprint(&self) -> &ServerFingerprint { - &self.fingerprint - } + async fn reconcile_server(&mut self, source: ServerSource, ctx: &PrepareContext) { + let name = source.name().clone(); + self.stop_service(&name).await; - pub async fn reload( - &mut self, - new_source: ServerSource, - ctx: &PrepareContext, - ) -> ReloadServerOutcome { - let prepared = match new_source.prepare(ctx).await { + let prepared = match source.prepare(ctx).await { Ok(prepared) => prepared, Err(error) => { - tracing::warn!( - server_name = %self.name, - error = %Report::from_error(&error), - "failed to prepare server reload" - ); - return ReloadServerOutcome::KeptOldOnPrepareFailure; + tracing::warn!(server_name = %name, error = %Report::from_error(&error), "server service preparation failed"); + self.release_resource(&name).await; + return; } }; - self.apply_reload(new_source, prepared).await - } - - #[cfg(test)] - pub(crate) async fn reload_with_prepared( - &mut self, - new_source: ServerSource, - prepared_result: Result< - PreparedServerUpdate, - crate::service::source::PrepareServerUpdateError, - >, - ) -> ReloadServerOutcome { - let prepared = match prepared_result { - Ok(p) => p, + let access_logs = match self.resources.acquire_access_logs(prepared.access_logs) { + Ok(access_logs) => access_logs, Err(error) => { tracing::warn!( - server_name = %self.name, + server_name = %name, error = %Report::from_error(&error), - "failed to prepare server reload" + "server access log resource acquisition failed" ); - return ReloadServerOutcome::KeptOldOnPrepareFailure; + self.release_resource(&name).await; + return; } }; - self.apply_reload(new_source, prepared).await - } - - async fn apply_reload( - &mut self, - new_source: ServerSource, - prepared: PreparedServerUpdate, - ) -> ReloadServerOutcome { - // Future refinement: split hard listener identity from soft updateable fields. - // The first implementation rebuilds on any ListenRequest fingerprint change, - // but TLS material or resolver metadata may later become updateable without - // rebuilding the underlying listener. - if prepared.listener_spec == self.listener_spec { - let Some(listener) = self.stop_accept().await else { - tracing::error!( - server_name = %self.name, - "accept loop was not running during reload; skipping" - ); - return ReloadServerOutcome::SkippedAcceptNotRunning; - }; - self.commit(new_source, prepared); - self.start_accept(listener); + let reusable = self.resources.servers.get(&name) + .is_some_and(|resources| resources.listener_spec() == &prepared.listener_spec); + if !reusable { self.release_resource(&name).await; } - ReloadServerOutcome::SwappedServiceOnReusedListener - } else { - let old_listener = match self.stop_accept().await { - Some(listener) => listener, - None => { - tracing::error!( - server_name = %self.name, - "failed to recover listener before rebuild (was not running)" - ); - return ReloadServerOutcome::StoppedAfterFatalRebuild; + if !self.resources.servers.contains_key(&name) { + let listener = match self.plane.listener(prepared.listen_request.clone()).await { + Ok(listener) => listener, + Err(error) => { + tracing::error!(server_name = %name, error = %Report::from_error(&error), "server listener acquisition failed"); + return; } }; + self.resources.servers.insert( + name.clone(), + ServerResources::new(listener, prepared.listener_spec.clone(), access_logs), + ); + } else { + self.resources + .servers + .get_mut(&name) + .expect("reusable resource exists") + .replace_access_logs(access_logs); + } - let request = prepared.listen_request.clone(); + let listener = self.resources.servers.get_mut(&name).expect("resource inserted").take_listener(); + let service = Arc::new(prepared.service.activate( + self.resources + .servers + .get(&name) + .expect("resource inserted") + .access_logs() + .clone(), + )); + let completed = self.services.completion_sender(); + self.services.servers.insert( + name.clone(), + ServerServiceHandle::start(name, listener, service, completed), + ); + } - match self.plane.rebuild_listener(old_listener, request).await { - Ok(new_listener) => { - self.commit(new_source, prepared); - self.start_accept(new_listener); - ReloadServerOutcome::RebuiltListener - } - Err(error) => { - tracing::error!( - server_name = %self.name, - error = %Report::from_error(&error), - "listener rebuild failed; old listener was consumed by the control plane" - ); - ReloadServerOutcome::StoppedAfterFatalRebuild - } - } + async fn stop_finished_services(&mut self) { + let finished = self + .services + .servers + .iter() + .filter(|(_, service)| service.is_finished()) + .map(|(name, _)| name.clone()) + .collect::>(); + for name in finished { + tracing::warn!(server_name = %name, "server service exited unexpectedly"); + self.stop_server(&name).await; } } - pub async fn remove(mut self) { - let recovered = self.stop_accept().await; - if let Some(listener) = recovered { - let _ = dhttp::h3x::quic::Listen::shutdown(&listener) - .await - .inspect_err(|error| { - tracing::error!( - server_name = %self.name, - error = %Report::from_error(error), - "failed to shut down listener during removal" - ); - }); + pub async fn wait_service_completion(&mut self) -> DhttpName<'static> { + loop { + let completion = self.services.next_completed().await; + if self + .services + .servers + .get(&completion.name) + .is_some_and(|service| service.owns_completion(&completion)) + { + return completion.name; + } } } - async fn stop_accept(&mut self) -> Option { - self.accept.take_listener().await.ok() + pub async fn handle_service_exit(&mut self, name: DhttpName<'static>) { + tracing::warn!(server_name = %name, "server service exited unexpectedly"); + self.stop_server(&name).await; } - fn start_accept(&mut self, listener: P::Listener) { - self.accept = AcceptState::start(listener, self.service.clone()); + async fn stop_service(&mut self, name: &DhttpName<'static>) { + let Some(service) = self.services.servers.remove(name) else { return }; + match service.drain().await { + DrainOutcome::Returned(listener) => { + if let Some(resources) = self.resources.servers.get_mut(name) { + resources.put_listener(listener); + } else { + let _ = listener.shutdown().await; + } + } + DrainOutcome::Aborted => { + self.resources.servers.remove(name); + } + } } - fn commit(&mut self, new_source: ServerSource, prepared: PreparedServerUpdate) { - self.source = new_source; - self.listener_spec = prepared.listener_spec; - self.fingerprint = prepared.fingerprint; - self.service = prepared.service; + async fn release_resource(&mut self, name: &DhttpName<'static>) { + let Some(mut resources) = self.resources.servers.remove(name) else { return }; + let listener = resources.take_listener(); + if let Err(error) = listener.shutdown().await { + tracing::warn!(server_name = %name, error = %Report::from_error(&error), "server listener shutdown failed"); + } } - #[cfg(test)] - pub(crate) fn accept_state(&self) -> &AcceptState { - &self.accept + async fn stop_server(&mut self, name: &DhttpName<'static>) { + self.stop_service(name).await; + self.release_resource(name).await; } -} - -pub struct RuntimeRegistry

-where - P: ProvideListener, -{ - plane: Arc

, - servers: HashMap, ServerRuntime

>, -} -impl

RuntimeRegistry

-where - P: ProvideListener + Send + Sync + 'static, - P::Listener: dhttp::h3x::quic::Listen + Send + 'static, - ::Error: std::error::Error + Send + Sync + 'static, - ::Connection: Send + 'static, - <::Connection as dhttp::h3x::quic::WithLocalAuthority>::LocalAuthority: - Send + Sync, - <::Connection as dhttp::h3x::quic::WithRemoteAuthority>::RemoteAuthority: - Send + Sync, -{ - pub fn new(plane: Arc

) -> Self { - Self { - plane, - servers: HashMap::new(), - } + pub async fn shutdown(mut self) { + let names = self.resources.servers.keys().cloned().collect::>(); + for name in names { self.stop_server(&name).await; } } - pub async fn apply_sources(&mut self, sources: Vec, ctx: &PrepareContext) { - let mut new_names = std::collections::HashSet::new(); - - for source in sources { - let name = source.name().clone(); - new_names.insert(name.clone()); - - let is_fatal = match self.servers.entry(name.clone()) { - std::collections::hash_map::Entry::Occupied(mut entry) => { - let outcome = entry.get_mut().reload(source, ctx).await; - matches!(outcome, ReloadServerOutcome::StoppedAfterFatalRebuild) - } - std::collections::hash_map::Entry::Vacant(entry) => { - let prepared = match source.prepare(ctx).await { - Ok(prepared) => prepared, - Err(error) => { - tracing::warn!( - server_name = %name, - error = %snafu::Report::from_error(&error), - "failed to prepare new server" - ); - continue; - } - }; - - let listener = match self.plane.listener(prepared.listen_request.clone()).await - { - Ok(listener) => listener, - Err(error) => { - tracing::error!( - server_name = %name, - error = %snafu::Report::from_error(&error), - "control plane failed to provide listener" - ); - continue; - } - }; - - let server = - ServerRuntime::start(source, prepared, self.plane.clone(), listener); - entry.insert(server); - false - } - }; - - if is_fatal { - let removed = self.servers.remove(&name); - if let Some(server) = removed { - server.remove().await; - } - } - } - - let mut to_remove = Vec::new(); - for name in self.servers.keys() { - if !new_names.contains(name) { - to_remove.push(name.clone()); - } - } - for name in to_remove { - if let Some(server) = self.servers.remove(&name) { - server.remove().await; - } - } + #[cfg(test)] + pub(crate) fn contains_service(&self, name: &str) -> bool { + self.services.servers.keys().any(|candidate| candidate.as_full() == name) } +} - pub async fn shutdown(mut self) { - let servers = std::mem::take(&mut self.servers); - for (_, server) in servers { - server.remove().await; - } - } +#[derive(Debug, snafu::Snafu)] +#[snafu(module(worker_reload_error))] +pub enum WorkerReloadError { + #[snafu(display("failed to load worker configuration"))] + Config { + source: gateway::parse::error::ConfigLoadFailure, + }, + #[snafu(display("failed to enumerate worker identities"))] + Identities { + source: crate::config::plan::LoadIdentityServerCandidatesError, + }, } pub struct WorkerRuntime

@@ -305,7 +198,8 @@ where P: ControlPlane + ProvideListener, { registry: RuntimeRegistry

, - dhttp_config: dhttp::home::DhttpHome, + dhttp_home: dhttp::home::DhttpHome, + root_defaults: gateway::parse::config::RootWorkerDefaultsSnapshot, router_state: gateway::reverse::router::RouterState, } @@ -315,250 +209,194 @@ where P::Listener: dhttp::h3x::quic::Listen + Send + 'static, ::Error: std::error::Error + Send + Sync + 'static, ::Connection: Send + 'static, - <::Connection as dhttp::h3x::quic::WithLocalAuthority>::LocalAuthority: - Send + Sync, - <::Connection as dhttp::h3x::quic::WithRemoteAuthority>::RemoteAuthority: - Send + Sync, + <::Connection as dhttp::h3x::quic::WithLocalAuthority>::LocalAuthority: Send + Sync, + <::Connection as dhttp::h3x::quic::WithRemoteAuthority>::RemoteAuthority: Send + Sync, { - pub fn new( - plane: Arc

, - dhttp_config: dhttp::home::DhttpHome, - router_state: gateway::reverse::router::RouterState, - ) -> Self { - Self { - registry: RuntimeRegistry::new(plane), - dhttp_config, - router_state, - } + pub fn new(plane: Arc

, dhttp_home: dhttp::home::DhttpHome, root_defaults: gateway::parse::config::RootWorkerDefaultsSnapshot, router_state: gateway::reverse::router::RouterState) -> Self { + Self { registry: RuntimeRegistry::new(plane), dhttp_home, root_defaults, router_state } } - pub async fn start(&mut self) { - self.reload().await; + pub async fn start(&mut self) -> Result<(), WorkerReloadError> { self.reload().await } + + pub async fn reload(&mut self) -> Result<(), WorkerReloadError> { + let mut parser = gateway::parse::TypedConfigParser::new(); + let path = self.dhttp_home.join(crate::config::PishooConfigSource::CONFIG_FILE_NAME); + let defaults = match gateway::parse::load_worker_config_file(&mut parser, &path, &self.dhttp_home, &self.root_defaults).await.context(worker_reload_error::ConfigSnafu)? { + Some(parsed) => parsed.pishoo().worker_defaults(), + None => self.root_defaults.clone(), + }; + let candidates = crate::config::load_identity_server_candidates(&self.dhttp_home, &defaults).await.context(worker_reload_error::IdentitiesSnafu)?; + let mut configs = Vec::new(); + for candidate in candidates.into_vec() { + let (profile, result) = candidate.into_parts(); + match result { + Ok(Some(server)) => configs.push(Arc::new(server)), + Ok(None) => {} + Err(error) => tracing::warn!(profile = ?profile.map(|profile| profile.name().to_string()), error = %Report::from_error(&error), "identity server config rejected"), + } + } + let (sources, ctx) = crate::service::source::TypedServerSource::load_all(configs, self.router_state.clone()).await; + self.registry.apply_sources(sources, &ctx).await; + Ok(()) } - pub async fn reload(&mut self) { - let sources = - match crate::worker::config::load_identity_service_sources(&self.dhttp_config).await { - Ok(sources) => sources, - Err(error) => { - tracing::warn!( - error = %snafu::Report::from_error(&error), - "failed to load identity service sources" - ); - return; - } - }; + pub async fn reload_with_root_defaults(&mut self, root_defaults: gateway::parse::config::RootWorkerDefaultsSnapshot) -> Result<(), WorkerReloadError> { + self.root_defaults = root_defaults; + self.reload().await + } - let ctx = match crate::service::source::PrepareContext::load_worker( - &self.dhttp_config, - self.router_state.clone(), - ) - .await - { - Ok(ctx) => ctx, - Err(error) => { - tracing::warn!( - error = %snafu::Report::from_error(&error), - "failed to load worker prepare context" - ); - return; - } - }; + pub async fn shutdown(self) { self.registry.shutdown().await; } - let server_sources: Vec = - sources.into_iter().map(ServerSource::IdentityService).collect(); - self.registry.apply_sources(server_sources, &ctx).await; + pub async fn wait_service_completion(&mut self) -> DhttpName<'static> { + self.registry.wait_service_completion().await } - pub async fn shutdown(self) { - self.registry.shutdown().await; + pub async fn handle_service_exit(&mut self, name: DhttpName<'static>) { + self.registry.handle_service_exit(name).await; } } #[cfg(test)] mod tests { - use std::sync::Arc; + use std::sync::{Arc, Mutex}; use snafu::Snafu; use super::*; use crate::service::source::{ListenerSpec, ServerSource}; - struct FakeListener; - + struct FakeListener { + operations: Arc>>, + } #[derive(Debug, Snafu)] - #[snafu(display("fake listener never errors in these tests"))] + #[snafu(display("fake listener error"))] struct FakeListenerError; - impl dhttp::h3x::quic::Listen for FakeListener { type Connection = dhttp::h3x::dquic::prelude::Connection; type Error = FakeListenerError; - async fn accept(&mut self) -> Result, Self::Error> { std::future::pending().await } - async fn shutdown(&self) -> Result<(), Self::Error> { + self.operations.lock().unwrap().push("shutdown"); Ok(()) } } - #[derive(Debug, Snafu)] - #[snafu(display("fake plane never rebuilds in these tests"))] - struct FakeRebuildError; - - struct FakePlane; - + struct FakePlane { + operations: Arc>>, + } impl gateway::control_plane::ProvideListener for FakePlane { type Listener = FakeListener; - type ListenError = FakeRebuildError; - type RebuildError = FakeRebuildError; - + type ListenError = FakeListenerError; async fn listener( &self, _request: gateway::control_plane::ListenRequest, ) -> Result { - Err(FakeRebuildError) - } - - async fn rebuild_listener( - &self, - _old: Self::Listener, - _request: gateway::control_plane::ListenRequest, - ) -> Result { - Err(FakeRebuildError) + self.operations.lock().unwrap().push("acquire"); + Ok(FakeListener { + operations: self.operations.clone(), + }) } } - fn fake_runtime(name: &str, generation: u64) -> ServerRuntime { - let spec = ListenerSpec::fake("same"); - let source = ServerSource::fake_success(name, generation, spec.clone()); - let name_owned = DhttpName::try_from(name.to_owned()).expect("valid dhttp name"); - ServerRuntime { - name: name_owned, - source, - listener_spec: spec.clone(), - service: Arc::new(ServerService::fake()), - accept: AcceptState::Stopped { - listener: FakeListener, + fn context() -> PrepareContext { + PrepareContext { + h3_settings: Arc::new(dhttp::h3x::dhttp::settings::Settings::default()), + router_state: gateway::reverse::router::RouterState { + #[cfg(feature = "sshd")] + session_spawner: Arc::new(DummySpawner), + #[cfg(feature = "sshd")] + task_scope: Arc::new(DummyScope), }, - fingerprint: ServerFingerprint { - listener_spec: spec, - service_generation: generation, - }, - plane: Arc::new(FakePlane), } } - #[tokio::test] - async fn reload_prepare_failure_keeps_current_fingerprint() { - let mut runtime = fake_runtime("alpha.example", 1); - let failing_source = ServerSource::fake_prepare_error("alpha.example"); - let prepared_result = if let ServerSource::Fake(fake) = &failing_source { - fake.prepare() - } else { - unreachable!() - }; - - let outcome = runtime - .reload_with_prepared(failing_source, prepared_result) - .await; - - assert!(matches!( - outcome, - ReloadServerOutcome::KeptOldOnPrepareFailure - )); - assert_eq!(runtime.fingerprint().generation_for_test(), 1); + #[cfg(feature = "sshd")] + struct DummySpawner; + #[cfg(feature = "sshd")] + impl gateway::control_plane::DynSpawnSession for DummySpawner { + fn spawn_session<'a>( + &'a self, + _username: &'a str, + ) -> futures::future::BoxFuture< + 'a, + Result< + gateway::control_plane::SessionTransport, + Box, + >, + > { + Box::pin(async { std::future::pending().await }) + } } - - #[tokio::test] - async fn unchanged_listener_reload_swaps_service_for_future_accepts() { - let mut runtime = fake_runtime("alpha.example", 1); - let source = ServerSource::fake_success("alpha.example", 2, ListenerSpec::fake("same")); - let prepared_result = if let ServerSource::Fake(fake) = &source { - fake.prepare() - } else { - unreachable!() - }; - - let outcome = runtime.reload_with_prepared(source, prepared_result).await; - - assert!(matches!( - outcome, - ReloadServerOutcome::SwappedServiceOnReusedListener - )); - assert_eq!(runtime.fingerprint().generation_for_test(), 2); + #[cfg(feature = "sshd")] + struct DummyScope; + #[cfg(feature = "sshd")] + impl gateway::reverse::router::DynTaskScope for DummyScope { + fn token(&self) -> tokio_util::sync::CancellationToken { + tokio_util::sync::CancellationToken::new() + } + fn spawn(&self, _task: futures::future::BoxFuture<'static, ()>) {} } #[tokio::test] - async fn reused_listener_reload_keeps_accept_running() { - let mut runtime = fake_runtime("alpha.example", 1); - let source = ServerSource::fake_success("alpha.example", 2, ListenerSpec::fake("same")); - let prepared_result = if let ServerSource::Fake(fake) = &source { - fake.prepare() - } else { - unreachable!() - }; - - let _ = runtime.reload_with_prepared(source, prepared_result).await; + async fn changed_listener_drains_closes_and_reacquires_in_one_round() { + let operations = Arc::new(Mutex::new(Vec::new())); + let mut runtime = RuntimeRegistry::new(Arc::new(FakePlane { + operations: operations.clone(), + })); + runtime + .apply_sources( + vec![ServerSource::fake_success( + "alpha.dhttp.net", + 1, + ListenerSpec::fake("a"), + )], + &context(), + ) + .await; + runtime + .apply_sources( + vec![ServerSource::fake_success( + "alpha.dhttp.net", + 2, + ListenerSpec::fake("b"), + )], + &context(), + ) + .await; - assert!(matches!( - runtime.accept_state(), - AcceptState::Running { .. } - )); + assert_eq!( + *operations.lock().unwrap(), + ["acquire", "shutdown", "acquire"] + ); + assert!(runtime.contains_service("alpha.dhttp.net")); } #[tokio::test] - async fn remove_completes_even_when_release_fails() { - struct FailingListener {} - impl dhttp::h3x::quic::Listen for FailingListener { - type Connection = dhttp::h3x::dquic::prelude::Connection; - type Error = FakeListenerError; - async fn accept(&mut self) -> Result, Self::Error> { - std::future::pending().await - } - async fn shutdown(&self) -> Result<(), Self::Error> { - Err(FakeListenerError) - } - } - - struct FailingPlane; - - impl gateway::control_plane::ProvideListener for FailingPlane { - type Listener = FailingListener; - type ListenError = FakeRebuildError; - type RebuildError = FakeRebuildError; - - async fn listener( - &self, - _request: gateway::control_plane::ListenRequest, - ) -> Result { - Err(FakeRebuildError) - } - async fn rebuild_listener( - &self, - _old: Self::Listener, - _request: gateway::control_plane::ListenRequest, - ) -> Result { - Err(FakeRebuildError) - } - } - - let runtime = ServerRuntime { - name: DhttpName::try_from("alpha.example".to_owned()).unwrap(), - source: ServerSource::fake_success("alpha.example", 1, ListenerSpec::fake("same")), - listener_spec: ListenerSpec::fake("same"), - service: Arc::new(ServerService::fake()), - accept: AcceptState::Stopped { - listener: FailingListener {}, - }, - fingerprint: ServerFingerprint { - listener_spec: ListenerSpec::fake("same"), - service_generation: 1, - }, - plane: Arc::new(FailingPlane), - }; + async fn preparation_failure_stops_only_that_server() { + let operations = Arc::new(Mutex::new(Vec::new())); + let mut runtime = RuntimeRegistry::new(Arc::new(FakePlane { operations })); + runtime + .apply_sources( + vec![ + ServerSource::fake_success("good.dhttp.net", 1, ListenerSpec::fake("good")), + ServerSource::fake_success("bad.dhttp.net", 1, ListenerSpec::fake("bad")), + ], + &context(), + ) + .await; + runtime + .apply_sources( + vec![ + ServerSource::fake_success("good.dhttp.net", 2, ListenerSpec::fake("good")), + ServerSource::fake_prepare_error("bad.dhttp.net"), + ], + &context(), + ) + .await; - runtime.remove().await; // Should not panic or hang + assert!(runtime.contains_service("good.dhttp.net")); + assert!(!runtime.contains_service("bad.dhttp.net")); } } diff --git a/pishoo/src/service/set.rs b/pishoo/src/service/set.rs new file mode 100644 index 00000000..aae7a151 --- /dev/null +++ b/pishoo/src/service/set.rs @@ -0,0 +1,237 @@ +use std::{ + collections::HashMap, + sync::{Arc, Weak}, +}; + +use dhttp::name::DhttpName; +use gateway::{ + parse::domain::ResolvedConfigPath, + reverse::log::{AccessLogOutput, OpenAccessLogOutputError}, +}; + +use super::{ + accept::{AcceptState, DrainOutcome}, + resource::{AccessLogResourcePlan, AccessLogResources, ServerResources}, +}; + +pub struct ResourceSet { + pub(crate) servers: HashMap, ServerResources>, + pub(crate) access_output_cache: HashMap>, +} + +impl Default for ResourceSet { + fn default() -> Self { + Self { + servers: HashMap::new(), + access_output_cache: HashMap::new(), + } + } +} + +impl ResourceSet { + pub(crate) fn acquire_output( + &mut self, + path: ResolvedConfigPath, + ) -> Result, OpenAccessLogOutputError> { + if let Some(output) = self.access_output_cache.get(&path).and_then(Weak::upgrade) { + return Ok(output); + } + + let output = Arc::new(AccessLogOutput::open(path.clone())?); + self.access_output_cache + .insert(path, Arc::downgrade(&output)); + Ok(output) + } + + pub(crate) fn acquire_access_logs( + &mut self, + plan: AccessLogResourcePlan, + ) -> Result { + let server = self.acquire_configured_output(plan.server)?; + let locations = plan + .locations + .into_vec() + .into_iter() + .map(|config| self.acquire_configured_output(config)) + .collect::, _>>()?; + Ok(AccessLogResources { server, locations }) + } + + fn acquire_configured_output( + &mut self, + config: gateway::parse::config::ResolvedAccessLogConfig, + ) -> Result>, OpenAccessLogOutputError> { + match config { + gateway::parse::config::ResolvedAccessLogConfig::Disabled => Ok(None), + gateway::parse::config::ResolvedAccessLogConfig::Enabled(path) => { + self.acquire_output(path).map(Some) + } + } + } +} + +pub(crate) struct ServiceCompletion { + pub(crate) name: DhttpName<'static>, + marker: Arc<()>, +} + +#[derive(Clone)] +struct ServiceCompletionToken { + name: DhttpName<'static>, + marker: Arc<()>, +} + +impl ServiceCompletionToken { + fn new(name: DhttpName<'static>) -> Self { + Self { + name, + marker: Arc::new(()), + } + } + + fn completion(&self) -> ServiceCompletion { + ServiceCompletion { + name: self.name.clone(), + marker: Arc::clone(&self.marker), + } + } + + fn owns(&self, completion: &ServiceCompletion) -> bool { + Arc::ptr_eq(&self.marker, &completion.marker) + } +} + +pub struct ServerServiceHandle { + accept: AcceptState, + completion: ServiceCompletionToken, +} + +impl ServerServiceHandle +where + L: Send + 'static, +{ + pub(crate) fn start( + name: DhttpName<'static>, + listener: L, + service: std::sync::Arc, + completed: tokio::sync::mpsc::UnboundedSender, + ) -> Self + where + L: dhttp::h3x::quic::Listen, + L::Error: Send, + L::Connection: Send + 'static, + ::LocalAuthority: Send + Sync, + ::RemoteAuthority: Send + Sync, + { + let completion = ServiceCompletionToken::new(name); + let task_completion = completion.clone(); + Self { + accept: AcceptState::start(listener, service, move || { + let _ = completed.send(task_completion.completion()); + }), + completion, + } + } + + pub async fn drain(self) -> DrainOutcome { + self.accept.drain().await + } + pub fn is_finished(&self) -> bool { + self.accept.is_finished() + } + + pub(crate) fn owns_completion(&self, completion: &ServiceCompletion) -> bool { + self.completion.owns(completion) + } +} + +pub struct ServiceSet { + pub(crate) servers: HashMap, ServerServiceHandle>, + completed_tx: tokio::sync::mpsc::UnboundedSender, + completed_rx: tokio::sync::mpsc::UnboundedReceiver, +} + +impl Default for ServiceSet { + fn default() -> Self { + let (completed_tx, completed_rx) = tokio::sync::mpsc::unbounded_channel(); + Self { + servers: HashMap::new(), + completed_tx, + completed_rx, + } + } +} + +impl ServiceSet { + pub(crate) fn completion_sender( + &self, + ) -> tokio::sync::mpsc::UnboundedSender { + self.completed_tx.clone() + } + + pub(crate) async fn next_completed(&mut self) -> ServiceCompletion { + self.completed_rx + .recv() + .await + .expect("service set owns the completion sender") + } +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use gateway::parse::domain::ResolvedConfigPath; + + use super::*; + + #[test] + fn completion_only_belongs_to_the_service_instance_that_created_it() { + let name = "alice.dhttp.net".parse().unwrap(); + let first = ServiceCompletionToken::new(name); + let replacement = ServiceCompletionToken::new("alice.dhttp.net".parse().unwrap()); + let completion = first.completion(); + + assert!(first.owns(&completion)); + assert!(!replacement.owns(&completion)); + } + + #[test] + fn same_process_path_reuses_output_and_last_binding_closes_it() { + let mut resources = ResourceSet::<()>::default(); + let path = ResolvedConfigPath::try_from(std::env::temp_dir().join(format!( + "pishoo-shared-access-{}-{}.log", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + ))) + .unwrap(); + + let first = resources.acquire_output(path.clone()).unwrap(); + let second = resources.acquire_output(path.clone()).unwrap(); + assert!(std::sync::Arc::ptr_eq(&first, &second)); + drop(first); + drop(second); + assert!( + resources + .access_output_cache + .get(&path) + .unwrap() + .upgrade() + .is_none() + ); + + let _ = std::fs::remove_file(PathBuf::from(path.as_ref())); + } + + #[test] + fn output_open_failure_does_not_create_a_binding() { + let mut resources = ResourceSet::<()>::default(); + let path = ResolvedConfigPath::try_from(PathBuf::from("/proc/pishoo/access.log")).unwrap(); + + assert!(resources.acquire_output(path).is_err()); + assert!(resources.access_output_cache.is_empty()); + } +} diff --git a/pishoo/src/service/snapshot.rs b/pishoo/src/service/snapshot.rs index fb7278e0..e9b88e49 100644 --- a/pishoo/src/service/snapshot.rs +++ b/pishoo/src/service/snapshot.rs @@ -1,4 +1,4 @@ -use std::{future::Future, path::PathBuf, sync::Arc}; +use std::{future::Future, sync::Arc}; use axum::middleware::from_fn_with_state; use dhttp::h3x::{ @@ -7,9 +7,9 @@ use dhttp::h3x::{ }; use gateway::reverse::{ access_control::{AccessControlState, access_control}, - access_log::{AccessLogState, access_log}, + access_log::{AccessLogState, ActiveAccessLog, access_log}, body_adapter::BodyAdapterLayer, - log::AccessLogWriter, + location::ConfiguredLocation, router::NginxRouter, }; use snafu::Report; @@ -17,13 +17,36 @@ use tokio_util::sync::CancellationToken; use tower::ServiceBuilder; use tracing::Instrument; +use super::resource::AccessLogResources; + +pub struct PreparedServerService { + pub h3_settings: Arc, + pub access_rules: Arc, + pub router_state: gateway::reverse::router::RouterState, + pub server_config: Arc, + pub server_name: dhttp::name::DhttpName<'static>, +} + +impl PreparedServerService { + pub fn activate(self, access_logs: AccessLogResources) -> ServerService { + ServerService { + h3_settings: self.h3_settings, + access_rules: self.access_rules, + router_state: self.router_state, + server_config: self.server_config, + server_name: self.server_name, + access_logs, + } + } +} + pub struct ServerService { pub h3_settings: Arc, pub access_rules: Arc, pub router_state: gateway::reverse::router::RouterState, - pub server_node: Arc, - pub access_log_dir: Option, + pub server_config: Arc, pub server_name: dhttp::name::DhttpName<'static>, + pub access_logs: AccessLogResources, } impl ServerService { @@ -39,37 +62,37 @@ impl ServerService { ::LocalAuthority: Send + Sync, ::RemoteAuthority: Send + Sync, { - let locations = self.server_node.children_optional("location").to_vec(); + assert_eq!( + self.server_config.locations().len(), + self.access_logs.locations.len(), + "access log resources correspond to every configured location" + ); + let locations = self + .server_config + .locations() + .iter() + .cloned() + .zip(self.access_logs.locations.iter().cloned()) + .map(|(location, output)| { + Arc::new(ConfiguredLocation::new( + Arc::new(location), + ActiveAccessLog::from_output(output), + )) + }) + .collect(); - let nginx_router = NginxRouter::new(locations, self.router_state.clone()); + let server_access_log = ActiveAccessLog::from_output(self.access_logs.server.clone()); + let nginx_router = NginxRouter::new( + locations, + server_access_log.clone(), + self.router_state.clone(), + ); let access_state = AccessControlState { access_rules: self.access_rules.clone(), server_name: Arc::from(self.server_name.as_full()), }; - - let access_log_writer = match &self.access_log_dir { - Some(dir) => match AccessLogWriter::new(dir.clone()) { - Ok(writer) => { - tracing::debug!( - server_name = %self.server_name, - dir = %dir.display(), - "access log writer created" - ); - writer - } - Err(error) => { - tracing::warn!( - server_name = %self.server_name, - error = %Report::from_error(&error), - "failed to create access log writer, access logging disabled" - ); - AccessLogWriter::disabled() - } - }, - None => AccessLogWriter::disabled(), - }; let access_log_state = AccessLogState { - writer: access_log_writer, + server: server_access_log, }; let service_stack = ServiceBuilder::new() @@ -137,8 +160,8 @@ where #[cfg(test)] impl ServerService { - pub(crate) fn fake() -> Self { - Self { + pub(crate) fn fake() -> PreparedServerService { + PreparedServerService { h3_settings: Arc::new(Settings::default()), access_rules: Arc::new(dhttp::access::matcher::LocationRulesMatcher::default()), router_state: { @@ -185,21 +208,24 @@ impl ServerService { ), } }, - server_node: { - let registry = gateway::parse::default_registry(); - let doc = gateway::parse::load_config_text( - "", + server_config: { + let mut parser = gateway::parse::TypedConfigParser::new(); + let parsed = parser.parse_root( + "pishoo { server { listen all 443; server_name test.example; ssl_certificate /tmp/test.crt; ssl_certificate_key /tmp/test.key; } }", + std::path::Path::new("/tmp/pishoo.conf"), None, - ®istry, - gateway::parse::registry::BuildOptions { - dhttp_home: None, - identity_profile: None, - }, + ).unwrap(); + Arc::new( + parsed + .into_parts() + .1 + .into_vec() + .pop() + .unwrap() + .into_result() + .unwrap(), ) - .unwrap(); - doc.root }, - access_log_dir: None, server_name: dhttp::name::DhttpName::try_from("test.example").unwrap(), } } diff --git a/pishoo/src/service/source.rs b/pishoo/src/service/source.rs index c999e25a..9dcab056 100644 --- a/pishoo/src/service/source.rs +++ b/pishoo/src/service/source.rs @@ -1,24 +1,17 @@ use std::sync::Arc; -use dhttp::{ - home::{DhttpHome, identity::IdentityProfile}, - name::DhttpName, -}; +use dhttp::name::DhttpName; use gateway::{ control_plane::ListenRequest, parse::{ - document::ConfigNode, - types::{AccessRulesUri, ListenConfig, Listens, ResolverConfig, ServerNames}, + config::{ServerConfig, ServerIdentity}, + types::Listens, }, reverse::router::RouterState, }; -use snafu::{OptionExt, ResultExt, Snafu}; - -use super::snapshot::ServerService; -use crate::{config::load_identity_servers, worker::config::BuildConfigError}; +use snafu::{ResultExt, Snafu}; -type SharedLocationRuleEvaluator = - Arc; +use super::{resource::AccessLogResourcePlan, snapshot::PreparedServerService}; #[derive(Debug, Clone, PartialEq, Eq)] pub struct ListenRequestFingerprint { @@ -43,16 +36,23 @@ pub struct PreparedServerUpdate { pub name: DhttpName<'static>, pub listen_request: ListenRequest, pub listener_spec: ListenerSpec, - pub service: Arc, + pub service: PreparedServerService, + pub access_logs: AccessLogResourcePlan, pub fingerprint: ServerFingerprint, } #[derive(Debug, Snafu)] -#[snafu(module)] +#[snafu(module(prepare_server_update_error))] pub enum PrepareServerUpdateError { - #[snafu(display("failed to build identity service config"))] - IdentityService { - source: crate::worker::config::BuildConfigError, + #[snafu(display("failed to load access policy for server `{name}`"))] + Policy { + name: String, + source: crate::policy::PolicyError, + }, + #[snafu(display("failed to materialize access log configuration for server `{name}`"))] + AccessLog { + name: String, + source: gateway::parse::config::MaterializeAccessLogError, }, #[cfg(test)] #[snafu(display("synthetic prepare failure for {server_name}"))] @@ -60,174 +60,194 @@ pub enum PrepareServerUpdateError { } pub enum ServerSource { - IdentityService(IdentityServiceSource), - PishooConfig(PishooConfigServiceSource), + Typed(TypedServerSource), #[cfg(test)] Fake(FakeServerSource), } -pub struct PrepareContext { - pub h3_settings: Arc, - pub access_rules: SharedLocationRuleEvaluator, - pub router_state: gateway::reverse::router::RouterState, -} - -fn h3_settings() -> Arc { - let settings = dhttp::h3x::dhttp::settings::Settings::default(); - #[cfg(feature = "sshd")] - let settings = settings - .with_all(dhttp::h3x::dhttp::webtransport::settings::WebTransportSupport::default()); - Arc::new(settings) -} - -pub struct IdentityServiceSource { - pub name: DhttpName<'static>, - pub home: DhttpHome, - pub identity_profile: IdentityProfile, +pub struct TypedServerSource { + name: DhttpName<'static>, + identity: dhttp::identity::Identity, + bind: Vec, + dns_resolver_url: Option, + server_config: Arc, } -pub struct PishooConfigServiceSource { - pub name: DhttpName<'static>, - pub identity: dhttp::identity::Identity, - pub bind: Vec, - pub dns_resolver_url: Option, - pub server_node: Arc, +pub struct PrepareContext { + pub h3_settings: Arc, + pub router_state: RouterState, } -#[cfg(test)] -pub struct FakeServerSource { - pub(crate) name: DhttpName<'static>, - pub(crate) outcome: FakePrepareOutcome, +impl PrepareContext { + pub fn new(router_state: RouterState) -> Self { + let settings = dhttp::h3x::dhttp::settings::Settings::default(); + #[cfg(feature = "sshd")] + let settings = settings + .with_all(dhttp::h3x::dhttp::webtransport::settings::WebTransportSupport::default()); + Self { + h3_settings: Arc::new(settings), + router_state, + } + } } -#[cfg(test)] -pub enum FakePrepareOutcome { - Success { - listener_spec: ListenerSpec, - service_generation: u64, +#[derive(Debug, Snafu)] +#[snafu(module(build_typed_server_source_error))] +pub enum BuildTypedServerSourceError { + #[snafu(display("server has no listen directive"))] + MissingListen, + #[snafu(display("server has no server_name"))] + MissingName, + #[snafu(display("failed to read certificate at `{}`", path.display()))] + ReadCert { + path: std::path::PathBuf, + source: std::io::Error, + }, + #[snafu(display("failed to read private key at `{}`", path.display()))] + ReadKey { + path: std::path::PathBuf, + source: std::io::Error, + }, + #[snafu(display("invalid TLS material"))] + InvalidTls { + source: crate::tls::TlsMaterialError, + }, + #[snafu(display("failed to load identity `{name}`"))] + LoadIdentity { + name: String, + source: dhttp::home::identity::ssl::LoadIdentityError, }, - Failure, } -impl PishooConfigServiceSource { +impl TypedServerSource { pub async fn load_all( - config_servers: &[Arc], - home: Option<&DhttpHome>, + configs: impl IntoIterator>, router_state: RouterState, - ) -> Result<(Vec, PrepareContext), BuildConfigServiceSourcesError> { - let canonicalized = crate::naming::canonicalize_server_nodes(config_servers) - .context(build_config_service_sources_error::CanonicalizeSnafu)?; - - let mut access_rules_uri: Option = None; + ) -> (Vec, PrepareContext) { let mut sources = Vec::new(); - let mut seen_server_names = std::collections::HashSet::new(); - - for server in &canonicalized { - let listens = listen_values(server) - .context(build_config_service_sources_error::ConfigQuerySnafu)?; - if listens.is_empty() { - return build_config_service_sources_error::MissingListenSnafu.fail(); - } - - let server_names = server - .get::("server_name") - .context(build_config_service_sources_error::ConfigQuerySnafu)? - .context(build_config_service_sources_error::MissingDirectiveSnafu { - directive: "server_name", - })?; - - let has_cert = server - .get::("ssl_certificate") - .context(build_config_service_sources_error::ConfigQuerySnafu)? - .is_some(); - let has_key = server - .get::("ssl_certificate_key") - .context(build_config_service_sources_error::ConfigQuerySnafu)? - .is_some(); - if home.is_some() && !has_cert && !has_key && server_names.0.len() > 1 { - return build_config_service_sources_error::AmbiguousImplicitTlsSnafu.fail(); - } - - if access_rules_uri.is_none() - && let Some(uri) = server - .get::("access_rules") - .context(build_config_service_sources_error::ConfigQuerySnafu)? - { - access_rules_uri = Some(uri.0.as_str().to_owned()); - } - - let dns_resolver_url = server - .get::("dns") - .context(build_config_service_sources_error::ConfigQuerySnafu)? - .map(|resolver| resolver.0.clone()); - - for configured_name in &server_names.0 { - let server_name = configured_name.name.clone(); - if !seen_server_names.insert(server_name.clone()) { - return build_config_service_sources_error::DuplicateServerNameSnafu { - name: server_name.to_string(), + let mut names = std::collections::HashSet::new(); + let loads = configs.into_iter().map(Self::load_config); + for result in futures::future::join_all(loads).await { + match result { + Ok(config_sources) => { + for source in config_sources { + if names.insert(source.name.clone()) { + sources.push(ServerSource::Typed(source)); + } else { + tracing::warn!(server_name = %source.name, "duplicate server name stopped"); + } } - .fail(); } - - let identity = load_identity_for_server(home, server, &server_name).await?; - sources.push(ServerSource::PishooConfig(PishooConfigServiceSource { - name: server_name, - identity, - bind: listens.clone(), - dns_resolver_url: dns_resolver_url.clone(), - server_node: server.clone(), - })); + Err(error) => tracing::warn!( + error = %snafu::Report::from_error(&error), + "server resource construction failed" + ), } } - - let ctx = PrepareContext::load_config_service(access_rules_uri.as_deref(), router_state) - .await - .context(build_config_service_sources_error::PrepareContextSnafu)?; - - Ok((sources, ctx)) - } - - pub async fn prepare( + (sources, PrepareContext::new(router_state)) + } + + async fn load_config( + config: Arc, + ) -> Result, BuildTypedServerSourceError> { + let bind = config + .listens() + .iter() + .flat_map(|listen| listen.0.clone()) + .collect::>(); + if bind.is_empty() { + return Err(BuildTypedServerSourceError::MissingListen); + } + if config.names().is_empty() { + return Err(BuildTypedServerSourceError::MissingName); + } + let identity = load_identity(&config).await?; + let resolver = config.resolver().map(|resolver| resolver.0.clone()); + Ok(config + .names() + .iter() + .map(|name| Self { + name: name.clone(), + identity: identity.clone(), + bind: bind.clone(), + dns_resolver_url: resolver.clone(), + server_config: config.clone(), + }) + .collect()) + } + + async fn prepare( &self, - ctx: &PrepareContext, + context: &PrepareContext, ) -> Result { + let access_rules_uri = self + .server_config + .http() + .access_rules() + .effective() + .as_ref() + .map(|uri| uri.0.as_str()); + let policy = crate::policy::load_policy_bundle(access_rules_uri) + .await + .context(prepare_server_update_error::PolicySnafu { + name: self.name.to_string(), + })?; let listen_request = ListenRequest { identity: self.identity.clone(), bind: self.bind.clone(), dns_resolver_url: self.dns_resolver_url.clone(), }; - - let request_fingerprint = ListenRequestFingerprint { - server_name: self.name.clone(), - bind_debug: format!("{:?}", self.bind), - identity_debug: compute_identity_fingerprint(&self.identity), - dns_resolver_debug: self.dns_resolver_url.as_ref().map(|u| u.to_string()), - }; - let listener_spec = ListenerSpec { - request_fingerprint, + request_fingerprint: ListenRequestFingerprint { + server_name: self.name.clone(), + bind_debug: format!("{:?}", self.bind), + identity_debug: compute_identity_fingerprint(&self.identity), + dns_resolver_debug: self.dns_resolver_url.as_ref().map(ToString::to_string), + }, + }; + let access_logs = AccessLogResourcePlan { + server: self + .server_config + .http() + .access_log() + .effective() + .materialize(self.server_config.identity()) + .context(prepare_server_update_error::AccessLogSnafu { + name: self.name.to_string(), + })?, + locations: self + .server_config + .locations() + .iter() + .map(|location| { + location + .http() + .access_log() + .effective() + .materialize(self.server_config.identity()) + .context(prepare_server_update_error::AccessLogSnafu { + name: self.name.to_string(), + }) + }) + .collect::, _>>()?, + }; + let service = PreparedServerService { + h3_settings: context.h3_settings.clone(), + access_rules: policy.location_rules, + router_state: context.router_state.clone(), + server_config: self.server_config.clone(), + server_name: self.name.clone(), }; - let service_generation = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() - .as_secs(); - - let service = Arc::new(ServerService { - h3_settings: ctx.h3_settings.clone(), - access_rules: ctx.access_rules.clone(), - router_state: ctx.router_state.clone(), - server_node: self.server_node.clone(), - access_log_dir: None, - server_name: self.name.clone(), - }); - + .as_nanos() as u64; Ok(PreparedServerUpdate { name: self.name.clone(), listen_request, listener_spec: listener_spec.clone(), service, + access_logs, fingerprint: ServerFingerprint { listener_spec, service_generation, @@ -236,197 +256,139 @@ impl PishooConfigServiceSource { } } -fn listen_values( - server: &ConfigNode, -) -> Result, gateway::parse::error::ConfigQueryError> { - Ok(server - .get_all::("listen")? - .into_iter() - .flat_map(|node| node.0.clone()) - .collect()) -} - -async fn load_identity_for_server( - home: Option<&DhttpHome>, - server: &ConfigNode, - server_name: &DhttpName<'static>, -) -> Result { - let cert_path = server - .get::("ssl_certificate") - .context(build_config_service_sources_error::ConfigQuerySnafu)?; - let key_path = server - .get::("ssl_certificate_key") - .context(build_config_service_sources_error::ConfigQuerySnafu)?; - - match (cert_path, key_path) { - (Some(cert_path), Some(key_path)) => { - load_identity_from_paths(server_name, &cert_path.0, &key_path.0).await - } - (None, None) => { - let home = home.context(build_config_service_sources_error::MissingDirectiveSnafu { - directive: "ssl_certificate", - })?; - let profile = home - .resolve_identity_profile(server_name.clone()) - .await - .context(build_config_service_sources_error::ResolveIdentitySnafu { - name: server_name.to_string(), - })?; - profile.load_identity().await.context( - build_config_service_sources_error::LoadIdentitySnafu { - name: server_name.to_string(), +async fn load_identity( + config: &ServerConfig, +) -> Result { + match config.identity() { + ServerIdentity::Profile(profile) => profile.load_identity().await.context( + build_typed_server_source_error::LoadIdentitySnafu { + name: profile.name().to_string(), + }, + ), + ServerIdentity::Direct { + certificate, + private_key, + } => { + let name = config + .names() + .first() + .ok_or(BuildTypedServerSourceError::MissingName)?; + let cert = tokio::fs::read(certificate.as_ref()).await.context( + build_typed_server_source_error::ReadCertSnafu { + path: certificate.as_ref(), }, - ) - } - (None, Some(_)) => build_config_service_sources_error::MissingDirectiveSnafu { - directive: "ssl_certificate", - } - .fail(), - (Some(_), None) => build_config_service_sources_error::MissingDirectiveSnafu { - directive: "ssl_certificate_key", + )?; + let key = tokio::fs::read(private_key.as_ref()).await.context( + build_typed_server_source_error::ReadKeySnafu { + path: private_key.as_ref(), + }, + )?; + let (certs, key) = crate::tls::validate_tls_material(&cert, &key) + .context(build_typed_server_source_error::InvalidTlsSnafu)?; + Ok(dhttp::identity::Identity::new( + name.clone().into(), + certs, + key, + )) } - .fail(), } } -async fn load_identity_from_paths( - server_name: &DhttpName<'static>, - cert_path: &std::path::Path, - key_path: &std::path::Path, -) -> Result { - let cert_pem = tokio::fs::read(cert_path) - .await - .context(build_config_service_sources_error::ReadCertSnafu { path: cert_path })?; - let key_pem = tokio::fs::read(key_path) - .await - .context(build_config_service_sources_error::ReadKeySnafu { path: key_path })?; - let (certs, key) = crate::tls::validate_tls_material(&cert_pem, &key_pem) - .context(build_config_service_sources_error::InvalidTlsSnafu)?; - Ok(dhttp::identity::Identity::new( - server_name.clone().into(), - certs, - key, - )) -} - impl ServerSource { pub fn name(&self) -> &DhttpName<'static> { match self { - Self::IdentityService(source) => &source.name, - Self::PishooConfig(source) => &source.name, + Self::Typed(source) => &source.name, #[cfg(test)] Self::Fake(source) => &source.name, } } - pub async fn prepare( &self, - ctx: &PrepareContext, + context: &PrepareContext, ) -> Result { match self { - Self::IdentityService(source) => source.prepare(ctx).await, - Self::PishooConfig(source) => source.prepare(ctx).await, + Self::Typed(source) => source.prepare(context).await, #[cfg(test)] Self::Fake(source) => source.prepare(), } } } +pub(crate) fn compute_identity_fingerprint(identity: &dhttp::identity::Identity) -> String { + use sha2::{Digest, Sha256}; + fn hex(bytes: impl AsRef<[u8]>) -> String { + bytes + .as_ref() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() + } + let mut certs = Sha256::new(); + for cert in identity.certs.iter() { + certs.update(cert.as_ref()); + } + let mut key = Sha256::new(); + key.update(identity.key.secret_der()); + format!( + "{}@{}@{}", + identity.name(), + hex(certs.finalize()), + hex(key.finalize()) + ) +} + +#[cfg(test)] +pub struct FakeServerSource { + pub(crate) name: DhttpName<'static>, + outcome: FakePrepareOutcome, +} +#[cfg(test)] +enum FakePrepareOutcome { + Success { + listener_spec: ListenerSpec, + service_generation: u64, + }, + Failure, +} #[cfg(test)] impl FakeServerSource { - pub(crate) fn prepare(&self) -> Result { + fn prepare(&self) -> Result { match &self.outcome { FakePrepareOutcome::Success { listener_spec, service_generation, } => Ok(PreparedServerUpdate { name: self.name.clone(), - listen_request: Self::fake_listen_request(&self.name), + listen_request: fake_listen_request(&self.name), listener_spec: listener_spec.clone(), - service: Arc::new(ServerService::fake()), + service: super::snapshot::ServerService::fake(), + access_logs: AccessLogResourcePlan { + server: gateway::parse::config::ResolvedAccessLogConfig::Disabled, + locations: Box::new([]), + }, fingerprint: ServerFingerprint { listener_spec: listener_spec.clone(), service_generation: *service_generation, }, }), FakePrepareOutcome::Failure => Err(PrepareServerUpdateError::SyntheticFailure { - server_name: self.name.as_full().to_owned(), + server_name: self.name.to_string(), }), } } - - fn dhttp_subject_key_identifier_der() -> Vec { - use dhttp::certificate::{ - CertificateChainKey, CertificateChainKind, CertificateSequence, - DhttpSubjectKeyIdentifier, OwnerHash, - }; - - let owner_hash = - OwnerHash::try_from("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef") - .unwrap(); - let value = DhttpSubjectKeyIdentifier::new( - CertificateChainKey::new( - CertificateSequence::from(0u8), - CertificateChainKind::Primary, - ), - owner_hash, - ) - .to_string(); - - let bytes = value.as_bytes(); - assert!(bytes.len() < 128, "test dhttp ski must use short-form DER"); - let mut der = Vec::with_capacity(bytes.len() + 2); - der.push(0x04); - der.push(bytes.len() as u8); - der.extend_from_slice(bytes); - der - } - - fn fake_listen_request(name: &DhttpName<'static>) -> ListenRequest { - use dhttp::identity::Identity; - - let fqdn = name.as_full().to_owned(); - let key_pair = rcgen::KeyPair::generate().expect("rcgen key generation"); - let mut params = rcgen::CertificateParams::new(vec![fqdn.clone()]).expect("rcgen params"); - params.distinguished_name = rcgen::DistinguishedName::new(); - params - .distinguished_name - .push(rcgen::DnType::CommonName, &fqdn); - params - .custom_extensions - .push(rcgen::CustomExtension::from_oid_content( - &[2, 5, 29, 14], - Self::dhttp_subject_key_identifier_der(), - )); - let cert = params.self_signed(&key_pair).expect("rcgen self-sign"); - let cert_der = rustls::pki_types::CertificateDer::from(cert.der().to_vec()); - let key_der = rustls::pki_types::PrivateKeyDer::try_from(key_pair.serialize_der()) - .expect("rcgen key der"); - let identity = Identity::new(name.clone().into(), vec![cert_der], key_der); - ListenRequest { - identity, - bind: vec![], - dns_resolver_url: None, - } - } } #[cfg(test)] impl ServerSource { - pub(crate) fn fake_success( - name: &str, - service_generation: u64, - listener_spec: ListenerSpec, - ) -> Self { + pub(crate) fn fake_success(name: &str, generation: u64, listener_spec: ListenerSpec) -> Self { Self::Fake(FakeServerSource { name: fake_name(name), outcome: FakePrepareOutcome::Success { listener_spec, - service_generation, + service_generation: generation, }, }) } - pub(crate) fn fake_prepare_error(name: &str) -> Self { Self::Fake(FakeServerSource { name: fake_name(name), @@ -440,7 +402,7 @@ impl ListenerSpec { pub(crate) fn fake(label: &str) -> Self { Self { request_fingerprint: ListenRequestFingerprint { - server_name: fake_name(label), + server_name: fake_name("fixture.dhttp.net"), bind_debug: format!("bind:{label}"), identity_debug: format!("identity:{label}"), dns_resolver_debug: None, @@ -449,766 +411,28 @@ impl ListenerSpec { } } -#[cfg(test)] -impl ServerFingerprint { - pub(crate) fn generation_for_test(&self) -> u64 { - self.service_generation - } -} - #[cfg(test)] fn fake_name(name: &str) -> DhttpName<'static> { - DhttpName::try_from(name.to_owned()).expect("test name must be a valid dhttp name") -} - -#[derive(Debug, Snafu)] -#[snafu(module)] -pub enum BuildConfigServiceSourcesError { - #[snafu(display("failed to canonicalize pishoo config server nodes"))] - Canonicalize { source: gateway::error::Whatever }, - - #[snafu(display("pishoo config service missing `{directive}`"))] - MissingDirective { directive: &'static str }, - - #[snafu(display("failed to read typed configuration value"))] - ConfigQuery { - source: gateway::parse::error::ConfigQueryError, - }, - - #[snafu(display("failed to read certificate at `{}`", path.display()))] - ReadCert { - path: std::path::PathBuf, - source: std::io::Error, - }, - - #[snafu(display("failed to read private key at `{}`", path.display()))] - ReadKey { - path: std::path::PathBuf, - source: std::io::Error, - }, - - #[snafu(display("invalid TLS material"))] - InvalidTls { - source: crate::tls::TlsMaterialError, - }, - - #[snafu(display("failed to resolve identity `{name}` in dhttp home"))] - ResolveIdentity { - name: String, - source: dhttp::home::identity::ssl::ResolveIdentityProfileError, - }, - - #[snafu(display("failed to load identity `{name}` from dhttp home"))] - LoadIdentity { - name: String, - source: dhttp::home::identity::ssl::LoadIdentityError, - }, - - #[snafu(display("implicit dhttp home TLS is ambiguous for multiple server_name values"))] - AmbiguousImplicitTls, - - #[snafu(display("duplicate pishoo config server_name `{name}`"))] - DuplicateServerName { name: String }, - - #[snafu(display("pishoo config service missing `listen`"))] - MissingListen, - - #[snafu(display("failed to prepare context"))] - PrepareContext { source: BuildPrepareContextError }, -} - -#[derive(Debug, Snafu)] -#[snafu(module)] -pub enum BuildPrepareContextError { - #[snafu(display("failed to load default policy bundle"))] - Policy { source: crate::policy::PolicyError }, -} - -impl PrepareContext { - pub async fn load_config_service( - access_rules_uri: Option<&str>, - router_state: RouterState, - ) -> Result { - let policy_bundle = crate::policy::load_policy_bundle(access_rules_uri) - .await - .context(build_prepare_context_error::PolicySnafu)?; - - Ok(Self { - h3_settings: h3_settings(), - access_rules: policy_bundle.location_rules, - router_state, - }) - } - - pub async fn load_worker( - _home: &DhttpHome, - router_state: RouterState, - ) -> Result { - let policy_bundle = crate::policy::load_policy_bundle(None) - .await - .context(build_prepare_context_error::PolicySnafu)?; - - Ok(Self { - h3_settings: h3_settings(), - access_rules: policy_bundle.location_rules, - router_state, - }) - } -} - -fn sqlite_access_rules_uri(path: &std::path::Path) -> String { - format!("sqlite://{}?mode=rw", path.display()) -} - -async fn load_identity_access_rules( - target_node: Option<&Arc>, - identity_profile: &IdentityProfile, - fallback: SharedLocationRuleEvaluator, -) -> Result { - if let Some(uri) = - target_node.and_then(|node| node.get::("access_rules").ok().flatten()) - { - let uri_str = uri.0.to_string(); - return crate::policy::load_policy_bundle(Some(&uri_str)) - .await - .map(|bundle| bundle.location_rules) - .map_err(|source| BuildConfigError::Policy { source }); - } - - let default_access_db = identity_profile.access_db_path(); - match std::fs::metadata(&default_access_db) { - Ok(_) => { - let uri = sqlite_access_rules_uri(&default_access_db); - crate::policy::load_policy_bundle(Some(&uri)) - .await - .map(|bundle| bundle.location_rules) - .map_err(|source| BuildConfigError::Policy { source }) - } - Err(source) if source.kind() == std::io::ErrorKind::NotFound => Ok(fallback), - Err(source) => Err(BuildConfigError::InspectDefaultAccessRules { - path: default_access_db, - source, - }), - } -} - -impl IdentityServiceSource { - pub async fn prepare( - &self, - ctx: &PrepareContext, - ) -> Result { - let identity = self - .identity_profile - .load_identity() - .await - .whatever_context::<_, BuildConfigError>("failed to load TLS material") - .context(prepare_server_update_error::IdentityServiceSnafu)?; - - let conf_path = self.identity_profile.server_conf_path(); - let identity_server_nodes = if conf_path.is_file() { - load_identity_servers(&self.home, &self.identity_profile) - .await - .whatever_context::<_, BuildConfigError>("failed to load identity config") - .context(prepare_server_update_error::IdentityServiceSnafu)? - } else { - Vec::new() - }; - - let mut target_node = None; - let mut target_binds = Vec::new(); - - for server_node in &identity_server_nodes { - let server_names = match server_node.get::("server_name") { - Ok(Some(sn)) => sn, - _ => continue, - }; - - let matches = server_names.0.iter().any(|sn| sn.name == self.name); - if matches { - let listens: Vec = server_node - .get_all::("listen") - .whatever_context::<_, BuildConfigError>("failed to read listen") - .context(prepare_server_update_error::IdentityServiceSnafu)? - .into_iter() - .flat_map(|listen| listen.0.clone()) - .collect(); - if !listens.is_empty() { - target_binds = listens; - target_node = Some(server_node.clone()); - break; - } - } - } - - if target_binds.is_empty() { - let error = ::without_source(format!( - "no listen specifications found for server {}", - self.name - )); - return Err(PrepareServerUpdateError::IdentityService { source: error }); - } - - let dns_resolver_url = target_node - .as_ref() - .and_then(|node| node.get::("dns").ok().flatten()) - .map(|resolver| resolver.0.clone()); - - let listen_request = ListenRequest { - identity: identity.clone(), - bind: target_binds.clone(), - dns_resolver_url: dns_resolver_url.clone(), - }; - - let request_fingerprint = ListenRequestFingerprint { - server_name: self.name.clone(), - bind_debug: format!("{:?}", target_binds), - identity_debug: crate::service::source::compute_identity_fingerprint(&identity), - dns_resolver_debug: dns_resolver_url.map(|u| u.to_string()), - }; - - let listener_spec = ListenerSpec { - request_fingerprint, - }; - - let service_generation = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - - let access_rules = load_identity_access_rules( - target_node.as_ref(), - &self.identity_profile, - ctx.access_rules.clone(), - ) - .await - .context(prepare_server_update_error::IdentityServiceSnafu)?; - - let server_node = target_node.clone().unwrap_or_else(|| { - let registry = gateway::parse::default_registry(); - let doc = gateway::parse::load_config_text( - "", - None, - ®istry, - gateway::parse::registry::BuildOptions { - dhttp_home: None, - identity_profile: None, - }, - ) - .unwrap(); - doc.root - }); - - let service = Arc::new(ServerService { - h3_settings: ctx.h3_settings.clone(), - access_rules, - router_state: ctx.router_state.clone(), - server_node, - access_log_dir: Some(self.identity_profile.logs_dir()), - server_name: self.name.clone(), - }); - - Ok(PreparedServerUpdate { - name: self.name.clone(), - listen_request, - listener_spec: listener_spec.clone(), - service, - fingerprint: ServerFingerprint { - listener_spec, - service_generation, - }, - }) - } -} - -pub(crate) fn compute_identity_fingerprint(identity: &dhttp::identity::Identity) -> String { - use sha2::{Digest, Sha256}; - let mut cert_digest = Sha256::new(); - for cert in identity.certs.iter() { - cert_digest.update(cert.as_ref()); - } - let cert_digest = cert_digest.finalize(); - - let mut key_digest = Sha256::new(); - key_digest.update(identity.key.secret_der()); - let key_digest = key_digest.finalize(); - - format!( - "{}@{}@{}", - identity.name(), - hex_lower(cert_digest.as_ref()), - hex_lower(key_digest.as_ref()) - ) -} - -fn hex_lower(bytes: &[u8]) -> String { - const HEX: &[u8; 16] = b"0123456789abcdef"; - let mut encoded = String::with_capacity(bytes.len() * 2); - for &byte in bytes { - encoded.push(HEX[(byte >> 4) as usize] as char); - encoded.push(HEX[(byte & 0x0f) as usize] as char); - } - encoded + DhttpName::try_from(name.to_owned()).unwrap() } #[cfg(test)] -mod tests { - use dhttp::{ - access::{ - expr::atomics::{AtomicLocationRuleExpr, EvalError}, - policy::LocationRuleRequest, - }, - name::DhttpName, - }; - - use super::*; - - struct TestAccessRequest; - - impl LocationRuleRequest for TestAccessRequest { - fn eval_atomic(&self, expr: &AtomicLocationRuleExpr) -> Result { - Ok(matches!(expr, AtomicLocationRuleExpr::Any(..))) - } - } - - #[test] - fn fingerprint_incorporates_cert_and_key() { - let name = DhttpName::try_from("test.dhttp.net".to_owned()).unwrap(); - let req1 = FakeServerSource::fake_listen_request(&name); - let req2 = FakeServerSource::fake_listen_request(&name); - - let fp1 = compute_identity_fingerprint(&req1.identity); - let fp2 = compute_identity_fingerprint(&req2.identity); - - assert_ne!(fp1, fp2, "Fingerprints must differ when cert/key differ"); - assert!(fp1.starts_with("test.dhttp.net@")); - } - - #[cfg(feature = "sshd")] - struct DummySpawner; - #[cfg(feature = "sshd")] - impl gateway::control_plane::DynSpawnSession for DummySpawner { - fn spawn_session<'a>( - &'a self, - _username: &'a str, - ) -> futures::future::BoxFuture< - 'a, - Result< - gateway::control_plane::SessionTransport, - Box, - >, - > { - Box::pin(async { Err("dummy".into()) }) - } - } - #[cfg(feature = "sshd")] - struct DummyScope; - #[cfg(feature = "sshd")] - impl gateway::reverse::router::DynTaskScope for DummyScope { - fn token(&self) -> tokio_util::sync::CancellationToken { - tokio_util::sync::CancellationToken::new() - } - fn spawn(&self, _task: futures::future::BoxFuture<'static, ()>) {} - } - - fn dummy_router_state() -> gateway::reverse::router::RouterState { - gateway::reverse::router::RouterState { - #[cfg(feature = "sshd")] - session_spawner: std::sync::Arc::new(DummySpawner), - #[cfg(feature = "sshd")] - task_scope: std::sync::Arc::new(DummyScope), - } - } - - #[test] - fn server_source_uses_identity_service_variant_name() { - let source = ServerSource::IdentityService(IdentityServiceSource { - name: fake_name("identity-source.dhttp.net"), - home: dhttp::home::DhttpHome::new(std::path::PathBuf::from("/tmp/home")), - identity_profile: dhttp::home::DhttpHome::new(std::path::PathBuf::from("/tmp/home")) - .identity_profile(fake_name("identity-source.dhttp.net")), - }); - - assert_eq!(source.name().as_full(), "identity-source.dhttp.net"); - } - - fn unique_test_dir(label: &str) -> std::path::PathBuf { - let nanos = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .expect("clock after epoch") - .as_nanos(); - std::env::temp_dir().join(format!("pishoo-{label}-{nanos}")) - } - - fn identity_pem(name: &dhttp::name::DhttpName<'static>) -> (String, String) { - let fqdn = name.as_full().to_owned(); - let key_pair = rcgen::KeyPair::generate().expect("rcgen key generation"); - let mut params = rcgen::CertificateParams::new(vec![fqdn.clone()]).expect("rcgen params"); - params.distinguished_name = rcgen::DistinguishedName::new(); - params - .distinguished_name - .push(rcgen::DnType::CommonName, &fqdn); - params - .custom_extensions - .push(rcgen::CustomExtension::from_oid_content( - &[2, 5, 29, 14], - FakeServerSource::dhttp_subject_key_identifier_der(), - )); - let cert = params.self_signed(&key_pair).expect("rcgen self-sign"); - (cert.pem(), key_pair.serialize_pem()) - } - - async fn write_identity( - profile: &dhttp::home::identity::IdentityProfile, - name: &dhttp::name::DhttpName<'static>, - ) { - let (cert_pem, key_pem) = identity_pem(name); - profile - .save_identity(cert_pem.as_bytes(), key_pem.as_bytes()) - .await - .expect("save identity"); - } - - async fn write_identity_server_conf(profile: &dhttp::home::identity::IdentityProfile) { - tokio::fs::write( - profile.server_conf_path(), - "server { listen all 0; location / { root .; } }", - ) - .await - .expect("write identity server config"); - } - - async fn write_identity_server_conf_with_access_rules( - profile: &dhttp::home::identity::IdentityProfile, - access_rules: &str, - ) { - tokio::fs::write( - profile.server_conf_path(), - format!( - "server {{ listen all 0; access_rules {access_rules}; location / {{ root .; }} }}" - ), - ) - .await - .expect("write identity server config"); - } - - async fn write_minimal_access_db(profile: &dhttp::home::identity::IdentityProfile) { - use sea_orm::{ConnectionTrait, Database, Statement}; - - let path = profile.access_db_path(); - tokio::fs::create_dir_all(path.parent().expect("access db path has parent")) - .await - .expect("create access db directory"); - - let uri = format!("sqlite://{}?mode=rwc", path.display()); - let db = Database::connect(&uri).await.expect("connect access db"); - db.execute_unprepared( - "CREATE TABLE location_rule_sets ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - pattern JSON NOT NULL UNIQUE, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL - );", - ) - .await - .expect("create location rule sets"); - db.execute_unprepared( - "CREATE TABLE location_rules ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - location_id INTEGER NOT NULL, - action INTEGER NOT NULL, - exprs JSON NOT NULL, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL - );", - ) - .await - .expect("create location rules"); - db.execute(Statement::from_string( - sea_orm::DatabaseBackend::Sqlite, - "INSERT INTO location_rule_sets (id, pattern, created_at, updated_at) - VALUES (1, '\"/\"', '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z')" - .to_string(), - )) - .await - .expect("insert location rule set"); - db.execute(Statement::from_string( - sea_orm::DatabaseBackend::Sqlite, - "INSERT INTO location_rules (location_id, action, exprs, created_at, updated_at) - VALUES (1, 0, '{\"infix\":\"*?\",\"polish\":\"*? \"}', '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z')" - .to_string(), - )) - .await - .expect("insert access rule"); - } - - fn empty_prepare_context() -> PrepareContext { - PrepareContext { - h3_settings: std::sync::Arc::new(dhttp::h3x::dhttp::settings::Settings::default()), - access_rules: std::sync::Arc::new( - dhttp::access::matcher::LocationRulesMatcher::default(), - ), - router_state: dummy_router_state(), - } - } - - #[tokio::test] - async fn identity_service_prepare_loads_default_access_db_when_it_exists() { - let home = dhttp::home::DhttpHome::new(unique_test_dir("identity-default-access-db")); - let name = fake_name("default-access.dhttp.net"); - let profile = home.identity_profile(name.clone()); - write_identity(&profile, &name).await; - write_identity_server_conf(&profile).await; - write_minimal_access_db(&profile).await; - - let source = IdentityServiceSource { - name: name.clone(), - home, - identity_profile: profile, - }; - - let prepared = source - .prepare(&empty_prepare_context()) - .await - .expect("default access db should load"); - - let decision = prepared - .service - .access_rules - .evaluate("/", &TestAccessRequest) - .await - .expect("identity default access db should provide root rule decision"); - assert_eq!(decision.location.to_string(), "/"); - } - - #[tokio::test] - async fn identity_service_prepare_uses_empty_access_rules_when_default_db_is_missing() { - let home = dhttp::home::DhttpHome::new(unique_test_dir("identity-missing-access-db")); - let name = fake_name("missing-access.dhttp.net"); - let profile = home.identity_profile(name.clone()); - write_identity(&profile, &name).await; - write_identity_server_conf(&profile).await; - - let source = IdentityServiceSource { - name: name.clone(), - home, - identity_profile: profile, - }; - - let prepared = source - .prepare(&empty_prepare_context()) - .await - .expect("missing default access db should fall back to empty rules"); - - assert!( - prepared - .service - .access_rules - .evaluate("/", &TestAccessRequest) - .await - .is_err(), - "missing default access db should not synthesize rules" - ); - } - - #[tokio::test] - async fn identity_service_prepare_errors_when_default_access_db_exists_but_is_invalid() { - let home = dhttp::home::DhttpHome::new(unique_test_dir("identity-invalid-access-db")); - let name = fake_name("invalid-access.dhttp.net"); - let profile = home.identity_profile(name.clone()); - write_identity(&profile, &name).await; - write_identity_server_conf(&profile).await; - let db_path = profile.access_db_path(); - tokio::fs::create_dir_all(db_path.parent().expect("access db path has parent")) - .await - .expect("create access db directory"); - tokio::fs::write(&db_path, b"not sqlite") - .await - .expect("write invalid access db"); - - let source = IdentityServiceSource { - name, - home, - identity_profile: profile, - }; - - let error = match source.prepare(&empty_prepare_context()).await { - Ok(_) => panic!("invalid existing default access db should be an error"), - Err(error) => error, - }; - let report = snafu::Report::from_error(&error).to_string(); - - assert!( - report.contains("failed to load access rules"), - "unexpected error report: {report}" - ); - } - - #[tokio::test] - async fn identity_service_prepare_errors_when_explicit_access_rules_db_is_invalid() { - let home = dhttp::home::DhttpHome::new(unique_test_dir("identity-invalid-explicit-access")); - let name = fake_name("invalid-explicit-access.dhttp.net"); - let profile = home.identity_profile(name.clone()); - write_identity(&profile, &name).await; - write_identity_server_conf_with_access_rules(&profile, "sqlite:./db/access.db?mode=ro") - .await; - let db_path = profile.access_db_path(); - tokio::fs::create_dir_all(db_path.parent().expect("access db path has parent")) - .await - .expect("create access db directory"); - tokio::fs::write(&db_path, b"not sqlite") - .await - .expect("write invalid access db"); - - let source = IdentityServiceSource { - name, - home, - identity_profile: profile, - }; - - let error = match source.prepare(&empty_prepare_context()).await { - Ok(_) => panic!("invalid explicit access_rules db should be an error"), - Err(error) => error, - }; - let report = snafu::Report::from_error(&error).to_string(); - - assert!( - report.contains("failed to load access rules"), - "unexpected error report: {report}" - ); - } - - #[tokio::test] - async fn pishoo_config_service_loads_identity_from_home_when_tls_is_omitted() { - let home = dhttp::home::DhttpHome::new(unique_test_dir("config-service-home")); - let name = fake_name("config-home.dhttp.net"); - let profile = home.identity_profile(name.clone()); - write_identity(&profile, &name).await; - - let config = gateway::parse::load_config_text( - "pishoo { server { listen all 443; server_name config-home.dhttp.net; } }", - Some(home.as_path()), - &gateway::parse::default_registry(), - gateway::parse::registry::BuildOptions { - dhttp_home: Some(&home), - identity_profile: None, - }, - ) - .expect("parse config with home context"); - let pishoo = config.root.children("pishoo").unwrap()[0].clone(); - let server = pishoo.children("server").unwrap()[0].clone(); - - let (sources, _ctx) = - PishooConfigServiceSource::load_all(&[server], Some(&home), dummy_router_state()) - .await - .expect("load config service sources"); - - assert_eq!(sources.len(), 1); - assert_eq!(sources[0].name().as_full(), "config-home.dhttp.net"); - } - - #[tokio::test] - async fn explicit_config_service_rejects_omitted_tls() { - let config = gateway::parse::parse_config_str_for_test( - "pishoo { server { listen all 443; server_name explicit-missing.dhttp.net; } }", - ) - .expect_err("explicit config parse should reject missing TLS"); - - let report = snafu::Report::from_error(&config.error).to_string(); - assert!(report.contains("missing ssl_certificate directive")); - } - - #[cfg(feature = "sshd")] - #[test] - fn h3_settings_advertise_webtransport_support_when_sshd_enabled() { - let settings = h3_settings(); - - assert!(settings.enable_connect_protocol()); - assert!(settings.enable_webtransport()); - assert!(settings.webtransport_flow_control_enabled()); - } - - #[tokio::test] - async fn pishoo_config_service_source_prepare_produces_listener_spec_with_cert_fingerprint() { - let name = DhttpName::try_from("test.dhttp.net".to_owned()).unwrap(); - let req1 = FakeServerSource::fake_listen_request(&name); - - let config = gateway::parse::parse_config_str_for_test( - "server { listen 127.0.0.1:443; server_name localhost; ssl_certificate /tmp/a; ssl_certificate_key /tmp/b; }", - ) - .unwrap(); - let server_node = config.root.children("server").unwrap()[0].clone(); - - let source = PishooConfigServiceSource { - name: name.clone(), - identity: req1.identity.clone(), - bind: req1.bind.clone(), - dns_resolver_url: req1.dns_resolver_url.clone(), - server_node, - }; - - let ctx = PrepareContext { - h3_settings: std::sync::Arc::new(dhttp::h3x::dhttp::settings::Settings::default()), - access_rules: std::sync::Arc::new( - dhttp::access::matcher::LocationRulesMatcher::default(), - ), - router_state: dummy_router_state(), - }; - - let prepared = source.prepare(&ctx).await.expect("prepare success"); - - let identity_debug = &prepared.listener_spec.request_fingerprint.identity_debug; - assert!( - identity_debug.starts_with("test.dhttp.net@"), - "fingerprint should contain name and separator: {}", - identity_debug - ); - assert!(identity_debug.len() > 20, "fingerprint should contain hash"); - } - - #[tokio::test] - async fn pishoo_config_service_source_prepare_two_rotated_certs_produce_distinct_fingerprints() - { - let name = DhttpName::try_from("rotated.dhttp.net".to_owned()).unwrap(); - let req1 = FakeServerSource::fake_listen_request(&name); - let req2 = FakeServerSource::fake_listen_request(&name); - - let config = gateway::parse::parse_config_str_for_test( - "server { listen 127.0.0.1:443; server_name localhost; ssl_certificate /tmp/a; ssl_certificate_key /tmp/b; }", - ) - .unwrap(); - let server_node = config.root.children("server").unwrap()[0].clone(); - - let source1 = PishooConfigServiceSource { - name: name.clone(), - identity: req1.identity.clone(), - bind: req1.bind.clone(), - dns_resolver_url: req1.dns_resolver_url.clone(), - server_node: server_node.clone(), - }; - - let source2 = PishooConfigServiceSource { - name: name.clone(), - identity: req2.identity.clone(), - bind: req2.bind.clone(), - dns_resolver_url: req2.dns_resolver_url.clone(), - server_node: server_node.clone(), - }; - - let ctx = PrepareContext { - h3_settings: std::sync::Arc::new(dhttp::h3x::dhttp::settings::Settings::default()), - access_rules: std::sync::Arc::new( - dhttp::access::matcher::LocationRulesMatcher::default(), - ), - router_state: dummy_router_state(), - }; - - let prepared1 = source1.prepare(&ctx).await.expect("prepare success"); - let prepared2 = source2.prepare(&ctx).await.expect("prepare success"); - - assert_ne!( - prepared1.listener_spec.request_fingerprint.identity_debug, - prepared2.listener_spec.request_fingerprint.identity_debug, - "Rotated certs should produce distinct fingerprints" - ); +fn fake_listen_request(name: &DhttpName<'static>) -> ListenRequest { + let fqdn = name.as_full().to_owned(); + let key_pair = rcgen::KeyPair::generate().unwrap(); + let mut params = rcgen::CertificateParams::new(vec![fqdn.clone()]).unwrap(); + params.distinguished_name = rcgen::DistinguishedName::new(); + params + .distinguished_name + .push(rcgen::DnType::CommonName, &fqdn); + let cert = params.self_signed(&key_pair).unwrap(); + ListenRequest { + identity: dhttp::identity::Identity::new( + name.clone().into(), + vec![rustls::pki_types::CertificateDer::from(cert.der().to_vec())], + rustls::pki_types::PrivateKeyDer::try_from(key_pair.serialize_der()).unwrap(), + ), + bind: vec![], + dns_resolver_url: None, } } diff --git a/pishoo/src/worker.rs b/pishoo/src/worker.rs index 1db9864d..7e4d620e 100644 --- a/pishoo/src/worker.rs +++ b/pishoo/src/worker.rs @@ -2,5 +2,4 @@ //! //! These modules are only linked into the worker binary (`pishoo-worker`). -pub mod config; pub mod remote_plane; diff --git a/pishoo/src/worker/config.rs b/pishoo/src/worker/config.rs deleted file mode 100644 index 6cb843a3..00000000 --- a/pishoo/src/worker/config.rs +++ /dev/null @@ -1,136 +0,0 @@ -//! Worker-side configuration source loading. -//! -//! Workers scan the configured DHTTP home for identities and return -//! [`IdentityServiceSource`](crate::service::source::IdentityServiceSource) values. -//! Runtime preparation happens in [`crate::service::source`]. - -use std::path::PathBuf; - -use dhttp::home::DhttpHome; -use futures::StreamExt; -use gateway::error::Whatever; -use snafu::Snafu; - -use crate::policy; - -/// Errors during worker configuration loading. -#[derive(Debug, Snafu)] -#[snafu(module)] -pub enum BuildConfigError { - #[snafu(transparent)] - Whatever { source: Whatever }, - #[snafu(display("failed to inspect default access rules path `{}`", path.display()))] - InspectDefaultAccessRules { - path: PathBuf, - source: std::io::Error, - }, - #[snafu(display("failed to load access rules"))] - Policy { source: policy::PolicyError }, -} - -impl snafu::FromString for BuildConfigError { - type Source = ::Source; - - fn without_source(message: String) -> Self { - Whatever::without_source(message).into() - } - - fn with_source(source: Self::Source, message: String) -> Self { - Whatever::with_source(source, message).into() - } -} - -pub async fn load_identity_service_sources( - dhttp_home: &DhttpHome, -) -> Result, BuildConfigError> { - let mut identity_names = Vec::new(); - let mut stream = std::pin::pin!(dhttp_home.identity_profile_names()); - while let Some(result) = stream.next().await { - match result { - Ok(name) => identity_names.push(name), - Err(error) => { - tracing::warn!( - error = %snafu::Report::from_error(&error), - "failed to read identity entry, skipping" - ); - } - } - } - - let mut sources = Vec::new(); - for name in identity_names { - let identity_profile = match dhttp_home.resolve_identity_profile(name.borrow()).await { - Ok(home) => home, - Err(error) => { - tracing::warn!( - %name, - error = %snafu::Report::from_error(&error), - "failed to load identity home, skipping" - ); - continue; - } - }; - - let server_conf_path = identity_profile.server_conf_path(); - if !server_conf_path.is_file() { - tracing::debug!( - %name, - path = %server_conf_path.display(), - "identity profile has no server.conf, skipping" - ); - continue; - } - - sources.push(crate::service::source::IdentityServiceSource { - name, - home: dhttp_home.clone(), - identity_profile, - }); - } - - Ok(sources) -} - -#[cfg(test)] -mod tests { - use dhttp::{home::DhttpHome, name::DhttpName}; - - use super::load_identity_service_sources; - - fn unique_test_dir(label: &str) -> std::path::PathBuf { - let nanos = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .expect("clock after epoch") - .as_nanos(); - std::env::temp_dir().join(format!("pishoo-worker-config-{label}-{nanos}")) - } - - #[tokio::test] - async fn identity_service_sources_skip_profiles_without_server_conf() { - let home = DhttpHome::new(unique_test_dir("server-conf-filter")); - let with_conf = DhttpName::try_from("with-conf.dhttp.net".to_owned()).unwrap(); - let without_conf = DhttpName::try_from("without-conf.dhttp.net".to_owned()).unwrap(); - let with_conf_profile = home.identity_profile(with_conf.clone()); - let without_conf_profile = home.identity_profile(without_conf); - - tokio::fs::create_dir_all(with_conf_profile.ssl_dir()) - .await - .expect("create profile with server.conf"); - tokio::fs::write( - with_conf_profile.server_conf_path(), - "server { listen all 443; }", - ) - .await - .expect("write server.conf"); - tokio::fs::create_dir_all(without_conf_profile.ssl_dir()) - .await - .expect("create profile without server.conf"); - - let sources = load_identity_service_sources(&home) - .await - .expect("load identity service sources"); - - assert_eq!(sources.len(), 1); - assert_eq!(sources[0].name, with_conf); - } -} diff --git a/pishoo/src/worker/remote_plane.rs b/pishoo/src/worker/remote_plane.rs index f2609061..ba0e41d5 100644 --- a/pishoo/src/worker/remote_plane.rs +++ b/pishoo/src/worker/remote_plane.rs @@ -124,39 +124,14 @@ pub enum RemoteConnectError { Protocol { source: crate::ipc::ConnectError }, } -/// Error from a remote rebuild request. -#[derive(Debug, Snafu)] -#[snafu(module)] -pub enum RemoteRebuildError { - #[snafu(transparent)] - Protocol { - source: crate::ipc::RebuildListenError, - }, -} - impl gateway::control_plane::ProvideListener for RemoteControlPlane { type Listener = IpcListener; type ListenError = RemoteListenError; - type RebuildError = RemoteRebuildError; async fn listener(&self, request: ListenRequest) -> Result { let ipc_client = self.client.listener(request).await?; Ok(IpcListener::new(ipc_client, self.fd_transfer.clone())) } - - async fn rebuild_listener( - &self, - _old: Self::Listener, - request: ListenRequest, - ) -> Result { - // _old is consumed without explicit shutdown: root destroys its side - // of the listener as part of the rebuild critical section, so calling - // shutdown on the old IpcListener would race against a server that - // has already gone away. Root disarms the old handle's release guard - // before installing the replacement. - let ipc_client = self.client.rebuild_listener(request).await?; - Ok(IpcListener::new(ipc_client, self.fd_transfer.clone())) - } } impl gateway::control_plane::ProvideConnector for RemoteControlPlane { @@ -185,7 +160,7 @@ mod tests { use super::RemoteControlPlane; use crate::ipc::{ ConnectError, ControlPlane, ControlPlaneClient, ControlPlaneServerShared, ListenError, - RebuildListenError, SpawnSessionError, + SpawnSessionError, }; struct AckingSpawnPlane { @@ -202,15 +177,6 @@ mod tests { }) } - async fn rebuild_listener( - &self, - _request: ListenRequest, - ) -> Result { - Err(RebuildListenError::Internal { - message: "not used by this test".to_owned(), - }) - } - async fn connector( &self, _request: ConnectorRequest, diff --git a/pishoo/tests/main.rs b/pishoo/tests/main.rs index 68847cf8..92aebf45 100644 --- a/pishoo/tests/main.rs +++ b/pishoo/tests/main.rs @@ -250,7 +250,7 @@ fn root_state_exposes_explicit_listener_operations() { assert!(source.contains("pub async fn acquire_listener")); assert!(source.contains("pub async fn release_listener")); - assert!(source.contains("pub async fn rebuild_listener")); + assert!(!source.contains("pub async fn rebuild_listener")); assert!(source.contains("pub async fn clear_listener_poison")); assert!(!source.contains("pub async fn release_server")); } @@ -324,12 +324,11 @@ fn hypervisor_uses_global_service_module_name() { } #[test] -fn entry_config_uses_pishoo_config_service_names() { - let entry_source = include_str!("../src/config/entry.rs"); +fn global_plan_owns_direct_server_candidates() { + let plan_source = include_str!("../src/config/plan.rs"); let global_service_source = include_str!("../src/hypervisor/global_service.rs"); - assert!(entry_source.contains("config_services")); - assert!(!entry_source.contains("local_servers")); - assert!(!entry_source.contains("parse_local_servers")); + assert!(plan_source.contains("direct_servers")); + assert!(!plan_source.contains("local_servers")); assert!(!global_service_source.contains("local_servers")); } diff --git a/pishoo/tests/worker.rs b/pishoo/tests/worker.rs index 4f02520c..84e36943 100644 --- a/pishoo/tests/worker.rs +++ b/pishoo/tests/worker.rs @@ -4,7 +4,7 @@ fn worker_requests_root_owned_connector() { assert!( worker_source.contains("RemoteControlPlane::new(") - && worker_source.contains("bootstrap.control_plane"), + && worker_source.contains("control_plane,\n } = bootstrap"), "worker must use RemoteControlPlane backed by the root-provided client" ); assert!( @@ -52,32 +52,16 @@ fn worker_stops_server_runtimes_before_exit() { } #[test] -fn listener_rebuild_uses_control_plane_rebuild_call() { +fn listener_changes_use_only_shutdown_and_normal_acquisition() { let ipc_source = include_str!("../src/ipc.rs"); let remote_source = include_str!("../src/worker/remote_plane.rs"); let local_source = include_str!("../src/hypervisor/in_process_plane.rs"); let ipc_server_source = include_str!("../src/hypervisor/ipc_server.rs"); - assert!( - ipc_source.contains("async fn rebuild_listener"), - "ipc ControlPlane trait must expose async fn rebuild_listener" - ); - assert!( - ipc_source.contains("RebuildListenError"), - "ipc must define a dedicated RebuildListenError type" - ); - assert!( - ipc_server_source.contains("rebuild_listener"), - "root WorkerControlPlane must implement rebuild_listener" - ); - assert!( - remote_source.contains("rebuild_listener"), - "RemoteControlPlane must expose rebuild_listener consuming the old IpcListener" - ); - assert!( - local_source.contains("rebuild_listener"), - "InProcessControlPlane must expose rebuild_listener consuming the old RegisteredEndpoint" - ); + for source in [ipc_source, ipc_server_source, remote_source, local_source] { + assert!(!source.contains("rebuild_listener")); + assert!(!source.contains("RebuildListen")); + } } #[test] @@ -104,8 +88,8 @@ fn accept_state_aborts_listener_task_on_drop() { "service module must expose the accept submodule" ); assert!( - accept_source.contains("pub enum AcceptState"), - "service::accept must define an AcceptState enum" + accept_source.contains("pub struct AcceptState"), + "service::accept must define the owned AcceptState" ); assert!( accept_source.contains("task: AbortOnDropHandle"), @@ -139,8 +123,8 @@ fn worker_uses_dhttp_home_api_for_user_home() { let worker_source = include_str!("../src/bin/pishoo_worker.rs"); assert!( - worker_source.contains("DhttpHome::for_user_home_dir"), - "worker must construct user dhttp home through the dhttp home API" + worker_source.contains("account.dhttp_home().clone()"), + "worker must use the root-selected DhttpHome from its typed account bootstrap" ); assert!( !worker_source.contains("join(\".dhttp\")"),