diff --git a/Cargo.lock b/Cargo.lock index 8428a4fb..0df96ed8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -341,9 +341,9 @@ dependencies = [ [[package]] name = "anstyle" -version = "1.0.13" +version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" [[package]] name = "anstyle-parse" @@ -794,6 +794,18 @@ dependencies = [ "serde_core", ] +[[package]] +name = "bitflagset" +version = "0.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64b6ee310aa7af14142c8c9121775774ff601ae055ed98ba7fac96098bcde1b9" +dependencies = [ + "num-integer", + "num-traits", + "radium 1.1.1", + "ref-cast", +] + [[package]] name = "bitvec" version = "1.0.1" @@ -1107,9 +1119,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.56" +version = "1.2.57" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" +checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423" dependencies = [ "find-msvc-tools", "jobserver", @@ -1276,9 +1288,9 @@ checksum = "2550f75b8cfac212855f6b1885455df8eaee8fe8e246b647d69146142e016084" [[package]] name = "colorchoice" -version = "1.0.4" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] name = "colored" @@ -1714,38 +1726,14 @@ dependencies = [ "libc", ] -[[package]] -name = "darling" -version = "0.21.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" -dependencies = [ - "darling_core 0.21.3", - "darling_macro 0.21.3", -] - [[package]] name = "darling" version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" dependencies = [ - "darling_core 0.23.0", - "darling_macro 0.23.0", -] - -[[package]] -name = "darling_core" -version = "0.21.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" -dependencies = [ - "fnv", - "ident_case", - "proc-macro2", - "quote", - "strsim", - "syn 2.0.117", + "darling_core", + "darling_macro", ] [[package]] @@ -1761,24 +1749,13 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "darling_macro" -version = "0.21.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" -dependencies = [ - "darling_core 0.21.3", - "quote", - "syn 2.0.117", -] - [[package]] name = "darling_macro" version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ - "darling_core 0.23.0", + "darling_core", "quote", "syn 2.0.117", ] @@ -1862,9 +1839,9 @@ dependencies = [ [[package]] name = "derive-where" -version = "1.6.0" +version = "1.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef941ded77d15ca19b40374869ac6000af1c9f2a4c0f3d4c70926287e6364a8f" +checksum = "d08b3a0bcc0d079199cd476b2cae8435016ec11d1c0986c6901c5ac223041534" dependencies = [ "proc-macro2", "quote", @@ -1979,9 +1956,9 @@ dependencies = [ [[package]] name = "doctest-file" -version = "1.0.0" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aac81fa3e28d21450aa4d2ac065992ba96a1d7303efbce51a95f4fd175b67562" +checksum = "c2db04e74f0a9a93103b50e90b96024c9b2bdca8bce6a632ec71b88736d3d359" [[package]] name = "dotenv" @@ -3239,11 +3216,11 @@ dependencies = [ [[package]] name = "instability" -version = "0.3.11" +version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "357b7205c6cd18dd2c86ed312d1e70add149aea98e7ef72b9fdf0270e555c11d" +checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" dependencies = [ - "darling 0.23.0", + "darling", "indoc", "proc-macro2", "quote", @@ -3958,6 +3935,7 @@ dependencies = [ "sha2", "shlex", "sled", + "sodiumoxide", "strum 0.27.2", "strum_macros 0.27.2", "sysinfo 0.34.2", @@ -4166,9 +4144,9 @@ dependencies = [ [[package]] name = "lz4_flex" -version = "0.12.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab6473172471198271ff72e9379150e9dfd70d8e533e0752a27e515b48dd375e" +checksum = "98c23545df7ecf1b16c303910a69b079e8e251d60f7dd2cc9b4177f2afaf1746" dependencies = [ "twox-hash", ] @@ -4662,9 +4640,9 @@ dependencies = [ [[package]] name = "num_enum" -version = "0.7.5" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1207a7e20ad57b847bbddc6776b968420d38292bbfe2089accff5e19e82454c" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" dependencies = [ "num_enum_derive", "rustversion", @@ -4672,9 +4650,9 @@ dependencies = [ [[package]] name = "num_enum_derive" -version = "0.7.5" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" dependencies = [ "proc-macro2", "quote", @@ -5445,9 +5423,9 @@ checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" [[package]] name = "portable-atomic-util" -version = "0.2.5" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a9db96d7fa8782dd8c15ce32ffe8680bbd1e978a43bf51a34d39483540495f5" +checksum = "091397be61a01d4be58e7841595bd4bfedb15f1cd54977d79b8271e94ed799a3" dependencies = [ "portable-atomic", ] @@ -5545,7 +5523,7 @@ version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit 0.25.4+spec-1.1.0", + "toml_edit 0.25.5+spec-1.1.0", ] [[package]] @@ -5766,9 +5744,9 @@ dependencies = [ [[package]] name = "pymath" -version = "0.1.5" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbfb6723b732fc7f0b29a0ee7150c7f70f947bf467b8c3e82530b13589a78b4c" +checksum = "bc10e50b7a1f2cc3887e983721cb51fc7574be0066c84bff3ef9e5c096e8d6d5" dependencies = [ "libc", "libm", @@ -6386,7 +6364,7 @@ dependencies = [ [[package]] name = "ruff_python_ast" version = "0.0.0" -source = "git+https://github.com/astral-sh/ruff.git?rev=5e4a3d9c3b381df20f6a52caef0f56ed0ebc74be#5e4a3d9c3b381df20f6a52caef0f56ed0ebc74be" +source = "git+https://github.com/astral-sh/ruff.git?rev=e4c7f357777a2fdd34dbe6a98b1b7d3e7488f675#e4c7f357777a2fdd34dbe6a98b1b7d3e7488f675" dependencies = [ "aho-corasick", "bitflags 2.11.0", @@ -6404,7 +6382,7 @@ dependencies = [ [[package]] name = "ruff_python_parser" version = "0.0.0" -source = "git+https://github.com/astral-sh/ruff.git?rev=5e4a3d9c3b381df20f6a52caef0f56ed0ebc74be#5e4a3d9c3b381df20f6a52caef0f56ed0ebc74be" +source = "git+https://github.com/astral-sh/ruff.git?rev=e4c7f357777a2fdd34dbe6a98b1b7d3e7488f675#e4c7f357777a2fdd34dbe6a98b1b7d3e7488f675" dependencies = [ "bitflags 2.11.0", "bstr", @@ -6424,7 +6402,7 @@ dependencies = [ [[package]] name = "ruff_python_trivia" version = "0.0.0" -source = "git+https://github.com/astral-sh/ruff.git?rev=5e4a3d9c3b381df20f6a52caef0f56ed0ebc74be#5e4a3d9c3b381df20f6a52caef0f56ed0ebc74be" +source = "git+https://github.com/astral-sh/ruff.git?rev=e4c7f357777a2fdd34dbe6a98b1b7d3e7488f675#e4c7f357777a2fdd34dbe6a98b1b7d3e7488f675" dependencies = [ "itertools 0.14.0", "ruff_source_file", @@ -6435,7 +6413,7 @@ dependencies = [ [[package]] name = "ruff_source_file" version = "0.0.0" -source = "git+https://github.com/astral-sh/ruff.git?rev=5e4a3d9c3b381df20f6a52caef0f56ed0ebc74be#5e4a3d9c3b381df20f6a52caef0f56ed0ebc74be" +source = "git+https://github.com/astral-sh/ruff.git?rev=e4c7f357777a2fdd34dbe6a98b1b7d3e7488f675#e4c7f357777a2fdd34dbe6a98b1b7d3e7488f675" dependencies = [ "memchr", "ruff_text_size", @@ -6444,7 +6422,7 @@ dependencies = [ [[package]] name = "ruff_text_size" version = "0.0.0" -source = "git+https://github.com/astral-sh/ruff.git?rev=5e4a3d9c3b381df20f6a52caef0f56ed0ebc74be#5e4a3d9c3b381df20f6a52caef0f56ed0ebc74be" +source = "git+https://github.com/astral-sh/ruff.git?rev=e4c7f357777a2fdd34dbe6a98b1b7d3e7488f675#e4c7f357777a2fdd34dbe6a98b1b7d3e7488f675" dependencies = [ "get-size2", ] @@ -6685,8 +6663,8 @@ dependencies = [ [[package]] name = "rustpython" -version = "0.4.0" -source = "git+https://github.com/RustPython/RustPython.git#7c0981b9ce7ef0e39b8d7a240784688c2a700bfe" +version = "0.5.0" +source = "git+https://github.com/RustPython/RustPython.git#0768cf80d346b9687c2106a71b00413eab2a4703" dependencies = [ "cfg-if", "dirs-next", @@ -6705,8 +6683,8 @@ dependencies = [ [[package]] name = "rustpython-codegen" -version = "0.4.0" -source = "git+https://github.com/RustPython/RustPython.git#7c0981b9ce7ef0e39b8d7a240784688c2a700bfe" +version = "0.5.0" +source = "git+https://github.com/RustPython/RustPython.git#0768cf80d346b9687c2106a71b00413eab2a4703" dependencies = [ "ahash 0.8.12", "bitflags 2.11.0", @@ -6728,8 +6706,8 @@ dependencies = [ [[package]] name = "rustpython-common" -version = "0.4.0" -source = "git+https://github.com/RustPython/RustPython.git#7c0981b9ce7ef0e39b8d7a240784688c2a700bfe" +version = "0.5.0" +source = "git+https://github.com/RustPython/RustPython.git#0768cf80d346b9687c2106a71b00413eab2a4703" dependencies = [ "ascii", "bitflags 2.11.0", @@ -6756,8 +6734,8 @@ dependencies = [ [[package]] name = "rustpython-compiler" -version = "0.4.0" -source = "git+https://github.com/RustPython/RustPython.git#7c0981b9ce7ef0e39b8d7a240784688c2a700bfe" +version = "0.5.0" +source = "git+https://github.com/RustPython/RustPython.git#0768cf80d346b9687c2106a71b00413eab2a4703" dependencies = [ "ruff_python_ast", "ruff_python_parser", @@ -6770,10 +6748,11 @@ dependencies = [ [[package]] name = "rustpython-compiler-core" -version = "0.4.0" -source = "git+https://github.com/RustPython/RustPython.git#7c0981b9ce7ef0e39b8d7a240784688c2a700bfe" +version = "0.5.0" +source = "git+https://github.com/RustPython/RustPython.git#0768cf80d346b9687c2106a71b00413eab2a4703" dependencies = [ "bitflags 2.11.0", + "bitflagset", "itertools 0.14.0", "lz4_flex", "malachite-bigint", @@ -6784,8 +6763,8 @@ dependencies = [ [[package]] name = "rustpython-derive" -version = "0.4.0" -source = "git+https://github.com/RustPython/RustPython.git#7c0981b9ce7ef0e39b8d7a240784688c2a700bfe" +version = "0.5.0" +source = "git+https://github.com/RustPython/RustPython.git#0768cf80d346b9687c2106a71b00413eab2a4703" dependencies = [ "rustpython-compiler", "rustpython-derive-impl", @@ -6794,8 +6773,8 @@ dependencies = [ [[package]] name = "rustpython-derive-impl" -version = "0.4.0" -source = "git+https://github.com/RustPython/RustPython.git#7c0981b9ce7ef0e39b8d7a240784688c2a700bfe" +version = "0.5.0" +source = "git+https://github.com/RustPython/RustPython.git#0768cf80d346b9687c2106a71b00413eab2a4703" dependencies = [ "itertools 0.14.0", "maplit", @@ -6810,16 +6789,16 @@ dependencies = [ [[package]] name = "rustpython-doc" -version = "0.4.0" -source = "git+https://github.com/RustPython/RustPython.git#7c0981b9ce7ef0e39b8d7a240784688c2a700bfe" +version = "0.5.0" +source = "git+https://github.com/RustPython/RustPython.git#0768cf80d346b9687c2106a71b00413eab2a4703" dependencies = [ "phf 0.13.1", ] [[package]] name = "rustpython-literal" -version = "0.4.0" -source = "git+https://github.com/RustPython/RustPython.git#7c0981b9ce7ef0e39b8d7a240784688c2a700bfe" +version = "0.5.0" +source = "git+https://github.com/RustPython/RustPython.git#0768cf80d346b9687c2106a71b00413eab2a4703" dependencies = [ "hexf-parse", "is-macro", @@ -6831,8 +6810,8 @@ dependencies = [ [[package]] name = "rustpython-pylib" -version = "0.4.0" -source = "git+https://github.com/RustPython/RustPython.git#7c0981b9ce7ef0e39b8d7a240784688c2a700bfe" +version = "0.5.0" +source = "git+https://github.com/RustPython/RustPython.git#0768cf80d346b9687c2106a71b00413eab2a4703" dependencies = [ "glob", "rustpython-compiler-core", @@ -6841,8 +6820,8 @@ dependencies = [ [[package]] name = "rustpython-sre_engine" -version = "0.4.0" -source = "git+https://github.com/RustPython/RustPython.git#7c0981b9ce7ef0e39b8d7a240784688c2a700bfe" +version = "0.5.0" +source = "git+https://github.com/RustPython/RustPython.git#0768cf80d346b9687c2106a71b00413eab2a4703" dependencies = [ "bitflags 2.11.0", "num_enum", @@ -6852,8 +6831,8 @@ dependencies = [ [[package]] name = "rustpython-stdlib" -version = "0.4.0" -source = "git+https://github.com/RustPython/RustPython.git#7c0981b9ce7ef0e39b8d7a240784688c2a700bfe" +version = "0.5.0" +source = "git+https://github.com/RustPython/RustPython.git#0768cf80d346b9687c2106a71b00413eab2a4703" dependencies = [ "adler32", "ahash 0.8.12", @@ -6943,8 +6922,8 @@ dependencies = [ [[package]] name = "rustpython-vm" -version = "0.4.0" -source = "git+https://github.com/RustPython/RustPython.git#7c0981b9ce7ef0e39b8d7a240784688c2a700bfe" +version = "0.5.0" +source = "git+https://github.com/RustPython/RustPython.git#0768cf80d346b9687c2106a71b00413eab2a4703" dependencies = [ "ahash 0.8.12", "ascii", @@ -6998,7 +6977,7 @@ dependencies = [ "scopeguard", "static_assertions", "strum 0.27.2", - "strum_macros 0.27.2", + "strum_macros 0.28.0", "thiserror 2.0.18", "thread_local", "timsort", @@ -7015,8 +6994,8 @@ dependencies = [ [[package]] name = "rustpython-wtf8" -version = "0.4.0" -source = "git+https://github.com/RustPython/RustPython.git#7c0981b9ce7ef0e39b8d7a240784688c2a700bfe" +version = "0.5.0" +source = "git+https://github.com/RustPython/RustPython.git#0768cf80d346b9687c2106a71b00413eab2a4703" dependencies = [ "ascii", "bstr", @@ -7297,9 +7276,9 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.17.0" +version = "3.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "381b283ce7bc6b476d903296fb59d0d36633652b633b27f64db4fb46dcbfc3b9" +checksum = "dd5414fad8e6907dbdd5bc441a50ae8d6e26151a03b1de04d89a5576de61d01f" dependencies = [ "base64", "chrono", @@ -7316,11 +7295,11 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.17.0" +version = "3.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6d4e30573c8cb306ed6ab1dca8423eec9a463ea0e155f45399455e0368b27e0" +checksum = "d3db8978e608f1fe7357e211969fd9abdcae80bac1ba7a3369bb7eb6b404eb65" dependencies = [ - "darling 0.21.3", + "darling", "proc-macro2", "quote", "syn 2.0.117", @@ -7674,6 +7653,18 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "subtle" version = "2.6.1" @@ -8141,9 +8132,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.10.0" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" dependencies = [ "tinyvec_macros", ] @@ -8282,17 +8273,17 @@ dependencies = [ [[package]] name = "toml" -version = "0.9.12+spec-1.1.0" +version = "1.0.7+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +checksum = "dd28d57d8a6f6e458bc0b8784f8fdcc4b99a437936056fa122cb234f18656a96" dependencies = [ "indexmap 2.13.0", "serde_core", "serde_spanned 1.0.4", - "toml_datetime 0.7.5+spec-1.1.0", + "toml_datetime 1.0.1+spec-1.1.0", "toml_parser", "toml_writer", - "winnow", + "winnow 1.0.0", ] [[package]] @@ -8306,18 +8297,9 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "0.7.5+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" -dependencies = [ - "serde_core", -] - -[[package]] -name = "toml_datetime" -version = "1.0.0+spec-1.1.0" +version = "1.0.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32c2555c699578a4f59f0cc68e5116c8d7cabbd45e1409b989d4be085b53f13e" +checksum = "9b320e741db58cac564e26c607d3cc1fdc4a88fd36c879568c07856ed83ff3e9" dependencies = [ "serde_core", ] @@ -8333,28 +8315,28 @@ dependencies = [ "serde_spanned 0.6.9", "toml_datetime 0.6.11", "toml_write", - "winnow", + "winnow 0.7.15", ] [[package]] name = "toml_edit" -version = "0.25.4+spec-1.1.0" +version = "0.25.5+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7193cbd0ce53dc966037f54351dbbcf0d5a642c7f0038c382ef9e677ce8c13f2" +checksum = "8ca1a40644a28bce036923f6a431df0b34236949d111cc07cb6dca830c9ef2e1" dependencies = [ "indexmap 2.13.0", - "toml_datetime 1.0.0+spec-1.1.0", + "toml_datetime 1.0.1+spec-1.1.0", "toml_parser", - "winnow", + "winnow 1.0.0", ] [[package]] name = "toml_parser" -version = "1.0.9+spec-1.1.0" +version = "1.0.10+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "702d4415e08923e7e1ef96cd5727c0dfed80b4d2fa25db9647fe5eb6f7c5a4c4" +checksum = "7df25b4befd31c4816df190124375d5a20c6b6921e2cad937316de3fccd63420" dependencies = [ - "winnow", + "winnow 1.0.0", ] [[package]] @@ -8365,9 +8347,9 @@ checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" [[package]] name = "toml_writer" -version = "1.0.6+spec-1.1.0" +version = "1.0.7+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607" +checksum = "f17aaa1c6e3dc22b1da4b6bba97d066e354c7945cac2f7852d4e4e7ca7a6b56d" [[package]] name = "tonic" @@ -8526,9 +8508,9 @@ dependencies = [ [[package]] name = "tracing-subscriber" -version = "0.3.22" +version = "0.3.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" dependencies = [ "nu-ansi-term", "sharded-slab", @@ -8576,9 +8558,9 @@ checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" [[package]] name = "uds_windows" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51b70b87d15e91f553711b40df3048faf27a7a04e01e0ddc0cf9309f0af7c2ca" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", @@ -9635,9 +9617,9 @@ dependencies = [ [[package]] name = "wide" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac11b009ebeae802ed758530b6496784ebfee7a87b9abfbcaf3bbe25b814eb25" +checksum = "198f6abc41fab83526d10880fa5c17e2b4ee44e763949b4bb34e2fd1e8ca48e4" dependencies = [ "bytemuck", "safe_arch", @@ -10168,13 +10150,22 @@ dependencies = [ "memchr", ] +[[package]] +name = "winnow" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a90e88e4667264a994d34e6d1ab2d26d398dcdca8b7f52bec8668957517fc7d8" +dependencies = [ + "memchr", +] + [[package]] name = "winresource" -version = "0.1.30" +version = "0.1.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e287ced0f21cd11f4035fe946fd3af145f068d1acb708afd248100f89ec7432d" +checksum = "0986a8b1d586b7d3e4fe3d9ea39fb451ae22869dcea4aa109d287a374d866087" dependencies = [ - "toml 0.9.12+spec-1.1.0", + "toml 1.0.7+spec-1.1.0", "version_check", ] @@ -10424,7 +10415,7 @@ dependencies = [ "uds_windows", "uuid", "windows-sys 0.61.2", - "winnow", + "winnow 0.7.15", "zbus_macros", "zbus_names", "zvariant", @@ -10452,7 +10443,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ffd8af6d5b78619bab301ff3c560a5bd22426150253db278f164d6cf3b72c50f" dependencies = [ "serde", - "winnow", + "winnow 0.7.15", "zvariant", ] @@ -10631,7 +10622,7 @@ dependencies = [ "endi", "enumflags2", "serde", - "winnow", + "winnow 0.7.15", "zvariant_derive", "zvariant_utils", ] @@ -10659,5 +10650,5 @@ dependencies = [ "quote", "serde", "syn 2.0.117", - "winnow", + "winnow 0.7.15", ] diff --git a/docs/genusage/cli.rst b/docs/genusage/cli.rst index 0f123a5d..9004740c 100644 --- a/docs/genusage/cli.rst +++ b/docs/genusage/cli.rst @@ -10,9 +10,222 @@ Overview Sysinspect consists of three main executables: -1. ``sysinspect`` — a command to send remote commands to the cluster or run models locally. -2. ``sysmaster`` — is a controller server for all the minion clients -3. ``sysminion`` — a minion client, running as ``root`` on the target +1. ``sysinspect`` — the operator-facing command-line tool +2. ``sysmaster`` — the controller for connected minions +3. ``sysminion`` — the agent running on the target host + +The rest of this page focuses on ``sysinspect`` itself. + +Running Models Remotely +----------------------- + +The most common use of ``sysinspect`` is sending a model query to the +master: + +.. code-block:: bash + + sysinspect "my_model" + sysinspect "my_model/my_entity" + sysinspect "my_model/my_entity/my_state" + +The optional second positional argument targets minions: + +.. code-block:: bash + + sysinspect "my_model" "*" + sysinspect "my_model" "web*" + sysinspect "my_model" "db01,db02" + +Use ``--traits`` to further narrow the target set: + +.. code-block:: bash + + sysinspect "my_model" "*" --traits "system.os.name:Ubuntu" + +Use ``--context`` to pass comma-separated key/value data into the model call: + +.. code-block:: bash + + sysinspect "my_model" "*" --context "foo:123,name:Fred" + +Running Models Locally +---------------------- + +``sysinspect`` can also execute a model locally without going through the +master. Use ``--model`` and optionally limit the selection by entities, +labels, and state: + +.. code-block:: bash + + sysinspect --model ./my_model + sysinspect --model ./my_model --entities foo,bar + sysinspect --model ./my_model --labels os-check + sysinspect --model ./my_model --state online + +Cluster Commands +---------------- + +The following commands talk to the local master instance and affect the +cluster: + +.. code-block:: bash + + sysinspect --sync + sysinspect --online + sysinspect --shutdown + sysinspect --unregister 30006546535e428aba0a0caa6712e225 + +``--sync`` instructs minions to refresh cluster artefacts and then report +their current traits back to the master. + +``--online`` prints the current online-minion summary directly to stdout. + +Traits Management +----------------- + +Master-managed static traits can be updated from the command line: + +.. code-block:: bash + + sysinspect traits --set "foo:bar" + sysinspect traits --set "foo:bar,baz:qux" "web*" + sysinspect traits --set "foo:bar" --id 30006546535e428aba0a0caa6712e225 + sysinspect traits --unset "foo,baz" "web*" + sysinspect traits --reset --id 30006546535e428aba0a0caa6712e225 + +The ``traits`` subcommand supports: + +* ``--set`` — comma-separated ``key:value`` pairs +* ``--unset`` — comma-separated keys +* ``--reset`` — clear only master-managed traits +* ``--id`` — target one minion by System Id +* ``--query`` or trailing positional query — target minions by hostname glob +* ``--traits`` — further narrow targeted minions by traits query + +Deployment Profiles +------------------- + +Deployment profiles describe which modules and libraries a minion is allowed +to sync. Profiles are assigned to minions through the ``minion.profile`` +static trait. + +Profile definitions: + +.. code-block:: bash + + sysinspect profile --new --name Toto + sysinspect profile --delete --name Toto + sysinspect profile --list + sysinspect profile --list --name 'T*' + sysinspect profile --show --name Toto + +Assign selectors to a profile: + +.. code-block:: bash + + sysinspect profile -A --name Toto --match 'runtime.lua,net.*' + sysinspect profile -A --lib --name Toto --match 'runtime/lua/*.lua' + sysinspect profile -R --name Toto --match 'net.*' + +Assign or remove profiles on minions: + +.. code-block:: bash + + sysinspect profile --tag 'Toto,Foo' --query 'web*' + sysinspect profile --tag 'Toto' --id 30006546535e428aba0a0caa6712e225 + sysinspect profile --untag 'Foo' --traits 'system.hostname.fqdn:db01.example.net' + +Notes: + +* ``--name`` is an exact profile name for ``--new``, ``--delete``, ``--show``, ``-A``, and ``-R`` +* ``--name`` is a glob pattern for ``--list`` +* ``--match`` accepts comma-separated exact names or glob patterns +* ``-l`` / ``--lib`` switches selector operations and listing to library selectors +* ``--tag`` and ``--untag`` update ``minion.profile`` on the targeted minions +* profile names are case-sensitive Unix-like names +* each profile file carries its own canonical ``name`` field; the filename is only storage +* new profile files are written with lowercase filenames, but existing indexed filenames remain valid even if they are mixed-case or arbitrary + +Profile Data Model +------------------ + +The master publishes a dedicated ``profiles.index`` next to ``mod.index``. +Each profile entry points to one profile file plus its checksum: + +.. code-block:: yaml + + profiles: + Toto: + file: totobullshit.profile + checksum: deadbeef + +Each profile file carries the actual profile identity and the allowed artefact +selectors: + +.. code-block:: yaml + + name: Toto + modules: + - runtime.lua + - net.* + libraries: + - lib/runtime/lua/*.lua + +The filename is only storage. The canonical profile identity is the +case-sensitive ``name`` field inside the file. Newly created profile files +are written with lowercase filenames, but already indexed filenames are +still honored as-is. + +Sync Behavior +------------- + +During minion sync: + +1. ``mod.index`` is downloaded from the fileserver +2. ``profiles.index`` is downloaded from the fileserver +3. the minion resolves its effective profiles from ``minion.profile`` +4. the selected profile files are refreshed into ``$SYSINSPECT/profiles`` +5. profile selectors are merged by union + dedup +6. module and library sync is filtered by that merged selector set +7. integrity cleanup removes now-forbidden artefacts + +Module Repository Management +---------------------------- + +The ``module`` subcommand manages the master's module repository: + +.. code-block:: bash + + sysinspect module -A --name runtime.lua --path ./target/debug/runtime/lua + sysinspect module -A --path ./lib -l + sysinspect module -L + sysinspect module -Ll + sysinspect module -R --name runtime.lua + sysinspect module -R --name runtime/lua/reader.lua -l + sysinspect module -i --name runtime.lua + +Supported operations are: + +* ``-A`` / ``--add`` +* ``-R`` / ``--remove`` +* ``-L`` / ``--list`` +* ``-i`` / ``--info`` + +Use ``-l`` / ``--lib`` when operating on library payloads instead of runnable +modules. + +TUI and Utility Commands +------------------------ + +``sysinspect`` also exposes a few utility entrypoints: + +.. code-block:: bash + + sysinspect --ui + sysinspect --list-handlers + +The terminal user interface is documented separately in +:doc:`../uix/ui`. Starting a Master ----------------- @@ -88,21 +301,10 @@ If connection was established successfully, then the last message should be "Ehl To start/stop a Minion in daemon mode, use ``--daemon`` and ``--stop`` respectively. -Minion can be also stopped remotely. However, to start it back, one needs to take care of the -process themselves (either via ``systemd``, manually via SSH or any other means). To stop a minion -remotely, use its System Id: - -.. code-block:: text - - sysinspect --stop 30006546535e428aba0a0caa6712e225 - -In this case a minion with the System Id above will be stopped, while the rest of the cluster will -continue working. - Removing a Minion ----------------- -To remove a Minion (unregister) use the following command, similar to stopping it by its System Id: +To remove a Minion (unregister) use the following command by its System Id: .. code-block:: text @@ -110,4 +312,4 @@ To remove a Minion (unregister) use the following command, similar to stopping i In this case the Minion will be unregistered, its RSA public key will be removed, connection terminated and the Master will be forgotten. In order to start this minion again, please refer to the Minion -registration. \ No newline at end of file +registration. diff --git a/docs/genusage/systraits.rst b/docs/genusage/systraits.rst index 34355185..1cf5e0c0 100644 --- a/docs/genusage/systraits.rst +++ b/docs/genusage/systraits.rst @@ -25,8 +25,8 @@ System Traits Definition and description of system traits and their purpose. -Traits are essentially static attributes of a minion. They can be a literally anything -in a form of key/value. There are different kinds of traits: +Traits are essentially static attributes of a minion. They can be almost anything +in a key/value form. There are different kinds of traits: **Common** @@ -36,29 +36,17 @@ in a form of key/value. There are different kinds of traits: **Custom** - Custom traits are static data that set explicity onto a minion. Any data in + Custom traits are static data that are set explicitly onto a minion. Any data in key/value form. They are usually various labels, rack number, physical floor, Asset Tag, serial number etc. **Dynamic** - Dynamic traits are custom functions, where data obtained by relevant modules. - essentially, they are just like normal modules, except the resulting data is stored as + Dynamic traits are custom functions where data is obtained by relevant modules. + Essentially, they are just like normal modules, except the resulting data is stored as a criterion by which a specific minion is targeted. For example, *"memory less than X"*, or *"runs process Y"* etc. -Listing Traits --------------- - -To list minion's traits, is enough to target a minion by its Id or hostname: - -.. code-block:: bash - - $ sysinspect --minions - ... - - $ sysinspect --info - Using Traits in a Model ----------------------- @@ -68,86 +56,84 @@ Static Minion Traits -------------------- Traits can be also custom static data, which is placed in a minion configuration. Traits are just -YAML files with key/value format, placed in ``$SYSINSPECT/traits`` directory of a minion. The naming -of those files is not important, they will be anyway merged into one tree. Important is to ensure -that trait keys do not repeat, so they do not overwrite each other. The ``$SYSINSPECT`` directory +YAML files with key/value format, placed in ``$SYSINSPECT/traits`` directory of a minion. Files +ending in ``.cfg`` are loaded and merged into one tree. The ``$SYSINSPECT`` directory is ``/etc/sysinspect`` by default or is defined in the minion configuration. +Load order is: + +1. discovered built-in traits +2. local ``*.cfg`` files in alphabetical order, except ``master.cfg`` +3. trait functions from ``$SYSINSPECT/functions`` +4. ``master.cfg`` last, overriding all previous values + Example of a trait file: .. code-block:: yaml - :caption: File: ``/etc/sysinspect/traits/example.trait`` + :caption: File: ``/etc/sysinspect/traits/example.cfg`` - traits: - name: Fred + name: Fred + rack: A3 -From now on, the minion can be targeded by the trait ``name``: +From now on, the minion can be targeted by the trait ``name``: .. code-block:: bash :caption: Targeting a minion by a custom trait - sysinspect "my_model/my_entity name:Fred" - -.. code-block:: + sysinspect "my_model/my_entity" "*" --traits "name:Fred" Populating Static Traits ------------------------ -Populating traits is done in two steps: - -1. Writing a specific static trait in a trait description -2. Populating the trait description to all targeted minions +Local static traits are simply written into separate ``*.cfg`` files by the +operator or provisioning system. -Synopsis of a trait description as follows: +Master-managed static traits use a reserved file: .. code-block:: text - :caption: Synopsis - : - [machine-id]: - - [list] - [hostname]: - - [list] - [traits]: - [key]: [value] - : - [key]: [value] + $SYSINSPECT/traits/master.cfg - # Only for dynamic traits (functions) - [functions]: - - [list] +This file is created automatically by the minion and is reserved for updates +coming from the master. It should not be edited manually. -For example, to make an alias for all Ubuntu running machines, the following valid trait description: +The ``sysinspect traits`` command updates only this file: -.. code-block:: yaml - :caption: An alias to a system trait +.. code-block:: bash - # This is to select what minions should have - # the following traits assigned - query: - traits: - - system.os.kernel.version: 6.* + sysinspect traits --set "foo:bar" "web*" + sysinspect traits --unset "foo,baz" "web*" + sysinspect traits --reset --id 30006546535e428aba0a0caa6712e225 - # Actual traits to be assigned - traits: - kernel: six +After such update the minion immediately sends refreshed traits back to the +master. Global ``sysinspect --sync`` also refreshes traits. -Now it is possible to call all minions with any kernel of major version 6 like so: +Deployment profile assignment also uses this same mechanism. For example: .. code-block:: bash - :caption: Target minions by own alias - sysinspect "my_model/my_entity kernel:six" + sysinspect profile --tag "tiny-lua" --query "pi*" + sysinspect profile --untag "tiny-lua" --id 30006546535e428aba0a0caa6712e225 + +This updates the master-managed ``minion.profile`` trait on the targeted +minions. + +If ``minion.profile`` is not set, the minion falls back to the +``default`` profile during sync. -The section ``functions`` is used for the dynamic traits, described below. +``default`` is fallback-only. Once one or more real profiles are assigned, +``default`` is not kept alongside them as a stored assignment. Dynamic Traits -------------- -Dynamic traits are functions that are doing something on the machine. Since those functions -are standalone executables, they do not accept any parameters. Functions are the same modules -like any other modules and using the same protocol with the JSON format. The difference is that -the module should return key/value structure. For example: +Function-based traits are standalone executables placed into +``$SYSINSPECT/functions``. Since those functions are standalone executables, +they do not accept any parameters. They use the same general JSON return +shape as other modules, except that the output is merged into the minion's +trait tree. + +The module should return a key/value structure. For example: .. code-block:: json @@ -155,7 +141,7 @@ the module should return key/value structure. For example: "key": "value", } -Example of using a custom module: +Example of using a custom function: .. code-block:: bash :caption: File: ``my_trait.sh`` @@ -178,30 +164,11 @@ function module is actionable or not on a target system. I.e. user must ensure t system where the particular minion is running, should be equipped with Bash in ``/usr/bin`` directory. -Any modules that return non-zero return like system error more than ``1`` is simply ignored -and error is logged. - -Populating Dynamic Traits -------------------------- - -To populate dynamic trait there are three steps for this: - -1. Writing a specific trait in a Trait Description -2. Placing the trait module to the file server so the minions can download it -3. Populating the Trait Description to all targeted minions - -To write a specific trait in a Trait Description, the ``functions`` section must be specified. -Example: - -.. code-block:: yaml - - functions: - # Specify a relative path on the fileserver - - /functions/my_trait.sh +Any function that returns a non-zero result greater than ``1`` is ignored and +an error is logged. -The script ``my_trait.sh`` will be copied to ``$SYSINSPECT/functions``. When the minion starts, -it will execute each function in alphabetical oder, read the JSON output and merge the result -into the common traits tree. Then the traits tree will be synchronised with the Master. +The script ``my_trait.sh`` will be executed when traits are loaded. The minion +reads its JSON output and merges the result into the common traits tree. .. important:: @@ -212,4 +179,4 @@ This means if the attribute will be different at every minion startup, it might to target a minion by such attribute, unless it is matching to some regular expression. There might be a rare use cases, such as *"select minion or not, depending on its mood"* (because the function returns every time a different value), but generally this sort of dynamism is nearly -outside of the scope of traits system. \ No newline at end of file +outside of the scope of traits system. diff --git a/docs/global_config.rst b/docs/global_config.rst index 528420ac..4bd38684 100644 --- a/docs/global_config.rst +++ b/docs/global_config.rst @@ -129,15 +129,41 @@ Master Sysinspect Master configuration is located under earlier mentioned ``master`` section, and contains the following directives: -``socket`` -########## +``console.bind.ip`` +################### Type: **string** - Path for a FIFO socket to communicate with the ``sysinspect`` command, - which is issuing commands over the network. + IPv4 address on which ``sysmaster`` listens for console connections from + ``sysinspect``. This is the active command transport between + ``sysinspect`` and ``sysmaster``. + + When this value is ``0.0.0.0``, the local ``sysinspect`` client still + connects through ``127.0.0.1``. + + If omitted, the default value is ``127.0.0.1``. + +``console.bind.port`` +##################### + + Type: **integer** + + TCP port for the master's console endpoint used by ``sysinspect``. + + If omitted, the default value is ``4203``. + +``console`` key material +######################## + + The console endpoint uses the master's RSA material under the master + root directory: + + * ``console.rsa`` — console private key + * ``console.rsa.pub`` — console public key + * ``console-keys/`` — authorised client public keys - Default value is ``/var/run/sysinspect-master.socket``. + These are filesystem conventions under the master root, not YAML + configuration directives. ``bind.ip`` ########### @@ -434,7 +460,8 @@ Example configuration for the Sysinspect Master: config: master: - socket: /tmp/sysinspect-master.socket + console.bind.ip: 127.0.0.1 + console.bind.port: 4203 bind.ip: 0.0.0.0 bind.port: 4200 diff --git a/docs/index.rst b/docs/index.rst index 1b247384..40c505f6 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -47,6 +47,7 @@ welcome—see the section on contributing for how to get involved. tutorial/checkbook_tutor tutorial/action_chain_tutor tutorial/module_management + tutorial/profiles_tutor tutorial/wasm_modules_tutor tutorial/lua_modules_tutor tutorial/menotify_tutor diff --git a/docs/tutorial/profiles_tutor.rst b/docs/tutorial/profiles_tutor.rst new file mode 100644 index 00000000..277c3519 --- /dev/null +++ b/docs/tutorial/profiles_tutor.rst @@ -0,0 +1,290 @@ +.. _profiles_tutorial: + +Deployment Profiles Tutorial +============================ + +.. note:: + + This tutorial walks through the full deployment profile flow on the + Master side: create profiles, add selectors, assign profiles to + minions, sync, and verify the result. + +Overview +-------- + +Deployment profiles define which modules and libraries a minion is allowed +to sync. + +Profiles are: + +* defined on the Master +* stored one profile per file +* assigned to minions through the ``minion.profile`` static trait +* enforced during minion sync + +The effective result is: + +1. a minion resolves its assigned profile names +2. it downloads the corresponding profile files +3. it merges selectors by union + dedup +4. it syncs only the allowed modules and libraries + +What We Will Build +------------------ + +In this tutorial we will: + +1. create a narrow profile named ``tiny-lua`` +2. allow only the Lua runtime and Lua-side libraries +3. assign that profile to one or more minions +4. sync the cluster +5. verify that only the allowed artefacts are present + +Prerequisites +------------- + +This tutorial assumes: + +* the Master is running +* one or more minions are already registered +* the runtime modules and libraries are already published in the module repository + +If you need the repository basics first, see :ref:`mod_mgmt_tutorial`. + +Creating a Profile +------------------ + +Create a new profile on the Master: + +.. code-block:: bash + + sysinspect profile --new --name tiny-lua + +This creates a profile entry in ``profiles.index`` and a profile file under +the Master's profiles directory. + +List available profiles: + +.. code-block:: bash + + sysinspect profile --list + +Expected output should include: + +.. code-block:: text + + tiny-lua + +Adding Module Selectors +----------------------- + +Now add the module selectors allowed by this profile: + +.. code-block:: bash + + sysinspect profile -A --name tiny-lua --match "runtime.lua" + +List the module selectors: + +.. code-block:: bash + + sysinspect profile --list --name tiny-lua + +Expected output: + +.. code-block:: text + + tiny-lua: runtime.lua + +Show the fully expanded profile content as a mixed modules/libraries table: + +.. code-block:: bash + + sysinspect profile --show --name tiny-lua + +Adding Library Selectors +------------------------ + +Add the library selectors allowed by the same profile: + +.. code-block:: bash + + sysinspect profile -A --lib --name tiny-lua --match "lib/runtime/lua/*.lua,lib/sensors/lua/*.lua" + +List the library selectors: + +.. code-block:: bash + + sysinspect profile --list --name tiny-lua --lib + +Expected output: + +.. code-block:: text + + tiny-lua: lib/runtime/lua/*.lua + tiny-lua: lib/sensors/lua/*.lua + +Editing a Profile +----------------- + +If you added a selector by mistake, remove it with ``-R``: + +.. code-block:: bash + + sysinspect profile -R --name tiny-lua --match "lib/sensors/lua/*.lua" --lib + +You can then add the correct selector again: + +.. code-block:: bash + + sysinspect profile -A --lib --name tiny-lua --match "lib/sensors/lua/*.lua" + +Profile files have a canonical ``name`` inside the file. The filename is +only storage. + +New profiles are written with lowercase filenames by default, but the index +can still point at any existing filename and that filename will continue to +work unchanged. + +For example, a profile file looks like this: + +.. code-block:: yaml + + name: tiny-lua + modules: + - runtime.lua + libraries: + - lib/runtime/lua/*.lua + - lib/sensors/lua/*.lua + +Assigning a Profile to Minions +------------------------------ + +Assign the profile to minions by query: + +.. code-block:: bash + + sysinspect profile --tag "tiny-lua" --query "pi*" + +Assign the profile to one exact minion by System Id: + +.. code-block:: bash + + sysinspect profile --tag "tiny-lua" --id 30006546535e428aba0a0caa6712e225 + +You can also combine profile assignment with trait-based minion targeting: + +.. code-block:: bash + + sysinspect profile --tag "tiny-lua" --traits "system.os.name:NetBSD" + +This updates the master-managed ``minion.profile`` static trait on the +targeted minions. + +Removing a Profile Assignment +----------------------------- + +To remove one assigned profile from targeted minions: + +.. code-block:: bash + + sysinspect profile --untag "tiny-lua" --query "pi*" + +If all assigned profiles are removed, the minion falls back to the +``default`` profile during sync. + +``default`` is fallback-only. It is not stored together with real assigned +profiles. + +Synchronising the Cluster +------------------------- + +After creating or changing profiles, refresh the cluster: + +.. code-block:: bash + + sysinspect --sync + +During sync: + +1. minions download ``mod.index`` +2. minions download ``profiles.index`` +3. minions resolve ``minion.profile`` +4. minions download the selected profile files +5. minions merge all selectors by union + dedup +6. minions sync only the allowed artefacts + +On minion startup, Sysinspect also logs the effective profile names being +activated. + +Verifying the Result +-------------------- + +There are a few practical ways to verify the setup. + +Check the assigned profile on the Master: + +.. code-block:: bash + + sysinspect --ui + +The TUI can be used to inspect online minions and their traits, including +``minion.profile``. + +Check the profile definition on the Master: + +.. code-block:: bash + + sysinspect profile --list --name tiny-lua + sysinspect profile --list --name tiny-lua --lib + sysinspect profile --show --name tiny-lua + +Check the minion logs: + +.. code-block:: text + + INFO: Activating profile tiny-lua + +or, for multiple profiles: + +.. code-block:: text + + INFO: Activating profiles tiny-lua, runtime-full + +Using the Shipped Examples +-------------------------- + +Sysinspect ships example profile files under: + +* ``examples/profiles/tiny-lua.profile`` +* ``examples/profiles/runtime-full.profile`` + +They are examples of the exact profile file format accepted by the Master. + +Deleting a Profile +------------------ + +When a profile is no longer needed: + +.. code-block:: bash + + sysinspect profile --delete --name tiny-lua + +This removes both: + +* the ``profiles.index`` entry +* the stored profile file on the Master + +Summary +------- + +The profile workflow is: + +1. create a profile +2. add module selectors +3. add library selectors +4. assign the profile to minions +5. sync the cluster +6. verify the result + +That is the complete deployment profile loop in Sysinspect. diff --git a/examples/profiles/README.md b/examples/profiles/README.md new file mode 100644 index 00000000..e3ce73ff --- /dev/null +++ b/examples/profiles/README.md @@ -0,0 +1,30 @@ +Deployment Profile Examples +=========================== + +This directory contains example deployment profile files for the master-side +`profiles.index` / `.profile` mechanism. + +Files: + +- `tiny-lua.profile` + - a narrow profile that allows only the Lua runtime and Lua-side libraries +- `runtime-full.profile` + - a fuller runtime profile that allows Lua, Py3, and Wasm runtimes together + +Profile file format: + +```yaml +name: tiny-lua +modules: + - runtime.lua +libraries: + - lib/runtime/lua/*.lua +``` + +Notes: + +- profile identity comes from `name`, not the filename +- new profiles are normally written with lowercase filenames +- already indexed arbitrary or mixed-case filenames remain valid +- selectors support exact names and glob patterns +- effective minion selection is driven by the `minion.profile` trait diff --git a/examples/profiles/runtime-full.profile b/examples/profiles/runtime-full.profile new file mode 100644 index 00000000..30227662 --- /dev/null +++ b/examples/profiles/runtime-full.profile @@ -0,0 +1,9 @@ +name: runtime-full +modules: + - runtime.lua + - runtime.py3 + - runtime.wasm +libraries: + - lib/runtime/lua/* + - lib/runtime/python3/* + - lib/runtime/wasm/* diff --git a/examples/profiles/tiny-lua.profile b/examples/profiles/tiny-lua.profile new file mode 100644 index 00000000..0d6d4e1b --- /dev/null +++ b/examples/profiles/tiny-lua.profile @@ -0,0 +1,6 @@ +name: tiny-lua +modules: + - runtime.lua +libraries: + - lib/runtime/lua/*.lua + - lib/sensors/lua/*.lua diff --git a/libmodpak/src/lib.rs b/libmodpak/src/lib.rs index f4699212..c9bcd234 100644 --- a/libmodpak/src/lib.rs +++ b/libmodpak/src/lib.rs @@ -2,18 +2,21 @@ use colored::Colorize; use cruet::Inflector; use fs_extra::dir::CopyOptions; use goblin::Object; -use indexmap::IndexMap; +use indexmap::{IndexMap, IndexSet}; use libcommon::SysinspectError; use libsysinspect::cfg::mmconf::DEFAULT_MODULES_DIR; -use libsysinspect::cfg::mmconf::{CFG_AUTOSYNC_FAST, CFG_AUTOSYNC_SHALLOW, DEFAULT_MODULES_LIB_DIR, MinionConfig}; -use libsysinspect::util::iofs::get_file_sha256; -use mpk::{ModAttrs, ModPakMetadata, ModPakRepoIndex}; +use libsysinspect::cfg::mmconf::{CFG_AUTOSYNC_FAST, CFG_AUTOSYNC_SHALLOW, CFG_PROFILES_ROOT, DEFAULT_MODULES_LIB_DIR, MinionConfig}; +use libsysinspect::traits::{current_os_type, effective_profiles, os_display_name}; +use libsysinspect::util::{iofs::get_file_sha256, pad_visible}; +use mpk::{ModAttrs, ModPakMetadata, ModPakProfile, ModPakProfilesIndex, ModPakRepoIndex}; use once_cell::sync::Lazy; use prettytable::{Cell, Row, Table, format}; -use regex::Regex; use std::os::unix::fs::PermissionsExt; use std::sync::Arc; -use std::{collections::HashMap, fs, path::PathBuf}; +use std::{ + fs, + path::{Component, Path, PathBuf}, +}; use textwrap::{Options, wrap}; use tokio::sync::Mutex; @@ -30,8 +33,17 @@ their dependencies, and their architecture. */ static REPO_MOD_INDEX: &str = "mod.index"; +static REPO_PROFILES_INDEX: &str = "profiles.index"; static REPO_MOD_SHA256_EXT: &str = "checksum.sha256"; -static ANSI_ESCAPE_RE: Lazy = Lazy::new(|| Regex::new(r"\x1b\[[0-9;]*m").expect("ansi regex should compile")); + +struct ArtefactRow { + kind: String, + name: String, + display_name: String, + os: String, + arch: String, + sha256: String, +} pub struct ModPakSyncState { state: Arc>, @@ -82,11 +94,117 @@ impl SysInspectModPakMinion { return Err(SysinspectError::MasterGeneralError(format!("Failed to get modpak index: {}", resp.status()))); } - let buff = resp.bytes().await.unwrap(); + let buff = resp + .bytes() + .await + .map_err(|e| SysinspectError::MasterGeneralError(format!("Failed to read modpak index response: {e}")))?; let idx = ModPakRepoIndex::from_yaml(&String::from_utf8_lossy(&buff))?; Ok(idx) } + async fn get_profiles_idx(&self) -> Result { + let resp = reqwest::Client::new() + .get(format!("http://{}/{}", self.cfg.fileserver(), REPO_PROFILES_INDEX)) + .send() + .await + .map_err(|e| SysinspectError::MasterGeneralError(format!("Request failed: {e}")))?; + if resp.status() == reqwest::StatusCode::NOT_FOUND { + return Ok(ModPakProfilesIndex::new()); + } + if resp.status() != reqwest::StatusCode::OK { + return Err(SysinspectError::MasterGeneralError(format!("Failed to get profiles index: {}", resp.status()))); + } + + let buff = resp + .bytes() + .await + .map_err(|e| SysinspectError::MasterGeneralError(format!("Failed to read profiles index response: {e}")))?; + Self::validate_profiles_index(ModPakProfilesIndex::from_yaml(&String::from_utf8_lossy(&buff))?) + } + + async fn sync_profiles(&self, profiles: &ModPakProfilesIndex, names: &[String]) -> Result<(), SysinspectError> { + if !self.cfg.profiles_dir().exists() { + fs::create_dir_all(self.cfg.profiles_dir())?; + } + + for name in names { + let profile = profiles + .get(name) + .ok_or_else(|| SysinspectError::MasterGeneralError(format!("Profile {} is missing from profiles.index", name.bright_yellow())))?; + let dst = self.cfg.profiles_dir().join(profile.file()); + if dst.exists() && get_file_sha256(dst.clone())?.eq(profile.checksum()) { + continue; + } + + let resp = reqwest::Client::new() + .get(format!("http://{}/{}/{}", self.cfg.fileserver(), CFG_PROFILES_ROOT, profile.file().display())) + .send() + .await + .map_err(|e| SysinspectError::MasterGeneralError(format!("Request failed: {e}")))?; + if resp.status() != reqwest::StatusCode::OK { + return Err(SysinspectError::MasterGeneralError(format!("Failed to get profile {}: {}", name, resp.status()))); + } + if let Some(parent) = dst.parent() + && !parent.exists() + { + fs::create_dir_all(parent)?; + } + fs::write(&dst, resp.bytes().await.map_err(|e| SysinspectError::MasterGeneralError(format!("Failed to read response: {e}")))? )?; + if !get_file_sha256(dst.clone())?.eq(profile.checksum()) { + return Err(SysinspectError::MasterGeneralError(format!( + "Checksum mismatch for profile {}", + name.bright_yellow() + ))); + } + } + + Ok(()) + } + + fn validate_profile_path(path: &Path) -> Result<(), SysinspectError> { + if path.components().all(|component| matches!(component, Component::Normal(_))) { + return Ok(()); + } + + Err(SysinspectError::MasterGeneralError(format!( + "Invalid profile path in profiles.index: {}", + path.display() + ))) + } + + fn validate_profiles_index(index: ModPakProfilesIndex) -> Result { + for profile in index.profiles().values() { + Self::validate_profile_path(profile.file())?; + } + + Ok(index) + } + + fn filtered_repo_index(&self, ridx: ModPakRepoIndex, profiles: &ModPakProfilesIndex, names: &[String]) -> Result { + if profiles.profiles().is_empty() { + return Ok(ridx); + } + + let found = names.iter().filter(|name| profiles.get(name).is_some()).cloned().collect::>(); + if found.is_empty() { + return Err(SysinspectError::MasterGeneralError(format!( + "None of the requested profile{} exist in profiles.index: {}", + if names.len() == 1 { "" } else { "s" }, + names.join(", ").bright_yellow() + ))); + } + + let mut modules = IndexSet::new(); + let mut libraries = IndexSet::new(); + for name in found { + if let Some(profile) = profiles.get(&name) { + ModPakProfile::from_yaml(&fs::read_to_string(self.cfg.profiles_dir().join(profile.file()))?)?.merge_into(&mut modules, &mut libraries); + } + } + + Ok(ridx.retain_profiles(&modules, &libraries)) + } + /// Verifies an artefact by its subpath and checksum. async fn verify_artefact_by_subpath(&self, section: &str, subpath: &str, checksum: &str) -> Result<(bool, Option), SysinspectError> { let path = self.cfg.sharelib_dir().join(section).join(subpath); @@ -122,7 +240,10 @@ impl SysInspectModPakMinion { } MODPAK_SYNC_STATE.set_syncing(true).await; - let ridx = self.get_modpak_idx().await?; + let profiles = self.get_profiles_idx().await?; + let names = effective_profiles(&self.cfg); + self.sync_profiles(&profiles, &names).await?; + let ridx = self.filtered_repo_index(self.get_modpak_idx().await?, &profiles, &names)?; self.sync_integrity(&ridx)?; // blocking self.sync_modules(&ridx).await?; @@ -163,6 +284,9 @@ impl SysInspectModPakMinion { let mut shared = IndexMap::new(); for sp in [DEFAULT_MODULES_DIR, DEFAULT_MODULES_LIB_DIR].iter() { let root = self.cfg.sharelib_dir().join(sp); + if !root.exists() { + continue; + } collect_files(&root, &root, &mut shared)?; } @@ -253,7 +377,7 @@ impl SysInspectModPakMinion { /// Syncs modules from the fileserver. async fn sync_modules(&self, ridx: &ModPakRepoIndex) -> Result<(), SysinspectError> { - let ostype = env!("THIS_OS"); + let ostype = current_os_type(); let osarch = env!("THIS_ARCH"); let modt = ridx.modules().len(); @@ -331,6 +455,126 @@ pub struct SysInspectModPak { } impl SysInspectModPak { + fn render_artefact_table(rows: Vec) -> String { + let type_width = rows.iter().map(|row| row.kind.chars().count()).max().unwrap_or(4).max("Type".chars().count()); + let name_width = rows.iter().map(|row| row.name.chars().count()).max().unwrap_or(4).max("Name".chars().count()); + let os_width = rows.iter().map(|row| row.os.chars().count()).max().unwrap_or(2).max("OS".chars().count()); + let arch_width = rows.iter().map(|row| row.arch.chars().count()).max().unwrap_or(4).max("Arch".chars().count()); + let sha_width = rows.iter().map(|row| row.sha256.chars().count()).max().unwrap_or(6).max("SHA256".chars().count()); + let mut out = vec![ + format!( + "{} {} {} {} {}", + pad_visible(&"Type".bright_yellow().to_string(), type_width), + pad_visible(&"Name".bright_yellow().to_string(), name_width), + pad_visible(&"OS".bright_yellow().to_string(), os_width), + pad_visible(&"Arch".bright_yellow().to_string(), arch_width), + pad_visible(&"SHA256".bright_yellow().to_string(), sha_width), + ), + format!( + "{} {} {} {} {}", + "─".repeat(type_width), + "─".repeat(name_width), + "─".repeat(os_width), + "─".repeat(arch_width), + "─".repeat(sha_width), + ), + ]; + + for row in rows { + out.push(format!( + "{} {} {} {} {}", + pad_visible(&row.kind.bright_green().to_string(), type_width), + pad_visible(&row.display_name, name_width), + pad_visible(&row.os.bright_green().to_string(), os_width), + pad_visible(&row.arch.bright_green().to_string(), arch_width), + pad_visible(&row.sha256.green().to_string(), sha_width), + )); + } + + out.join("\n") + } + + /// Load the on-disk profiles index published next to the module repository. + fn get_profiles_index(&self) -> Result { + SysInspectModPakMinion::validate_profiles_index(ModPakProfilesIndex::from_yaml( + &fs::read_to_string(self.root.parent().unwrap_or(&self.root).join(REPO_PROFILES_INDEX))?, + )?) + } + + /// Persist the profiles index next to the module repository. + fn set_profiles_index(&self, index: &ModPakProfilesIndex) -> Result<(), SysinspectError> { + fs::write(self.root.parent().unwrap_or(&self.root).join(REPO_PROFILES_INDEX), index.to_yaml()?)?; + Ok(()) + } + + fn validate_profile_name_and_file(name: &str, file: &Path) -> Result<(), SysinspectError> { + if name.contains('/') || name.contains('\\') || name.contains("..") { + return Err(SysinspectError::MasterGeneralError(format!( + "Invalid profile name {}", + name.bright_yellow() + ))); + } + + if file.components().all(|component| matches!(component, Component::Normal(_))) { + return Ok(()); + } + + Err(SysinspectError::MasterGeneralError(format!( + "Invalid profile file path for {}: {}", + name.bright_yellow(), + file.display() + ))) + } + + /// Load one profile by canonical name and verify the file content name matches the index entry. + fn get_profile(&self, name: &str) -> Result { + let index = self.get_profiles_index()?; + let entry = index + .get(name) + .ok_or_else(|| SysinspectError::MasterGeneralError(format!("Profile {} was not found", name.bright_yellow())))?; + Self::validate_profile_name_and_file(name, entry.file())?; + { + let profile = + ModPakProfile::from_yaml(&fs::read_to_string(self.root.parent().unwrap_or(&self.root).join(CFG_PROFILES_ROOT).join(entry.file()))?)?; + if profile.name() != name { + return Err(SysinspectError::MasterGeneralError(format!( + "Profile {} does not match the file content name {}", + name.bright_yellow(), + profile.name().bright_yellow() + ))); + } + Ok(profile) + } + } + + /// Persist one profile file and refresh its `profiles.index` checksum entry. + fn set_profile(&self, name: &str, profile: &ModPakProfile) -> Result<(), SysinspectError> { + let mut index = self.get_profiles_index()?; + let file = index.get(name).map(|entry| entry.file().to_path_buf()).unwrap_or_else(|| PathBuf::from(format!("{}.profile", name.to_lowercase()))); + Self::validate_profile_name_and_file(name, &file)?; + let path = self.root.parent().unwrap_or(&self.root).join(CFG_PROFILES_ROOT).join(&file); + if !self.root.parent().unwrap_or(&self.root).join(CFG_PROFILES_ROOT).exists() { + fs::create_dir_all(self.root.parent().unwrap_or(&self.root).join(CFG_PROFILES_ROOT))?; + } + fs::write(&path, profile.to_yaml()?)?; + index.insert(name, file, &get_file_sha256(path)?); + self.set_profiles_index(&index) + } + + /// Remove one profile file and its `profiles.index` entry. + fn remove_profile_entry(&self, name: &str) -> Result<(), SysinspectError> { + let mut index = self.get_profiles_index()?; + if let Some(entry) = index.profiles().get(name) { + Self::validate_profile_name_and_file(name, entry.file())?; + let path = self.root.parent().unwrap_or(&self.root).join(CFG_PROFILES_ROOT).join(entry.file()); + if path.exists() { + fs::remove_file(path)?; + } + } + index.remove(name); + self.set_profiles_index(&index) + } + /// Format a library path for `module -Ll` with runtime-aware filename colors. pub(crate) fn format_library_name(name: &str) -> String { for marker in ["site-lua/", "site-packages/"] { @@ -354,17 +598,14 @@ impl SysInspectModPak { format!("{}{}", prefix.bright_white().bold(), file) } - fn pad_visible(text: &str, width: usize) -> String { - let visible = ANSI_ESCAPE_RE.replace_all(text, "").chars().count(); - if visible >= width { text.to_string() } else { format!("{text}{}", " ".repeat(width - visible)) } - } - /// Creates a new ModPakRepo with the given root path. pub fn new(root: PathBuf) -> Result { if !root.exists() { log::info!("Creating module repository at {}", root.display()); std::fs::create_dir_all(&root)?; fs::write(root.join(REPO_MOD_INDEX), ModPakRepoIndex::new().to_yaml()?)?; // XXX: needs flock + fs::create_dir_all(root.parent().unwrap_or(&root).join(CFG_PROFILES_ROOT))?; + fs::write(root.parent().unwrap_or(&root).join(REPO_PROFILES_INDEX), ModPakProfilesIndex::new().to_yaml()?)?; } let ridx = root.join(REPO_MOD_INDEX); @@ -373,6 +614,15 @@ impl SysInspectModPak { fs::write(&ridx, ModPakRepoIndex::new().to_yaml()?)?; } + if !root.parent().unwrap_or(&root).join(CFG_PROFILES_ROOT).exists() { + fs::create_dir_all(root.parent().unwrap_or(&root).join(CFG_PROFILES_ROOT))?; + } + + let pidx = root.parent().unwrap_or(&root).join(REPO_PROFILES_INDEX); + if !pidx.exists() { + fs::write(&pidx, ModPakProfilesIndex::new().to_yaml()?)?; + } + Ok(Self { root: root.clone(), idx: ModPakRepoIndex::from_yaml(&fs::read_to_string(ridx)?)? }) } @@ -533,7 +783,7 @@ impl SysInspectModPak { /// Lists all libraries in the repository. pub fn list_libraries(&self, expr: Option<&str>) -> Result<(), SysinspectError> { let expr = glob::Pattern::new(expr.unwrap_or("*")).map_err(|e| SysinspectError::MasterGeneralError(format!("Invalid pattern: {e}")))?; - let mut rows = Vec::<(String, String, String, String, String)>::new(); + let mut rows = Vec::::new(); for (_, mpklf) in self.idx.library() { if !expr.matches(&mpklf.file().display().to_string()) { continue; @@ -560,16 +810,17 @@ impl SysInspectModPak { (mpklf.kind().to_string(), "noarch".to_string(), "any".to_string()) }; - rows.push(( - match kind.as_str() { + rows.push(ArtefactRow { + kind: match kind.as_str() { "wasm" | "binary" => "binary".to_string(), _ => "script".to_string(), }, - mpklf.file().display().to_string(), - p.to_title_case(), + name: mpklf.file().display().to_string(), + display_name: Self::format_library_name(&mpklf.file().display().to_string()), + os: p.to_title_case(), arch, - format!("{}...{}", &mpklf.checksum()[..4], &mpklf.checksum()[mpklf.checksum().len() - 4..]), - )); + sha256: format!("{}...{}", &mpklf.checksum()[..4], &mpklf.checksum()[mpklf.checksum().len() - 4..]), + }); } if rows.is_empty() { @@ -577,47 +828,62 @@ impl SysInspectModPak { return Ok(()); } - let type_width = rows.iter().map(|r| r.0.chars().count()).max().unwrap_or(4).max("Type".chars().count()); - let name_width = rows.iter().map(|r| r.1.chars().count()).max().unwrap_or(4).max("Name".chars().count()); - let os_width = rows.iter().map(|r| r.2.chars().count()).max().unwrap_or(2).max("OS".chars().count()); - let arch_width = rows.iter().map(|r| r.3.chars().count()).max().unwrap_or(4).max("Arch".chars().count()); - let sha_width = rows.iter().map(|r| r.4.chars().count()).max().unwrap_or(6).max("SHA256".chars().count()); - - println!( - "{} {} {} {} {}", - Self::pad_visible(&"Type".bright_yellow().to_string(), type_width), - Self::pad_visible(&"Name".bright_yellow().to_string(), name_width), - Self::pad_visible(&"OS".bright_yellow().to_string(), os_width), - Self::pad_visible(&"Arch".bright_yellow().to_string(), arch_width), - Self::pad_visible(&"SHA256".bright_yellow().to_string(), sha_width), - ); - println!( - "{} {} {} {} {}", - "─".repeat(type_width), - "─".repeat(name_width), - "─".repeat(os_width), - "─".repeat(arch_width), - "─".repeat(sha_width), - ); - - for (kind, name, os_name, arch, sha) in rows { - println!( - "{} {} {} {} {}", - Self::pad_visible(&kind.bright_green().to_string(), type_width), - Self::pad_visible(&Self::format_library_name(&name), name_width), - Self::pad_visible(&os_name.bright_green().to_string(), os_width), - Self::pad_visible(&arch.bright_green().to_string(), arch_width), - Self::pad_visible(&sha.green().to_string(), sha_width), - ); - } + println!("{}", Self::render_artefact_table(rows)); Ok(()) } + /// Render the expanded artefact content of one profile as a mixed modules/libraries table. + pub fn show_profile(&self, name: &str) -> Result { + let profile = self.get_profile(name)?; + let mut modules = self.idx.match_modules(profile.modules()); + modules.sort_by(|a, b| a.name().cmp(b.name())); + let mut libraries = self + .idx + .library() + .into_iter() + .filter(|(name, _)| profile.libraries().iter().any(|expr| glob::Pattern::new(expr).is_ok_and(|pattern| pattern.matches(name)))) + .collect::>(); + libraries.sort_by(|(a, _), (b, _)| a.cmp(b)); + + if modules.is_empty() && libraries.is_empty() { + return Err(SysinspectError::MasterGeneralError(format!("Profile {} does not match any modules or libraries", name.bright_yellow()))); + } + + let mut rows = modules + .into_iter() + .map(|module| ArtefactRow { + kind: "module".to_string(), + name: module.name().to_string(), + display_name: module.name().bright_cyan().bold().to_string(), + os: module.os().iter().map(|os| os_display_name(os).to_string()).collect::>().join(", "), + arch: module.arch().join(", "), + sha256: module + .checksums() + .iter() + .map(|checksum| format!("{}...{}", &checksum[..4], &checksum[checksum.len() - 4..])) + .collect::>() + .join(", "), + }) + .collect::>(); + rows.extend(libraries.into_iter().map(|(name, file)| ArtefactRow { + kind: match file.kind() { + "wasm" | "binary" => "binary".to_string(), + _ => "script".to_string(), + }, + name: name.clone(), + display_name: Self::format_library_name(&name), + os: "Any".to_string(), + arch: "noarch".to_string(), + sha256: format!("{}...{}", &file.checksum()[..4], &file.checksum()[file.checksum().len() - 4..]), + })); + Ok(Self::render_artefact_table(rows)) + } + pub fn module_info(&self, name: &str) -> Result<(), SysinspectError> { let mut found = false; for (p, archset) in self.idx.all_modules(None, Some(vec![name])) { - let p = if p.eq("sysv") { "Linux" } else { p.as_str() }; + let p = os_display_name(&p); for (arch, modules) in archset { println!("{} ({}): ", p, arch.bright_green()); Self::print_table(&modules, true); @@ -632,24 +898,73 @@ impl SysInspectModPak { Ok(()) } + /// List profile names filtered by an optional glob expression. + pub fn list_profiles(&self, expr: Option<&str>) -> Result, SysinspectError> { + let expr = glob::Pattern::new(expr.unwrap_or("*")).map_err(|e| SysinspectError::MasterGeneralError(format!("Invalid pattern: {e}")))?; + let mut profiles = self.get_profiles_index()?.profiles().keys().filter(|name| expr.matches(name)).map(|name| name.to_string()).collect::>(); + profiles.sort(); + Ok(profiles) + } + + /// Create a new empty profile with the given canonical name. + pub fn new_profile(&self, name: &str) -> Result<(), SysinspectError> { + if self.get_profiles_index()?.get(name).is_some() { + return Err(SysinspectError::MasterGeneralError(format!("Profile {} already exists", name.bright_yellow()))); + } + self.set_profile(name, &ModPakProfile::new(name)) + } + + /// Delete one profile by canonical name. + pub fn delete_profile(&self, name: &str) -> Result<(), SysinspectError> { + if self.get_profiles_index()?.get(name).is_none() { + return Err(SysinspectError::MasterGeneralError(format!("Profile {} was not found", name.bright_yellow()))); + } + self.remove_profile_entry(name) + } + + /// Add module or library selectors to the named profile. + pub fn add_profile_matches(&self, name: &str, matches: Vec, library: bool) -> Result<(), SysinspectError> { + let mut profile = self.get_profile(name)?; + if library { + profile.add_libraries(matches); + } else { + profile.add_modules(matches); + } + self.set_profile(name, &profile) + } + + /// Remove module or library selectors from the named profile. + pub fn remove_profile_matches(&self, name: &str, matches: Vec, library: bool) -> Result<(), SysinspectError> { + let mut profile = self.get_profile(name)?; + if library { + profile.remove_libraries(matches); + } else { + profile.remove_modules(matches); + } + self.set_profile(name, &profile) + } + + /// List module or library selectors for profiles matching the optional glob expression. + pub fn list_profile_matches(&self, expr: Option<&str>, library: bool) -> Result, SysinspectError> { + let mut out = Vec::new(); + for profile in self.list_profiles(expr)? { + let data = self.get_profile(&profile)?; + for entry in if library { data.libraries() } else { data.modules() } { + out.push(format!("{profile}: {entry}")); + } + } + Ok(out) + } + /// Lists all modules in the repository. pub fn list_modules(&self) -> Result<(), SysinspectError> { - let osn = HashMap::from([ - ("sysv", "Linux"), - ("any", "Any"), - ("linux", "Linux"), - ("netbsd", "NetBSD"), - ("freebsd", "FreeBSD"), - ("openbsd", "OpenBSD"), - ]); - let allmods = self.idx.all_modules(None, None); let mut platforms = allmods.iter().map(|(p, _)| p.to_string()).collect::>(); platforms.sort(); for p in platforms { let archset = allmods.get(&p).unwrap(); // safe: iter above - let p = if osn.contains_key(p.as_str()) { osn.get(p.as_str()).unwrap() } else { p.as_str() }; + let p = os_display_name(&p); for (arch, modules) in archset { println!("{} ({}): ", p, arch.bright_green()); Self::print_table(modules, false); diff --git a/libmodpak/src/lib_ut.rs b/libmodpak/src/lib_ut.rs index 661a66c7..27806d7d 100644 --- a/libmodpak/src/lib_ut.rs +++ b/libmodpak/src/lib_ut.rs @@ -1,8 +1,11 @@ #[cfg(test)] mod tests { use crate::{SysInspectModPak, mpk::ModPakMetadata}; + use libsysinspect::{cfg::mmconf::MinionConfig, traits::effective_profiles}; + use std::collections::HashSet; use colored::control; use std::{fs, path::Path}; + use libsysinspect::cfg::mmconf::CFG_PROFILES_ROOT; /// Creates a minimal library tree under `src/lib`. /// @@ -223,4 +226,149 @@ mod tests { } assert!(found, "runtime.lua should be indexed"); } + + #[test] + fn profile_crud_updates_index_and_profile_file() { + let root = tempfile::tempdir().expect("repo tempdir should be created"); + let repo = SysInspectModPak::new(root.path().join("repo")).expect("repo should be created"); + + repo.new_profile("toto").expect("profile should be created"); + repo.add_profile_matches("toto", vec!["runtime.lua".to_string(), "net.*".to_string()], false) + .expect("module selectors should be added"); + repo.add_profile_matches("toto", vec!["runtime/lua/*.lua".to_string()], true).expect("library selectors should be added"); + + assert_eq!(repo.list_profiles(None).expect("profiles should list"), vec!["toto".to_string()]); + assert!(repo + .list_profile_matches(Some("toto"), false) + .expect("profile modules should list") + .contains(&"toto: runtime.lua".to_string())); + assert!(repo + .list_profile_matches(Some("toto"), false) + .expect("profile modules should list") + .contains(&"toto: net.*".to_string())); + assert!(repo + .list_profile_matches(Some("toto"), true) + .expect("profile libraries should list") + .contains(&"toto: runtime/lua/*.lua".to_string())); + + repo.remove_profile_matches("toto", vec!["net.*".to_string()], false).expect("module selector should be removed"); + assert!(!repo + .list_profile_matches(Some("toto"), false) + .expect("profile modules should list") + .contains(&"toto: net.*".to_string())); + + repo.delete_profile("toto").expect("profile should be deleted"); + assert!(repo.list_profiles(None).expect("profiles should list").is_empty()); + } + + #[test] + fn profile_create_and_delete_validate_existence() { + let root = tempfile::tempdir().expect("repo tempdir should be created"); + let repo = SysInspectModPak::new(root.path().join("repo")).expect("repo should be created"); + + assert!(repo.delete_profile("missing").is_err()); + repo.new_profile("toto").expect("profile should be created"); + assert!(repo.new_profile("toto").is_err()); + } + + #[test] + fn new_profiles_use_lowercase_filenames_without_changing_profile_name() { + let root = tempfile::tempdir().expect("repo tempdir should be created"); + let repo = SysInspectModPak::new(root.path().join("repo")).expect("repo should be created"); + + repo.new_profile("Toto").expect("profile should be created"); + + let idx = repo.get_profiles_index().expect("profiles index should load"); + let profile = repo.get_profile("Toto").expect("profile should load"); + assert_eq!(idx.get("Toto").expect("profile ref should exist").file(), &std::path::PathBuf::from("toto.profile")); + assert_eq!(profile.name(), "Toto"); + } + + #[test] + fn existing_profile_keeps_arbitrary_indexed_filename() { + let root = tempfile::tempdir().expect("repo tempdir should be created"); + let repo = SysInspectModPak::new(root.path().join("repo")).expect("repo should be created"); + let profiles_root = root.path().join(CFG_PROFILES_ROOT); + fs::write( + profiles_root.join("totobullshit.profile"), + "name: Toto\nmodules:\n - runtime.lua\n", + ) + .expect("profile file should be written"); + fs::write( + root.path().join("profiles.index"), + "profiles:\n Toto:\n file: totobullshit.profile\n checksum: deadbeef\n", + ) + .expect("profiles index should be written"); + + repo.add_profile_matches("Toto", vec!["net.*".to_string()], false).expect("profile should be updated"); + + let idx = repo.get_profiles_index().expect("profiles index should load"); + let profile = repo.get_profile("Toto").expect("profile should load"); + assert_eq!(idx.get("Toto").expect("profile ref should exist").file(), &std::path::PathBuf::from("totobullshit.profile")); + assert_eq!(profile.name(), "Toto"); + assert!(profile.modules().contains(&"runtime.lua".to_string())); + assert!(profile.modules().contains(&"net.*".to_string())); + } + + #[test] + fn profiles_index_rejects_parent_dir_traversal() { + let root = tempfile::tempdir().expect("repo tempdir should be created"); + let repo = SysInspectModPak::new(root.path().join("repo")).expect("repo should be created"); + fs::write( + root.path().join("profiles.index"), + "profiles:\n Toto:\n file: ../escape.profile\n checksum: deadbeef\n", + ) + .expect("profiles index should be written"); + + assert!(repo.get_profiles_index().is_err()); + } + + #[test] + fn new_profile_rejects_traversing_name() { + let root = tempfile::tempdir().expect("repo tempdir should be created"); + let repo = SysInspectModPak::new(root.path().join("repo")).expect("repo should be created"); + + assert!(repo.new_profile("../escape").is_err()); + } + + #[test] + fn show_profile_renders_modules_first_and_libraries_after() { + control::set_override(true); + + let root = tempfile::tempdir().expect("repo tempdir should be created"); + let src = tempfile::tempdir().expect("src tempdir should be created"); + let repo_root = root.path().join("repo"); + let mut repo = SysInspectModPak::new(repo_root.clone()).expect("repo should be created"); + write_library(src.path(), "runtime/lua/reader.lua"); + repo.add_library(src.path().to_path_buf()).expect("library tree should be indexed"); + write_module(&mut repo, "linux", "x86_64", "runtime.lua", "runtime/lua"); + write_module(&mut repo, "netbsd", "noarch", "runtime.lua", "runtime/lua"); + repo.new_profile("toto").expect("profile should be created"); + repo.add_profile_matches("toto", vec!["runtime.lua".to_string()], false).expect("module selector should be added"); + repo.add_profile_matches("toto", vec!["lib/runtime/lua/*.lua".to_string()], true).expect("library selector should be added"); + + let rendered = repo.show_profile("toto").expect("profile should render"); + let module_pos = rendered.find("runtime.lua").expect("module row should exist"); + let library_pos = rendered.find("reader.lua").expect("library row should exist"); + + assert!(rendered.contains("Linux, NetBSD") || rendered.contains("NetBSD, Linux")); + assert!(rendered.contains("x86_64, noarch") || rendered.contains("noarch, x86_64")); + assert!(module_pos < library_pos, "modules should be rendered before libraries"); + } + + #[test] + fn effective_profile_names_fallback_to_default_and_accept_array() { + let root = tempfile::tempdir().expect("root tempdir should be created"); + let share = tempfile::tempdir().expect("share tempdir should be created"); + let mut cfg = MinionConfig::default(); + cfg.set_root_dir(root.path().to_str().expect("root path should be valid")); + cfg.set_sharelib_path(share.path().to_str().expect("share path should be valid")); + fs::create_dir_all(cfg.traits_dir()).expect("traits dir should be created"); + + assert_eq!(effective_profiles(&cfg), vec!["default".to_string()]); + + fs::write(cfg.traits_dir().join("master.cfg"), "minion.profile:\n - Toto\n - Foo\n - Toto\n").expect("master traits should be written"); + let names = effective_profiles(&cfg).into_iter().collect::>(); + assert_eq!(names, HashSet::from(["Toto".to_string(), "Foo".to_string()])); + } } diff --git a/libmodpak/src/mpk.rs b/libmodpak/src/mpk.rs index 8ae63592..d375a973 100644 --- a/libmodpak/src/mpk.rs +++ b/libmodpak/src/mpk.rs @@ -1,6 +1,8 @@ +//! Module repository index, profile index, and package metadata types. + use anyhow::Context; use colored::Colorize; -use indexmap::IndexMap; +use indexmap::{IndexMap, IndexSet}; use libcommon::SysinspectError; use libmodcore::modinit::{ModArgument, ModInterface, ModOption}; use once_cell::sync::Lazy; @@ -106,6 +108,205 @@ fn default_library_kind() -> String { "script".to_string() } +#[derive(Debug, Serialize, Deserialize, Clone)] +/// Indexed profile reference stored in `profiles.index`. +pub struct ModPakProfileRef { + file: PathBuf, + checksum: String, +} + +impl ModPakProfileRef { + /// Create a new indexed profile reference. + pub fn new(file: PathBuf, checksum: &str) -> Self { + Self { file, checksum: checksum.to_string() } + } + + /// Return the stored profile filename. + pub fn file(&self) -> &PathBuf { + &self.file + } + + /// Return the stored profile checksum. + pub fn checksum(&self) -> &str { + &self.checksum + } +} + +#[derive(Debug, Serialize, Deserialize, Clone, Default)] +/// One deployment profile file with its canonical name and selector lists. +pub struct ModPakProfile { + name: String, + #[serde(default)] + modules: Vec, + #[serde(default)] + libraries: Vec, +} + +impl ModPakProfile { + /// Create a new empty profile with the given canonical name. + pub fn new(name: &str) -> Self { + Self { name: name.to_string(), ..Default::default() } + } + + /// Return the canonical profile name from the profile file. + pub fn name(&self) -> &str { + &self.name + } + + /// Return the module selector list. + pub fn modules(&self) -> &[String] { + &self.modules + } + + /// Return the library selector list. + pub fn libraries(&self) -> &[String] { + &self.libraries + } +} + +#[derive(Debug, Serialize, Deserialize, Clone, Default)] +/// Top-level `profiles.index` structure published by the master. +pub struct ModPakProfilesIndex { + #[serde(default)] + profiles: IndexMap, +} + +impl ModPakProfilesIndex { + /// Create an empty profiles index. + pub fn new() -> Self { + Self::default() + } + + /// Return all indexed profiles. + pub fn profiles(&self) -> &IndexMap { + &self.profiles + } + + /// Return one indexed profile reference by canonical name. + pub fn get(&self, name: &str) -> Option<&ModPakProfileRef> { + self.profiles.get(name) + } + + /// Insert or replace one indexed profile reference. + pub fn insert(&mut self, name: &str, file: PathBuf, checksum: &str) { + self.profiles.insert(name.to_string(), ModPakProfileRef::new(file, checksum)); + } + + /// Remove one indexed profile reference by canonical name. + pub fn remove(&mut self, name: &str) { + self.profiles.shift_remove(name); + } + + /// Serialize the profiles index to YAML. + pub fn to_yaml(&self) -> Result { + Ok(serde_yaml::to_string(self)?) + } + + /// Deserialize the profiles index from YAML. + pub fn from_yaml(yaml: &str) -> Result { + Ok(serde_yaml::from_str(yaml)?) + } +} + +#[derive(Debug, Clone, Default)] +/// Aggregated repository view of one module across all matching platforms and architectures. +pub struct ModPakRepoModuleView { + name: String, + os: Vec, + arch: Vec, + checksums: Vec, +} + +impl ModPakRepoModuleView { + /// Create an empty aggregated module view. + pub fn new(name: &str) -> Self { + Self { name: name.to_string(), ..Default::default() } + } + + /// Return the module namespace name. + pub fn name(&self) -> &str { + &self.name + } + + /// Return the supported operating systems. + pub fn os(&self) -> &[String] { + &self.os + } + + /// Return the supported architectures. + pub fn arch(&self) -> &[String] { + &self.arch + } + + /// Return the checksums for the matched module variants. + pub fn checksums(&self) -> &[String] { + &self.checksums + } + + /// Merge one platform, architecture, and checksum tuple into the aggregated view. + pub fn merge_variant(&mut self, os: &str, arch: &str, checksum: &str) { + if !self.os.contains(&os.to_string()) { + self.os.push(os.to_string()); + } + if !self.arch.contains(&arch.to_string()) { + self.arch.push(arch.to_string()); + } + if !self.checksums.contains(&checksum.to_string()) { + self.checksums.push(checksum.to_string()); + } + } +} + +impl ModPakProfile { + /// Deserialize one profile file from YAML. + pub fn from_yaml(yaml: &str) -> Result { + Ok(serde_yaml::from_str(yaml)?) + } + + /// Merge this profile's selectors into the effective deduplicated selector sets. + pub fn merge_into(&self, modules: &mut IndexSet, libraries: &mut IndexSet) { + for module in &self.modules { + modules.insert(module.to_string()); + } + for library in &self.libraries { + libraries.insert(library.to_string()); + } + } + + /// Add module selectors, keeping insertion order and skipping duplicates. + pub fn add_modules(&mut self, modules: Vec) { + for module in modules { + if !self.modules.contains(&module) { + self.modules.push(module); + } + } + } + + /// Add library selectors, keeping insertion order and skipping duplicates. + pub fn add_libraries(&mut self, libraries: Vec) { + for library in libraries { + if !self.libraries.contains(&library) { + self.libraries.push(library); + } + } + } + + /// Remove matching module selectors. + pub fn remove_modules(&mut self, modules: Vec) { + self.modules.retain(|module| !modules.contains(module)); + } + + /// Remove matching library selectors. + pub fn remove_libraries(&mut self, libraries: Vec) { + self.libraries.retain(|library| !libraries.contains(library)); + } + + /// Serialize one profile file to YAML. + pub fn to_yaml(&self) -> Result { + Ok(serde_yaml::to_string(self)?) + } +} + #[allow(clippy::type_complexity)] #[derive(Debug, Serialize, Deserialize)] pub struct ModPakRepoIndex { @@ -156,6 +357,10 @@ impl ModPakRepoIndex { } } + fn library_selector_matches(pattern: &glob::Pattern, name: &str) -> bool { + pattern.matches(name) || pattern.matches(&format!("lib/{name}")) || name.strip_prefix("lib/").is_some_and(|rel| pattern.matches(rel)) + } + pub fn index_library(&mut self, p: &Path) -> Result<(), SysinspectError> { for (fname, cs) in libsysinspect::util::iofs::scan_files_sha256(p.to_path_buf(), None) { log::debug!("Adding library file: {fname} with checksum: {cs}"); @@ -258,6 +463,45 @@ impl ModPakRepoIndex { modules } + /// Return a filtered repository view that keeps only artefacts matched by the given profile selectors. + pub fn retain_profiles(&self, modules: &IndexSet, libraries: &IndexSet) -> Self { + let mut index = Self::new(); + for (platform, archset) in &self.platform { + for (arch, entries) in archset { + for (name, attrs) in entries.iter().filter(|(name, _)| modules.iter().any(|expr| glob::Pattern::new(expr).is_ok_and(|pattern| pattern.matches(name)))) { + index + .platform + .entry(platform.to_string()) + .or_default() + .entry(arch.to_string()) + .or_default() + .insert(name.to_string(), attrs.clone()); + } + } + } + + for (name, entry) in &self.library { + if libraries.iter().any(|expr| glob::Pattern::new(expr).is_ok_and(|pattern| Self::library_selector_matches(&pattern, name))) { + index.library.insert(name.to_string(), entry.clone()); + } + } + + index + } + + /// Return aggregated module views matched by the given selector patterns. + pub fn match_modules(&self, patterns: &[String]) -> Vec { + let mut views = IndexMap::::new(); + for (platform, archset) in &self.platform { + for (arch, entries) in archset { + for (name, attrs) in entries.iter().filter(|(name, _)| patterns.iter().any(|expr| glob::Pattern::new(expr).is_ok_and(|pattern| pattern.matches(name)))) { + views.entry(name.to_string()).or_insert_with(|| ModPakRepoModuleView::new(name)).merge_variant(platform, arch, attrs.checksum()); + } + } + } + views.into_values().collect() + } + /// Returns the modules in the index. Optionally filtered by architecture and names. pub(crate) fn all_modules(&self, arch: Option<&str>, names: Option>) -> IndexMap>> { if let Some(arch) = arch { @@ -401,6 +645,7 @@ impl ModPakMetadata { Ok(()) } + /// Set the module architecture label. pub fn set_arch(&mut self, arch: &str) { self.arch = arch.to_string(); } @@ -425,6 +670,7 @@ impl ModPakMetadata { &self.options } + /// Return the repository subpath derived from the module namespace. pub fn get_subpath(&self) -> PathBuf { let p = self.get_name().trim_start_matches('.').trim_end_matches('.').to_string().replace('.', "/"); if self.arch.eq("noarch") { PathBuf::from(format!("{p}.py")) } else { PathBuf::from(p) } @@ -456,6 +702,7 @@ impl ModPakMetadata { } } + /// Build module metadata from the `sysinspect module -A` CLI arguments and optional sidecar spec. pub fn from_cli_matches(matches: &clap::ArgMatches) -> Result { let mut mpm = ModPakMetadata::default(); diff --git a/libmodpak/src/mpk_ut.rs b/libmodpak/src/mpk_ut.rs index a175de20..68e556cb 100644 --- a/libmodpak/src/mpk_ut.rs +++ b/libmodpak/src/mpk_ut.rs @@ -1,4 +1,5 @@ -use crate::mpk::ModPakMetadata; +use crate::mpk::{ModPakMetadata, ModPakProfile, ModPakProfilesIndex, ModPakRepoIndex}; +use indexmap::IndexSet; use std::path::PathBuf; #[test] @@ -12,3 +13,85 @@ fn runtime_dispatcher_names_are_reserved() { let meta = ModPakMetadata::new_for_test(PathBuf::from("/tmp/custom-module"), "lua.reader"); assert!(meta.validate_namespace().is_err()); } + +#[test] +fn profiles_index_and_profile_roundtrip() { + let mut index = ModPakProfilesIndex::new(); + index.insert("default", PathBuf::from("default.profile"), "deadbeef"); + let index = ModPakProfilesIndex::from_yaml(&index.to_yaml().expect("profiles index should serialize")).expect("profiles index should deserialize"); + let profile = + ModPakProfile::from_yaml("name: default\nmodules:\n - runtime.lua\nlibraries:\n - runtime/lua/reader.lua\n").expect("profile should deserialize"); + + assert_eq!(index.get("default").expect("default profile should exist").file(), &PathBuf::from("default.profile")); + assert_eq!(profile.name(), "default"); + assert_eq!(profile.modules(), &["runtime.lua".to_string()]); + assert_eq!(profile.libraries(), &["runtime/lua/reader.lua".to_string()]); +} + +#[test] +fn profile_merge_and_repo_filter_dedup_exact_matches() { + let mut modules = IndexSet::new(); + let mut libraries = IndexSet::new(); + ModPakProfile::from_yaml("name: default\nmodules:\n - runtime.lua\n - runtime.lua\nlibraries:\n - runtime/lua/reader.lua\n") + .expect("profile should deserialize") + .merge_into(&mut modules, &mut libraries); + + let mut repo = ModPakRepoIndex::from_yaml( + r#" +platform: {} +library: + runtime/lua/reader.lua: + file: runtime/lua/reader.lua + checksum: beadfeed + kind: lua + runtime/py3/reader.py: + file: runtime/py3/reader.py + checksum: facefeed + kind: python +"#, + ) + .expect("repo index should deserialize"); + repo.index_module("runtime.lua", "runtime/lua", "any", "noarch", "lua runtime", false, "deadbeef", None, None) + .expect("runtime module should index"); + repo.index_module("net.ping", "net/ping", "any", "noarch", "ping module", false, "cafebabe", None, None) + .expect("ping module should index"); + + let filtered = repo.retain_profiles(&modules, &libraries); + let modules = filtered.modules(); + let libraries = filtered.library(); + + assert!(modules.contains_key("runtime.lua")); + assert!(!modules.contains_key("net.ping")); + assert!(libraries.contains_key("runtime/lua/reader.lua")); + assert!(!libraries.contains_key("runtime/py3/reader.py")); +} + +#[test] +fn profile_library_filter_accepts_optional_lib_prefix() { + let mut modules = IndexSet::new(); + let mut libraries = IndexSet::new(); + ModPakProfile::from_yaml("name: default\nlibraries:\n - lib/runtime/lua/reader.lua\n") + .expect("profile should deserialize") + .merge_into(&mut modules, &mut libraries); + + let filtered = ModPakRepoIndex::from_yaml( + r#" +platform: {} +library: + runtime/lua/reader.lua: + file: runtime/lua/reader.lua + checksum: beadfeed + kind: lua + runtime/py3/reader.py: + file: runtime/py3/reader.py + checksum: facefeed + kind: python +"#, + ) + .expect("repo index should deserialize") + .retain_profiles(&modules, &libraries) + .library(); + + assert!(filtered.contains_key("runtime/lua/reader.lua")); + assert!(!filtered.contains_key("runtime/py3/reader.py")); +} diff --git a/libmodpak/tests/profile_sync.rs b/libmodpak/tests/profile_sync.rs new file mode 100644 index 00000000..69f16915 --- /dev/null +++ b/libmodpak/tests/profile_sync.rs @@ -0,0 +1,245 @@ +use libmodpak::{SysInspectModPak, SysInspectModPakMinion, mpk::ModPakRepoIndex}; +use libsysinspect::{ + cfg::mmconf::MinionConfig, + traits::{TraitUpdateRequest, ensure_master_traits_file}, +}; +use std::{fs, path::{Path, PathBuf}}; +use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + net::TcpListener, +}; + +fn write_file(path: &Path, data: &[u8]) { + fs::create_dir_all(path.parent().expect("parent should exist")).expect("parent dir should be created"); + fs::write(path, data).expect("file should be written"); +} + +fn add_script_module(root: &Path, name: &str, body: &str) { + let subpath = name.replace('.', "/"); + write_file(&root.join("script/any/noarch").join(&subpath), body.as_bytes()); +} + +fn set_script_modules(root: &Path, modules: &[&str]) { + let mut index = if root.join("mod.index").exists() { + ModPakRepoIndex::from_yaml(&fs::read_to_string(root.join("mod.index")).expect("mod.index should read")).expect("mod.index should deserialize") + } else { + ModPakRepoIndex::new() + }; + for module in modules { + index + .index_module(module, &module.replace('.', "/"), "any", "noarch", "demo module", false, "deadbeef", None, None) + .expect("module should index"); + } + fs::write(root.join("mod.index"), index.to_yaml().expect("mod.index should serialize")).expect("mod.index should write"); +} + +fn add_library_tree(repo: &mut SysInspectModPak, root: &Path, rel: &str) { + let file = root.join("lib").join(rel); + write_file(&file, rel.as_bytes()); + repo.add_library(root.to_path_buf()).expect("library should be added"); +} + +async fn start_fileserver(root: PathBuf) -> (u16, tokio::task::JoinHandle<()>) { + let listener = TcpListener::bind("127.0.0.1:0").await.expect("listener should bind"); + let port = listener.local_addr().expect("listener addr should exist").port(); + let handle = tokio::spawn(async move { + loop { + let Ok((mut stream, _)) = listener.accept().await else { break }; + let root = root.clone(); + tokio::spawn(async move { + let mut buf = [0_u8; 4096]; + let Ok(n) = stream.read(&mut buf).await else { return }; + let req = String::from_utf8_lossy(&buf[..n]); + let path = req + .lines() + .next() + .and_then(|line| line.split_whitespace().nth(1)) + .unwrap_or("/"); + let file = root.join(path.trim_start_matches('/')); + let response = match fs::read(&file) { + Ok(body) => format!("HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", body.len()).into_bytes(), + Err(_) => b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n".to_vec(), + }; + let body = fs::read(&file).unwrap_or_default(); + let _ = stream.write_all(&response).await; + if !body.is_empty() { + let _ = stream.write_all(&body).await; + } + }); + } + }); + (port, handle) +} + +fn configured_minion(root: &Path, share: &Path, port: u16) -> MinionConfig { + let mut cfg = MinionConfig::default(); + cfg.set_root_dir(root.to_str().expect("root path should be valid")); + cfg.set_sharelib_path(share.to_str().expect("share path should be valid")); + cfg.set_master_ip("127.0.0.1"); + cfg.set_master_fileserver_port(port.into()); + cfg +} + +#[tokio::test] +async fn narrow_profile_syncs_only_allowed_artifacts_and_removes_old_ones() { + let master = tempfile::tempdir().expect("master tempdir should be created"); + let mut repo = SysInspectModPak::new(master.path().join("data/repo")).expect("repo should be created"); + let libs = tempfile::tempdir().expect("library tempdir should be created"); + add_library_tree(&mut repo, libs.path(), "runtime/lua/alpha.lua"); + add_library_tree(&mut repo, libs.path(), "runtime/lua/beta.lua"); + add_script_module(&master.path().join("data/repo"), "alpha.demo", "# alpha"); + add_script_module(&master.path().join("data/repo"), "beta.demo", "# beta"); + set_script_modules(&master.path().join("data/repo"), &["alpha.demo", "beta.demo"]); + repo.new_profile("Alpha").expect("Alpha should be created"); + repo.new_profile("Beta").expect("Beta should be created"); + repo.add_profile_matches("Alpha", vec!["alpha.demo".to_string()], false).expect("Alpha module selector should be added"); + repo.add_profile_matches("Alpha", vec!["lib/runtime/lua/alpha.lua".to_string()], true).expect("Alpha library selector should be added"); + repo.add_profile_matches("Beta", vec!["beta.demo".to_string()], false).expect("Beta module selector should be added"); + repo.add_profile_matches("Beta", vec!["lib/runtime/lua/beta.lua".to_string()], true).expect("Beta library selector should be added"); + + let (port, server) = start_fileserver(master.path().join("data")).await; + let minion = tempfile::tempdir().expect("minion tempdir should be created"); + let share = tempfile::tempdir().expect("share tempdir should be created"); + let cfg = configured_minion(minion.path(), share.path(), port); + fs::create_dir_all(cfg.traits_dir()).expect("traits dir should be created"); + ensure_master_traits_file(&cfg).expect("master traits file should exist"); + TraitUpdateRequest::from_context(r#"{"op":"set","traits":{"minion.profile":["Alpha"]}}"#) + .expect("set request should parse") + .apply(&cfg) + .expect("set request should apply"); + + SysInspectModPakMinion::new(cfg.clone()).sync().await.expect("first sync should work"); + assert!(share.path().join("modules/alpha/demo").exists()); + assert!(!share.path().join("modules/beta/demo").exists()); + assert!(share.path().join("lib/lib/runtime/lua/alpha.lua").exists()); + assert!(!share.path().join("lib/lib/runtime/lua/beta.lua").exists()); + + TraitUpdateRequest::from_context(r#"{"op":"set","traits":{"minion.profile":["Beta"]}}"#) + .expect("set request should parse") + .apply(&cfg) + .expect("set request should apply"); + SysInspectModPakMinion::new(cfg).sync().await.expect("second sync should work"); + assert!(!share.path().join("modules/alpha/demo").exists()); + assert!(share.path().join("modules/beta/demo").exists()); + assert!(!share.path().join("lib/lib/runtime/lua/alpha.lua").exists()); + assert!(share.path().join("lib/lib/runtime/lua/beta.lua").exists()); + + server.abort(); +} + +#[tokio::test] +async fn overlapping_multi_profile_sync_merges_by_union_and_dedup() { + let master = tempfile::tempdir().expect("master tempdir should be created"); + let repo = SysInspectModPak::new(master.path().join("data/repo")).expect("repo should be created"); + add_script_module(&master.path().join("data/repo"), "alpha.demo", "# alpha"); + add_script_module(&master.path().join("data/repo"), "beta.demo", "# beta"); + add_script_module(&master.path().join("data/repo"), "gamma.demo", "# gamma"); + set_script_modules(&master.path().join("data/repo"), &["alpha.demo", "beta.demo", "gamma.demo"]); + repo.new_profile("One").expect("One should be created"); + repo.new_profile("Two").expect("Two should be created"); + repo.add_profile_matches("One", vec!["alpha.demo".to_string(), "beta.demo".to_string()], false).expect("One selectors should be added"); + repo.add_profile_matches("Two", vec!["beta.demo".to_string(), "gamma.demo".to_string()], false).expect("Two selectors should be added"); + + let (port, server) = start_fileserver(master.path().join("data")).await; + let minion = tempfile::tempdir().expect("minion tempdir should be created"); + let share = tempfile::tempdir().expect("share tempdir should be created"); + let cfg = configured_minion(minion.path(), share.path(), port); + fs::create_dir_all(cfg.traits_dir()).expect("traits dir should be created"); + ensure_master_traits_file(&cfg).expect("master traits file should exist"); + TraitUpdateRequest::from_context(r#"{"op":"set","traits":{"minion.profile":["One","Two","One"]}}"#) + .expect("set request should parse") + .apply(&cfg) + .expect("set request should apply"); + + SysInspectModPakMinion::new(cfg).sync().await.expect("sync should work"); + assert!(share.path().join("modules/alpha/demo").exists()); + assert!(share.path().join("modules/beta/demo").exists()); + assert!(share.path().join("modules/gamma/demo").exists()); + + server.abort(); +} + +#[tokio::test] +async fn sync_fails_if_effective_profiles_are_missing_from_profiles_index() { + let master = tempfile::tempdir().expect("master tempdir should be created"); + let repo = SysInspectModPak::new(master.path().join("data/repo")).expect("repo should be created"); + add_script_module(&master.path().join("data/repo"), "alpha.demo", "# alpha"); + set_script_modules(&master.path().join("data/repo"), &["alpha.demo"]); + repo.new_profile("Existing").expect("Existing should be created"); + repo.add_profile_matches("Existing", vec!["alpha.demo".to_string()], false).expect("Existing selector should be added"); + + let (port, server) = start_fileserver(master.path().join("data")).await; + let minion = tempfile::tempdir().expect("minion tempdir should be created"); + let share = tempfile::tempdir().expect("share tempdir should be created"); + let cfg = configured_minion(minion.path(), share.path(), port); + fs::create_dir_all(cfg.traits_dir()).expect("traits dir should be created"); + ensure_master_traits_file(&cfg).expect("master traits file should exist"); + TraitUpdateRequest::from_context(r#"{"op":"set","traits":{"minion.profile":["Missing"]}}"#) + .expect("set request should parse") + .apply(&cfg) + .expect("set request should apply"); + + let err = SysInspectModPakMinion::new(cfg).sync().await.expect_err("sync should fail when effective profiles are missing"); + assert!(err.to_string().contains("Missing")); + + server.abort(); +} + +#[tokio::test] +async fn sync_rejects_profile_paths_with_traversal_components() { + let master = tempfile::tempdir().expect("master tempdir should be created"); + fs::create_dir_all(master.path().join("data")).expect("data dir should be created"); + fs::write( + master.path().join("data/profiles.index"), + "profiles:\n Escape:\n file: ../escape.profile\n checksum: deadbeef\n", + ) + .expect("profiles index should be written"); + + let (port, server) = start_fileserver(master.path().join("data")).await; + let minion = tempfile::tempdir().expect("minion tempdir should be created"); + let share = tempfile::tempdir().expect("share tempdir should be created"); + let cfg = configured_minion(minion.path(), share.path(), port); + fs::create_dir_all(cfg.traits_dir()).expect("traits dir should be created"); + ensure_master_traits_file(&cfg).expect("master traits file should exist"); + TraitUpdateRequest::from_context(r#"{"op":"set","traits":{"minion.profile":["Escape"]}}"#) + .expect("set request should parse") + .apply(&cfg) + .expect("set request should apply"); + + let err = SysInspectModPakMinion::new(cfg).sync().await.expect_err("sync should fail on path traversal"); + assert!(err.to_string().contains("Invalid profile path")); + + server.abort(); +} + +#[tokio::test] +async fn sync_fails_if_downloaded_profile_checksum_does_not_match_index() { + let master = tempfile::tempdir().expect("master tempdir should be created"); + let repo = SysInspectModPak::new(master.path().join("data/repo")).expect("repo should be created"); + add_script_module(&master.path().join("data/repo"), "alpha.demo", "# alpha"); + set_script_modules(&master.path().join("data/repo"), &["alpha.demo"]); + repo.new_profile("Broken").expect("Broken should be created"); + repo.add_profile_matches("Broken", vec!["alpha.demo".to_string()], false) + .expect("Broken selector should be added"); + fs::write( + master.path().join("data/profiles.index"), + "profiles:\n Broken:\n file: broken.profile\n checksum: deadbeef\n", + ) + .expect("profiles index should be overwritten"); + + let (port, server) = start_fileserver(master.path().join("data")).await; + let minion = tempfile::tempdir().expect("minion tempdir should be created"); + let share = tempfile::tempdir().expect("share tempdir should be created"); + let cfg = configured_minion(minion.path(), share.path(), port); + fs::create_dir_all(cfg.traits_dir()).expect("traits dir should be created"); + ensure_master_traits_file(&cfg).expect("master traits file should exist"); + TraitUpdateRequest::from_context(r#"{"op":"set","traits":{"minion.profile":["Broken"]}}"#) + .expect("set request should parse") + .apply(&cfg) + .expect("set request should apply"); + + let err = SysInspectModPakMinion::new(cfg).sync().await.expect_err("sync should fail on profile checksum mismatch"); + assert!(err.to_string().contains("Checksum mismatch for profile")); + + server.abort(); +} diff --git a/libsysinspect/Cargo.toml b/libsysinspect/Cargo.toml index 02975cbe..8e191faf 100644 --- a/libsysinspect/Cargo.toml +++ b/libsysinspect/Cargo.toml @@ -20,6 +20,7 @@ prettytable-rs = "0.10.0" rand = "0.8.5" regex = "1.12.2" rsa = { version = "0.9.10", features = ["pkcs5", "sha1", "sha2"] } +sodiumoxide = "0.2.7" libcommon = { path = "../libcommon" } libsysproto = { path = "../libsysproto" } diff --git a/libsysinspect/src/cfg/mmconf.rs b/libsysinspect/src/cfg/mmconf.rs index 56e4832b..c3ae5c80 100644 --- a/libsysinspect/src/cfg/mmconf.rs +++ b/libsysinspect/src/cfg/mmconf.rs @@ -21,6 +21,9 @@ pub static DEFAULT_FILESERVER_PORT: u32 = 4201; /// Default API port for the web API pub static DEFAULT_API_PORT: u32 = 4202; +/// Default port for the local console/control endpoint on the master +pub static DEFAULT_CONSOLE_PORT: u32 = 4203; + // Default directories // -------------------- @@ -98,11 +101,15 @@ pub static CFG_TRAIT_FUNCTIONS_ROOT: &str = "functions"; /// Directory within the `DEFAULT_MODULES_SHARELIB` for sensors pub static CFG_SENSORS_ROOT: &str = "sensors"; +pub static CFG_PROFILES_ROOT: &str = "profiles"; // Key names // --------- pub static CFG_MASTER_KEY_PUB: &str = "master.rsa.pub"; pub static CFG_MASTER_KEY_PRI: &str = "master.rsa"; +pub static CFG_CONSOLE_KEY_PUB: &str = "console.rsa.pub"; +pub static CFG_CONSOLE_KEY_PRI: &str = "console.rsa"; +pub static CFG_CONSOLE_KEYS: &str = "console-keys"; pub static CFG_MINION_RSA_PUB: &str = "minion.rsa.pub"; pub static CFG_MINION_RSA_PRV: &str = "minion.rsa"; @@ -247,7 +254,7 @@ impl TelemetryConfig { ("service.namespace", CFG_OTLP_SERVICE_NAME), ("service.version", CFG_OTLP_SERVICE_VERSION), ("host.name", sysinfo::System::host_name().unwrap_or_default().as_str()), - ("os.type", sysinfo::System::distribution_id().as_str()), + ("os.type", crate::traits::current_os_type()), ("deployment.environment", "production"), ("os.version", sysinfo::System::kernel_version().unwrap_or_default().as_str()), ] { @@ -459,6 +466,11 @@ impl MinionConfig { self.root = Some(dir.to_string()); } + /// Set master fileserver port + pub fn set_master_fileserver_port(&mut self, port: u32) { + self.master_fileserver_port = Some(port); + } + /// Set sharelib path pub fn set_sharelib_path(&mut self, p: &str) { self.sharelib_path = Some(p.to_string()); @@ -508,6 +520,11 @@ impl MinionConfig { self.root_dir().join(CFG_SENSORS_ROOT) } + /// Get root directory for synced deployment profiles + pub fn profiles_dir(&self) -> PathBuf { + self.root_dir().join(CFG_PROFILES_ROOT) + } + /// Return machine Id path pub fn machine_id_path(&self) -> PathBuf { if let Some(mid) = self.machine_id.clone() { @@ -747,6 +764,12 @@ pub struct MasterConfig { // Path to FIFO socket. Default: /var/run/sysinspect-master.socket socket: Option, + #[serde(rename = "console.bind.ip")] + console_ip: Option, + + #[serde(rename = "console.bind.port")] + console_port: Option, + #[serde(rename = "fileserver.bind.ip")] fsr_ip: Option, @@ -913,6 +936,28 @@ impl MasterConfig { self.socket.to_owned().unwrap_or(DEFAULT_SOCKET.to_string()) } + /// Return console listener address for `sysmaster`. + pub fn console_listen_addr(&self) -> String { + format!( + "{}:{}", + self.console_ip.to_owned().unwrap_or("127.0.0.1".to_string()), + self.console_port.unwrap_or(DEFAULT_CONSOLE_PORT) + ) + } + + /// Return console connect address for `sysinspect`. + /// + /// If the console listener is configured as `0.0.0.0`, clients still + /// connect through loopback. + pub fn console_connect_addr(&self) -> String { + format!( + "{}:{}", + if self.console_ip.as_deref() == Some("0.0.0.0") { "127.0.0.1".to_string() } else { self.console_ip.to_owned().unwrap_or("127.0.0.1".to_string()) }, + self.console_port.unwrap_or(DEFAULT_CONSOLE_PORT) + ) + } + + /// Get API enabled status (default: true) pub fn api_enabled(&self) -> bool { self.api_enabled.unwrap_or(true) @@ -994,6 +1039,11 @@ impl MasterConfig { } } + /// Get deployment profiles root on the fileserver + pub fn fileserver_profiles_root(&self) -> PathBuf { + self.fileserver_root().join(CFG_PROFILES_ROOT) + } + /// Get default sysinspect root. For master it is always /etc/sysinspect pub fn root_dir(&self) -> PathBuf { PathBuf::from(DEFAULT_SYSINSPECT_ROOT.to_string()) @@ -1013,6 +1063,18 @@ impl MasterConfig { self.root_dir().join(CFG_API_KEYS) } + pub fn console_keys_root(&self) -> PathBuf { + self.root_dir().join(CFG_CONSOLE_KEYS) + } + + pub fn console_privkey(&self) -> PathBuf { + self.root_dir().join(CFG_CONSOLE_KEY_PRI) + } + + pub fn console_pubkey(&self) -> PathBuf { + self.root_dir().join(CFG_CONSOLE_KEY_PUB) + } + /// Return a pidfile. Either from config or default. /// The default pidfile conforms to POSIX at /run/user//.... pub fn pidfile(&self) -> PathBuf { diff --git a/libsysinspect/src/cfg/mmconf_ut.rs b/libsysinspect/src/cfg/mmconf_ut.rs new file mode 100644 index 00000000..29219ff5 --- /dev/null +++ b/libsysinspect/src/cfg/mmconf_ut.rs @@ -0,0 +1,44 @@ +use super::mmconf::{DEFAULT_CONSOLE_PORT, MasterConfig}; +use std::{fs, time::{SystemTime, UNIX_EPOCH}}; + +fn write_master_cfg(contents: &str) -> std::path::PathBuf { + let base = std::env::temp_dir().join(format!( + "sysinspect-mmconf-ut-{}-{}", + std::process::id(), + SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_nanos() + )); + fs::create_dir_all(&base).unwrap(); + let path = base.join("sysinspect.conf"); + fs::write(&path, contents).unwrap(); + path +} + +#[test] +fn master_console_defaults_are_used_when_not_configured() { + let cfg = MasterConfig::new(write_master_cfg("config:\n master:\n fileserver.models: []\n")).unwrap(); + + assert_eq!(cfg.console_listen_addr(), format!("127.0.0.1:{DEFAULT_CONSOLE_PORT}")); + assert_eq!(cfg.console_connect_addr(), format!("127.0.0.1:{DEFAULT_CONSOLE_PORT}")); +} + +#[test] +fn master_console_config_overrides_defaults() { + let cfg = MasterConfig::new(write_master_cfg( + "config:\n master:\n fileserver.models: []\n console.bind.ip: 127.0.0.1\n console.bind.port: 5511\n", + )) + .unwrap(); + + assert_eq!(cfg.console_listen_addr(), "127.0.0.1:5511"); + assert_eq!(cfg.console_connect_addr(), "127.0.0.1:5511"); +} + +#[test] +fn master_console_connect_addr_rewrites_wildcard_bind_to_loopback() { + let cfg = MasterConfig::new(write_master_cfg( + "config:\n master:\n fileserver.models: []\n console.bind.ip: 0.0.0.0\n console.bind.port: 5511\n", + )) + .unwrap(); + + assert_eq!(cfg.console_listen_addr(), "0.0.0.0:5511"); + assert_eq!(cfg.console_connect_addr(), "127.0.0.1:5511"); +} diff --git a/libsysinspect/src/cfg/mod.rs b/libsysinspect/src/cfg/mod.rs index 0da06578..6b5735b7 100644 --- a/libsysinspect/src/cfg/mod.rs +++ b/libsysinspect/src/cfg/mod.rs @@ -3,6 +3,8 @@ Config reader */ pub mod mmconf; +#[cfg(test)] +mod mmconf_ut; use libcommon::SysinspectError; use mmconf::MinionConfig; diff --git a/libsysinspect/src/console/console_ut.rs b/libsysinspect/src/console/console_ut.rs new file mode 100644 index 00000000..9b2700c9 --- /dev/null +++ b/libsysinspect/src/console/console_ut.rs @@ -0,0 +1,73 @@ +use super::{ConsoleBootstrap, ConsoleQuery, ConsoleSealed, ensure_console_keypair}; +use crate::{ + cfg::mmconf::{CFG_MASTER_KEY_PRI, CFG_MASTER_KEY_PUB}, + rsa::keys::{RsaKey::{Private, Public}, key_to_file, keygen}, +}; +use rsa::traits::PublicKeyParts; +use sodiumoxide::crypto::secretbox; +use tempfile::tempdir; + +#[test] +fn console_bootstrap_roundtrips_session_key() { + let root = tempdir().unwrap(); + let (master_prk, master_pbk) = keygen(crate::rsa::keys::DEFAULT_KEY_SIZE).unwrap(); + key_to_file(&Private(master_prk.clone()), root.path().to_str().unwrap_or_default(), CFG_MASTER_KEY_PRI).unwrap(); + key_to_file(&Public(master_pbk.clone()), root.path().to_str().unwrap_or_default(), CFG_MASTER_KEY_PUB).unwrap(); + + let (client_prk, client_pbk) = ensure_console_keypair(root.path()).unwrap(); + let symkey = secretbox::gen_key(); + let bootstrap = ConsoleBootstrap::new(&client_prk, &client_pbk, &master_pbk, &symkey).unwrap(); + let (opened, _) = bootstrap.session_key(&master_prk).unwrap(); + + assert_eq!(opened.0.to_vec(), symkey.0.to_vec()); +} + +#[test] +fn console_sealed_roundtrips_payload() { + let payload = ConsoleQuery { + model: "cmd://cluster/sync".to_string(), + query: "*".to_string(), + traits: "".to_string(), + mid: "".to_string(), + context: "{\"op\":\"reset\",\"traits\":{}}".to_string(), + }; + let key = secretbox::gen_key(); + let sealed = ConsoleSealed::seal(&payload, &key).unwrap(); + let opened: ConsoleQuery = sealed.open(&key).unwrap(); + + assert_eq!(opened.model, payload.model); + assert_eq!(opened.query, payload.query); + assert_eq!(opened.context, payload.context); +} + +#[test] +fn ensure_console_keypair_recovers_missing_public_key_from_private_key() { + let root = tempdir().unwrap(); + let (client_prk, _) = keygen(crate::rsa::keys::DEFAULT_KEY_SIZE).unwrap(); + key_to_file(&Private(client_prk.clone()), root.path().to_str().unwrap_or_default(), crate::cfg::mmconf::CFG_CONSOLE_KEY_PRI).unwrap(); + + let (loaded_prk, loaded_pbk) = ensure_console_keypair(root.path()).unwrap(); + + assert_eq!(loaded_prk.n().to_bytes_be(), client_prk.n().to_bytes_be()); + assert!(root.path().join(crate::cfg::mmconf::CFG_CONSOLE_KEY_PUB).exists()); + assert_eq!(loaded_pbk.n().to_bytes_be(), client_prk.n().to_bytes_be()); +} + +#[cfg(unix)] +#[test] +fn ensure_console_keypair_sets_restrictive_permissions() { + use std::os::unix::fs::PermissionsExt; + + let root = tempdir().unwrap(); + let _ = ensure_console_keypair(root.path()).unwrap(); + + let dir_mode = std::fs::metadata(root.path()).unwrap().permissions().mode() & 0o777; + let key_mode = std::fs::metadata(root.path().join(crate::cfg::mmconf::CFG_CONSOLE_KEY_PRI)) + .unwrap() + .permissions() + .mode() + & 0o777; + + assert_eq!(dir_mode, 0o700); + assert_eq!(key_mode, 0o600); +} diff --git a/libsysinspect/src/console/mod.rs b/libsysinspect/src/console/mod.rs new file mode 100644 index 00000000..0993a8a3 --- /dev/null +++ b/libsysinspect/src/console/mod.rs @@ -0,0 +1,298 @@ +//! Encrypted console transport primitives shared by `sysinspect` and `sysmaster`. + +use base64::{Engine, engine::general_purpose::STANDARD}; +use libcommon::SysinspectError; +use rsa::{RsaPrivateKey, RsaPublicKey}; +use serde::{Deserialize, Serialize, de::DeserializeOwned}; +use sodiumoxide::crypto::secretbox::{self, Key, Nonce}; +use std::{ + fs, + path::{Path, PathBuf}, + sync::OnceLock, +}; + +use crate::{ + cfg::mmconf::{CFG_CONSOLE_KEY_PRI, CFG_CONSOLE_KEY_PUB, MasterConfig}, + rsa::keys::{ + RsaKey::{Private, Public}, + decrypt, encrypt, key_from_file, key_to_file, keygen, sign_data, to_pem, verify_sign, + }, +}; + +#[cfg(test)] +mod console_ut; + +static SODIUM_INIT: OnceLock<()> = OnceLock::new(); +const CONSOLE_KEY_SIZE: usize = 2048; + +/// RSA-bootstrapped session bootstrap data sent before opening the sealed console payload. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ConsoleBootstrap { + /// Client console public key in PEM format. + pub client_pubkey: String, + /// Session key encrypted to the master's RSA public key. + pub symkey_cipher: String, + /// Signature over the raw session key bytes using the client RSA private key. + pub symkey_sign: String, +} + +/// Symmetrically encrypted console frame payload. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ConsoleSealed { + /// Base64-encoded libsodium nonce. + pub nonce: String, + /// Base64-encoded libsodium `secretbox` payload. + pub payload: String, +} + +/// Full console request envelope containing the RSA bootstrap and sealed request. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ConsoleEnvelope { + /// Bootstrap data used to derive the symmetric session key. + pub bootstrap: ConsoleBootstrap, + /// Encrypted request payload. + pub sealed: ConsoleSealed, +} + +/// Structured console request sent from `sysinspect` to `sysmaster`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ConsoleQuery { + /// Requested model or command URI. + pub model: String, + /// Target query string or hostname glob. + pub query: String, + /// Optional traits selector expression. + pub traits: String, + /// Optional direct minion System Id target. + pub mid: String, + /// Optional JSON-encoded context payload. + pub context: String, +} + +/// Structured console response returned by `sysmaster`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ConsoleResponse { + /// Response success flag. + pub ok: bool, + /// Human-readable response message or payload. + pub message: String, +} + +/// Ensure the local libsodium state is initialised once for console sealing operations. +fn sodium_ready() -> Result<(), SysinspectError> { + if SODIUM_INIT.get().is_some() { + return Ok(()); + } + if sodiumoxide::init().is_err() { + return Err(SysinspectError::ConfigError("Failed to initialise libsodium".to_string())); + } + let _ = SODIUM_INIT.set(()); + Ok(()) +} + +fn console_keypair(root: &Path) -> (PathBuf, PathBuf) { + (root.join(CFG_CONSOLE_KEY_PRI), root.join(CFG_CONSOLE_KEY_PUB)) +} + +fn ensure_console_permissions(root: &Path, prk_path: &Path) -> Result<(), SysinspectError> { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + + let mut root_perms = fs::metadata(root).map_err(SysinspectError::IoErr)?.permissions(); + root_perms.set_mode(0o700); + fs::set_permissions(root, root_perms).map_err(SysinspectError::IoErr)?; + + if prk_path.exists() { + let mut prk_perms = fs::metadata(prk_path).map_err(SysinspectError::IoErr)?.permissions(); + prk_perms.set_mode(0o600); + fs::set_permissions(prk_path, prk_perms).map_err(SysinspectError::IoErr)?; + } + } + + Ok(()) +} + +/// Ensure a console RSA keypair exists under the given root and return it. +pub fn ensure_console_keypair(root: &Path) -> Result<(RsaPrivateKey, RsaPublicKey), SysinspectError> { + let (prk_path, pbk_path) = console_keypair(root); + match (prk_path.exists(), pbk_path.exists()) { + (true, true) => { + let prk = load_private_key(&prk_path)?; + let pbk = load_public_key(&pbk_path)?; + ensure_console_permissions(root, &prk_path)?; + Ok((prk, pbk)) + } + (true, false) => { + fs::create_dir_all(root).map_err(SysinspectError::IoErr)?; + let prk = load_private_key(&prk_path)?; + let pbk = RsaPublicKey::from(&prk); + key_to_file(&Public(pbk.clone()), root.to_str().unwrap_or_default(), CFG_CONSOLE_KEY_PUB)?; + ensure_console_permissions(root, &prk_path)?; + Ok((prk, pbk)) + } + (false, true) => Err(SysinspectError::ConfigError(format!( + "Console public key exists at {} but private key is missing at {}. Remove the stale public key so a new console keypair can be generated.", + pbk_path.display(), + prk_path.display() + ))), + (false, false) => { + fs::create_dir_all(root).map_err(SysinspectError::IoErr)?; + let (prk, pbk) = keygen(CONSOLE_KEY_SIZE).map_err(|e| SysinspectError::RSAError(e.to_string()))?; + key_to_file(&Private(prk.clone()), root.to_str().unwrap_or_default(), CFG_CONSOLE_KEY_PRI)?; + key_to_file(&Public(pbk.clone()), root.to_str().unwrap_or_default(), CFG_CONSOLE_KEY_PUB)?; + ensure_console_permissions(root, &prk_path)?; + Ok((prk, pbk)) + } + } +} + +/// Load the master's public RSA key used for console session bootstrap. +pub fn load_master_public_key(cfg: &MasterConfig) -> Result { + load_public_key(&cfg.root_dir().join(crate::cfg::mmconf::CFG_MASTER_KEY_PUB)) +} + +/// Load the master's private RSA key used for console session bootstrap. +pub fn load_master_private_key(cfg: &MasterConfig) -> Result { + load_private_key(&cfg.root_dir().join(crate::cfg::mmconf::CFG_MASTER_KEY_PRI)) +} + +fn load_private_key(path: &Path) -> Result { + match key_from_file(path.to_str().unwrap_or_default())? { + Some(Private(prk)) => Ok(prk), + Some(_) => Err(SysinspectError::RSAError(format!("Expected private key at {}", path.display()))), + None => Err(SysinspectError::RSAError(format!("Private key not found at {}", path.display()))), + } +} + +fn load_public_key(path: &Path) -> Result { + match key_from_file(path.to_str().unwrap_or_default())? { + Some(Public(pbk)) => Ok(pbk), + Some(_) => Err(SysinspectError::RSAError(format!("Expected public key at {}", path.display()))), + None => Err(SysinspectError::RSAError(format!("Public key not found at {}", path.display()))), + } +} + +impl ConsoleBootstrap { + /// Build bootstrap material for a new console session. + pub fn new(client_prk: &RsaPrivateKey, client_pbk: &RsaPublicKey, master_pbk: &RsaPublicKey, symkey: &Key) -> Result { + Ok(Self { + client_pubkey: to_pem(None, Some(client_pbk)) + .map_err(|e| SysinspectError::RSAError(e.to_string()))? + .1 + .unwrap_or_default(), + symkey_cipher: STANDARD.encode( + encrypt(master_pbk.clone(), symkey.0.to_vec()) + .map_err(|_| SysinspectError::RSAError("Failed to encrypt console session key".to_string()))?, + ), + symkey_sign: STANDARD.encode( + sign_data(client_prk.clone(), &symkey.0) + .map_err(|_| SysinspectError::RSAError("Failed to sign console session key".to_string()))?, + ), + }) + } + + /// Recover and verify the console session key from the bootstrap payload. + pub fn session_key(&self, master_prk: &RsaPrivateKey) -> Result<(Key, RsaPublicKey), SysinspectError> { + let client_pbk = crate::rsa::keys::from_pem(None, Some(&self.client_pubkey)) + .map_err(|e| SysinspectError::RSAError(e.to_string()))? + .1 + .ok_or_else(|| SysinspectError::RSAError("Client public key missing from console bootstrap".to_string()))?; + let symkey = decrypt( + master_prk.clone(), + STANDARD + .decode(&self.symkey_cipher) + .map_err(|e| SysinspectError::SerializationError(format!("Failed to decode console session key: {e}")))?, + ) + .map_err(|_| SysinspectError::RSAError("Failed to decrypt console session key".to_string()))?; + let signature = STANDARD + .decode(&self.symkey_sign) + .map_err(|e| SysinspectError::SerializationError(format!("Failed to decode console session signature: {e}")))?; + + if !verify_sign(&client_pbk, &symkey, signature).map_err(|e| SysinspectError::RSAError(e.to_string()))? { + return Err(SysinspectError::RSAError("Console session signature verification failed".to_string())); + } + + Ok(( + Key::from_slice(&symkey).ok_or_else(|| SysinspectError::RSAError("Console session key has invalid size".to_string()))?, + client_pbk, + )) + } +} + +impl ConsoleSealed { + /// Seal a serializable console payload with the given symmetric session key. + pub fn seal(payload: &T, key: &Key) -> Result { + sodium_ready()?; + let nonce = secretbox::gen_nonce(); + Ok(Self { + nonce: STANDARD.encode(nonce.0), + payload: STANDARD.encode(secretbox::seal( + &serde_json::to_vec(payload).map_err(|e| SysinspectError::SerializationError(e.to_string()))?, + &nonce, + key, + )), + }) + } + + /// Open a sealed console payload with the given symmetric session key. + pub fn open(&self, key: &Key) -> Result { + sodium_ready()?; + let nonce = Nonce::from_slice( + &STANDARD + .decode(&self.nonce) + .map_err(|e| SysinspectError::SerializationError(format!("Failed to decode console nonce: {e}")))?, + ) + .ok_or_else(|| SysinspectError::SerializationError("Console nonce has invalid size".to_string()))?; + let payload = secretbox::open( + &STANDARD + .decode(&self.payload) + .map_err(|e| SysinspectError::SerializationError(format!("Failed to decode console payload: {e}")))?, + &nonce, + key, + ) + .map_err(|_| SysinspectError::RSAError("Failed to decrypt console payload".to_string()))?; + serde_json::from_slice(&payload).map_err(|e| SysinspectError::DeserializationError(e.to_string())) + } +} + +/// Check whether the provided client console public key is authorised by the master. +pub fn authorised_console_client(cfg: &MasterConfig, client_pem: &str) -> Result { + let client_pem = client_pem.trim(); + if cfg.console_pubkey().exists() + && fs::read_to_string(cfg.console_pubkey()).map_err(SysinspectError::IoErr)?.trim() == client_pem + { + return Ok(true); + } + + let root = cfg.console_keys_root(); + if !root.exists() { + return Ok(false); + } + + for entry in fs::read_dir(root).map_err(SysinspectError::IoErr)? { + let path = entry.map_err(SysinspectError::IoErr)?.path(); + if path.is_file() + && fs::read_to_string(&path).map_err(SysinspectError::IoErr)?.trim() == client_pem + { + return Ok(true); + } + } + + Ok(false) +} + +/// Build a fully bootstrapped encrypted console request envelope for the given query. +pub fn build_console_query(root: &Path, cfg: &MasterConfig, query: &ConsoleQuery) -> Result<(ConsoleEnvelope, Key), SysinspectError> { + sodium_ready()?; + let (client_prk, client_pbk) = ensure_console_keypair(root)?; + let master_pbk = load_master_public_key(cfg)?; + let key = secretbox::gen_key(); + Ok(( + ConsoleEnvelope { + bootstrap: ConsoleBootstrap::new(&client_prk, &client_pbk, &master_pbk, &key)?, + sealed: ConsoleSealed::seal(query, &key)?, + }, + key, + )) +} diff --git a/libsysinspect/src/context/mod.rs b/libsysinspect/src/context/mod.rs index d2e63876..ddade778 100644 --- a/libsysinspect/src/context/mod.rs +++ b/libsysinspect/src/context/mod.rs @@ -1,9 +1,12 @@ +//! Shared context parsing and small request payload types used by CLI and console paths. + pub mod host; #[cfg(test)] mod host_ut; use indexmap::IndexMap; +use serde::{Deserialize, Serialize}; use serde_json::{Number, Value}; /// Parse a string into a serde_json::Value @@ -43,7 +46,11 @@ fn get_json_value(s: &str) -> Value { Value::String(s.to_string()) } -/// Get context data from a string +/// Parse comma-separated `key:value` pairs into a typed JSON map. +/// +/// Values are interpreted as JSON-like scalars where possible: +/// `null`, booleans, integers, floats, and quoted strings. Everything +/// else is kept as a plain string. pub fn get_context(c: &str) -> Option> { let c = c.trim(); if c.is_empty() { @@ -67,3 +74,54 @@ pub fn get_context(c: &str) -> Option> { Some(c) } + +/// Parse comma-separated keys from a string. +pub fn get_context_keys(c: &str) -> Vec { + c.trim().split(',').map(str::trim).filter(|s| !s.is_empty()).map(str::to_string).collect() +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +/// Console request payload used for profile management operations. +pub struct ProfileConsoleRequest { + op: String, + #[serde(default)] + name: String, + #[serde(default)] + matches: Vec, + #[serde(default)] + library: bool, + #[serde(default)] + profiles: Vec, +} + +impl ProfileConsoleRequest { + /// Parse a profile console request from the JSON context payload. + pub fn from_context(context: &str) -> Result { + serde_json::from_str(context) + } + + /// Return the requested profile operation name. + pub fn op(&self) -> &str { + &self.op + } + + /// Return the target profile name, if present. + pub fn name(&self) -> &str { + &self.name + } + + /// Return the module or library selector list carried by the request. + pub fn matches(&self) -> &[String] { + &self.matches + } + + /// Return whether the request targets library selectors instead of module selectors. + pub fn library(&self) -> bool { + self.library + } + + /// Return the profile names carried by tag or untag requests. + pub fn profiles(&self) -> &[String] { + &self.profiles + } +} diff --git a/libsysinspect/src/lib.rs b/libsysinspect/src/lib.rs index 043cb87b..44ee13a8 100644 --- a/libsysinspect/src/lib.rs +++ b/libsysinspect/src/lib.rs @@ -1,4 +1,5 @@ pub mod cfg; +pub mod console; pub mod context; pub mod inspector; pub mod intp; diff --git a/libsysinspect/src/traits/mod.rs b/libsysinspect/src/traits/mod.rs index cca66947..6fd411b1 100644 --- a/libsysinspect/src/traits/mod.rs +++ b/libsysinspect/src/traits/mod.rs @@ -1,31 +1,56 @@ +//! Trait parsing, loading, and master-managed static trait helpers. + pub mod systraits; use crate::cfg::mmconf::MinionConfig; -use indexmap::IndexMap; +use indexmap::{IndexMap, IndexSet}; use libcommon::SysinspectError; use once_cell::sync::OnceCell; use pest::Parser; use pest_derive::Parser; +use serde::Deserialize; use serde_json::Value; +use std::fs; use systraits::SystemTraits; +#[cfg(test)] +mod traits_ut; + /// Standard Traits +/// Stable System Id trait key. pub static SYS_ID: &str = "system.id"; +/// Operating system kernel trait key. pub static SYS_OS_KERNEL: &str = "system.kernel"; +/// Operating system version trait key. pub static SYS_OS_VERSION: &str = "system.os.version"; +/// Operating system name trait key. pub static SYS_OS_NAME: &str = "system.os.name"; +/// Operating system distribution trait key. pub static SYS_OS_DISTRO: &str = "system.os.distribution"; +/// Hostname trait key. pub static SYS_NET_HOSTNAME: &str = "system.hostname"; +/// FQDN hostname trait key. pub static SYS_NET_HOSTNAME_FQDN: &str = "system.hostname.fqdn"; +/// Primary hostname IP trait key. pub static SYS_NET_HOSTNAME_IP: &str = "system.hostname.ip"; +/// Memory trait key. pub static HW_MEM: &str = "hardware.memory"; +/// Swap trait key. pub static HW_SWAP: &str = "hardware.swap"; +/// CPU count trait key. pub static HW_CPU_TOTAL: &str = "hardware.cpu.total"; +/// CPU brand trait key. pub static HW_CPU_BRAND: &str = "hardware.cpu.brand"; +/// CPU frequency trait key. pub static HW_CPU_FREQ: &str = "hardware.cpu.frequency"; +/// CPU vendor trait key. pub static HW_CPU_VENDOR: &str = "hardware.cpu.vendor"; +/// CPU core count trait key. pub static HW_CPU_CORES: &str = "hardware.cpu.cores"; +/// Reserved master-managed traits overlay filename. +pub static MASTER_TRAITS_FILE: &str = "master.cfg"; +static MASTER_TRAITS_FILE_HEADER: &str = "# THIS FILE IS AUTOGENERATED BY SYSINSPECT MASTER.\n# DO NOT EDIT THIS FILE MANUALLY.\n# LOCAL CUSTOM TRAITS BELONG IN SEPARATE *.cfg FILES.\n"; #[derive(Parser)] #[grammar = "traits/traits_query.pest"] @@ -114,6 +139,61 @@ pub fn get_minion_traits_nolog(cfg: Option<&MinionConfig>) -> SystemTraits { __get_minion_traits(cfg, true) } +/// Return the canonical lowercase OS type for the current build target. +pub fn current_os_type() -> &'static str { + std::env::consts::OS +} + +/// Return a stable display label for a canonical lowercase OS type. +pub fn os_display_name(os: &str) -> &str { + match os { + "android" => "Android", + "linux" | "sysv" => "Linux", + "any" => "Any", + "netbsd" => "NetBSD", + "freebsd" => "FreeBSD", + "openbsd" => "OpenBSD", + _ => os, + } +} + +fn normalized_profiles(value: &Value) -> Vec { + let mut names = IndexSet::new(); + match value { + Value::String(name) if !name.trim().is_empty() => { + names.insert(name.to_string()); + } + Value::Array(items) => { + for item in items { + if let Some(name) = item.as_str() + && !name.trim().is_empty() + { + names.insert(name.to_string()); + } + } + } + _ => {} + } + if names.len() > 1 { + names.shift_remove("default"); + } + names.into_iter().collect() +} + +/// Resolve the effective deployment profile names for a minion. +/// +/// This reads the merged trait view fresh and interprets `minion.profile` +/// as either a single string or an array of strings. Missing or empty values +/// fall back to `default`. Duplicate profile names are removed while keeping +/// the first-seen order. +pub fn effective_profiles(cfg: &MinionConfig) -> Vec { + let names = match SystemTraits::new(cfg.clone(), true).get("minion.profile") { + Some(value) => normalized_profiles(&value), + _ => vec!["default".to_string()], + }; + if names.is_empty() { vec!["default".to_string()] } else { names } +} + /// Get or initialise system traits fn __get_minion_traits(cfg: Option<&MinionConfig>, q: bool) -> SystemTraits { if let Some(cfg) = cfg { @@ -122,3 +202,85 @@ fn __get_minion_traits(cfg: Option<&MinionConfig>, q: bool) -> SystemTraits { _TRAITS.get_or_init(|| SystemTraits::new(MinionConfig::default(), q)).to_owned() } + +/// Ensure the reserved master-managed traits file exists under the traits directory. +pub fn ensure_master_traits_file(cfg: &MinionConfig) -> Result<(), SysinspectError> { + if !cfg.traits_dir().exists() { + fs::create_dir_all(cfg.traits_dir())?; + } + + let master_traits = cfg.traits_dir().join(MASTER_TRAITS_FILE); + if !master_traits.exists() { + fs::write(master_traits, MASTER_TRAITS_FILE_HEADER)?; + } + + Ok(()) +} + +fn load_master_traits(cfg: &MinionConfig) -> Result, SysinspectError> { + ensure_master_traits_file(cfg)?; + let content = fs::read_to_string(cfg.traits_dir().join(MASTER_TRAITS_FILE))?; + Ok(serde_yaml::from_str::>>(&content)?.unwrap_or_default()) +} + +fn store_master_traits(cfg: &MinionConfig, traits: &IndexMap) -> Result<(), SysinspectError> { + ensure_master_traits_file(cfg)?; + let body = if traits.is_empty() { String::new() } else { serde_yaml::to_string(traits)? }; + fs::write(cfg.traits_dir().join(MASTER_TRAITS_FILE), format!("{MASTER_TRAITS_FILE_HEADER}{body}"))?; + Ok(()) +} + +#[derive(Debug, Deserialize)] +/// Generic master-to-minion trait update payload. +pub struct TraitUpdateRequest { + op: String, + #[serde(default)] + traits: IndexMap, +} + +impl TraitUpdateRequest { + /// Parse a trait update request from the console command JSON context. + pub fn from_context(context: &str) -> Result { + Ok(serde_json::from_str(context)?) + } + + /// Apply the requested update to the reserved master-managed traits file. + pub fn apply(&self, cfg: &MinionConfig) -> Result, SysinspectError> { + let mut current = load_master_traits(cfg)?; + match self.op.as_str() { + "set" => { + for (key, value) in &self.traits { + if key == "minion.profile" { + let profiles = normalized_profiles(value); + if profiles.is_empty() { + current.shift_remove(key); + } else { + current.insert(key.to_string(), serde_json::to_value(profiles)?); + } + } else { + current.insert(key.to_string(), value.clone()); + } + } + } + "unset" => { + for key in self.traits.keys() { + current.shift_remove(key); + } + } + "reset" => current.clear(), + _ => return Err(SysinspectError::InvalidQuery(format!("Unknown trait update operation: {}", self.op))), + } + store_master_traits(cfg, ¤t)?; + Ok(current) + } + + /// Return the requested update operation name. + pub fn op(&self) -> &str { + &self.op + } + + /// Return the raw trait payload carried by the request. + pub fn traits(&self) -> &IndexMap { + &self.traits + } +} diff --git a/libsysinspect/src/traits/systraits.rs b/libsysinspect/src/traits/systraits.rs index dcfbc00a..589336bd 100644 --- a/libsysinspect/src/traits/systraits.rs +++ b/libsysinspect/src/traits/systraits.rs @@ -1,8 +1,8 @@ use crate::{ cfg::mmconf::MinionConfig, traits::{ - HW_CPU_BRAND, HW_CPU_CORES, HW_CPU_FREQ, HW_CPU_TOTAL, HW_CPU_VENDOR, HW_MEM, HW_SWAP, SYS_ID, SYS_NET_HOSTNAME, SYS_NET_HOSTNAME_FQDN, - SYS_NET_HOSTNAME_IP, SYS_OS_DISTRO, SYS_OS_KERNEL, SYS_OS_NAME, SYS_OS_VERSION, + HW_CPU_BRAND, HW_CPU_CORES, HW_CPU_FREQ, HW_CPU_TOTAL, HW_CPU_VENDOR, HW_MEM, HW_SWAP, MASTER_TRAITS_FILE, SYS_ID, SYS_NET_HOSTNAME, + SYS_NET_HOSTNAME_FQDN, SYS_NET_HOSTNAME_IP, SYS_OS_DISTRO, SYS_OS_KERNEL, SYS_OS_NAME, SYS_OS_VERSION, }, util::sys::to_fqdn_ip, }; @@ -148,11 +148,8 @@ impl SystemTraits { self.put(SYS_OS_VERSION.to_string(), json!(v)); } - if let Some(v) = sysinfo::System::name() { - self.put(SYS_OS_NAME.to_string(), json!(v)); - } - - self.put(SYS_OS_DISTRO.to_string(), json!(sysinfo::System::distribution_id())); + self.put(SYS_OS_NAME.to_string(), json!(super::os_display_name(super::current_os_type()))); + self.put(SYS_OS_DISTRO.to_string(), json!(super::current_os_type())); // Machine Id (not always there) let mut mid = String::default(); @@ -196,38 +193,50 @@ impl SystemTraits { log::info!("Loading defined/custom traits"); } - for f in fs::read_dir(self.cfg.traits_dir())?.flatten() { + let mut files = fs::read_dir(self.cfg.traits_dir())? + .flatten() + .filter(|f| f.file_name().to_str().unwrap_or_default().ends_with(".cfg")) + .collect::>(); + files.sort_by_key(|f| { + let name = f.file_name().to_str().unwrap_or_default().to_string(); + (name == MASTER_TRAITS_FILE, name) + }); + + for f in files { let fname = f.file_name(); let fname = fname.to_str().unwrap_or_default(); - if fname.ends_with(".cfg") { - let content = Self::proxy_log_error(fs::read_to_string(f.path()), format!("Unable to read custom trait file at {fname}").as_str()) - .unwrap_or_default(); + let content = Self::proxy_log_error(fs::read_to_string(f.path()), format!("Unable to read custom trait file at {fname}").as_str()) + .unwrap_or_default(); - if content.is_empty() { - continue; - } + if content.is_empty() { + continue; + } - let content: Option = Self::proxy_log_error(serde_yaml::from_str(&content), "Custom trait file has broken YAML"); + let content: Option = Self::proxy_log_error(serde_yaml::from_str(&content), "Custom trait file has broken YAML"); - let content: Option = - content.as_ref().and_then(|v| Self::proxy_log_error(serde_json::to_value(v), "Unable to convert existing YAML to JSON format")); + let content: Option = + content.as_ref().and_then(|v| Self::proxy_log_error(serde_json::to_value(v), "Unable to convert existing YAML to JSON format")); - if content.is_none() { - log::error!("Unable to load custom traits from {}", f.file_name().to_str().unwrap_or_default()); - continue; - } + if fname == MASTER_TRAITS_FILE + && content.as_ref().is_none_or(serde_json::Value::is_null) + { + continue; + } - let content = content.as_ref().and_then(|v| { - Self::proxy_log_error(serde_json::from_value::>(v.clone()), "Unable to parse JSON") - }); + if content.is_none() { + log::error!("Unable to load custom traits from {}", f.file_name().to_str().unwrap_or_default()); + continue; + } - if let Some(content) = content { - for (k, v) in content { - self.put(k, json!(v)); - } - } else { - log::error!("Custom traits data is empty or in a wrong format"); + let content = + content.as_ref().and_then(|v| Self::proxy_log_error(serde_json::from_value::>(v.clone()), "Unable to parse JSON")); + + if let Some(content) = content { + for (k, v) in content { + self.put(k, json!(v)); } + } else { + log::error!("Custom traits data is empty or in a wrong format"); } } Ok(()) diff --git a/libsysinspect/src/traits/traits_ut.rs b/libsysinspect/src/traits/traits_ut.rs new file mode 100644 index 00000000..324f83ac --- /dev/null +++ b/libsysinspect/src/traits/traits_ut.rs @@ -0,0 +1,121 @@ +use crate::cfg::mmconf::MinionConfig; +use crate::traits::{MASTER_TRAITS_FILE, TraitUpdateRequest, current_os_type, effective_profiles, ensure_master_traits_file, os_display_name}; +use crate::traits::systraits::SystemTraits; +use std::fs; + +#[test] +fn ensure_master_traits_file_creates_reserved_master_cfg() { + let root = tempfile::tempdir().unwrap_or_else(|err| panic!("failed to create tempdir: {err}")); + let mut cfg = MinionConfig::default(); + cfg.set_root_dir(root.path().to_str().unwrap_or_default()); + + ensure_master_traits_file(&cfg).unwrap_or_else(|err| panic!("failed to ensure master traits file: {err}")); + + let pth = cfg.traits_dir().join(MASTER_TRAITS_FILE); + assert!(pth.exists(), "master-managed traits file should exist"); + + let content = fs::read_to_string(pth).unwrap_or_else(|err| panic!("failed to read master traits file: {err}")); + assert!(content.contains("AUTOGENERATED"), "master-managed traits header should be present"); +} + +#[test] +fn trait_update_request_set_writes_master_managed_traits() { + let root = tempfile::tempdir().unwrap_or_else(|err| panic!("failed to create tempdir: {err}")); + let mut cfg = MinionConfig::default(); + cfg.set_root_dir(root.path().to_str().unwrap_or_default()); + + TraitUpdateRequest::from_context(r#"{"op":"set","traits":{"foo":"bar","count":3}}"#) + .unwrap_or_else(|err| panic!("failed to parse traits update: {err}")) + .apply(&cfg) + .unwrap_or_else(|err| panic!("failed to apply traits update: {err}")); + + let content = + fs::read_to_string(cfg.traits_dir().join(MASTER_TRAITS_FILE)).unwrap_or_else(|err| panic!("failed to read master traits file: {err}")); + assert!(content.contains("foo: bar")); + assert!(content.contains("count: 3")); +} + +#[test] +fn trait_update_request_unset_removes_keys_from_master_managed_traits() { + let root = tempfile::tempdir().unwrap_or_else(|err| panic!("failed to create tempdir: {err}")); + let mut cfg = MinionConfig::default(); + cfg.set_root_dir(root.path().to_str().unwrap_or_default()); + + let set = TraitUpdateRequest::from_context(r#"{"op":"set","traits":{"foo":"bar","keep":"yes"}}"#) + .unwrap_or_else(|err| panic!("failed to parse set request: {err}")); + set.apply(&cfg).unwrap_or_else(|err| panic!("failed to apply set request: {err}")); + + let unset = TraitUpdateRequest::from_context(r#"{"op":"unset","traits":{"foo":null}}"#) + .unwrap_or_else(|err| panic!("failed to parse unset request: {err}")); + unset.apply(&cfg).unwrap_or_else(|err| panic!("failed to apply unset request: {err}")); + + let content = + fs::read_to_string(cfg.traits_dir().join(MASTER_TRAITS_FILE)).unwrap_or_else(|err| panic!("failed to read master traits file: {err}")); + assert!(!content.contains("foo:")); + assert!(content.contains("keep: yes")); +} + +#[test] +fn trait_update_request_reset_clears_master_managed_traits_file_body() { + let root = tempfile::tempdir().unwrap_or_else(|err| panic!("failed to create tempdir: {err}")); + let mut cfg = MinionConfig::default(); + cfg.set_root_dir(root.path().to_str().unwrap_or_default()); + + let set = TraitUpdateRequest::from_context(r#"{"op":"set","traits":{"foo":"bar"}}"#) + .unwrap_or_else(|err| panic!("failed to parse set request: {err}")); + set.apply(&cfg).unwrap_or_else(|err| panic!("failed to apply set request: {err}")); + + let reset = TraitUpdateRequest::from_context(r#"{"op":"reset","traits":{}}"#) + .unwrap_or_else(|err| panic!("failed to parse reset request: {err}")); + reset.apply(&cfg).unwrap_or_else(|err| panic!("failed to apply reset request: {err}")); + + let content = + fs::read_to_string(cfg.traits_dir().join(MASTER_TRAITS_FILE)).unwrap_or_else(|err| panic!("failed to read master traits file: {err}")); + assert!(content.contains("AUTOGENERATED")); + assert!(!content.contains("foo:")); +} + +#[test] +fn empty_master_traits_file_is_accepted_during_trait_load() { + let root = tempfile::tempdir().unwrap_or_else(|err| panic!("failed to create tempdir: {err}")); + let mut cfg = MinionConfig::default(); + cfg.set_root_dir(root.path().to_str().unwrap_or_default()); + + ensure_master_traits_file(&cfg).unwrap_or_else(|err| panic!("failed to ensure master traits file: {err}")); + + let traits = SystemTraits::new(cfg, true); + assert!(!traits.has("foo"), "header-only master.cfg should not inject traits"); +} + +#[test] +fn effective_profiles_fallback_to_default_and_dedup_array_values() { + let root = tempfile::tempdir().unwrap_or_else(|err| panic!("failed to create tempdir: {err}")); + let mut cfg = MinionConfig::default(); + cfg.set_root_dir(root.path().to_str().unwrap_or_default()); + + ensure_master_traits_file(&cfg).unwrap_or_else(|err| panic!("failed to ensure master traits file: {err}")); + assert_eq!(effective_profiles(&cfg), vec!["default".to_string()]); + + fs::write(cfg.traits_dir().join(MASTER_TRAITS_FILE), "minion.profile:\n - Foo\n - Bar\n - Foo\n") + .unwrap_or_else(|err| panic!("failed to write master traits file: {err}")); + assert_eq!(effective_profiles(&cfg), vec!["Foo".to_string(), "Bar".to_string()]); +} + +#[test] +fn effective_profiles_drops_default_when_real_profiles_are_present() { + let root = tempfile::tempdir().unwrap_or_else(|err| panic!("failed to create tempdir: {err}")); + let mut cfg = MinionConfig::default(); + cfg.set_root_dir(root.path().to_str().unwrap_or_default()); + + ensure_master_traits_file(&cfg).unwrap_or_else(|err| panic!("failed to ensure master traits file: {err}")); + fs::write(cfg.traits_dir().join(MASTER_TRAITS_FILE), "minion.profile:\n - default\n - Runtimes\n") + .unwrap_or_else(|err| panic!("failed to write master traits file: {err}")); + assert_eq!(effective_profiles(&cfg), vec!["Runtimes".to_string()]); +} + +#[test] +fn os_display_name_keeps_android_distinct_from_linux() { + assert_eq!(os_display_name("android"), "Android"); + assert_eq!(os_display_name("linux"), "Linux"); + assert!(!current_os_type().is_empty(), "current os type should be available"); +} diff --git a/libsysinspect/src/util/mod.rs b/libsysinspect/src/util/mod.rs index edd2eb7a..79723d03 100644 --- a/libsysinspect/src/util/mod.rs +++ b/libsysinspect/src/util/mod.rs @@ -4,9 +4,13 @@ pub mod sys; pub mod tty; use libcommon::SysinspectError; +use once_cell::sync::Lazy; +use regex::Regex; use std::{fs, io, path::PathBuf}; use uuid::Uuid; +static ANSI_ESCAPE_RE: Lazy = Lazy::new(|| Regex::new(r"\x1b\[[0-9;]*m").expect("ansi regex should compile")); + /// The `/etc/machine-id` is not always present, especially /// on the custom embedded systems. However, this file is used /// to identify a minion. @@ -27,3 +31,8 @@ pub fn write_machine_id(p: Option) -> Result<(), SysinspectError> { Ok(()) } + +pub fn pad_visible(text: &str, width: usize) -> String { + let visible = ANSI_ESCAPE_RE.replace_all(text, "").chars().count(); + if visible >= width { text.to_string() } else { format!("{text}{}", " ".repeat(width - visible)) } +} diff --git a/libsysproto/src/query.rs b/libsysproto/src/query.rs index 2975db67..5b886493 100644 --- a/libsysproto/src/query.rs +++ b/libsysproto/src/query.rs @@ -26,6 +26,12 @@ pub mod commands { // Get online minions pub const CLUSTER_ONLINE_MINIONS: &str = "cluster/minion/online"; + + // Update master-managed static traits on minions + pub const CLUSTER_TRAITS_UPDATE: &str = "cluster/traits/update"; + + // Manage deployment profiles on the master + pub const CLUSTER_PROFILE: &str = "cluster/profile"; } /// diff --git a/man/sysinspect.8.md b/man/sysinspect.8.md index 0552f768..9a294735 100644 --- a/man/sysinspect.8.md +++ b/man/sysinspect.8.md @@ -10,6 +10,11 @@ SYNOPSIS ======== | **sysinspect** \[**OPTIONS**]... +| **sysinspect** *model* \[*query*] \[**--traits** *traits-query*] \[**--context** *k:v,...*] +| **sysinspect** **--model** *path* \[**--entities** *list* | **--labels** *list*] \[**--state** *name*] +| **sysinspect** **traits** \[**--set** *k:v,...* | **--unset** *k,...* | **--reset**] \[**--id** *id* | **--query** *glob* | *glob*] \[**--traits** *query*] +| **sysinspect** **profile** \[**--new** | **--delete** | **--list** | **-A** | **-R** | **--tag** | **--untag**] ... +| **sysinspect** **module** \[**-A** | **-R** | **-L** | **-i**] ... DESCRIPTION =========== @@ -20,6 +25,147 @@ system for the following means: - Root Cause Analysis - Anomaly Detection +The command-line tool talks to the local or configured master instance, +submits model requests, manages the module repository, updates +master-managed static traits on minions, and manages deployment profiles. + +RUNNING MODELS REMOTELY +======================= + +The most common use of **sysinspect** is sending a model request to the +master. + +Examples: + +| **sysinspect** "my_model" +| **sysinspect** "my_model/my_entity" +| **sysinspect** "my_model/my_entity/my_state" +| **sysinspect** "my_model" "*" +| **sysinspect** "my_model" "web*" +| **sysinspect** "my_model" "db01,db02" +| **sysinspect** "my_model" "*" **--traits** "system.os.name:Ubuntu" +| **sysinspect** "my_model" "*" **--context** "foo:123,name:Fred" + +The optional second positional argument targets minions by hostname glob +or comma-separated host list. The **--traits** option further narrows the +target set. The **--context** option passes comma-separated key/value +data into the model call. + +RUNNING MODELS LOCALLY +====================== + +**sysinspect** can also execute a model locally without going through the +master. + +Examples: + +| **sysinspect** **--model** ./my_model +| **sysinspect** **--model** ./my_model **--entities** foo,bar +| **sysinspect** **--model** ./my_model **--labels** os-check +| **sysinspect** **--model** ./my_model **--state** online + +The local selector options are: + +- **--entities** limit execution to specific entities +- **--labels** limit execution to specific labels +- **--state** choose the state to process + +CLUSTER COMMANDS +================ + +- **--sync** refreshes cluster artefacts and triggers minions to report + fresh traits back to the master +- **--online** prints the current online-minion summary to standard output +- **--shutdown** asks the master to stop +- **--unregister** *id* unregisters a minion by System Id + +TRAITS +====== + +The **traits** subcommand updates only the master-managed static trait +overlay stored on minions. + +Examples: + +| **sysinspect** **traits** **--set** "foo:bar" +| **sysinspect** **traits** **--set** "foo:bar,baz:qux" "web*" +| **sysinspect** **traits** **--set** "foo:bar" **--id** 30006546535e428aba0a0caa6712e225 +| **sysinspect** **traits** **--unset** "foo,baz" "web*" +| **sysinspect** **traits** **--reset** **--id** 30006546535e428aba0a0caa6712e225 + +Supported selectors: + +- **--id** target one minion by System Id +- **--query** or trailing positional query target minions by hostname glob +- **--traits** further narrow the target set by traits query + +PROFILES +======== + +Deployment profiles define which modules and libraries a minion is +allowed to sync. + +Examples: + +| **sysinspect** **profile** **--new** **--name** Toto +| **sysinspect** **profile** **--delete** **--name** Toto +| **sysinspect** **profile** **--list** +| **sysinspect** **profile** **--list** **--name** 'T*' +| **sysinspect** **profile** **--show** **--name** Toto +| **sysinspect** **profile** **-A** **--name** Toto **--match** 'runtime.lua,net.*' +| **sysinspect** **profile** **-A** **--lib** **--name** Toto **--match** 'runtime/lua/*.lua' +| **sysinspect** **profile** **-R** **--name** Toto **--match** 'net.*' +| **sysinspect** **profile** **--tag** 'Toto,Foo' **--query** 'web*' +| **sysinspect** **profile** **--untag** 'Foo' **--traits** 'system.hostname.fqdn:db01.example.net' + +Notes: + +- **--name** is an exact profile name for **--new**, **--delete**, + **--show**, **-A**, and **-R** +- **--name** is a glob pattern for **--list** +- **--match** accepts comma-separated exact names or glob patterns +- **-l** or **--lib** switches selector operations and listing to + library selectors +- **--tag** and **--untag** update the **minion.profile** static trait +- a profile file carries its own canonical **name** field; the filename + is only storage +- new profile files are written with lowercase filenames, but existing + indexed filenames remain valid as-is + +MODULE REPOSITORY +================= + +The **module** subcommand manages the master's module repository. + +Examples: + +| **sysinspect** **module** **-A** **--name** runtime.lua **--path** ./target/debug/runtime/lua +| **sysinspect** **module** **-A** **--path** ./lib **-l** +| **sysinspect** **module** **-L** +| **sysinspect** **module** **-Ll** +| **sysinspect** **module** **-R** **--name** runtime.lua +| **sysinspect** **module** **-R** **--name** runtime/lua/reader.lua **-l** +| **sysinspect** **module** **-i** **--name** runtime.lua + +UTILITY COMMANDS +================ + +Additional operator entrypoints: + +| **sysinspect** **--ui** +| **sysinspect** **--list-handlers** + +**--ui** starts the terminal user interface. **--list-handlers** prints +the registered event handler identifiers. + +COMMON OPTIONS +============== + +- **-c**, **--config** *path* use an alternative configuration file +- **-d**, **--debug** increase log verbosity; repeat for more verbosity +- **-h**, **--help** display help +- **-v**, **--version** display version + DETAILED DOCUMENTATION ====================== diff --git a/src/clidef.rs b/src/clidef.rs index e4338db3..a7b05cb7 100644 --- a/src/clidef.rs +++ b/src/clidef.rs @@ -31,6 +31,34 @@ pub fn cli(version: &'static str) -> Command { .arg(Arg::new("arch").short('a').long("arch").help("Specify the module architecture (x86, x64, arm, arm64, noarch)").default_value("noarch")) .arg(Arg::new("help").short('h').long("help").action(ArgAction::SetTrue).help("Display help for this command")) ) + .subcommand(Command::new("traits").about("Sync or update minion traits").styles(styles.clone()).disable_help_flag(true) + .arg(Arg::new("set").long("set").help("Set traits as comma-separated key:value pairs").conflicts_with_all(["unset", "reset"])) + .arg(Arg::new("unset").long("unset").help("Unset traits as comma-separated keys").conflicts_with_all(["set", "reset"])) + .arg(Arg::new("reset").long("reset").action(ArgAction::SetTrue).help("Reset all master-managed traits on targeted minions").conflicts_with_all(["set", "unset"])) + .arg(Arg::new("id").long("id").help("Target a specific minion by its system id").conflicts_with_all(["query", "query-pos"])) + .arg(Arg::new("query").long("query").help("Target minions by hostname glob or query").conflicts_with("query-pos")) + .arg(Arg::new("select-traits").long("traits").help("Target minions by traits query")) + .arg(Arg::new("query-pos").help("Target minions by hostname glob or query").required(false).index(1)) + .arg(Arg::new("help").short('h').long("help").action(ArgAction::SetTrue).help("Display help for this command")) + ) + .subcommand(Command::new("profile").about("Manage deployment profiles").styles(styles.clone()).disable_help_flag(true) + .arg(Arg::new("new").long("new").action(ArgAction::SetTrue).help("Create a deployment profile").conflicts_with_all(["delete", "list", "show", "add", "remove", "tag", "untag"])) + .arg(Arg::new("delete").long("delete").action(ArgAction::SetTrue).help("Delete a deployment profile").conflicts_with_all(["new", "list", "show", "add", "remove", "tag", "untag"])) + .arg(Arg::new("list").long("list").action(ArgAction::SetTrue).help("List deployment profiles or their assigned selectors").conflicts_with_all(["new", "delete", "show", "add", "remove", "tag", "untag"])) + .arg(Arg::new("show").long("show").action(ArgAction::SetTrue).help("Show the expanded artefact content of one deployment profile").conflicts_with_all(["new", "delete", "list", "add", "remove", "tag", "untag"])) + .arg(Arg::new("add").short('A').long("add").action(ArgAction::SetTrue).help("Add selectors to a deployment profile").conflicts_with_all(["new", "delete", "list", "show", "remove", "tag", "untag"])) + .arg(Arg::new("remove").short('R').long("remove").action(ArgAction::SetTrue).help("Remove selectors from a deployment profile").conflicts_with_all(["new", "delete", "list", "show", "add", "tag", "untag"])) + .arg(Arg::new("tag").long("tag").help("Assign one or more profiles to targeted minions").conflicts_with_all(["new", "delete", "list", "show", "add", "remove", "untag"])) + .arg(Arg::new("untag").long("untag").help("Unassign one or more profiles from targeted minions").conflicts_with_all(["new", "delete", "list", "show", "add", "remove", "tag"])) + .arg(Arg::new("name").short('n').long("name").help("Profile name or profile glob pattern")) + .arg(Arg::new("match").short('m').long("match").help("Comma-separated module or library selectors")) + .arg(Arg::new("lib").short('l').long("lib").action(ArgAction::SetTrue).help("Operate on library selectors instead of module selectors")) + .arg(Arg::new("id").long("id").help("Target a specific minion by its system id").conflicts_with_all(["query", "query-pos"])) + .arg(Arg::new("query").long("query").help("Target minions by hostname glob or query").conflicts_with("query-pos")) + .arg(Arg::new("select-traits").long("traits").help("Target minions by traits query")) + .arg(Arg::new("query-pos").help("Target minions by hostname glob or query").required(false).index(1)) + .arg(Arg::new("help").short('h').long("help").action(ArgAction::SetTrue).help("Display help for this command")) + ) // Sysinspect .next_help_heading("Main") diff --git a/src/main.rs b/src/main.rs index 4f6e06a2..ecdf7fd6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,21 +7,31 @@ use libsysinspect::{ mmconf::{MasterConfig, MinionConfig}, select_config_path, }, + console::{ConsoleQuery, ConsoleResponse, ConsoleSealed, build_console_query}, + context, inspector::SysInspectRunner, logger::{self, MemoryLogger, STDOUTLogger}, reactor::handlers, traits::get_minion_traits, }; use libsysproto::query::SCHEME_COMMAND; -use libsysproto::query::commands::{CLUSTER_ONLINE_MINIONS, CLUSTER_REMOVE_MINION, CLUSTER_SHUTDOWN, CLUSTER_SYNC}; +use libsysproto::query::commands::{ + CLUSTER_ONLINE_MINIONS, CLUSTER_PROFILE, CLUSTER_REMOVE_MINION, CLUSTER_SHUTDOWN, CLUSTER_SYNC, CLUSTER_TRAITS_UPDATE, +}; use log::LevelFilter; +use serde_json::json; use std::{ env, - fs::OpenOptions, - io::{ErrorKind, Write}, + io::ErrorKind, path::PathBuf, process::exit, sync::{Mutex, OnceLock}, + time::Duration, +}; +use tokio::{ + io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}, + net::TcpStream, + time::timeout, }; mod clidef; @@ -30,6 +40,9 @@ mod ui; static VERSION: &str = "0.4.0"; static LOGGER: OnceLock = OnceLock::new(); static MEM_LOGGER: MemoryLogger = MemoryLogger { messages: Mutex::new(Vec::new()) }; +const CONSOLE_CONNECT_TIMEOUT: Duration = Duration::from_secs(5); +const CONSOLE_READ_TIMEOUT: Duration = Duration::from_secs(5); +const MAX_CONSOLE_RESPONSE_SIZE: u64 = 64 * 1024; /// Display event handlers fn print_event_handlers() { @@ -41,16 +54,157 @@ fn print_event_handlers() { println!(); } -/// Call master via FIFO -fn call_master_fifo( - model: &str, query: &str, traits: Option<&String>, mid: Option<&str>, fifo: &str, context: Option<&String>, -) -> Result<(), SysinspectError> { - let payload = - format!("{model};{query};{};{};{}\n", traits.unwrap_or(&"".to_string()), mid.unwrap_or_default(), context.unwrap_or(&"".to_string())); - OpenOptions::new().write(true).open(fifo)?.write_all(payload.as_bytes())?; +async fn call_master_console( + cfg: &MasterConfig, model: &str, query: &str, traits: Option<&String>, mid: Option<&str>, context: Option<&String>, +) -> Result { + let request = ConsoleQuery { + model: model.to_string(), + query: query.to_string(), + traits: traits.cloned().unwrap_or_default(), + mid: mid.unwrap_or_default().to_string(), + context: context.cloned().unwrap_or_default(), + }; + let (envelope, key) = build_console_query(&cfg.root_dir(), cfg, &request)?; + let mut stream = timeout(CONSOLE_CONNECT_TIMEOUT, TcpStream::connect(cfg.console_connect_addr())) + .await + .map_err(|_| std::io::Error::new(ErrorKind::TimedOut, "timeout while connecting to master console"))??; + stream.write_all(format!("{}\n", serde_json::to_string(&envelope)?).as_bytes()).await?; - log::debug!("Message sent to the master via FIFO: {payload:?}"); - Ok(()) + let mut reader = BufReader::new(stream).take(MAX_CONSOLE_RESPONSE_SIZE + 1); + let mut reply = String::new(); + timeout(CONSOLE_READ_TIMEOUT, reader.read_line(&mut reply)) + .await + .map_err(|_| std::io::Error::new(ErrorKind::TimedOut, "timeout while reading response from master console"))??; + if reply.len() as u64 > MAX_CONSOLE_RESPONSE_SIZE || !reply.ends_with('\n') { + return Err(SysinspectError::SerializationError(format!( + "Console response exceeds {} bytes", + MAX_CONSOLE_RESPONSE_SIZE + ))); + } + let response: ConsoleResponse = match serde_json::from_str::(reply.trim()) { + Ok(sealed) => sealed.open(&key)?, + Err(_) => serde_json::from_str(reply.trim())?, + }; + if !response.ok { + return Err(SysinspectError::MasterGeneralError(response.message)); + } + Ok(response) +} + +fn traits_update_context(am: &ArgMatches) -> Result, SysinspectError> { + if let Some(setv) = am.get_one::("set") { + let traits = context::get_context(setv) + .ok_or_else(|| SysinspectError::InvalidQuery("Trait values must be in key:value format".to_string()))?; + return Ok(Some(json!({"op": "set", "traits": traits}).to_string())); + } + + if let Some(keys) = am.get_one::("unset") { + return Ok(Some(json!({ + "op": "unset", + "traits": context::get_context_keys(keys).into_iter().map(|key| (key, serde_json::Value::Null)).collect::>() + }) + .to_string())); + } + + if am.get_flag("reset") { + return Ok(Some(json!({"op": "reset", "traits": {}}).to_string())); + } + + Err(SysinspectError::InvalidQuery("Specify one of --set, --unset, or --reset".to_string())) +} + +fn profile_update_context(am: &ArgMatches) -> Result, SysinspectError> { + let invalid_name = |name: &str| { + let name = name.trim(); + name.is_empty() + || matches!(name, "." | "..") + || !name.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')) + }; + if am.get_flag("new") { + if am.get_one::("name").is_none() { + return Err(SysinspectError::InvalidQuery("Specify --name for --new".to_string())); + } + if invalid_name(am.get_one::("name").unwrap()) { + return Err(SysinspectError::InvalidQuery( + "Profile names for --new must be exact names and may only contain letters, digits, '.', '_', or '-'".to_string(), + )); + } + return Ok(Some(json!({"op": "new", "name": am.get_one::("name").cloned().unwrap_or_default()}).to_string())); + } + + if am.get_flag("delete") { + if am.get_one::("name").is_none() { + return Err(SysinspectError::InvalidQuery("Specify --name for --delete".to_string())); + } + if invalid_name(am.get_one::("name").unwrap()) { + return Err(SysinspectError::InvalidQuery( + "Profile names for --delete must be exact names and may only contain letters, digits, '.', '_', or '-'".to_string(), + )); + } + return Ok(Some(json!({"op": "delete", "name": am.get_one::("name").cloned().unwrap_or_default()}).to_string())); + } + + if am.get_flag("list") { + return Ok(Some( + json!({"op": "list", "name": am.get_one::("name").cloned().unwrap_or_default(), "library": am.get_flag("lib")}).to_string(), + )); + } + + if am.get_flag("show") { + if am.get_one::("name").is_none() { + return Err(SysinspectError::InvalidQuery("Specify --name for --show".to_string())); + } + if invalid_name(am.get_one::("name").unwrap()) { + return Err(SysinspectError::InvalidQuery( + "Profile names for --show must be exact names and may only contain letters, digits, '.', '_', or '-'".to_string(), + )); + } + return Ok(Some(json!({"op": "show", "name": am.get_one::("name").cloned().unwrap_or_default()}).to_string())); + } + + if am.get_flag("add") || am.get_flag("remove") { + if am.get_one::("name").is_none() || am.get_one::("match").is_none() { + return Err(SysinspectError::InvalidQuery("Specify both --name and --match for profile selector updates".to_string())); + } + if invalid_name(am.get_one::("name").unwrap()) { + return Err(SysinspectError::InvalidQuery( + "Profile names for selector updates must be exact names and may only contain letters, digits, '.', '_', or '-'".to_string(), + )); + } + if clidef::split_by(am, "match", None).is_empty() { + return Err(SysinspectError::InvalidQuery("At least one selector is required in --match".to_string())); + } + return Ok(Some( + json!({ + "op": if am.get_flag("add") { "add" } else { "remove" }, + "name": am.get_one::("name").cloned().unwrap_or_default(), + "matches": clidef::split_by(am, "match", None), + "library": am.get_flag("lib"), + }) + .to_string(), + )); + } + + if am.get_one::("tag").is_some() || am.get_one::("untag").is_some() { + let arg_name = if am.get_one::("tag").is_some() { "tag" } else { "untag" }; + let profiles = clidef::split_by(am, arg_name, None) + .into_iter() + .map(|profile| profile.trim().to_string()) + .filter(|profile| !profile.is_empty()) + .collect::>(); + if profiles.is_empty() { + return Err(SysinspectError::InvalidQuery("Specify at least one profile name for --tag or --untag".to_string())); + } + return Ok(Some( + json!({ + "op": arg_name, + "profiles": profiles, + }) + .to_string(), + )); + } + + Err(SysinspectError::InvalidQuery("Specify one profile operation".to_string())) } /// Set logger @@ -88,6 +242,24 @@ fn help(cli: &mut Command, params: &ArgMatches) -> bool { } return false; } + if let Some(sub) = params.subcommand_matches("traits") + && sub.get_flag("help") + { + if let Some(s_cli) = cli.find_subcommand_mut("traits") { + _ = s_cli.print_help(); + return true; + } + return false; + } + if let Some(sub) = params.subcommand_matches("profile") + && sub.get_flag("help") + { + if let Some(s_cli) = cli.find_subcommand_mut("profile") { + _ = s_cli.print_help(); + return true; + } + return false; + } if params.get_flag("help") { _ = &cli.print_long_help(); return true; @@ -218,6 +390,57 @@ async fn main() { exit(0) } + if let Some(sub) = params.subcommand_matches("traits") { + let target_id = sub.get_one::("id").map(String::as_str); + let target_query = sub + .get_one::("query") + .or_else(|| sub.get_one::("query-pos")) + .map(String::as_str) + .unwrap_or("*"); + let target_traits = sub.get_one::("select-traits"); + let scheme = format!("{SCHEME_COMMAND}{CLUSTER_TRAITS_UPDATE}"); + + let context = match traits_update_context(sub) { + Ok(ctx) => ctx, + Err(err) => { + log::error!("{err}"); + exit(1); + } + }; + + if let Err(err) = call_master_console(&cfg, &scheme, target_query, target_traits, target_id, context.as_ref()).await { + log::error!("Cannot reach master: {err}"); + } + exit(0); + } + + if let Some(sub) = params.subcommand_matches("profile") { + let target_id = sub.get_one::("id").map(String::as_str); + let target_query = sub + .get_one::("query") + .or_else(|| sub.get_one::("query-pos")) + .map(String::as_str) + .unwrap_or("*"); + let target_traits = sub.get_one::("select-traits"); + let context = match profile_update_context(sub) { + Ok(ctx) => ctx, + Err(err) => { + log::error!("{err}"); + exit(1); + } + }; + + match call_master_console(&cfg, &format!("{SCHEME_COMMAND}{CLUSTER_PROFILE}"), target_query, target_traits, target_id, context.as_ref()).await { + Ok(resp) => { + if !resp.message.is_empty() { + println!("{}", resp.message); + } + } + Err(err) => log::error!("Cannot reach master: {err}"), + } + exit(0); + } + if *params.get_one::("list-handlers").unwrap_or(&false) { print_event_handlers(); return; @@ -241,26 +464,26 @@ async fn main() { let query = params.get_one::("query"); let traits = params.get_one::("traits"); let context = params.get_one::("context"); - if let Err(err) = call_master_fifo(model, query.unwrap_or(&"".to_string()), traits, None, &cfg.socket(), context) { + if let Err(err) = call_master_console(&cfg, model, query.unwrap_or(&"".to_string()), traits, None, context).await { log::error!("Cannot reach master: {err}"); } } else if params.get_flag("shutdown") { - if let Err(err) = call_master_fifo(&format!("{SCHEME_COMMAND}{CLUSTER_SHUTDOWN}"), "*", None, None, &cfg.socket(), None) { + if let Err(err) = call_master_console(&cfg, &format!("{SCHEME_COMMAND}{CLUSTER_SHUTDOWN}"), "*", None, None, None).await { log::error!("Cannot reach master: {err}"); } } else if params.get_flag("sync") { - if let Err(err) = call_master_fifo(&format!("{SCHEME_COMMAND}{CLUSTER_SYNC}"), "*", None, None, &cfg.socket(), None) { + if let Err(err) = call_master_console(&cfg, &format!("{SCHEME_COMMAND}{CLUSTER_SYNC}"), "*", None, None, None).await { log::error!("Cannot reach master: {err}"); } } else if let Some(mid) = params.get_one::("unregister") { - if let Err(err) = call_master_fifo(&format!("{SCHEME_COMMAND}{CLUSTER_REMOVE_MINION}"), "", None, Some(mid), &cfg.socket(), None) { + if let Err(err) = call_master_console(&cfg, &format!("{SCHEME_COMMAND}{CLUSTER_REMOVE_MINION}"), "", None, Some(mid), None).await { log::error!("Cannot reach master: {err}"); } } else if params.get_flag("online") { - if let Err(err) = call_master_fifo(&format!("{SCHEME_COMMAND}{CLUSTER_ONLINE_MINIONS}"), "", None, None, &cfg.socket(), None) { - log::error!("Cannot reach master: {err}"); - } else { - println!("Check the master's logs for online minions information. 😀"); + match call_master_console(&cfg, &format!("{SCHEME_COMMAND}{CLUSTER_ONLINE_MINIONS}"), "", None, None, None).await { + Ok(response) if !response.message.is_empty() => println!("{}", response.message), + Ok(_) => {} + Err(err) => log::error!("Cannot reach master: {err}"), } } else if let Some(mpath) = params.get_one::("model") { let mut sr = SysInspectRunner::new(&MinionConfig::default()); diff --git a/sysmaster/src/dataserv/fls.rs b/sysmaster/src/dataserv/fls.rs index 2a61fc45..4e336cea 100644 --- a/sysmaster/src/dataserv/fls.rs +++ b/sysmaster/src/dataserv/fls.rs @@ -2,7 +2,7 @@ use actix_web::{App, HttpResponse, HttpServer, Responder, rt::System, web}; use colored::Colorize; use libcommon::SysinspectError; use libsysinspect::cfg::mmconf::{ - CFG_FILESERVER_ROOT, CFG_MODELS_ROOT, CFG_MODREPO_ROOT, CFG_SENSORS_ROOT, CFG_TRAIT_FUNCTIONS_ROOT, CFG_TRAITS_ROOT, MasterConfig, + CFG_FILESERVER_ROOT, CFG_MODELS_ROOT, CFG_MODREPO_ROOT, CFG_PROFILES_ROOT, CFG_SENSORS_ROOT, CFG_TRAIT_FUNCTIONS_ROOT, CFG_TRAITS_ROOT, MasterConfig, }; use std::{fs, path::PathBuf, thread}; @@ -14,7 +14,7 @@ fn init_fs_env(cfg: &MasterConfig) -> Result<(), SysinspectError> { fs::create_dir_all(&root)?; } - for sub in [CFG_TRAIT_FUNCTIONS_ROOT, CFG_MODELS_ROOT, CFG_MODREPO_ROOT, CFG_TRAITS_ROOT, CFG_SENSORS_ROOT] { + for sub in [CFG_TRAIT_FUNCTIONS_ROOT, CFG_MODELS_ROOT, CFG_MODREPO_ROOT, CFG_PROFILES_ROOT, CFG_TRAITS_ROOT, CFG_SENSORS_ROOT] { let subdir = root.join(sub); if !subdir.exists() { log::info!("Created file server subdirectory at {}", subdir.display().to_string().bright_yellow()); @@ -22,6 +22,10 @@ fn init_fs_env(cfg: &MasterConfig) -> Result<(), SysinspectError> { } } + if !root.join("profiles.index").exists() { + fs::write(root.join("profiles.index"), "profiles: {}\n")?; + } + Ok(()) } diff --git a/sysmaster/src/master.rs b/sysmaster/src/master.rs index 3e08767d..2f4cbdf7 100644 --- a/sysmaster/src/master.rs +++ b/sysmaster/src/master.rs @@ -17,10 +17,13 @@ use libeventreg::{ ipcs::DbIPCService, kvdb::{EventMinion, EventsRegistry}, }; +use libmodpak::SysInspectModPak; use libsysinspect::{ cfg::mmconf::{CFG_MODELS_ROOT, MasterConfig}, + console::{ConsoleEnvelope, ConsoleQuery, ConsoleResponse, ConsoleSealed, authorised_console_client, ensure_console_keypair, load_master_private_key}, + context::{ProfileConsoleRequest, get_context}, mdescr::{mspec::MODEL_FILE_EXT, mspecdef::ModelSpec, telemetry::DataExportType}, - util::{self, iofs::scan_files_sha256}, + util::{self, iofs::scan_files_sha256, pad_visible}, }; use libsysproto::{ self, MasterMessage, MinionMessage, MinionTarget, ProtoConversion, @@ -28,7 +31,7 @@ use libsysproto::{ payload::{ModStatePayload, PingData}, query::{ SCHEME_COMMAND, - commands::{CLUSTER_ONLINE_MINIONS, CLUSTER_REMOVE_MINION}, + commands::{CLUSTER_ONLINE_MINIONS, CLUSTER_PROFILE, CLUSTER_REMOVE_MINION, CLUSTER_TRAITS_UPDATE}, }, rqtypes::{ProtoKey, ProtoValue, RequestType}, }; @@ -37,15 +40,14 @@ use serde_json::json; use std::time::Duration as StdDuration; use std::{ collections::{HashMap, HashSet}, - path::{Path, PathBuf}, + path::PathBuf, sync::{Arc, Weak}, vec, }; use tokio::net::TcpListener; -use tokio::select; use tokio::sync::{broadcast, mpsc}; use tokio::time::{Duration, sleep}; -use tokio::{fs::OpenOptions, sync::Mutex}; +use tokio::sync::Mutex; use tokio::{ io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader as TokioBufReader}, time, @@ -54,6 +56,8 @@ use tokio::{ // Session singleton pub static SHARED_SESSION: Lazy>> = Lazy::new(|| Arc::new(Mutex::new(SessionKeeper::new(30)))); static MODEL_CACHE: Lazy>>> = Lazy::new(|| Arc::new(Mutex::new(HashMap::new()))); +static MAX_CONSOLE_FRAME_SIZE: usize = 64 * 1024; +const CONSOLE_READ_TIMEOUT: StdDuration = StdDuration::from_secs(5); #[derive(Debug)] pub struct SysMaster { @@ -105,17 +109,6 @@ impl SysMaster { }) } - /// Open FIFO socket for command-line communication - fn open_socket(&self, path: &str) -> Result<(), SysinspectError> { - if !Path::new(path).exists() { - if unsafe { libc::mkfifo(std::ffi::CString::new(path)?.as_ptr(), 0o600) } != 0 { - return Err(SysinspectError::ConfigError(format!("{}", std::io::Error::last_os_error()))); - } - log::info!("Socket opened at {path}"); - } - Ok(()) - } - /// Parse minion request fn to_request(&self, data: &str) -> Option { match serde_json::from_str::(data) { @@ -133,7 +126,8 @@ impl SysMaster { /// Start sysmaster pub async fn init(&mut self) -> Result<(), SysinspectError> { log::info!("Starting master at {}", self.cfg.bind_addr()); - self.open_socket(&self.cfg.socket())?; + ensure_console_keypair(&self.cfg.root_dir())?; + std::fs::create_dir_all(self.cfg.console_keys_root()).map_err(SysinspectError::IoErr)?; self.vmcluster.init().await?; Ok(()) } @@ -241,84 +235,88 @@ impl SysMaster { pub(crate) async fn msg_query(&mut self, payload: &str) -> Option { let query = payload.split(";").map(|s| s.to_string()).collect::>(); if let [querypath, query, traits, mid, context] = query.as_slice() { - let is_virtual = query.to_lowercase().starts_with("v:"); - let query = query.to_lowercase().replace("v:", ""); - - log::debug!("Context: {context}"); - - let hostnames: Vec = query.split(',').map(|h| h.to_string()).collect(); - let mut tgt = MinionTarget::new(mid, ""); - tgt.set_scheme(querypath); - tgt.set_context_query(context); + return self.msg_query_data(querypath, query, traits, mid, context).await; + } - log::debug!( - "Querying minions for: {}, traits: {}, is virtual: {}", - query.bright_yellow(), - traits.bright_yellow(), - if is_virtual { "yes".bright_green() } else { "no".bright_red() } - ); + None + } - let mut targeted = false; - if is_virtual && let Some(decided) = self.vmcluster.decide(&query, traits).await { - for hostname in decided.iter() { - log::debug!("Virtual minion requested. Decided to run on a physical: {}", hostname.bright_yellow()); - tgt.add_hostname(hostname); - if !targeted { - targeted = true; - } - } - } else if !is_virtual { - for hostname in hostnames.iter() { - tgt.add_hostname(hostname); - if !targeted { - targeted = true; - } + async fn msg_query_data(&mut self, querypath: &str, query: &str, traits: &str, mid: &str, context: &str) -> Option { + let is_virtual = query.to_lowercase().starts_with("v:"); + let query = query.to_lowercase().replace("v:", ""); + + log::debug!("Context: {context}"); + + let hostnames: Vec = query.split(',').map(|h| h.to_string()).collect(); + let mut tgt = MinionTarget::new(mid, ""); + tgt.set_scheme(querypath); + tgt.set_context_query(context); + + log::debug!( + "Querying minions for: {}, traits: {}, is virtual: {}", + query.bright_yellow(), + traits.bright_yellow(), + if is_virtual { "yes".bright_green() } else { "no".bright_red() } + ); + + let mut targeted = false; + if is_virtual && let Some(decided) = self.vmcluster.decide(&query, traits).await { + for hostname in decided.iter() { + log::debug!("Virtual minion requested. Decided to run on a physical: {}", hostname.bright_yellow()); + tgt.add_hostname(hostname); + if !targeted { + targeted = true; } - tgt.set_traits_query(traits); - } - - if !targeted { - log::warn!( - "No suitable {}minion found for the query: {}, traits query: {}, context: {}", - if is_virtual { "virtual " } else { "" }, - if query.is_empty() { "".red() } else { query.bright_yellow() }, - if traits.is_empty() { "".red() } else { traits.bright_yellow() }, - if context.is_empty() { "".red() } else { context.bright_yellow() } - ); - return None; } - log::debug!("Target: {:#?}", tgt); - - let mut out: IndexMap = IndexMap::default(); - for em in self.cfg.fileserver_models() { - for (n, cs) in scan_files_sha256(self.cfg.fileserver_models_root(false).join(em), Some(MODEL_FILE_EXT)) { - out.insert(format!("/{}/{em}/{n}", self.cfg.fileserver_models_root(false).file_name().unwrap().to_str().unwrap()), cs); + } else if !is_virtual { + for hostname in hostnames.iter() { + tgt.add_hostname(hostname); + if !targeted { + targeted = true; } } + tgt.set_traits_query(traits); + } - let mut payload = String::from(""); - if tgt.scheme().eq(SCHEME_COMMAND) { - payload = query.to_owned(); - } - - let mut msg = MasterMessage::new( - RequestType::Command, - json!( - ModStatePayload::new(payload) - .set_uri(querypath.to_string()) - .add_files(out) - .set_models_root(self.cfg.fileserver_models_root(true).to_str().unwrap_or_default()) - ), // TODO: SID part + if !targeted { + log::warn!( + "No suitable {}minion found for the query: {}, traits query: {}, context: {}", + if is_virtual { "virtual " } else { "" }, + if query.is_empty() { "".red() } else { query.bright_yellow() }, + if traits.is_empty() { "".red() } else { traits.bright_yellow() }, + if context.is_empty() { "".red() } else { context.bright_yellow() } ); - msg.set_target(tgt); - msg.set_retcode(ProtoErrorCode::Success); + return None; + } + log::debug!("Target: {:#?}", tgt); - log::debug!("Constructed message: {:#?}", msg); + let mut out: IndexMap = IndexMap::default(); + for em in self.cfg.fileserver_models() { + for (n, cs) in scan_files_sha256(self.cfg.fileserver_models_root(false).join(em), Some(MODEL_FILE_EXT)) { + out.insert(format!("/{}/{em}/{n}", self.cfg.fileserver_models_root(false).file_name().unwrap().to_str().unwrap()), cs); + } + } - return Some(msg); + let mut payload = String::new(); + if tgt.scheme().eq(SCHEME_COMMAND) { + payload = query.to_owned(); } - None + let mut msg = MasterMessage::new( + RequestType::Command, + json!( + ModStatePayload::new(payload) + .set_uri(querypath.to_string()) + .add_files(out) + .set_models_root(self.cfg.fileserver_models_root(true).to_str().unwrap_or_default()) + ), + ); + msg.set_target(tgt); + msg.set_retcode(ProtoErrorCode::Success); + + log::debug!("Constructed message: {:#?}", msg); + + Some(msg) } fn msg_sensors_files(&mut self) -> MasterMessage { @@ -735,58 +733,402 @@ impl SysMaster { } } - pub async fn do_fifo(master: Arc>) { - log::trace!("Init local command channel"); + /// Build the formatted online-minion summary returned by the console `--online` command. + async fn online_minions_summary(&mut self) -> Result { + let mreg = self.mreg.lock().await; + let mut session = self.session.lock().await; + let ids = mreg.get_registered_ids()?; + let mut rows: Vec<(String, String, String, String, String, String)> = vec![]; + + for mid in &ids { + let alive = session.alive(mid); + let traits = match mreg.get(mid) { + Ok(Some(mrec)) => mrec.get_traits().to_owned(), + _ => HashMap::new(), + }; + let mut h = traits.get("system.hostname.fqdn").and_then(|v| v.as_str()).unwrap_or("unknown"); + if h.is_empty() { + h = traits.get("system.hostname").and_then(|v| v.as_str()).unwrap_or("unknown"); + } + let ip = traits.get("system.hostname.ip").and_then(|v| v.as_str()).unwrap_or("unknown"); + let mid_short = if mid.chars().count() > 8 { + format!("{}...{}", &mid[..4], &mid[mid.len() - 4..]) + } else { + mid.to_string() + }; + rows.push(( + h.to_string(), + if alive { h.bright_green().to_string() } else { h.red().to_string() }, + ip.to_string(), + if alive { ip.bright_blue().to_string() } else { ip.blue().to_string() }, + mid_short.clone(), + if alive { mid_short.bright_green().to_string() } else { mid_short.green().to_string() }, + )); + } + + let host_width = rows.iter().map(|r| r.0.chars().count()).max().unwrap_or(4).max("HOST".chars().count()); + let ip_width = rows.iter().map(|r| r.2.chars().count()).max().unwrap_or(2).max("IP".chars().count()); + let id_width = rows.iter().map(|r| r.4.chars().count()).max().unwrap_or(2).max("ID".chars().count()); + + let mut out = vec![ + format!( + "{} {} {}", + pad_visible(&"HOST".bright_yellow().to_string(), host_width), + pad_visible(&"IP".bright_yellow().to_string(), ip_width), + pad_visible(&"ID".bright_yellow().to_string(), id_width), + ), + format!("{} {} {}", "─".repeat(host_width), "─".repeat(ip_width), "─".repeat(id_width)), + ]; + + for (_, host, _, ip, _, mid) in rows { + out.push(format!( + "{} {} {}", + pad_visible(&host, host_width), + pad_visible(&ip, ip_width), + pad_visible(&mid, id_width), + )); + } + + Ok(out.join("\n")) + } + + /// Resolve target minions for console profile operations from id, traits query, or hostname query. + async fn selected_minions(&mut self, query: &str, traits: &str, mid: &str) -> Result, SysinspectError> { + let mut records = if !mid.is_empty() { + self.mreg.lock().await.get(mid)?.into_iter().collect::>() + } else if !traits.trim().is_empty() { + let traits = get_context(traits) + .ok_or_else(|| SysinspectError::InvalidQuery("Traits selector must be in key:value format".to_string()))? + .into_iter() + .collect::>(); + self.mreg + .lock() + .await + .get_by_traits(traits)? + } else { + self.mreg.lock().await.get_by_query(if query.trim().is_empty() { "*" } else { query })? + }; + records.sort_by(|a, b| a.id().cmp(b.id())); + Ok(records) + } + + /// Execute one profile console request and return its console response plus any outbound master messages to broadcast. + async fn profile_console_response( + &mut self, request: &ProfileConsoleRequest, query: &str, traits: &str, mid: &str, + ) -> Result<(ConsoleResponse, Vec), SysinspectError> { + fn require_profile_name(request: &ProfileConsoleRequest) -> Result<(), SysinspectError> { + if !request.name().trim().is_empty() { + return Ok(()); + } + + Err(SysinspectError::InvalidQuery("Profile name cannot be empty".to_string())) + } + + let repo = SysInspectModPak::new(self.cfg.get_mod_repo_root())?; + + match request.op() { + "new" => Ok(( + { + require_profile_name(request)?; + repo.new_profile(request.name())?; + ConsoleResponse { ok: true, message: format!("Created profile {}", request.name().bright_yellow()) } + }, + vec![], + )), + "delete" => Ok(( + { + require_profile_name(request)?; + repo.delete_profile(request.name())?; + ConsoleResponse { ok: true, message: format!("Deleted profile {}", request.name().bright_yellow()) } + }, + vec![], + )), + "list" => Ok(( + ConsoleResponse { + ok: true, + message: if request.name().is_empty() { + repo.list_profiles(None)?.join("\n") + } else { + repo.list_profile_matches(Some(request.name()), request.library())?.join("\n") + }, + }, + vec![], + )), + "show" => Ok(( + { + require_profile_name(request)?; + ConsoleResponse { ok: true, message: repo.show_profile(request.name())? } + }, + vec![], + )), + "add" => Ok(( + { + require_profile_name(request)?; + repo.add_profile_matches(request.name(), request.matches().to_vec(), request.library())?; + ConsoleResponse { ok: true, message: format!("Updated profile {}", request.name().bright_yellow()) } + }, + vec![], + )), + "remove" => Ok(( + { + require_profile_name(request)?; + repo.remove_profile_matches(request.name(), request.matches().to_vec(), request.library())?; + ConsoleResponse { ok: true, message: format!("Updated profile {}", request.name().bright_yellow()) } + }, + vec![], + )), + "tag" | "untag" => { + let known_profiles = repo.list_profiles(None)?; + let missing = request.profiles().iter().filter(|name| !known_profiles.contains(name)).cloned().collect::>(); + if !missing.is_empty() { + return Ok(( + ConsoleResponse { + ok: false, + message: format!("Unknown profile{}: {}", if missing.len() == 1 { "" } else { "s" }, missing.join(", ").bright_yellow()), + }, + vec![], + )); + } + + let mut msgs = Vec::new(); + for minion in self.selected_minions(query, traits, mid).await? { + let mut profiles = match minion.get_traits().get("minion.profile") { + Some(serde_json::Value::String(name)) if !name.trim().is_empty() => vec![name.to_string()], + Some(serde_json::Value::Array(names)) => names.iter().filter_map(|name| name.as_str().map(str::to_string)).collect::>(), + _ => vec![], + }; + profiles.retain(|profile| profile != "default"); + if request.op() == "tag" { + for profile in request.profiles() { + if !profiles.contains(profile) { + profiles.push(profile.to_string()); + } + } + } else { + profiles.retain(|profile| !request.profiles().contains(profile)); + } + if let Some(msg) = self + .msg_query_data( + &format!("{SCHEME_COMMAND}{CLUSTER_TRAITS_UPDATE}"), + "", + "", + minion.id(), + &if profiles.is_empty() { + json!({"op": "unset", "traits": {"minion.profile": null}}) + } else { + json!({"op": "set", "traits": {"minion.profile": profiles}}) + } + .to_string(), + ) + .await + { + msgs.push(msg); + } + } + + Ok(( + ConsoleResponse { + ok: true, + message: format!( + "{} {} on {} minion{}", + if request.op() == "tag" { "Applied profiles" } else { "Removed profiles" }, + request.profiles().join(", ").bright_yellow(), + msgs.len(), + if msgs.len() == 1 { "" } else { "s" } + ), + }, + msgs, + )) + } + _ => Ok(( + ConsoleResponse { ok: false, message: format!("Unsupported profile operation {}", request.op().bright_yellow()) }, + vec![], + )), + } + } + + /// Start the encrypted TCP console listener used by `sysinspect` to talk to the master. + pub async fn do_console(master: Arc>) { + log::trace!("Init local console channel"); tokio::spawn({ - let master = Arc::clone(&master); // Don't move master into the closure multiple times. + let master = Arc::clone(&master); async move { - // Only lock to get broadcast and cfg, then drop immediately. let (cfg, bcast) = { - let master = master.lock().await; - (master.cfg(), master.broadcast().clone()) + let guard = master.lock().await; + (guard.cfg(), guard.broadcast().clone()) + }; + let master_prk = match load_master_private_key(&cfg) { + Ok(prk) => prk, + Err(err) => { + log::error!("Failed to load console private key: {err}"); + return; + } + }; + let listener = match TcpListener::bind(cfg.console_listen_addr()).await { + Ok(listener) => listener, + Err(err) => { + log::error!("Failed to bind console listener: {err}"); + return; + } }; loop { - match OpenOptions::new().read(true).open(cfg.socket()).await { - Ok(file) => { - let reader = TokioBufReader::new(file); - let mut lines = reader.lines(); - - loop { - select! { - line = lines.next_line() => { - match line { - Ok(Some(payload)) => { - log::debug!("Querying minions: {payload}"); - let msg = { - let mut guard = master.lock().await; - guard.msg_query(&payload).await - }; - - if msg.is_none() { - log::warn!("No message constructed for the query: {}", payload.bright_yellow()); - continue; - } - - SysMaster::bcast_master_msg(&bcast, cfg.telemetry_enabled(), Arc::clone(&master), msg.clone()).await; - { - let guard = master.lock().await; - let ids = guard.mreg.lock().await.get_targeted_minions(msg.as_ref().unwrap().target(), false).await; - guard.taskreg.lock().await.register(msg.as_ref().unwrap().cycle(), ids); + match listener.accept().await { + Ok((stream, peer)) => { + let master = Arc::clone(&master); + let cfg = cfg.clone(); + let bcast = bcast.clone(); + let master_prk = master_prk.clone(); + tokio::spawn(async move { + let (read_half, mut write_half) = stream.into_split(); + let reader = TokioBufReader::new(read_half); + let mut frame = Vec::new(); + let mut reader = reader.take((MAX_CONSOLE_FRAME_SIZE + 1) as u64); + let reply = match time::timeout(CONSOLE_READ_TIMEOUT, reader.read_until(b'\n', &mut frame)).await { + Err(_) => serde_json::to_string(&ConsoleResponse { + ok: false, + message: format!("Console request timed out after {} seconds", CONSOLE_READ_TIMEOUT.as_secs()), + }) + .ok(), + Ok(Ok(0)) => { + serde_json::to_string(&ConsoleResponse { ok: false, message: "Empty console request".to_string() }).ok() + } + Ok(Ok(_)) if frame.len() > MAX_CONSOLE_FRAME_SIZE || !frame.ends_with(b"\n") => { + serde_json::to_string(&ConsoleResponse { + ok: false, + message: format!("Console request exceeds {} bytes", MAX_CONSOLE_FRAME_SIZE), + }) + .ok() + } + Ok(Ok(_)) => match String::from_utf8(frame).map(|line| line.trim().to_string()) { + Ok(line) => match serde_json::from_str::(&line) { + Ok(envelope) => { + if !authorised_console_client(&cfg, &envelope.bootstrap.client_pubkey).unwrap_or(false) { + serde_json::to_string(&ConsoleResponse { + ok: false, + message: "Console client key is not authorised".to_string(), + }) + .ok() + } else { + match envelope.bootstrap.session_key(&master_prk) { + Ok((key, _client_pkey)) => { + let response = match envelope.sealed.open::(&key) { + Ok(query) => { + if query.model.eq(&format!("{SCHEME_COMMAND}{CLUSTER_ONLINE_MINIONS}")) { + match master.lock().await.online_minions_summary().await { + Ok(summary) => ConsoleResponse { ok: true, message: summary }, + Err(err) => ConsoleResponse { + ok: false, + message: format!("Unable to get online minions: {err}"), + }, + } + } else if query.model.eq(&format!("{SCHEME_COMMAND}{CLUSTER_PROFILE}")) { + let (response, msgs) = match ProfileConsoleRequest::from_context(&query.context) { + Ok(request) => { + let mut guard = master.lock().await; + match guard.profile_console_response(&request, &query.query, &query.traits, &query.mid).await { + Ok(data) => data, + Err(err) => (ConsoleResponse { ok: false, message: err.to_string() }, vec![]), + } + } + Err(err) => ( + ConsoleResponse { + ok: false, + message: format!("Failed to parse profile request: {err}"), + }, + vec![], + ), + }; + for msg in msgs { + SysMaster::bcast_master_msg( + &bcast, + cfg.telemetry_enabled(), + Arc::clone(&master), + Some(msg.clone()), + ) + .await; + let guard = master.lock().await; + let ids = guard.mreg.lock().await.get_targeted_minions(msg.target(), false).await; + guard.taskreg.lock().await.register(msg.cycle(), ids); + } + response + } else { + let msg = { + let mut guard = master.lock().await; + guard.msg_query_data(&query.model, &query.query, &query.traits, &query.mid, &query.context).await + }; + if let Some(msg) = msg { + SysMaster::bcast_master_msg( + &bcast, + cfg.telemetry_enabled(), + Arc::clone(&master), + Some(msg.clone()), + ) + .await; + let guard = master.lock().await; + let ids = guard.mreg.lock().await.get_targeted_minions(msg.target(), false).await; + guard.taskreg.lock().await.register(msg.cycle(), ids); + ConsoleResponse { ok: true, message: format!("Accepted console command from {peer}") } + } else { + ConsoleResponse { + ok: false, + message: "No message constructed for the console query".to_string(), + } + } + } + } + Err(err) => ConsoleResponse { + ok: false, + message: format!("Failed to open console query: {err}"), + }, + }; + match ConsoleSealed::seal(&response, &key).and_then(|sealed| { + serde_json::to_string(&sealed) + .map_err(|e| SysinspectError::SerializationError(e.to_string())) + }) { + Ok(reply) => Some(reply), + Err(err) => { + log::error!("Failed to seal console response: {err}"); + serde_json::to_string(&ConsoleResponse { + ok: false, + message: format!("Failed to seal console response: {err}"), + }) + .ok() + } + } + } + Err(err) => serde_json::to_string(&ConsoleResponse { + ok: false, + message: format!("Console bootstrap failed: {err}"), + }) + .ok(), + } } } - Ok(None) => break, // End of file, re-open the FIFO - Err(e) => { - log::error!("Error reading from FIFO: {e}"); - break; + Err(err) => { + serde_json::to_string(&ConsoleResponse { + ok: false, + message: format!("Failed to parse console request: {err}"), + }) + .ok() } + }, + Err(err) => { + serde_json::to_string(&ConsoleResponse { ok: false, message: format!("Console request is not valid UTF-8: {err}") }).ok() } - } + }, + Ok(Err(err)) => serde_json::to_string(&ConsoleResponse { ok: false, message: format!("Failed to read console request: {err}") }).ok(), + }; + + if let Some(reply) = reply + && let Err(err) = write_half.write_all(format!("{reply}\n").as_bytes()).await + { + log::error!("Failed to write console response: {err}"); } - } + }); } - Err(e) => { - log::error!("Failed to open FIFO: {e}"); - sleep(Duration::from_secs(1)).await; // Retry after a sec + Err(err) => { + log::error!("Console listener accept error: {err}"); + sleep(Duration::from_secs(1)).await; } } } @@ -1006,9 +1348,8 @@ pub(crate) async fn master(cfg: MasterConfig) -> Result<(), SysinspectError> { let scheduler = SysMaster::do_scheduler_service(Arc::clone(&master)).await; libtelemetry::init_otel_collector(cfg).await?; - // Task to read from the FIFO and broadcast messages to clients - SysMaster::do_fifo(Arc::clone(&master)).await; - log::info!("Local command channel initialized"); + SysMaster::do_console(Arc::clone(&master)).await; + log::info!("Local console channel initialized"); // Handle incoming messages from minions SysMaster::do_incoming(Arc::clone(&master), client_rx).await; diff --git a/sysminion/src/minion.rs b/sysminion/src/minion.rs index eb05a501..484eb288 100644 --- a/sysminion/src/minion.rs +++ b/sysminion/src/minion.rs @@ -34,7 +34,7 @@ use libsysinspect::{ fmt::{formatter::StringFormatter, kvfmt::KeyValueFormatter}, }, rsa, - traits::{self}, + traits::{self, TraitUpdateRequest, effective_profiles, ensure_master_traits_file, systraits::SystemTraits}, util::{self, dataconv}, }; use libsysproto::{ @@ -43,7 +43,7 @@ use libsysproto::{ payload::{ModStatePayload, PayloadType}, query::{ MinionQuery, SCHEME_COMMAND, - commands::{CLUSTER_REBOOT, CLUSTER_REMOVE_MINION, CLUSTER_ROTATE, CLUSTER_SHUTDOWN, CLUSTER_SYNC}, + commands::{CLUSTER_REBOOT, CLUSTER_REMOVE_MINION, CLUSTER_ROTATE, CLUSTER_SHUTDOWN, CLUSTER_SYNC, CLUSTER_TRAITS_UPDATE}, }, rqtypes::{ProtoValue, RequestType}, }; @@ -171,6 +171,7 @@ impl SysMinion { log::debug!("Creating directory for the drop-in traits at {}", self.cfg.traits_dir().as_os_str().to_str().unwrap_or_default()); fs::create_dir_all(self.cfg.traits_dir())?; } + ensure_master_traits_file(&self.cfg)?; // Place for trait functions if !self.cfg.functions_dir().exists() { @@ -184,11 +185,22 @@ impl SysMinion { fs::create_dir_all(self.cfg.sensors_dir())?; } + if !self.cfg.profiles_dir().exists() { + log::debug!("Creating directory for the synced profiles at {}", self.cfg.profiles_dir().as_os_str().to_str().unwrap_or_default()); + fs::create_dir_all(self.cfg.profiles_dir())?; + } + let mut out: Vec = vec![]; for t in traits::get_minion_traits(Some(&self.cfg)).trait_keys() { out.push(format!("{}: {}", t.to_owned(), dataconv::to_string(traits::get_minion_traits(None).get(&t)).unwrap_or_default())); } log::debug!("Minion traits:\n{}", out.join("\n")); + let profiles = effective_profiles(&self.cfg); + log::info!( + "{} {}", + if profiles.len() == 1 { "Activating profile" } else { "Activating profiles" }, + profiles.iter().map(|name| name.bright_yellow().to_string()).collect::>().join(", ") + ); Ok(()) } @@ -487,7 +499,7 @@ impl SysMinion { let scheme = msg.target().scheme().to_string(); if scheme.starts_with(SCHEME_COMMAND) { - this.as_ptr().call_internal_command(&scheme).await; + this.as_ptr().call_internal_command(&scheme, msg.target().context()).await; continue; } @@ -621,7 +633,8 @@ impl SysMinion { } pub async fn send_traits(self: Arc) -> Result<(), SysinspectError> { - let mut r = MinionMessage::new(self.get_minion_id().to_string(), RequestType::Traits, traits::get_minion_traits(None).to_json_value()?); + let fresh_traits = SystemTraits::new(self.cfg.clone(), false); + let mut r = MinionMessage::new(self.get_minion_id().to_string(), RequestType::Traits, fresh_traits.to_json_value()?); r.set_sid(MINION_SID.to_string()); self.request(r.sendable().map_err(|e| { log::error!("Error preparing traits message: {e}"); @@ -633,10 +646,11 @@ impl SysMinion { /// Send ehlo pub async fn send_ehlo(self: Arc) -> Result<(), SysinspectError> { + let fresh_traits = SystemTraits::new(self.cfg.clone(), false); let mut r = MinionMessage::new( - dataconv::as_str(traits::get_minion_traits(None).get(traits::SYS_ID)), + dataconv::as_str(fresh_traits.get(traits::SYS_ID)), RequestType::Ehlo, - traits::get_minion_traits(None).to_json_value()?, + fresh_traits.to_json_value()?, ); r.set_sid(MINION_SID.to_string()); @@ -823,7 +837,7 @@ impl SysMinion { } /// Calls internal command - async fn call_internal_command(self: Arc, cmd: &str) { + async fn call_internal_command(self: Arc, cmd: &str, context: &str) { let cmd = cmd.strip_prefix(SCHEME_COMMAND).unwrap_or_default(); match cmd { CLUSTER_SHUTDOWN => { @@ -843,11 +857,49 @@ impl SysMinion { } CLUSTER_SYNC => { log::info!("Syncing the minion with the master"); + if let Err(e) = ensure_master_traits_file(&self.cfg) { + log::error!("Failed to ensure master-managed traits file: {e}"); + } if let Err(e) = SysInspectModPakMinion::new(self.cfg.clone()).sync().await { log::error!("Failed to sync minion with master: {e}"); } + if let Err(e) = self.as_ptr().send_traits().await { + log::error!("Failed to sync traits with master: {e}"); + } let _ = self.as_ptr().send_sensors_sync().await; } + CLUSTER_TRAITS_UPDATE => { + match TraitUpdateRequest::from_context(context) { + Ok(update) => match update.apply(&self.cfg) { + Ok(_) => { + let summary = if update.op() == "reset" { + "all master-managed traits".bright_yellow().to_string() + } else if update.op() == "unset" { + update.traits().keys().map(|key| key.yellow().to_string()).collect::>().join(", ") + } else { + update + .traits() + .iter() + .map(|(key, value)| format!("{}: {}", key.yellow(), dataconv::to_string(Some(value.clone())).unwrap_or_default().bright_yellow())) + .collect::>() + .join(", ") + }; + let label = match update.op() { + "set" => "Set traits", + "unset" => "Unset traits", + "reset" => "Reset traits", + _ => "Updated traits", + }; + log::info!("{}: {}", label, summary); + if let Err(err) = self.as_ptr().send_traits().await { + log::error!("Failed to sync traits with master: {err}"); + } + } + Err(err) => log::error!("Failed to apply traits update: {err}"), + }, + Err(err) => log::error!("Failed to parse traits update payload: {err}"), + } + } _ => { log::warn!("Unknown command: {cmd}"); } @@ -917,7 +969,7 @@ impl SysMinion { match PayloadType::try_from(cmd.payload().clone()) { Ok(PayloadType::ModelOrStatement(pld)) => { if cmd.target().scheme().starts_with(SCHEME_COMMAND) { - self.as_ptr().call_internal_command(cmd.target().scheme()).await; + self.as_ptr().call_internal_command(cmd.target().scheme(), cmd.target().context()).await; } else { self.as_ptr().launch_sysinspect(cmd.cycle(), cmd.target().scheme(), &pld, cmd.target().context()).await; log::debug!("Command dispatched");