From 261e6b1bdbbd61938cbd30a9a5d976fc376300e4 Mon Sep 17 00:00:00 2001 From: GW_MC <72297530+GWMCwing@users.noreply.github.com> Date: Sat, 21 Mar 2026 03:06:41 +0000 Subject: [PATCH 01/10] feat: Update project configuration and add new development commands --- .gitignore | 2 + Cargo.lock | 602 +++++++++++++++++++++++++++++++++-------------------- Cargo.toml | 5 + justfile | 10 +- 4 files changed, 387 insertions(+), 232 deletions(-) diff --git a/.gitignore b/.gitignore index 75ec03c..bdc6b78 100644 --- a/.gitignore +++ b/.gitignore @@ -160,3 +160,5 @@ target **/mutants.out*/ .local/ + +certs/ diff --git a/Cargo.lock b/Cargo.lock index 42062f7..c85cdfd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,6 +8,17 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + [[package]] name = "ahash" version = "0.7.8" @@ -302,6 +313,45 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "asn1-rs" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56624a96882bb8c26d61312ae18cb45868e5a9992ea73c58e45c3101e56a1e60" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "async-attributes" version = "1.1.2" @@ -481,47 +531,13 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" -[[package]] -name = "axum" -version = "0.7.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" -dependencies = [ - "async-trait", - "axum-core 0.4.5", - "bytes", - "futures-util", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-util", - "itoa", - "matchit 0.7.3", - "memchr", - "mime", - "percent-encoding", - "pin-project-lite", - "rustversion", - "serde", - "serde_json", - "serde_path_to_error", - "serde_urlencoded", - "sync_wrapper", - "tokio", - "tower", - "tower-layer", - "tower-service", - "tracing", -] - [[package]] name = "axum" version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" dependencies = [ - "axum-core 0.5.6", + "axum-core", "base64", "bytes", "form_urlencoded", @@ -532,7 +548,7 @@ dependencies = [ "hyper", "hyper-util", "itoa", - "matchit 0.8.4", + "matchit", "memchr", "mime", "percent-encoding", @@ -551,27 +567,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "axum-core" -version = "0.4.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" -dependencies = [ - "async-trait", - "bytes", - "futures-util", - "http", - "http-body", - "http-body-util", - "mime", - "pin-project-lite", - "rustversion", - "sync_wrapper", - "tower-layer", - "tower-service", - "tracing", -] - [[package]] name = "axum-core" version = "0.5.6" @@ -732,6 +727,15 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +[[package]] +name = "bzip2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a53fac24f34a81bc9954b5d6cfce0c21e18ec6959f44f56e8e90e4bb7c346c" +dependencies = [ + "libbz2-rs-sys", +] + [[package]] name = "cc" version = "1.2.56" @@ -739,6 +743,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] @@ -779,6 +785,16 @@ dependencies = [ "windows-link", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "clap" version = "4.5.60" @@ -880,6 +896,12 @@ dependencies = [ "tiny-keccak", ] +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + [[package]] name = "convert_case" version = "0.6.0" @@ -1019,6 +1041,12 @@ version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" +[[package]] +name = "deflate64" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "807800ff3288b621186fe0a8f3392c4652068257302709c24efd918c3dffcdc2" + [[package]] name = "der" version = "0.7.10" @@ -1030,6 +1058,20 @@ dependencies = [ "zeroize", ] +[[package]] +name = "der-parser" +version = "10.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom", + "num-bigint", + "num-traits", + "rusticata-macros", +] + [[package]] name = "deranged" version = "0.5.8" @@ -1244,8 +1286,8 @@ version = "1.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" dependencies = [ - "crc32fast", "miniz_oxide", + "zlib-rs", ] [[package]] @@ -1459,11 +1501,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 6.0.0", "rand_core 0.10.0", "wasip2", "wasip3", + "wasm-bindgen", ] [[package]] @@ -1727,16 +1771,13 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ - "base64", "bytes", "futures-channel", "futures-util", "http", "http-body", "hyper", - "ipnet", "libc", - "percent-encoding", "pin-project-lite", "socket2", "tokio", @@ -1915,19 +1956,12 @@ dependencies = [ ] [[package]] -name = "ipnet" -version = "2.12.0" +name = "inout" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" - -[[package]] -name = "iri-string" -version = "0.7.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" dependencies = [ - "memchr", - "serde", + "generic-array", ] [[package]] @@ -1951,6 +1985,16 @@ version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + [[package]] name = "js-sys" version = "0.3.91" @@ -2069,6 +2113,12 @@ dependencies = [ "lexical-util", ] +[[package]] +name = "libbz2-rs-sys" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c4a545a15244c7d945065b5d392b2d2d7f21526fba56ce51467b06ed445e8f7" + [[package]] name = "libc" version = "0.2.182" @@ -2133,6 +2183,15 @@ dependencies = [ "value-bag", ] +[[package]] +name = "lzma-rust2" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47bb1e988e6fb779cf720ad431242d3f03167c1b3f2b1aae7f1a94b2495b36ae" +dependencies = [ + "sha2", +] + [[package]] name = "matchers" version = "0.2.0" @@ -2142,12 +2201,6 @@ dependencies = [ "regex-automata", ] -[[package]] -name = "matchit" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" - [[package]] name = "matchit" version = "0.8.4" @@ -2186,6 +2239,12 @@ dependencies = [ "unicase", ] +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -2256,6 +2315,16 @@ dependencies = [ "tempfile", ] +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -2357,6 +2426,7 @@ version = "0.1.0" dependencies = [ "async-trait", "chrono", + "clap", "config", "futures", "hex", @@ -2364,7 +2434,6 @@ dependencies = [ "mockall", "nxmesh-core", "nxmesh-proto", - "reqwest", "serde", "serde_json", "sha2", @@ -2378,6 +2447,7 @@ dependencies = [ "tracing", "tracing-subscriber", "uuid", + "zip", ] [[package]] @@ -2399,8 +2469,9 @@ dependencies = [ "argon2", "async-stream", "async-trait", - "axum 0.8.8", + "axum", "chrono", + "clap", "config", "futures", "handlebars", @@ -2411,12 +2482,14 @@ dependencies = [ "nxmesh-migration", "nxmesh-proto", "rand 0.10.0", + "rcgen", "sea-orm", "sea-orm-migration", "serde", "serde_json", "sha2", "thiserror", + "time", "tokio", "tokio-test", "toml", @@ -2426,9 +2499,9 @@ dependencies = [ "tracing", "tracing-subscriber", "utoipa", - "utoipa-swagger-ui", "uuid", "validator", + "zip", ] [[package]] @@ -2445,9 +2518,20 @@ version = "0.1.0" dependencies = [ "prost", "tonic", + "tonic-async-interceptor", + "tonic-prost", "tonic-prost-build", ] +[[package]] +name = "oid-registry" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7" +dependencies = [ + "asn1-rs", +] + [[package]] name = "once_cell" version = "1.21.3" @@ -2593,6 +2677,16 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest", + "hmac", +] + [[package]] name = "pem" version = "3.0.6" @@ -2796,6 +2890,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +[[package]] +name = "ppmd-rust" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efca4c95a19a79d1c98f791f10aebd5c1363b473244630bb7dbde1dc98455a24" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -2850,30 +2950,6 @@ dependencies = [ "toml_edit", ] -[[package]] -name = "proc-macro-error" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" -dependencies = [ - "proc-macro-error-attr", - "proc-macro2", - "quote", - "syn 1.0.109", - "version_check", -] - -[[package]] -name = "proc-macro-error-attr" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" -dependencies = [ - "proc-macro2", - "quote", - "version_check", -] - [[package]] name = "proc-macro-error-attr2" version = "2.0.0" @@ -3114,6 +3190,20 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba" +[[package]] +name = "rcgen" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10b99e0098aa4082912d4c649628623db6aba77335e4f4569ff5083a6448b32e" +dependencies = [ + "pem", + "ring", + "rustls-pki-types", + "time", + "x509-parser", + "yasna", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -3170,37 +3260,6 @@ dependencies = [ "bytecheck", ] -[[package]] -name = "reqwest" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" -dependencies = [ - "base64", - "bytes", - "futures-core", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-util", - "js-sys", - "log", - "percent-encoding", - "pin-project-lite", - "serde", - "serde_json", - "sync_wrapper", - "tokio", - "tower", - "tower-http", - "tower-service", - "url", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", -] - [[package]] name = "ring" version = "0.17.14" @@ -3278,40 +3337,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "rust-embed" -version = "8.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04113cb9355a377d83f06ef1f0a45b8ab8cd7d8b1288160717d66df5c7988d27" -dependencies = [ - "rust-embed-impl", - "rust-embed-utils", - "walkdir", -] - -[[package]] -name = "rust-embed-impl" -version = "8.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da0902e4c7c8e997159ab384e6d0fc91c221375f6894346ae107f47dd0f3ccaa" -dependencies = [ - "proc-macro2", - "quote", - "rust-embed-utils", - "syn 2.0.117", - "walkdir", -] - -[[package]] -name = "rust-embed-utils" -version = "8.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5bcdef0be6fe7f6fa333b1073c949729274b05f123a0ad7efcb8efd878e5c3b1" -dependencies = [ - "sha2", - "walkdir", -] - [[package]] name = "rust-ini" version = "0.21.3" @@ -3347,6 +3372,15 @@ dependencies = [ "semver", ] +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom", +] + [[package]] name = "rustix" version = "1.1.4" @@ -3366,6 +3400,7 @@ version = "0.23.37" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" dependencies = [ + "log", "once_cell", "ring", "rustls-pki-types", @@ -3374,6 +3409,18 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-native-certs" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + [[package]] name = "rustls-pki-types" version = "1.14.0" @@ -3406,15 +3453,6 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" -[[package]] -name = "same-file" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" -dependencies = [ - "winapi-util", -] - [[package]] name = "schannel" version = "0.1.28" @@ -4125,9 +4163,6 @@ name = "sync_wrapper" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" -dependencies = [ - "futures-core", -] [[package]] name = "synstructure" @@ -4202,6 +4237,7 @@ checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", "itoa", + "js-sys", "num-conv", "powerfmt", "serde_core", @@ -4287,6 +4323,16 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "tokio-stream" version = "0.1.18" @@ -4401,7 +4447,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fec7c61a0695dc1887c1b53952990f3ad2e3a31453e1f49f10e75424943a93ec" dependencies = [ "async-trait", - "axum 0.8.8", + "axum", "base64", "bytes", "h2", @@ -4413,9 +4459,11 @@ dependencies = [ "hyper-util", "percent-encoding", "pin-project", + "rustls-native-certs", "socket2", "sync_wrapper", "tokio", + "tokio-rustls", "tokio-stream", "tower", "tower-layer", @@ -4423,6 +4471,21 @@ dependencies = [ "tracing", ] +[[package]] +name = "tonic-async-interceptor" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d86dde0491d10837902da3df4ee565cf49f623a7be023cf969a7bc7ac45d89c3" +dependencies = [ + "bytes", + "http", + "http-body", + "pin-project", + "tonic", + "tower-layer", + "tower-service", +] + [[package]] name = "tonic-build" version = "0.14.5" @@ -4435,6 +4498,17 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "tonic-prost" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a55376a0bbaa4975a3f10d009ad763d8f4108f067c7c2e74f3001fb49778d309" +dependencies = [ + "bytes", + "prost", + "tonic", +] + [[package]] name = "tonic-prost-build" version = "0.14.5" @@ -4485,14 +4559,12 @@ dependencies = [ "http-body-util", "http-range-header", "httpdate", - "iri-string", "mime", "mime_guess", "percent-encoding", "pin-project-lite", "tokio", "tokio-util", - "tower", "tower-layer", "tower-service", "tracing", @@ -4608,6 +4680,12 @@ dependencies = [ "utf-8", ] +[[package]] +name = "typed-path" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e" + [[package]] name = "typeid" version = "1.0.3" @@ -4709,9 +4787,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "utoipa" -version = "4.2.3" +version = "5.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5afb1a60e207dca502682537fefcfd9921e71d0b83e9576060f09abc6efab23" +checksum = "2fcc29c80c21c31608227e0912b2d7fddba57ad76b606890627ba8ee7964e993" dependencies = [ "indexmap", "serde", @@ -4721,33 +4799,16 @@ dependencies = [ [[package]] name = "utoipa-gen" -version = "4.3.1" +version = "5.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20c24e8ab68ff9ee746aad22d39b5535601e6416d1b0feeabf78be986a5c4392" +checksum = "6d79d08d92ab8af4c5e8a6da20c47ae3f61a0f1dabc1997cdf2d082b757ca08b" dependencies = [ - "proc-macro-error", "proc-macro2", "quote", "regex", "syn 2.0.117", ] -[[package]] -name = "utoipa-swagger-ui" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b39868d43c011961e04b41623e050aedf2cc93652562ff7935ce0f819aaf2da" -dependencies = [ - "axum 0.7.9", - "mime_guess", - "regex", - "rust-embed", - "serde", - "serde_json", - "utoipa", - "zip", -] - [[package]] name = "uuid" version = "1.21.0" @@ -4814,16 +4875,6 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" -[[package]] -name = "walkdir" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" -dependencies = [ - "same-file", - "winapi-util", -] - [[package]] name = "want" version = "0.3.1" @@ -4994,15 +5045,6 @@ dependencies = [ "wasite", ] -[[package]] -name = "winapi-util" -version = "0.1.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" -dependencies = [ - "windows-sys 0.61.2", -] - [[package]] name = "windows-core" version = "0.62.2" @@ -5396,6 +5438,24 @@ dependencies = [ "tap", ] +[[package]] +name = "x509-parser" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202" +dependencies = [ + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom", + "oid-registry", + "ring", + "rusticata-macros", + "thiserror", + "time", +] + [[package]] name = "yaml-rust2" version = "0.10.4" @@ -5413,6 +5473,15 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" +[[package]] +name = "yasna" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd" +dependencies = [ + "time", +] + [[package]] name = "yoke" version = "0.8.1" @@ -5482,6 +5551,20 @@ name = "zeroize" version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] [[package]] name = "zerotrie" @@ -5518,18 +5601,79 @@ dependencies = [ [[package]] name = "zip" -version = "0.6.6" +version = "8.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "760394e246e4c28189f19d488c058bf16f564016aefac5d32bb1f3b51d5e9261" +checksum = "b680f2a0cd479b4cff6e1233c483fdead418106eae419dc60200ae9850f6d004" dependencies = [ - "byteorder", + "aes", + "bzip2", + "constant_time_eq", "crc32fast", - "crossbeam-utils", + "deflate64", "flate2", + "getrandom 0.4.2", + "hmac", + "indexmap", + "lzma-rust2", + "memchr", + "pbkdf2", + "ppmd-rust", + "sha1", + "time", + "typed-path", + "zeroize", + "zopfli", + "zstd", ] +[[package]] +name = "zlib-rs" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3be3d40e40a133f9c916ee3f9f4fa2d9d63435b5fbe1bfc6d9dae0aa0ada1513" + [[package]] name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/Cargo.toml b/Cargo.toml index 51e3725..94de3ae 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,7 +37,9 @@ tower-http = { version = "0.6.8", features = ["trace", "cors", "fs"] } # gRPC tonic = "0.14" +tonic-prost = "0.14" prost = "0.14" +tonic-async-interceptor = "0.14" # Database sea-orm = { version = "2.0.0-rc", features = [ @@ -75,6 +77,9 @@ uuid = { version = "1", features = ["v4", "serde"] } # Templating handlebars = "6.4" +# Cert handling +zip = { version = "8.2.0" } + # CLI clap = { version = "4", features = ["derive"] } diff --git a/justfile b/justfile index caeacb1..dcfcf2c 100644 --- a/justfile +++ b/justfile @@ -43,9 +43,13 @@ dev: wait # Start Rust backend with hot reload -dev-backend: +dev-master *ARGS: @echo "🔧 Starting Rust backend..." - cargo watch -x run + cargo watch -w apps/nxmesh-master -x 'run --bin nxmesh-master -- {{ARGS}}' + +dev-agent *ARGS: + @echo "🔧 Starting Rust agent..." + cargo watch -w apps/nxmesh-agent -x 'run --bin nxmesh-agent -- {{ARGS}}' # Start Vite frontend development server dev-frontend: @@ -113,7 +117,7 @@ db-new-migration name: # Reset database (drop and recreate) db-reset: @echo "⚠️ Resetting database..." - cd crates && sea-orm-cli database reset + cd crates && sea-orm-cli migrate reset # Connect to database with psql db-console: -- 2.49.1 From 2fcdc7d0dfbec4bc54b23f6f605086be63e512fd Mon Sep 17 00:00:00 2001 From: GW_MC <72297530+GWMCwing@users.noreply.github.com> Date: Sat, 21 Mar 2026 03:06:56 +0000 Subject: [PATCH 02/10] feat: Add SSH authentication interceptor and update proto definitions --- crates/nxmesh-proto/Cargo.toml | 12 +++ crates/nxmesh-proto/proto/agent.proto | 102 ++++------------------- crates/nxmesh-proto/src/auth/mod.rs | 1 + crates/nxmesh-proto/src/auth/ssh_auth.rs | 49 +++++++++++ crates/nxmesh-proto/src/lib.rs | 4 + 5 files changed, 82 insertions(+), 86 deletions(-) create mode 100644 crates/nxmesh-proto/src/auth/mod.rs create mode 100644 crates/nxmesh-proto/src/auth/ssh_auth.rs diff --git a/crates/nxmesh-proto/Cargo.toml b/crates/nxmesh-proto/Cargo.toml index 77b586b..2503d86 100644 --- a/crates/nxmesh-proto/Cargo.toml +++ b/crates/nxmesh-proto/Cargo.toml @@ -10,6 +10,18 @@ rust-version.workspace = true [dependencies] tonic.workspace = true prost.workspace = true +tonic-prost.workspace = true +tonic-async-interceptor = { workspace = true, optional = true } + +# allow user to specify tonic server or client +[features] +default = ["server", "client"] +server = [ + "tonic/server", + "tonic/tls-native-roots", + "dep:tonic-async-interceptor", +] +client = [] [build-dependencies] tonic-prost-build.workspace = true diff --git a/crates/nxmesh-proto/proto/agent.proto b/crates/nxmesh-proto/proto/agent.proto index 891a227..ed42b96 100644 --- a/crates/nxmesh-proto/proto/agent.proto +++ b/crates/nxmesh-proto/proto/agent.proto @@ -3,6 +3,9 @@ package nxmesh.agent.v1; option go_package = "github.com/nxmesh/api/agent/v1"; +// For all file paths in this proto, we use forward slashes ("/") as the separator, even on Windows. This is because gRPC and protobuf are designed to be cross-platform and forward slashes are universally accepted as path separators in URLs and many programming languages. Using forward slashes ensures consistency and avoids issues with escaping backslashes on different platforms. +// All file paths MUST be relative paths from other config files, e.g. "site.conf", "private/example.com.conf". Absolute paths or path traversal above the config directory should be rejected by the agent for security reasons. The config files must live within the generated config directory, e.g. "/etc/nginx/conf-/site.conf". This allows the agent to manage the lifecycle of config files, e.g. cleanup old configs after successful apply. + // AgentService defines the bidirectional communication between master and agents service AgentService { // Stream establishes a persistent connection for real-time communication @@ -43,13 +46,12 @@ message MasterMessage { // Registration message RegistrationRequest { - string token = 1; - string hostname = 2; - string ip_address = 3; - string version = 4; - repeated string capabilities = 5; - map labels = 6; - DeploymentMode deployment_mode = 7; + string hostname = 1; + string ip_address = 2; + string version = 3; + repeated string capabilities = 4; + map labels = 5; + DeploymentMode deployment_mode = 6; } message RegistrationResponse { @@ -104,94 +106,22 @@ message Alert { message ConfigUpdate { string config_id = 1; int64 version = 2; - repeated VirtualHost virtual_hosts = 3; - repeated Upstream upstreams = 4; - map certificates = 5; - GlobalSettings global_settings = 6; + repeated ConfigContent configs = 3; + repeated CertificateContent certificates = 4; } -message VirtualHost { - string id = 1; - string name = 2; - string server_name = 3; - uint32 listen_port = 4; - bool ssl_enabled = 5; - string ssl_certificate_id = 6; - bool http2_enabled = 7; - bool http3_enabled = 8; - repeated Location locations = 9; - map custom_directives = 10; -} - -message Location { +message ConfigContent { + // relative path from other config files, e.g. "site.conf", "private/example.com.conf" string path = 1; - string proxy_pass = 2; - string upstream_id = 3; - string root = 4; - string index = 5; - repeated Header custom_headers = 6; - repeated RewriteRule rewrite_rules = 7; - map custom_directives = 8; + string content = 2; } -message Header { - string name = 1; - string value = 2; - bool always = 3; -} - -message RewriteRule { - string pattern = 1; - string replacement = 2; - string flag = 3; -} - -message Upstream { +message CertificateContent { string id = 1; - string name = 2; - LoadBalanceAlgorithm algorithm = 3; - repeated UpstreamServer servers = 4; - HealthCheckConfig health_check = 5; - uint32 keepalive_connections = 6; -} - -enum LoadBalanceAlgorithm { - LOAD_BALANCE_ALGORITHM_UNSPECIFIED = 0; - ROUND_ROBIN = 1; - LEAST_CONNECTIONS = 2; - IP_HASH = 3; - WEIGHTED_ROUND_ROBIN = 4; -} - -message UpstreamServer { - string address = 1; - uint32 weight = 2; - bool backup = 3; - bool down = 4; - uint32 max_fails = 5; - uint32 fail_timeout_seconds = 6; -} - -message HealthCheckConfig { - bool enabled = 1; + // relative path from other config files, e.g. "certs/example.com.pem" string path = 2; - uint32 interval_seconds = 3; - uint32 timeout_seconds = 4; - uint32 healthy_threshold = 5; - uint32 unhealthy_threshold = 6; -} - -message Certificate { - string id = 1; - string domain = 2; string certificate_pem = 3; string private_key_pem = 4; - int64 expires_at = 5; -} - -message GlobalSettings { - map nginx_directives = 1; - map env_vars = 2; } message ConfigStatus { diff --git a/crates/nxmesh-proto/src/auth/mod.rs b/crates/nxmesh-proto/src/auth/mod.rs new file mode 100644 index 0000000..88b2f38 --- /dev/null +++ b/crates/nxmesh-proto/src/auth/mod.rs @@ -0,0 +1 @@ +pub mod ssh_auth; diff --git a/crates/nxmesh-proto/src/auth/ssh_auth.rs b/crates/nxmesh-proto/src/auth/ssh_auth.rs new file mode 100644 index 0000000..a3c74ac --- /dev/null +++ b/crates/nxmesh-proto/src/auth/ssh_auth.rs @@ -0,0 +1,49 @@ +use std::sync::Arc; + +use tonic::{Request, Status, async_trait, transport::CertificateDer}; +use tonic_async_interceptor::{AsyncInterceptor, AsyncInterceptorLayer, async_interceptor}; + +pub fn create_ssh_auth_interceptor( + certificate_provider: Arc, +) -> AsyncInterceptorLayer { + async_interceptor(SshAuthInterceptor::new(certificate_provider)) +} + +#[derive(Clone)] +pub struct SshAuthInterceptor { + certificate_provider: Arc, +} + +#[async_trait] +pub trait CertificateValidationProvider: Send + Sync { + async fn is_authorized(&self, certs: &Arc>>) -> Result; +} + +impl AsyncInterceptor for SshAuthInterceptor { + type Future = + std::pin::Pin, Status>> + Send>>; + fn call(&mut self, req: Request<()>) -> Self::Future { + let this = self.clone(); + Box::pin(async move { this.authenticate(req).await }) + } +} + +impl SshAuthInterceptor { + pub fn new(certificate_provider: Arc) -> Self { + SshAuthInterceptor { + certificate_provider, + } + } + + async fn authenticate(&self, req: Request<()>) -> Result, Status> { + let certs = req.peer_certs().ok_or(Status::unauthenticated("No cert"))?; + + let is_authorized = self.certificate_provider.is_authorized(&certs).await?; + + if is_authorized { + Ok(req) + } else { + Err(Status::permission_denied("Blocked")) + } + } +} diff --git a/crates/nxmesh-proto/src/lib.rs b/crates/nxmesh-proto/src/lib.rs index e8da2fb..37c6dd2 100644 --- a/crates/nxmesh-proto/src/lib.rs +++ b/crates/nxmesh-proto/src/lib.rs @@ -1,9 +1,13 @@ //! NxMesh Protocol Buffers //! //! This crate contains the gRPC protocol definitions for master-agent communication. +#![forbid(clippy::unwrap_used, clippy::panic, unsafe_code)] +#![deny(clippy::expect_used)] pub mod agent { tonic::include_proto!("nxmesh.agent.v1"); } pub use agent::*; +pub mod auth; +pub use tonic_async_interceptor::*; -- 2.49.1 From 1a453a7e5c20081298abb0d0457838292a0efb52 Mon Sep 17 00:00:00 2001 From: GW_MC <72297530+GWMCwing@users.noreply.github.com> Date: Sat, 21 Mar 2026 03:07:58 +0000 Subject: [PATCH 03/10] feat: Implement SSH master connector and CLI for certificate management --- apps/nxmesh-agent/Cargo.toml | 9 +- apps/nxmesh-agent/src/cli/mod.rs | 81 +++++ apps/nxmesh-agent/src/config/mod.rs | 1 + apps/nxmesh-agent/src/config/settings.rs | 333 ++++++++++++++++++ apps/nxmesh-agent/src/connector/master/mod.rs | 40 +++ apps/nxmesh-agent/src/connector/master/ssh.rs | 132 +++++++ apps/nxmesh-agent/src/connector/mod.rs | 1 + apps/nxmesh-agent/src/main.rs | 93 +++++ 8 files changed, 687 insertions(+), 3 deletions(-) create mode 100644 apps/nxmesh-agent/src/cli/mod.rs create mode 100644 apps/nxmesh-agent/src/config/mod.rs create mode 100644 apps/nxmesh-agent/src/config/settings.rs create mode 100644 apps/nxmesh-agent/src/connector/master/mod.rs create mode 100644 apps/nxmesh-agent/src/connector/master/ssh.rs create mode 100644 apps/nxmesh-agent/src/connector/mod.rs diff --git a/apps/nxmesh-agent/Cargo.toml b/apps/nxmesh-agent/Cargo.toml index 077ffda..8c833b8 100644 --- a/apps/nxmesh-agent/Cargo.toml +++ b/apps/nxmesh-agent/Cargo.toml @@ -29,9 +29,6 @@ tracing-subscriber.workspace = true # gRPC tonic.workspace = true -# HTTP -reqwest.workspace = true - # Async async-trait.workspace = true futures.workspace = true @@ -54,6 +51,12 @@ uuid.workspace = true # Hostname hostname = "0.4" +# Certificates +zip = { workspace = true } + +# CLI +clap = { workspace = true, features = ["derive"] } + [dev-dependencies] tokio-test.workspace = true mockall.workspace = true diff --git a/apps/nxmesh-agent/src/cli/mod.rs b/apps/nxmesh-agent/src/cli/mod.rs new file mode 100644 index 0000000..676c83b --- /dev/null +++ b/apps/nxmesh-agent/src/cli/mod.rs @@ -0,0 +1,81 @@ +use clap::{Parser, Subcommand}; + +#[derive(Parser)] +#[command(version, about, long_about = None)] +pub struct Cli { + /// Start the agent server + #[arg(short, long, group = "mode")] + pub serve: bool, + + #[command(subcommand)] + pub command: Option, +} + +#[derive(Subcommand)] +pub enum Commands { + #[command(about = "Import certificates for agent from zip file or separate cert and key files")] + ImportCerts { + // Zip file input, mutually exclusive with separate cert and key file inputs + /// Zip file containing ca.pem cert.pem and key.pem + #[arg(value_name = "ZIP_FILE", group = "input_source")] + zip: Option, + /// Certificate name in zip file, required if using zip input + #[arg( + long, + group = "input_source", + requires = "zip", + default_value = "cert.pem", + value_name = "CERT_NAME" + )] + cert_name: Option, + /// Key name in zip file, required if using zip input + #[arg( + long, + group = "input_source", + requires = "zip", + default_value = "key.pem", + value_name = "KEY_NAME" + )] + key_name: Option, + /// CA certificate name in zip file, required if using zip input + #[arg( + long, + group = "input_source", + requires = "zip", + default_value = "ca.pem", + value_name = "CA_NAME" + )] + ca_name: Option, + + // Separate cert and key file inputs, required if not using zip input + /// Certificate file path + #[arg( + long, + group = "input_source", + requires = "key", + conflicts_with = "zip", + value_name = "CERT_FILE" + )] + cert: Option, + + /// Key file path + #[arg( + long, + group = "input_source", + requires = "cert", + conflicts_with = "zip", + value_name = "KEY_FILE" + )] + key: Option, + + /// Master CA certificate file path for verifying master identity, optional if the CA certificate is already trusted by the system + /// This is required if the master server uses a self-signed certificate that is not trusted by the system + #[arg( + long, + group = "input_source", + conflicts_with = "zip", + value_name = "CA_CERT_FILE" + )] + ca_cert: Option, + }, +} diff --git a/apps/nxmesh-agent/src/config/mod.rs b/apps/nxmesh-agent/src/config/mod.rs new file mode 100644 index 0000000..6e98cef --- /dev/null +++ b/apps/nxmesh-agent/src/config/mod.rs @@ -0,0 +1 @@ +pub mod settings; diff --git a/apps/nxmesh-agent/src/config/settings.rs b/apps/nxmesh-agent/src/config/settings.rs new file mode 100644 index 0000000..2a579c1 --- /dev/null +++ b/apps/nxmesh-agent/src/config/settings.rs @@ -0,0 +1,333 @@ +use config::{Config, ConfigError, Environment, File}; +use serde::{Deserialize, Deserializer, Serialize}; +use std::{os::unix::fs::PermissionsExt, str::FromStr}; +use tracing::level_filters::LevelFilter; + +const NGINX_BINARY_PATH_TEMPLATE: &str = "{{nginx_binary_path}}"; +const NGINX_DEFAULT_BINARY: &str = "nginx"; + +type ValidationError = String; + +trait Validate { + fn validate(&self) -> Result<(), ValidationError>; +} + +/// Agent settings +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Settings { + pub grpc: GrpcSettings, + #[serde(default)] + pub log: LogSettings, + pub nginx: Option, +} + +/// gRPC client settings +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GrpcSettings { + pub connection_string: String, + pub m_auth: MAuthSettings, + #[serde(default)] + pub cors: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum MAuthSettings { + Tls(TLSSettings), +} + +/// TLS certificate settings +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum TLSSettings { + RawPath { + ca_path: String, + cert_path: String, + key_path: String, + }, + ZipPath { + cert_zip_path: String, + }, +} + +/// CORS settings +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct CorsSettings { + #[serde(default)] + pub allowed_origins: Vec, + #[serde(default)] + pub allowed_methods: Vec, + #[serde(default)] + pub allowed_headers: Vec, + #[serde(default)] + pub allow_credentials: bool, +} + +/// Logging settings +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LogSettings { + #[serde( + deserialize_with = "deserialize_level_filter", + serialize_with = "serialize_level_filter" + )] + pub level: LevelFilter, +} + +impl Default for LogSettings { + fn default() -> Self { + Self { + level: default_log_level(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NginxSettings { + #[serde(default = "default_nginx_config_path")] + pub nginx_config_path: String, + // #[serde(default = "default_nginx_binary_path")] + #[serde(default)] + pub nginx_binary_path: Option, + // commands + #[serde(default = "default_nginx_reload_command")] + pub override_nginx_reload_command: Vec, + #[serde(default = "default_nginx_test_command")] + pub override_nginx_test_command: Vec, + // timeouts + #[serde(default = "default_nginx_reload_timeout_seconds")] + pub nginx_reload_timeout_seconds: u64, + #[serde(default = "default_nginx_test_timeout_seconds")] + pub nginx_test_timeout_seconds: u64, +} + +impl Validate for Settings { + fn validate(&self) -> Result<(), ValidationError> { + self.grpc.validate()?; + if let Some(nginx) = &self.nginx { + nginx.validate()?; + } + Ok(()) + } +} + +impl Settings { + /// Load settings from config files and environment + pub fn load() -> Result { + let run_mode = std::env::var("RUN_MODE").unwrap_or_else(|_| "development".into()); + + let settings = Config::builder() + .add_source(File::with_name("config/default").required(false)) + .add_source(File::with_name(&format!("config/{}", run_mode)).required(false)) + .add_source(File::with_name("config/agent/default").required(false)) + .add_source(File::with_name(&format!("config/agent/{}", run_mode)).required(false)) + .add_source(Environment::with_prefix("NXMESH").separator("__")) + .build()?; + + let mut settings: Self = settings.try_deserialize()?; + + settings.validate().map_err(ConfigError::Message)?; + + if let Some(nginx) = &mut settings.nginx { + nginx.validate().map_err(ConfigError::Message)?; + + // replace binary path template in commands with actual binary path, if the template is present + nginx + .override_nginx_reload_command + .iter_mut() + .for_each(|cmd| { + *cmd = cmd.replace( + NGINX_BINARY_PATH_TEMPLATE, + &nginx + .nginx_binary_path + .clone() + .unwrap_or_else(|| NGINX_DEFAULT_BINARY.into()), + ); + }); + nginx + .override_nginx_test_command + .iter_mut() + .for_each(|cmd| { + *cmd = cmd.replace( + NGINX_BINARY_PATH_TEMPLATE, + &nginx + .nginx_binary_path + .clone() + .unwrap_or_else(|| NGINX_DEFAULT_BINARY.into()), + ); + }); + } + + Ok(settings) + } +} + +impl Validate for GrpcSettings { + fn validate(&self) -> Result<(), ValidationError> { + if self.connection_string.is_empty() { + return Err("gRPC connection string cannot be empty".into()); + } + self.m_auth.validate()?; + if let Some(cors) = &self.cors { + cors.validate()?; + } + Ok(()) + } +} + +impl Validate for MAuthSettings { + fn validate(&self) -> Result<(), ValidationError> { + match self { + MAuthSettings::Tls(tls_settings) => tls_settings.validate()?, + } + Ok(()) + } +} + +impl Validate for TLSSettings { + fn validate(&self) -> Result<(), ValidationError> { + match self { + TLSSettings::RawPath { + ca_path, + cert_path, + key_path, + } => { + if !std::path::Path::new(ca_path).exists() { + return Err(format!("CA file not found: {}", ca_path)); + } + if !std::path::Path::new(cert_path).exists() { + return Err(format!("Certificate file not found: {}", cert_path)); + } + if !std::path::Path::new(key_path).exists() { + return Err(format!("Key file not found: {}", key_path)); + } + } + TLSSettings::ZipPath { cert_zip_path } => { + if !std::path::Path::new(cert_zip_path).exists() { + return Err(format!("Certificate zip file not found: {}", cert_zip_path)); + } + } + } + Ok(()) + } +} + +impl Validate for CorsSettings { + fn validate(&self) -> Result<(), ValidationError> { + Ok(()) + } +} + +impl Validate for NginxSettings { + fn validate(&self) -> Result<(), ValidationError> { + match &self.nginx_binary_path { + Some(path) if path.is_empty() => { + return Err("Nginx binary path cannot be empty".into()); + } + Some(path) if !std::path::Path::new(path).exists() => { + return Err(format!("Nginx binary not found: {}", path)); + } + Some(path) + if !std::fs::metadata(path) + .map_err(|e| format!("Failed to read nginx binary metadata: {}", e))? + .permissions() + .mode() + & 0o111 + != 0 => + { + return Err(format!("Nginx binary is not executable: {}", path)); + } + _ => {} + } + if self.nginx_config_path.is_empty() { + return Err("Nginx config path cannot be empty".into()); + } + if !std::path::Path::new(&self.nginx_config_path).exists() { + return Err(format!( + "Nginx config file not found: {}", + self.nginx_config_path + )); + } + + // ensure reload and test commands contain the binary path template + if !&self + .override_nginx_reload_command + .join(" ") + .contains(NGINX_BINARY_PATH_TEMPLATE) + { + return Err(format!( + "Nginx reload command must contain the binary path template '{}': {}", + NGINX_BINARY_PATH_TEMPLATE, + self.override_nginx_reload_command.join(" ") + )); + } + if !&self + .override_nginx_test_command + .join(" ") + .contains(NGINX_BINARY_PATH_TEMPLATE) + { + return Err(format!( + "Nginx test command must contain the binary path template '{}': {}", + NGINX_BINARY_PATH_TEMPLATE, + self.override_nginx_test_command.join(" ") + )); + } + Ok(()) + } +} + +fn default_log_level() -> LevelFilter { + LevelFilter::INFO +} + +fn default_nginx_config_path() -> String { + "/etc/nginx/nginx.conf".into() +} + +fn default_nginx_reload_command() -> Vec { + vec![ + NGINX_BINARY_PATH_TEMPLATE.to_string(), + "-s".to_string(), + "reload".to_string(), + ] +} + +fn default_nginx_test_command() -> Vec { + vec![NGINX_BINARY_PATH_TEMPLATE.to_string(), "-t".to_string()] +} + +fn default_nginx_reload_timeout_seconds() -> u64 { + 30 +} + +fn default_nginx_test_timeout_seconds() -> u64 { + 30 +} + +fn deserialize_level_filter<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + let s = String::deserialize(deserializer)?; + LevelFilter::from_str(&s).map_err(serde::de::Error::custom) +} + +fn serialize_level_filter(level: &LevelFilter, serializer: S) -> Result +where + S: serde::Serializer, +{ + serializer.serialize_str(&level.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_esnure_send_and_sync() { + fn assert_send_sync() {} + assert_send_sync::(); + assert_send_sync::(); + assert_send_sync::(); + assert_send_sync::(); + assert_send_sync::(); + assert_send_sync::(); + } +} diff --git a/apps/nxmesh-agent/src/connector/master/mod.rs b/apps/nxmesh-agent/src/connector/master/mod.rs new file mode 100644 index 0000000..da408b2 --- /dev/null +++ b/apps/nxmesh-agent/src/connector/master/mod.rs @@ -0,0 +1,40 @@ +use std::sync::Arc; + +use tokio::sync::Mutex; + +pub mod ssh; + +pub type AgentClient = nxmesh_proto::agent_service_client::AgentServiceClient; + +#[async_trait::async_trait] +pub trait MasterConnectorTrait: Send + Sync { + async fn connect( + &mut self, + settings: &crate::config::settings::Settings, + ) -> Result<(), Box>; + fn get_client(&self) -> Arc>; +} + +pub struct MasterConnector { + connector: Box, +} + +impl MasterConnector { + pub fn new(connector: Box) -> Self { + Self { connector } + } +} + +#[async_trait::async_trait] +impl MasterConnectorTrait for MasterConnector { + async fn connect( + &mut self, + settings: &crate::config::settings::Settings, + ) -> Result<(), Box> { + self.connector.connect(settings).await + } + + fn get_client(&self) -> Arc> { + self.connector.get_client() + } +} diff --git a/apps/nxmesh-agent/src/connector/master/ssh.rs b/apps/nxmesh-agent/src/connector/master/ssh.rs new file mode 100644 index 0000000..2414bf4 --- /dev/null +++ b/apps/nxmesh-agent/src/connector/master/ssh.rs @@ -0,0 +1,132 @@ +use std::{fs::File, io::Read, sync::Arc}; + +use tokio::{fs::read, sync::Mutex}; + +use nxmesh_proto::agent_service_client::AgentServiceClient; +use tonic::transport::{Certificate, ClientTlsConfig, Identity}; +use tracing::warn; + +use crate::config::settings::{self, MAuthSettings, TLSSettings}; + +use super::{AgentClient, MasterConnectorTrait}; + +pub struct SshMasterConnector { + client: Arc>, +} + +impl SshMasterConnector { + pub async fn new( + settings: crate::config::settings::GrpcSettings, + ) -> Result> { + let tls_config = Self::generate_tls_config(&settings.m_auth).await?; + // Create a gRPC channel + let endpoint = tonic::transport::Channel::from_shared(settings.connection_string.clone()) + .map_err(|e| format!("Failed to create gRPC endpoint: {}", e))? + .tls_config(tls_config) + .map_err(|e| { + format!( + "Failed to set TLS config: {}. Ensure TLS settings and certificates are correct.", + e + ) + })? + .connect_timeout(std::time::Duration::from_secs(5)) + .timeout(std::time::Duration::from_secs(10)) + .connect_lazy(); + + // Create the gRPC client + let client = Arc::new(Mutex::new(AgentServiceClient::new(endpoint))); + Ok(Self { client }) + } + + async fn generate_tls_config( + settings: &MAuthSettings, + ) -> Result> { + let tls_config = match &settings { + MAuthSettings::Tls(tls_settings) => { + let (ca, cert, key) = match tls_settings { + TLSSettings::RawPath { + ca_path, + cert_path, + key_path, + } => { + // Read the certificate and key from the specified file paths + let ca = read(ca_path).await?; + let cert = read(cert_path).await?; + let key = read(key_path).await?; + (ca, cert, key) + } + TLSSettings::ZipPath { cert_zip_path } => { + // Extract the certificate and key from the zip file + Self::extract_certificate(cert_zip_path).await? + } + }; + + // TODO: allow skipping SANs validation if specified in the settings + ClientTlsConfig::new() + .ca_certificate(Certificate::from_pem(&ca)) + .identity(Identity::from_pem(&cert, &key)) + } + #[allow(unreachable_patterns)] + _ => { + return Err("TLS settings are required for SSH connection".into()); + } + }; + + Ok(tls_config) + } + + async fn extract_certificate( + cert_zip_path: &str, + ) -> Result<(Vec, Vec, Vec), Box> { + // unzip the file and extract the cert, ca and key + let file = File::open(cert_zip_path)?; + let mut archive = zip::ZipArchive::new(file)?; + let mut cert = Vec::new(); + let mut key = Vec::new(); + let mut ca = Vec::new(); + + for i in 0..archive.len() { + let mut file = archive.by_index(i)?; + let outpath = match file.enclosed_name() { + Some(path) => path.to_owned(), + None => continue, + }; + let file_name = outpath + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or_default(); + if file_name != "cert.pem" && file_name != "key.pem" && file_name != "ca.pem" { + warn!("Unexpected file in certificate zip: {}", file_name); + continue; + } + if file_name == "cert.pem" { + file.read_to_end(&mut cert)?; + } else if file_name == "key.pem" { + file.read_to_end(&mut key)?; + } else if file_name == "ca.pem" { + file.read_to_end(&mut ca)?; + } + } + + if cert.is_empty() || key.is_empty() || ca.is_empty() { + return Err("Certificate zip must contain cert.pem, key.pem and ca.pem".into()); + } + + Ok((ca, cert, key)) + } +} + +#[async_trait::async_trait] +impl MasterConnectorTrait for SshMasterConnector { + async fn connect( + &mut self, + _settings: &crate::config::settings::Settings, + ) -> Result<(), Box> { + // ensure connection if required + Ok(()) + } + + fn get_client(&self) -> Arc> { + self.client.clone() + } +} diff --git a/apps/nxmesh-agent/src/connector/mod.rs b/apps/nxmesh-agent/src/connector/mod.rs new file mode 100644 index 0000000..0d22726 --- /dev/null +++ b/apps/nxmesh-agent/src/connector/mod.rs @@ -0,0 +1 @@ +pub mod master; \ No newline at end of file diff --git a/apps/nxmesh-agent/src/main.rs b/apps/nxmesh-agent/src/main.rs index e69de29..e3dc71f 100644 --- a/apps/nxmesh-agent/src/main.rs +++ b/apps/nxmesh-agent/src/main.rs @@ -0,0 +1,93 @@ +#![forbid(unsafe_code)] +#![deny(clippy::unwrap_used, clippy::panic, clippy::expect_used)] + +use std::process::exit; + +use tracing::{error, info}; +use tracing_subscriber::{ + Layer, filter::LevelFilter, fmt, layer::SubscriberExt, registry::Registry, reload, + util::SubscriberInitExt, +}; + +use crate::connector::master::{MasterConnector, MasterConnectorTrait, ssh::SshMasterConnector}; + +mod cli; +mod config; +mod connector; + +#[tokio::main] +async fn main() { + // install a global subscriber for logging + let reload_handle = install_tracing_subscriber(); + // Load configuration settings + let settings = match config::settings::Settings::load() { + Ok(s) => s, + Err(e) => { + error!("Failed to load configuration: {}", e); + std::process::exit(1); + } + }; + + reload_handle + .modify(|filter| *filter = Box::new(settings.log.level)) + .inspect_err(|e| { + error!( + "Failed to set log level: {}. Continuing with default level.", + e + ) + }) + // ignore errors here since we can still run with the default log level + .ok(); + + // print the loaded settings for debugging + // info!("Loaded settings: {:#?}", settings); + + info!("Starting NxMesh Agent..."); + // install grpc client + #[expect(clippy::expect_used)] + let ssh_connector = SshMasterConnector::new(settings.grpc.clone()) + .await + .inspect_err(|e| { + error!("Failed to create SSH Master Connector: {}", e); + exit(1); + }) + .expect("Failed to create SSH Master Connector"); + let mut master_connector = MasterConnector::new(Box::new(ssh_connector)); + + if let Err(e) = master_connector.connect(&settings).await { + error!("Failed to connect to master: {}", e); + exit(1); + } + + // send a dummy heartbeat to verify the connection is working + let client = master_connector.get_client(); + + let request = nxmesh_proto::HealthReport { + ..Default::default() + }; + + match client.lock().await.report_health(request).await { + Ok(_) => info!("Successfully sent health report to master."), + Err(e) => { + error!("Failed to send health report to master: {}", e); + exit(1); + } + } + + info!("Successfully connected to master. Agent is running."); +} + +fn install_tracing_subscriber() +-> reload::Handle + Send + Sync>, Registry> { + let filter = LevelFilter::INFO; + let (filter_layer, reload_handle) = + reload::Layer::new(Box::new(fmt::layer().with_filter(filter)) + as Box + Send + Sync>); + + tracing_subscriber::registry() + .with(filter_layer) + .with(fmt::Layer::default()) + .init(); + + reload_handle +} -- 2.49.1 From 3800e3846346f80c4653943cfd7a08d519961f2e Mon Sep 17 00:00:00 2001 From: GW_MC <72297530+GWMCwing@users.noreply.github.com> Date: Sat, 21 Mar 2026 03:09:10 +0000 Subject: [PATCH 04/10] feat: Add development configuration for gRPC and Nginx --- config/.gitignore | 1 + config/agent/development.toml | 23 +++++++++++++++++++++++ 2 files changed, 24 insertions(+) create mode 100644 config/.gitignore create mode 100644 config/agent/development.toml diff --git a/config/.gitignore b/config/.gitignore new file mode 100644 index 0000000..cfaad76 --- /dev/null +++ b/config/.gitignore @@ -0,0 +1 @@ +*.pem diff --git a/config/agent/development.toml b/config/agent/development.toml new file mode 100644 index 0000000..0859b25 --- /dev/null +++ b/config/agent/development.toml @@ -0,0 +1,23 @@ +[grpc] +connection_string = "https://127.0.0.1:8443" + +[grpc.m_auth.Tls.ZipPath] +# cert_zip_path = "./.local/agent-tls.zip" +cert_zip_path = "./certs/agent-id-placeholder/cert.zip" + +[grpc.cors] +allowed_origins = ["*"] +allowed_methods = ["GET", "POST"] +allowed_headers = ["*"] +allow_credentials = true + +[log] +level = "INFO" + +[nginx] +nginx_config_path = "./.devcontainer/nginx/nginx.conf" +# nginx_binary_path = "/usr/sbin/nginx" +# override_nginx_reload_command = ["/usr/sbin/nginx", "-s", "reload"] +# override_nginx_test_command = ["/usr/sbin/nginx", "-t"] +nginx_reload_timeout_seconds = 30 +nginx_test_timeout_seconds = 30 -- 2.49.1 From 9640f03d69d24b96c077ed084fc9702ae3602f57 Mon Sep 17 00:00:00 2001 From: GW_MC <72297530+GWMCwing@users.noreply.github.com> Date: Sat, 21 Mar 2026 03:09:22 +0000 Subject: [PATCH 05/10] feat: Add migration scripts for agents and public key revocations --- crates/migration/Cargo.toml | 1 + crates/migration/src/lib.rs | 9 ++- .../src/m20260301_000001_create_agents.rs | 61 +++++++++++++++++++ ...01_000002_create_public_key_revokaction.rs | 59 ++++++++++++++++++ 4 files changed, 129 insertions(+), 1 deletion(-) create mode 100644 crates/migration/src/m20260301_000001_create_agents.rs create mode 100644 crates/migration/src/m20260301_000002_create_public_key_revokaction.rs diff --git a/crates/migration/Cargo.toml b/crates/migration/Cargo.toml index 1a9ca86..14a2192 100644 --- a/crates/migration/Cargo.toml +++ b/crates/migration/Cargo.toml @@ -14,3 +14,4 @@ async-std = { version = "1", features = ["attributes", "tokio1"] } [dependencies.sea-orm-migration] version = "2.0.0-rc" features = ["runtime-tokio-rustls", "sqlx-postgres"] + diff --git a/crates/migration/src/lib.rs b/crates/migration/src/lib.rs index 467dfa9..fb7c7c5 100644 --- a/crates/migration/src/lib.rs +++ b/crates/migration/src/lib.rs @@ -1,9 +1,16 @@ pub use sea_orm_migration::prelude::*; + +mod m20260301_000001_create_agents; +mod m20260301_000002_create_public_key_revokaction; + pub struct Migrator; #[async_trait::async_trait] impl MigratorTrait for Migrator { fn migrations() -> Vec> { - vec![] + vec![ + Box::new(m20260301_000001_create_agents::Migration), + Box::new(m20260301_000002_create_public_key_revokaction::Migration), + ] } } diff --git a/crates/migration/src/m20260301_000001_create_agents.rs b/crates/migration/src/m20260301_000001_create_agents.rs new file mode 100644 index 0000000..1441394 --- /dev/null +++ b/crates/migration/src/m20260301_000001_create_agents.rs @@ -0,0 +1,61 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(Agents::Table) + .if_not_exists() + .col(ColumnDef::new(Agents::Id).uuid().not_null().primary_key()) + .col(ColumnDef::new(Agents::Name).string().not_null()) + .col(ColumnDef::new(Agents::IpAddress).string()) + .col(ColumnDef::new(Agents::Version).string()) + .col(ColumnDef::new(Agents::State).string().not_null()) + .col(ColumnDef::new(Agents::DeploymentMode).string()) + .col(ColumnDef::new(Agents::LastSeenAt).timestamp_with_time_zone()) + .col(ColumnDef::new(Agents::Capabilities).json()) + .col(ColumnDef::new(Agents::PublicKeyHash).string()) + .col(ColumnDef::new(Agents::Labels).json()) + .col( + ColumnDef::new(Agents::CreatedAt) + .timestamp_with_time_zone() + .not_null(), + ) + .col( + ColumnDef::new(Agents::UpdatedAt) + .timestamp_with_time_zone() + .not_null(), + ) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(Agents::Table).to_owned()) + .await + } +} + +#[derive(DeriveIden)] +enum Agents { + Table, + Id, + Name, + IpAddress, + Version, + State, + DeploymentMode, + LastSeenAt, + Capabilities, + PublicKeyHash, + Labels, + CreatedAt, + UpdatedAt, +} diff --git a/crates/migration/src/m20260301_000002_create_public_key_revokaction.rs b/crates/migration/src/m20260301_000002_create_public_key_revokaction.rs new file mode 100644 index 0000000..6da5eea --- /dev/null +++ b/crates/migration/src/m20260301_000002_create_public_key_revokaction.rs @@ -0,0 +1,59 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let table = manager + .create_table( + Table::create() + .table(PublicKeyRevocations::Table) + .if_not_exists() + .col( + ColumnDef::new(PublicKeyRevocations::Id) + .uuid() + .not_null() + .primary_key(), + ) + .col( + ColumnDef::new(PublicKeyRevocations::PublicKeyHash) + .string() + .not_null(), + ) + .col( + ColumnDef::new(PublicKeyRevocations::CreatedAt) + .timestamp_with_time_zone() + .not_null(), + ) + .to_owned(), + ) + .await?; + // add index on PublicKeyHash for faster lookups + manager + .create_index( + Index::create() + .name("idx_public_key_hash") + .table(PublicKeyRevocations::Table) + .col(PublicKeyRevocations::PublicKeyHash) + .to_owned(), + ) + .await?; + Ok(table) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(PublicKeyRevocations::Table).to_owned()) + .await + } +} + +#[derive(DeriveIden)] +enum PublicKeyRevocations { + Table, + Id, + PublicKeyHash, + CreatedAt, +} -- 2.49.1 From f5eb25993b611c72710db67a743734b22e1dd896 Mon Sep 17 00:00:00 2001 From: GW_MC <72297530+GWMCwing@users.noreply.github.com> Date: Sat, 21 Mar 2026 03:09:39 +0000 Subject: [PATCH 06/10] feat: Implement SSH Agent Connector and gRPC server - Added `AgentConnectorTrait` and `AgentConnector` for managing agent connections. - Introduced `SshAgentConnector` to handle SSH-related functionalities and start a gRPC server. - Created database entities for `agents`, `certificates`, `organizations`, `public_key_revocations`, `setup_tokens`, `upstreams`, `users`, `virtual_hosts`, and `workspaces` using SeaORM. - Developed `CertificateService` for managing certificate generation and retrieval. - Implemented the main server logic to initialize the database connection and start the agent server. - Configured development settings in `development.toml` for server and database connections. --- apps/nxmesh-master/Cargo.toml | 9 +- apps/nxmesh-master/src/bin/gen-openapi.rs | 3 + apps/nxmesh-master/src/cli/gen_agent_certs.rs | 52 +++ apps/nxmesh-master/src/cli/gen_certs.rs | 31 ++ apps/nxmesh-master/src/cli/mod.rs | 63 ++++ apps/nxmesh-master/src/config/mod.rs | 1 + apps/nxmesh-master/src/config/settings.rs | 351 ++++++++++++++++++ apps/nxmesh-master/src/connector/agent/mod.rs | 40 ++ apps/nxmesh-master/src/connector/agent/ssh.rs | 110 ++++++ apps/nxmesh-master/src/connector/mod.rs | 1 + apps/nxmesh-master/src/db/entities/agents.rs | 27 ++ .../src/db/entities/certificates.rs | 45 +++ apps/nxmesh-master/src/db/entities/mod.rs | 6 + .../src/db/entities/organizations.rs | 39 ++ apps/nxmesh-master/src/db/entities/prelude.rs | 4 + .../src/db/entities/public_key_revocations.rs | 18 + .../src/db/entities/setup_tokens.rs | 21 ++ .../src/db/entities/upstreams.rs | 40 ++ apps/nxmesh-master/src/db/entities/users.rs | 39 ++ .../src/db/entities/virtual_hosts.rs | 44 +++ .../src/db/entities/workspaces.rs | 70 ++++ apps/nxmesh-master/src/db/mod.rs | 11 + apps/nxmesh-master/src/main.rs | 84 +++++ apps/nxmesh-master/src/service/agent/mod.rs | 54 +++ .../src/service/certificate/mod.rs | 349 +++++++++++++++++ apps/nxmesh-master/src/service/mod.rs | 40 ++ config/master/development.toml | 31 ++ 27 files changed, 1581 insertions(+), 2 deletions(-) create mode 100644 apps/nxmesh-master/src/bin/gen-openapi.rs create mode 100644 apps/nxmesh-master/src/cli/gen_agent_certs.rs create mode 100644 apps/nxmesh-master/src/cli/gen_certs.rs create mode 100644 apps/nxmesh-master/src/cli/mod.rs create mode 100644 apps/nxmesh-master/src/config/mod.rs create mode 100644 apps/nxmesh-master/src/config/settings.rs create mode 100644 apps/nxmesh-master/src/connector/agent/mod.rs create mode 100644 apps/nxmesh-master/src/connector/agent/ssh.rs create mode 100644 apps/nxmesh-master/src/connector/mod.rs create mode 100644 apps/nxmesh-master/src/db/entities/agents.rs create mode 100644 apps/nxmesh-master/src/db/entities/certificates.rs create mode 100644 apps/nxmesh-master/src/db/entities/mod.rs create mode 100644 apps/nxmesh-master/src/db/entities/organizations.rs create mode 100644 apps/nxmesh-master/src/db/entities/prelude.rs create mode 100644 apps/nxmesh-master/src/db/entities/public_key_revocations.rs create mode 100644 apps/nxmesh-master/src/db/entities/setup_tokens.rs create mode 100644 apps/nxmesh-master/src/db/entities/upstreams.rs create mode 100644 apps/nxmesh-master/src/db/entities/users.rs create mode 100644 apps/nxmesh-master/src/db/entities/virtual_hosts.rs create mode 100644 apps/nxmesh-master/src/db/entities/workspaces.rs create mode 100644 apps/nxmesh-master/src/db/mod.rs create mode 100644 apps/nxmesh-master/src/service/agent/mod.rs create mode 100644 apps/nxmesh-master/src/service/certificate/mod.rs create mode 100644 apps/nxmesh-master/src/service/mod.rs create mode 100644 config/master/development.toml diff --git a/apps/nxmesh-master/Cargo.toml b/apps/nxmesh-master/Cargo.toml index 8ff4b36..a851768 100644 --- a/apps/nxmesh-master/Cargo.toml +++ b/apps/nxmesh-master/Cargo.toml @@ -37,8 +37,7 @@ tower.workspace = true tower-http = { workspace = true, features = ["fs", "cors"] } # OpenAPI -utoipa = { version = "4", features = ["axum_extras"] } -utoipa-swagger-ui = { version = "6", features = ["axum"] } +utoipa = { version = "5.4", features = ["axum_extras"] } # gRPC tonic.workspace = true @@ -79,6 +78,12 @@ handlebars.workspace = true # Random generation rand = "0.10" +clap = { workspace = true, features = ["derive"] } +rcgen = { version = "0.14.7", features = ["x509-parser"] } +time = "0.3" + +# Cert handling +zip = { workspace = true } [dev-dependencies] tokio-test.workspace = true diff --git a/apps/nxmesh-master/src/bin/gen-openapi.rs b/apps/nxmesh-master/src/bin/gen-openapi.rs new file mode 100644 index 0000000..394379d --- /dev/null +++ b/apps/nxmesh-master/src/bin/gen-openapi.rs @@ -0,0 +1,3 @@ +fn main() { + // TODO: +} diff --git a/apps/nxmesh-master/src/cli/gen_agent_certs.rs b/apps/nxmesh-master/src/cli/gen_agent_certs.rs new file mode 100644 index 0000000..22963c8 --- /dev/null +++ b/apps/nxmesh-master/src/cli/gen_agent_certs.rs @@ -0,0 +1,52 @@ +use std::sync::Arc; + +use tracing::{error, info}; + +use crate::{config::settings::Settings, db, service}; + +pub async fn gen_agent_certs( + settings: &Settings, + output: String, + agent_id: String, + zip: bool, +) -> Result<(), Box> { + info!("Generating certificates to output directory: {}", output); + use service::certificate::CertificateService; + // + let cert_service = service::certificate::CertificateServiceImpl::new( + #[expect(clippy::expect_used)] + db::establish_connection(&settings.database.url) + .await + .expect("Failed to connect to database"), + output.clone(), + Arc::new(settings.clone()), + ); + + let output = cert_service + .generate_agent_certs(&agent_id, &output) + .await + .map_err(|e| { + error!("Failed to generate agent certificates: {}", e); + std::process::exit(1); + }) + .unwrap(); + + info!( + "Successfully generated agent certificates at: cert path: {}, key path: {}, ca cert path: {}", + output.cert_path, output.key_path, output.ca_cert_path + ); + if zip { + // Implementation for zipping certificates + info!("Zipping generated certificates..."); + if let Err(e) = cert_service + .zip_certificates(&output.cert_path, &output.key_path, &output.ca_cert_path) + .await + { + error!("Failed to zip certificates: {}", e); + std::process::exit(1); + } + info!("Successfully zipped certificates."); + } + + Ok(()) +} diff --git a/apps/nxmesh-master/src/cli/gen_certs.rs b/apps/nxmesh-master/src/cli/gen_certs.rs new file mode 100644 index 0000000..2ae32ea --- /dev/null +++ b/apps/nxmesh-master/src/cli/gen_certs.rs @@ -0,0 +1,31 @@ +use std::sync::Arc; + +use tracing::{error, info}; + +use crate::{config::settings::Settings, db, service}; + +pub async fn gen_certs( + settings: &Settings, + output: String, +) -> Result<(), Box> { + info!("Generating CA certificate to output directory: {}", output); + use service::certificate::CertificateService; + let cert_service = service::certificate::CertificateServiceImpl::new( + #[expect(clippy::expect_used)] + db::establish_connection(&settings.database.url) + .await + .expect("Failed to connect to database"), + output.to_string(), + Arc::new(settings.clone()), + ); + cert_service + .generate_ca_cert() + .await + .map_err(|e| { + error!("Failed to generate CA certificate: {}", e); + std::process::exit(1); + }) + .unwrap(); + info!("Successfully generated CA certificate at: {}", output); + Ok(()) +} diff --git a/apps/nxmesh-master/src/cli/mod.rs b/apps/nxmesh-master/src/cli/mod.rs new file mode 100644 index 0000000..dc3a2c0 --- /dev/null +++ b/apps/nxmesh-master/src/cli/mod.rs @@ -0,0 +1,63 @@ +mod gen_agent_certs; +mod gen_certs; + +use clap::{Parser, Subcommand}; + +use crate::{ + cli::{gen_agent_certs::gen_agent_certs, gen_certs::gen_certs}, + config::settings::Settings, +}; + +#[derive(Parser)] +#[command(version, about, long_about = None)] +pub struct Cli { + /// Start the master server + #[arg(short, long, group = "mode")] + pub serve: bool, + + /// generate CA for key signing if not exist + /// If the CA already exists, generating CA will be skipped and the existing CA will be used + /// If the CA does not exist, a new CA will be generated and saved to the default location (./certs/ca.crt and ./certs/ca.key) + /// The generated CA will be used for signing agent certificates + /// If not specified, the server will check if the CA already exists and use it if available, otherwise exit with an error + #[arg(long)] + pub generate_ca: bool, + + #[command(subcommand)] + pub command: Option, +} + +#[derive(Subcommand)] +pub enum Commands { + GenCerts { + /// Output directory for generated certificates + #[arg(short, long, default_value = "./certs")] + output: String, + }, + /// Generate certificates for agent + #[command(about = "Generate certificates for agent")] + GenAgentCerts { + /// Output directory for generated certificates + #[arg(short, long, default_value = "./certs")] + output: String, + #[arg(long, default_value = "agent-id-placeholder")] + agent_id: String, + #[arg(short, long, default_value = "false")] + zip: bool, + }, +} + +pub async fn handle_sub_command( + settings: &Settings, + command: Commands, +) -> Result<(), Box> { + // run as a CLI tool for other commands + match command { + Commands::GenCerts { output } => Ok(gen_certs(settings, output).await?), + Commands::GenAgentCerts { + output, + agent_id, + zip, + } => Ok(gen_agent_certs(settings, output, agent_id, zip).await?), + } +} diff --git a/apps/nxmesh-master/src/config/mod.rs b/apps/nxmesh-master/src/config/mod.rs new file mode 100644 index 0000000..6e98cef --- /dev/null +++ b/apps/nxmesh-master/src/config/mod.rs @@ -0,0 +1 @@ +pub mod settings; diff --git a/apps/nxmesh-master/src/config/settings.rs b/apps/nxmesh-master/src/config/settings.rs new file mode 100644 index 0000000..c578fec --- /dev/null +++ b/apps/nxmesh-master/src/config/settings.rs @@ -0,0 +1,351 @@ +use config::{Config, ConfigError, Environment, File}; +use rcgen::string::Ia5String; +use serde::{Deserialize, Deserializer, Serialize}; +use std::{net::IpAddr, str::FromStr}; +use tracing::level_filters::LevelFilter; + +type ValidationError = String; + +trait Validate { + fn validate(&self) -> Result<(), ValidationError>; +} + +/// Master server settings +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Settings { + pub server: ServerSettings, + pub database: DatabaseSettings, + pub grpc: GrpcSettings, + pub auth: AuthSettings, + #[serde(default)] + pub log: LogSettings, +} + +/// HTTP server settings +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ServerSettings { + #[serde(default = "default_server_bind_address")] + pub bind_address: String, + #[serde(default = "default_server_port")] + pub port: u16, + #[serde(default)] + pub certificate: CertificateSettings, + #[serde(default)] + pub cors: Option, +} + +/// Database connection settings +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DatabaseSettings { + pub url: String, + pub max_connections: Option, +} + +/// gRPC server settings +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GrpcSettings { + #[serde(default = "default_grpc_bind_address")] + pub bind_address: String, + #[serde(default = "default_grpc_port")] + pub port: u16, + #[serde(default)] + pub certificate: CertificateSettings, + #[serde(default)] + pub cors: Option, +} + +/// Authentication settings +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AuthSettings { + pub jwt_secret: String, + #[serde(default = "default_jwt_expiration_hours")] + pub jwt_expiration_hours: u64, +} + +/// TLS certificate settings +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct CertificateSettings { + #[serde(default = "default_cert_folder")] + pub cert_dir: String, + #[serde( + default, + serialize_with = "serialize_ia5string_vec", + deserialize_with = "deserialize_ia5string_vec" + )] + pub san_dns: Vec, + #[serde(default)] + pub san_ip: Vec, + #[serde(default)] + cert_path: Option, + #[serde(default)] + key_path: Option, +} + +impl CertificateSettings { + pub fn cert_path(&self) -> Option { + self.cert_path + .as_ref() + .map(|p| format!("{}/{}", self.cert_dir, p)) + } + + pub fn key_path(&self) -> Option { + self.key_path + .as_ref() + .map(|p| format!("{}/{}", self.cert_dir, p)) + } +} + +/// CORS settings +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct CorsSettings { + #[serde(default)] + pub allowed_origins: Vec, + #[serde(default)] + pub allowed_methods: Vec, + #[serde(default)] + pub allowed_headers: Vec, + #[serde(default)] + pub allow_credentials: bool, +} + +/// Logging settings +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LogSettings { + #[serde( + deserialize_with = "deserialize_level_filter", + serialize_with = "serialize_level_filter" + )] + pub level: LevelFilter, +} + +impl Default for LogSettings { + fn default() -> Self { + Self { + level: default_log_level(), + } + } +} + +impl Validate for Settings { + fn validate(&self) -> Result<(), ValidationError> { + self.server.validate()?; + self.grpc.validate()?; + self.database.validate()?; + self.auth.validate()?; + Ok(()) + } +} + +impl Settings { + /// Load settings from config files and environment + pub fn load() -> Result { + let run_mode = std::env::var("RUN_MODE").unwrap_or_else(|_| "development".into()); + + let settings = Config::builder() + .add_source(File::with_name("config/default").required(false)) + .add_source(File::with_name(&format!("config/{}", run_mode)).required(false)) + .add_source(File::with_name("config/master/default").required(false)) + .add_source(File::with_name(&format!("config/master/{}", run_mode)).required(false)) + .add_source(Environment::with_prefix("NXMESH").separator("__")) + .build()?; + + let settings: Self = settings.try_deserialize()?; + + settings.validate().map_err(ConfigError::Message)?; + + Ok(settings) + } +} + +impl Validate for ServerSettings { + fn validate(&self) -> Result<(), ValidationError> { + if self.bind_address.is_empty() { + return Err("Server bind address cannot be empty".into()); + } + if self.port == 0 { + return Err("Server port must be greater than 0".into()); + } + self.certificate.validate()?; + if let Some(cors) = &self.cors { + cors.validate()?; + } + Ok(()) + } +} + +impl Validate for GrpcSettings { + fn validate(&self) -> Result<(), ValidationError> { + if self.bind_address.is_empty() { + return Err("gRPC bind address cannot be empty".into()); + } + if self.port == 0 { + return Err("gRPC port must be greater than 0".into()); + } + self.certificate.validate()?; + if let Some(cors) = &self.cors { + cors.validate()?; + } + Ok(()) + } +} + +impl Validate for DatabaseSettings { + fn validate(&self) -> Result<(), ValidationError> { + if self.url.is_empty() { + return Err("Database URL cannot be empty".into()); + } + if let Some(max_connections) = self.max_connections + && max_connections == 0 + { + return Err("Max database connections must be greater than 0".into()); + } + Ok(()) + } +} + +impl Validate for AuthSettings { + fn validate(&self) -> Result<(), ValidationError> { + if self.jwt_secret.is_empty() { + return Err("JWT secret cannot be empty".into()); + } + if self.jwt_expiration_hours == 0 { + return Err("JWT expiration hours must be greater than 0".into()); + } + Ok(()) + } +} + +impl Validate for CertificateSettings { + fn validate(&self) -> Result<(), ValidationError> { + let base_path = std::path::Path::new(&self.cert_dir); + if !base_path.exists() { + // create the cert directory if it doesn't exist + std::fs::create_dir_all(base_path).map_err(|e| { + format!( + "Failed to create certificate directory {:?}: {}", + base_path, e + ) + })?; + } + let cert_path = self.cert_path.as_ref().map(|p| base_path.join(p)); + let key_path = self.key_path.as_ref().map(|p| base_path.join(p)); + if (cert_path.is_some() && key_path.is_none()) + || (cert_path.is_none() && key_path.is_some()) + { + return Err("Both certificate and key paths must be provided for TLS".into()); + } + if let (Some(cert_path), Some(key_path)) = (&cert_path, &key_path) { + if !std::path::Path::new(cert_path).exists() { + return Err(format!("Certificate file not found: {:?}", cert_path)); + } + if !std::path::Path::new(key_path).exists() { + return Err(format!("Key file not found: {:?}", key_path)); + } + } + + // validate for SAN entries - must be valid DNS names or IP addresses + for dns in &self.san_dns { + if dns.to_string().is_empty() { + return Err("SAN DNS entries cannot be empty".into()); + } + } + for ip in &self.san_ip { + if ip.is_unspecified() { + return Err("SAN IP entries cannot be unspecified".into()); + } + } + // require at least one SAN entry for the generated certificate + if self.san_dns.is_empty() && self.san_ip.is_empty() { + return Err( + "At least one SAN entry (DNS or IP) must be provided for the certificate".into(), + ); + } + + Ok(()) + } +} + +impl Validate for CorsSettings { + fn validate(&self) -> Result<(), ValidationError> { + Ok(()) + } +} + +fn default_jwt_expiration_hours() -> u64 { + 24 +} + +fn default_server_bind_address() -> String { + "0.0.0.0".into() +} + +fn default_server_port() -> u16 { + 8080 +} + +fn default_grpc_bind_address() -> String { + "0.0.0.0".into() +} + +fn default_grpc_port() -> u16 { + 50051 +} + +fn default_log_level() -> LevelFilter { + LevelFilter::INFO +} + +fn default_cert_folder() -> String { + "./certs".into() +} + +fn deserialize_level_filter<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + let s = String::deserialize(deserializer)?; + LevelFilter::from_str(&s).map_err(serde::de::Error::custom) +} + +fn serialize_level_filter(level: &LevelFilter, serializer: S) -> Result +where + S: serde::Serializer, +{ + serializer.serialize_str(&level.to_string()) +} + +fn deserialize_ia5string_vec<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let vec = Vec::::deserialize(deserializer)?; + vec.into_iter() + .map(|s| Ia5String::try_from(s).map_err(serde::de::Error::custom)) + .collect() +} + +fn serialize_ia5string_vec(vec: &Vec, serializer: S) -> Result +where + S: serde::Serializer, +{ + let string_vec: Vec = vec.iter().map(|ia5| ia5.to_string()).collect(); + string_vec.serialize(serializer) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_esnure_send_and_sync() { + fn assert_send_sync() {} + assert_send_sync::(); + assert_send_sync::(); + assert_send_sync::(); + assert_send_sync::(); + assert_send_sync::(); + assert_send_sync::(); + assert_send_sync::(); + assert_send_sync::(); + } +} diff --git a/apps/nxmesh-master/src/connector/agent/mod.rs b/apps/nxmesh-master/src/connector/agent/mod.rs new file mode 100644 index 0000000..dbeba2a --- /dev/null +++ b/apps/nxmesh-master/src/connector/agent/mod.rs @@ -0,0 +1,40 @@ +use std::sync::Arc; + +use sea_orm::DatabaseConnection; +use tonic::transport::Server; + +pub mod ssh; + +#[async_trait::async_trait] +pub trait AgentConnectorTrait: Send + Sync { + async fn start_server( + &mut self, + settings: &crate::config::settings::Settings, + cert_service: Arc, + connection: DatabaseConnection, + ) -> Result<(), Box>; +} + +pub struct AgentConnector { + connector: Box, +} + +impl AgentConnector { + pub fn new(connector: Box) -> Self { + Self { connector } + } +} + +#[async_trait::async_trait] +impl AgentConnectorTrait for AgentConnector { + async fn start_server( + &mut self, + settings: &crate::config::settings::Settings, + cert_service: Arc, + connection: DatabaseConnection, + ) -> Result<(), Box> { + self.connector + .start_server(settings, cert_service, connection) + .await + } +} diff --git a/apps/nxmesh-master/src/connector/agent/ssh.rs b/apps/nxmesh-master/src/connector/agent/ssh.rs new file mode 100644 index 0000000..5b02082 --- /dev/null +++ b/apps/nxmesh-master/src/connector/agent/ssh.rs @@ -0,0 +1,110 @@ +use std::sync::Arc; + +use nxmesh_proto::{ + agent_service_server::AgentServiceServer, + auth::ssh_auth::{CertificateValidationProvider, create_ssh_auth_interceptor}, +}; +use sea_orm::{ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter}; +use tonic::transport::Server; + +use crate::{db::entities::public_key_revocations, service::agent::AgentServerService}; + +use super::AgentConnectorTrait; + +const MAX_CERTS_TO_CHECK: usize = 50; + +pub struct SshAgentConnector { + // router: Router, Identity>>, + settings: Arc, +} + +impl SshAgentConnector { + pub fn new( + settings: impl Into>, + ) -> Result> { + Ok(Self { + settings: settings.into(), + }) + } + + async fn get_tls_config( + cert_service: Arc, + ) -> Result> { + let (san_ips, san_dns) = + cert_service.get_sans(crate::service::certificate::ConnectionType::GRPC); + let (cert_pem, key_pem) = cert_service + .generate_pub_cert_pair(san_ips, san_dns) + .await?; + let (ca_cert_path, _) = cert_service.get_ca_cert().await?; + let ca_cert_pem = std::fs::read_to_string(&ca_cert_path)?; + + let tls_config = tonic::transport::ServerTlsConfig::new() + .identity(tonic::transport::Identity::from_pem(cert_pem, key_pem)) + .client_ca_root(tonic::transport::Certificate::from_pem(ca_cert_pem)); + Ok(tls_config) + } +} + +#[async_trait::async_trait] +impl AgentConnectorTrait for SshAgentConnector { + async fn start_server( + &mut self, + settings: &crate::config::settings::Settings, + cert_service: Arc, + connection: DatabaseConnection, + ) -> Result<(), Box> { + let addr = settings.grpc.bind_address.clone().parse()?; + let port = settings.grpc.port; + let addr = std::net::SocketAddr::new(addr, port); + + // Create the gRPC server + let cert_validation_provider = Arc::new(CertificateValidationProviderImpl::new(connection)); + let ssh_interceptor = create_ssh_auth_interceptor(cert_validation_provider); + let agent_server_service = AgentServiceServer::new(AgentServerService::default()); + + let tls_config = Self::get_tls_config(cert_service.clone()).await?; + + let router = Server::builder() + .tls_config(tls_config)? + .layer(ssh_interceptor) + .add_service(agent_server_service); + + router.serve(addr).await?; + Ok(()) + } +} + +struct CertificateValidationProviderImpl { + connection: DatabaseConnection, +} + +impl CertificateValidationProviderImpl { + pub fn new(connection: DatabaseConnection) -> Self { + CertificateValidationProviderImpl { connection } + } +} + +#[async_trait::async_trait] +impl CertificateValidationProvider for CertificateValidationProviderImpl { + async fn is_authorized( + &self, + certs: &Arc>>, + ) -> Result { + // check if the certificate's public key matches any agent's public key in the database + let found = public_key_revocations::Entity::find() + .filter(public_key_revocations::Column::PublicKeyHash.is_in( + certs.iter().take(MAX_CERTS_TO_CHECK).map(|cert| { + use sha2::{Digest, Sha256}; + let mut hasher = Sha256::new(); + hasher.update(cert.as_ref()); + hex::encode(hasher.finalize()) + }), + )) + .one(&self.connection) + .await + .map_err(|e| tonic::Status::internal(format!("Database query failed: {}", e)))? + .is_some(); + + Ok(!found) + } +} diff --git a/apps/nxmesh-master/src/connector/mod.rs b/apps/nxmesh-master/src/connector/mod.rs new file mode 100644 index 0000000..f17bc55 --- /dev/null +++ b/apps/nxmesh-master/src/connector/mod.rs @@ -0,0 +1 @@ +pub mod agent; diff --git a/apps/nxmesh-master/src/db/entities/agents.rs b/apps/nxmesh-master/src/db/entities/agents.rs new file mode 100644 index 0000000..e9a681a --- /dev/null +++ b/apps/nxmesh-master/src/db/entities/agents.rs @@ -0,0 +1,27 @@ +//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0 + +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "agents")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub name: String, + pub ip_address: Option, + pub version: Option, + pub state: String, + pub deployment_mode: Option, + pub last_seen_at: Option, + pub capabilities: Option, + pub public_key_hash: Option, + pub labels: Option, + pub created_at: DateTimeWithTimeZone, + pub updated_at: DateTimeWithTimeZone, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/apps/nxmesh-master/src/db/entities/certificates.rs b/apps/nxmesh-master/src/db/entities/certificates.rs new file mode 100644 index 0000000..c9acd4e --- /dev/null +++ b/apps/nxmesh-master/src/db/entities/certificates.rs @@ -0,0 +1,45 @@ +//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0 + +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "certificates")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub workspace_id: Uuid, + pub domain: String, + pub is_wildcard: bool, + pub provider: Option, + pub status: Option, + pub issued_at: Option, + pub expires_at: Option, + pub auto_renew: bool, + #[sea_orm(column_type = "Text", nullable)] + pub certificate_pem: Option, + #[sea_orm(column_type = "Text", nullable)] + pub private_key_pem: Option, + pub created_at: DateTimeWithTimeZone, + pub updated_at: DateTimeWithTimeZone, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::workspaces::Entity", + from = "Column::WorkspaceId", + to = "super::workspaces::Column::Id", + on_update = "NoAction", + on_delete = "Cascade" + )] + Workspaces, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Workspaces.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/apps/nxmesh-master/src/db/entities/mod.rs b/apps/nxmesh-master/src/db/entities/mod.rs new file mode 100644 index 0000000..5224c47 --- /dev/null +++ b/apps/nxmesh-master/src/db/entities/mod.rs @@ -0,0 +1,6 @@ +//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0 + +pub mod prelude; + +pub mod agents; +pub mod public_key_revocations; diff --git a/apps/nxmesh-master/src/db/entities/organizations.rs b/apps/nxmesh-master/src/db/entities/organizations.rs new file mode 100644 index 0000000..a73a39e --- /dev/null +++ b/apps/nxmesh-master/src/db/entities/organizations.rs @@ -0,0 +1,39 @@ +//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0 + +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "organizations")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub name: String, + #[sea_orm(unique)] + pub slug: String, + pub created_at: DateTimeWithTimeZone, + pub updated_at: DateTimeWithTimeZone, + pub settings: Option, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm(has_many = "super::users::Entity")] + Users, + #[sea_orm(has_many = "super::workspaces::Entity")] + Workspaces, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Users.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Workspaces.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/apps/nxmesh-master/src/db/entities/prelude.rs b/apps/nxmesh-master/src/db/entities/prelude.rs new file mode 100644 index 0000000..4fc9460 --- /dev/null +++ b/apps/nxmesh-master/src/db/entities/prelude.rs @@ -0,0 +1,4 @@ +//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0 + +pub use super::agents::Entity as Agents; +pub use super::public_key_revocations::Entity as PublicKeyRevocations; diff --git a/apps/nxmesh-master/src/db/entities/public_key_revocations.rs b/apps/nxmesh-master/src/db/entities/public_key_revocations.rs new file mode 100644 index 0000000..1c38e0f --- /dev/null +++ b/apps/nxmesh-master/src/db/entities/public_key_revocations.rs @@ -0,0 +1,18 @@ +//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0 + +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "public_key_revocations")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub public_key_hash: String, + pub created_at: DateTimeWithTimeZone, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/apps/nxmesh-master/src/db/entities/setup_tokens.rs b/apps/nxmesh-master/src/db/entities/setup_tokens.rs new file mode 100644 index 0000000..aa96351 --- /dev/null +++ b/apps/nxmesh-master/src/db/entities/setup_tokens.rs @@ -0,0 +1,21 @@ +//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0 + +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "setup_tokens")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + #[sea_orm(unique)] + pub token_hash: String, + pub expires_at: DateTimeWithTimeZone, + pub used_at: Option, + pub created_at: DateTimeWithTimeZone, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/apps/nxmesh-master/src/db/entities/upstreams.rs b/apps/nxmesh-master/src/db/entities/upstreams.rs new file mode 100644 index 0000000..a27a1d7 --- /dev/null +++ b/apps/nxmesh-master/src/db/entities/upstreams.rs @@ -0,0 +1,40 @@ +//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0 + +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "upstreams")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub workspace_id: Uuid, + pub name: String, + pub algorithm: String, + pub servers: Option, + pub health_check: Option, + pub keepalive_connections: Option, + pub keepalive_timeout: Option, + pub created_at: DateTimeWithTimeZone, + pub updated_at: DateTimeWithTimeZone, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::workspaces::Entity", + from = "Column::WorkspaceId", + to = "super::workspaces::Column::Id", + on_update = "NoAction", + on_delete = "Cascade" + )] + Workspaces, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Workspaces.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/apps/nxmesh-master/src/db/entities/users.rs b/apps/nxmesh-master/src/db/entities/users.rs new file mode 100644 index 0000000..33b8482 --- /dev/null +++ b/apps/nxmesh-master/src/db/entities/users.rs @@ -0,0 +1,39 @@ +//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0 + +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "users")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + #[sea_orm(unique)] + pub email: String, + pub password_hash: String, + pub name: Option, + pub role: String, + pub organization_id: Option, + pub created_at: DateTimeWithTimeZone, + pub updated_at: DateTimeWithTimeZone, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::organizations::Entity", + from = "Column::OrganizationId", + to = "super::organizations::Column::Id", + on_update = "NoAction", + on_delete = "SetNull" + )] + Organizations, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Organizations.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/apps/nxmesh-master/src/db/entities/virtual_hosts.rs b/apps/nxmesh-master/src/db/entities/virtual_hosts.rs new file mode 100644 index 0000000..631a917 --- /dev/null +++ b/apps/nxmesh-master/src/db/entities/virtual_hosts.rs @@ -0,0 +1,44 @@ +//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0 + +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "virtual_hosts")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub workspace_id: Uuid, + pub name: String, + pub server_name: String, + pub listen_port: i32, + pub ssl_enabled: bool, + pub ssl_certificate_id: Option, + pub locations: Option, + pub http2_enabled: bool, + pub http3_enabled: bool, + pub gzip_enabled: bool, + pub target_agents: Option, + pub created_at: DateTimeWithTimeZone, + pub updated_at: DateTimeWithTimeZone, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::workspaces::Entity", + from = "Column::WorkspaceId", + to = "super::workspaces::Column::Id", + on_update = "NoAction", + on_delete = "Cascade" + )] + Workspaces, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Workspaces.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/apps/nxmesh-master/src/db/entities/workspaces.rs b/apps/nxmesh-master/src/db/entities/workspaces.rs new file mode 100644 index 0000000..0cd5728 --- /dev/null +++ b/apps/nxmesh-master/src/db/entities/workspaces.rs @@ -0,0 +1,70 @@ +//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0 + +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "workspaces")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + #[sea_orm(unique_key = "idx_workspaces_org_slug")] + pub organization_id: Uuid, + pub name: String, + #[sea_orm(unique_key = "idx_workspaces_org_slug")] + pub slug: String, + pub created_at: DateTimeWithTimeZone, + pub updated_at: DateTimeWithTimeZone, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm(has_many = "super::agents::Entity")] + Agents, + #[sea_orm(has_many = "super::certificates::Entity")] + Certificates, + #[sea_orm( + belongs_to = "super::organizations::Entity", + from = "Column::OrganizationId", + to = "super::organizations::Column::Id", + on_update = "NoAction", + on_delete = "Cascade" + )] + Organizations, + #[sea_orm(has_many = "super::upstreams::Entity")] + Upstreams, + #[sea_orm(has_many = "super::virtual_hosts::Entity")] + VirtualHosts, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Agents.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Certificates.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Organizations.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Upstreams.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::VirtualHosts.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/apps/nxmesh-master/src/db/mod.rs b/apps/nxmesh-master/src/db/mod.rs new file mode 100644 index 0000000..084546f --- /dev/null +++ b/apps/nxmesh-master/src/db/mod.rs @@ -0,0 +1,11 @@ +use sea_orm::{Database, DatabaseConnection}; + +pub mod entities; + +pub(crate) async fn establish_connection( + url: &str, +) -> Result> { + Database::connect(url) + .await + .map_err(|e| format!("Failed to connect to database: {}", e).into()) +} diff --git a/apps/nxmesh-master/src/main.rs b/apps/nxmesh-master/src/main.rs index e69de29..5aad5d4 100644 --- a/apps/nxmesh-master/src/main.rs +++ b/apps/nxmesh-master/src/main.rs @@ -0,0 +1,84 @@ +#![forbid(unsafe_code, unused_must_use)] +#![deny(clippy::unwrap_used, clippy::panic, clippy::expect_used)] + +use clap::{CommandFactory, Parser}; +use tracing::{error, info}; +use tracing_subscriber::{ + Layer, filter::LevelFilter, fmt, layer::SubscriberExt, registry::Registry, reload, + util::SubscriberInitExt, +}; + +use crate::cli::{Cli, handle_sub_command}; + +mod cli; +mod config; +mod connector; +mod db; +mod service; + +#[tokio::main] +async fn main() { + // install a global subscriber for logging + let reload_handle = install_tracing_subscriber(); + // Load configuration settings + let settings = match config::settings::Settings::load() { + Ok(s) => s, + Err(e) => { + error!("Failed to load configuration: {}", e); + std::process::exit(1); + } + }; + + reload_handle + .modify(|filter| *filter = Box::new(settings.log.level)) + .inspect_err(|e| { + error!( + "Failed to set log level: {}. Continuing with default level.", + e + ) + }) + // ignore errors here since we can still run with the default log level + .ok(); + + // print the loaded settings for debugging + // info!("Loaded settings: {:#?}", settings); + let cli = Cli::parse(); + + if cli.serve { + info!("Starting master server..."); + if let Err(e) = service::start_master_server(settings, cli).await { + error!("Failed to start master server: {}", e); + std::process::exit(1); + } + } else if let Some(command) = cli.command { + handle_sub_command(&settings, command) + .await + .unwrap_or_else(|e| { + error!("Error handling command: {}", e); + std::process::exit(1); + }); + } else { + error!("No mode specified."); + // display help message + #[allow(clippy::expect_used)] + Cli::command() + .print_help() + .expect("Failed to print help message"); + std::process::exit(1); + } +} + +fn install_tracing_subscriber() +-> reload::Handle + Send + Sync>, Registry> { + let filter = LevelFilter::INFO; + let (filter_layer, reload_handle) = + reload::Layer::new(Box::new(fmt::layer().with_filter(filter)) + as Box + Send + Sync>); + + tracing_subscriber::registry() + .with(filter_layer) + .with(fmt::Layer::default()) + .init(); + + reload_handle +} diff --git a/apps/nxmesh-master/src/service/agent/mod.rs b/apps/nxmesh-master/src/service/agent/mod.rs new file mode 100644 index 0000000..ad5a849 --- /dev/null +++ b/apps/nxmesh-master/src/service/agent/mod.rs @@ -0,0 +1,54 @@ +use nxmesh_proto::{ + Ack, AgentMessage, HealthReport, MasterMessage, MetricsBatch, + agent_service_server::AgentService, +}; +use tracing::warn; + +#[derive(Debug, Default)] +pub struct AgentServerService {} + +#[async_trait::async_trait] +impl AgentService for AgentServerService { + #[doc = " Server streaming response type for the Stream method."] + type StreamStream = tonic::codec::Streaming; + + #[doc = " Stream establishes a persistent connection for real-time communication"] + #[allow( + mismatched_lifetime_syntaxes, + clippy::type_complexity, + clippy::type_repetition_in_bounds + )] + async fn stream( + &self, + request: tonic::Request>, + ) -> Result, tonic::Status> { + todo!() + } + + #[doc = " ReportHealth sends a health report to the master"] + #[allow( + mismatched_lifetime_syntaxes, + clippy::type_complexity, + clippy::type_repetition_in_bounds + )] + async fn report_health( + &self, + request: tonic::Request, + ) -> Result, tonic::Status> { + warn!("Received health report: {:?}", request.get_ref()); + todo!() + } + + #[doc = " ReportMetrics sends metrics batch to the master"] + #[allow( + mismatched_lifetime_syntaxes, + clippy::type_complexity, + clippy::type_repetition_in_bounds + )] + async fn report_metrics( + &self, + request: tonic::Request, + ) -> Result, tonic::Status> { + todo!() + } +} diff --git a/apps/nxmesh-master/src/service/certificate/mod.rs b/apps/nxmesh-master/src/service/certificate/mod.rs new file mode 100644 index 0000000..d7e483b --- /dev/null +++ b/apps/nxmesh-master/src/service/certificate/mod.rs @@ -0,0 +1,349 @@ +use std::{io::Write, os::unix::fs::PermissionsExt, path::Path, sync::Arc}; + +use rcgen::{ + BasicConstraints, CertificateParams, DnType, ExtendedKeyUsagePurpose, IsCa, Issuer, KeyPair, + KeyUsagePurpose, SanType, string::Ia5String, +}; +use sea_orm::DatabaseConnection; +use time::{Duration, OffsetDateTime}; +use tracing::debug; + +// TODO: cert rotation, revocation, and CRL support + +pub enum ConnectionType { + GRPC, + HTTP, +} + +#[async_trait::async_trait] +pub trait CertificateService: Sync + Send { + /// Get the CA certificate path, if the CA certificate does not exist, return an error + async fn get_ca_cert( + &self, + ) -> Result<(String, String), Box>; + /// Generate an in memory public and private key pair, sign it with the CA certificate and key, and return the signed public and private key as PEM string, if the CA certificate does not exist, return an error, if the CA certificate already exists, return an error + async fn generate_pub_cert_pair( + &self, + san_ips: Vec, + san_dns: Vec, + ) -> Result<(String, String), Box>; + /// Generate a new CA certificate and save it to the specified path, if the CA certificate already exists, return an error + async fn generate_ca_cert( + &self, + ) -> Result>; + /// Generate certificates for agent and save them to the specified output directory, the output directory should be created if it does not exist + async fn generate_agent_certs( + &self, + agent_id: &str, + output_dir: &str, + ) -> Result>; + /// Zip the generated agent certificates, the input should be the cert path and key path, the output should be a zip file containing the cert and key + async fn zip_certificates( + &self, + cert_path: &str, + key_path: &str, + ca_cert_path: &str, + ) -> Result>; + /// Get the sans to be included in the generated certificates, this is used to support IP-based connections to the agent, the SANs will be included in both the CA certificate and the agent certificates, if the SANs are not specified, some common local addresses will be included by default + fn get_sans(&self, connection_type: ConnectionType) -> (Vec, Vec); +} + +pub struct CertificateServiceImpl { + db: DatabaseConnection, + /// The path to the CA certificate, the CA certificate and private key will be saved to this path when generating a new CA certificate + cert_folder_path: String, + settings: Arc, +} + +impl CertificateServiceImpl { + pub fn new( + db: DatabaseConnection, + cert_folder_path: String, + settings: Arc, + ) -> Self { + Self { + db, + cert_folder_path, + settings, + } + } +} + +#[derive(Debug, Clone)] +pub struct CertPathInfo { + pub private_key: String, + pub cert_pem: String, + pub public_key: String, +} + +#[derive(Debug, Clone)] +pub struct AgentCertPathInfo { + pub cert_path: String, + pub key_path: String, + pub ca_cert_path: String, +} + +#[async_trait::async_trait] +impl CertificateService for CertificateServiceImpl { + async fn get_ca_cert( + &self, + ) -> Result<(String, String), Box> { + if Path::new(&self.cert_folder_path).exists() { + let cert_path = Path::new(&self.cert_folder_path).join("ca.crt"); + let key_path = Path::new(&self.cert_folder_path).join("ca.key"); + if cert_path.exists() && key_path.exists() { + Ok(( + cert_path.to_string_lossy().to_string(), + key_path.to_string_lossy().to_string(), + )) + } else { + Err(Box::new(std::io::Error::new( + std::io::ErrorKind::NotFound, + "CA certificate or key not found", + ))) + } + } else { + Err(Box::new(std::io::Error::new( + std::io::ErrorKind::NotFound, + "CA certificate folder not found", + ))) + } + } + + async fn generate_pub_cert_pair( + &self, + san_ips: Vec, + san_dns: Vec, + ) -> Result<(String, String), Box> { + let (ca_cert_path, ca_key_path) = self.get_ca_cert().await?; + let ca_cert_pem = std::fs::read_to_string(ca_cert_path)?; + let ca_key_pem = std::fs::read_to_string(ca_key_path)?; + + let ca_key = KeyPair::from_pem(&ca_key_pem)?; + let issuer = Issuer::from_ca_cert_pem(&ca_cert_pem, ca_key)?; + + // TODO: require input to set the SANs for the generated certificate, for now we will include some common local addresses to support IP-based connections to the agent, but in the future we should allow users to specify the SANs for the generated certificates + // Include SANs for common local addresses to support IP-based connections + let subject_alt_names: Vec = [ + san_ips + .into_iter() + .map(SanType::IpAddress) + .collect::>(), + san_dns + .into_iter() + .map(|dns| SanType::DnsName(dns)) + .collect::>(), + ] + .concat(); + + let mut params = CertificateParams::default(); + params.subject_alt_names = subject_alt_names; + params.is_ca = IsCa::NoCa; + params.key_usages.push(KeyUsagePurpose::DigitalSignature); + params + .extended_key_usages + .push(ExtendedKeyUsagePurpose::ServerAuth); + params + .extended_key_usages + .push(ExtendedKeyUsagePurpose::ClientAuth); + params.serial_number = Some(rand::random::().into()); // Unique serial + + let (not_before, not_after) = validity_period(); + params.not_before = not_before; + params.not_after = not_after; + + let key_pair = KeyPair::generate_for(&rcgen::PKCS_ED25519)?; + let cert = params.signed_by(&key_pair, &issuer)?; + Ok((cert.pem(), key_pair.serialize_pem())) + } + + async fn generate_ca_cert( + &self, + ) -> Result> { + // check if the CA certificate already exists in the folder + let cert_folder_path = Path::new(&self.cert_folder_path); + let cert_path = cert_folder_path.join("ca.crt"); + let key_path = cert_folder_path.join("ca.key"); + let pub_path = cert_folder_path.join("ca.pub"); + if !cert_folder_path.exists() { + std::fs::create_dir_all(cert_folder_path)?; + } + if cert_path.exists() || key_path.exists() || pub_path.exists() { + return Err(Box::new(std::io::Error::new( + std::io::ErrorKind::AlreadyExists, + "CA certificate already exists", + ))); + } + + let kp = KeyPair::generate_for(&rcgen::PKCS_ED25519)?; + let mut params = CertificateParams::new(Vec::default())?; + params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained); + params + .distinguished_name + .push(DnType::OrganizationName, "MasterCA"); + params.key_usages.push(KeyUsagePurpose::DigitalSignature); + params.key_usages.push(KeyUsagePurpose::KeyCertSign); + params.key_usages.push(KeyUsagePurpose::CrlSign); + + let (not_before, not_after) = validity_period(); + params.not_before = not_before; + params.not_after = not_after; + + let ca_cert = params.self_signed(&kp)?; + let cert_pem = ca_cert.pem(); + let private_key = kp.serialize_pem(); + let public_key = kp.public_key_pem(); + + // save the CA certificate and private key to the specified path + + std::fs::write(&cert_path, cert_pem.as_bytes())?; + std::fs::set_permissions(cert_path, std::fs::Permissions::from_mode(0o600))?; + + std::fs::write(&key_path, private_key.as_bytes())?; + std::fs::set_permissions(key_path, std::fs::Permissions::from_mode(0o600))?; + + std::fs::write(&pub_path, public_key.as_bytes())?; + std::fs::set_permissions(pub_path, std::fs::Permissions::from_mode(0o600))?; + + Ok(CertPathInfo { + private_key, + cert_pem, + public_key, + }) + } + + async fn generate_agent_certs( + &self, + agent_id: &str, + output_dir: &str, + ) -> Result> { + debug!( + "Generating agent certificates for agent_id: {}, output_dir: {}", + agent_id, output_dir + ); + let output_path_dir = Path::new(output_dir).join(agent_id); + let cert_path = output_path_dir.join("cert.pem"); + let key_path = output_path_dir.join("key.pem"); + + // validate output parent directory exists + if !std::path::Path::new(output_dir).exists() { + // TODO: custom error type + return Err(Box::new(std::io::Error::new( + std::io::ErrorKind::NotFound, + "Output parent directory does not exist", + ))); + } + + // create output directory if it does not exist + if !output_path_dir.exists() { + std::fs::create_dir_all(&output_path_dir)?; + } + + // Check if CA certificate exists + let (ca_cert_path, ca_key_path) = self.get_ca_cert().await?; + + // Read CA certificate and key from disk + debug!("Reading CA certificate from path: {:?}", ca_cert_path); + let ca_cert_pem = std::fs::read_to_string(ca_cert_path.clone())?; + let ca_key_pem = std::fs::read_to_string(ca_key_path)?; + + // Parse CA key and create issuer + debug!("Parsing CA key and creating issuer"); + let ca_key = KeyPair::from_pem(&ca_key_pem)?; + let issuer = Issuer::from_ca_cert_pem(&ca_cert_pem, ca_key)?; + + // Generate agent keypair + let agent_keypair = KeyPair::generate_for(&rcgen::PKCS_ED25519)?; + + // Params for agent leaf cert + let mut params = CertificateParams::new(vec![agent_id.to_string()])?; + params + .distinguished_name + .push(DnType::CommonName, agent_id.to_string()); + params.use_authority_key_identifier_extension = true; + params.key_usages.push(KeyUsagePurpose::DigitalSignature); + params + .extended_key_usages + .push(ExtendedKeyUsagePurpose::ServerAuth); + params + .extended_key_usages + .push(ExtendedKeyUsagePurpose::ClientAuth); + params.serial_number = Some(rand::random::().into()); // Unique serial + + let (not_before, not_after) = validity_period(); + params.not_before = not_before; + params.not_after = not_after; + + // Sign with CA + let agent_cert = params.signed_by(&agent_keypair, &issuer)?; + let agent_cert_pem = agent_cert.pem(); + let agent_key_pem = agent_keypair.serialize_pem(); + + // Save agent certificate and private key to output directory + + debug!( + "Saving agent certificate and key to output directory: {:?}", + output_path_dir + ); + std::fs::write(&cert_path, agent_cert_pem.as_bytes())?; + std::fs::set_permissions(&cert_path, std::fs::Permissions::from_mode(0o600))?; + + std::fs::write(&key_path, agent_key_pem.as_bytes())?; + std::fs::set_permissions(&key_path, std::fs::Permissions::from_mode(0o600))?; + + Ok(AgentCertPathInfo { + cert_path: cert_path.to_string_lossy().to_string(), + key_path: key_path.to_string_lossy().to_string(), + ca_cert_path: ca_cert_path.to_string(), + }) + } + + async fn zip_certificates( + &self, + cert_path: &str, + key_path: &str, + ca_cert_path: &str, + ) -> Result> { + let zip_path = format!("{}.zip", cert_path.trim_end_matches(".pem")); + let file = std::fs::File::create(&zip_path)?; + let mut zip = zip::ZipWriter::new(file); + + let options = zip::write::SimpleFileOptions::default() + .compression_method(zip::CompressionMethod::Deflated) + .unix_permissions(0o600); + + zip.start_file("cert.pem", options)?; + let cert_data = std::fs::read(cert_path)?; + zip.write_all(&cert_data)?; + + zip.start_file("key.pem", options)?; + let key_data = std::fs::read(key_path)?; + zip.write_all(&key_data)?; + + zip.start_file("ca.pem", options)?; + let ca_cert_data = std::fs::read(ca_cert_path)?; + zip.write_all(&ca_cert_data)?; + + zip.finish()?; + Ok(zip_path) + } + + fn get_sans(&self, connection_type: ConnectionType) -> (Vec, Vec) { + let cert_settings = match connection_type { + ConnectionType::GRPC => &self.settings.grpc.certificate, + ConnectionType::HTTP => &self.settings.server.certificate, + }; + + (cert_settings.san_ip.clone(), cert_settings.san_dns.clone()) + } +} + +fn validity_period() -> (OffsetDateTime, OffsetDateTime) { + let year = Duration::new(365 * 86400, 0); + let not_before = OffsetDateTime::now_utc(); + let not_after = match not_before.checked_add(year) { + Some(v) => v, + None => not_before, + }; + (not_before, not_after) +} diff --git a/apps/nxmesh-master/src/service/mod.rs b/apps/nxmesh-master/src/service/mod.rs new file mode 100644 index 0000000..a3ade80 --- /dev/null +++ b/apps/nxmesh-master/src/service/mod.rs @@ -0,0 +1,40 @@ +use std::sync::Arc; + +use crate::{connector::agent::AgentConnectorTrait, service::certificate::CertificateService}; + +pub mod agent; +pub mod certificate; + +pub async fn start_master_server( + settings: crate::config::settings::Settings, + cli: crate::cli::Cli, +) -> Result<(), Box> { + // Initialize database connection + let db_connection = crate::db::establish_connection(&settings.database.url).await?; + + // Initialize certificate service with default cert folder path + let cert_service = Arc::new(crate::service::certificate::CertificateServiceImpl::new( + db_connection.clone(), + settings.grpc.certificate.cert_dir.clone(), + Arc::new(settings.clone()), + )); + + // if generate_ca is set, generate a new certificate and exit + if cli.generate_ca { + // TODO: check the error type and return a more specific error message + cert_service.generate_ca_cert().await.ok(); + println!("Certificate generated and stored successfully."); + } + + // Initialize agent connector + let mut agent_connector = crate::connector::agent::AgentConnector::new(Box::new( + crate::connector::agent::ssh::SshAgentConnector::new(settings.clone())?, + )); + + // Start the agent server + agent_connector + .start_server(&settings, cert_service, db_connection) + .await?; + + Ok(()) +} diff --git a/config/master/development.toml b/config/master/development.toml new file mode 100644 index 0000000..4d31523 --- /dev/null +++ b/config/master/development.toml @@ -0,0 +1,31 @@ +[server] +bind_address = "0.0.0.0" +port = 8080 +[server.certificate] +san_dns = ["localhost"] +san_ip = ["127.0.0.1"] + +[log] +level = "debug" + +[database] +url = "postgres://postgres:postgres@postgres:5432/nxmesh" +max_connections = 10 + +[grpc] +bind_address = "0.0.0.0" +port = 8443 +[grpc.certificate] +san_dns = ["localhost"] +san_ip = ["127.0.0.1"] + +[auth] +jwt_secret = "development-secret-do-not-use-in-production" +jwt_expiration_hours = 24 + +[agent] +name = "development-agent" +data_dir = "./agent-runtime-data" + +[master] +url = "http://localhost:8080" -- 2.49.1 From d7cbb2a2ce360f075ca4ec28ab07692424941eb0 Mon Sep 17 00:00:00 2001 From: GW_MC <72297530+GWMCwing@users.noreply.github.com> Date: Sat, 21 Mar 2026 03:17:43 +0000 Subject: [PATCH 07/10] feat: Remove unused SeaORM entity files for certificates, organizations, setup tokens, upstreams, users, virtual hosts, and workspaces --- .../src/db/entities/certificates.rs | 45 ------------ .../src/db/entities/organizations.rs | 39 ----------- .../src/db/entities/setup_tokens.rs | 21 ------ .../src/db/entities/upstreams.rs | 40 ----------- apps/nxmesh-master/src/db/entities/users.rs | 39 ----------- .../src/db/entities/virtual_hosts.rs | 44 ------------ .../src/db/entities/workspaces.rs | 70 ------------------- 7 files changed, 298 deletions(-) delete mode 100644 apps/nxmesh-master/src/db/entities/certificates.rs delete mode 100644 apps/nxmesh-master/src/db/entities/organizations.rs delete mode 100644 apps/nxmesh-master/src/db/entities/setup_tokens.rs delete mode 100644 apps/nxmesh-master/src/db/entities/upstreams.rs delete mode 100644 apps/nxmesh-master/src/db/entities/users.rs delete mode 100644 apps/nxmesh-master/src/db/entities/virtual_hosts.rs delete mode 100644 apps/nxmesh-master/src/db/entities/workspaces.rs diff --git a/apps/nxmesh-master/src/db/entities/certificates.rs b/apps/nxmesh-master/src/db/entities/certificates.rs deleted file mode 100644 index c9acd4e..0000000 --- a/apps/nxmesh-master/src/db/entities/certificates.rs +++ /dev/null @@ -1,45 +0,0 @@ -//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0 - -use sea_orm::entity::prelude::*; -use serde::{Deserialize, Serialize}; - -#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)] -#[sea_orm(table_name = "certificates")] -pub struct Model { - #[sea_orm(primary_key, auto_increment = false)] - pub id: Uuid, - pub workspace_id: Uuid, - pub domain: String, - pub is_wildcard: bool, - pub provider: Option, - pub status: Option, - pub issued_at: Option, - pub expires_at: Option, - pub auto_renew: bool, - #[sea_orm(column_type = "Text", nullable)] - pub certificate_pem: Option, - #[sea_orm(column_type = "Text", nullable)] - pub private_key_pem: Option, - pub created_at: DateTimeWithTimeZone, - pub updated_at: DateTimeWithTimeZone, -} - -#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] -pub enum Relation { - #[sea_orm( - belongs_to = "super::workspaces::Entity", - from = "Column::WorkspaceId", - to = "super::workspaces::Column::Id", - on_update = "NoAction", - on_delete = "Cascade" - )] - Workspaces, -} - -impl Related for Entity { - fn to() -> RelationDef { - Relation::Workspaces.def() - } -} - -impl ActiveModelBehavior for ActiveModel {} diff --git a/apps/nxmesh-master/src/db/entities/organizations.rs b/apps/nxmesh-master/src/db/entities/organizations.rs deleted file mode 100644 index a73a39e..0000000 --- a/apps/nxmesh-master/src/db/entities/organizations.rs +++ /dev/null @@ -1,39 +0,0 @@ -//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0 - -use sea_orm::entity::prelude::*; -use serde::{Deserialize, Serialize}; - -#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)] -#[sea_orm(table_name = "organizations")] -pub struct Model { - #[sea_orm(primary_key, auto_increment = false)] - pub id: Uuid, - pub name: String, - #[sea_orm(unique)] - pub slug: String, - pub created_at: DateTimeWithTimeZone, - pub updated_at: DateTimeWithTimeZone, - pub settings: Option, -} - -#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] -pub enum Relation { - #[sea_orm(has_many = "super::users::Entity")] - Users, - #[sea_orm(has_many = "super::workspaces::Entity")] - Workspaces, -} - -impl Related for Entity { - fn to() -> RelationDef { - Relation::Users.def() - } -} - -impl Related for Entity { - fn to() -> RelationDef { - Relation::Workspaces.def() - } -} - -impl ActiveModelBehavior for ActiveModel {} diff --git a/apps/nxmesh-master/src/db/entities/setup_tokens.rs b/apps/nxmesh-master/src/db/entities/setup_tokens.rs deleted file mode 100644 index aa96351..0000000 --- a/apps/nxmesh-master/src/db/entities/setup_tokens.rs +++ /dev/null @@ -1,21 +0,0 @@ -//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0 - -use sea_orm::entity::prelude::*; -use serde::{Deserialize, Serialize}; - -#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)] -#[sea_orm(table_name = "setup_tokens")] -pub struct Model { - #[sea_orm(primary_key, auto_increment = false)] - pub id: Uuid, - #[sea_orm(unique)] - pub token_hash: String, - pub expires_at: DateTimeWithTimeZone, - pub used_at: Option, - pub created_at: DateTimeWithTimeZone, -} - -#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] -pub enum Relation {} - -impl ActiveModelBehavior for ActiveModel {} diff --git a/apps/nxmesh-master/src/db/entities/upstreams.rs b/apps/nxmesh-master/src/db/entities/upstreams.rs deleted file mode 100644 index a27a1d7..0000000 --- a/apps/nxmesh-master/src/db/entities/upstreams.rs +++ /dev/null @@ -1,40 +0,0 @@ -//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0 - -use sea_orm::entity::prelude::*; -use serde::{Deserialize, Serialize}; - -#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)] -#[sea_orm(table_name = "upstreams")] -pub struct Model { - #[sea_orm(primary_key, auto_increment = false)] - pub id: Uuid, - pub workspace_id: Uuid, - pub name: String, - pub algorithm: String, - pub servers: Option, - pub health_check: Option, - pub keepalive_connections: Option, - pub keepalive_timeout: Option, - pub created_at: DateTimeWithTimeZone, - pub updated_at: DateTimeWithTimeZone, -} - -#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] -pub enum Relation { - #[sea_orm( - belongs_to = "super::workspaces::Entity", - from = "Column::WorkspaceId", - to = "super::workspaces::Column::Id", - on_update = "NoAction", - on_delete = "Cascade" - )] - Workspaces, -} - -impl Related for Entity { - fn to() -> RelationDef { - Relation::Workspaces.def() - } -} - -impl ActiveModelBehavior for ActiveModel {} diff --git a/apps/nxmesh-master/src/db/entities/users.rs b/apps/nxmesh-master/src/db/entities/users.rs deleted file mode 100644 index 33b8482..0000000 --- a/apps/nxmesh-master/src/db/entities/users.rs +++ /dev/null @@ -1,39 +0,0 @@ -//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0 - -use sea_orm::entity::prelude::*; -use serde::{Deserialize, Serialize}; - -#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)] -#[sea_orm(table_name = "users")] -pub struct Model { - #[sea_orm(primary_key, auto_increment = false)] - pub id: Uuid, - #[sea_orm(unique)] - pub email: String, - pub password_hash: String, - pub name: Option, - pub role: String, - pub organization_id: Option, - pub created_at: DateTimeWithTimeZone, - pub updated_at: DateTimeWithTimeZone, -} - -#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] -pub enum Relation { - #[sea_orm( - belongs_to = "super::organizations::Entity", - from = "Column::OrganizationId", - to = "super::organizations::Column::Id", - on_update = "NoAction", - on_delete = "SetNull" - )] - Organizations, -} - -impl Related for Entity { - fn to() -> RelationDef { - Relation::Organizations.def() - } -} - -impl ActiveModelBehavior for ActiveModel {} diff --git a/apps/nxmesh-master/src/db/entities/virtual_hosts.rs b/apps/nxmesh-master/src/db/entities/virtual_hosts.rs deleted file mode 100644 index 631a917..0000000 --- a/apps/nxmesh-master/src/db/entities/virtual_hosts.rs +++ /dev/null @@ -1,44 +0,0 @@ -//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0 - -use sea_orm::entity::prelude::*; -use serde::{Deserialize, Serialize}; - -#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)] -#[sea_orm(table_name = "virtual_hosts")] -pub struct Model { - #[sea_orm(primary_key, auto_increment = false)] - pub id: Uuid, - pub workspace_id: Uuid, - pub name: String, - pub server_name: String, - pub listen_port: i32, - pub ssl_enabled: bool, - pub ssl_certificate_id: Option, - pub locations: Option, - pub http2_enabled: bool, - pub http3_enabled: bool, - pub gzip_enabled: bool, - pub target_agents: Option, - pub created_at: DateTimeWithTimeZone, - pub updated_at: DateTimeWithTimeZone, -} - -#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] -pub enum Relation { - #[sea_orm( - belongs_to = "super::workspaces::Entity", - from = "Column::WorkspaceId", - to = "super::workspaces::Column::Id", - on_update = "NoAction", - on_delete = "Cascade" - )] - Workspaces, -} - -impl Related for Entity { - fn to() -> RelationDef { - Relation::Workspaces.def() - } -} - -impl ActiveModelBehavior for ActiveModel {} diff --git a/apps/nxmesh-master/src/db/entities/workspaces.rs b/apps/nxmesh-master/src/db/entities/workspaces.rs deleted file mode 100644 index 0cd5728..0000000 --- a/apps/nxmesh-master/src/db/entities/workspaces.rs +++ /dev/null @@ -1,70 +0,0 @@ -//! `SeaORM` Entity, @generated by sea-orm-codegen 2.0 - -use sea_orm::entity::prelude::*; -use serde::{Deserialize, Serialize}; - -#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Serialize, Deserialize)] -#[sea_orm(table_name = "workspaces")] -pub struct Model { - #[sea_orm(primary_key, auto_increment = false)] - pub id: Uuid, - #[sea_orm(unique_key = "idx_workspaces_org_slug")] - pub organization_id: Uuid, - pub name: String, - #[sea_orm(unique_key = "idx_workspaces_org_slug")] - pub slug: String, - pub created_at: DateTimeWithTimeZone, - pub updated_at: DateTimeWithTimeZone, -} - -#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] -pub enum Relation { - #[sea_orm(has_many = "super::agents::Entity")] - Agents, - #[sea_orm(has_many = "super::certificates::Entity")] - Certificates, - #[sea_orm( - belongs_to = "super::organizations::Entity", - from = "Column::OrganizationId", - to = "super::organizations::Column::Id", - on_update = "NoAction", - on_delete = "Cascade" - )] - Organizations, - #[sea_orm(has_many = "super::upstreams::Entity")] - Upstreams, - #[sea_orm(has_many = "super::virtual_hosts::Entity")] - VirtualHosts, -} - -impl Related for Entity { - fn to() -> RelationDef { - Relation::Agents.def() - } -} - -impl Related for Entity { - fn to() -> RelationDef { - Relation::Certificates.def() - } -} - -impl Related for Entity { - fn to() -> RelationDef { - Relation::Organizations.def() - } -} - -impl Related for Entity { - fn to() -> RelationDef { - Relation::Upstreams.def() - } -} - -impl Related for Entity { - fn to() -> RelationDef { - Relation::VirtualHosts.def() - } -} - -impl ActiveModelBehavior for ActiveModel {} -- 2.49.1 From eba30f557e965079b6187925c7565c43a67b0a31 Mon Sep 17 00:00:00 2001 From: GW_MC <72297530+GWMCwing@users.noreply.github.com> Date: Sat, 21 Mar 2026 03:32:09 +0000 Subject: [PATCH 08/10] feat: Add unit tests for CLI and MasterConnector, including certificate extraction and validation --- apps/nxmesh-agent/src/cli/mod.rs | 81 +++++++ apps/nxmesh-agent/src/config/settings.rs | 227 ++++++++++++++++++ apps/nxmesh-agent/src/connector/master/mod.rs | 105 +++++++- apps/nxmesh-agent/src/connector/master/ssh.rs | 150 +++++++++++- apps/nxmesh-agent/src/connector/mod.rs | 2 +- 5 files changed, 562 insertions(+), 3 deletions(-) diff --git a/apps/nxmesh-agent/src/cli/mod.rs b/apps/nxmesh-agent/src/cli/mod.rs index 676c83b..ad41011 100644 --- a/apps/nxmesh-agent/src/cli/mod.rs +++ b/apps/nxmesh-agent/src/cli/mod.rs @@ -79,3 +79,84 @@ pub enum Commands { ca_cert: Option, }, } + +#[cfg(test)] +mod tests { + use clap::Parser; + + use super::{Cli, Commands}; + + #[test] + fn parses_serve_flag_without_subcommand() { + let parsed = Cli::try_parse_from(["nxmesh-agent", "--serve"]); + assert!(parsed.is_ok()); + + let parsed = parsed.ok(); + assert!(parsed.is_some()); + let parsed = parsed.unwrap_or_else(|| unreachable!()); + + assert!(parsed.serve); + assert!(parsed.command.is_none()); + } + + #[test] + fn parses_import_certs_with_zip_defaults() { + let parsed = Cli::try_parse_from(["nxmesh-agent", "import-certs", "bundle.zip"]); + assert!(parsed.is_ok()); + + let parsed = parsed.ok(); + assert!(parsed.is_some()); + let parsed = parsed.unwrap_or_else(|| unreachable!()); + + match parsed.command { + Some(Commands::ImportCerts { + zip, + cert_name, + key_name, + ca_name, + cert, + key, + ca_cert, + }) => { + assert_eq!(zip.as_deref(), Some("bundle.zip")); + assert_eq!(cert_name.as_deref(), Some("cert.pem")); + assert_eq!(key_name.as_deref(), Some("key.pem")); + assert_eq!(ca_name.as_deref(), Some("ca.pem")); + assert!(cert.is_none()); + assert!(key.is_none()); + assert!(ca_cert.is_none()); + } + _ => unreachable!(), + } + } + + #[test] + fn rejects_import_certs_with_separate_files() { + let parsed = Cli::try_parse_from([ + "nxmesh-agent", + "import-certs", + "--cert", + "agent.crt", + "--key", + "agent.key", + "--ca-cert", + "ca.crt", + ]); + assert!(parsed.is_err()); + } + + #[test] + fn rejects_conflicting_zip_and_separate_inputs() { + let parsed = Cli::try_parse_from([ + "nxmesh-agent", + "import-certs", + "bundle.zip", + "--cert", + "agent.crt", + "--key", + "agent.key", + ]); + + assert!(parsed.is_err()); + } +} diff --git a/apps/nxmesh-agent/src/config/settings.rs b/apps/nxmesh-agent/src/config/settings.rs index 2a579c1..11c419a 100644 --- a/apps/nxmesh-agent/src/config/settings.rs +++ b/apps/nxmesh-agent/src/config/settings.rs @@ -318,6 +318,14 @@ where #[cfg(test)] mod tests { + use std::{ + fs, + os::unix::fs::PermissionsExt, + path::{Path, PathBuf}, + }; + + use tempfile::TempDir; + use super::*; #[test] @@ -330,4 +338,223 @@ mod tests { assert_send_sync::(); assert_send_sync::(); } + + fn write_file(path: &Path) { + let result = fs::write(path, b"content"); + assert!(result.is_ok()); + } + + fn create_exec_file(path: &Path) { + write_file(path); + let metadata = fs::metadata(path); + assert!(metadata.is_ok()); + let metadata = metadata.ok(); + assert!(metadata.is_some()); + let metadata = metadata.unwrap_or_else(|| unreachable!()); + + let mut perms = metadata.permissions(); + perms.set_mode(0o755); + let result = fs::set_permissions(path, perms); + assert!(result.is_ok()); + } + + fn create_non_exec_file(path: &Path) { + write_file(path); + let metadata = fs::metadata(path); + assert!(metadata.is_ok()); + let metadata = metadata.ok(); + assert!(metadata.is_some()); + let metadata = metadata.unwrap_or_else(|| unreachable!()); + + let mut perms = metadata.permissions(); + perms.set_mode(0o644); + let result = fs::set_permissions(path, perms); + assert!(result.is_ok()); + } + + fn valid_tls_raw_paths(temp_dir: &TempDir) -> (PathBuf, PathBuf, PathBuf) { + let ca_path = temp_dir.path().join("ca.pem"); + let cert_path = temp_dir.path().join("cert.pem"); + let key_path = temp_dir.path().join("key.pem"); + + write_file(&ca_path); + write_file(&cert_path); + write_file(&key_path); + + (ca_path, cert_path, key_path) + } + + #[test] + fn tls_raw_path_validate_succeeds_when_all_files_exist() { + let temp_dir = TempDir::new(); + assert!(temp_dir.is_ok()); + let temp_dir = temp_dir.ok(); + assert!(temp_dir.is_some()); + let temp_dir = temp_dir.unwrap_or_else(|| unreachable!()); + + let (ca_path, cert_path, key_path) = valid_tls_raw_paths(&temp_dir); + let settings = TLSSettings::RawPath { + ca_path: ca_path.to_string_lossy().to_string(), + cert_path: cert_path.to_string_lossy().to_string(), + key_path: key_path.to_string_lossy().to_string(), + }; + + assert!(settings.validate().is_ok()); + } + + #[test] + fn tls_raw_path_validate_fails_when_ca_missing() { + let settings = TLSSettings::RawPath { + ca_path: "/tmp/does-not-exist-ca.pem".into(), + cert_path: "/tmp/does-not-exist-cert.pem".into(), + key_path: "/tmp/does-not-exist-key.pem".into(), + }; + + let result = settings.validate(); + assert!(result.is_err()); + let msg = result.err().unwrap_or_else(|| unreachable!()); + assert!(msg.contains("CA file not found")); + } + + #[test] + fn tls_zip_path_validate_fails_when_zip_missing() { + let settings = TLSSettings::ZipPath { + cert_zip_path: "/tmp/missing-certs.zip".into(), + }; + + let result = settings.validate(); + assert!(result.is_err()); + let msg = result.err().unwrap_or_else(|| unreachable!()); + assert!(msg.contains("Certificate zip file not found")); + } + + #[test] + fn grpc_validate_fails_when_connection_string_empty() { + let settings = GrpcSettings { + connection_string: "".into(), + m_auth: MAuthSettings::Tls(TLSSettings::ZipPath { + cert_zip_path: "/tmp/does-not-exist.zip".into(), + }), + cors: None, + }; + + let result = settings.validate(); + assert!(result.is_err()); + let msg = result.err().unwrap_or_else(|| unreachable!()); + assert!(msg.contains("gRPC connection string cannot be empty")); + } + + #[test] + fn nginx_validate_succeeds_for_valid_paths_and_commands() { + let temp_dir = TempDir::new(); + assert!(temp_dir.is_ok()); + let temp_dir = temp_dir.ok(); + assert!(temp_dir.is_some()); + let temp_dir = temp_dir.unwrap_or_else(|| unreachable!()); + + let nginx_binary = temp_dir.path().join("nginx"); + let nginx_config = temp_dir.path().join("nginx.conf"); + + create_exec_file(&nginx_binary); + write_file(&nginx_config); + + let nginx = NginxSettings { + nginx_config_path: nginx_config.to_string_lossy().to_string(), + nginx_binary_path: Some(nginx_binary.to_string_lossy().to_string()), + override_nginx_reload_command: default_nginx_reload_command(), + override_nginx_test_command: default_nginx_test_command(), + nginx_reload_timeout_seconds: 30, + nginx_test_timeout_seconds: 30, + }; + + assert!(nginx.validate().is_ok()); + } + + #[test] + fn nginx_validate_fails_for_non_executable_binary() { + let temp_dir = TempDir::new(); + assert!(temp_dir.is_ok()); + let temp_dir = temp_dir.ok(); + assert!(temp_dir.is_some()); + let temp_dir = temp_dir.unwrap_or_else(|| unreachable!()); + + let nginx_binary = temp_dir.path().join("nginx"); + let nginx_config = temp_dir.path().join("nginx.conf"); + + create_non_exec_file(&nginx_binary); + write_file(&nginx_config); + + let nginx = NginxSettings { + nginx_config_path: nginx_config.to_string_lossy().to_string(), + nginx_binary_path: Some(nginx_binary.to_string_lossy().to_string()), + override_nginx_reload_command: default_nginx_reload_command(), + override_nginx_test_command: default_nginx_test_command(), + nginx_reload_timeout_seconds: 30, + nginx_test_timeout_seconds: 30, + }; + + let result = nginx.validate(); + assert!(result.is_err()); + let msg = result.err().unwrap_or_else(|| unreachable!()); + assert!(msg.contains("Nginx binary is not executable")); + } + + #[test] + fn nginx_validate_fails_when_reload_command_lacks_template() { + let temp_dir = TempDir::new(); + assert!(temp_dir.is_ok()); + let temp_dir = temp_dir.ok(); + assert!(temp_dir.is_some()); + let temp_dir = temp_dir.unwrap_or_else(|| unreachable!()); + + let nginx_binary = temp_dir.path().join("nginx"); + let nginx_config = temp_dir.path().join("nginx.conf"); + + create_exec_file(&nginx_binary); + write_file(&nginx_config); + + let nginx = NginxSettings { + nginx_config_path: nginx_config.to_string_lossy().to_string(), + nginx_binary_path: Some(nginx_binary.to_string_lossy().to_string()), + override_nginx_reload_command: vec!["nginx".into(), "-s".into(), "reload".into()], + override_nginx_test_command: default_nginx_test_command(), + nginx_reload_timeout_seconds: 30, + nginx_test_timeout_seconds: 30, + }; + + let result = nginx.validate(); + assert!(result.is_err()); + let msg = result.err().unwrap_or_else(|| unreachable!()); + assert!(msg.contains("Nginx reload command must contain the binary path template")); + } + + #[test] + fn level_filter_round_trip_serialization() { + #[derive(Serialize, Deserialize)] + struct Wrapper { + #[serde( + deserialize_with = "deserialize_level_filter", + serialize_with = "serialize_level_filter" + )] + level: LevelFilter, + } + + let original = Wrapper { + level: LevelFilter::DEBUG, + }; + + let encoded = serde_json::to_string(&original); + assert!(encoded.is_ok()); + let encoded = encoded.ok(); + assert!(encoded.is_some()); + let encoded = encoded.unwrap_or_else(|| unreachable!()); + assert!(encoded.to_lowercase().contains("debug")); + + let decoded = serde_json::from_str::(&encoded); + assert!(decoded.is_ok()); + let decoded = decoded.ok(); + assert!(decoded.is_some()); + let decoded = decoded.unwrap_or_else(|| unreachable!()); + assert_eq!(decoded.level, LevelFilter::DEBUG); + } } diff --git a/apps/nxmesh-agent/src/connector/master/mod.rs b/apps/nxmesh-agent/src/connector/master/mod.rs index da408b2..4e72f1b 100644 --- a/apps/nxmesh-agent/src/connector/master/mod.rs +++ b/apps/nxmesh-agent/src/connector/master/mod.rs @@ -4,7 +4,8 @@ use tokio::sync::Mutex; pub mod ssh; -pub type AgentClient = nxmesh_proto::agent_service_client::AgentServiceClient; +pub type AgentClient = + nxmesh_proto::agent_service_client::AgentServiceClient; #[async_trait::async_trait] pub trait MasterConnectorTrait: Send + Sync { @@ -38,3 +39,105 @@ impl MasterConnectorTrait for MasterConnector { self.connector.get_client() } } + +#[cfg(test)] +mod tests { + use std::sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }; + + use tokio::sync::Mutex; + + use crate::config::settings::{ + GrpcSettings, LogSettings, MAuthSettings, Settings, TLSSettings, + }; + + use super::{AgentClient, MasterConnector, MasterConnectorTrait}; + + struct FakeConnector { + called: Arc, + fail: bool, + client: Arc>, + } + + #[async_trait::async_trait] + impl MasterConnectorTrait for FakeConnector { + async fn connect( + &mut self, + _settings: &Settings, + ) -> Result<(), Box> { + self.called.store(true, Ordering::SeqCst); + if self.fail { + return Err("connector failed".into()); + } + Ok(()) + } + + fn get_client(&self) -> Arc> { + self.client.clone() + } + } + + fn test_settings() -> Settings { + Settings { + grpc: GrpcSettings { + connection_string: "https://localhost:50051".to_string(), + m_auth: MAuthSettings::Tls(TLSSettings::ZipPath { + cert_zip_path: "/tmp/certs.zip".to_string(), + }), + cors: None, + }, + log: LogSettings::default(), + nginx: None, + } + } + + fn test_client() -> Arc> { + let channel = + tonic::transport::Channel::from_static("http://127.0.0.1:50051").connect_lazy(); + Arc::new(Mutex::new(AgentClient::new(channel))) + } + + #[tokio::test] + async fn master_connector_delegates_connect_successfully() { + let called = Arc::new(AtomicBool::new(false)); + let fake = FakeConnector { + called: called.clone(), + fail: false, + client: test_client(), + }; + let mut master = MasterConnector::new(Box::new(fake)); + + let result = master.connect(&test_settings()).await; + assert!(result.is_ok()); + assert!(called.load(Ordering::SeqCst)); + } + + #[tokio::test] + async fn master_connector_propagates_connect_errors() { + let fake = FakeConnector { + called: Arc::new(AtomicBool::new(false)), + fail: true, + client: test_client(), + }; + let mut master = MasterConnector::new(Box::new(fake)); + + let result = master.connect(&test_settings()).await; + assert!(result.is_err()); + } + + #[tokio::test] + async fn master_connector_returns_underlying_client() { + let shared_client = test_client(); + let fake = FakeConnector { + called: Arc::new(AtomicBool::new(false)), + fail: false, + client: shared_client.clone(), + }; + let master = MasterConnector::new(Box::new(fake)); + + let client = master.get_client(); + assert!(Arc::ptr_eq(&client, &shared_client)); + } +} diff --git a/apps/nxmesh-agent/src/connector/master/ssh.rs b/apps/nxmesh-agent/src/connector/master/ssh.rs index 2414bf4..3245609 100644 --- a/apps/nxmesh-agent/src/connector/master/ssh.rs +++ b/apps/nxmesh-agent/src/connector/master/ssh.rs @@ -6,7 +6,7 @@ use nxmesh_proto::agent_service_client::AgentServiceClient; use tonic::transport::{Certificate, ClientTlsConfig, Identity}; use tracing::warn; -use crate::config::settings::{self, MAuthSettings, TLSSettings}; +use crate::config::settings::{MAuthSettings, TLSSettings}; use super::{AgentClient, MasterConnectorTrait}; @@ -130,3 +130,151 @@ impl MasterConnectorTrait for SshMasterConnector { self.client.clone() } } + +#[cfg(test)] +#[allow(clippy::expect_used)] +mod tests { + use std::{ + fs::{self, File}, + io::Write, + path::Path, + }; + + use tempfile::TempDir; + + use crate::config::settings::{MAuthSettings, TLSSettings}; + + use super::SshMasterConnector; + + const CERT_PEM: &[u8] = b"-----BEGIN CERTIFICATE-----\nAQ==\n-----END CERTIFICATE-----\n"; + const KEY_PEM: &[u8] = b"-----BEGIN PRIVATE KEY-----\nAQ==\n-----END PRIVATE KEY-----\n"; + const CA_PEM: &[u8] = b"-----BEGIN CERTIFICATE-----\nAQ==\n-----END CERTIFICATE-----\n"; + + fn create_zip_with_entries( + dir: &TempDir, + file_name: &str, + entries: &[(&str, &[u8])], + ) -> Result> { + let zip_path = dir.path().join(file_name); + let file = File::create(&zip_path)?; + let mut zip = zip::ZipWriter::new(file); + let options = zip::write::SimpleFileOptions::default() + .compression_method(zip::CompressionMethod::Deflated) + .unix_permissions(0o600); + + for (entry_name, contents) in entries { + zip.start_file(entry_name, options)?; + zip.write_all(contents)?; + } + + zip.finish()?; + Ok(zip_path.to_string_lossy().to_string()) + } + + fn write_file( + path: &Path, + contents: &[u8], + ) -> Result<(), Box> { + fs::write(path, contents)?; + Ok(()) + } + + #[tokio::test] + async fn extract_certificate_succeeds_with_expected_files() { + let temp_dir = TempDir::new().expect("failed to create temp dir"); + let zip_path = create_zip_with_entries( + &temp_dir, + "certs.zip", + &[ + ("cert.pem", CERT_PEM), + ("key.pem", KEY_PEM), + ("ca.pem", CA_PEM), + ("ignored.txt", b"ignored"), + ], + ) + .expect("failed to create zip"); + + let (ca, cert, key) = SshMasterConnector::extract_certificate(&zip_path) + .await + .expect("expected cert extraction to succeed"); + + assert_eq!(ca, CA_PEM); + assert_eq!(cert, CERT_PEM); + assert_eq!(key, KEY_PEM); + } + + #[tokio::test] + async fn extract_certificate_fails_when_required_files_are_missing() { + let temp_dir = TempDir::new().expect("failed to create temp dir"); + let zip_path = create_zip_with_entries( + &temp_dir, + "missing-key.zip", + &[("cert.pem", CERT_PEM), ("ca.pem", CA_PEM)], + ) + .expect("failed to create zip"); + + let err = SshMasterConnector::extract_certificate(&zip_path) + .await + .expect_err("expected extraction to fail when key.pem is missing"); + + assert!( + err.to_string() + .contains("Certificate zip must contain cert.pem, key.pem and ca.pem") + ); + } + + #[tokio::test] + async fn generate_tls_config_succeeds_for_raw_paths() { + let temp_dir = TempDir::new().expect("failed to create temp dir"); + let cert_path = temp_dir.path().join("cert.pem"); + let key_path = temp_dir.path().join("key.pem"); + let ca_path = temp_dir.path().join("ca.pem"); + + write_file(&cert_path, CERT_PEM).expect("failed to write cert.pem"); + write_file(&key_path, KEY_PEM).expect("failed to write key.pem"); + write_file(&ca_path, CA_PEM).expect("failed to write ca.pem"); + + let settings = MAuthSettings::Tls(TLSSettings::RawPath { + ca_path: ca_path.to_string_lossy().to_string(), + cert_path: cert_path.to_string_lossy().to_string(), + key_path: key_path.to_string_lossy().to_string(), + }); + + let result = SshMasterConnector::generate_tls_config(&settings).await; + assert!(result.is_ok(), "expected raw path TLS config to succeed"); + } + + #[tokio::test] + async fn generate_tls_config_succeeds_for_zip_path() { + let temp_dir = TempDir::new().expect("failed to create temp dir"); + let zip_path = create_zip_with_entries( + &temp_dir, + "certs.zip", + &[ + ("cert.pem", CERT_PEM), + ("key.pem", KEY_PEM), + ("ca.pem", CA_PEM), + ], + ) + .expect("failed to create zip"); + + let settings = MAuthSettings::Tls(TLSSettings::ZipPath { + cert_zip_path: zip_path, + }); + + let result = SshMasterConnector::generate_tls_config(&settings).await; + assert!(result.is_ok(), "expected zip path TLS config to succeed"); + } + + #[tokio::test] + async fn generate_tls_config_fails_for_missing_raw_files() { + let settings = MAuthSettings::Tls(TLSSettings::RawPath { + ca_path: "/tmp/non-existent-ca.pem".to_string(), + cert_path: "/tmp/non-existent-cert.pem".to_string(), + key_path: "/tmp/non-existent-key.pem".to_string(), + }); + + let result = SshMasterConnector::generate_tls_config(&settings).await; + assert!(result.is_err(), "expected raw path TLS config to fail"); + } +} diff --git a/apps/nxmesh-agent/src/connector/mod.rs b/apps/nxmesh-agent/src/connector/mod.rs index 0d22726..d9873fa 100644 --- a/apps/nxmesh-agent/src/connector/mod.rs +++ b/apps/nxmesh-agent/src/connector/mod.rs @@ -1 +1 @@ -pub mod master; \ No newline at end of file +pub mod master; -- 2.49.1 From 109d693d59f73d2153cb1495f2f7c930e3327098 Mon Sep 17 00:00:00 2001 From: GW_MC <72297530+GWMCwing@users.noreply.github.com> Date: Sat, 21 Mar 2026 03:42:47 +0000 Subject: [PATCH 09/10] feat: Add comprehensive unit tests for CLI and CertificateService, covering command parsing and certificate generation --- apps/nxmesh-master/src/cli/mod.rs | 70 ++++ apps/nxmesh-master/src/config/settings.rs | 235 +++++++++++ apps/nxmesh-master/src/db/mod.rs | 13 + .../src/service/certificate/mod.rs | 389 ++++++++++++++++++ 4 files changed, 707 insertions(+) diff --git a/apps/nxmesh-master/src/cli/mod.rs b/apps/nxmesh-master/src/cli/mod.rs index dc3a2c0..3cdada7 100644 --- a/apps/nxmesh-master/src/cli/mod.rs +++ b/apps/nxmesh-master/src/cli/mod.rs @@ -61,3 +61,73 @@ pub async fn handle_sub_command( } => Ok(gen_agent_certs(settings, output, agent_id, zip).await?), } } + +#[cfg(test)] +mod tests { + use clap::Parser; + + use super::{Cli, Commands}; + + #[test] + fn parses_serve_mode() { + let parsed = Cli::try_parse_from(["nxmesh-master", "--serve"]); + assert!(parsed.is_ok()); + let parsed = parsed.unwrap_or_else(|_| unreachable!()); + + assert!(parsed.serve); + assert!(!parsed.generate_ca); + assert!(parsed.command.is_none()); + } + + #[test] + fn parses_generate_ca_flag() { + let parsed = Cli::try_parse_from(["nxmesh-master", "--generate-ca", "--serve"]); + assert!(parsed.is_ok()); + let parsed = parsed.unwrap_or_else(|_| unreachable!()); + + assert!(parsed.generate_ca); + assert!(parsed.serve); + } + + #[test] + fn parses_gen_certs_with_default_output() { + let parsed = Cli::try_parse_from(["nxmesh-master", "gen-certs"]); + assert!(parsed.is_ok()); + let parsed = parsed.unwrap_or_else(|_| unreachable!()); + + match parsed.command { + Some(Commands::GenCerts { output }) => { + assert_eq!(output, "./certs"); + } + _ => unreachable!(), + } + } + + #[test] + fn parses_gen_agent_certs_with_custom_values() { + let parsed = Cli::try_parse_from([ + "nxmesh-master", + "gen-agent-certs", + "--output", + "./out", + "--agent-id", + "agent-123", + "--zip", + ]); + assert!(parsed.is_ok()); + let parsed = parsed.unwrap_or_else(|_| unreachable!()); + + match parsed.command { + Some(Commands::GenAgentCerts { + output, + agent_id, + zip, + }) => { + assert_eq!(output, "./out"); + assert_eq!(agent_id, "agent-123"); + assert!(zip); + } + _ => unreachable!(), + } + } +} diff --git a/apps/nxmesh-master/src/config/settings.rs b/apps/nxmesh-master/src/config/settings.rs index c578fec..64f01e2 100644 --- a/apps/nxmesh-master/src/config/settings.rs +++ b/apps/nxmesh-master/src/config/settings.rs @@ -334,6 +334,13 @@ where #[cfg(test)] mod tests { + use std::{ + fs, + net::{IpAddr, Ipv4Addr}, + path::PathBuf, + time::{SystemTime, UNIX_EPOCH}, + }; + use super::*; #[test] @@ -348,4 +355,232 @@ mod tests { assert_send_sync::(); assert_send_sync::(); } + + fn make_temp_dir(prefix: &str) -> PathBuf { + let ts = SystemTime::now().duration_since(UNIX_EPOCH); + assert!(ts.is_ok()); + let ts = ts.unwrap_or_default(); + let path = std::env::temp_dir().join(format!( + "{}_{}_{}", + prefix, + std::process::id(), + ts.as_nanos() + )); + let created = fs::create_dir_all(&path); + assert!(created.is_ok()); + path + } + + #[test] + fn certificate_paths_include_cert_dir() { + let cert = CertificateSettings { + cert_dir: "./certs".to_string(), + san_dns: Vec::new(), + san_ip: Vec::new(), + cert_path: Some("server.crt".to_string()), + key_path: Some("server.key".to_string()), + }; + + assert_eq!(cert.cert_path(), Some("./certs/server.crt".to_string())); + assert_eq!(cert.key_path(), Some("./certs/server.key".to_string())); + } + + #[test] + fn certificate_validate_creates_directory_when_missing() { + let cert_dir = make_temp_dir("nxmesh-master-cert-create").join("nested"); + let san = Ia5String::try_from("localhost".to_string()); + assert!(san.is_ok()); + let san = san.unwrap_or_else(|_| unreachable!()); + let cert = CertificateSettings { + cert_dir: cert_dir.to_string_lossy().to_string(), + san_dns: vec![san], + san_ip: Vec::new(), + cert_path: None, + key_path: None, + }; + + let result = cert.validate(); + assert!(result.is_ok()); + assert!(cert_dir.exists()); + + let _ = fs::remove_dir_all(cert_dir.parent().unwrap_or(&cert_dir)); + } + + #[test] + fn certificate_validate_fails_when_only_cert_path_is_set() { + let cert_dir = make_temp_dir("nxmesh-master-cert-partial"); + let san = Ia5String::try_from("localhost".to_string()); + assert!(san.is_ok()); + let san = san.unwrap_or_else(|_| unreachable!()); + let cert = CertificateSettings { + cert_dir: cert_dir.to_string_lossy().to_string(), + san_dns: vec![san], + san_ip: Vec::new(), + cert_path: Some("server.crt".to_string()), + key_path: None, + }; + + let result = cert.validate(); + assert!(result.is_err()); + let msg = result.err().unwrap_or_default(); + assert!(msg.contains("Both certificate and key paths must be provided")); + + let _ = fs::remove_dir_all(&cert_dir); + } + + #[test] + fn certificate_validate_fails_with_unspecified_ip() { + let cert_dir = make_temp_dir("nxmesh-master-cert-unspecified-ip"); + let cert = CertificateSettings { + cert_dir: cert_dir.to_string_lossy().to_string(), + san_dns: Vec::new(), + san_ip: vec![IpAddr::V4(Ipv4Addr::UNSPECIFIED)], + cert_path: None, + key_path: None, + }; + + let result = cert.validate(); + assert!(result.is_err()); + let msg = result.err().unwrap_or_default(); + assert!(msg.contains("SAN IP entries cannot be unspecified")); + + let _ = fs::remove_dir_all(&cert_dir); + } + + #[test] + fn certificate_validate_fails_without_any_san_entries() { + let cert_dir = make_temp_dir("nxmesh-master-cert-no-san"); + let cert = CertificateSettings { + cert_dir: cert_dir.to_string_lossy().to_string(), + san_dns: Vec::new(), + san_ip: Vec::new(), + cert_path: None, + key_path: None, + }; + + let result = cert.validate(); + assert!(result.is_err()); + let msg = result.err().unwrap_or_default(); + assert!(msg.contains("At least one SAN entry")); + + let _ = fs::remove_dir_all(&cert_dir); + } + + #[test] + fn database_validate_fails_for_zero_max_connections() { + let db = DatabaseSettings { + url: "postgres://localhost/db".to_string(), + max_connections: Some(0), + }; + + let result = db.validate(); + assert!(result.is_err()); + let msg = result.err().unwrap_or_default(); + assert!(msg.contains("Max database connections must be greater than 0")); + } + + #[test] + fn auth_validate_fails_for_empty_secret() { + let auth = AuthSettings { + jwt_secret: "".to_string(), + jwt_expiration_hours: 24, + }; + + let result = auth.validate(); + assert!(result.is_err()); + let msg = result.err().unwrap_or_default(); + assert!(msg.contains("JWT secret cannot be empty")); + } + + #[test] + fn server_validate_fails_for_zero_port() { + let cert_dir = make_temp_dir("nxmesh-master-server-validate"); + let san = Ia5String::try_from("localhost".to_string()); + assert!(san.is_ok()); + let san = san.unwrap_or_else(|_| unreachable!()); + let server = ServerSettings { + bind_address: "0.0.0.0".to_string(), + port: 0, + certificate: CertificateSettings { + cert_dir: cert_dir.to_string_lossy().to_string(), + san_dns: vec![san], + san_ip: Vec::new(), + cert_path: None, + key_path: None, + }, + cors: None, + }; + + let result = server.validate(); + assert!(result.is_err()); + let msg = result.err().unwrap_or_default(); + assert!(msg.contains("Server port must be greater than 0")); + + let _ = fs::remove_dir_all(&cert_dir); + } + + #[test] + fn level_filter_round_trip_serialization() { + #[derive(Serialize, Deserialize)] + struct Wrapper { + #[serde( + deserialize_with = "deserialize_level_filter", + serialize_with = "serialize_level_filter" + )] + level: LevelFilter, + } + + let data = Wrapper { + level: LevelFilter::DEBUG, + }; + + let encoded = serde_json::to_string(&data); + assert!(encoded.is_ok()); + let encoded = encoded.unwrap_or_default(); + assert!(encoded.to_lowercase().contains("debug")); + + let decoded: Result = serde_json::from_str(&encoded); + assert!(decoded.is_ok()); + let decoded = decoded.unwrap_or(Wrapper { + level: LevelFilter::ERROR, + }); + assert_eq!(decoded.level, LevelFilter::DEBUG); + } + + #[test] + fn ia5string_vec_round_trip_serialization() { + #[derive(Serialize, Deserialize)] + struct Wrapper { + #[serde( + deserialize_with = "deserialize_ia5string_vec", + serialize_with = "serialize_ia5string_vec" + )] + san_dns: Vec, + } + + let first = Ia5String::try_from("localhost".to_string()); + assert!(first.is_ok()); + let second = Ia5String::try_from("example.com".to_string()); + assert!(second.is_ok()); + + let first = first.unwrap_or_else(|_| unreachable!()); + let second = second.unwrap_or_else(|_| unreachable!()); + + let data = Wrapper { + san_dns: vec![first, second], + }; + + let encoded = serde_json::to_string(&data); + assert!(encoded.is_ok()); + let encoded = encoded.unwrap_or_default(); + assert!(encoded.contains("localhost")); + assert!(encoded.contains("example.com")); + + let decoded: Result = serde_json::from_str(&encoded); + assert!(decoded.is_ok()); + let decoded = decoded.unwrap_or(Wrapper { + san_dns: Vec::new(), + }); + assert_eq!(decoded.san_dns.len(), 2); + } } diff --git a/apps/nxmesh-master/src/db/mod.rs b/apps/nxmesh-master/src/db/mod.rs index 084546f..28e19c0 100644 --- a/apps/nxmesh-master/src/db/mod.rs +++ b/apps/nxmesh-master/src/db/mod.rs @@ -9,3 +9,16 @@ pub(crate) async fn establish_connection( .await .map_err(|e| format!("Failed to connect to database: {}", e).into()) } + +#[cfg(test)] +mod tests { + use super::establish_connection; + + #[tokio::test] + async fn establish_connection_fails_for_invalid_url_scheme() { + let result = establish_connection("invalid://not-a-db").await; + assert!(result.is_err()); + let msg = result.err().map(|e| e.to_string()).unwrap_or_default(); + assert!(msg.contains("Failed to connect to database")); + } +} diff --git a/apps/nxmesh-master/src/service/certificate/mod.rs b/apps/nxmesh-master/src/service/certificate/mod.rs index d7e483b..952ac5f 100644 --- a/apps/nxmesh-master/src/service/certificate/mod.rs +++ b/apps/nxmesh-master/src/service/certificate/mod.rs @@ -347,3 +347,392 @@ fn validity_period() -> (OffsetDateTime, OffsetDateTime) { }; (not_before, not_after) } + +#[cfg(test)] +mod tests { + use std::{ + fs, + net::{IpAddr, Ipv4Addr}, + os::unix::fs::PermissionsExt, + path::{Path, PathBuf}, + sync::Arc, + time::{SystemTime, UNIX_EPOCH}, + }; + + use rcgen::string::Ia5String; + use sea_orm::DatabaseConnection; + use serde_json::json; + + use crate::config::settings::Settings; + + use super::{CertificateService, CertificateServiceImpl, ConnectionType, validity_period}; + + fn unique_temp_dir(prefix: &str) -> PathBuf { + let now = SystemTime::now().duration_since(UNIX_EPOCH); + assert!(now.is_ok()); + let now = now.unwrap_or_default(); + + let dir = std::env::temp_dir().join(format!( + "{}_{}_{}", + prefix, + std::process::id(), + now.as_nanos() + )); + let created = fs::create_dir_all(&dir); + assert!(created.is_ok()); + dir + } + + fn mock_db_connection() -> DatabaseConnection { + DatabaseConnection::default() + } + + fn parse_ia5(input: &str) -> Ia5String { + let value = Ia5String::try_from(input.to_string()); + assert!(value.is_ok()); + value.unwrap_or_else(|_| unreachable!()) + } + + fn test_settings(grpc_dns: &str, http_dns: &str) -> Arc { + let parsed = serde_json::from_value::(json!({ + "server": { + "bind_address": "127.0.0.1", + "port": 8080, + "certificate": { + "cert_dir": "./certs", + "san_dns": [http_dns], + "san_ip": ["127.0.0.2"] + }, + "cors": null + }, + "database": { + "url": "postgres://user:pass@localhost/db", + "max_connections": 5 + }, + "grpc": { + "bind_address": "127.0.0.1", + "port": 50051, + "certificate": { + "cert_dir": "./certs", + "san_dns": [grpc_dns], + "san_ip": ["127.0.0.1"] + }, + "cors": null + }, + "auth": { + "jwt_secret": "secret", + "jwt_expiration_hours": 24 + }, + "log": { + "level": "INFO" + } + })); + + assert!(parsed.is_ok()); + Arc::new(parsed.unwrap_or_else(|_| unreachable!())) + } + + fn new_service(cert_dir: &str) -> CertificateServiceImpl { + CertificateServiceImpl::new( + mock_db_connection(), + cert_dir.to_string(), + test_settings("grpc.local", "http.local"), + ) + } + + #[test] + fn validity_period_is_forward_and_about_one_year() { + let (not_before, not_after) = validity_period(); + assert!(not_after > not_before); + + let diff = not_after - not_before; + assert!(diff.whole_days() >= 364); + assert!(diff.whole_days() <= 366); + } + + #[tokio::test] + async fn get_ca_cert_fails_when_folder_missing() { + let dir = unique_temp_dir("nxmesh-master-ca-missing"); + let removed = fs::remove_dir_all(&dir); + assert!(removed.is_ok()); + + let service = new_service(&dir.to_string_lossy()); + let result = service.get_ca_cert().await; + assert!(result.is_err()); + + let msg = result.err().map(|e| e.to_string()).unwrap_or_default(); + assert!(msg.contains("CA certificate folder not found")); + } + + #[tokio::test] + async fn generate_ca_cert_creates_files_and_is_retrievable() { + let dir = unique_temp_dir("nxmesh-master-generate-ca"); + let service = new_service(&dir.to_string_lossy()); + + let generated = service.generate_ca_cert().await; + assert!(generated.is_ok()); + let generated = generated.unwrap_or_else(|_| unreachable!()); + assert!(!generated.cert_pem.is_empty()); + assert!(!generated.private_key.is_empty()); + assert!(!generated.public_key.is_empty()); + + let ca_cert = dir.join("ca.crt"); + let ca_key = dir.join("ca.key"); + let ca_pub = dir.join("ca.pub"); + + assert!(ca_cert.exists()); + assert!(ca_key.exists()); + assert!(ca_pub.exists()); + + let cert_meta = fs::metadata(&ca_cert); + assert!(cert_meta.is_ok()); + let cert_meta = cert_meta.unwrap_or_else(|_| unreachable!()); + assert_eq!(cert_meta.permissions().mode() & 0o777, 0o600); + + let key_meta = fs::metadata(&ca_key); + assert!(key_meta.is_ok()); + let key_meta = key_meta.unwrap_or_else(|_| unreachable!()); + assert_eq!(key_meta.permissions().mode() & 0o777, 0o600); + + let pub_meta = fs::metadata(&ca_pub); + assert!(pub_meta.is_ok()); + let pub_meta = pub_meta.unwrap_or_else(|_| unreachable!()); + assert_eq!(pub_meta.permissions().mode() & 0o777, 0o600); + + let retrieved = service.get_ca_cert().await; + assert!(retrieved.is_ok()); + let (cert_path, key_path) = retrieved.unwrap_or_else(|_| unreachable!()); + assert_eq!(cert_path, ca_cert.to_string_lossy()); + assert_eq!(key_path, ca_key.to_string_lossy()); + + let _ = fs::remove_dir_all(&dir); + } + + #[tokio::test] + async fn get_ca_cert_fails_when_folder_exists_but_files_missing() { + let dir = unique_temp_dir("nxmesh-master-ca-partial"); + let service = new_service(&dir.to_string_lossy()); + + let ca_cert_path = dir.join("ca.crt"); + let write_result = fs::write(&ca_cert_path, "dummy cert"); + assert!(write_result.is_ok()); + + let result = service.get_ca_cert().await; + assert!(result.is_err()); + let msg = result.err().map(|e| e.to_string()).unwrap_or_default(); + assert!(msg.contains("CA certificate or key not found")); + + let _ = fs::remove_dir_all(&dir); + } + + #[tokio::test] + async fn generate_ca_cert_fails_when_ca_exists() { + let dir = unique_temp_dir("nxmesh-master-ca-exists"); + let service = new_service(&dir.to_string_lossy()); + + let first = service.generate_ca_cert().await; + assert!(first.is_ok()); + + let second = service.generate_ca_cert().await; + assert!(second.is_err()); + let msg = second.err().map(|e| e.to_string()).unwrap_or_default(); + assert!(msg.contains("CA certificate already exists")); + + let _ = fs::remove_dir_all(&dir); + } + + #[tokio::test] + async fn generate_pub_cert_pair_requires_ca_cert() { + let dir = unique_temp_dir("nxmesh-master-pub-cert-missing-ca"); + let service = new_service(&dir.to_string_lossy()); + + let result = service + .generate_pub_cert_pair( + vec![IpAddr::V4(Ipv4Addr::LOCALHOST)], + vec![parse_ia5("localhost")], + ) + .await; + assert!(result.is_err()); + + let _ = fs::remove_dir_all(&dir); + } + + #[tokio::test] + async fn generate_pub_cert_pair_succeeds_after_ca_generation() { + let dir = unique_temp_dir("nxmesh-master-pub-cert-ok"); + let service = new_service(&dir.to_string_lossy()); + + let ca = service.generate_ca_cert().await; + assert!(ca.is_ok()); + + let cert_pair = service + .generate_pub_cert_pair( + vec![IpAddr::V4(Ipv4Addr::LOCALHOST)], + vec![parse_ia5("localhost")], + ) + .await; + assert!(cert_pair.is_ok()); + let (cert, key) = cert_pair.unwrap_or_else(|_| unreachable!()); + + assert!(cert.contains("BEGIN CERTIFICATE")); + assert!(key.contains("BEGIN PRIVATE KEY")); + + let _ = fs::remove_dir_all(&dir); + } + + #[tokio::test] + async fn generate_agent_certs_fails_when_output_parent_missing() { + let ca_dir = unique_temp_dir("nxmesh-master-agent-certs-ca"); + let missing_output = unique_temp_dir("nxmesh-master-agent-certs-missing"); + let removed = fs::remove_dir_all(&missing_output); + assert!(removed.is_ok()); + + let service = new_service(&ca_dir.to_string_lossy()); + let ca = service.generate_ca_cert().await; + assert!(ca.is_ok()); + + let result = service + .generate_agent_certs("agent-id", &missing_output.to_string_lossy()) + .await; + assert!(result.is_err()); + let msg = result.err().map(|e| e.to_string()).unwrap_or_default(); + assert!(msg.contains("Output parent directory does not exist")); + + let _ = fs::remove_dir_all(&ca_dir); + } + + #[tokio::test] + async fn generate_agent_certs_fails_when_ca_is_missing() { + let ca_dir = unique_temp_dir("nxmesh-master-agent-certs-no-ca"); + let output_parent = unique_temp_dir("nxmesh-master-agent-certs-no-ca-out"); + let service = new_service(&ca_dir.to_string_lossy()); + + let result = service + .generate_agent_certs("agent-1", &output_parent.to_string_lossy()) + .await; + assert!(result.is_err()); + let msg = result.err().map(|e| e.to_string()).unwrap_or_default(); + assert!( + msg.contains("CA certificate") + || msg.contains("CA certificate folder not found") + || msg.contains("CA certificate or key not found") + ); + + let _ = fs::remove_dir_all(&ca_dir); + let _ = fs::remove_dir_all(&output_parent); + } + + #[tokio::test] + async fn generate_agent_certs_and_zip_certificates_succeeds() { + let ca_dir = unique_temp_dir("nxmesh-master-agent-certs-zip-ca"); + let output_parent = unique_temp_dir("nxmesh-master-agent-certs-zip-out"); + let service = new_service(&ca_dir.to_string_lossy()); + + let ca = service.generate_ca_cert().await; + assert!(ca.is_ok()); + + let generated = service + .generate_agent_certs("agent-42", &output_parent.to_string_lossy()) + .await; + assert!(generated.is_ok()); + let generated = generated.unwrap_or_else(|_| unreachable!()); + + assert!(Path::new(&generated.cert_path).exists()); + assert!(Path::new(&generated.key_path).exists()); + assert!(Path::new(&generated.ca_cert_path).exists()); + + assert!(generated.cert_path.ends_with("agent-42/cert.pem")); + assert!(generated.key_path.ends_with("agent-42/key.pem")); + + let cert_meta = fs::metadata(&generated.cert_path); + assert!(cert_meta.is_ok()); + let cert_meta = cert_meta.unwrap_or_else(|_| unreachable!()); + assert_eq!(cert_meta.permissions().mode() & 0o777, 0o600); + + let key_meta = fs::metadata(&generated.key_path); + assert!(key_meta.is_ok()); + let key_meta = key_meta.unwrap_or_else(|_| unreachable!()); + assert_eq!(key_meta.permissions().mode() & 0o777, 0o600); + + let zip = service + .zip_certificates( + &generated.cert_path, + &generated.key_path, + &generated.ca_cert_path, + ) + .await; + assert!(zip.is_ok()); + let zip = zip.unwrap_or_else(|_| unreachable!()); + assert!(Path::new(&zip).exists()); + + let zip_file = std::fs::File::open(&zip); + assert!(zip_file.is_ok()); + let zip_file = zip_file.unwrap_or_else(|_| unreachable!()); + let archive = zip::ZipArchive::new(zip_file); + assert!(archive.is_ok()); + let mut archive = archive.unwrap_or_else(|_| unreachable!()); + + assert!(archive.by_name("cert.pem").is_ok()); + assert!(archive.by_name("key.pem").is_ok()); + assert!(archive.by_name("ca.pem").is_ok()); + + assert!(zip.ends_with("cert.zip")); + + let _ = fs::remove_dir_all(&ca_dir); + let _ = fs::remove_dir_all(&output_parent); + } + + #[tokio::test] + async fn zip_certificates_fails_when_input_files_are_missing() { + let cert_dir = unique_temp_dir("nxmesh-master-zip-missing-input"); + let service = new_service(&cert_dir.to_string_lossy()); + + let missing_cert = cert_dir.join("missing-cert.pem"); + let missing_key = cert_dir.join("missing-key.pem"); + let missing_ca = cert_dir.join("missing-ca.pem"); + + let result = service + .zip_certificates( + &missing_cert.to_string_lossy(), + &missing_key.to_string_lossy(), + &missing_ca.to_string_lossy(), + ) + .await; + assert!(result.is_err()); + + let _ = fs::remove_dir_all(&cert_dir); + } + + #[tokio::test] + async fn get_sans_returns_values_for_each_connection_type() { + let dir = unique_temp_dir("nxmesh-master-get-sans"); + let service = CertificateServiceImpl::new( + mock_db_connection(), + dir.to_string_lossy().to_string(), + test_settings("grpc.example.test", "http.example.test"), + ); + + let (grpc_ips, grpc_dns) = service.get_sans(ConnectionType::GRPC); + assert_eq!(grpc_ips.len(), 1); + assert_eq!(grpc_ips[0], IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1))); + assert_eq!(grpc_dns.len(), 1); + assert_eq!(grpc_dns[0].to_string(), "grpc.example.test"); + + let (http_ips, http_dns) = service.get_sans(ConnectionType::HTTP); + assert_eq!(http_ips.len(), 1); + assert_eq!(http_ips[0], IpAddr::V4(Ipv4Addr::new(127, 0, 0, 2))); + assert_eq!(http_dns.len(), 1); + assert_eq!(http_dns[0].to_string(), "http.example.test"); + + let mut grpc_ips_mut = grpc_ips.clone(); + grpc_ips_mut.clear(); + let mut grpc_dns_mut = grpc_dns.clone(); + grpc_dns_mut.clear(); + + let (grpc_ips_again, grpc_dns_again) = service.get_sans(ConnectionType::GRPC); + assert_eq!(grpc_ips_again.len(), 1); + assert_eq!(grpc_dns_again.len(), 1); + + let _ = fs::remove_dir_all(&dir); + } +} -- 2.49.1 From 7a9eeb5f3ac29c97d509cc7fced1e68e39a874e7 Mon Sep 17 00:00:00 2001 From: GW_MC <72297530+GWMCwing@users.noreply.github.com> Date: Sat, 21 Mar 2026 03:52:34 +0000 Subject: [PATCH 10/10] feat: Add validation for SSH certificates and implement unit tests for SshAuthInterceptor --- Cargo.lock | 1 + crates/nxmesh-proto/Cargo.toml | 3 + crates/nxmesh-proto/src/auth/ssh_auth.rs | 154 ++++++++++++++++++++++- crates/nxmesh-proto/src/lib.rs | 111 ++++++++++++++++ 4 files changed, 267 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c85cdfd..6a0439e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2517,6 +2517,7 @@ name = "nxmesh-proto" version = "0.1.0" dependencies = [ "prost", + "tokio-test", "tonic", "tonic-async-interceptor", "tonic-prost", diff --git a/crates/nxmesh-proto/Cargo.toml b/crates/nxmesh-proto/Cargo.toml index 2503d86..3dd8cf7 100644 --- a/crates/nxmesh-proto/Cargo.toml +++ b/crates/nxmesh-proto/Cargo.toml @@ -25,3 +25,6 @@ client = [] [build-dependencies] tonic-prost-build.workspace = true + +[dev-dependencies] +tokio-test.workspace = true diff --git a/crates/nxmesh-proto/src/auth/ssh_auth.rs b/crates/nxmesh-proto/src/auth/ssh_auth.rs index a3c74ac..5cefa08 100644 --- a/crates/nxmesh-proto/src/auth/ssh_auth.rs +++ b/crates/nxmesh-proto/src/auth/ssh_auth.rs @@ -38,12 +38,162 @@ impl SshAuthInterceptor { async fn authenticate(&self, req: Request<()>) -> Result, Status> { let certs = req.peer_certs().ok_or(Status::unauthenticated("No cert"))?; - let is_authorized = self.certificate_provider.is_authorized(&certs).await?; + self.validate_certs(&certs).await?; + Ok(req) + } + + async fn validate_certs(&self, certs: &Arc>>) -> Result<(), Status> { + let is_authorized = self.certificate_provider.is_authorized(certs).await?; if is_authorized { - Ok(req) + Ok(()) } else { Err(Status::permission_denied("Blocked")) } } } + +#[cfg(test)] +mod tests { + use std::sync::{ + Arc, Mutex, + atomic::{AtomicUsize, Ordering}, + }; + + use tonic::{Request, Status, transport::CertificateDer}; + use tonic_async_interceptor::AsyncInterceptor; + + use super::{CertificateValidationProvider, SshAuthInterceptor, create_ssh_auth_interceptor}; + + #[derive(Clone, Copy)] + enum ProviderMode { + Allow, + Deny, + Error, + } + + struct TestCertificateProvider { + mode: ProviderMode, + calls: Arc, + cert_count_seen: Arc>>, + } + + #[tonic::async_trait] + impl CertificateValidationProvider for TestCertificateProvider { + async fn is_authorized( + &self, + certs: &Arc>>, + ) -> Result { + self.calls.fetch_add(1, Ordering::SeqCst); + + let lock = self.cert_count_seen.lock(); + assert!(lock.is_ok()); + let mut lock = lock.unwrap_or_else(|_| unreachable!()); + *lock = Some(certs.len()); + + match self.mode { + ProviderMode::Allow => Ok(true), + ProviderMode::Deny => Ok(false), + ProviderMode::Error => Err(Status::internal("provider failed")), + } + } + } + + fn build_provider( + mode: ProviderMode, + ) -> ( + Arc, + Arc, + Arc>>, + ) { + let calls = Arc::new(AtomicUsize::new(0)); + let cert_count_seen = Arc::new(Mutex::new(None)); + let provider = Arc::new(TestCertificateProvider { + mode, + calls: calls.clone(), + cert_count_seen: cert_count_seen.clone(), + }); + (provider, calls, cert_count_seen) + } + + fn sample_certs() -> Arc>> { + Arc::new(vec![ + CertificateDer::from(vec![1, 2, 3]), + CertificateDer::from(vec![4, 5, 6]), + ]) + } + + #[test] + fn create_ssh_auth_interceptor_builds_layer() { + let (provider, _, _) = build_provider(ProviderMode::Allow); + let _ = create_ssh_auth_interceptor(provider); + } + + #[test] + fn authenticate_fails_when_no_peer_certificates_exist() { + let (provider, calls, _) = build_provider(ProviderMode::Allow); + let interceptor = SshAuthInterceptor::new(provider); + + let result = tokio_test::block_on(interceptor.authenticate(Request::new(()))); + assert!(result.is_err()); + let err = result.err().unwrap_or_else(|| unreachable!()); + assert_eq!(err.code(), tonic::Code::Unauthenticated); + assert_eq!(calls.load(Ordering::SeqCst), 0); + } + + #[test] + fn validate_certs_succeeds_when_provider_allows() { + let (provider, calls, cert_count_seen) = build_provider(ProviderMode::Allow); + let interceptor = SshAuthInterceptor::new(provider); + let certs = sample_certs(); + + let result = tokio_test::block_on(interceptor.validate_certs(&certs)); + assert!(result.is_ok()); + assert_eq!(calls.load(Ordering::SeqCst), 1); + + let seen = cert_count_seen.lock(); + assert!(seen.is_ok()); + let seen = seen.unwrap_or_else(|_| unreachable!()); + assert_eq!(*seen, Some(2)); + } + + #[test] + fn validate_certs_returns_permission_denied_when_provider_denies() { + let (provider, calls, _) = build_provider(ProviderMode::Deny); + let interceptor = SshAuthInterceptor::new(provider); + let certs = sample_certs(); + + let result = tokio_test::block_on(interceptor.validate_certs(&certs)); + assert!(result.is_err()); + let err = result.err().unwrap_or_else(|| unreachable!()); + assert_eq!(err.code(), tonic::Code::PermissionDenied); + assert_eq!(err.message(), "Blocked"); + assert_eq!(calls.load(Ordering::SeqCst), 1); + } + + #[test] + fn validate_certs_propagates_provider_errors() { + let (provider, calls, _) = build_provider(ProviderMode::Error); + let interceptor = SshAuthInterceptor::new(provider); + let certs = sample_certs(); + + let result = tokio_test::block_on(interceptor.validate_certs(&certs)); + assert!(result.is_err()); + let err = result.err().unwrap_or_else(|| unreachable!()); + assert_eq!(err.code(), tonic::Code::Internal); + assert_eq!(err.message(), "provider failed"); + assert_eq!(calls.load(Ordering::SeqCst), 1); + } + + #[test] + fn async_interceptor_call_delegates_to_authenticate() { + let (provider, calls, _) = build_provider(ProviderMode::Allow); + let mut interceptor = SshAuthInterceptor::new(provider); + + let result = tokio_test::block_on(interceptor.call(Request::new(()))); + assert!(result.is_err()); + let err = result.err().unwrap_or_else(|| unreachable!()); + assert_eq!(err.code(), tonic::Code::Unauthenticated); + assert_eq!(calls.load(Ordering::SeqCst), 0); + } +} diff --git a/crates/nxmesh-proto/src/lib.rs b/crates/nxmesh-proto/src/lib.rs index 37c6dd2..c718240 100644 --- a/crates/nxmesh-proto/src/lib.rs +++ b/crates/nxmesh-proto/src/lib.rs @@ -11,3 +11,114 @@ pub mod agent { pub use agent::*; pub mod auth; pub use tonic_async_interceptor::*; + +#[cfg(test)] +mod tests { + use prost::Message; + + use crate::agent::{ + AgentMessage, ConfigApplyStatus, ConfigStatus, DeploymentMode, Error, MasterMessage, + MetricType, RegistrationRequest, agent_message, master_message, + }; + + #[test] + fn agent_message_round_trip_with_registration_payload() { + let msg = AgentMessage { + agent_id: "agent-1".to_string(), + timestamp: 123, + payload: Some(agent_message::Payload::Registration(RegistrationRequest { + hostname: "node-1".to_string(), + ip_address: "127.0.0.1".to_string(), + version: "1.0.0".to_string(), + capabilities: vec!["reload".to_string(), "metrics".to_string()], + labels: std::collections::HashMap::from([ + ("region".to_string(), "dev".to_string()), + ("tier".to_string(), "edge".to_string()), + ]), + deployment_mode: DeploymentMode::Standalone as i32, + })), + }; + + let encoded = msg.encode_to_vec(); + let decoded = AgentMessage::decode(encoded.as_slice()); + assert!(decoded.is_ok()); + let decoded = decoded.unwrap_or_else(|_| unreachable!()); + + assert_eq!(decoded.agent_id, "agent-1"); + assert_eq!(decoded.timestamp, 123); + + match decoded.payload { + Some(agent_message::Payload::Registration(payload)) => { + assert_eq!(payload.hostname, "node-1"); + assert_eq!(payload.ip_address, "127.0.0.1"); + assert_eq!(payload.version, "1.0.0"); + assert_eq!(payload.capabilities.len(), 2); + assert_eq!(payload.labels.get("region"), Some(&"dev".to_string())); + assert_eq!(payload.deployment_mode, DeploymentMode::Standalone as i32); + } + _ => unreachable!(), + } + } + + #[test] + fn master_message_round_trip_with_error_payload() { + let msg = MasterMessage { + timestamp: 999, + payload: Some(master_message::Payload::Error(Error { + code: "E_CONFIG_INVALID".to_string(), + message: "invalid config".to_string(), + details: std::collections::HashMap::from([ + ("file".to_string(), "site.conf".to_string()), + ("line".to_string(), "42".to_string()), + ]), + })), + }; + + let encoded = msg.encode_to_vec(); + let decoded = MasterMessage::decode(encoded.as_slice()); + assert!(decoded.is_ok()); + let decoded = decoded.unwrap_or_else(|_| unreachable!()); + + assert_eq!(decoded.timestamp, 999); + match decoded.payload { + Some(master_message::Payload::Error(err)) => { + assert_eq!(err.code, "E_CONFIG_INVALID"); + assert_eq!(err.message, "invalid config"); + assert_eq!(err.details.get("line"), Some(&"42".to_string())); + } + _ => unreachable!(), + } + } + + #[test] + fn enum_integer_mappings_are_stable() { + assert_eq!(DeploymentMode::Unspecified as i32, 0); + assert_eq!(DeploymentMode::DockerSidecar as i32, 1); + assert_eq!(DeploymentMode::KubernetesSidecar as i32, 2); + assert_eq!(DeploymentMode::Standalone as i32, 3); + + assert_eq!(ConfigApplyStatus::Unspecified as i32, 0); + assert_eq!(ConfigApplyStatus::Pending as i32, 1); + assert_eq!(ConfigApplyStatus::Validating as i32, 2); + assert_eq!(ConfigApplyStatus::Applying as i32, 3); + assert_eq!(ConfigApplyStatus::Success as i32, 4); + assert_eq!(ConfigApplyStatus::Failed as i32, 5); + assert_eq!(ConfigApplyStatus::RolledBack as i32, 6); + + assert_eq!(MetricType::Unspecified as i32, 0); + assert_eq!(MetricType::Gauge as i32, 1); + assert_eq!(MetricType::Counter as i32, 2); + assert_eq!(MetricType::Histogram as i32, 3); + } + + #[test] + fn config_status_defaults_are_proto3_zero_values() { + let status = ConfigStatus::default(); + + assert_eq!(status.config_id, ""); + assert_eq!(status.version, 0); + assert_eq!(status.status, ConfigApplyStatus::Unspecified as i32); + assert_eq!(status.error_message, ""); + assert_eq!(status.applied_at, 0); + } +} -- 2.49.1