diff --git a/docs/v2.0/configuration-advanced.md b/docs/v2.0/configuration-advanced.md index bc83d7c1..c466bdb8 100644 --- a/docs/v2.0/configuration-advanced.md +++ b/docs/v2.0/configuration-advanced.md @@ -155,6 +155,32 @@ This can be used to exclude entire subscriber populations from LibreQoS shaping Use this carefully: `ignore_subnets` is broader than a billing-only toggle. The same setting also affects other LibreQoS/Insight IP-policy handling. +#### Dynamic circuits (optional) + +LibreQoS can be configured with an optional `[dynamic_circuits]` section. This is intended for the dynamic-circuit overlay layer (including unknown-IP promotion workflows). + +Example: + +```toml +[dynamic_circuits] +enabled = false +ttl_seconds = 300 +enable_unknown_ip_promotion = false + +[[dynamic_circuits.ranges]] +name = "Default" +ip_range = "0.0.0.0" # shorthand for 0.0.0.0/0 +download_min_mbps = 10.0 +upload_min_mbps = 10.0 +download_max_mbps = 100.0 +upload_max_mbps = 100.0 +attach_to = "" # optional network.json node name +``` + +Notes: +- `ip_range` must be a CIDR. `0.0.0.0` (and `::`) are allowed shorthands for the match-all `/0` networks. +- `attach_to` is a `network.json` node name (optional; empty is allowed). + #### CRM/NMS Integrations Learn more about [configuring integrations here](integrations.md). diff --git a/src/rust/Cargo.lock b/src/rust/Cargo.lock index d8705f06..b637645a 100644 --- a/src/rust/Cargo.lock +++ b/src/rust/Cargo.lock @@ -26,7 +26,7 @@ checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ "cfg-if", "cipher", - "cpufeatures", + "cpufeatures 0.2.17", ] [[package]] @@ -93,7 +93,7 @@ checksum = "fe233a377643e0fc1a56421d7c90acdec45c291b30345eb9f08e8d0ddce5a4ab" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -119,9 +119,9 @@ checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" [[package]] name = "anstream" -version = "0.6.21" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" dependencies = [ "anstyle", "anstyle-parse", @@ -134,15 +134,15 @@ 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" -version = "0.2.7" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" dependencies = [ "utf8parse", ] @@ -169,15 +169,15 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.101" +version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" [[package]] name = "arc-swap" -version = "1.8.1" +version = "1.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ded5f9a03ac8f24d1b8a25101ee812cd32cdc8c50a4c50237de2c4915850e73" +checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207" dependencies = [ "rustversion", ] @@ -190,7 +190,7 @@ checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" dependencies = [ "base64ct", "blake2", - "cpufeatures", + "cpufeatures 0.2.17", "password-hash", ] @@ -202,7 +202,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -229,9 +229,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.39.0" +version = "0.39.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa7e52a4c5c547c741610a2c6f123f3881e409b714cd27e6798ef020c514f0a" +checksum = "83a25cf98105baa966497416dbd42565ce3a8cf8dbfd59803ec9ad46f3126399" dependencies = [ "cc", "cmake", @@ -348,7 +348,7 @@ version = "0.71.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f58bf3d7db68cfbac37cfc485a8d711e87e064c3d0fe0435b92f7a407f9d6b3" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "cexpr", "clang-sys", "itertools 0.13.0", @@ -359,7 +359,7 @@ dependencies = [ "regex", "rustc-hash", "shlex", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -370,9 +370,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.10.0" +version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" [[package]] name = "blake2" @@ -403,9 +403,9 @@ dependencies = [ [[package]] name = "bumpalo" -version = "3.19.1" +version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" [[package]] name = "byteorder" @@ -442,9 +442,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.55" +version = "1.2.60" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b26a0954ae34af09b50f0de26458fa95369a0d478d8236d3f93082b219bd29" +checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20" dependencies = [ "find-msvc-tools", "jobserver", @@ -487,14 +487,25 @@ checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" dependencies = [ "cfg-if", "cipher", - "cpufeatures", + "cpufeatures 0.2.17", +] + +[[package]] +name = "chacha20" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", ] [[package]] name = "chrono" -version = "0.4.43" +version = "0.4.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" dependencies = [ "iana-time-zone", "js-sys", @@ -554,9 +565,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.57" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6899ea499e3fb9305a65d5ebf6e3d2248c5fab291f300ad0a704fbe142eae31a" +checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" dependencies = [ "clap_builder", "clap_derive", @@ -564,9 +575,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.57" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b12c8b680195a62a8364d16b8447b01b6c2c8f9aaf68bee653be34d4245e238" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" dependencies = [ "anstream", "anstyle", @@ -576,36 +587,46 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.5.55" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" +checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] name = "clap_lex" -version = "0.7.7" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] name = "cmake" -version = "0.1.57" +version = "0.1.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" dependencies = [ "cc", ] [[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" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c" +dependencies = [ + "lazy_static", + "windows-sys 0.59.0", +] [[package]] name = "combine" @@ -694,6 +715,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.5.0" @@ -787,7 +817,7 @@ version = "0.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f476fe445d41c9e991fd07515a6f463074b782242ccf4a5b7b1d1012e70824df" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "crossterm_winapi", "libc", "mio 0.8.11", @@ -803,9 +833,9 @@ version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "crossterm_winapi", - "mio 1.1.1", + "mio 1.2.0", "parking_lot", "rustix 0.38.44", "signal-hook", @@ -881,9 +911,9 @@ dependencies = [ [[package]] name = "ctrlc" -version = "3.5.1" +version = "3.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73736a89c4aff73035ba2ed2e565061954da00d4970fc9ac25dcc85a2a20d790" +checksum = "e0b1fab2ae45819af2d0731d60f2afe17227ebb1a1538a236da84c93e9a60162" dependencies = [ "dispatch2", "nix", @@ -948,7 +978,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "curve25519-dalek-derive", "fiat-crypto", "rustc_version", @@ -964,7 +994,7 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -987,7 +1017,7 @@ dependencies = [ "ident_case", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -998,7 +1028,7 @@ checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" dependencies = [ "darling_core", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -1039,9 +1069,9 @@ dependencies = [ [[package]] name = "deranged" -version = "0.5.5" +version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" dependencies = [ "powerfmt", ] @@ -1059,11 +1089,11 @@ dependencies = [ [[package]] name = "dispatch2" -version = "0.3.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "block2", "libc", "objc2", @@ -1077,7 +1107,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -1097,8 +1127,8 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e73e0fee365832cd9b9a53ea62f944cc0d7a4c71f2b9c96a28fc74749517afa" dependencies = [ - "bitflags 2.10.0", - "chacha20", + "bitflags 2.11.0", + "chacha20 0.9.1", "curve25519-dalek", "generic-array", "lazy_static", @@ -1150,7 +1180,7 @@ checksum = "f282cfdfe92516eb26c2af8589c274c7c17681f5ecc03c18255fe741c6aa64eb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -1171,7 +1201,7 @@ dependencies = [ "darling", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -1192,9 +1222,9 @@ dependencies = [ [[package]] name = "fastrand" -version = "2.3.0" +version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" [[package]] name = "fiat-crypto" @@ -1288,9 +1318,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", "futures-sink", @@ -1298,44 +1328,44 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" [[package]] name = "futures-io" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" [[package]] name = "futures-macro" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] name = "futures-sink" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" [[package]] name = "futures-task" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" [[package]] name = "futures-util" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ "futures-core", "futures-io", @@ -1344,7 +1374,6 @@ dependencies = [ "futures-task", "memchr", "pin-project-lite", - "pin-utils", "slab", ] @@ -1386,19 +1415,20 @@ checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 5.3.0", "wasip2", ] [[package]] name = "getrandom" -version = "0.4.1" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 6.0.0", + "rand_core 0.10.1", "wasip2", "wasip3", ] @@ -1474,9 +1504,9 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.16.1" +version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" [[package]] name = "heck" @@ -1552,9 +1582,9 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "hyper" -version = "1.8.1" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" dependencies = [ "atomic-waker", "bytes", @@ -1567,7 +1597,6 @@ dependencies = [ "httpdate", "itoa", "pin-project-lite", - "pin-utils", "smallvec", "tokio", "want", @@ -1575,15 +1604,14 @@ dependencies = [ [[package]] name = "hyper-rustls" -version = "0.27.7" +version = "0.27.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +checksum = "c2b52f86d1d4bc0d6b4e6826d960b1b333217e07d36b882dca570a5e1c48895b" dependencies = [ "http", "hyper", "hyper-util", "rustls", - "rustls-pki-types", "tokio", "tokio-rustls", "tower-service", @@ -1640,12 +1668,13 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" dependencies = [ "displaydoc", "potential_utf", + "utf8_iter", "yoke", "zerofrom", "zerovec", @@ -1653,9 +1682,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" dependencies = [ "displaydoc", "litemap", @@ -1666,9 +1695,9 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -1680,15 +1709,15 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" [[package]] name = "icu_properties" -version = "2.1.2" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" dependencies = [ "icu_collections", "icu_locale_core", @@ -1700,15 +1729,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.1.2" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" [[package]] name = "icu_provider" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" dependencies = [ "displaydoc", "icu_locale_core", @@ -1754,12 +1783,12 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.13.0" +version = "2.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.16.1", + "hashbrown 0.17.0", "serde", "serde_core", ] @@ -1826,15 +1855,15 @@ checksum = "8e537132deb99c0eb4b752f0346b6a836200eaaa3516dd7e5514b63930a09e5d" [[package]] name = "ipnet" -version = "2.11.0" +version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" [[package]] name = "iri-string" -version = "0.7.10" +version = "0.7.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" +checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20" dependencies = [ "memchr", "serde", @@ -1866,9 +1895,9 @@ dependencies = [ [[package]] name = "itoa" -version = "1.0.17" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "jni" @@ -1879,7 +1908,7 @@ dependencies = [ "cesu8", "cfg-if", "combine", - "jni-sys", + "jni-sys 0.3.1", "log", "thiserror", "walkdir", @@ -1888,9 +1917,31 @@ dependencies = [ [[package]] name = "jni-sys" -version = "0.3.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.117", +] [[package]] name = "jobserver" @@ -1904,10 +1955,12 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.85" +version = "0.3.95" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" +checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca" dependencies = [ + "cfg-if", + "futures-util", "once_cell", "wasm-bindgen", ] @@ -1946,9 +1999,9 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "libbpf-sys" -version = "1.6.2+v1.6.2" +version = "1.7.0+v1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba0346fc595fa2c8e274903e8a0e3ed5e6a29183af167567f6289fd3b116881b" +checksum = "a109478760b2900aa2a6f2087e9d0de1d9c535b1758602af2845d5d2ccfaed7c" dependencies = [ "cc", "nix", @@ -1957,9 +2010,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.181" +version = "0.2.185" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "459427e2af2b9c839b132acb702a1c654d95e10f8c326bfc2ad11310e458b1c5" +checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f" [[package]] name = "libloading" @@ -1973,13 +2026,14 @@ dependencies = [ [[package]] name = "libredox" -version = "0.1.12" +version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" +checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "libc", - "redox_syscall 0.7.0", + "plain", + "redox_syscall 0.7.4", ] [[package]] @@ -1990,15 +2044,15 @@ checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" [[package]] name = "linux-raw-sys" -version = "0.11.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" [[package]] name = "lock_api" @@ -2071,7 +2125,7 @@ dependencies = [ "sha2", "thiserror", "toml", - "toml_edit 0.24.1+spec-1.1.0", + "toml_edit 0.25.11+spec-1.1.0", "tracing", "uuid", ] @@ -2117,16 +2171,39 @@ dependencies = [ "uuid", ] +[[package]] +name = "lqos_network_devices" +version = "0.1.0" +dependencies = [ + "anyhow", + "arc-swap", + "crossbeam-channel", + "fxhash", + "ip_network", + "ip_network_table", + "lqos_config", + "lqos_topology_compile", + "lqos_utils", + "once_cell", + "parking_lot", + "serde", + "serde_json", + "tokio", + "tracing", +] + [[package]] name = "lqos_overrides" version = "0.1.0" dependencies = [ "anyhow", "clap", + "lqos_bus", "lqos_config", "nix", "serde", "serde_json", + "tokio", ] [[package]] @@ -2154,6 +2231,7 @@ dependencies = [ "lqos_bakery", "lqos_bus", "lqos_config", + "lqos_network_devices", "lqos_overrides", "lqos_sys", "lqos_topology_compile", @@ -2221,6 +2299,7 @@ dependencies = [ "lqos_bakery", "lqos_bus", "lqos_config", + "lqos_network_devices", "lqos_overrides", "lqos_probe", "lqos_queue_tracker", @@ -2230,6 +2309,22 @@ dependencies = [ "tracing", ] +[[package]] +name = "lqos_support_tool" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "colored", + "lqos_config", + "lqos_network_devices", + "miniz_oxide", + "nix", + "serde", + "serde_cbor", + "serde_json", +] + [[package]] name = "lqos_sys" version = "0.1.0" @@ -2326,6 +2421,7 @@ dependencies = [ "lqos_config", "lqos_heimdall", "lqos_netplan_helper", + "lqos_network_devices", "lqos_overrides", "lqos_probe", "lqos_queue_tracker", @@ -2480,9 +2576,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", "log", @@ -2509,17 +2605,17 @@ dependencies = [ [[package]] name = "native-tls" -version = "0.2.14" +version = "0.2.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" dependencies = [ "libc", "log", "openssl", - "openssl-probe 0.1.6", + "openssl-probe", "openssl-sys", "schannel", - "security-framework 2.11.1", + "security-framework", "security-framework-sys", "tempfile", ] @@ -2574,11 +2670,11 @@ dependencies = [ [[package]] name = "nix" -version = "0.30.1" +version = "0.31.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" +checksum = "5d6d0705320c1e6ba1d912b5e37cf18071b6c2e9b7fa8215a1e8a7651966f5d3" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "cfg-if", "cfg_aliases", "libc", @@ -2607,7 +2703,7 @@ version = "6.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6205bd8bb1e454ad2e27422015fb5e4f2bcc7e08fa8f27058670d208324a4d2d" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "crossbeam-channel", "filetime", "fsevent-sys", @@ -2662,9 +2758,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.2.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" [[package]] name = "num-integer" @@ -2716,20 +2812,20 @@ dependencies = [ [[package]] name = "objc2" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c2599ce0ec54857b29ce62166b0ed9b4f6f1a70ccc9a71165b6154caca8c05" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" dependencies = [ "objc2-encode", ] [[package]] name = "objc2-core-foundation" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c10c2894a6fed806ade6027bcd50662746363a9589d3ec9d9bef30a4e4bc166" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", ] [[package]] @@ -2740,9 +2836,9 @@ checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" [[package]] name = "objc2-io-kit" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71c1c64d6120e51cd86033f67176b1cb66780c2efe34dec55176f77befd93c0a" +checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15" dependencies = [ "libc", "objc2-core-foundation", @@ -2750,9 +2846,9 @@ dependencies = [ [[package]] name = "once_cell" -version = "1.21.3" +version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "once_cell_polyfill" @@ -2774,11 +2870,11 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" [[package]] name = "openssl" -version = "0.10.75" +version = "0.10.77" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" +checksum = "bfe4646e360ec77dff7dde40ed3d6c5fee52d156ef4a62f53973d38294dad87f" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "cfg-if", "foreign-types", "libc", @@ -2795,15 +2891,9 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] -[[package]] -name = "openssl-probe" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" - [[package]] name = "openssl-probe" version = "0.2.1" @@ -2812,9 +2902,9 @@ checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" [[package]] name = "openssl-sys" -version = "0.9.111" +version = "0.9.113" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" +checksum = "ad2f2c0eba47118757e4c6d2bff2838f3e0523380021356e7875e858372ce644" dependencies = [ "cc", "libc", @@ -2892,21 +2982,21 @@ dependencies = [ [[package]] name = "pin-project-lite" -version = "0.2.16" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" - -[[package]] -name = "pin-utils" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "pkg-config" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" [[package]] name = "plotters" @@ -2954,7 +3044,7 @@ dependencies = [ "proc-macro2", "quote", "regex", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -2985,7 +3075,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "opaque-debug", "universal-hash", ] @@ -2998,9 +3088,9 @@ checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" [[package]] name = "potential_utf" -version = "0.1.4" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" dependencies = [ "zerovec", ] @@ -3027,7 +3117,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -3085,7 +3175,7 @@ dependencies = [ "proc-macro2", "pyo3-macros-backend", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -3098,14 +3188,14 @@ dependencies = [ "proc-macro2", "pyo3-build-config", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] name = "quote" -version = "1.0.44" +version = "1.0.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" dependencies = [ "proc-macro2", ] @@ -3116,6 +3206,12 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "rand" version = "0.8.5" @@ -3129,14 +3225,25 @@ dependencies = [ [[package]] name = "rand" -version = "0.9.2" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" dependencies = [ "rand_chacha 0.9.0", "rand_core 0.9.5", ] +[[package]] +name = "rand" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +dependencies = [ + "chacha20 0.10.0", + "getrandom 0.4.2", + "rand_core 0.10.1", +] + [[package]] name = "rand_chacha" version = "0.3.1" @@ -3175,13 +3282,19 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + [[package]] name = "ratatui" version = "0.26.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f44c9e68fd46eda15c646fbb85e1040b657a58cdc8c98db1d97a55930d991eef" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "cassowary", "compact_str 0.7.1", "crossterm 0.27.0", @@ -3221,16 +3334,16 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", ] [[package]] name = "redox_syscall" -version = "0.7.0" +version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49f3fe0889e69e2ae9e41f4d6c4c0181701d00e4697b356fb1f74173a5e0ee27" +checksum = "f450ad9c3b1da563fb6948a8e0fb0fb9269711c9c73d9ea1de5058c79c8d643a" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", ] [[package]] @@ -3258,9 +3371,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.9" +version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" [[package]] name = "reqwest" @@ -3319,9 +3432,9 @@ dependencies = [ [[package]] name = "rustc-hash" -version = "2.1.1" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" [[package]] name = "rustc_version" @@ -3338,7 +3451,7 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "errno", "libc", "linux-raw-sys 0.4.15", @@ -3347,22 +3460,22 @@ dependencies = [ [[package]] name = "rustix" -version = "1.1.3" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "errno", "libc", - "linux-raw-sys 0.11.0", + "linux-raw-sys 0.12.1", "windows-sys 0.61.2", ] [[package]] name = "rustls" -version = "0.23.37" +version = "0.23.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +checksum = "69f9466fb2c14ea04357e91413efb882e2a6d4a406e625449bc0a5d360d53a21" dependencies = [ "aws-lc-rs", "log", @@ -3380,10 +3493,10 @@ version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" dependencies = [ - "openssl-probe 0.2.1", + "openssl-probe", "rustls-pki-types", "schannel", - "security-framework 3.5.1", + "security-framework", ] [[package]] @@ -3410,7 +3523,7 @@ dependencies = [ "rustls-native-certs", "rustls-platform-verifier-android", "rustls-webpki", - "security-framework 3.5.1", + "security-framework", "security-framework-sys", "webpki-root-certs", "windows-sys 0.61.2", @@ -3424,9 +3537,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" [[package]] name = "rustls-webpki" -version = "0.103.10" +version = "0.103.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" +checksum = "20a6af516fea4b20eccceaf166e8aa666ac996208e8a644ce3ef5aa783bc7cd4" dependencies = [ "aws-lc-rs", "ring", @@ -3466,9 +3579,9 @@ dependencies = [ [[package]] name = "schannel" -version = "0.1.28" +version = "0.1.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" dependencies = [ "windows-sys 0.61.2", ] @@ -3481,24 +3594,11 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] name = "security-framework" -version = "2.11.1" +version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags 2.10.0", - "core-foundation 0.9.4", - "core-foundation-sys", - "libc", - "security-framework-sys", -] - -[[package]] -name = "security-framework" -version = "3.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef" -dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "core-foundation 0.10.1", "core-foundation-sys", "libc", @@ -3507,9 +3607,9 @@ dependencies = [ [[package]] name = "security-framework-sys" -version = "2.15.0" +version = "2.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" dependencies = [ "core-foundation-sys", "libc", @@ -3517,9 +3617,9 @@ dependencies = [ [[package]] name = "semver" -version = "1.0.27" +version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" [[package]] name = "serde" @@ -3558,7 +3658,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -3596,9 +3696,9 @@ dependencies = [ [[package]] name = "serde_spanned" -version = "1.0.4" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" dependencies = [ "serde_core", ] @@ -3635,7 +3735,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -3646,7 +3746,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -3696,7 +3796,7 @@ checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" dependencies = [ "libc", "mio 0.8.11", - "mio 1.1.1", + "mio 1.2.0", "signal-hook", ] @@ -3712,9 +3812,9 @@ dependencies = [ [[package]] name = "simd-adler32" -version = "0.3.8" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" [[package]] name = "slab" @@ -3730,12 +3830,12 @@ checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" [[package]] name = "socket2" -version = "0.6.2" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -3751,7 +3851,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d904e7009df136af5297832a3ace3370cd14ff1546a232f4f185036c2736fcac" dependencies = [ "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -3791,7 +3891,7 @@ dependencies = [ "proc-macro2", "quote", "rustversion", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -3809,7 +3909,7 @@ dependencies = [ "hex", "parking_lot", "pnet_packet", - "rand 0.9.2", + "rand 0.9.4", "socket2", "thiserror", "tokio", @@ -3829,9 +3929,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.114" +version = "2.0.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" dependencies = [ "proc-macro2", "quote", @@ -3855,14 +3955,14 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] name = "sysinfo" -version = "0.38.1" +version = "0.38.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5792d209c2eac902426c0c4a166c9f72147db453af548cf9bf3242644c4d4fe3" +checksum = "92ab6a2f8bfe508deb3c6406578252e491d299cbbf3bc0529ecc3313aee4a52f" dependencies = [ "libc", "memchr", @@ -3889,7 +3989,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "core-foundation 0.9.4", "system-configuration-sys 0.6.0", ] @@ -3916,20 +4016,20 @@ dependencies = [ [[package]] name = "target-lexicon" -version = "0.13.4" +version = "0.13.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1dd07eb858a2067e2f3c7155d54e929265c264e6f37efe3ee7a8d1b5a1dd0ba" +checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" [[package]] name = "tempfile" -version = "3.25.0" +version = "3.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0136791f7c95b1f6dd99f9cc786b91bb81c3800b639b3478e561ddb7be95e5f1" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.1", + "getrandom 0.4.2", "once_cell", - "rustix 1.1.3", + "rustix 1.1.4", "windows-sys 0.61.2", ] @@ -3950,7 +4050,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -4006,9 +4106,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" dependencies = [ "displaydoc", "zerovec", @@ -4026,13 +4126,13 @@ dependencies = [ [[package]] name = "tokio" -version = "1.49.0" +version = "1.51.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" +checksum = "f66bf9585cda4b724d3e78ab34b73fb2bbaba9011b9bfdf69dc836382ea13b8c" dependencies = [ "bytes", "libc", - "mio 1.1.1", + "mio 1.2.0", "parking_lot", "pin-project-lite", "signal-hook-registry", @@ -4043,13 +4143,13 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.6.0" +version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -4122,9 +4222,9 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "0.7.5+spec-1.1.0" +version = "1.1.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" dependencies = [ "serde_core", ] @@ -4140,31 +4240,31 @@ dependencies = [ "serde_spanned 0.6.9", "toml_datetime 0.6.11", "toml_write", - "winnow", + "winnow 0.7.15", ] [[package]] name = "toml_edit" -version = "0.24.1+spec-1.1.0" +version = "0.25.11+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01f2eadbbc6b377a847be05f60791ef1058d9f696ecb51d2c07fe911d8569d8e" +checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" dependencies = [ "indexmap", "serde_core", - "serde_spanned 1.0.4", - "toml_datetime 0.7.5+spec-1.1.0", + "serde_spanned 1.1.1", + "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", "toml_writer", - "winnow", + "winnow 1.0.1", ] [[package]] name = "toml_parser" -version = "1.0.7+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "247eaa3197818b831697600aadf81514e577e0cba5eab10f7e064e78ae154df1" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ - "winnow", + "winnow 1.0.1", ] [[package]] @@ -4175,9 +4275,9 @@ checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" [[package]] name = "toml_writer" -version = "1.0.6+spec-1.1.0" +version = "1.1.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" [[package]] name = "tower" @@ -4201,7 +4301,7 @@ version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "bytes", "futures-core", "futures-util", @@ -4255,7 +4355,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -4281,9 +4381,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 = [ "matchers", "nu-ansi-term", @@ -4373,15 +4473,15 @@ checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" [[package]] name = "unicode-ident" -version = "1.0.23" +version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "537dd038a89878be9b64dd4bd1b260315c1bb94f4d784956b81e27a088d9a09e" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "unicode-segmentation" -version = "1.12.0" +version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" [[package]] name = "unicode-truncate" @@ -4485,13 +4585,13 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.20.0" +version = "1.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee48d38b119b0cd71fe4141b30f5ba9c7c5d9f4e7a3a8b4a674e4b6ef789976f" +checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9" dependencies = [ - "getrandom 0.3.4", + "getrandom 0.4.2", "js-sys", - "rand 0.9.2", + "rand 0.10.1", "serde_core", "wasm-bindgen", ] @@ -4559,9 +4659,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.108" +version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" +checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89" dependencies = [ "cfg-if", "once_cell", @@ -4572,23 +4672,19 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.58" +version = "0.4.68" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70a6e77fd0ae8029c9ea0063f87c46fde723e7d887703d74ad2616d792e51e6f" +checksum = "f371d383f2fb139252e0bfac3b81b265689bf45b6874af544ffa4c975ac1ebf8" dependencies = [ - "cfg-if", - "futures-util", "js-sys", - "once_cell", "wasm-bindgen", - "web-sys", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.108" +version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" +checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -4596,22 +4692,22 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.108" +version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" +checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.108" +version = "0.2.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" +checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129" dependencies = [ "unicode-ident", ] @@ -4644,7 +4740,7 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.11.0", "hashbrown 0.15.5", "indexmap", "semver", @@ -4652,9 +4748,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.85" +version = "0.3.95" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598" +checksum = "4f2dfbb17949fa2088e5d39408c48368947b86f7834484e87b73de55bc14d97d" dependencies = [ "js-sys", "wasm-bindgen", @@ -4780,7 +4876,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -4791,7 +4887,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] @@ -4875,15 +4971,6 @@ dependencies = [ "windows-targets 0.52.6", ] -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.5", -] - [[package]] name = "windows-sys" version = "0.61.2" @@ -4932,30 +5019,13 @@ dependencies = [ "windows_aarch64_gnullvm 0.52.6", "windows_aarch64_msvc 0.52.6", "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", + "windows_i686_gnullvm", "windows_i686_msvc 0.52.6", "windows_x86_64_gnu 0.52.6", "windows_x86_64_gnullvm 0.52.6", "windows_x86_64_msvc 0.52.6", ] -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", -] - [[package]] name = "windows-threading" version = "0.2.1" @@ -4983,12 +5053,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - [[package]] name = "windows_aarch64_msvc" version = "0.42.2" @@ -5007,12 +5071,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - [[package]] name = "windows_i686_gnu" version = "0.42.2" @@ -5031,24 +5089,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - [[package]] name = "windows_i686_msvc" version = "0.42.2" @@ -5067,12 +5113,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - [[package]] name = "windows_x86_64_gnu" version = "0.42.2" @@ -5091,12 +5131,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - [[package]] name = "windows_x86_64_gnullvm" version = "0.42.2" @@ -5115,12 +5149,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - [[package]] name = "windows_x86_64_msvc" version = "0.42.2" @@ -5140,16 +5168,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" +name = "winnow" +version = "0.7.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] [[package]] name = "winnow" -version = "0.7.14" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" +checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5" dependencies = [ "memchr", ] @@ -5184,7 +5215,7 @@ dependencies = [ "heck", "indexmap", "prettyplease", - "syn 2.0.114", + "syn 2.0.117", "wasm-metadata", "wit-bindgen-core", "wit-component", @@ -5200,7 +5231,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", "wit-bindgen-core", "wit-bindgen-rust", ] @@ -5212,7 +5243,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags 2.10.0", + "bitflags 2.11.0", "indexmap", "log", "serde", @@ -5244,9 +5275,9 @@ dependencies = [ [[package]] name = "writeable" -version = "0.6.2" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" [[package]] name = "xdp_iphash_to_cpu_cmdline" @@ -5276,9 +5307,9 @@ checksum = "a67300977d3dc3f8034dae89778f502b6ba20b269527b3223ba59c0cf393bb8a" [[package]] name = "yoke" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -5287,54 +5318,54 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", "synstructure", ] [[package]] name = "zerocopy" -version = "0.8.39" +version = "0.8.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.39" +version = "0.8.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] name = "zerofrom" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" dependencies = [ "zerofrom-derive", ] [[package]] name = "zerofrom-derive" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", "synstructure", ] @@ -5355,14 +5386,14 @@ checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] name = "zerotrie" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" dependencies = [ "displaydoc", "yoke", @@ -5371,9 +5402,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.5" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" dependencies = [ "yoke", "zerofrom", @@ -5382,17 +5413,17 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.2" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.117", ] [[package]] name = "zmij" -version = "1.0.20" +version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4de98dfa5d5b7fef4ee834d0073d560c9ca7b6c46a71d058c48db7960f8cfaf7" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/src/rust/Cargo.toml b/src/rust/Cargo.toml index 185cf5c9..f2a0f51d 100644 --- a/src/rust/Cargo.toml +++ b/src/rust/Cargo.toml @@ -24,6 +24,7 @@ codegen-units = 16 members = [ "lqos_sys", # System support for handling the XDP component "lqos_config", # Configuration support + "lqos_network_devices", # Shared network.json + ShapedDevices.csv runtime access layer "lqosd", # LibreQoS Daemon "lqos_bus", # Bus data types "lqtop", # A command line utility to show current activity @@ -43,6 +44,7 @@ members = [ "lqos_overrides", # A CLI tool and library for unifying the override system and allowing API support for changing network.json and ShapedDevices.csv "lqos_topology", # Shared topology runtime domain logic and health/effective-state helper. "lqos_topology_compile", # Shared topology compilation and mode projection layer. + "lqos_support_tool", # Support/sanity-check CLI for gathering and submitting support dumps. "lqos_netplan_helper", # Managed netplan inspection/transaction helper. ] @@ -64,7 +66,7 @@ serde_json = "1" csv = "1" ip_network_table = "0" ip_network = "0" -sha2 = "0" +sha2 = "0.10" hmac = "0.12" base64 = "0.22" argon2 = "0.5" diff --git a/src/rust/lqos_bakery/src/commands.rs b/src/rust/lqos_bakery/src/commands.rs index 66d6adc6..14230ab1 100644 --- a/src/rust/lqos_bakery/src/commands.rs +++ b/src/rust/lqos_bakery/src/commands.rs @@ -231,6 +231,28 @@ pub enum BakeryCommands { /// Optional per-circuit SQM override: "cake" or "fq_codel" sqm_override: Option, }, + /// Create or update a runtime-only dynamic circuit overlay entry. + /// + /// This does not mutate `ShapedDevices.csv`. Overlay circuits are retained across full reloads + /// and are applied to the active shaping tree when possible. + UpsertDynamicCircuitOverlay { + /// Shaped-device-like definition for the dynamic circuit/device. + shaped_device: Box, + /// Optional synchronous reply channel for immediate acceptance reporting. + #[allocative(skip)] + reply: Option, String>>>, + }, + /// Remove a runtime-only dynamic circuit overlay entry by circuit identifier. + /// + /// This does not mutate `ShapedDevices.csv`. When the circuit exists in the active shaping + /// tree, it is removed live when possible. + RemoveDynamicCircuitOverlay { + /// Stable circuit identifier to remove. + circuit_id: String, + /// Optional synchronous reply channel for immediate acceptance reporting. + #[allocative(skip)] + reply: Option>>, + }, /// Change a specific HTB class rate on-the-fly; optionally dry-run. StormGuardAdjustment { /// If true, log the tc command instead of executing it. diff --git a/src/rust/lqos_bakery/src/lib.rs b/src/rust/lqos_bakery/src/lib.rs index 6c1b546a..a2488ff7 100644 --- a/src/rust/lqos_bakery/src/lib.rs +++ b/src/rust/lqos_bakery/src/lib.rs @@ -29,7 +29,7 @@ use crossbeam_channel::{Receiver, RecvTimeoutError, Sender}; use parking_lot::RwLock; use std::collections::VecDeque; use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::sync::atomic::Ordering::Relaxed; use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}; use std::sync::{Arc, OnceLock}; @@ -70,6 +70,7 @@ use lqos_config::{ plan_top_level_assignments, }; use qdisc_handles::MqDeviceLayout; +use serde::Deserialize; use serde_json::{Map, Value}; const TEST_FAULT_ONCE_PATH: &str = "/tmp/lqos_bakery_fail_purpose_once.txt"; @@ -481,6 +482,520 @@ fn parse_ip_list(s: &str) -> Vec { .collect() } +const DYNAMIC_CIRCUITS_FILENAME: &str = "dynamic_circuits.json"; + +#[derive(Clone, Debug)] +struct DynamicCircuitOverlayEntry { + shaped_device: lqos_config::ShapedDevice, + class_minor: Option, +} + +#[derive(Debug, Deserialize)] +struct PersistedDynamicCircuit { + shaped: lqos_config::ShapedDevice, + #[allow(dead_code)] + #[serde(default)] + last_seen_unix: u64, +} + +#[derive(Debug, Default, Deserialize)] +struct PersistedDynamicCircuitsFile { + #[serde(default)] + #[allow(dead_code)] + schema_version: u32, + #[serde(default)] + circuits: Vec, +} + +fn dynamic_circuits_path(config: &Config) -> PathBuf { + Path::new(&config.lqos_directory).join(DYNAMIC_CIRCUITS_FILENAME) +} + +fn recompute_shaped_hashes(device: &mut lqos_config::ShapedDevice) { + device.circuit_hash = runtime_hash_to_i64(&device.circuit_id); + device.device_hash = runtime_hash_to_i64(&device.device_id); + device.parent_hash = runtime_hash_to_i64(&device.parent_node); +} + +fn load_dynamic_circuit_overlays_from_disk( + config: &Config, +) -> HashMap { + let path = dynamic_circuits_path(config); + if !path.exists() { + return HashMap::new(); + } + + let raw = match std::fs::read_to_string(&path) { + Ok(value) => value, + Err(err) => { + warn!( + "Bakery: unable to read dynamic circuits file {} ({err}); treating as empty", + path.display() + ); + return HashMap::new(); + } + }; + + if raw.trim().is_empty() { + return HashMap::new(); + } + + let parsed: PersistedDynamicCircuitsFile = match serde_json::from_str(&raw) { + Ok(value) => value, + Err(err) => { + warn!( + "Bakery: unable to parse dynamic circuits file {} ({err}); treating as empty", + path.display() + ); + return HashMap::new(); + } + }; + + let mut overlays = HashMap::new(); + for entry in parsed.circuits { + let mut shaped = entry.shaped; + if shaped.circuit_id.trim().is_empty() { + continue; + } + if shaped.device_id.trim().is_empty() { + continue; + } + recompute_shaped_hashes(&mut shaped); + let circuit_hash = shaped.circuit_hash; + overlays.insert( + circuit_hash, + DynamicCircuitOverlayEntry { + shaped_device: shaped, + class_minor: None, + }, + ); + } + + overlays +} + +fn ip_list_from_shaped_device(device: &lqos_config::ShapedDevice) -> String { + let mut ips = Vec::new(); + for (ip, prefix) in device.ipv4.iter() { + ips.push(format!("{ip}/{prefix}")); + } + for (ip, prefix) in device.ipv6.iter() { + ips.push(format!("{ip}/{prefix}")); + } + ips.sort(); + ips.dedup(); + ips.join(",") +} + +fn normalize_circuit_id_key(value: &str) -> String { + value.trim().to_ascii_lowercase() +} + +fn find_free_dynamic_circuit_minor( + used_down: &HashSet, + used_up: &HashSet, +) -> Option { + let start = ACTIVE_RUNTIME_MINOR_START.min(u16::MAX as u32) as u16; + (start..=0xFFFEu16).find(|m| !used_down.contains(m) && !used_up.contains(m)) +} + +fn append_dynamic_circuit_overlays_to_batch( + batch: &mut Vec>, + overlays: &mut HashMap, + migrations: &HashMap, +) { + if overlays.is_empty() { + return; + } + + let planned_sites: HashMap> = batch + .iter() + .filter_map(|cmd| match cmd.as_ref() { + BakeryCommands::AddSite { site_hash, .. } => Some((*site_hash, Arc::clone(cmd))), + _ => None, + }) + .collect(); + let planned_circuits: HashMap> = batch + .iter() + .filter_map(|cmd| match cmd.as_ref() { + BakeryCommands::AddCircuit { circuit_hash, .. } => { + Some((*circuit_hash, Arc::clone(cmd))) + } + _ => None, + }) + .collect(); + + let mut overlay_keys: Vec = overlays.keys().copied().collect(); + overlay_keys.sort_unstable(); + + let mut used_by_major: HashMap<(u16, u16), (HashSet, HashSet)> = HashMap::new(); + + for circuit_hash in overlay_keys { + let Some(entry) = overlays.get_mut(&circuit_hash) else { + continue; + }; + + if entry.shaped_device.circuit_id.trim().is_empty() + || entry.shaped_device.device_id.trim().is_empty() + { + continue; + } + + recompute_shaped_hashes(&mut entry.shaped_device); + let site_hash = runtime_hash_to_i64(&entry.shaped_device.parent_node); + let Some(site_cmd) = planned_sites.get(&site_hash) else { + warn!( + "Bakery: skipping dynamic circuit overlay {} because parent node '{}' is not present in the current batch sites", + entry.shaped_device.circuit_id, entry.shaped_device.parent_node + ); + continue; + }; + let Some((down_parent, up_parent)) = site_class_handles(site_cmd.as_ref()) else { + warn!( + "Bakery: skipping dynamic circuit overlay {} because parent node '{}' did not resolve to a valid site class", + entry.shaped_device.circuit_id, entry.shaped_device.parent_node + ); + continue; + }; + + let down_major = down_parent.get_major_minor().0; + let up_major = up_parent.get_major_minor().0; + + let (used_down, used_up) = + used_by_major + .entry((down_major, up_major)) + .or_insert_with(|| { + let (mut used_down, mut used_up) = + used_site_minors_for_majors(&planned_sites, down_major, up_major); + let (used_circuit_down, used_circuit_up) = + used_circuit_minors_for_majors(&planned_circuits, down_major, up_major); + used_down.extend(used_circuit_down); + used_up.extend(used_circuit_up); + let (used_shadow_down, used_shadow_up) = + used_pending_migration_shadow_minors_for_majors( + migrations, down_major, up_major, + ); + used_down.extend(used_shadow_down); + used_up.extend(used_shadow_up); + (used_down, used_up) + }); + + let mut desired_minor = entry.class_minor; + if let Some(minor) = desired_minor + && (minor < ACTIVE_RUNTIME_MINOR_START as u16 + || used_down.contains(&minor) + || used_up.contains(&minor)) + { + desired_minor = None; + } + + let class_minor = match desired_minor + .or_else(|| find_free_dynamic_circuit_minor(used_down, used_up)) + { + Some(minor) => minor, + None => { + warn!( + "Bakery: unable to allocate a free class_minor for dynamic circuit overlay {} (down_major=0x{:x}, up_major=0x{:x})", + entry.shaped_device.circuit_id, down_major, up_major + ); + continue; + } + }; + entry.class_minor = Some(class_minor); + used_down.insert(class_minor); + used_up.insert(class_minor); + + // Ensure the overlay command is the only circuit entry for this hash. + batch.retain(|cmd| match cmd.as_ref() { + BakeryCommands::AddCircuit { + circuit_hash: h, .. + } => *h != circuit_hash, + _ => true, + }); + + let ip_addresses = ip_list_from_shaped_device(&entry.shaped_device); + let circuit_name = (!entry.shaped_device.circuit_name.trim().is_empty()) + .then(|| entry.shaped_device.circuit_name.clone()); + let site_name = (!entry.shaped_device.parent_node.trim().is_empty()) + .then(|| entry.shaped_device.parent_node.clone()); + + batch.push(Arc::new(BakeryCommands::AddCircuit { + circuit_hash, + circuit_name, + site_name, + parent_class_id: down_parent, + up_parent_class_id: up_parent, + class_minor, + download_bandwidth_min: entry.shaped_device.download_min_mbps, + upload_bandwidth_min: entry.shaped_device.upload_min_mbps, + download_bandwidth_max: entry.shaped_device.download_max_mbps, + upload_bandwidth_max: entry.shaped_device.upload_max_mbps, + class_major: down_major, + up_class_major: up_major, + down_qdisc_handle: None, + up_qdisc_handle: None, + ip_addresses, + sqm_override: entry.shaped_device.sqm_override.clone(), + })); + } +} + +#[allow(clippy::too_many_arguments)] +fn handle_upsert_dynamic_circuit_overlay( + mut shaped_device: lqos_config::ShapedDevice, + overlays: &mut HashMap, + batch_in_progress: bool, + sites: &HashMap>, + circuits: &mut HashMap>, + live_circuits: &mut HashMap, + mq_layout: &Option, + qdisc_handles: &mut QdiscHandleState, + migrations: &HashMap, +) -> Result, String> { + if shaped_device.circuit_id.trim().is_empty() { + return Err("dynamic circuit requires circuit_id".to_string()); + } + if shaped_device.device_id.trim().is_empty() { + return Err("dynamic circuit requires device_id".to_string()); + } + if shaped_device.parent_node.trim().is_empty() { + return Err("dynamic circuit requires parent_node".to_string()); + } + + recompute_shaped_hashes(&mut shaped_device); + let circuit_hash = shaped_device.circuit_hash; + + let entry = overlays + .entry(circuit_hash) + .or_insert_with(|| DynamicCircuitOverlayEntry { + shaped_device: shaped_device.clone(), + class_minor: None, + }); + entry.shaped_device = shaped_device; + + if batch_in_progress { + // Avoid mutating live TC while a full batch is being assembled; the overlay will be + // applied when the batch is committed. + return Ok(None); + } + + let Ok(config) = lqos_config::load_config() else { + return Err("unable to load config".to_string()); + }; + + if config.queues.queue_mode.is_observe() { + return Ok(None); + } + + // If we don't yet have a baseline MQ layout or any sites, keep the overlay and let the next + // commit batch create the circuit. + if !MQ_CREATED.load(Ordering::Relaxed) || mq_layout.is_none() || sites.is_empty() { + return Ok(None); + } + + // If the circuit is already present, don't attempt to live-update it here yet. + if let Some(existing) = circuits.get(&circuit_hash) { + if let BakeryCommands::AddCircuit { + class_major, + class_minor, + .. + } = existing.as_ref() + { + return Ok(Some(TcHandle::from_u32( + ((*class_major as u32) << 16) | (*class_minor as u32), + ))); + } + return Ok(None); + } + + let site_hash = runtime_hash_to_i64(&entry.shaped_device.parent_node); + let Some(site_cmd) = sites.get(&site_hash) else { + return Err(format!( + "parent node '{}' is not present in current bakery site state", + entry.shaped_device.parent_node + )); + }; + let Some((down_parent, up_parent)) = site_class_handles(site_cmd.as_ref()) else { + return Err(format!( + "parent node '{}' did not resolve to a valid site class", + entry.shaped_device.parent_node + )); + }; + + let down_major = down_parent.get_major_minor().0; + let up_major = up_parent.get_major_minor().0; + + let (mut used_down, mut used_up) = used_site_minors_for_majors(sites, down_major, up_major); + let (used_circuit_down, used_circuit_up) = + used_circuit_minors_for_majors(circuits, down_major, up_major); + used_down.extend(used_circuit_down); + used_up.extend(used_circuit_up); + let (used_shadow_down, used_shadow_up) = + used_pending_migration_shadow_minors_for_majors(migrations, down_major, up_major); + used_down.extend(used_shadow_down); + used_up.extend(used_shadow_up); + + let mut desired_minor = entry.class_minor; + if let Some(minor) = desired_minor + && (minor < ACTIVE_RUNTIME_MINOR_START as u16 + || used_down.contains(&minor) + || used_up.contains(&minor)) + { + desired_minor = None; + } + let class_minor = desired_minor + .or_else(|| find_free_dynamic_circuit_minor(&used_down, &used_up)) + .ok_or_else(|| "unable to allocate free class_minor for dynamic circuit".to_string())?; + entry.class_minor = Some(class_minor); + + let ip_addresses = ip_list_from_shaped_device(&entry.shaped_device); + let candidate = Arc::new(BakeryCommands::AddCircuit { + circuit_hash, + circuit_name: (!entry.shaped_device.circuit_name.trim().is_empty()) + .then(|| entry.shaped_device.circuit_name.clone()), + site_name: (!entry.shaped_device.parent_node.trim().is_empty()) + .then(|| entry.shaped_device.parent_node.clone()), + parent_class_id: down_parent, + up_parent_class_id: up_parent, + class_minor, + download_bandwidth_min: entry.shaped_device.download_min_mbps, + upload_bandwidth_min: entry.shaped_device.upload_min_mbps, + download_bandwidth_max: entry.shaped_device.download_max_mbps, + upload_bandwidth_max: entry.shaped_device.upload_max_mbps, + class_major: down_major, + up_class_major: up_major, + down_qdisc_handle: None, + up_qdisc_handle: None, + ip_addresses, + sqm_override: entry.shaped_device.sqm_override.clone(), + }); + + let mapped_limit = resolve_mapped_circuit_limit(); + if is_mapped_add_circuit(candidate.as_ref()) + && let Some(limit) = mapped_limit.effective_limit + && circuits + .values() + .filter(|c| is_mapped_add_circuit(c.as_ref())) + .count() + >= limit + { + let stats = MappedLimitStats { + enforced_limit: mapped_limit.effective_limit, + requested_mapped: 1, + allowed_mapped: 0, + dropped_mapped: 1, + }; + warn!( + "Bakery mapped circuit cap enforced (dynamic circuit addition): requested={}, allowed={}, dropped={}, limit={} (licensed={}, max_circuits={:?})", + stats.requested_mapped, + stats.allowed_mapped, + stats.dropped_mapped, + format_mapped_limit(mapped_limit.effective_limit), + mapped_limit.licensed, + mapped_limit.max_circuits + ); + maybe_emit_mapped_circuit_limit_urgent(&stats); + return Err("mapped circuit limit reached".to_string()); + } + + let Some(layout) = mq_layout.as_ref() else { + return Ok(None); + }; + let live_reserved_handles = + snapshot_live_qdisc_handle_majors_or_empty(&config, "dynamic circuit additions"); + let enriched = with_assigned_qdisc_handles_reserved( + &candidate, + &config, + layout, + qdisc_handles, + &live_reserved_handles, + ); + let commands = enriched + .to_commands(&config, ExecutionMode::Builder) + .unwrap_or_default(); + if !commands.is_empty() { + execute_and_record_live_change(&commands, "adding dynamic circuit"); + } + circuits.insert(circuit_hash, enriched); + live_circuits.remove(&circuit_hash); + qdisc_handles.save(&config); + update_queue_distribution_snapshot(sites, circuits); + + Ok(Some(TcHandle::from_u32( + ((down_major as u32) << 16) | (class_minor as u32), + ))) +} + +#[allow(clippy::too_many_arguments)] +fn handle_remove_dynamic_circuit_overlay( + circuit_id: &str, + overlays: &mut HashMap, + batch_in_progress: bool, + sites: &HashMap>, + circuits: &mut HashMap>, + live_circuits: &mut HashMap, + _mq_layout: &Option, + qdisc_handles: &mut QdiscHandleState, +) -> Result<(), String> { + let normalized = normalize_circuit_id_key(circuit_id); + let overlay_key = overlays + .iter() + .find(|(_, entry)| normalize_circuit_id_key(&entry.shaped_device.circuit_id) == normalized) + .map(|(k, _)| *k) + .or_else(|| { + let computed = runtime_hash_to_i64(circuit_id); + overlays.contains_key(&computed).then_some(computed) + }); + + if let Some(key) = overlay_key { + overlays.remove(&key); + } + + if batch_in_progress { + // Avoid mutating live TC while a full batch is being assembled; the next commit batch + // will reconcile the removal. + return Ok(()); + } + + let Ok(config) = lqos_config::load_config() else { + return Err("unable to load config".to_string()); + }; + + let circuit_hash = overlay_key.unwrap_or_else(|| runtime_hash_to_i64(circuit_id)); + if let Some(circuit) = circuits.remove(&circuit_hash) { + let was_activated = live_circuits.contains_key(&circuit_hash); + let commands = match config.queues.lazy_queues.as_ref() { + None | Some(LazyQueueMode::No) => circuit.to_prune(&config, true), + Some(LazyQueueMode::Htb) => { + if was_activated { + circuit.to_prune(&config, false) + } else { + None + } + } + Some(LazyQueueMode::Full) => { + if was_activated { + circuit.to_prune(&config, true) + } else { + None + } + } + }; + if let Some(cmd) = commands { + execute_and_record_live_change(&cmd, "removing dynamic circuit"); + } + live_circuits.remove(&circuit_hash); + qdisc_handles.release_circuit(&config.isp_interface(), circuit_hash); + if !config.on_a_stick_mode() { + qdisc_handles.release_circuit(&config.internet_interface(), circuit_hash); + } + qdisc_handles.save(&config); + update_queue_distribution_snapshot(sites, circuits); + } + + Ok(()) +} + /*fn parse_ip_and_prefix(ip: &str) -> (String, u32) { if let Some((addr, pfx)) = ip.split_once('/') { if let Ok(n) = pfx.parse::() { @@ -3109,6 +3624,11 @@ fn bakery_main(rx: Receiver, tx: Sender) { let mut virtualized_sites: HashMap = HashMap::new(); let mut runtime_node_operations: HashMap = HashMap::new(); let mut next_runtime_operation_id: u64 = 1; + let mut dynamic_circuit_overlays: HashMap = + lqos_config::load_config() + .ok() + .map(|config| load_dynamic_circuit_overlays_from_disk(&config)) + .unwrap_or_default(); // Mapping state #[derive(Clone, Hash, PartialEq, Eq, Debug)] @@ -4037,6 +4557,7 @@ fn bakery_main(rx: Receiver, tx: Sender) { &mut batch, &mut sites, &mut circuits, + &mut dynamic_circuit_overlays, &mut live_circuits, &mut mq_layout, &mut qdisc_handles, @@ -4075,6 +4596,40 @@ fn bakery_main(rx: Receiver, tx: Sender) { batch.push(Arc::new(command)); } } + BakeryCommands::UpsertDynamicCircuitOverlay { + shaped_device, + reply, + } => { + let result = handle_upsert_dynamic_circuit_overlay( + *shaped_device, + &mut dynamic_circuit_overlays, + batch.is_some(), + &sites, + &mut circuits, + &mut live_circuits, + &mq_layout, + &mut qdisc_handles, + &migrations, + ); + if let Some(reply) = reply { + let _ = reply.send(result); + } + } + BakeryCommands::RemoveDynamicCircuitOverlay { circuit_id, reply } => { + let result = handle_remove_dynamic_circuit_overlay( + &circuit_id, + &mut dynamic_circuit_overlays, + batch.is_some(), + &sites, + &mut circuits, + &mut live_circuits, + &mq_layout, + &mut qdisc_handles, + ); + if let Some(reply) = reply { + let _ = reply.send(result); + } + } BakeryCommands::OnCircuitActivity { circuit_ids } => { handle_circuit_activity(circuit_ids, &circuits, &mut live_circuits); } @@ -4219,6 +4774,7 @@ fn handle_commit_batch( batch: &mut Option>>, sites: &mut HashMap>, circuits: &mut HashMap>, + dynamic_circuit_overlays: &mut HashMap, live_circuits: &mut HashMap, mq_layout: &mut Option, qdisc_handles: &mut QdiscHandleState, @@ -4238,6 +4794,8 @@ fn handle_commit_batch( debug!("CommitBatch received without a batch to commit."); return; }; + let mut raw_batch = raw_batch; + append_dynamic_circuit_overlays_to_batch(&mut raw_batch, dynamic_circuit_overlays, migrations); let (baseline_sites, baseline_circuits) = reconstruct_structural_baseline_state(sites, circuits, virtualized_sites); let effective_new_batch = diff --git a/src/rust/lqos_bus/src/bus/request.rs b/src/rust/lqos_bus/src/bus/request.rs index 462cebf3..e2561fd9 100644 --- a/src/rust/lqos_bus/src/bus/request.rs +++ b/src/rust/lqos_bus/src/bus/request.rs @@ -218,6 +218,23 @@ pub enum BusRequest { /// Requests that the configuration be updated UpdateLqosdConfig(Box), + /// Create or update a runtime-only dynamic circuit overlay entry. + /// + /// This does **not** mutate `ShapedDevices.csv`. The daemon may ignore this request when + /// dynamic circuits are disabled in the configuration. + CreateDynamicCircuit { + /// Shaped-device-like definition for the dynamic circuit/device. + shaped_device: Box, + }, + + /// Remove a runtime-only dynamic circuit overlay entry. + /// + /// The daemon may ignore this request when dynamic circuits are disabled in the configuration. + RemoveDynamicCircuit { + /// Stable circuit identifier to remove. + circuit_id: String, + }, + /// Invalidate cached Node Manager authentication state. InvalidateAuthCache, diff --git a/src/rust/lqos_bus/src/bus/response.rs b/src/rust/lqos_bus/src/bus/response.rs index a755e31c..a0570bc9 100644 --- a/src/rust/lqos_bus/src/bus/response.rs +++ b/src/rust/lqos_bus/src/bus/response.rs @@ -184,7 +184,7 @@ pub struct AsnHeatmapData { /// Metrics for the Executive Summary header cards. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Allocative, Default)] pub struct ExecutiveSummaryHeader { - /// Total number of unique circuits from SHAPED_DEVICES. + /// Total number of unique circuits from the shaped-devices snapshot. pub circuit_count: u64, /// Total number of shaped devices. pub device_count: u64, diff --git a/src/rust/lqos_bus/src/dynamic_circuits.rs b/src/rust/lqos_bus/src/dynamic_circuits.rs new file mode 100644 index 00000000..9f82db87 --- /dev/null +++ b/src/rust/lqos_bus/src/dynamic_circuits.rs @@ -0,0 +1,87 @@ +//! Bus types for dynamic circuit overlay operations. +//! +//! `DynamicAddCircuit` and `DynamicRemoveCircuit` are high-level requests used +//! to manage runtime-only dynamic circuits. These operations apply to the +//! overlay state and never mutate `ShapedDevices.csv`. +//! +//! When callers omit identity fields, the daemon allocates stable defaults +//! such as `Dynamic ` for `circuit_id` and reuses that value for +//! `device_id`. + +use allocative::Allocative; +use serde::{Deserialize, Serialize}; + +/// Attachment target for a dynamic circuit overlay request. +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Allocative)] +pub struct DynamicCircuitAttachmentTarget { + /// Stable topology node identifier from `network.json` or `network.effective.json`. + pub node_id: Option, + /// Human-readable topology node name used when a stable ID is unavailable. + pub node_name: Option, +} + +/// Optional caller-supplied identity fields for a dynamic circuit. +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Allocative)] +pub struct DynamicCircuitIdentitySpec { + /// Circuit identifier override. The daemon allocates `Dynamic ` when omitted. + pub circuit_id: Option, + /// Device identifier override. Defaults to `circuit_id` when omitted. + pub device_id: Option, + /// Human-readable circuit display name. + pub circuit_name: Option, + /// Human-readable device display name. + pub device_name: Option, +} + +/// Rate inputs for a dynamic circuit overlay request. +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Allocative)] +pub struct DynamicCircuitRates { + /// Minimum guaranteed downstream rate in Mbps. + pub download_min_mbps: f32, + /// Minimum guaranteed upstream rate in Mbps. + pub upload_min_mbps: f32, + /// Maximum downstream rate in Mbps. + pub download_max_mbps: f32, + /// Maximum upstream rate in Mbps. + pub upload_max_mbps: f32, + /// Optional SQM override such as `cake` or `fq_codel`. + pub sqm_override: Option, +} + +/// Request payload for adding a dynamic circuit overlay entry. +/// +/// This is a high-level overlay operation and never writes to +/// `ShapedDevices.csv`. When `identity` is omitted, the daemon allocates +/// defaults such as `Dynamic `. +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Allocative)] +pub struct DynamicAddCircuitRequest { + /// Attachment target for the dynamic circuit. + pub attachment: DynamicCircuitAttachmentTarget, + /// Optional caller-supplied identity fields. + pub identity: Option, + /// Requested shaping rates. + pub rates: DynamicCircuitRates, + /// IPs and CIDRs owned by this dynamic circuit. + pub ip_cidrs: Vec, + /// Optional TTL in seconds. The daemon applies the default when omitted. + pub ttl_seconds: Option, +} + +/// Request payload for removing a dynamic circuit overlay entry. +/// +/// This removes only the runtime overlay entry; it never edits +/// `ShapedDevices.csv`. +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Allocative)] +pub struct DynamicRemoveCircuitRequest { + /// Stable circuit identifier to remove from the dynamic overlay. + pub circuit_id: String, +} + +/// Reply payload returned after creating a dynamic circuit overlay entry. +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Allocative)] +pub struct DynamicAddCircuitReply { + /// Allocated or accepted circuit identifier. + pub circuit_id: String, + /// Allocated or accepted device identifier. + pub device_id: String, +} diff --git a/src/rust/lqos_config/src/authentication.rs b/src/rust/lqos_config/src/authentication.rs index 3262030b..6461f4f3 100644 --- a/src/rust/lqos_config/src/authentication.rs +++ b/src/rust/lqos_config/src/authentication.rs @@ -285,7 +285,7 @@ impl WebUsers { let salted = format!("!x{password}{LEGACY_PASSWORD_PEPPER}"); let mut sha256 = Sha256::new(); sha256.update(salted); - format!("{:X}", sha256.finalize()) + crate::hex_encoding::encode_hex_upper(sha256.finalize()) } fn verify_password( diff --git a/src/rust/lqos_config/src/dynamic_status.rs b/src/rust/lqos_config/src/dynamic_status.rs new file mode 100644 index 00000000..c6631e72 --- /dev/null +++ b/src/rust/lqos_config/src/dynamic_status.rs @@ -0,0 +1,600 @@ +//! Dynamic circuit persistence for the runtime overlay. +//! +//! The dynamic-circuits store is an overlay on top of +//! `lqos_config::ConfigShapedDevices` and never mutates ShapedDevices.csv. +//! Static `ShapedDevices.csv` data remains authoritative; dynamic state exists +//! only to persist runtime overlay entries in `dynamic_status.json`. +//! +//! This module is intentionally small until the runtime overlay loader lands. + +use allocative::Allocative; +use serde::{Deserialize, Serialize}; +use std::{fs, path::{Path, PathBuf}}; +use thiserror::Error; + +/// File name used to persist dynamic circuit overlay state. +pub const DYNAMIC_STATUS_FILENAME: &str = "dynamic_status.json"; +/// Current supported on-disk schema version for `dynamic_status.json`. +pub const DYNAMIC_STATUS_VERSION: u32 = 1; + +/// Errors that can occur while resolving or loading `dynamic_status.json`. +#[derive(Debug, Error)] +pub enum DynamicStatusError { + /// The main LibreQoS config could not be loaded. + #[error("Unable to load LibreQoS config while resolving dynamic_status.json path")] + ConfigLoadError, + /// A `dynamic_status.json` file could not be read from disk. + #[error("Unable to read dynamic status file at {path}: {source}")] + CannotRead { + /// Full path to the file that failed. + path: String, + /// Source IO error. + source: std::io::Error, + }, + /// A `dynamic_status.json` file existed but could not be parsed. + #[error("Unable to parse dynamic status file at {path}: {details}")] + ParseError { + /// Full path to the file that failed. + path: String, + /// Parse error details. + details: String, + }, + /// A `dynamic_status.json` file could not be written to disk. + #[error("Unable to write dynamic status file at {path}: {source}")] + CannotWrite { + /// Full path to the file that failed. + path: String, + /// Source IO error. + source: std::io::Error, + }, + /// Dynamic status data could not be serialized. + #[error("Unable to serialize dynamic status file: {details}")] + SerializeError { + /// Serialization error details. + details: String, + }, + /// A dynamic circuit record is internally inconsistent or contains invalid data. + #[error("Invalid dynamic circuit record: {details}")] + InvalidRecord { + /// Specific validation or conversion details. + details: String, + }, +} + +/// Root persisted state for the dynamic circuit runtime overlay. +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Allocative)] +pub struct DynamicStatusFile { + /// On-disk schema version for `dynamic_status.json`. + pub version: u32, + /// Monotonic ID allocator so `Dynamic ` values are never reused. + pub next_dynamic_id: u64, + /// Persisted dynamic circuits that can be materialized into overlay devices. + pub circuits: Vec, +} + +impl DynamicStatusFile { + /// Loads `dynamic_status.json`, returning an empty default state when the file is absent. + pub fn load_or_default() -> Result { + let path = dynamic_status_path()?; + if !path.exists() { + return Ok(Self::default()); + } + + let raw = fs::read_to_string(&path).map_err(|source| DynamicStatusError::CannotRead { + path: path.display().to_string(), + source, + })?; + let parsed: Self = serde_json::from_str(&raw).map_err(|error| DynamicStatusError::ParseError { + path: path.display().to_string(), + details: error.to_string(), + })?; + if parsed.version != DYNAMIC_STATUS_VERSION { + return Err(DynamicStatusError::InvalidRecord { + details: format!( + "Unsupported dynamic_status.json version {} at {} (expected {})", + parsed.version, + path.display(), + DYNAMIC_STATUS_VERSION + ), + }); + } + Ok(parsed) + } + + /// Saves `dynamic_status.json` using a same-directory temp file and atomic rename. + pub fn save_atomic(&self) -> Result<(), DynamicStatusError> { + let path = dynamic_status_path()?; + let tmp_path = path.with_file_name(format!("{DYNAMIC_STATUS_FILENAME}.tmp")); + let serialized = + serde_json::to_string_pretty(self).map_err(|error| DynamicStatusError::SerializeError { + details: error.to_string(), + })?; + + fs::write(&tmp_path, serialized).map_err(|source| DynamicStatusError::CannotWrite { + path: tmp_path.display().to_string(), + source, + })?; + fs::rename(&tmp_path, &path).map_err(|source| DynamicStatusError::CannotWrite { + path: path.display().to_string(), + source, + })?; + Ok(()) + } + + /// Allocates default circuit/device identifiers when callers omit them. + pub fn allocate_default_ids( + &mut self, + circuit_id: Option, + device_id: Option, + ) -> (String, String) { + let circuit_id = circuit_id.unwrap_or_else(|| { + let allocated = format!("Dynamic {}", self.next_dynamic_id); + self.next_dynamic_id += 1; + allocated + }); + let device_id = device_id.unwrap_or_else(|| circuit_id.clone()); + (circuit_id, device_id) + } +} + +/// Persisted attachment target for a dynamic circuit. +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq, Allocative)] +pub struct DynamicAttachmentTarget { + /// Stable topology node identifier when known. + pub node_id: Option, + /// Human-readable parent node name. + pub node_name: String, +} + +/// Persisted dynamic circuit record. +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Allocative)] +pub struct DynamicCircuitRecord { + /// Stable circuit identifier. + pub circuit_id: String, + /// Stable device identifier. + pub device_id: String, + /// Optional human-readable circuit name. + pub circuit_name: Option, + /// Optional human-readable device name. + pub device_name: Option, + /// Attachment target used to place the circuit in the topology. + pub attachment: DynamicAttachmentTarget, + /// Optional MAC address associated with the device. + pub mac: Option, + /// IP/CIDR strings assigned to the dynamic circuit. + pub ip_cidrs: Vec, + /// Minimum guaranteed downstream rate in Mbps. + pub download_min_mbps: f32, + /// Minimum guaranteed upstream rate in Mbps. + pub upload_min_mbps: f32, + /// Maximum downstream rate in Mbps. + pub download_max_mbps: f32, + /// Maximum upstream rate in Mbps. + pub upload_max_mbps: f32, + /// Optional SQM override. + pub sqm_override: Option, + /// TTL in seconds applied when extending expiry on activity. + pub ttl_seconds: u64, + /// Unix timestamp when the record was created. + pub created: u64, + /// Unix timestamp when the record was last observed active. + #[serde(alias = "last_seen")] + pub last_seen_at: u64, + /// Unix timestamp when the record should expire if not refreshed. + #[serde(alias = "expires")] + pub expires_at: u64, +} + +impl DynamicCircuitRecord { + /// Converts the persisted record into a `ShapedDevice` for overlay indexing. + pub fn to_shaped_device(&self) -> Result { + let mut ipv4 = Vec::new(); + let mut ipv6 = Vec::new(); + for cidr in &self.ip_cidrs { + if let Ok(parsed) = crate::ShapedDevice::parse_cidr_v4(cidr) { + ipv4.push(parsed); + continue; + } + if let Ok(parsed) = crate::ShapedDevice::parse_cidr_v6(cidr) { + ipv6.push(parsed); + continue; + } + return Err(DynamicStatusError::InvalidRecord { + details: format!("Unable to parse CIDR '{cidr}' for dynamic circuit '{}'", self.circuit_id), + }); + } + + let circuit_name = self + .circuit_name + .clone() + .unwrap_or_else(|| self.circuit_id.clone()); + let device_name = self + .device_name + .clone() + .unwrap_or_else(|| self.device_id.clone()); + let parent_node = self.attachment.node_name.clone(); + + Ok(crate::ShapedDevice { + circuit_hash: lqos_utils::hash_to_i64(&self.circuit_id), + device_hash: lqos_utils::hash_to_i64(&self.device_id), + parent_hash: lqos_utils::hash_to_i64(&parent_node), + circuit_id: self.circuit_id.clone(), + circuit_name, + device_id: self.device_id.clone(), + device_name, + parent_node, + mac: self.mac.clone().unwrap_or_default(), + ipv4, + ipv6, + download_min_mbps: self.download_min_mbps, + upload_min_mbps: self.upload_min_mbps, + download_max_mbps: self.download_max_mbps, + upload_max_mbps: self.upload_max_mbps, + comment: String::new(), + sqm_override: self.sqm_override.clone(), + }) + } +} + +fn dynamic_status_path_for_config(config: &crate::Config) -> PathBuf { + Path::new(&config.lqos_directory).join(DYNAMIC_STATUS_FILENAME) +} + +/// Resolves the active `dynamic_status.json` path from the LibreQoS config. +pub fn dynamic_status_path() -> Result { + let config = crate::load_config().map_err(|_| DynamicStatusError::ConfigLoadError)?; + Ok(dynamic_status_path_for_config(&config)) +} + +#[cfg(test)] +mod tests { + use super::{ + DYNAMIC_STATUS_FILENAME, DYNAMIC_STATUS_VERSION, DynamicAttachmentTarget, + DynamicCircuitRecord, DynamicStatusFile, + dynamic_status_path, + }; + use crate::{Config, clear_cached_config}; + use std::{ + fs, + path::PathBuf, + sync::Mutex, + sync::atomic::{AtomicU64, Ordering}, + }; + + static TEST_COUNTER: AtomicU64 = AtomicU64::new(0); + static TEST_ENV_LOCK: Mutex<()> = Mutex::new(()); + + fn unique_test_dir(prefix: &str) -> PathBuf { + let suffix = TEST_COUNTER.fetch_add(1, Ordering::Relaxed); + std::env::temp_dir().join(format!("libreqos-{prefix}-{}-{suffix}", std::process::id())) + } + + #[test] + fn dynamic_status_path_ends_with_filename() { + let _guard = TEST_ENV_LOCK.lock().expect("lock test env"); + clear_cached_config(); + + let config_dir = unique_test_dir("dynamic-status-path"); + let config_path = config_dir.join("lqos.conf"); + fs::create_dir_all(&config_dir).expect("create config dir"); + let mut config = Config::default(); + config.lqos_directory = config_dir.to_string_lossy().to_string(); + let config_toml = toml::to_string(&config).expect("serialize config"); + fs::write(&config_path, config_toml).expect("write config"); + + let old_lqos_config = std::env::var_os("LQOS_CONFIG"); + let old_lqos_directory = std::env::var_os("LQOS_DIRECTORY"); + unsafe { + std::env::set_var("LQOS_CONFIG", &config_path); + std::env::set_var("LQOS_DIRECTORY", &config_dir); + } + + let resolved = dynamic_status_path().expect("dynamic_status_path"); + assert!(resolved.ends_with(DYNAMIC_STATUS_FILENAME)); + + match old_lqos_config { + Some(value) => unsafe { std::env::set_var("LQOS_CONFIG", value) }, + None => unsafe { std::env::remove_var("LQOS_CONFIG") }, + } + match old_lqos_directory { + Some(value) => unsafe { std::env::set_var("LQOS_DIRECTORY", value) }, + None => unsafe { std::env::remove_var("LQOS_DIRECTORY") }, + } + clear_cached_config(); + let _ = fs::remove_file(&config_path); + let _ = fs::remove_dir_all(&config_dir); + } + + #[test] + fn load_or_default_missing_file_returns_empty_status() { + let _guard = TEST_ENV_LOCK.lock().expect("lock test env"); + clear_cached_config(); + + let config_dir = unique_test_dir("dynamic-status-missing"); + let config_path = config_dir.join("lqos.conf"); + fs::create_dir_all(&config_dir).expect("create config dir"); + let mut config = Config::default(); + config.lqos_directory = config_dir.to_string_lossy().to_string(); + let config_toml = toml::to_string(&config).expect("serialize config"); + fs::write(&config_path, config_toml).expect("write config"); + + let old_lqos_config = std::env::var_os("LQOS_CONFIG"); + let old_lqos_directory = std::env::var_os("LQOS_DIRECTORY"); + unsafe { + std::env::set_var("LQOS_CONFIG", &config_path); + std::env::set_var("LQOS_DIRECTORY", &config_dir); + } + + let loaded = DynamicStatusFile::load_or_default().expect("load_or_default"); + assert_eq!(loaded.circuits.len(), 0); + + match old_lqos_config { + Some(value) => unsafe { std::env::set_var("LQOS_CONFIG", value) }, + None => unsafe { std::env::remove_var("LQOS_CONFIG") }, + } + match old_lqos_directory { + Some(value) => unsafe { std::env::set_var("LQOS_DIRECTORY", value) }, + None => unsafe { std::env::remove_var("LQOS_DIRECTORY") }, + } + clear_cached_config(); + let _ = fs::remove_file(&config_path); + let _ = fs::remove_dir_all(&config_dir); + } + + #[test] + fn save_atomic_then_load_round_trip() { + let _guard = TEST_ENV_LOCK.lock().expect("lock test env"); + clear_cached_config(); + + let config_dir = unique_test_dir("dynamic-status-save"); + let config_path = config_dir.join("lqos.conf"); + fs::create_dir_all(&config_dir).expect("create config dir"); + let mut config = Config::default(); + config.lqos_directory = config_dir.to_string_lossy().to_string(); + let config_toml = toml::to_string(&config).expect("serialize config"); + fs::write(&config_path, config_toml).expect("write config"); + + let old_lqos_config = std::env::var_os("LQOS_CONFIG"); + let old_lqos_directory = std::env::var_os("LQOS_DIRECTORY"); + unsafe { + std::env::set_var("LQOS_CONFIG", &config_path); + std::env::set_var("LQOS_DIRECTORY", &config_dir); + } + + let status = DynamicStatusFile { + version: DYNAMIC_STATUS_VERSION, + next_dynamic_id: 43, + circuits: vec![super::DynamicCircuitRecord { + circuit_id: "Dynamic 42".to_string(), + device_id: "Dynamic 42".to_string(), + circuit_name: Some("Transient Subscriber".to_string()), + device_name: Some("Transient CPE".to_string()), + attachment: super::DynamicAttachmentTarget { + node_id: Some("tower-a-sector-3".to_string()), + node_name: "Tower A / Sector 3".to_string(), + }, + mac: Some("aa:bb:cc:dd:ee:ff".to_string()), + ip_cidrs: vec!["192.0.2.10/32".to_string(), "2001:db8::1234/128".to_string()], + download_min_mbps: 25.0, + upload_min_mbps: 5.0, + download_max_mbps: 150.0, + upload_max_mbps: 30.0, + sqm_override: Some("cake".to_string()), + ttl_seconds: 3600, + created: 1_775_606_400, + last_seen_at: 1_775_608_200, + expires_at: 1_775_611_800, + }], + }; + status.save_atomic().expect("save_atomic"); + let loaded = DynamicStatusFile::load_or_default().expect("load_or_default"); + assert_eq!(loaded, status); + + match old_lqos_config { + Some(value) => unsafe { std::env::set_var("LQOS_CONFIG", value) }, + None => unsafe { std::env::remove_var("LQOS_CONFIG") }, + } + match old_lqos_directory { + Some(value) => unsafe { std::env::set_var("LQOS_DIRECTORY", value) }, + None => unsafe { std::env::remove_var("LQOS_DIRECTORY") }, + } + clear_cached_config(); + let _ = fs::remove_file(config_dir.join(DYNAMIC_STATUS_FILENAME)); + let _ = fs::remove_file(&config_path); + let _ = fs::remove_dir_all(&config_dir); + } + + #[test] + fn allocate_default_ids_increments_next_dynamic_id() { + let mut status = DynamicStatusFile { + version: DYNAMIC_STATUS_VERSION, + next_dynamic_id: 42, + circuits: Vec::new(), + }; + + let first = status.allocate_default_ids(None, None); + let second = status.allocate_default_ids(None, None); + + assert_eq!(first.0, "Dynamic 42"); + assert_eq!(first.1, "Dynamic 42"); + assert_eq!(second.0, "Dynamic 43"); + assert_eq!(second.1, "Dynamic 43"); + assert_eq!(status.next_dynamic_id, 44); + } + + #[test] + fn to_shaped_device_sets_hashes_from_ids() { + let record = DynamicCircuitRecord { + circuit_id: "Dynamic 42".to_string(), + device_id: "Dynamic 42".to_string(), + circuit_name: Some("Transient Subscriber".to_string()), + device_name: Some("Transient CPE".to_string()), + attachment: DynamicAttachmentTarget { + node_id: Some("tower-a-sector-3".to_string()), + node_name: "Tower A / Sector 3".to_string(), + }, + mac: Some("aa:bb:cc:dd:ee:ff".to_string()), + ip_cidrs: vec!["192.0.2.10/32".to_string(), "2001:db8::1234/128".to_string()], + download_min_mbps: 25.0, + upload_min_mbps: 5.0, + download_max_mbps: 150.0, + upload_max_mbps: 30.0, + sqm_override: Some("cake".to_string()), + ttl_seconds: 3600, + created: 1_775_606_400, + last_seen_at: 1_775_608_200, + expires_at: 1_775_611_800, + }; + + let device = record.to_shaped_device().expect("to_shaped_device"); + assert_eq!(device.circuit_hash, lqos_utils::hash_to_i64(&device.circuit_id)); + } + + #[test] + fn to_shaped_device_ipv4_cidr_uses_ipv6_mapped_prefix() { + let record = DynamicCircuitRecord { + circuit_id: "Dynamic 7".to_string(), + device_id: "Dynamic 7".to_string(), + circuit_name: None, + device_name: None, + attachment: DynamicAttachmentTarget { + node_id: None, + node_name: "Orphans".to_string(), + }, + mac: None, + ip_cidrs: vec!["192.0.2.10/24".to_string()], + download_min_mbps: 10.0, + upload_min_mbps: 10.0, + download_max_mbps: 20.0, + upload_max_mbps: 20.0, + sqm_override: None, + ttl_seconds: 3600, + created: 1, + last_seen_at: 1, + expires_at: 2, + }; + + let device = record.to_shaped_device().expect("to_shaped_device"); + let ipv6_list = device.to_ipv6_list(); + assert_eq!(ipv6_list.len(), 1); + let (mapped, prefix) = ipv6_list[0]; + assert_eq!(mapped, std::net::Ipv4Addr::new(192, 0, 2, 10).to_ipv6_mapped()); + assert_eq!(prefix, 120); + } + + #[test] + fn to_shaped_device_invalid_ip_reports_offending_string() { + let record = DynamicCircuitRecord { + circuit_id: "Dynamic 99".to_string(), + device_id: "Dynamic 99".to_string(), + circuit_name: None, + device_name: None, + attachment: DynamicAttachmentTarget { + node_id: None, + node_name: "Orphans".to_string(), + }, + mac: None, + ip_cidrs: vec!["not-an-ip".to_string()], + download_min_mbps: 10.0, + upload_min_mbps: 10.0, + download_max_mbps: 20.0, + upload_max_mbps: 20.0, + sqm_override: None, + ttl_seconds: 3600, + created: 1, + last_seen_at: 1, + expires_at: 2, + }; + + let error = record.to_shaped_device().expect_err("invalid CIDR should fail"); + let message = error.to_string(); + assert!(message.contains("not-an-ip")); + } + + #[test] + fn example_fixture_parses_ttl_fields() { + let parsed: DynamicStatusFile = + serde_json::from_str(include_str!("testdata/dynamic_status.example.json")) + .expect("parse example fixture"); + assert_eq!(parsed.version, DYNAMIC_STATUS_VERSION); + assert_eq!(parsed.circuits.len(), 1); + assert_eq!(parsed.circuits[0].ttl_seconds, 3600); + assert_eq!(parsed.circuits[0].last_seen_at, 1_775_608_200); + assert_eq!(parsed.circuits[0].expires_at, 1_775_611_800); + } + + #[test] + fn legacy_ttl_field_names_still_parse() { + let parsed: DynamicStatusFile = serde_json::from_str( + r#"{ + "version": 1, + "next_dynamic_id": 2, + "circuits": [ + { + "circuit_id": "Dynamic 1", + "device_id": "Dynamic 1", + "attachment": { "node_id": null, "node_name": "AP-1" }, + "mac": null, + "ip_cidrs": ["100.64.0.10/32"], + "download_min_mbps": 25.0, + "upload_min_mbps": 10.0, + "download_max_mbps": 50.0, + "upload_max_mbps": 20.0, + "sqm_override": null, + "ttl_seconds": 3600, + "created": 1, + "last_seen": 2, + "expires": 3602 + } + ] +}"#, + ) + .expect("parse legacy ttl field names"); + assert_eq!(parsed.circuits[0].last_seen_at, 2); + assert_eq!(parsed.circuits[0].expires_at, 3602); + } + + #[test] + fn load_or_default_rejects_unsupported_version() { + let _guard = TEST_ENV_LOCK.lock().expect("lock test env"); + clear_cached_config(); + + let config_dir = unique_test_dir("dynamic-status-bad-version"); + let config_path = config_dir.join("lqos.conf"); + fs::create_dir_all(&config_dir).expect("create config dir"); + let mut config = Config::default(); + config.lqos_directory = config_dir.to_string_lossy().to_string(); + let config_toml = toml::to_string(&config).expect("serialize config"); + fs::write(&config_path, config_toml).expect("write config"); + + let old_lqos_config = std::env::var_os("LQOS_CONFIG"); + let old_lqos_directory = std::env::var_os("LQOS_DIRECTORY"); + unsafe { + std::env::set_var("LQOS_CONFIG", &config_path); + std::env::set_var("LQOS_DIRECTORY", &config_dir); + } + + let status_path = config_dir.join(DYNAMIC_STATUS_FILENAME); + fs::write( + &status_path, + r#"{"version":99,"next_dynamic_id":1,"circuits":[]}"#, + ) + .expect("write dynamic status"); + + let error = DynamicStatusFile::load_or_default().expect_err("unsupported version"); + assert!(matches!(error, super::DynamicStatusError::InvalidRecord { .. })); + + match old_lqos_config { + Some(value) => unsafe { std::env::set_var("LQOS_CONFIG", value) }, + None => unsafe { std::env::remove_var("LQOS_CONFIG") }, + } + match old_lqos_directory { + Some(value) => unsafe { std::env::set_var("LQOS_DIRECTORY", value) }, + None => unsafe { std::env::remove_var("LQOS_DIRECTORY") }, + } + clear_cached_config(); + let _ = fs::remove_file(&status_path); + let _ = fs::remove_file(&config_path); + let _ = fs::remove_dir_all(&config_dir); + } +} diff --git a/src/rust/lqos_config/src/etc/etclqos_migration.rs b/src/rust/lqos_config/src/etc/etclqos_migration.rs index fdf9acbf..963e7d34 100644 --- a/src/rust/lqos_config/src/etc/etclqos_migration.rs +++ b/src/rust/lqos_config/src/etc/etclqos_migration.rs @@ -243,8 +243,9 @@ fn check_config(cfg_doc: &mut DocumentMut, cfg: &mut EtcLqos) { && let Ok(machine_id) = std::fs::read_to_string("/etc/machine-id") { let hash = sha2::Sha256::new().chain(machine_id).finalize(); - cfg.node_id = Some(format!("{:x}", hash)); - cfg_doc["node_id"] = value(format!("{:x}", hash)); + let node_id = crate::hex_encoding::encode_hex_lower(hash); + cfg.node_id = Some(node_id.clone()); + cfg_doc["node_id"] = value(node_id); println!("Updating"); if let Err(e) = cfg.save(cfg_doc) { error!("Unable to save /etc/lqos.conf"); diff --git a/src/rust/lqos_config/src/etc/mod.rs b/src/rust/lqos_config/src/etc/mod.rs index b0c8285c..4a0995c8 100644 --- a/src/rust/lqos_config/src/etc/mod.rs +++ b/src/rust/lqos_config/src/etc/mod.rs @@ -19,8 +19,9 @@ mod python_migration; pub mod test_data; mod v15; pub use v15::{ - BridgeConfig, IntegrationConfig, LazyQueueMode, MikrotikIpv6Config, QueueMode, RttThresholds, - SingleInterfaceConfig, StormguardConfig, StormguardStrategy, TopologyConfig, + BridgeConfig, DynamicCircuitRangeRule, DynamicCircuitsConfig, IntegrationConfig, LazyQueueMode, + MikrotikIpv6Config, QueueMode, RttThresholds, SingleInterfaceConfig, StormguardConfig, + StormguardStrategy, TopologyConfig, TreeguardCircuitsConfig, TreeguardConfig, TreeguardCpuConfig, TreeguardCpuMode, TreeguardLinksConfig, TreeguardQooConfig, Tunables, }; diff --git a/src/rust/lqos_config/src/etc/v15/dynamic_circuits.rs b/src/rust/lqos_config/src/etc/v15/dynamic_circuits.rs new file mode 100644 index 00000000..f725db28 --- /dev/null +++ b/src/rust/lqos_config/src/etc/v15/dynamic_circuits.rs @@ -0,0 +1,210 @@ +//! Dynamic circuit configuration. +//! +//! This module defines the configuration schema for the "dynamic circuits" +//! subsystem. Runtime behavior is implemented elsewhere; for now this is +//! configuration + validation only. + +use allocative::Allocative; +use ip_network::IpNetwork; +use serde::{Deserialize, Serialize}; + +fn default_ttl_seconds() -> u64 { + 300 +} + +fn deserialize_ip_network_allow_default<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + let raw = String::deserialize(deserializer)?; + let trimmed = raw.trim(); + let normalized = match trimmed { + "0.0.0.0" => "0.0.0.0/0", + "::" => "::/0", + other => other, + }; + normalized + .parse::() + .map_err(serde::de::Error::custom) +} + +fn serialize_ip_network(value: &IpNetwork, serializer: S) -> Result +where + S: serde::Serializer, +{ + serializer.serialize_str(&value.to_string()) +} + +/// One rule that applies a default set of rates + optional attachment to an IP range. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Allocative)] +pub struct DynamicCircuitRangeRule { + /// Human-readable rule name used in logs and diagnostics. + pub name: String, + /// IP network matched by this rule (CIDR notation). + /// + /// For convenience, `0.0.0.0` and `::` are accepted shorthands for the default + /// routes (`0.0.0.0/0` and `::/0`). + #[allocative(skip)] + #[serde( + serialize_with = "serialize_ip_network", + deserialize_with = "deserialize_ip_network_allow_default" + )] + pub ip_range: IpNetwork, + /// Minimum guaranteed downstream rate in Mbps. + pub download_min_mbps: f32, + /// Minimum guaranteed upstream rate in Mbps. + pub upload_min_mbps: f32, + /// Maximum downstream rate in Mbps. + pub download_max_mbps: f32, + /// Maximum upstream rate in Mbps. + pub upload_max_mbps: f32, + /// Optional attachment target in `network.json` (node name). Empty means no attachment. + #[serde(default)] + pub attach_to: String, +} + +/// Dynamic circuits configuration. +/// +/// This section is optional in the top-level config so older installations can +/// upgrade without needing to add a new config section. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Allocative)] +pub struct DynamicCircuitsConfig { + /// Master switch for the dynamic circuits feature set. + #[serde(default)] + pub enabled: bool, + /// Time-to-live in seconds for runtime dynamic circuit state. + #[serde(default = "default_ttl_seconds")] + pub ttl_seconds: u64, + /// Enable promotion of unknown IPs into dynamic circuits (when a rule matches). + #[serde(default)] + pub enable_unknown_ip_promotion: bool, + /// Rules evaluated against unknown IPs (and future dynamic overlay logic). + #[serde(default)] + pub ranges: Vec, +} + +impl Default for DynamicCircuitsConfig { + fn default() -> Self { + Self { + enabled: false, + ttl_seconds: default_ttl_seconds(), + enable_unknown_ip_promotion: false, + ranges: Vec::new(), + } + } +} + +impl DynamicCircuitsConfig { + /// Validates configured dynamic circuit settings. + pub fn validate(&self) -> Result<(), String> { + if self.ttl_seconds == 0 { + return Err("dynamic_circuits.ttl_seconds must be > 0".to_string()); + } + + for (index, rule) in self.ranges.iter().enumerate() { + let label = if rule.name.trim().is_empty() { + format!("dynamic_circuits.ranges[{index}]") + } else { + format!("dynamic_circuits.ranges[{index}] ('{}')", rule.name.trim()) + }; + + if rule.name.trim().is_empty() { + return Err(format!("{label}: name must not be empty")); + } + + if !rule.download_min_mbps.is_finite() || rule.download_min_mbps < 0.1 { + return Err(format!("{label}: download_min_mbps must be >= 0.1")); + } + if !rule.upload_min_mbps.is_finite() || rule.upload_min_mbps < 0.1 { + return Err(format!("{label}: upload_min_mbps must be >= 0.1")); + } + if !rule.download_max_mbps.is_finite() || rule.download_max_mbps < 0.2 { + return Err(format!("{label}: download_max_mbps must be >= 0.2")); + } + if !rule.upload_max_mbps.is_finite() || rule.upload_max_mbps < 0.2 { + return Err(format!("{label}: upload_max_mbps must be >= 0.2")); + } + if rule.download_min_mbps > rule.download_max_mbps { + return Err(format!( + "{label}: download_min_mbps must be <= download_max_mbps" + )); + } + if rule.upload_min_mbps > rule.upload_max_mbps { + return Err(format!( + "{label}: upload_min_mbps must be <= upload_max_mbps" + )); + } + } + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::etc::v15::Config; + + #[test] + fn parses_default_ipv4_shorthand() { + #[derive(Deserialize)] + struct Wrapper { + #[serde(deserialize_with = "deserialize_ip_network_allow_default")] + ip_range: IpNetwork, + } + + let parsed: Wrapper = + toml::from_str(r#"ip_range = "0.0.0.0""#).expect("parse ip_range shorthand"); + assert_eq!(parsed.ip_range.to_string(), "0.0.0.0/0"); + } + + #[test] + fn parses_default_ipv6_shorthand() { + #[derive(Deserialize)] + struct Wrapper { + #[serde(deserialize_with = "deserialize_ip_network_allow_default")] + ip_range: IpNetwork, + } + + let parsed: Wrapper = + toml::from_str(r#"ip_range = "::""#).expect("parse ip_range shorthand"); + assert_eq!(parsed.ip_range.to_string(), "::/0"); + } + + #[test] + fn config_roundtrips_with_dynamic_circuit_ranges() { + let mut config = Config::default(); + + let rule = DynamicCircuitRangeRule { + name: "Default".to_string(), + ip_range: "0.0.0.0/0" + .parse() + .expect("default ip network should parse"), + download_min_mbps: 10.0, + upload_min_mbps: 2.0, + download_max_mbps: 100.0, + upload_max_mbps: 20.0, + attach_to: String::new(), + }; + + config.dynamic_circuits = Some(DynamicCircuitsConfig { + enabled: true, + ttl_seconds: 300, + enable_unknown_ip_promotion: true, + ranges: vec![rule], + }); + + let raw = toml::to_string_pretty(&config).expect("config should serialize to TOML"); + let parsed = Config::load_from_string(&raw).expect("config should deserialize"); + assert!(parsed.dynamic_circuits.is_some()); + assert_eq!( + parsed + .dynamic_circuits + .as_ref() + .expect("dynamic circuits should deserialize") + .ranges + .len(), + 1 + ); + } +} diff --git a/src/rust/lqos_config/src/etc/v15/mod.rs b/src/rust/lqos_config/src/etc/v15/mod.rs index f0876422..c2db9307 100644 --- a/src/rust/lqos_config/src/etc/v15/mod.rs +++ b/src/rust/lqos_config/src/etc/v15/mod.rs @@ -4,6 +4,7 @@ mod top_config; pub use top_config::Config; pub use top_config::RttThresholds; mod bridge; +mod dynamic_circuits; mod flows; pub mod influxdb; mod integration_common; @@ -24,6 +25,7 @@ mod visp_integration; mod wispgate; pub use bridge::*; +pub use dynamic_circuits::*; pub use integration_common::IntegrationConfig; pub use long_term_stats::LongTermStats; pub use mikrotik_ipv6::MikrotikIpv6Config; diff --git a/src/rust/lqos_config/src/etc/v15/top_config.rs b/src/rust/lqos_config/src/etc/v15/top_config.rs index ab41bbbd..6f7627ab 100644 --- a/src/rust/lqos_config/src/etc/v15/top_config.rs +++ b/src/rust/lqos_config/src/etc/v15/top_config.rs @@ -111,6 +111,12 @@ pub struct Config { /// IP Range definitions pub ip_ranges: super::ip_ranges::IpRanges, + /// Dynamic circuits configuration. + /// + /// Optional so older configs without this section still deserialize cleanly. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub dynamic_circuits: Option, + /// Network flows configuration pub flows: Option, @@ -201,7 +207,7 @@ impl Config { pub fn calculate_node_id() -> String { if let Ok(machine_id) = std::fs::read_to_string("/etc/machine-id") { let hash = sha2::Sha256::new().chain(machine_id).finalize(); - format!("{:x}", hash) + crate::hex_encoding::encode_hex_lower(hash) } else { Uuid::new_v4().to_string() } @@ -279,6 +285,9 @@ impl Config { stormguard.validate()?; } self.treeguard.validate()?; + if let Some(dynamic_circuits) = &self.dynamic_circuits { + dynamic_circuits.validate()?; + } Ok(()) } @@ -352,6 +361,7 @@ impl Default for Config { queues: super::queues::QueueConfig::default(), long_term_stats: super::long_term_stats::LongTermStats::default(), ip_ranges: super::ip_ranges::IpRanges::default(), + dynamic_circuits: None, integration_common: super::integration_common::IntegrationConfig::default(), topology: super::topology::TopologyConfig::default(), mikrotik_ipv6: super::mikrotik_ipv6::MikrotikIpv6Config::default(), diff --git a/src/rust/lqos_config/src/hex_encoding.rs b/src/rust/lqos_config/src/hex_encoding.rs new file mode 100644 index 00000000..45a6295f --- /dev/null +++ b/src/rust/lqos_config/src/hex_encoding.rs @@ -0,0 +1,19 @@ +const HEX_LOWER: &[u8; 16] = b"0123456789abcdef"; +const HEX_UPPER: &[u8; 16] = b"0123456789ABCDEF"; + +pub(crate) fn encode_hex_lower(bytes: impl AsRef<[u8]>) -> String { + encode_hex(bytes.as_ref(), HEX_LOWER) +} + +pub(crate) fn encode_hex_upper(bytes: impl AsRef<[u8]>) -> String { + encode_hex(bytes.as_ref(), HEX_UPPER) +} + +fn encode_hex(bytes: &[u8], table: &[u8; 16]) -> String { + let mut encoded = String::with_capacity(bytes.len().saturating_mul(2)); + for &byte in bytes { + encoded.push(table[(byte >> 4) as usize] as char); + encoded.push(table[(byte & 0x0f) as usize] as char); + } + encoded +} diff --git a/src/rust/lqos_config/src/lib.rs b/src/rust/lqos_config/src/lib.rs index 03b692a4..7be006d7 100644 --- a/src/rust/lqos_config/src/lib.rs +++ b/src/rust/lqos_config/src/lib.rs @@ -15,6 +15,7 @@ mod circuit_ethernet_metadata; mod cpu_topology; mod etc; mod ethernet_port_limits; +mod hex_encoding; mod mikrotik_ipv6_credentials; mod network_json; mod planner; @@ -40,8 +41,9 @@ pub use cpu_topology::{ CpuListParseError, ShapingCpuDetection, ShapingCpuSource, detect_shaping_cpus, }; pub use etc::{ - BridgeConfig, Config, LazyQueueMode, MikrotikIpv6Config, QueueMode, RttThresholds, - SingleInterfaceConfig, StormguardConfig, StormguardStrategy, TopologyConfig, + BridgeConfig, Config, DynamicCircuitRangeRule, DynamicCircuitsConfig, LazyQueueMode, QueueMode, + MikrotikIpv6Config, RttThresholds, SingleInterfaceConfig, StormguardConfig, + StormguardStrategy, TopologyConfig, TreeguardCircuitsConfig, TreeguardConfig, TreeguardCpuConfig, TreeguardCpuMode, TreeguardLinksConfig, TreeguardQooConfig, Tunables, clear_cached_config, disable_xdp_bridge, enable_long_term_stats, load_config, treeguard_cpu_mode_migration_notice, update_config, diff --git a/src/rust/lqos_config/src/topology_canonical_state.rs b/src/rust/lqos_config/src/topology_canonical_state.rs index 3fa734c1..2e253342 100644 --- a/src/rust/lqos_config/src/topology_canonical_state.rs +++ b/src/rust/lqos_config/src/topology_canonical_state.rs @@ -221,7 +221,7 @@ where hasher.update([0]); hasher.update(identifier.as_bytes()); } - Some(format!("{:x}", hasher.finalize())) + Some(crate::hex_encoding::encode_hex_lower(hasher.finalize())) } /// Returns a stable identity hash for imported topology facts and compile mode selection. @@ -246,7 +246,7 @@ where hasher.update([0]); hasher.update(identifier.as_bytes()); } - Some(format!("{:x}", hasher.finalize())) + Some(crate::hex_encoding::encode_hex_lower(hasher.finalize())) } fn collect_network_json_ingress_tokens(map: &Map, out: &mut Vec) { diff --git a/src/rust/lqos_config/src/topology_runtime_state.rs b/src/rust/lqos_config/src/topology_runtime_state.rs index 3a46f184..4bf53b5d 100644 --- a/src/rust/lqos_config/src/topology_runtime_state.rs +++ b/src/rust/lqos_config/src/topology_runtime_state.rs @@ -582,7 +582,7 @@ pub fn compute_topology_source_generation( hash_file_state(&mut hasher, TOPOLOGY_EDITOR_STATE_FILENAME, &editor_path)?; } - Ok(format!("{:x}", hasher.finalize())) + Ok(crate::hex_encoding::encode_hex_lower(hasher.finalize())) } impl TopologyAttachmentHealthStateFile { @@ -667,7 +667,7 @@ impl TopologyShapingInputsFile { hasher.update(b"topology-runtime-shaping-generation"); hasher.update([0xff]); hasher.update(payload); - Ok(format!("{:x}", hasher.finalize())) + Ok(crate::hex_encoding::encode_hex_lower(hasher.finalize())) } } @@ -702,7 +702,7 @@ pub fn compute_effective_network_generation( hasher.update(b"topology-runtime-effective-generation"); hasher.update([0xff]); hasher.update(payload); - Ok(format!("{:x}", hasher.finalize())) + Ok(crate::hex_encoding::encode_hex_lower(hasher.finalize())) } impl TopologyRuntimeStatusFile { diff --git a/src/rust/lqos_network_devices/Cargo.toml b/src/rust/lqos_network_devices/Cargo.toml new file mode 100644 index 00000000..6a91aa3c --- /dev/null +++ b/src/rust/lqos_network_devices/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "lqos_network_devices" +version = "0.1.0" +edition = "2024" +license = "GPL-2.0-only" + +[dependencies] +anyhow = { workspace = true } +arc-swap = { workspace = true } +crossbeam-channel = { workspace = true } +fxhash = "0.2.1" +ip_network = { workspace = true } +ip_network_table = { workspace = true } +lqos_config = { path = "../lqos_config" } +lqos_topology_compile = { path = "../lqos_topology_compile" } +lqos_utils = { path = "../lqos_utils" } +once_cell = { workspace = true } +parking_lot = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +tokio = { workspace = true } +tracing = { workspace = true } diff --git a/src/rust/lqos_network_devices/README.md b/src/rust/lqos_network_devices/README.md new file mode 100644 index 00000000..2f12b450 --- /dev/null +++ b/src/rust/lqos_network_devices/README.md @@ -0,0 +1,63 @@ +# `lqos_network_devices` + +Centralized, shared access to LibreQoS topology inputs: + +- `ShapedDevices.csv` (and `ShapedDevices.insight.csv`) +- `network.json` (and `network.insight.json` / `network.effective.json`) + +## Runtime model + +In daemon mode, this crate starts: + +- A single-thread actor that owns reload/apply commands. +- A config-directory watcher that coalesces filesystem events and requests reloads. + +Callers access state through public accessor functions; the underlying channel sender is kept in a +private static. + +## Public API (high level) + +- `start_daemon_mode(hooks)`: starts actor + directory watcher. +- `shaped_devices_catalog()`: preferred shaped-devices snapshot handle with common lookup helpers. +- `shaped_devices_snapshot()`: low-level `Arc` snapshot. +- `shaped_device_hash_cache_snapshot()`: low-level hash→index cache snapshot (device/circuit hash lookups). +- `with_network_json_read(|net_json| ...)`: read-only access to in-memory `NetworkJson`. +- `with_network_json_write(|net_json| ...)`: mutable access to in-memory `NetworkJson` (used by runtime counters). +- `resolve_parent_node_reference(parent_node, parent_node_id)`: canonicalize shaped-device parent references against `network.json`. +- `request_reload_shaped_devices(reason)` / `request_reload_network_json(reason)`: ask actor to reload from disk. +- `apply_shaped_devices_snapshot(reason, shaped)`: publish a caller-provided shaped-devices snapshot via actor. +- `swap_shaped_devices_snapshot(reason, shaped_arc)`: replace shaped-devices snapshot without actor (intended for tests). + +## `ShapedDevicesCatalog` helpers + +`ShapedDevicesCatalog` exposes common verb-style operations over a consistent shaped-devices snapshot: + +- `device_by_hashes(device_hash, circuit_hash)`: lookups using stable baked hashes. +- `devices_for_circuit_id(circuit_id)`: rows for one circuit. +- `configured_circuit_count()`: unique configured circuits. +- `circuit_rate_caps_by_circuit_id()`: per-circuit configured max Mbps. +- `device_longest_match_for_ip(ip)`: longest-prefix match via the LPM trie. +- `iter_ip_mappings()`: iterate the LPM trie mappings. + +## Notes for `lqosd` + +`lqosd` starts daemon mode and provides hooks to invalidate derived snapshots/caches on updates. +Runtime throughput tracking mutates the in-memory `NetworkJson` via `with_network_json_write`. + +## Access & update catalog + +Current (2026-04) call sites and update patterns: + +- Daemon startup: `lqosd` calls `start_daemon_mode()` once at boot and provides `DaemonHooks`. +- Shaped-device updates: + - Directory watcher requests reload when `ShapedDevices.csv` or `ShapedDevices.insight.csv` changes. + - Node manager admin edits write `ShapedDevices.csv` and immediately publish the new snapshot via + `apply_shaped_devices_snapshot()`. +- Network topology updates: + - Directory watcher requests reload when `network.json`, `network.insight.json`, or + `network.effective.json` changes. + - Node manager admin edits write `network.json` and request reload via `request_reload_network_json()`. +- Hot-path reads (in-memory snapshots): node manager REST endpoints, throughput tracking, Insight/LTS2 + control-channel snapshotting, and shaped-device/circuit views. +- One-shot disk loads: tools/sidecars (e.g. `lqos_python`, `lqos_stormguard`, support tooling) should + use `load_shaped_devices()` / `load_network_json()` rather than reaching into config modules directly. diff --git a/src/rust/lqos_network_devices/src/actor.rs b/src/rust/lqos_network_devices/src/actor.rs new file mode 100644 index 00000000..6b6f3069 --- /dev/null +++ b/src/rust/lqos_network_devices/src/actor.rs @@ -0,0 +1,684 @@ +use crate::dynamic::{ + CircuitObservation, expired_dynamic_circuit_ids, is_superseded_by_shaped_devices, +}; +use crate::dynamic_store::{load_dynamic_circuits_from_disk, persist_dynamic_circuits_to_disk}; +use crate::state; +use crate::{DaemonHooks, ShapedDevicesCatalog, load_network_json, load_shaped_devices}; +use anyhow::{Context, Result, anyhow}; +use crossbeam_channel::{Receiver, Sender}; +use ip_network_table::IpNetworkTable; +use once_cell::sync::OnceCell; +use std::collections::HashSet; +use std::net::IpAddr; +use std::sync::Arc; +use std::time::Duration; +use tokio::sync::oneshot; +use tracing::{debug, error, warn}; + +const RELOAD_RETRY_DELAY_MS: u64 = 500; +const RELOAD_ATTEMPTS: usize = 2; +const DYNAMIC_CIRCUITS_MAINTENANCE_TICK_SECONDS: u64 = 60; +const MAX_UNKNOWN_IP_PROMOTIONS_PER_OBSERVATION: usize = 32; + +static ACTOR_SENDER: OnceCell> = OnceCell::new(); + +pub(crate) fn start_actor(hooks: Option>) -> Result<()> { + if ACTOR_SENDER.get().is_some() { + return Ok(()); + } + + let (tx, rx) = crossbeam_channel::bounded::(64); + let _ = ACTOR_SENDER.set(tx); + + std::thread::Builder::new() + .name("lqos_network_devices".to_string()) + .spawn(move || actor_loop(rx, hooks))?; + + Ok(()) +} + +fn sender() -> Result> { + ACTOR_SENDER + .get() + .cloned() + .ok_or_else(|| anyhow!("lqos_network_devices runtime actor is not running")) +} + +pub(crate) fn request_reload_shaped_devices(reason: &str) -> Result<()> { + let (reply_tx, reply_rx) = oneshot::channel(); + sender()?.send(NetworkDevicesCommand::ReloadShapedDevices { + reason: reason.to_string(), + reply: reply_tx, + })?; + reply_rx + .blocking_recv() + .map_err(|_| anyhow!("ShapedDevices reload reply channel closed"))? +} + +pub(crate) fn request_reload_network_json(reason: &str) -> Result<()> { + let (reply_tx, reply_rx) = oneshot::channel(); + sender()?.send(NetworkDevicesCommand::ReloadNetworkJson { + reason: reason.to_string(), + reply: reply_tx, + })?; + reply_rx + .blocking_recv() + .map_err(|_| anyhow!("NetworkJson reload reply channel closed"))? +} + +pub(crate) fn apply_shaped_devices_snapshot( + reason: &str, + shaped: lqos_config::ConfigShapedDevices, +) -> Result<()> { + let (reply_tx, reply_rx) = oneshot::channel(); + sender()?.send(NetworkDevicesCommand::ApplyShapedDevicesSnapshot { + reason: reason.to_string(), + shaped: Box::new(shaped), + reply: reply_tx, + })?; + reply_rx + .blocking_recv() + .map_err(|_| anyhow!("Apply shaped devices reply channel closed"))? +} + +pub(crate) fn report_observations(observations: &[CircuitObservation]) { + if observations.is_empty() { + return; + } + + let Some(sender) = ACTOR_SENDER.get().cloned() else { + return; + }; + let _ = sender.try_send(NetworkDevicesCommand::ReportObservations { + observations: observations.to_vec(), + }); +} + +pub(crate) fn upsert_dynamic_circuit(shaped_device: lqos_config::ShapedDevice) -> Result<()> { + let (reply_tx, reply_rx) = oneshot::channel(); + sender()?.send(NetworkDevicesCommand::UpsertDynamicCircuit { + shaped_device: Box::new(shaped_device), + reply: reply_tx, + })?; + reply_rx + .blocking_recv() + .map_err(|_| anyhow!("Upsert dynamic circuit reply channel closed"))? +} + +pub(crate) fn remove_dynamic_circuit(circuit_id: &str) -> Result { + let (reply_tx, reply_rx) = oneshot::channel(); + sender()?.send(NetworkDevicesCommand::RemoveDynamicCircuit { + circuit_id: circuit_id.to_string(), + reply: reply_tx, + })?; + reply_rx + .blocking_recv() + .map_err(|_| anyhow!("Remove dynamic circuit reply channel closed"))? +} + +enum NetworkDevicesCommand { + ReloadShapedDevices { + reason: String, + reply: oneshot::Sender>, + }, + ReloadNetworkJson { + reason: String, + reply: oneshot::Sender>, + }, + ApplyShapedDevicesSnapshot { + reason: String, + shaped: Box, + reply: oneshot::Sender>, + }, + ReportObservations { + observations: Vec, + }, + UpsertDynamicCircuit { + shaped_device: Box, + reply: oneshot::Sender>, + }, + RemoveDynamicCircuit { + circuit_id: String, + reply: oneshot::Sender>, + }, +} + +fn actor_loop(rx: Receiver, hooks: Option>) { + debug!("lqos_network_devices actor starting"); + + if let Err(err) = reload_shaped_devices_inner("startup", hooks.as_deref()) { + warn!("Initial shaped-devices load failed: {err}"); + } + if let Err(err) = reload_network_json_inner("startup", hooks.as_deref()) { + warn!("Initial network-json load failed: {err}"); + } + reload_dynamic_circuits_inner("startup"); + prune_dynamic_circuits_superseded_by_shaped_devices_inner(hooks.as_deref()); + + let tick = crossbeam_channel::tick(Duration::from_secs( + DYNAMIC_CIRCUITS_MAINTENANCE_TICK_SECONDS, + )); + + loop { + crossbeam_channel::select! { + recv(rx) -> msg => { + let command = match msg { + Ok(value) => value, + Err(_) => break, + }; + + match command { + NetworkDevicesCommand::ReloadShapedDevices { reason, reply } => { + let result = reload_shaped_devices_inner(&reason, hooks.as_deref()); + let _ = reply.send(result); + } + NetworkDevicesCommand::ReloadNetworkJson { reason, reply } => { + let result = reload_network_json_inner(&reason, hooks.as_deref()); + let _ = reply.send(result); + } + NetworkDevicesCommand::ApplyShapedDevicesSnapshot { + reason, + shaped, + reply, + } => { + debug!("Publishing shaped-devices snapshot reason={reason}"); + state::publish_shaped_devices(*shaped); + prune_dynamic_circuits_superseded_by_shaped_devices_inner(hooks.as_deref()); + if let Some(hooks) = &hooks { + hooks.on_shaped_devices_updated(); + } + let _ = reply.send(Ok(())); + } + NetworkDevicesCommand::ReportObservations { observations } => { + handle_observations(&observations, hooks.as_deref()); + } + NetworkDevicesCommand::UpsertDynamicCircuit { + shaped_device, + reply, + } => { + let result = upsert_dynamic_circuit_inner(*shaped_device); + let _ = reply.send(result); + } + NetworkDevicesCommand::RemoveDynamicCircuit { circuit_id, reply } => { + let result = remove_dynamic_circuit_inner(&circuit_id); + let _ = reply.send(result); + } + } + } + recv(tick) -> _ => { + prune_expired_dynamic_circuits_inner(hooks.as_deref()); + } + } + } + + error!("lqos_network_devices actor stopped"); +} + +fn normalize_circuit_id_key(value: &str) -> String { + value.trim().to_ascii_lowercase() +} + +fn recompute_hashes(device: &mut lqos_config::ShapedDevice) { + device.circuit_hash = lqos_utils::hash_to_i64(&device.circuit_id); + device.device_hash = lqos_utils::hash_to_i64(&device.device_id); + device.parent_hash = lqos_utils::hash_to_i64(&device.parent_node); +} + +fn reload_dynamic_circuits_inner(reason: &str) { + let circuits = load_dynamic_circuits_from_disk(); + debug!("Loaded {} dynamic circuits reason={reason}", circuits.len()); + state::publish_dynamic_circuits_snapshot(circuits); +} + +fn prune_expired_dynamic_circuits_inner(hooks: Option<&dyn DaemonHooks>) { + let Ok(config) = lqos_config::load_config() else { + return; + }; + let Some(dynamic_cfg) = config.dynamic_circuits.as_ref() else { + return; + }; + if !dynamic_cfg.enabled { + return; + } + let ttl_seconds = dynamic_cfg.ttl_seconds; + if ttl_seconds == 0 { + return; + } + + let Ok(now_unix) = lqos_utils::unix_time::unix_now() else { + return; + }; + + let snapshot = state::dynamic_circuits_snapshot(); + if snapshot.is_empty() { + return; + } + + let mut expired_ids = expired_dynamic_circuit_ids(snapshot.as_ref(), now_unix, ttl_seconds); + if expired_ids.is_empty() { + return; + } + expired_ids.sort(); + expired_ids.dedup(); + + let expired_keys: HashSet = expired_ids + .iter() + .map(|id| normalize_circuit_id_key(id)) + .collect(); + let updated: Vec<_> = snapshot + .iter() + .filter(|circuit| { + let key = normalize_circuit_id_key(&circuit.shaped.circuit_id); + !expired_keys.contains(&key) + }) + .cloned() + .collect(); + + if let Err(err) = persist_dynamic_circuits_to_disk(&updated) { + warn!("Unable to persist dynamic circuits after TTL pruning: {err:?}"); + return; + } + state::publish_dynamic_circuits_snapshot(updated); + debug!( + "Pruned {} expired dynamic circuits ttl_seconds={ttl_seconds}", + expired_ids.len() + ); + + if let Some(hooks) = hooks { + hooks.on_dynamic_circuits_expired(&expired_ids); + } +} + +fn upsert_dynamic_circuit_inner(mut shaped_device: lqos_config::ShapedDevice) -> Result<()> { + recompute_hashes(&mut shaped_device); + let now_unix = lqos_utils::unix_time::unix_now().context("get unix time")?; + + let snapshot = state::dynamic_circuits_snapshot(); + let mut updated = snapshot.as_ref().clone(); + let key = normalize_circuit_id_key(&shaped_device.circuit_id); + let mut should_persist = false; + + if let Some(pos) = updated + .iter() + .position(|c| normalize_circuit_id_key(&c.shaped.circuit_id) == key) + { + if updated[pos].shaped != shaped_device { + should_persist = true; + } + updated[pos].shaped = shaped_device; + updated[pos].last_seen_unix = now_unix; + } else { + updated.push(crate::DynamicCircuit { + shaped: shaped_device, + last_seen_unix: now_unix, + }); + should_persist = true; + } + + if should_persist { + persist_dynamic_circuits_to_disk(&updated).context("persist dynamic circuits to disk")?; + } + state::publish_dynamic_circuits_snapshot(updated); + Ok(()) +} + +fn remove_dynamic_circuit_inner(circuit_id: &str) -> Result { + let snapshot = state::dynamic_circuits_snapshot(); + if snapshot.is_empty() { + return Ok(false); + } + + let key = normalize_circuit_id_key(circuit_id); + let updated: Vec<_> = snapshot + .iter() + .filter(|c| normalize_circuit_id_key(&c.shaped.circuit_id) != key) + .cloned() + .collect(); + + if updated.len() == snapshot.len() { + return Ok(false); + } + + persist_dynamic_circuits_to_disk(&updated).context("persist dynamic circuits to disk")?; + state::publish_dynamic_circuits_snapshot(updated); + Ok(true) +} + +fn handle_observations(observations: &[CircuitObservation], hooks: Option<&dyn DaemonHooks>) { + if observations.is_empty() { + return; + } + + let catalog = state::shaped_devices_catalog(); + let dynamic_snapshot = state::dynamic_circuits_snapshot(); + let mut dynamic_device_hashes: HashSet = HashSet::new(); + let mut dynamic_circuit_hashes: HashSet = HashSet::new(); + dynamic_device_hashes.reserve(dynamic_snapshot.len()); + dynamic_circuit_hashes.reserve(dynamic_snapshot.len()); + for circuit in dynamic_snapshot.iter() { + dynamic_device_hashes.insert(circuit.shaped.device_hash); + dynamic_circuit_hashes.insert(circuit.shaped.circuit_hash); + } + + let mut seen_device_hashes: HashSet = HashSet::new(); + let mut seen_circuit_hashes: HashSet = HashSet::new(); + let mut unknown_candidates: Vec = Vec::new(); + + for observation in observations { + if catalog + .device_by_hashes(observation.device_hash, observation.circuit_hash) + .is_some() + { + continue; + } + + if let Some(device_hash) = observation.device_hash + && dynamic_device_hashes.contains(&device_hash) + { + seen_device_hashes.insert(device_hash); + continue; + } + + if let Some(circuit_hash) = observation.circuit_hash + && dynamic_circuit_hashes.contains(&circuit_hash) + { + seen_circuit_hashes.insert(circuit_hash); + continue; + } + + unknown_candidates.push(*observation); + } + + if (!seen_device_hashes.is_empty() || !seen_circuit_hashes.is_empty()) + && let Ok(now_unix) = lqos_utils::unix_time::unix_now() + { + state::refresh_dynamic_circuits_last_seen_for_hashes( + &seen_device_hashes, + &seen_circuit_hashes, + now_unix, + ); + } + + if !unknown_candidates.is_empty() { + maybe_promote_unknown_ips(&unknown_candidates, &catalog, hooks); + } +} + +fn maybe_promote_unknown_ips( + unknown_candidates: &[CircuitObservation], + catalog: &ShapedDevicesCatalog, + hooks: Option<&dyn DaemonHooks>, +) { + let Ok(config) = lqos_config::load_config() else { + return; + }; + + let Some(dynamic_cfg) = config.dynamic_circuits.as_ref() else { + return; + }; + if !dynamic_cfg.enabled || !dynamic_cfg.enable_unknown_ip_promotion { + return; + } + if dynamic_cfg.ranges.is_empty() { + return; + } + + let mut trie: IpNetworkTable = IpNetworkTable::new(); + for (idx, rule) in dynamic_cfg.ranges.iter().enumerate() { + trie.insert(rule.ip_range, idx); + } + + let mut promoted = 0usize; + for observation in unknown_candidates { + if promoted >= MAX_UNKNOWN_IP_PROMOTIONS_PER_OBSERVATION { + break; + } + + // Never promote an IP that is already covered by static shaped devices. + if catalog + .device_longest_match_for_ip(&observation.ip) + .is_some() + { + continue; + } + + let ip: IpAddr = observation.ip.as_ip(); + let Some((_net, rule_idx)) = trie.longest_match(ip) else { + continue; + }; + let Some(rule) = dynamic_cfg.ranges.get(*rule_idx) else { + continue; + }; + + let label = format!("[dyn] ({}) {}", rule.name.trim(), observation.ip); + let parent_node = resolve_unknown_promotion_parent(rule.attach_to.trim()); + if parent_node.trim().is_empty() { + continue; + } + + let shaped_device = shaped_device_from_unknown_ip(&label, &parent_node, ip, rule); + if let Err(err) = upsert_dynamic_circuit_inner(shaped_device.clone()) { + warn!( + "Unable to persist dynamic circuit from unknown IP promotion for {}: {err:?}", + observation.ip + ); + continue; + } + + if let Some(hooks) = hooks { + hooks.on_unknown_ip_promoted(&shaped_device); + } + + promoted += 1; + } + + if promoted > 0 { + debug!( + "Promoted {promoted} unknown IP(s) into dynamic circuits (candidates={})", + unknown_candidates.len() + ); + } +} + +fn resolve_unknown_promotion_parent(attach_to: &str) -> String { + let trimmed_attach = attach_to.trim(); + if !trimmed_attach.is_empty() { + if let Some(resolved) = crate::resolve_parent_node(trimmed_attach) { + return resolved.name; + } + + // In "flat" setups (no meaningful `network.json`), attachments are just site-name strings. + let is_flat = state::with_network_json_read(|net_json| { + let nodes = net_json.get_nodes_when_ready(); + let has_non_root = nodes + .iter() + .any(|node| node.name.trim() != "Root" && !node.name.trim().is_empty()); + nodes.len() <= 1 || !has_non_root + }); + if is_flat { + return trimmed_attach.to_string(); + } + } + + let (node_len, orphans, first_non_root) = state::with_network_json_read(|net_json| { + let nodes = net_json.get_nodes_when_ready(); + let first_non_root = nodes + .iter() + .find(|node| node.name.trim() != "Root" && !node.name.trim().is_empty()) + .map(|node| node.name.clone()); + let orphans = nodes + .iter() + .find(|node| node.name.trim().eq_ignore_ascii_case("ORPHANS")) + .map(|node| node.name.clone()); + + (nodes.len(), orphans, first_non_root) + }); + // "Flat" means `network.json` had no meaningful tree beyond Root. + let is_flat = node_len <= 1 || first_non_root.is_none(); + if !is_flat { + if let Some(orphans) = orphans { + return orphans; + } + if let Some(first) = first_non_root { + return first; + } + } + + let shaped_parent = state::shaped_devices_snapshot() + .devices + .iter() + .find_map(|dev| { + let trimmed = dev.parent_node.trim(); + (!trimmed.is_empty()).then_some(trimmed.to_string()) + }); + + shaped_parent.unwrap_or_else(|| "Root".to_string()) +} + +fn shaped_device_from_unknown_ip( + label: &str, + parent_node: &str, + ip: IpAddr, + rule: &lqos_config::DynamicCircuitRangeRule, +) -> lqos_config::ShapedDevice { + let mut device = lqos_config::ShapedDevice { + circuit_id: label.to_string(), + circuit_name: label.to_string(), + device_id: label.to_string(), + device_name: label.to_string(), + parent_node: parent_node.to_string(), + download_min_mbps: rule.download_min_mbps, + upload_min_mbps: rule.upload_min_mbps, + download_max_mbps: rule.download_max_mbps, + upload_max_mbps: rule.upload_max_mbps, + ..Default::default() + }; + + match ip { + IpAddr::V4(ip) => device.ipv4.push((ip, 32)), + IpAddr::V6(ip) => device.ipv6.push((ip, 128)), + } + + device +} + +fn reload_shaped_devices_inner(reason: &str, hooks: Option<&dyn DaemonHooks>) -> Result<()> { + for attempt in 1..=RELOAD_ATTEMPTS { + match load_shaped_devices() { + Ok(shaped) => { + debug!("Loaded shaped devices reason={reason}"); + state::publish_shaped_devices(shaped); + prune_dynamic_circuits_superseded_by_shaped_devices_inner(hooks); + if let Some(hooks) = hooks { + hooks.on_shaped_devices_updated(); + } + return Ok(()); + } + Err(err) if attempt < RELOAD_ATTEMPTS => { + warn!( + "ShapedDevices reload reason={reason} attempt {attempt}/{} failed: {err}. Retrying after {} ms.", + RELOAD_ATTEMPTS, RELOAD_RETRY_DELAY_MS + ); + std::thread::sleep(Duration::from_millis(RELOAD_RETRY_DELAY_MS)); + } + Err(err) => { + warn!( + "ShapedDevices reload reason={reason} failed after {} attempts: {err}. Keeping last-known-good snapshot with {} devices.", + RELOAD_ATTEMPTS, + state::shaped_devices_snapshot().devices.len() + ); + return Err(err); + } + } + } + unreachable!("reload loop must return"); +} + +fn prune_dynamic_circuits_superseded_by_shaped_devices_inner(hooks: Option<&dyn DaemonHooks>) { + let catalog = state::shaped_devices_catalog(); + let snapshot = state::dynamic_circuits_snapshot(); + if snapshot.is_empty() { + return; + } + + let mut removed_ids: Vec = Vec::new(); + let mut removed_keys: HashSet = HashSet::new(); + + for circuit in snapshot.iter() { + if !is_superseded_by_shaped_devices(circuit, &catalog) { + continue; + } + + let circuit_id = circuit.shaped.circuit_id.trim(); + if circuit_id.is_empty() { + continue; + } + let key = normalize_circuit_id_key(circuit_id); + if removed_keys.insert(key) { + removed_ids.push(circuit_id.to_string()); + } + } + + if removed_keys.is_empty() { + return; + } + + removed_ids.sort(); + removed_ids.dedup(); + + let updated: Vec<_> = snapshot + .iter() + .filter(|circuit| { + let circuit_id = circuit.shaped.circuit_id.trim(); + if circuit_id.is_empty() { + return true; + } + let key = normalize_circuit_id_key(circuit_id); + !removed_keys.contains(&key) + }) + .cloned() + .collect(); + + if let Err(err) = persist_dynamic_circuits_to_disk(&updated) { + warn!("Unable to persist dynamic circuits after shaped-devices conflict prune: {err:?}"); + } + state::publish_dynamic_circuits_snapshot(updated); + debug!( + "Pruned {} dynamic circuits superseded by ShapedDevices.csv changes", + removed_ids.len() + ); + + if let Some(hooks) = hooks { + hooks.on_dynamic_circuits_expired(&removed_ids); + } +} + +fn reload_network_json_inner(reason: &str, hooks: Option<&dyn DaemonHooks>) -> Result<()> { + for attempt in 1..=RELOAD_ATTEMPTS { + match load_network_json() { + Ok(net_json) => { + debug!("Loaded network json reason={reason}"); + state::publish_network_json(net_json); + if let Some(hooks) = hooks { + hooks.on_network_json_updated(); + } + return Ok(()); + } + Err(err) if attempt < RELOAD_ATTEMPTS => { + warn!( + "NetworkJson reload reason={reason} attempt {attempt}/{} failed: {err}. Retrying after {} ms.", + RELOAD_ATTEMPTS, RELOAD_RETRY_DELAY_MS + ); + std::thread::sleep(Duration::from_millis(RELOAD_RETRY_DELAY_MS)); + } + Err(err) => { + warn!( + "NetworkJson reload reason={reason} failed after {} attempts: {err}. Keeping last-known-good snapshot.", + RELOAD_ATTEMPTS + ); + return Err(err); + } + } + } + unreachable!("reload loop must return"); +} diff --git a/src/rust/lqos_network_devices/src/catalog.rs b/src/rust/lqos_network_devices/src/catalog.rs new file mode 100644 index 00000000..bb1a085e --- /dev/null +++ b/src/rust/lqos_network_devices/src/catalog.rs @@ -0,0 +1,182 @@ +use crate::hash_cache::ShapedDeviceHashCache; +use fxhash::FxHashSet; +use ip_network::IpNetwork; +use lqos_config::{ConfigShapedDevices, ShapedDevice}; +use lqos_utils::XdpIpAddress; +use std::collections::HashMap; +use std::net::IpAddr; +use std::sync::Arc; + +/// Snapshot handle for shaped devices plus derived lookup structures. +/// +/// Prefer using this catalog over reaching into `ConfigShapedDevices` directly. +/// It keeps related snapshots consistent (shaped devices + hash cache) and exposes +/// common operations as verb-style methods. +#[derive(Clone)] +pub struct ShapedDevicesCatalog { + shaped: Arc, + cache: Arc, + generation: u64, +} + +impl ShapedDevicesCatalog { + pub(crate) fn new( + shaped: Arc, + cache: Arc, + generation: u64, + ) -> Self { + Self { + shaped, + cache, + generation, + } + } + + /// Builds a catalog from an arbitrary shaped-devices snapshot. + /// + /// This is intended for one-shot tools that load `ShapedDevices.csv` from disk + /// and want the higher-level lookup helpers without starting the runtime actor. + pub fn from_shaped_devices(shaped: Arc) -> Self { + let cache = Arc::new(ShapedDeviceHashCache::from_devices(&shaped.devices)); + Self { + shaped, + cache, + generation: 0, + } + } + + /// Returns the monotonic generation number of the underlying shaped-devices snapshot. + pub fn generation(&self) -> u64 { + self.generation + } + + /// Returns the number of shaped-device rows in the snapshot. + pub fn devices_len(&self) -> usize { + self.shaped.devices.len() + } + + /// Iterates over all shaped-device rows in the snapshot. + pub fn iter_devices(&self) -> impl Iterator { + self.shaped.devices.iter() + } + + /// Returns an owned copy of all shaped-device rows. + pub fn clone_all_devices(&self) -> Vec { + self.shaped.devices.clone() + } + + /// Looks up a shaped device by the stable `device_hash` field. + pub fn device_by_device_hash(&self, device_hash: i64) -> Option<&ShapedDevice> { + self.cache + .index_by_device_hash(&self.shaped, device_hash) + .and_then(|idx| self.shaped.devices.get(idx)) + } + + /// Looks up a shaped device by the stable `circuit_hash` field. + pub fn device_by_circuit_hash(&self, circuit_hash: i64) -> Option<&ShapedDevice> { + self.cache + .index_by_circuit_hash(&self.shaped, circuit_hash) + .and_then(|idx| self.shaped.devices.get(idx)) + } + + /// Looks up a shaped device using optional hashes, preferring `device_hash` + /// over `circuit_hash` when both are present. + pub fn device_by_hashes( + &self, + device_hash: Option, + circuit_hash: Option, + ) -> Option<&ShapedDevice> { + if let Some(device_hash) = device_hash + && let Some(device) = self.device_by_device_hash(device_hash) + { + return Some(device); + } + if let Some(circuit_hash) = circuit_hash + && let Some(device) = self.device_by_circuit_hash(circuit_hash) + { + return Some(device); + } + None + } + + /// Returns a cloned list of shaped-device rows that belong to a circuit identifier. + pub fn devices_for_circuit_id(&self, circuit_id: &str) -> Vec { + let safe_id = circuit_id.to_lowercase().trim().to_string(); + self.shaped + .devices + .iter() + .filter(|device| device.circuit_id.to_lowercase().trim() == safe_id) + .cloned() + .collect() + } + + /// Returns the count of unique configured circuits present in the shaped-devices snapshot. + pub fn configured_circuit_count(&self) -> usize { + let mut circuits: FxHashSet<&str> = FxHashSet::default(); + circuits.reserve(self.shaped.devices.len()); + for device in &self.shaped.devices { + let circuit_id = device.circuit_id.trim(); + if circuit_id.is_empty() { + continue; + } + circuits.insert(circuit_id); + } + circuits.len() + } + + /// Returns a per-circuit map of maximum configured rates, using the first-seen parent node. + pub fn circuit_rate_caps_by_circuit_id(&self) -> HashMap { + let mut by_circuit_id: HashMap = HashMap::new(); + for device in &self.shaped.devices { + let entry = by_circuit_id + .entry(device.circuit_id.clone()) + .or_insert_with(|| CircuitRateCaps { + parent_node: device.parent_node.clone(), + download_max_mbps: 0.0, + upload_max_mbps: 0.0, + }); + entry.download_max_mbps = entry.download_max_mbps.max(device.download_max_mbps); + entry.upload_max_mbps = entry.upload_max_mbps.max(device.upload_max_mbps); + } + by_circuit_id + } + + /// Looks up the `circuit_hash` for an IP address using the shaped-devices LPM trie. + pub fn circuit_hash_for_ip(&self, ip: &XdpIpAddress) -> Option { + self.shaped.get_circuit_hash_from_ip(ip) + } + + /// Looks up a `(circuit_id, circuit_name)` pair for an IP address using the shaped-devices LPM trie. + pub fn circuit_id_and_name_for_ip(&self, ip: &XdpIpAddress) -> Option<(String, String)> { + self.shaped.get_circuit_id_and_name_from_ip(ip) + } + + /// Returns the longest-prefix match entry for an IP address using the shaped-devices LPM trie. + pub fn device_longest_match_for_ip( + &self, + ip: &XdpIpAddress, + ) -> Option<(IpNetwork, &ShapedDevice)> { + let lookup = match ip.as_ip() { + IpAddr::V4(ip) => ip.to_ipv6_mapped(), + IpAddr::V6(ip) => ip, + }; + let (net, idx) = self.shaped.trie.longest_match(lookup)?; + self.shaped.devices.get(*idx).map(|dev| (net, dev)) + } + + /// Iterates over all IP network mappings in the shaped-devices LPM trie. + pub fn iter_ip_mappings(&self) -> impl Iterator { + self.shaped + .trie + .iter() + .filter_map(|(net, &idx)| self.shaped.devices.get(idx).map(|dev| (net, dev))) + } +} + +/// Canonical per-circuit configured rate caps derived from shaped-device rows. +#[derive(Clone, Debug, PartialEq)] +pub struct CircuitRateCaps { + pub parent_node: String, + pub download_max_mbps: f32, + pub upload_max_mbps: f32, +} diff --git a/src/rust/lqos_network_devices/src/combined_catalog.rs b/src/rust/lqos_network_devices/src/combined_catalog.rs new file mode 100644 index 00000000..fbe20eaa --- /dev/null +++ b/src/rust/lqos_network_devices/src/combined_catalog.rs @@ -0,0 +1,162 @@ +use crate::{DynamicCircuit, ShapedDevicesCatalog}; +use fxhash::FxHashMap; +use ip_network::IpNetwork; +use ip_network_table::IpNetworkTable; +use lqos_config::ShapedDevice; +use lqos_utils::XdpIpAddress; +use std::sync::Arc; +use std::net::IpAddr; + +fn normalize_circuit_id_key(circuit_id: &str) -> String { + circuit_id.trim().to_ascii_lowercase() +} + +/// Snapshot handle for shaped devices plus runtime dynamic circuits. +/// +/// This catalog is intended for read-heavy paths (dashboards, APIs) that need to +/// treat dynamic circuits as first-class circuits alongside `ShapedDevices.csv`. +pub struct NetworkDevicesCatalog { + shaped: ShapedDevicesCatalog, + dynamic: Arc>, + dyn_by_device_hash: FxHashMap, + dyn_by_circuit_hash: FxHashMap, + dyn_by_circuit_id: FxHashMap, + dyn_ip_table: IpNetworkTable, +} + +impl Clone for NetworkDevicesCatalog { + fn clone(&self) -> Self { + Self::from_snapshots(self.shaped.clone(), self.dynamic.clone()) + } +} + +impl NetworkDevicesCatalog { + /// Builds a combined catalog from explicit snapshots. + pub fn from_snapshots( + shaped: ShapedDevicesCatalog, + dynamic: Arc>, + ) -> Self { + let mut dyn_by_device_hash = FxHashMap::default(); + let mut dyn_by_circuit_hash = FxHashMap::default(); + let mut dyn_by_circuit_id = FxHashMap::default(); + let mut dyn_ip_table = IpNetworkTable::new(); + + for (idx, circuit) in dynamic.iter().enumerate() { + dyn_by_device_hash.insert(circuit.shaped.device_hash, idx); + dyn_by_circuit_hash.insert(circuit.shaped.circuit_hash, idx); + dyn_by_circuit_id.insert(normalize_circuit_id_key(&circuit.shaped.circuit_id), idx); + + for (ipv4, prefix) in &circuit.shaped.ipv4 { + let prefix = prefix.saturating_add(96).min(128); + if let Ok(net) = IpNetwork::new(ipv4.to_ipv6_mapped(), prefix as u8) { + dyn_ip_table.insert(net, idx); + } + } + for (ipv6, prefix) in &circuit.shaped.ipv6 { + if *prefix <= 128 { + if let Ok(net) = IpNetwork::new(*ipv6, *prefix as u8) { + dyn_ip_table.insert(net, idx); + } + } + } + } + + Self { + shaped, + dynamic, + dyn_by_device_hash, + dyn_by_circuit_hash, + dyn_by_circuit_id, + dyn_ip_table, + } + } + + /// Returns the underlying static shaped-devices catalog (`ShapedDevices.csv`). + pub fn shaped_devices(&self) -> &ShapedDevicesCatalog { + &self.shaped + } + + /// Returns the dynamic circuit overlay snapshot. + pub fn dynamic_circuits(&self) -> &[DynamicCircuit] { + self.dynamic.as_ref() + } + + /// Iterates over static shaped-device rows (`ShapedDevices.csv`). + pub fn iter_static_devices(&self) -> impl Iterator { + self.shaped.iter_devices() + } + + /// Iterates over dynamic circuit overlay entries as shaped-device rows. + pub fn iter_dynamic_devices(&self) -> impl Iterator { + self.dynamic.iter().map(|circuit| &circuit.shaped) + } + + /// Iterates over both static and dynamic shaped-device rows. + pub fn iter_all_devices(&self) -> impl Iterator { + self.iter_static_devices().chain(self.iter_dynamic_devices()) + } + + /// Returns the longest-prefix match entry for an IP address. + /// + /// This prefers static shaped devices, then falls back to dynamic circuits. + pub fn device_longest_match_for_ip( + &self, + ip: &XdpIpAddress, + ) -> Option<(IpNetwork, &ShapedDevice)> { + if let Some((net, device)) = self.shaped.device_longest_match_for_ip(ip) { + return Some((net, device)); + } + + let lookup = match ip.as_ip() { + IpAddr::V4(ip) => ip.to_ipv6_mapped(), + IpAddr::V6(ip) => ip, + }; + let (net, idx) = self.dyn_ip_table.longest_match(lookup)?; + self.dynamic.get(*idx).map(|circuit| (net, &circuit.shaped)) + } + + /// Returns true if the device hash is currently tracked as a dynamic circuit. + pub fn is_dynamic_device_hash(&self, device_hash: i64) -> bool { + self.dyn_by_device_hash.contains_key(&device_hash) + } + + /// Returns true if the circuit hash is currently tracked as a dynamic circuit. + pub fn is_dynamic_circuit_hash(&self, circuit_hash: i64) -> bool { + self.dyn_by_circuit_hash.contains_key(&circuit_hash) + } + + /// Looks up a shaped device using optional hashes, preferring static shaped devices. + /// + /// When the hashes are not present in `ShapedDevices.csv`, this falls back to the + /// runtime dynamic circuit overlay snapshot. + pub fn device_by_hashes( + &self, + device_hash: Option, + circuit_hash: Option, + ) -> Option<&ShapedDevice> { + if let Some(device) = self.shaped.device_by_hashes(device_hash, circuit_hash) { + return Some(device); + } + + if let Some(device_hash) = device_hash + && let Some(idx) = self.dyn_by_device_hash.get(&device_hash) + { + return self.dynamic.get(*idx).map(|circuit| &circuit.shaped); + } + + if let Some(circuit_hash) = circuit_hash + && let Some(idx) = self.dyn_by_circuit_hash.get(&circuit_hash) + { + return self.dynamic.get(*idx).map(|circuit| &circuit.shaped); + } + + None + } + + /// Looks up a dynamic circuit overlay entry by circuit id. + pub fn dynamic_device_by_circuit_id(&self, circuit_id: &str) -> Option<&ShapedDevice> { + let key = normalize_circuit_id_key(circuit_id); + let idx = self.dyn_by_circuit_id.get(&key)?; + self.dynamic.get(*idx).map(|circuit| &circuit.shaped) + } +} diff --git a/src/rust/lqos_network_devices/src/directory_watcher.rs b/src/rust/lqos_network_devices/src/directory_watcher.rs new file mode 100644 index 00000000..eb6fe23c --- /dev/null +++ b/src/rust/lqos_network_devices/src/directory_watcher.rs @@ -0,0 +1,111 @@ +use crate::{request_reload_network_json, request_reload_shaped_devices}; +use anyhow::Result; +use lqos_utils::directory_watcher::{DirectoryWatcher, WatchedDirectoryError}; +use std::collections::BTreeSet; +use std::path::{Path, PathBuf}; +use tracing::{debug, error, warn}; + +#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] +enum DirectoryReloadEvent { + ShapedDevices, + NetworkJson, +} + +pub(crate) fn start_network_devices_directory_watch() -> Result<()> { + std::thread::Builder::new() + .name("Network Devices Dir Watcher".to_string()) + .spawn(|| { + let mut watcher = match NetworkDevicesDirectoryWatcher::new() { + Ok(watcher) => watcher, + Err(err) => { + error!("Failed to start Network Devices directory watcher: {err}"); + return; + } + }; + debug!("Watching LibreQoS config directory for network/shaped updates"); + loop { + match watcher.watch_relevant() { + Ok(events) => { + for event in events { + if let Err(err) = dispatch_reload(event) { + warn!("Network Devices directory reload dispatch failed: {err}"); + } + } + } + Err(err) => { + warn!("Network Devices directory watcher returned: {err}"); + } + } + } + })?; + Ok(()) +} + +struct NetworkDevicesDirectoryWatcher { + inner: DirectoryWatcher, +} + +impl NetworkDevicesDirectoryWatcher { + fn new() -> Result { + let config = lqos_config::load_config()?; + let path = PathBuf::from(&config.lqos_directory); + Ok(Self { + inner: DirectoryWatcher::new("Network Devices Directory", path), + }) + } + + fn watch(&mut self) -> Result, WatchedDirectoryError> { + self.inner.watch() + } + + fn watch_relevant(&mut self) -> Result, WatchedDirectoryError> { + loop { + let events = classify_changed_paths(&self.watch()?); + if !events.is_empty() { + return Ok(events); + } + } + } +} + +fn classify_changed_paths(paths: &[PathBuf]) -> Vec { + paths + .iter() + .filter_map(|path| classify_changed_path(path)) + .collect::>() + .into_iter() + .collect() +} + +fn classify_changed_path(path: &Path) -> Option { + let file_name = path.file_name()?.to_str()?; + if is_ignored_filename(file_name) { + return None; + } + + match file_name { + "ShapedDevices.csv" | "ShapedDevices.insight.csv" | "topology_import.json" => { + Some(DirectoryReloadEvent::ShapedDevices) + } + "network.effective.json" | "network.insight.json" | "network.json" => { + Some(DirectoryReloadEvent::NetworkJson) + } + _ => None, + } +} + +fn is_ignored_filename(file_name: &str) -> bool { + file_name.ends_with(".lock") + || file_name.ends_with(".tmp") + || file_name.ends_with(".swp") + || file_name.ends_with('~') +} + +fn dispatch_reload(event: DirectoryReloadEvent) -> Result<()> { + match event { + DirectoryReloadEvent::ShapedDevices => { + request_reload_shaped_devices("dirwatch:ShapedDevices.csv") + } + DirectoryReloadEvent::NetworkJson => request_reload_network_json("dirwatch:network.json"), + } +} diff --git a/src/rust/lqos_network_devices/src/dynamic.rs b/src/rust/lqos_network_devices/src/dynamic.rs new file mode 100644 index 00000000..a9c29348 --- /dev/null +++ b/src/rust/lqos_network_devices/src/dynamic.rs @@ -0,0 +1,75 @@ +use lqos_config::ShapedDevice; +use lqos_utils::XdpIpAddress; +use serde::{Deserialize, Serialize}; +use std::net::IpAddr; + +/// Runtime-only dynamic circuit overlay entry. +/// +/// This is an overlay on top of `ShapedDevices.csv` and never mutates the static file. +/// It is intended for circuits/devices that are created and managed dynamically at runtime. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct DynamicCircuit { + /// Shaped-device-like fields describing the dynamic circuit/device. + pub shaped: ShapedDevice, + /// Unix timestamp (seconds) when this dynamic circuit was last observed active. + pub last_seen_unix: u64, +} + +/// Minimal kernel observation used to drive dynamic-circuit activity updates. +/// +/// Hash fields come from the kernel IP map; when present they should be preferred over +/// any trie-derived lookups. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct CircuitObservation { + pub ip: XdpIpAddress, + pub device_hash: Option, + pub circuit_hash: Option, +} + +pub(crate) fn is_superseded_by_shaped_devices( + circuit: &DynamicCircuit, + shaped_devices: &crate::ShapedDevicesCatalog, +) -> bool { + if shaped_devices + .device_by_hashes( + Some(circuit.shaped.device_hash), + Some(circuit.shaped.circuit_hash), + ) + .is_some() + { + return true; + } + + for (ip, _) in circuit.shaped.ipv4.iter() { + let probe = XdpIpAddress::from_ip(IpAddr::V4(*ip)); + if shaped_devices.device_longest_match_for_ip(&probe).is_some() { + return true; + } + } + for (ip, _) in circuit.shaped.ipv6.iter() { + let probe = XdpIpAddress::from_ip(IpAddr::V6(*ip)); + if shaped_devices.device_longest_match_for_ip(&probe).is_some() { + return true; + } + } + + false +} + +pub(crate) fn expired_dynamic_circuit_ids( + circuits: &[DynamicCircuit], + now_unix: u64, + ttl_seconds: u64, +) -> Vec { + circuits + .iter() + .filter_map(|circuit| { + let circuit_id = circuit.shaped.circuit_id.trim(); + if circuit_id.is_empty() { + return None; + } + let age_seconds = now_unix.saturating_sub(circuit.last_seen_unix); + (age_seconds > ttl_seconds).then_some(circuit_id.to_string()) + }) + .collect() +} diff --git a/src/rust/lqos_network_devices/src/dynamic_store.rs b/src/rust/lqos_network_devices/src/dynamic_store.rs new file mode 100644 index 00000000..27a7cb86 --- /dev/null +++ b/src/rust/lqos_network_devices/src/dynamic_store.rs @@ -0,0 +1,123 @@ +use crate::DynamicCircuit; +use anyhow::{Context, Result}; +use lqos_config::ShapedDevice; +use lqos_utils::hash_to_i64; +use serde::{Deserialize, Serialize}; +use std::fs::File; +use std::io::Write; +use std::path::{Path, PathBuf}; +use tracing::warn; + +const DYNAMIC_CIRCUITS_FILENAME: &str = "dynamic_circuits.json"; + +fn dynamic_circuits_path(config: &lqos_config::Config) -> PathBuf { + Path::new(&config.lqos_directory).join(DYNAMIC_CIRCUITS_FILENAME) +} + +fn default_dynamic_circuits_schema_version() -> u32 { + 1 +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)] +struct DynamicCircuitsFile { + #[serde(default = "default_dynamic_circuits_schema_version")] + schema_version: u32, + #[serde(default)] + circuits: Vec, +} + +fn recompute_hashes(device: &mut ShapedDevice) { + device.circuit_hash = hash_to_i64(&device.circuit_id); + device.device_hash = hash_to_i64(&device.device_id); + device.parent_hash = hash_to_i64(&device.parent_node); +} + +fn normalize_circuit_id_key(value: &str) -> String { + value.trim().to_ascii_lowercase() +} + +fn atomic_write_json(path: &Path, value: &T) -> Result<()> { + let raw = serde_json::to_string_pretty(value).context("serialize dynamic circuits JSON")?; + let temp_path = path.with_extension("tmp"); + let mut file = File::create(&temp_path) + .with_context(|| format!("create temp dynamic circuits file {}", temp_path.display()))?; + file.write_all(raw.as_bytes()) + .with_context(|| format!("write temp dynamic circuits file {}", temp_path.display()))?; + file.sync_all() + .with_context(|| format!("sync temp dynamic circuits file {}", temp_path.display()))?; + std::fs::rename(&temp_path, path).with_context(|| { + format!( + "rename temp dynamic circuits file {} to {}", + temp_path.display(), + path.display() + ) + })?; + Ok(()) +} + +pub(crate) fn load_dynamic_circuits_from_disk() -> Vec { + let Ok(config) = lqos_config::load_config() else { + warn!("Unable to load lqos config while loading dynamic circuits; treating as empty"); + return Vec::new(); + }; + + let path = dynamic_circuits_path(config.as_ref()); + if !path.exists() { + return Vec::new(); + } + + let Ok(raw) = std::fs::read_to_string(&path) else { + warn!( + "Unable to read dynamic circuits file {}; treating as empty", + path.display() + ); + return Vec::new(); + }; + + if raw.trim().is_empty() { + return Vec::new(); + } + + let mut parsed: DynamicCircuitsFile = match serde_json::from_str(&raw) { + Ok(value) => value, + Err(err) => { + warn!( + "Unable to parse dynamic circuits file {} ({err}); treating as empty", + path.display() + ); + return Vec::new(); + } + }; + + for circuit in &mut parsed.circuits { + recompute_hashes(&mut circuit.shaped); + } + + // De-duplicate by circuit_id (case-insensitive), keeping last entry. + parsed + .circuits + .into_iter() + .fold(Vec::::new(), |mut acc, circuit| { + let key = normalize_circuit_id_key(&circuit.shaped.circuit_id); + if let Some(pos) = acc + .iter() + .position(|c| normalize_circuit_id_key(&c.shaped.circuit_id) == key) + { + acc[pos] = circuit; + } else { + acc.push(circuit); + } + acc + }) +} + +pub(crate) fn persist_dynamic_circuits_to_disk(circuits: &[DynamicCircuit]) -> Result<()> { + let config = lqos_config::load_config().context("load lqos config")?; + let path = dynamic_circuits_path(config.as_ref()); + let file = DynamicCircuitsFile { + schema_version: default_dynamic_circuits_schema_version(), + circuits: circuits.to_vec(), + }; + atomic_write_json(&path, &file) + .with_context(|| format!("write dynamic circuits file {}", path.display())) +} diff --git a/src/rust/lqos_network_devices/src/hash_cache.rs b/src/rust/lqos_network_devices/src/hash_cache.rs new file mode 100644 index 00000000..5e668187 --- /dev/null +++ b/src/rust/lqos_network_devices/src/hash_cache.rs @@ -0,0 +1,63 @@ +use fxhash::FxHashMap; +use lqos_config::ShapedDevice; + +#[derive(Debug, Default)] +pub struct ShapedDeviceHashCache { + by_device_hash: FxHashMap, + by_circuit_hash: FxHashMap, +} + +impl ShapedDeviceHashCache { + pub fn from_devices(devices: &[ShapedDevice]) -> Self { + let mut by_device_hash = FxHashMap::default(); + by_device_hash.reserve(devices.len()); + let mut by_circuit_hash = FxHashMap::default(); + by_circuit_hash.reserve(devices.len()); + for (idx, dev) in devices.iter().enumerate() { + by_device_hash.insert(dev.device_hash, idx); + by_circuit_hash.entry(dev.circuit_hash).or_insert(idx); + } + Self { + by_device_hash, + by_circuit_hash, + } + } + + pub fn index_by_device_hash( + &self, + shaped: &lqos_config::ConfigShapedDevices, + device_hash: i64, + ) -> Option { + if let Some(idx) = self.by_device_hash.get(&device_hash).copied() + && shaped + .devices + .get(idx) + .is_some_and(|d| d.device_hash == device_hash) + { + return Some(idx); + } + shaped + .devices + .iter() + .position(|d| d.device_hash == device_hash) + } + + pub fn index_by_circuit_hash( + &self, + shaped: &lqos_config::ConfigShapedDevices, + circuit_hash: i64, + ) -> Option { + if let Some(idx) = self.by_circuit_hash.get(&circuit_hash).copied() + && shaped + .devices + .get(idx) + .is_some_and(|d| d.circuit_hash == circuit_hash) + { + return Some(idx); + } + shaped + .devices + .iter() + .position(|d| d.circuit_hash == circuit_hash) + } +} diff --git a/src/rust/lqos_network_devices/src/lib.rs b/src/rust/lqos_network_devices/src/lib.rs new file mode 100644 index 00000000..6e7a12a1 --- /dev/null +++ b/src/rust/lqos_network_devices/src/lib.rs @@ -0,0 +1,215 @@ +//! Shared, centralized access to `network.json` and `ShapedDevices.csv`. +//! +//! This crate provides: +//! - Disk-load helpers used by one-shot tools. +//! - An optional runtime actor + directory watcher used by `lqosd` to keep +//! in-memory snapshots up to date. + +mod actor; +mod catalog; +mod combined_catalog; +mod directory_watcher; +mod dynamic; +mod dynamic_store; +mod hash_cache; +mod state; +mod topology; + +#[cfg(test)] +mod tests; + +use anyhow::{Context, Result}; +use lqos_config::{ConfigShapedDevices, NetworkJson}; +use std::sync::Arc; +use tracing::debug; + +pub use catalog::{CircuitRateCaps, ShapedDevicesCatalog}; +pub use combined_catalog::NetworkDevicesCatalog; +pub use dynamic::{CircuitObservation, DynamicCircuit}; +pub use hash_cache::ShapedDeviceHashCache; +pub use topology::{ResolvedParentNode, resolve_parent_node, resolve_parent_node_reference}; + +/// Hooks that `lqosd` can provide to run side effects after updates. +pub trait DaemonHooks: Send + Sync + 'static { + /// Called after a new `ShapedDevices.csv` snapshot is published. + fn on_shaped_devices_updated(&self); + /// Called after a new `network.json` snapshot is published. + fn on_network_json_updated(&self); + /// Called after one or more dynamic circuits are expired and pruned by the runtime actor. + /// + /// Default implementation does nothing so non-daemon callers don't need to handle it. + fn on_dynamic_circuits_expired(&self, _circuit_ids: &[String]) {} + /// Called when the runtime actor promotes an unknown IP into a dynamic circuit overlay entry. + /// + /// Default implementation does nothing so non-daemon callers don't need to handle it. + fn on_unknown_ip_promoted(&self, _shaped_device: &lqos_config::ShapedDevice) {} +} + +/// Starts the runtime actor and directory watcher used by long-running daemons. +/// +/// This function has side effects: it spawns background threads and installs global state. +pub fn start_daemon_mode(hooks: Option>) -> Result<()> { + actor::start_actor(hooks)?; + directory_watcher::start_network_devices_directory_watch() +} + +fn integration_ingress_enabled(config: &lqos_config::Config) -> bool { + config.uisp_integration.enable_uisp + || config.splynx_integration.enable_splynx + || config + .netzur_integration + .as_ref() + .is_some_and(|integration| integration.enable_netzur) + || config + .visp_integration + .as_ref() + .is_some_and(|integration| integration.enable_visp) + || config.powercode_integration.enable_powercode + || config.sonar_integration.enable_sonar + || config + .wispgate_integration + .as_ref() + .is_some_and(|integration| integration.enable_wispgate) +} + +/// Loads shaped-device configuration from disk. +/// +/// When an integration-backed topology ingress is enabled, this loads shaped devices from the +/// active `topology_import.json` bundle. Otherwise, it loads `ShapedDevices.csv`. +pub fn load_shaped_devices() -> Result { + let config = lqos_config::load_config().context("Unable to load /etc/lqos.conf")?; + if integration_ingress_enabled(config.as_ref()) { + match lqos_topology_compile::TopologyImportFile::load(config.as_ref()) { + Ok(Some(topology_import)) => { + let shaped_devices = topology_import.into_imported_bundle().shaped_devices; + if !shaped_devices.devices.is_empty() { + return Ok(shaped_devices); + } + debug!( + "topology_import.json contained 0 shaped devices; falling back to ShapedDevices.csv" + ); + } + Ok(None) => { + debug!("topology_import.json missing; falling back to ShapedDevices.csv"); + } + Err(err) => { + debug!( + "Unable to load topology_import.json ({err}); falling back to ShapedDevices.csv" + ); + } + } + } + ConfigShapedDevices::load().context("Unable to load ShapedDevices.csv") +} + +/// Loads `network.json` from disk. +pub fn load_network_json() -> Result { + NetworkJson::load().context("Unable to load network.json") +} + +/// Returns the current in-memory `ShapedDevices.csv` snapshot. +/// +/// Note: If the runtime actor has not been started, this returns an empty default snapshot. +pub fn shaped_devices_snapshot() -> Arc { + state::shaped_devices_snapshot() +} + +/// Returns a higher-level shaped-devices catalog snapshot. +/// +/// Prefer this API over directly iterating `shaped_devices_snapshot().devices` or separately +/// fetching the hash cache snapshot. +pub fn shaped_devices_catalog() -> ShapedDevicesCatalog { + state::shaped_devices_catalog() +} + +/// Returns a combined catalog snapshot for both shaped devices and dynamic circuits. +pub fn network_devices_catalog() -> NetworkDevicesCatalog { + NetworkDevicesCatalog::from_snapshots( + state::shaped_devices_catalog(), + state::dynamic_circuits_snapshot(), + ) +} + +/// Returns the current in-memory shaped-device hash cache snapshot. +pub fn shaped_device_hash_cache_snapshot() -> Arc { + state::shaped_device_hash_cache_snapshot() +} + +/// Returns the current in-memory dynamic circuit overlay snapshot. +/// +/// Note: This is a runtime-only overlay and does not mutate `ShapedDevices.csv`. +pub fn dynamic_circuits_snapshot() -> Arc> { + state::dynamic_circuits_snapshot() +} + +/// Run a closure with a read-only view of the current in-memory `NetworkJson`. +pub fn with_network_json_read(f: impl FnOnce(&NetworkJson) -> R) -> R { + state::with_network_json_read(f) +} + +/// Run a closure with mutable access to the in-memory `NetworkJson`. +/// +/// Side effects: this mutates the in-memory snapshot used by `lqosd`. +pub fn with_network_json_write(f: impl FnOnce(&mut NetworkJson) -> R) -> R { + state::with_network_json_write(f) +} + +/// Requests the runtime actor reload `ShapedDevices.csv` from disk. +pub fn request_reload_shaped_devices(reason: &str) -> Result<()> { + actor::request_reload_shaped_devices(reason) +} + +/// Requests the runtime actor reload `network.json` from disk. +pub fn request_reload_network_json(reason: &str) -> Result<()> { + actor::request_reload_network_json(reason) +} + +/// Publishes a caller-provided shaped-devices snapshot through the runtime actor. +pub fn apply_shaped_devices_snapshot(reason: &str, shaped: ConfigShapedDevices) -> Result<()> { + actor::apply_shaped_devices_snapshot(reason, shaped) +} + +/// Reports kernel observations that may indicate dynamic circuit activity. +/// +/// This function is not pure: it sends a message to the runtime actor when running. +pub fn report_observations(observations: &[CircuitObservation]) { + actor::report_observations(observations) +} + +/// Creates or updates a runtime-only dynamic circuit overlay entry. +/// +/// Side effects: +/// - Updates the in-memory dynamic circuit snapshot. +/// - Writes `dynamic_circuits.json` in the LibreQoS directory. +/// +/// This function does **not** mutate `ShapedDevices.csv`. +pub fn upsert_dynamic_circuit(shaped_device: lqos_config::ShapedDevice) -> Result<()> { + actor::upsert_dynamic_circuit(shaped_device) +} + +/// Removes a runtime-only dynamic circuit overlay entry by circuit id. +/// +/// Side effects: +/// - Updates the in-memory dynamic circuit snapshot. +/// - Writes `dynamic_circuits.json` in the LibreQoS directory when a circuit is removed. +/// +/// This function does **not** mutate `ShapedDevices.csv`. +pub fn remove_dynamic_circuit(circuit_id: &str) -> Result { + actor::remove_dynamic_circuit(circuit_id) +} + +/// Replaces the in-memory `ShapedDevices.csv` snapshot without involving the runtime actor. +/// +/// Side effects: +/// - Updates the shaped-device hash cache snapshot. +/// - Does **not** write to disk. +/// - Does **not** invoke any daemon hooks. +/// +/// This is primarily intended for tests and for callers that already hold an `Arc` snapshot. +pub fn swap_shaped_devices_snapshot( + reason: &str, + shaped: Arc, +) -> Arc { + debug!("Swapping shaped-devices snapshot reason={reason}"); + state::swap_shaped_devices_snapshot(shaped) +} diff --git a/src/rust/lqos_network_devices/src/state.rs b/src/rust/lqos_network_devices/src/state.rs new file mode 100644 index 00000000..769cbf52 --- /dev/null +++ b/src/rust/lqos_network_devices/src/state.rs @@ -0,0 +1,119 @@ +use crate::catalog::ShapedDevicesCatalog; +use crate::dynamic::DynamicCircuit; +use crate::hash_cache::ShapedDeviceHashCache; +use arc_swap::ArcSwap; +use lqos_config::{ConfigShapedDevices, NetworkJson}; +use once_cell::sync::Lazy; +use parking_lot::RwLock; +use std::collections::HashSet; +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; + +#[derive(Clone)] +struct PublishedShapedState { + shaped: Arc, + cache: Arc, + generation: u64, +} + +static NEXT_SHAPED_GENERATION: AtomicU64 = AtomicU64::new(1); + +static SHAPED_STATE: Lazy> = Lazy::new(|| { + ArcSwap::new(Arc::new(PublishedShapedState { + shaped: Arc::new(ConfigShapedDevices::default()), + cache: Arc::new(ShapedDeviceHashCache::default()), + generation: 0, + })) +}); +static DYNAMIC_CIRCUITS: Lazy>> = + Lazy::new(|| ArcSwap::new(Arc::new(Vec::new()))); +static NETWORK_JSON: Lazy> = Lazy::new(|| RwLock::new(NetworkJson::default())); + +pub(crate) fn shaped_devices_snapshot() -> Arc { + SHAPED_STATE.load_full().shaped.clone() +} + +pub(crate) fn shaped_device_hash_cache_snapshot() -> Arc { + SHAPED_STATE.load_full().cache.clone() +} + +pub(crate) fn shaped_devices_catalog() -> ShapedDevicesCatalog { + let state = SHAPED_STATE.load_full(); + ShapedDevicesCatalog::new(state.shaped.clone(), state.cache.clone(), state.generation) +} + +pub(crate) fn dynamic_circuits_snapshot() -> Arc> { + DYNAMIC_CIRCUITS.load_full() +} + +pub(crate) fn with_network_json_read(f: impl FnOnce(&NetworkJson) -> R) -> R { + let reader = NETWORK_JSON.read(); + f(&reader) +} + +pub(crate) fn with_network_json_write(f: impl FnOnce(&mut NetworkJson) -> R) -> R { + let mut writer = NETWORK_JSON.write(); + f(&mut writer) +} + +pub(crate) fn publish_shaped_devices(new_file: ConfigShapedDevices) { + let generation = NEXT_SHAPED_GENERATION.fetch_add(1, Ordering::Relaxed); + let shaped = Arc::new(new_file); + let cache = Arc::new(ShapedDeviceHashCache::from_devices(&shaped.devices)); + SHAPED_STATE.store(Arc::new(PublishedShapedState { + shaped, + cache, + generation, + })); +} + +pub(crate) fn swap_shaped_devices_snapshot( + new_snapshot: Arc, +) -> Arc { + let generation = NEXT_SHAPED_GENERATION.fetch_add(1, Ordering::Relaxed); + let cache = Arc::new(ShapedDeviceHashCache::from_devices(&new_snapshot.devices)); + let new_state = Arc::new(PublishedShapedState { + shaped: new_snapshot, + cache, + generation, + }); + let old = SHAPED_STATE.swap(new_state); + old.shaped.clone() +} + +pub(crate) fn publish_network_json(new_file: NetworkJson) { + let mut writer = NETWORK_JSON.write(); + *writer = new_file; +} + +pub(crate) fn publish_dynamic_circuits_snapshot(new_snapshot: Vec) { + DYNAMIC_CIRCUITS.store(Arc::new(new_snapshot)); +} + +pub(crate) fn refresh_dynamic_circuits_last_seen_for_hashes( + seen_device_hashes: &HashSet, + seen_circuit_hashes: &HashSet, + now_unix: u64, +) -> bool { + let snapshot = dynamic_circuits_snapshot(); + if snapshot.is_empty() { + return false; + } + + let mut updated = snapshot.as_ref().clone(); + let mut changed = false; + for circuit in updated.iter_mut() { + let is_seen = seen_device_hashes.contains(&circuit.shaped.device_hash) + || seen_circuit_hashes.contains(&circuit.shaped.circuit_hash); + if is_seen && circuit.last_seen_unix != now_unix { + circuit.last_seen_unix = now_unix; + changed = true; + } + } + + if changed { + publish_dynamic_circuits_snapshot(updated); + } + + changed +} diff --git a/src/rust/lqos_network_devices/src/tests.rs b/src/rust/lqos_network_devices/src/tests.rs new file mode 100644 index 00000000..dc57fc96 --- /dev/null +++ b/src/rust/lqos_network_devices/src/tests.rs @@ -0,0 +1,236 @@ +use crate::{ + DynamicCircuit, ShapedDevicesCatalog, resolve_parent_node_reference, with_network_json_write, +}; +use lqos_config::{ConfigShapedDevices, NetworkJsonNode, ShapedDevice}; +use lqos_utils::rtt::RttBuffer; +use lqos_utils::units::DownUpOrder; +use once_cell::sync::Lazy; +use parking_lot::Mutex; +use std::collections::HashSet; +use std::net::Ipv4Addr; +use std::sync::Arc; + +static TEST_LOCK: Lazy> = Lazy::new(|| Mutex::new(())); + +fn make_node( + name: &str, + id: Option<&str>, + active_attachment_name: Option<&str>, +) -> NetworkJsonNode { + NetworkJsonNode { + name: name.to_string(), + id: id.map(|value| value.to_string()), + virtual_node: false, + max_throughput: (0.0, 0.0), + current_throughput: DownUpOrder::zeroed(), + current_packets: DownUpOrder::zeroed(), + current_tcp_packets: DownUpOrder::zeroed(), + current_udp_packets: DownUpOrder::zeroed(), + current_icmp_packets: DownUpOrder::zeroed(), + current_tcp_retransmits: DownUpOrder::zeroed(), + current_tcp_retransmit_packets: DownUpOrder::zeroed(), + current_marks: DownUpOrder::zeroed(), + current_drops: DownUpOrder::zeroed(), + rtt_buffer: RttBuffer::default(), + parents: Vec::new(), + immediate_parent: None, + node_type: None, + latitude: None, + longitude: None, + active_attachment_name: active_attachment_name.map(|value| value.to_string()), + heatmap: None, + qoq_heatmap: None, + } +} + +#[test] +fn catalog_device_by_hashes_prefers_device_hash() { + let _guard = TEST_LOCK.lock(); + + let a = ShapedDevice { + circuit_id: "circuit-a".into(), + device_id: "device-a".into(), + ..Default::default() + }; + + let b = ShapedDevice { + circuit_id: "circuit-b".into(), + device_id: "device-b".into(), + ..Default::default() + }; + + let mut shaped = ConfigShapedDevices::default(); + shaped.replace_with_new_data(vec![a.clone(), b.clone()]); + + let catalog = ShapedDevicesCatalog::from_shaped_devices(Arc::new(shaped)); + + let a_device_hash = lqos_utils::hash_to_i64(&a.device_id); + let b_circuit_hash = lqos_utils::hash_to_i64(&b.circuit_id); + + let resolved = catalog + .device_by_hashes(Some(a_device_hash), Some(b_circuit_hash)) + .expect("Expected shaped device match"); + assert_eq!(resolved.device_id, a.device_id); + + let fallback = catalog + .device_by_hashes(Some(999), Some(b_circuit_hash)) + .expect("Expected circuit-hash fallback match"); + assert_eq!(fallback.device_id, b.device_id); +} + +#[test] +fn resolve_parent_node_reference_prefers_id_then_name_then_alias() { + let _guard = TEST_LOCK.lock(); + + with_network_json_write(|net_json| { + net_json.nodes = vec![ + make_node("Root", Some("root"), None), + make_node("Site A", Some("node-a"), None), + make_node("Site B", Some("node-b"), Some("B-alias")), + ]; + }); + + let by_id = resolve_parent_node_reference("ignored", Some("node-a")) + .expect("Expected node id lookup to resolve"); + assert_eq!(by_id.name, "Site A"); + + let by_name = resolve_parent_node_reference("Site B", None) + .expect("Expected node name lookup to resolve"); + assert_eq!(by_name.id.as_deref(), Some("node-b")); + + let by_alias = resolve_parent_node_reference("B-alias", None) + .expect("Expected active attachment alias to resolve"); + assert_eq!(by_alias.name, "Site B"); +} + +#[test] +fn dynamic_circuit_last_seen_updates_for_seen_hashes() { + let _guard = TEST_LOCK.lock(); + + let original = crate::state::dynamic_circuits_snapshot(); + + let shaped = ShapedDevice { + circuit_id: "dynamic-circuit".into(), + device_id: "dynamic-device".into(), + circuit_hash: 10, + device_hash: 100, + ..Default::default() + }; + + crate::state::publish_dynamic_circuits_snapshot(vec![DynamicCircuit { + shaped: shaped.clone(), + last_seen_unix: 0, + }]); + + let mut seen_device_hashes: HashSet = HashSet::new(); + seen_device_hashes.insert(shaped.device_hash); + let seen_circuit_hashes: HashSet = HashSet::new(); + let now_unix = 1234; + + let changed = crate::state::refresh_dynamic_circuits_last_seen_for_hashes( + &seen_device_hashes, + &seen_circuit_hashes, + now_unix, + ); + assert!(changed); + + let updated = crate::state::dynamic_circuits_snapshot(); + assert_eq!(updated.len(), 1); + assert_eq!(updated[0].last_seen_unix, now_unix); + + crate::state::publish_dynamic_circuits_snapshot(original.as_ref().clone()); +} + +#[test] +fn dynamic_circuit_expiration_helper_respects_ttl_boundary() { + let _guard = TEST_LOCK.lock(); + + let now_unix = 1_000; + let ttl_seconds = 300; + + let alive = ShapedDevice { + circuit_id: "alive".into(), + device_id: "device-alive".into(), + ..Default::default() + }; + + let expired = ShapedDevice { + circuit_id: "expired".into(), + device_id: "device-expired".into(), + ..Default::default() + }; + + let circuits = vec![ + DynamicCircuit { + shaped: alive, + // age == ttl => not expired + last_seen_unix: now_unix - ttl_seconds, + }, + DynamicCircuit { + shaped: expired, + // age > ttl => expired + last_seen_unix: now_unix - ttl_seconds - 1, + }, + ]; + + let mut ids = crate::dynamic::expired_dynamic_circuit_ids(&circuits, now_unix, ttl_seconds); + ids.sort(); + assert_eq!(ids, vec!["expired".to_string()]); +} + +#[test] +fn dynamic_circuit_expiration_helper_expires_zero_last_seen() { + let _guard = TEST_LOCK.lock(); + + let now_unix = 1_000; + let ttl_seconds = 300; + + let shaped = ShapedDevice { + circuit_id: "zero".into(), + device_id: "device-zero".into(), + ..Default::default() + }; + + let circuits = vec![DynamicCircuit { + shaped, + last_seen_unix: 0, + }]; + + let ids = crate::dynamic::expired_dynamic_circuit_ids(&circuits, now_unix, ttl_seconds); + assert_eq!(ids, vec!["zero".to_string()]); +} + +#[test] +fn dynamic_circuit_is_superseded_when_shaped_devices_now_cover_it() { + let _guard = TEST_LOCK.lock(); + + let mut static_device = ShapedDevice { + circuit_id: "static".into(), + device_id: "static-device".into(), + ..Default::default() + }; + static_device.ipv4.push((Ipv4Addr::new(192, 0, 2, 42), 32)); + + let mut shaped = ConfigShapedDevices::default(); + shaped.replace_with_new_data(vec![static_device]); + let catalog = ShapedDevicesCatalog::from_shaped_devices(Arc::new(shaped)); + + let mut dynamic_shaped = ShapedDevice { + circuit_id: "[dyn] (test) 192.0.2.42".into(), + device_id: "[dyn] (test) 192.0.2.42".into(), + circuit_hash: 123, + device_hash: 456, + ..Default::default() + }; + dynamic_shaped.ipv4.push((Ipv4Addr::new(192, 0, 2, 42), 32)); + + let dyn_circuit = DynamicCircuit { + shaped: dynamic_shaped, + last_seen_unix: 0, + }; + + assert!(crate::dynamic::is_superseded_by_shaped_devices( + &dyn_circuit, + &catalog + )); +} diff --git a/src/rust/lqos_network_devices/src/topology.rs b/src/rust/lqos_network_devices/src/topology.rs new file mode 100644 index 00000000..b8141be9 --- /dev/null +++ b/src/rust/lqos_network_devices/src/topology.rs @@ -0,0 +1,64 @@ +use crate::state; +use lqos_config::NetworkJsonNode; + +/// Canonical parent-node metadata resolved from `network.json`. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ResolvedParentNode { + /// Canonical node name from `network.json`. + pub name: String, + /// Optional stable node identifier from `network.json` metadata. + pub id: Option, +} + +/// Resolve a shaped-device parent reference into canonical `network.json` +/// parent metadata, preferring a stable node ID when one is available. +pub fn resolve_parent_node_reference( + parent_node: &str, + parent_node_id: Option<&str>, +) -> Option { + let trimmed_id = parent_node_id.map(str::trim).filter(|id| !id.is_empty()); + let trimmed = parent_node.trim(); + if trimmed.is_empty() && trimmed_id.is_none() { + return None; + } + + state::with_network_json_read(|net_json| { + let nodes = net_json.get_nodes_when_ready(); + + if let Some(parent_node_id) = trimmed_id + && let Some(node) = find_node_by_id(nodes, parent_node_id) + { + return Some(ResolvedParentNode { + name: node.name.clone(), + id: node.id.clone(), + }); + } + + if let Some(node) = nodes.iter().find(|node| node.name == trimmed) { + return Some(ResolvedParentNode { + name: node.name.clone(), + id: node.id.clone(), + }); + } + + nodes.iter().find_map(|node| { + node.active_attachment_name + .as_deref() + .filter(|alias| alias.trim() == trimmed) + .map(|_| ResolvedParentNode { + name: node.name.clone(), + id: node.id.clone(), + }) + }) + }) +} + +/// Resolve a shaped-device parent node or active attachment alias into canonical `network.json` +/// parent metadata. +pub fn resolve_parent_node(parent_node: &str) -> Option { + resolve_parent_node_reference(parent_node, None) +} + +fn find_node_by_id<'a>(nodes: &'a [NetworkJsonNode], id: &str) -> Option<&'a NetworkJsonNode> { + nodes.iter().find(|node| node.id.as_deref() == Some(id)) +} diff --git a/src/rust/lqos_overrides/Cargo.toml b/src/rust/lqos_overrides/Cargo.toml index e7612296..1a343db2 100644 --- a/src/rust/lqos_overrides/Cargo.toml +++ b/src/rust/lqos_overrides/Cargo.toml @@ -9,4 +9,6 @@ serde_json.workspace = true anyhow.workspace = true nix.workspace = true lqos_config = { path = "../lqos_config" } +lqos_bus = { path = "../lqos_bus" } clap = { workspace = true, features = ["derive"] } +tokio.workspace = true diff --git a/src/rust/lqos_overrides/src/main.rs b/src/rust/lqos_overrides/src/main.rs index 09dd670e..fbc2cde2 100644 --- a/src/rust/lqos_overrides/src/main.rs +++ b/src/rust/lqos_overrides/src/main.rs @@ -3,6 +3,7 @@ use std::net::{Ipv4Addr, Ipv6Addr}; use anyhow::{Result, anyhow}; use clap::{Args, Parser, Subcommand}; +use lqos_bus::{BusRequest, BusResponse, LibreqosBusClient}; use lqos_config::ShapedDevice; use lqos_overrides::{CircuitAdjustment, NetworkAdjustment, OverrideFile}; @@ -36,6 +37,11 @@ enum Commands { #[command(subcommand)] command: UispCommand, }, + /// Manage runtime-only dynamic circuit overlay entries (via lqosd bus) + DynamicCircuits { + #[command(subcommand)] + command: DynamicCircuitsCommand, + }, } #[derive(Subcommand, Debug)] @@ -148,6 +154,17 @@ enum UispCommand { RouteList, } +#[derive(Subcommand, Debug)] +enum DynamicCircuitsCommand { + /// Add or update a dynamic circuit overlay entry + Add(Box), + /// Remove a dynamic circuit overlay entry by circuit ID + Remove { + #[arg(long)] + circuit_id: String, + }, +} + #[derive(Args, Debug)] struct AddArgs { #[arg(long)] @@ -318,222 +335,265 @@ struct AddSiteSpeedArgs { fn main() -> Result<()> { let cli = Cli::parse(); - // Load overrides file at start - let mut overrides = OverrideFile::load()?; + fn send_bus_request(requests: Vec) -> Result> { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build()?; + rt.block_on(async { + let mut bus = LibreqosBusClient::new().await?; + Ok(bus.request(requests).await?) + }) + } match cli.command { - Commands::PersistentDevices { command: cmd } => match cmd { - PersistentDevicesCommand::Add(args) => { - let device = args.into_device()?; - let changed = overrides.add_persistent_shaped_device_return_changed(device); - if changed { - overrides.save()?; - println!("Added device; overrides saved."); - } else { - println!("No changes (device already present)."); + Commands::DynamicCircuits { command: cmd } => match cmd { + DynamicCircuitsCommand::Add(args) => { + let device = (*args).into_device()?; + let mut replies = send_bus_request(vec![BusRequest::CreateDynamicCircuit { + shaped_device: Box::new(device), + }])?; + match replies.pop() { + Some(BusResponse::Ack) => println!("Dynamic circuit created/updated."), + Some(BusResponse::Fail(message)) => return Err(anyhow!(message)), + Some(other) => println!("Unexpected bus response: {other:?}"), + None => println!("No bus response."), } } - PersistentDevicesCommand::DeleteCircuitId { circuit_id } => { - let removed = - overrides.remove_persistent_shaped_device_by_circuit_count(&circuit_id); - if removed > 0 { - overrides.save()?; - println!("Removed {removed} device(s) by circuit_id; overrides saved."); - } else { - println!("No devices matched circuit_id {circuit_id}."); - } - } - PersistentDevicesCommand::DeleteDeviceId { device_id } => { - let removed = overrides.remove_persistent_shaped_device_by_device_count(&device_id); - if removed > 0 { - overrides.save()?; - println!("Removed {removed} device(s) by device_id; overrides saved."); - } else { - println!("No devices matched device_id {device_id}."); - } - } - PersistentDevicesCommand::List => { - let list = overrides.persistent_devices(); - println!("{}", serde_json::to_string_pretty(&list)?); - } - }, - Commands::Adjustments { command: cmd } => match cmd { - AdjustmentsCommand::AddCircuitSpeed(args) => { - let adj = CircuitAdjustment::CircuitAdjustSpeed { - circuit_id: args.circuit_id, - min_download_bandwidth: args.min_download_bandwidth, - max_download_bandwidth: args.max_download_bandwidth, - min_upload_bandwidth: args.min_upload_bandwidth, - max_upload_bandwidth: args.max_upload_bandwidth, - }; - overrides.add_circuit_adjustment(adj); - overrides.save()?; - println!("Added circuit speed adjustment; overrides saved."); - } - AdjustmentsCommand::AddDeviceSpeed(args) => { - let adj = CircuitAdjustment::DeviceAdjustSpeed { - device_id: args.device_id, - min_download_bandwidth: args.min_download_bandwidth, - max_download_bandwidth: args.max_download_bandwidth, - min_upload_bandwidth: args.min_upload_bandwidth, - max_upload_bandwidth: args.max_upload_bandwidth, - }; - overrides.add_circuit_adjustment(adj); - overrides.save()?; - println!("Added device speed adjustment; overrides saved."); - } - AdjustmentsCommand::AddRemoveCircuit { circuit_id } => { - let adj = CircuitAdjustment::RemoveCircuit { circuit_id }; - overrides.add_circuit_adjustment(adj); - overrides.save()?; - println!("Added remove-circuit adjustment; overrides saved."); - } - AdjustmentsCommand::AddRemoveDevice { device_id } => { - let adj = CircuitAdjustment::RemoveDevice { device_id }; - overrides.add_circuit_adjustment(adj); - overrides.save()?; - println!("Added remove-device adjustment; overrides saved."); - } - AdjustmentsCommand::AddReparentCircuit { - circuit_id, - parent_node, - } => { - let adj = CircuitAdjustment::ReparentCircuit { - circuit_id, - parent_node, - }; - overrides.add_circuit_adjustment(adj); - overrides.save()?; - println!("Added reparent-circuit adjustment; overrides saved."); - } - AdjustmentsCommand::DeleteIndex { index } => { - let ok = overrides.remove_circuit_adjustment_by_index(index); - if ok { - overrides.save()?; - println!("Removed adjustment at index {index}; overrides saved."); - } else { - println!("No adjustment at index {index}."); - } - } - AdjustmentsCommand::List => { - let list = overrides.circuit_adjustments(); - println!("{}", serde_json::to_string_pretty(&list)?); - } - }, - Commands::NetworkAdjustments { command: cmd } => match cmd { - NetworkAdjustmentsCommand::AddSiteSpeed(args) => { - let adj = NetworkAdjustment::AdjustSiteSpeed { - node_id: args.node_id, - site_name: args.site_name, - download_bandwidth_mbps: args.download_bandwidth_mbps, - upload_bandwidth_mbps: args.upload_bandwidth_mbps, - }; - overrides.add_network_adjustment(adj); - overrides.save()?; - println!("Added site speed adjustment; overrides saved."); - } - NetworkAdjustmentsCommand::SetVirtual { - node_name, - virtual_node, - } => { - overrides.set_network_node_virtual(node_name, virtual_node); - overrides.save()?; - println!("Set node virtual flag; overrides saved."); - } - NetworkAdjustmentsCommand::DeleteVirtual { node_name } => { - let removed = overrides.remove_network_node_virtual_by_name_count(&node_name); - if removed > 0 { - overrides.save()?; - println!( - "Removed {removed} virtual override(s) for node '{node_name}'; overrides saved." - ); - } else { - println!("No virtual override found for node '{node_name}'."); - } - } - NetworkAdjustmentsCommand::DeleteIndex { index } => { - let ok = overrides.remove_network_adjustment_by_index(index); - if ok { - overrides.save()?; - println!("Removed network adjustment at index {index}; overrides saved."); - } else { - println!("No network adjustment at index {index}."); - } - } - NetworkAdjustmentsCommand::List => { - let list = overrides.network_adjustments(); - println!("{}", serde_json::to_string_pretty(&list)?); - } - }, - Commands::Uisp { command: cmd } => match cmd { - UispCommand::BandwidthSet { - site_name, - down, - up, - } => { - overrides.set_uisp_bandwidth_override(site_name, down, up); - overrides.save()?; - println!( - "Added deprecated UISP bandwidth override entry; overrides saved. Current UISP builds ignore these entries and use AdjustSiteSpeed overrides instead." - ); - } - UispCommand::BandwidthRemove { site_name } => { - let removed = overrides.remove_uisp_bandwidth_override(&site_name); - if removed { - overrides.save()?; - println!( - "Removed deprecated UISP bandwidth override for {site_name}; overrides saved." - ); - } else { - println!("No UISP bandwidth override found for {site_name}."); - } - } - UispCommand::BandwidthList => { - println!( - "Deprecated UISP bandwidth override entries. Current UISP builds ignore these entries and use AdjustSiteSpeed overrides instead." - ); - if let Some(uisp) = overrides.uisp() { - println!( - "{}", - serde_json::to_string_pretty(&uisp.bandwidth_overrides)? - ); - } else { - println!("{{}}"); - } - } - UispCommand::RouteAdd { - from_site, - to_site, - cost, - } => { - overrides.add_uisp_route_override(from_site, to_site, cost); - overrides.save()?; - println!( - "Added deprecated UISP route override entry; overrides saved. Current UISP builds ignore these overrides." - ); - } - UispCommand::RouteRemoveIndex { index } => { - let removed = overrides.remove_uisp_route_by_index(index); - if removed { - overrides.save()?; - println!( - "Removed deprecated UISP route override at index {index}; overrides saved." - ); - } else { - println!("No UISP route override at index {index}."); - } - } - UispCommand::RouteList => { - println!( - "Deprecated UISP route override entries. Current UISP builds ignore these overrides." - ); - if let Some(uisp) = overrides.uisp() { - println!("{}", serde_json::to_string_pretty(&uisp.route_overrides)?); - } else { - println!("[]"); + DynamicCircuitsCommand::Remove { circuit_id } => { + let mut replies = + send_bus_request(vec![BusRequest::RemoveDynamicCircuit { circuit_id }])?; + match replies.pop() { + Some(BusResponse::Ack) => println!("Dynamic circuit removed."), + Some(BusResponse::Fail(message)) => return Err(anyhow!(message)), + Some(other) => println!("Unexpected bus response: {other:?}"), + None => println!("No bus response."), } } }, + command => { + // All other commands operate on the overrides file. + let mut overrides = OverrideFile::load()?; + + match command { + Commands::PersistentDevices { command: cmd } => match cmd { + PersistentDevicesCommand::Add(args) => { + let device = args.into_device()?; + let changed = overrides.add_persistent_shaped_device_return_changed(device); + if changed { + overrides.save()?; + println!("Added device; overrides saved."); + } else { + println!("No changes (device already present)."); + } + } + PersistentDevicesCommand::DeleteCircuitId { circuit_id } => { + let removed = + overrides.remove_persistent_shaped_device_by_circuit_count(&circuit_id); + if removed > 0 { + overrides.save()?; + println!("Removed {removed} device(s) by circuit_id; overrides saved."); + } else { + println!("No devices matched circuit_id {circuit_id}."); + } + } + PersistentDevicesCommand::DeleteDeviceId { device_id } => { + let removed = + overrides.remove_persistent_shaped_device_by_device_count(&device_id); + if removed > 0 { + overrides.save()?; + println!("Removed {removed} device(s) by device_id; overrides saved."); + } else { + println!("No devices matched device_id {device_id}."); + } + } + PersistentDevicesCommand::List => { + let list = overrides.persistent_devices(); + println!("{}", serde_json::to_string_pretty(&list)?); + } + }, + Commands::Adjustments { command: cmd } => match cmd { + AdjustmentsCommand::AddCircuitSpeed(args) => { + let adj = CircuitAdjustment::CircuitAdjustSpeed { + circuit_id: args.circuit_id, + min_download_bandwidth: args.min_download_bandwidth, + max_download_bandwidth: args.max_download_bandwidth, + min_upload_bandwidth: args.min_upload_bandwidth, + max_upload_bandwidth: args.max_upload_bandwidth, + }; + overrides.add_circuit_adjustment(adj); + overrides.save()?; + println!("Added circuit speed adjustment; overrides saved."); + } + AdjustmentsCommand::AddDeviceSpeed(args) => { + let adj = CircuitAdjustment::DeviceAdjustSpeed { + device_id: args.device_id, + min_download_bandwidth: args.min_download_bandwidth, + max_download_bandwidth: args.max_download_bandwidth, + min_upload_bandwidth: args.min_upload_bandwidth, + max_upload_bandwidth: args.max_upload_bandwidth, + }; + overrides.add_circuit_adjustment(adj); + overrides.save()?; + println!("Added device speed adjustment; overrides saved."); + } + AdjustmentsCommand::AddRemoveCircuit { circuit_id } => { + let adj = CircuitAdjustment::RemoveCircuit { circuit_id }; + overrides.add_circuit_adjustment(adj); + overrides.save()?; + println!("Added remove-circuit adjustment; overrides saved."); + } + AdjustmentsCommand::AddRemoveDevice { device_id } => { + let adj = CircuitAdjustment::RemoveDevice { device_id }; + overrides.add_circuit_adjustment(adj); + overrides.save()?; + println!("Added remove-device adjustment; overrides saved."); + } + AdjustmentsCommand::AddReparentCircuit { + circuit_id, + parent_node, + } => { + let adj = CircuitAdjustment::ReparentCircuit { + circuit_id, + parent_node, + }; + overrides.add_circuit_adjustment(adj); + overrides.save()?; + println!("Added reparent-circuit adjustment; overrides saved."); + } + AdjustmentsCommand::DeleteIndex { index } => { + let ok = overrides.remove_circuit_adjustment_by_index(index); + if ok { + overrides.save()?; + println!("Removed adjustment at index {index}; overrides saved."); + } else { + println!("No adjustment at index {index}."); + } + } + AdjustmentsCommand::List => { + let list = overrides.circuit_adjustments(); + println!("{}", serde_json::to_string_pretty(&list)?); + } + }, + Commands::NetworkAdjustments { command: cmd } => match cmd { + NetworkAdjustmentsCommand::AddSiteSpeed(args) => { + let adj = NetworkAdjustment::AdjustSiteSpeed { + node_id: args.node_id, + site_name: args.site_name, + download_bandwidth_mbps: args.download_bandwidth_mbps, + upload_bandwidth_mbps: args.upload_bandwidth_mbps, + }; + overrides.add_network_adjustment(adj); + overrides.save()?; + println!("Added site speed adjustment; overrides saved."); + } + NetworkAdjustmentsCommand::SetVirtual { + node_name, + virtual_node, + } => { + overrides.set_network_node_virtual(node_name, virtual_node); + overrides.save()?; + println!("Set node virtual flag; overrides saved."); + } + NetworkAdjustmentsCommand::DeleteVirtual { node_name } => { + let removed = + overrides.remove_network_node_virtual_by_name_count(&node_name); + if removed > 0 { + overrides.save()?; + println!( + "Removed {removed} virtual override(s) for node '{node_name}'; overrides saved." + ); + } else { + println!("No virtual override found for node '{node_name}'."); + } + } + NetworkAdjustmentsCommand::DeleteIndex { index } => { + let ok = overrides.remove_network_adjustment_by_index(index); + if ok { + overrides.save()?; + println!( + "Removed network adjustment at index {index}; overrides saved." + ); + } else { + println!("No network adjustment at index {index}."); + } + } + NetworkAdjustmentsCommand::List => { + let list = overrides.network_adjustments(); + println!("{}", serde_json::to_string_pretty(&list)?); + } + }, + Commands::Uisp { command: cmd } => match cmd { + UispCommand::BandwidthSet { + site_name, + down, + up, + } => { + overrides.set_uisp_bandwidth_override(site_name, down, up); + overrides.save()?; + println!( + "Added deprecated UISP bandwidth override entry; overrides saved. Current UISP builds ignore these entries and use AdjustSiteSpeed overrides instead." + ); + } + UispCommand::BandwidthRemove { site_name } => { + let removed = overrides.remove_uisp_bandwidth_override(&site_name); + if removed { + overrides.save()?; + println!( + "Removed deprecated UISP bandwidth override for {site_name}; overrides saved." + ); + } else { + println!("No UISP bandwidth override found for {site_name}."); + } + } + UispCommand::BandwidthList => { + println!( + "Deprecated UISP bandwidth override entries. Current UISP builds ignore these entries and use AdjustSiteSpeed overrides instead." + ); + if let Some(uisp) = overrides.uisp() { + println!( + "{}", + serde_json::to_string_pretty(&uisp.bandwidth_overrides)? + ); + } else { + println!("{{}}"); + } + } + UispCommand::RouteAdd { + from_site, + to_site, + cost, + } => { + overrides.add_uisp_route_override(from_site, to_site, cost); + overrides.save()?; + println!( + "Added deprecated UISP route override entry; overrides saved. Current UISP builds ignore these overrides." + ); + } + UispCommand::RouteRemoveIndex { index } => { + let removed = overrides.remove_uisp_route_by_index(index); + if removed { + overrides.save()?; + println!( + "Removed deprecated UISP route override at index {index}; overrides saved." + ); + } else { + println!("No UISP route override at index {index}."); + } + } + UispCommand::RouteList => { + println!( + "Deprecated UISP route override entries. Current UISP builds ignore these overrides." + ); + if let Some(uisp) = overrides.uisp() { + println!("{}", serde_json::to_string_pretty(&uisp.route_overrides)?); + } else { + println!("[]"); + } + } + }, + Commands::DynamicCircuits { .. } => unreachable!("dynamic circuits handled above"), + } + } } Ok(()) diff --git a/src/rust/lqos_python/Cargo.toml b/src/rust/lqos_python/Cargo.toml index 411a746c..ec3ffb66 100644 --- a/src/rust/lqos_python/Cargo.toml +++ b/src/rust/lqos_python/Cargo.toml @@ -15,6 +15,7 @@ lqos_bus = { path = "../lqos_bus" } lqos_sys = { path = "../lqos_sys" } lqos_utils = { path = "../lqos_utils" } lqos_config = { path = "../lqos_config" } +lqos_network_devices = { path = "../lqos_network_devices" } lqos_overrides = { path = "../lqos_overrides" } lqos_topology_compile = { path = "../lqos_topology_compile" } tokio = { workspace = true } diff --git a/src/rust/lqos_python/src/device_weights.rs b/src/rust/lqos_python/src/device_weights.rs index 72ad6f0e..51c00b7e 100644 --- a/src/rust/lqos_python/src/device_weights.rs +++ b/src/rust/lqos_python/src/device_weights.rs @@ -13,7 +13,7 @@ use anyhow::Result; use lqos_bus::{BusRequest, BusResponse}; -use lqos_config::{ConfigShapedDevices, ShapedDevice, load_config}; +use lqos_config::{ShapedDevice, load_config}; use pyo3::pyclass; use serde::{Deserialize, Serialize}; use std::time::Duration; @@ -84,7 +84,7 @@ fn get_weights_from_lts( /// This function is used to get the device weights from the ShapedDevices.csv file fn get_weights_from_shaped_devices() -> Result> { - let mut devices_list = ConfigShapedDevices::load()?.devices; + let mut devices_list = lqos_network_devices::load_shaped_devices()?.devices; devices_list.sort_by(|a, b| a.circuit_id.cmp(&b.circuit_id)); let mut result = vec![]; let mut prev_id = String::new(); @@ -211,9 +211,9 @@ pub struct NetworkNodeWeight { /// Calculate the top-level network tree nodes and then /// calculate the weights for each node pub(crate) fn calculate_tree_weights() -> Result> { - let device_list = ConfigShapedDevices::load()?.devices; + let device_list = lqos_network_devices::load_shaped_devices()?.devices; let device_weights = get_weights_rust()?; - let network = lqos_config::NetworkJson::load()?; + let network = lqos_network_devices::load_network_json()?; let root_index = network .get_nodes_when_ready() .iter() diff --git a/src/rust/lqos_python/src/lib.rs b/src/rust/lqos_python/src/lib.rs index 9fe0a594..fa165bac 100644 --- a/src/rust/lqos_python/src/lib.rs +++ b/src/rust/lqos_python/src/lib.rs @@ -2820,7 +2820,7 @@ pub fn get_libreqos_state_directory() -> PyResult { #[pyfunction] /// Returns `true` when the loaded network graph contains only a single root node. pub fn is_network_flat() -> PyResult { - Ok(lqos_config::NetworkJson::load() + Ok(lqos_network_devices::load_network_json() .unwrap() .get_nodes_when_ready() .len() diff --git a/src/rust/lqos_queue_tracker/src/tracking/watched_queues.rs b/src/rust/lqos_queue_tracker/src/tracking/watched_queues.rs index 0d33767c..98ebb35c 100644 --- a/src/rust/lqos_queue_tracker/src/tracking/watched_queues.rs +++ b/src/rust/lqos_queue_tracker/src/tracking/watched_queues.rs @@ -4,7 +4,7 @@ use lqos_bus::TcHandle; use lqos_sys::num_possible_cpus; use lqos_utils::unix_time::unix_now; use once_cell::sync::Lazy; -use tracing::{info, warn}; +use tracing::{debug, info, warn}; pub(crate) static WATCHED_QUEUES: Lazy> = Lazy::new(DashMap::new); @@ -74,7 +74,7 @@ pub fn add_watched_queue(circuit_id: &str) { WATCHED_QUEUES.insert(circuit_id.to_string(), new_watch); //info!("Added {circuit_id} to watched queues. Now watching {} queues.", WATCHED_QUEUES.len()); } else { - warn!("No circuit ID of {circuit_id}"); + debug!("Circuit queue not found for watch request: {circuit_id}"); } } else { warn!("Unable to access watched queue list. Try again later."); diff --git a/src/rust/lqos_stormguard/Cargo.toml b/src/rust/lqos_stormguard/Cargo.toml index 6713c7e9..14f9ebbe 100644 --- a/src/rust/lqos_stormguard/Cargo.toml +++ b/src/rust/lqos_stormguard/Cargo.toml @@ -11,6 +11,7 @@ lqos_bus = { path = "../lqos_bus" } tokio.workspace = true lqos_queue_tracker = { path = "../lqos_queue_tracker" } lqos_utils = { path = "../lqos_utils" } +lqos_network_devices = { path = "../lqos_network_devices" } lqos_bakery = { path = "../lqos_bakery" } lqos_overrides = { path = "../lqos_overrides" } lqos_probe = { path = "../lqos_probe" } diff --git a/src/rust/lqos_stormguard/src/adaptive_actions.rs b/src/rust/lqos_stormguard/src/adaptive_actions.rs index b50ba262..21ab8125 100644 --- a/src/rust/lqos_stormguard/src/adaptive_actions.rs +++ b/src/rust/lqos_stormguard/src/adaptive_actions.rs @@ -1,6 +1,6 @@ use anyhow::{Result, anyhow}; use lqos_bakery::BakeryCommands; -use lqos_config::{ConfigShapedDevices, ShapedDevice}; +use lqos_config::ShapedDevice; use lqos_overrides::{OverrideFile, OverrideLayer, OverrideStore}; use lqos_queue_tracker::QUEUE_STRUCTURE; use lqos_utils::hash_to_i64; @@ -136,7 +136,7 @@ pub fn clear_circuit_fallback( pub fn load_persisted_circuit_fallbacks() -> Result> { let overrides = OverrideStore::load_layer(OverrideLayer::Stormguard)?; - let current_devices = ConfigShapedDevices::load()?.devices; + let current_devices = lqos_network_devices::load_shaped_devices()?.devices; Ok(group_circuit_fallbacks(&overrides, ¤t_devices)) } @@ -159,7 +159,7 @@ fn current_site_override( } fn load_devices_for_circuit(circuit_id: &str) -> Result> { - let shaped_devices = ConfigShapedDevices::load()?; + let shaped_devices = lqos_network_devices::load_shaped_devices()?; Ok(shaped_devices .devices .into_iter() diff --git a/src/rust/lqos_support_tool/Cargo.toml b/src/rust/lqos_support_tool/Cargo.toml index 4ba95f9b..1c9d6116 100644 --- a/src/rust/lqos_support_tool/Cargo.toml +++ b/src/rust/lqos_support_tool/Cargo.toml @@ -8,6 +8,7 @@ anyhow = { workspace = true } clap = { workspace = true } colored = { workspace = true } lqos_config = { path = "../lqos_config" } +lqos_network_devices = { path = "../lqos_network_devices" } serde = { workspace = true } serde_cbor = { workspace = true } miniz_oxide = { workspace = true } diff --git a/src/rust/lqos_support_tool/src/sanity_checks/net_json.rs b/src/rust/lqos_support_tool/src/sanity_checks/net_json.rs index 5d92a215..b10372ad 100644 --- a/src/rust/lqos_support_tool/src/sanity_checks/net_json.rs +++ b/src/rust/lqos_support_tool/src/sanity_checks/net_json.rs @@ -47,7 +47,7 @@ pub fn can_we_load_net_json(results: &mut Vec) { } pub fn can_we_parse_net_json(results: &mut Vec) { - match lqos_config::NetworkJson::load() { + match lqos_network_devices::load_network_json() { Ok(_json) => { results.push(SanityCheck { name: "network.json is valid JSON".to_string(), diff --git a/src/rust/lqos_support_tool/src/sanity_checks/shaped_devices.rs b/src/rust/lqos_support_tool/src/sanity_checks/shaped_devices.rs index 5adada5f..6bfe3327 100644 --- a/src/rust/lqos_support_tool/src/sanity_checks/shaped_devices.rs +++ b/src/rust/lqos_support_tool/src/sanity_checks/shaped_devices.rs @@ -19,7 +19,7 @@ pub fn shaped_devices_exists(results: &mut Vec) { } pub fn can_we_read_shaped_devices(results: &mut Vec) { - match lqos_config::ConfigShapedDevices::load() { + match lqos_network_devices::load_shaped_devices() { Ok(sd) => { results.push(SanityCheck { name: "ShapedDevices.csv Loads?".to_string(), @@ -38,7 +38,7 @@ pub fn can_we_read_shaped_devices(results: &mut Vec) { } pub fn parent_check(results: &mut Vec) { - if let Ok(net_json) = lqos_config::NetworkJson::load() { + if let Ok(net_json) = lqos_network_devices::load_network_json() { if net_json.get_nodes_when_ready().len() < 2 { results.push(SanityCheck { name: "Flat Network - Skipping Parent Check".to_string(), @@ -48,7 +48,7 @@ pub fn parent_check(results: &mut Vec) { return; } - if let Ok(shaped_devices) = lqos_config::ConfigShapedDevices::load() { + if let Ok(shaped_devices) = lqos_network_devices::load_shaped_devices() { for sd in shaped_devices.devices.iter() { if !net_json .get_nodes_when_ready() diff --git a/src/rust/lqos_utils/src/directory_watcher.rs b/src/rust/lqos_utils/src/directory_watcher.rs new file mode 100644 index 00000000..018893a3 --- /dev/null +++ b/src/rust/lqos_utils/src/directory_watcher.rs @@ -0,0 +1,135 @@ +use notify::{Config, Event, RecursiveMode, Watcher}; +use std::{ + collections::BTreeSet, + path::PathBuf, + time::{Duration, Instant}, +}; +use thiserror::Error; +use tracing::{debug, error}; + +const DIRECTORY_WATCH_DEBOUNCE_MILLIS: u64 = 250; + +/// Watches one directory and returns the coalesced changed paths for each batch +/// of file-system notifications. +pub struct DirectoryWatcher { + nice_name: String, + path: PathBuf, + debounce_window: Duration, + rx: Option>>, + watcher: Option, +} + +impl DirectoryWatcher { + /// Creates a new directory watcher with the default debounce window. + pub fn new(nice_name: S, path: PathBuf) -> Self { + Self { + nice_name: nice_name.to_string(), + path, + debounce_window: Duration::from_millis(DIRECTORY_WATCH_DEBOUNCE_MILLIS), + rx: None, + watcher: None, + } + } + + /// Waits for the next batch of changed paths in the watched directory. + pub fn watch(&mut self) -> Result, WatchedDirectoryError> { + if !self.path.is_dir() { + return Err(WatchedDirectoryError::NotADirectory(self.path.clone())); + } + + self.ensure_watcher_started()?; + let Some(rx) = &self.rx else { + return Err(WatchedDirectoryError::WatchReceiveError); + }; + + loop { + let first_event = rx + .recv() + .map_err(|_| WatchedDirectoryError::WatchReceiveError)?; + let mut changed_paths = BTreeSet::new(); + collect_paths(first_event, &mut changed_paths); + + let mut deadline = Instant::now() + self.debounce_window; + loop { + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + break; + } + + match rx.recv_timeout(remaining) { + Ok(event) => { + collect_paths(event, &mut changed_paths); + deadline = Instant::now() + self.debounce_window; + } + Err(crossbeam_channel::RecvTimeoutError::Timeout) => break, + Err(crossbeam_channel::RecvTimeoutError::Disconnected) => { + return Err(WatchedDirectoryError::WatchReceiveError); + } + } + } + + if !changed_paths.is_empty() { + debug!("{} changed: {:?}", self.nice_name, changed_paths); + return Ok(changed_paths.into_iter().collect()); + } + } + } + + fn ensure_watcher_started(&mut self) -> Result<(), WatchedDirectoryError> { + if self.watcher.is_some() { + return Ok(()); + } + + let (tx, rx) = crossbeam_channel::bounded(64); + let maybe_watcher = notify::RecommendedWatcher::new(tx, Config::default()); + let Ok(mut watcher) = maybe_watcher else { + if let Err(err) = maybe_watcher { + error!("Unable to create watcher for {}", self.nice_name); + error!("{err:?}"); + } + return Err(WatchedDirectoryError::CreateWatcherError); + }; + + watcher + .watch(&self.path, RecursiveMode::NonRecursive) + .map_err(|_| WatchedDirectoryError::StartWatcherError)?; + + self.rx = Some(rx); + self.watcher = Some(watcher); + Ok(()) + } +} + +fn collect_paths( + event: Result, + changed_paths: &mut BTreeSet, +) { + match event { + Ok(event) => { + changed_paths.extend(event.paths); + } + Err(err) => { + error!("Directory watcher event error: {err:?}"); + } + } +} + +/// Errors that can occur while watching a directory. +#[derive(Debug, Error)] +pub enum WatchedDirectoryError { + /// The requested watch path does not exist as a directory. + #[error("Watch path is not a directory: {0}")] + NotADirectory(PathBuf), + + /// Unable to create the underlying notify watcher. + #[error("Unable to create watcher")] + CreateWatcherError, + + /// Unable to start the underlying notify watcher. + #[error("Unable to start watcher")] + StartWatcherError, + + /// The watcher channel stopped delivering events. + #[error("Unable to receive watcher events")] + WatchReceiveError, +} diff --git a/src/rust/lqos_utils/src/lib.rs b/src/rust/lqos_utils/src/lib.rs index c7867a89..781a3a41 100644 --- a/src/rust/lqos_utils/src/lib.rs +++ b/src/rust/lqos_utils/src/lib.rs @@ -10,6 +10,9 @@ pub mod fdtimer; /// Wrapper for watching when a file has changed. pub mod file_watcher; +/// Watches a directory and coalesces filesystem events. +pub mod directory_watcher; + /// Utilities for handling strings in hex format pub mod hex_string; diff --git a/src/rust/lqosd/Cargo.toml b/src/rust/lqosd/Cargo.toml index aade11c2..f7663067 100644 --- a/src/rust/lqosd/Cargo.toml +++ b/src/rust/lqosd/Cargo.toml @@ -12,6 +12,7 @@ flamegraphs = [] [dependencies] anyhow = { workspace = true } lqos_config = { path = "../lqos_config" } +lqos_network_devices = { path = "../lqos_network_devices" } lqos_sys = { path = "../lqos_sys" } lqos_queue_tracker = { path = "../lqos_queue_tracker" } lqos_utils = { path = "../lqos_utils" } diff --git a/src/rust/lqosd/src/dynamic_circuits.rs b/src/rust/lqosd/src/dynamic_circuits.rs new file mode 100644 index 00000000..4a8664a5 --- /dev/null +++ b/src/rust/lqosd/src/dynamic_circuits.rs @@ -0,0 +1,102 @@ +use lqos_bus::BusResponse; +use lqos_config::ShapedDevice; +use std::sync::mpsc; +use std::time::Duration; +use tracing::{error, warn}; + +fn dynamic_circuits_enabled() -> bool { + let Ok(config) = lqos_config::load_config() else { + return false; + }; + config + .dynamic_circuits + .as_ref() + .is_some_and(|cfg| cfg.enabled) +} + +pub(crate) fn create_dynamic_circuit(shaped_device: ShapedDevice) -> BusResponse { + if !dynamic_circuits_enabled() { + return BusResponse::Ack; + } + + if shaped_device.circuit_id.trim().is_empty() { + return BusResponse::Fail("dynamic circuit requires circuit_id".to_string()); + } + if shaped_device.device_id.trim().is_empty() { + return BusResponse::Fail("dynamic circuit requires device_id".to_string()); + } + if shaped_device.parent_node.trim().is_empty() { + return BusResponse::Fail("dynamic circuit requires parent_node".to_string()); + } + + if let Err(err) = lqos_network_devices::upsert_dynamic_circuit(shaped_device.clone()) { + error!("Dynamic circuit persist failed: {err:?}"); + return BusResponse::Fail(format!("dynamic circuit persist failed: {err}")); + } + + if let Err(err) = upsert_bakery_overlay(shaped_device.clone()) { + warn!("Dynamic circuit bakery apply failed: {err}"); + // Best-effort rollback: keep network_devices and bakery in sync. + let _ = lqos_network_devices::remove_dynamic_circuit(&shaped_device.circuit_id); + return BusResponse::Fail(err); + } + + BusResponse::Ack +} + +pub(crate) fn remove_dynamic_circuit(circuit_id: &str) -> BusResponse { + if !dynamic_circuits_enabled() { + return BusResponse::Ack; + } + + if circuit_id.trim().is_empty() { + return BusResponse::Fail("dynamic circuit removal requires circuit_id".to_string()); + } + + if let Err(err) = remove_bakery_overlay(circuit_id) { + warn!("Dynamic circuit bakery removal failed: {err}"); + return BusResponse::Fail(err); + } + + if let Err(err) = lqos_network_devices::remove_dynamic_circuit(circuit_id) { + error!("Dynamic circuit removal persist failed: {err:?}"); + return BusResponse::Fail(format!("dynamic circuit removal persist failed: {err}")); + } + + BusResponse::Ack +} + +fn upsert_bakery_overlay(shaped_device: ShapedDevice) -> Result<(), String> { + let Some(sender) = lqos_bakery::BAKERY_SENDER.get() else { + return Err("Bakery not initialized".to_string()); + }; + + let (tx, rx) = mpsc::channel::, String>>(); + sender + .send(lqos_bakery::BakeryCommands::UpsertDynamicCircuitOverlay { + shaped_device: Box::new(shaped_device), + reply: Some(tx), + }) + .map_err(|e| format!("send to bakery failed: {e}"))?; + + rx.recv_timeout(Duration::from_secs(10)) + .map_err(|e| format!("bakery reply timeout: {e}"))? + .map(|_| ()) +} + +fn remove_bakery_overlay(circuit_id: &str) -> Result<(), String> { + let Some(sender) = lqos_bakery::BAKERY_SENDER.get() else { + return Err("Bakery not initialized".to_string()); + }; + + let (tx, rx) = mpsc::channel::>(); + sender + .send(lqos_bakery::BakeryCommands::RemoveDynamicCircuitOverlay { + circuit_id: circuit_id.to_string(), + reply: Some(tx), + }) + .map_err(|e| format!("send to bakery failed: {e}"))?; + + rx.recv_timeout(Duration::from_secs(10)) + .map_err(|e| format!("bakery reply timeout: {e}"))? +} diff --git a/src/rust/lqosd/src/lts2_sys/control_channel.rs b/src/rust/lqosd/src/lts2_sys/control_channel.rs index ba8e1e29..3618619b 100644 --- a/src/rust/lqosd/src/lts2_sys/control_channel.rs +++ b/src/rust/lqosd/src/lts2_sys/control_channel.rs @@ -1464,17 +1464,16 @@ async fn circuit_snapshot_streaming( } } - // Load "shaped devices" snapshot and pick devices in the target circuit - let shaped = crate::shaped_devices_tracker::SHAPED_DEVICES.load(); - let shaped_cache = crate::shaped_devices_tracker::SHAPED_DEVICE_HASH_CACHE.load(); - let mut device_indexes: Vec = Vec::new(); - for (idx, dev) in shaped.devices.iter().enumerate() { - if dev.circuit_hash == circuit_hash { - device_indexes.push(idx); - } - } + // Load network devices catalog and pick devices in the target circuit. + // This includes dynamic circuits so circuit streaming works consistently for runtime overlays. + let catalog = lqos_network_devices::network_devices_catalog(); + let circuit_devices = catalog + .iter_all_devices() + .filter(|dev| dev.circuit_hash == circuit_hash) + .cloned() + .collect::>(); - // Aggregation holders per device index + // Aggregation holders per device hash use lqos_utils::units::{DownUpOrder, down_up_divide}; struct Agg { bps_bytes: DownUpOrder, @@ -1482,18 +1481,7 @@ async fn circuit_snapshot_streaming( tcp_retries: DownUpOrder, rtts: Vec, } - let mut aggregates: std::collections::HashMap = std::collections::HashMap::new(); - for idx in &device_indexes { - aggregates.insert( - *idx, - Agg { - bps_bytes: DownUpOrder::zeroed(), - tcp_packets: DownUpOrder::zeroed(), - tcp_retries: DownUpOrder::zeroed(), - rtts: Vec::new(), - }, - ); - } + let mut aggregates: std::collections::HashMap = std::collections::HashMap::new(); // Walk raw throughput data and fold into devices of this circuit { @@ -1501,34 +1489,32 @@ async fn circuit_snapshot_streaming( .raw_data .lock(); for (xdp_ip, te) in raw.iter() { - let device = crate::shaped_devices_tracker::shaped_device_from_hashes_or_ip( - &shaped, - &shaped_cache, - xdp_ip, - te.device_hash, - te.circuit_hash, - ); - let matches_desired = te.circuit_hash == Some(circuit_hash) - || device.is_some_and(|device| device.circuit_hash == circuit_hash); + let device = catalog + .device_by_hashes(te.device_hash, te.circuit_hash) + .or_else(|| catalog.device_longest_match_for_ip(xdp_ip).map(|(_, device)| device)); + let matches_desired = + te.circuit_hash == Some(circuit_hash) + || device.is_some_and(|device| device.circuit_hash == circuit_hash); if !matches_desired { continue; } // retire_check is local; use the same heuristic: require most_recent_cycle >= tp_cycle - RETIRE_AFTER_SECONDS // We don't have RETIRE_AFTER_SECONDS here; accept all entries for snapshot. - if let Some(id) = device.and_then(|device| { - shaped - .devices - .iter() - .position(|candidate| std::ptr::eq(candidate, device)) - }) && let Some(agg) = aggregates.get_mut(&id) - { - // bytes_per_second -> later convert to bits - agg.bps_bytes += te.bytes_per_second; - agg.tcp_packets += te.tcp_packets; - agg.tcp_retries += te.tcp_retransmits; - if let Some(rtt) = te.median_latency() { - agg.rtts.push(rtt); - } + let Some(device_hash) = device.map(|device| device.device_hash) else { + continue; + }; + let agg = aggregates.entry(device_hash).or_insert_with(|| Agg { + bps_bytes: DownUpOrder::zeroed(), + tcp_packets: DownUpOrder::zeroed(), + tcp_retries: DownUpOrder::zeroed(), + rtts: Vec::new(), + }); + // bytes_per_second -> later convert to bits + agg.bps_bytes += te.bytes_per_second; + agg.tcp_packets += te.tcp_packets; + agg.tcp_retries += te.tcp_retransmits; + if let Some(rtt) = te.median_latency() { + agg.rtts.push(rtt); } } // raw lock dropped here @@ -1536,9 +1522,8 @@ async fn circuit_snapshot_streaming( // Build device snapshots let mut devices_out: Vec = Vec::new(); - for idx in device_indexes { - let dev = &shaped.devices[idx]; - let agg = aggregates.remove(&idx).unwrap_or(Agg { + for dev in circuit_devices { + let agg = aggregates.remove(&dev.device_hash).unwrap_or(Agg { bps_bytes: DownUpOrder::zeroed(), tcp_packets: DownUpOrder::zeroed(), tcp_retries: DownUpOrder::zeroed(), @@ -1607,15 +1592,16 @@ async fn circuit_snapshot_streaming( if local.last_seen < five_minutes_ago { continue; } - let device = crate::shaped_devices_tracker::shaped_device_from_hashes_or_ip( - &shaped, - &shaped_cache, - &key.local_ip, - local.device_hash, - local.circuit_hash, - ); - let matches_desired = local.circuit_hash == Some(circuit_hash) - || device.is_some_and(|device| device.circuit_hash == circuit_hash); + let device = catalog + .device_by_hashes(local.device_hash, local.circuit_hash) + .or_else(|| { + catalog + .device_longest_match_for_ip(&key.local_ip) + .map(|(_, device)| device) + }); + let matches_desired = + local.circuit_hash == Some(circuit_hash) + || device.is_some_and(|device| device.circuit_hash == circuit_hash); if !matches_desired { continue; } @@ -1696,32 +1682,34 @@ async fn tree_snapshot_streaming( } // Use the same data source as local_api::network_tree - let net_json = crate::shaped_devices_tracker::NETWORK_JSON.read(); - let result: Vec<(usize, LiveNetworkTransport)> = net_json - .get_nodes_when_ready() - .iter() - .enumerate() - .map(|(i, n)| { - let t = n.clone_to_transit(); - let mapped = LiveNetworkTransport { - name: t.name, - max_throughput: t.max_throughput, - current_throughput: t.current_throughput, - current_packets: t.current_packets, - current_tcp_packets: t.current_tcp_packets, - current_udp_packets: t.current_udp_packets, - current_icmp_packets: t.current_icmp_packets, - current_retransmits: t.current_retransmits, - current_marks: t.current_marks, - current_drops: t.current_drops, - rtts: t.rtts, - parents: t.parents, - immediate_parent: t.immediate_parent, - node_type: t.node_type, - }; - (i, mapped) - }) - .collect(); + let result: Vec<(usize, LiveNetworkTransport)> = + lqos_network_devices::with_network_json_read(|net_json| { + net_json + .get_nodes_when_ready() + .iter() + .enumerate() + .map(|(i, n)| { + let t = n.clone_to_transit(); + let mapped = LiveNetworkTransport { + name: t.name, + max_throughput: t.max_throughput, + current_throughput: t.current_throughput, + current_packets: t.current_packets, + current_tcp_packets: t.current_tcp_packets, + current_udp_packets: t.current_udp_packets, + current_icmp_packets: t.current_icmp_packets, + current_retransmits: t.current_retransmits, + current_marks: t.current_marks, + current_drops: t.current_drops, + rtts: t.rtts, + parents: t.parents, + immediate_parent: t.immediate_parent, + node_type: t.node_type, + }; + (i, mapped) + }) + .collect() + }); let Ok(bytes) = serde_cbor::to_vec(&result) else { error!("Failed to serialize LiveNetworkTransport payload"); diff --git a/src/rust/lqosd/src/main.rs b/src/rust/lqosd/src/main.rs index acaf1aef..9bc37563 100644 --- a/src/rust/lqosd/src/main.rs +++ b/src/rust/lqosd/src/main.rs @@ -4,11 +4,13 @@ #![deny(clippy::unwrap_used)] mod blackboard; +mod dynamic_circuits; mod file_lock; mod ip_mapping; #[cfg(feature = "equinix_tests")] mod lqos_daht_test; pub mod lts2_sys; +mod network_devices_hooks; mod node_manager; mod preflight_checks; mod probe_provider; @@ -230,6 +232,9 @@ fn main() -> Result<()> { } else { info!("Insight client started successfully"); } + lqos_network_devices::start_daemon_mode(Some(std::sync::Arc::new( + network_devices_hooks::LqosdNetworkDevicesHooks, + )))?; let system_usage_tx = system_stats::start_system_stats()?; // Handle signals @@ -251,6 +256,7 @@ fn main() -> Result<()> { .spawn(move || { let Ok(tokio_runtime) = tokio::runtime::Builder::new_multi_thread() .enable_all() + .thread_stack_size(8 * 1024 * 1024) .build() else { error!("Unable to start Tokio runtime. Not much is going to work"); @@ -375,8 +381,6 @@ fn main() -> Result<()> { let _ = throughput_tracker::flow_data::setup_flow_analysis(); start_heimdall()?; spawn_queue_structure_monitor()?; - shaped_devices_tracker::shaped_devices_watcher()?; - shaped_devices_tracker::network_json_watcher()?; shaped_devices_tracker::shaping_inputs_watcher()?; throughput_tracker::spawn_throughput_monitor( flow_tx, @@ -493,7 +497,7 @@ fn memory_debug() { // fb.visit_root(&*THROUGHPUT_TRACKER); // fb.visit_root(&*ALL_FLOWS); // fb.visit_root(&*RECENT_FLOWS); - //fb.visit_root(&*NETWORK_JSON); + // lqos_network_devices::with_network_json_read(|net_json| fb.visit_root(net_json)); let flamegraph_src = fb.finish(); let flamegraph_src = flamegraph_src.flamegraph(); let Ok(mut file) = std::fs::File::create("/tmp/lqosd-mem.svg") else { @@ -622,6 +626,12 @@ fn handle_bus_requests(requests: &[BusRequest], responses: &mut Vec } BusResponse::Ack } + BusRequest::CreateDynamicCircuit { shaped_device } => { + crate::dynamic_circuits::create_dynamic_circuit((**shaped_device).clone()) + } + BusRequest::RemoveDynamicCircuit { circuit_id } => { + crate::dynamic_circuits::remove_dynamic_circuit(circuit_id) + } BusRequest::InvalidateAuthCache => { crate::node_manager::invalidate_auth_cache(); BusResponse::Ack @@ -1215,7 +1225,6 @@ fn queue_stats_total_data() -> lqos_bus::QueueStatsTotal { } fn circuit_capacity_data() -> Vec { - use crate::shaped_devices_tracker::SHAPED_DEVICES; use crate::throughput_tracker::THROUGHPUT_TRACKER; use lqos_utils::units::DownUpOrder; use std::collections::HashMap; @@ -1250,13 +1259,12 @@ fn circuit_capacity_data() -> Vec { } }); - let shaped_devices = SHAPED_DEVICES.load(); + let catalog = lqos_network_devices::shaped_devices_catalog(); circuits .iter() .filter_map(|(circuit_id, accumulator)| { - shaped_devices - .devices - .iter() + catalog + .iter_devices() .find(|sd| sd.circuit_id == *circuit_id) .map(|device| { let down_mbps = (accumulator.bytes.down as f64 * 8.0) / 1_000_000.0; @@ -1276,42 +1284,41 @@ fn circuit_capacity_data() -> Vec { } fn tree_capacity_data() -> Vec { - use crate::shaped_devices_tracker::NETWORK_JSON; - - let net_json = NETWORK_JSON.read(); - net_json - .get_nodes_when_ready() - .iter() - .enumerate() - .map(|(id, node)| { - let node = crate::shaped_devices_tracker::node_to_transport(node); - let down = node.current_throughput.0 as f64 * 8.0 / 1_000_000.0; - let up = node.current_throughput.1 as f64 * 8.0 / 1_000_000.0; - let effective_max = node.effective_max_throughput.unwrap_or(node.max_throughput); - let max_down = effective_max.0; - let max_up = effective_max.1; - let median_rtt = if node.rtts.is_empty() { - 0.0 - } else { - let n = node.rtts.len() / 2; - if node.rtts.len().is_multiple_of(2) { - (node.rtts[n - 1] + node.rtts[n]) / 2.0 + lqos_network_devices::with_network_json_read(|net_json| { + net_json + .get_nodes_when_ready() + .iter() + .enumerate() + .map(|(id, node)| { + let node = crate::shaped_devices_tracker::node_to_transport(node); + let down = node.current_throughput.0 as f64 * 8.0 / 1_000_000.0; + let up = node.current_throughput.1 as f64 * 8.0 / 1_000_000.0; + let effective_max = node.effective_max_throughput.unwrap_or(node.max_throughput); + let max_down = effective_max.0; + let max_up = effective_max.1; + let median_rtt = if node.rtts.is_empty() { + 0.0 } else { - node.rtts[n] - } - }; + let n = node.rtts.len() / 2; + if node.rtts.len().is_multiple_of(2) { + (node.rtts[n - 1] + node.rtts[n]) / 2.0 + } else { + node.rtts[n] + } + }; - lqos_bus::NodeCapacity { - id, - name: node.name.clone(), - down, - up, - max_down, - max_up, - median_rtt, - } - }) - .collect() + lqos_bus::NodeCapacity { + id, + name: node.name.clone(), + down, + up, + max_down, + max_up, + median_rtt, + } + }) + .collect() + }) } fn retransmit_summary_data() -> lqos_bus::RetransmitSummary { @@ -1325,37 +1332,39 @@ fn retransmit_summary_data() -> lqos_bus::RetransmitSummary { } fn tree_summary_l2_data() -> Vec<(usize, Vec<(usize, lqos_config::NetworkJsonTransport)>)> { - use crate::shaped_devices_tracker::NETWORK_JSON; use std::collections::BTreeMap; - let net_json = NETWORK_JSON.read(); - let nodes = net_json.get_nodes_when_ready(); - let mut candidates: Vec<(usize, usize, lqos_config::NetworkJsonTransport, u64)> = Vec::new(); + lqos_network_devices::with_network_json_read(|net_json| { + let nodes = net_json.get_nodes_when_ready(); + let mut candidates: Vec<(usize, usize, lqos_config::NetworkJsonTransport, u64)> = + Vec::new(); - for (p_idx, p_node) in nodes.iter().enumerate() { - if p_node.immediate_parent == Some(0) { - for (c_idx, c_node) in nodes.iter().enumerate() { - if c_node.immediate_parent == Some(p_idx) { - let t = crate::shaped_devices_tracker::node_to_transport(c_node); - let total = t.current_throughput.0 + t.current_throughput.1; - candidates.push((p_idx, c_idx, t, total)); + for (p_idx, p_node) in nodes.iter().enumerate() { + if p_node.immediate_parent == Some(0) { + for (c_idx, c_node) in nodes.iter().enumerate() { + if c_node.immediate_parent == Some(p_idx) { + let t = crate::shaped_devices_tracker::node_to_transport(c_node); + let total = t.current_throughput.0 + t.current_throughput.1; + candidates.push((p_idx, c_idx, t, total)); + } } } } - } - candidates.sort_by(|a, b| b.3.cmp(&a.3)); - let n: usize = 10; - if candidates.len() > n { - candidates.truncate(n); - } + candidates.sort_by(|a, b| b.3.cmp(&a.3)); + let n: usize = 10; + if candidates.len() > n { + candidates.truncate(n); + } - let mut map: BTreeMap> = BTreeMap::new(); - for (p_idx, c_idx, t, _total) in candidates.into_iter() { - map.entry(p_idx).or_default().push((c_idx, t)); - } + let mut map: BTreeMap> = + BTreeMap::new(); + for (p_idx, c_idx, t, _total) in candidates.into_iter() { + map.entry(p_idx).or_default().push((c_idx, t)); + } - map.into_iter().collect() + map.into_iter().collect() + }) } fn search_result_to_bus(entry: node_manager::SearchResult) -> lqos_bus::SearchResultEntry { diff --git a/src/rust/lqosd/src/network_devices_hooks.rs b/src/rust/lqosd/src/network_devices_hooks.rs new file mode 100644 index 00000000..9d90817d --- /dev/null +++ b/src/rust/lqosd/src/network_devices_hooks.rs @@ -0,0 +1,166 @@ +use crate::shaped_devices_tracker; +use crate::throughput_tracker::THROUGHPUT_TRACKER; +use lqos_bus::TcHandle; +use lqos_config::ShapedDevice; +use std::sync::mpsc; +use std::time::Duration; +use tracing::warn; + +pub(crate) struct LqosdNetworkDevicesHooks; + +impl lqos_network_devices::DaemonHooks for LqosdNetworkDevicesHooks { + fn on_shaped_devices_updated(&self) { + shaped_devices_tracker::invalidate_circuit_live_snapshot(); + shaped_devices_tracker::invalidate_executive_cache_snapshot(); + lqos_network_devices::with_network_json_read(|net_json| { + THROUGHPUT_TRACKER.refresh_circuit_ids(net_json); + }); + } + + fn on_network_json_updated(&self) { + shaped_devices_tracker::invalidate_circuit_live_snapshot(); + shaped_devices_tracker::invalidate_executive_cache_snapshot(); + lqos_network_devices::with_network_json_read(|net_json| { + THROUGHPUT_TRACKER.refresh_circuit_ids(net_json); + }); + } + + fn on_dynamic_circuits_expired(&self, circuit_ids: &[String]) { + let Some(sender) = lqos_bakery::BAKERY_SENDER.get() else { + return; + }; + + for circuit_id in circuit_ids { + let result = sender.send(lqos_bakery::BakeryCommands::RemoveDynamicCircuitOverlay { + circuit_id: circuit_id.clone(), + reply: None, + }); + if let Err(err) = result { + warn!( + "Unable to enqueue dynamic circuit overlay removal for '{circuit_id}': {err}" + ); + } + } + } + + fn on_unknown_ip_promoted(&self, shaped_device: &ShapedDevice) { + let shaped_device = shaped_device.clone(); + let circuit_id = shaped_device.circuit_id.clone(); + + let result = std::thread::Builder::new() + .name("dyn-circuit-unknown-ip".to_string()) + .spawn(move || { + let Some(sender) = lqos_bakery::BAKERY_SENDER.get() else { + return; + }; + + let (tx, rx) = mpsc::channel::, String>>(); + if let Err(err) = + sender.send(lqos_bakery::BakeryCommands::UpsertDynamicCircuitOverlay { + shaped_device: Box::new(shaped_device.clone()), + reply: Some(tx), + }) + { + warn!( + "Unable to enqueue dynamic circuit overlay for unknown IP promotion '{}': {err}", + shaped_device.circuit_id + ); + return; + } + + let handle = match rx.recv_timeout(Duration::from_secs(10)) { + Ok(Ok(Some(handle))) => handle, + Ok(Ok(None)) => { + // Bakery accepted the overlay but could not yet allocate a concrete class + // handle (e.g., baseline not ready). We'll retry on future observations. + return; + } + Ok(Err(err)) => { + warn!( + "Bakery rejected dynamic circuit overlay for unknown IP promotion '{}': {err}", + shaped_device.circuit_id + ); + return; + } + Err(err) => { + warn!( + "Timeout waiting for Bakery reply while promoting unknown IP '{}': {err}", + shaped_device.circuit_id + ); + return; + } + }; + + let circuit_hash = if shaped_device.circuit_hash != 0 { + shaped_device.circuit_hash + } else { + lqos_utils::hash_to_i64(&shaped_device.circuit_id) + }; + let device_hash = if shaped_device.device_hash != 0 { + shaped_device.device_hash + } else { + lqos_utils::hash_to_i64(&shaped_device.device_id) + }; + + let cpu_count = lqos_sys::num_possible_cpus() + .map(|n| n.max(1)) + .unwrap_or(1); + let cpu = ((circuit_hash as u64) % (cpu_count as u64)) as u32; + + for (ip, prefix) in shaped_device.ipv4.iter() { + let addr = if *prefix == 32 { + ip.to_string() + } else { + format!("{ip}/{prefix}") + }; + if let Err(err) = lqos_sys::add_ip_to_tc( + &addr, + handle, + cpu, + false, + circuit_hash as u64, + device_hash as u64, + ) { + warn!( + "Unable to map unknown IP dynamic circuit '{}' for {addr}: {err:?}", + shaped_device.circuit_id + ); + } + } + for (ip, prefix) in shaped_device.ipv6.iter() { + let addr = if *prefix == 128 { + ip.to_string() + } else { + format!("{ip}/{prefix}") + }; + if let Err(err) = lqos_sys::add_ip_to_tc( + &addr, + handle, + cpu, + false, + circuit_hash as u64, + device_hash as u64, + ) { + warn!( + "Unable to map unknown IP dynamic circuit '{}' for {addr}: {err:?}", + shaped_device.circuit_id + ); + } + } + + if let Err(err) = lqos_sys::clear_hot_cache() { + warn!( + "Unable to clear hot cache after mapping unknown IP dynamic circuit '{}': {err:?}", + shaped_device.circuit_id + ); + } + }); + + if let Err(err) = result { + warn!( + "Unable to spawn unknown IP promotion helper thread for '{}': {err}", + circuit_id + ); + } + } +} diff --git a/src/rust/lqosd/src/node_manager/js_build/entrypoints.txt b/src/rust/lqosd/src/node_manager/js_build/entrypoints.txt index 350ada34..3854a804 100644 --- a/src/rust/lqosd/src/node_manager/js_build/entrypoints.txt +++ b/src/rust/lqosd/src/node_manager/js_build/entrypoints.txt @@ -25,6 +25,7 @@ config_treeguard.js config_lts.js config_iprange.js config_flows.js +config_dynamic_circuits.js config_integration.js config_splynx.js config_netzur.js diff --git a/src/rust/lqosd/src/node_manager/js_build/src/config/config_helper.js b/src/rust/lqosd/src/node_manager/js_build/src/config/config_helper.js index a74d084e..cd677401 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/config/config_helper.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/config/config_helper.js @@ -639,6 +639,7 @@ export function renderConfigMenu(currentPage) { { href: "config_lts.html", icon: "fa-line-chart", text: "License & Services", id: "lts" }, { href: "config_iprange.html", icon: "fa-address-card", text: "IP Ranges", id: "iprange" }, { href: "config_flows.html", icon: "fa-arrow-circle-down", text: "Flow Tracking", id: "flows" }, + { href: "config_dynamic_circuits.html", icon: "fa-layer-group", text: "Dynamic Circuits", id: "dynamic_circuits" }, { href: "config_integration.html", icon: "fa-link", text: "Integration - Common", id: "integration" }, { href: "config_splynx.html", icon: "fa-link", text: "Splynx", id: "splynx" }, { href: "config_netzur.html", icon: "fa-link", text: "Netzur", id: "netzur" }, diff --git a/src/rust/lqosd/src/node_manager/js_build/src/config_dynamic_circuits.js b/src/rust/lqosd/src/node_manager/js_build/src/config_dynamic_circuits.js new file mode 100644 index 00000000..e17520b9 --- /dev/null +++ b/src/rust/lqosd/src/node_manager/js_build/src/config_dynamic_circuits.js @@ -0,0 +1,378 @@ +import { + loadConfig, + loadNetworkJson, + renderConfigMenu, + saveConfig, + validNodeList, +} from "./config/config_helper"; + +const DEFAULT_TTL_SECONDS = 300; + +function normalizeIpRangeInput(value) { + const raw = String(value ?? "").trim(); + if (!raw) return ""; + if (raw === "0.0.0.0") return "0.0.0.0/0"; + if (raw === "::") return "::/0"; + return raw; +} + +function isValidIPv4(ip) { + if (!/^(\d{1,3}\.){3}\d{1,3}$/.test(ip)) return false; + const parts = ip.split(".").map((p) => parseInt(p, 10)); + return parts.length === 4 && !parts.some((p) => Number.isNaN(p) || p < 0 || p > 255); +} + +function isValidIPv6(ip) { + return ip.includes(":") && /^[0-9a-fA-F:]+$/.test(ip); +} + +function isValidCIDR(cidr) { + try { + const [ip, mask, extra] = String(cidr).trim().split("/"); + if (!ip || !mask || extra !== undefined) return false; + + if (ip.includes(":")) { + if (!isValidIPv6(ip)) return false; + } else if (!isValidIPv4(ip)) { + return false; + } + + const maskNum = parseInt(mask, 10); + if (Number.isNaN(maskNum)) return false; + if (ip.includes(":")) { + if (maskNum < 0 || maskNum > 128) return false; + } else { + if (maskNum < 0 || maskNum > 32) return false; + } + + return true; + } catch { + return false; + } +} + +function parseFiniteFloat(value) { + const num = parseFloat(String(value ?? "").trim()); + return Number.isFinite(num) ? num : null; +} + +function parsePositiveInt(value) { + const num = parseInt(String(value ?? "").trim(), 10); + if (!Number.isFinite(num)) return null; + if (num <= 0) return null; + return num; +} + +function getDefaultRule() { + return { + name: "", + ip_range: "0.0.0.0/0", + download_min_mbps: "10.0", + upload_min_mbps: "10.0", + download_max_mbps: "100.0", + upload_max_mbps: "100.0", + attach_to: "", + }; +} + +function ensureDynamicCircuitsConfig(config) { + if (!config || typeof config !== "object") return; + if (!config.dynamic_circuits || typeof config.dynamic_circuits !== "object") { + config.dynamic_circuits = { + enabled: false, + ttl_seconds: DEFAULT_TTL_SECONDS, + enable_unknown_ip_promotion: false, + ranges: [], + }; + return; + } + + const dyn = config.dynamic_circuits; + if (typeof dyn.enabled !== "boolean") dyn.enabled = false; + if (!Number.isFinite(Number(dyn.ttl_seconds))) dyn.ttl_seconds = DEFAULT_TTL_SECONDS; + if (typeof dyn.enable_unknown_ip_promotion !== "boolean") dyn.enable_unknown_ip_promotion = false; + if (!Array.isArray(dyn.ranges)) dyn.ranges = []; +} + +function setNodeDatalist(networkJson) { + const datalist = document.getElementById("networkNodesDatalist"); + if (!datalist) return; + datalist.innerHTML = ""; + + if (!networkJson || typeof networkJson !== "object") { + return; + } + + for (const name of validNodeList(networkJson)) { + const option = document.createElement("option"); + option.value = name; + datalist.appendChild(option); + } +} + +function renderRangesTable() { + const tbody = document.getElementById("dynamicRangesBody"); + if (!tbody) return; + tbody.innerHTML = ""; + + const ranges = window.config?.dynamic_circuits?.ranges; + if (!Array.isArray(ranges) || ranges.length === 0) { + const empty = document.createElement("tr"); + const cell = document.createElement("td"); + cell.colSpan = 6; + cell.className = "text-muted"; + cell.textContent = "No rules configured."; + empty.appendChild(cell); + tbody.appendChild(empty); + return; + } + + ranges.forEach((rule, index) => { + const tr = document.createElement("tr"); + + const mkText = (value, onChange, placeholder = "") => { + const input = document.createElement("input"); + input.type = "text"; + input.className = "form-control form-control-sm"; + input.placeholder = placeholder; + input.value = String(value ?? ""); + input.addEventListener("input", (ev) => onChange(ev.target.value)); + return input; + }; + + const mkNumber = (value, onChange, step = "0.1") => { + const input = document.createElement("input"); + input.type = "number"; + input.className = "form-control form-control-sm"; + input.step = step; + input.value = String(value ?? ""); + input.addEventListener("input", (ev) => onChange(ev.target.value)); + return input; + }; + + const mkRatePair = ( + downValue, + onDownChange, + upValue, + onUpChange, + ) => { + const container = document.createElement("div"); + container.className = "d-grid gap-1"; + + const mkDir = (icon, ariaLabel, value, onChange) => { + const group = document.createElement("div"); + group.className = "input-group input-group-sm"; + + const prefix = document.createElement("span"); + prefix.className = "input-group-text"; + const i = document.createElement("i"); + i.className = `fa ${icon}`; + prefix.appendChild(i); + + const input = mkNumber(value, onChange); + input.setAttribute("aria-label", ariaLabel); + + group.appendChild(prefix); + group.appendChild(input); + return group; + }; + + container.appendChild(mkDir("fa-arrow-down", "Down Mbps", downValue, onDownChange)); + container.appendChild(mkDir("fa-arrow-up", "Up Mbps", upValue, onUpChange)); + return container; + }; + + const addCell = (label, child) => { + const td = document.createElement("td"); + td.dataset.label = label; + td.appendChild(child); + tr.appendChild(td); + }; + + addCell( + "Name", + mkText(rule.name, (v) => { + window.config.dynamic_circuits.ranges[index].name = v; + }, "Rule name"), + ); + addCell( + "IP Range", + mkText(rule.ip_range, (v) => { + window.config.dynamic_circuits.ranges[index].ip_range = v; + }, "192.168.0.0/24"), + ); + addCell( + "Min Mbps", + mkRatePair( + rule.download_min_mbps, + (v) => { + window.config.dynamic_circuits.ranges[index].download_min_mbps = v; + }, + rule.upload_min_mbps, + (v) => { + window.config.dynamic_circuits.ranges[index].upload_min_mbps = v; + }, + ), + ); + addCell( + "Max Mbps", + mkRatePair( + rule.download_max_mbps, + (v) => { + window.config.dynamic_circuits.ranges[index].download_max_mbps = v; + }, + rule.upload_max_mbps, + (v) => { + window.config.dynamic_circuits.ranges[index].upload_max_mbps = v; + }, + ), + ); + const attach = mkText(rule.attach_to, (v) => { + window.config.dynamic_circuits.ranges[index].attach_to = v; + }, "network.json node (optional)"); + attach.setAttribute("list", "networkNodesDatalist"); + addCell("Attach To", attach); + + const removeTd = document.createElement("td"); + removeTd.dataset.label = "Actions"; + const removeBtn = document.createElement("button"); + removeBtn.type = "button"; + removeBtn.className = "btn btn-sm btn-outline-danger"; + removeBtn.textContent = "Remove"; + removeBtn.addEventListener("click", () => { + window.config.dynamic_circuits.ranges.splice(index, 1); + renderRangesTable(); + }); + removeTd.appendChild(removeBtn); + tr.appendChild(removeTd); + + tbody.appendChild(tr); + }); +} + +function validateConfig() { + const errors = []; + + const dyn = window.config?.dynamic_circuits; + if (!dyn || typeof dyn !== "object") { + errors.push("dynamic_circuits section is missing"); + } else { + const ttl = parsePositiveInt(dyn.ttl_seconds); + if (ttl === null) { + errors.push("Time to live must be a positive integer (seconds)"); + } + + const ranges = Array.isArray(dyn.ranges) ? dyn.ranges : []; + ranges.forEach((rule, index) => { + const label = (rule?.name ?? "").trim() + ? `Rule '${String(rule.name).trim()}'` + : `Rule #${index + 1}`; + + if (!String(rule?.name ?? "").trim()) { + errors.push(`${label}: Name is required`); + } + + const ipRange = normalizeIpRangeInput(rule?.ip_range); + if (!ipRange) { + errors.push(`${label}: IP Range is required`); + } else if (!isValidCIDR(ipRange)) { + errors.push(`${label}: IP Range must be a valid CIDR (e.g. 192.168.1.0/24). 0.0.0.0 and :: are allowed.`); + } + + const dmin = parseFiniteFloat(rule?.download_min_mbps); + const umin = parseFiniteFloat(rule?.upload_min_mbps); + const dmax = parseFiniteFloat(rule?.download_max_mbps); + const umax = parseFiniteFloat(rule?.upload_max_mbps); + + if (dmin === null || umin === null || dmax === null || umax === null) { + errors.push(`${label}: All rate fields must be valid numbers`); + return; + } + + if (dmin < 0.1) errors.push(`${label}: Min Download must be >= 0.1 Mbps`); + if (umin < 0.1) errors.push(`${label}: Min Upload must be >= 0.1 Mbps`); + if (dmax < 0.2) errors.push(`${label}: Max Download must be >= 0.2 Mbps`); + if (umax < 0.2) errors.push(`${label}: Max Upload must be >= 0.2 Mbps`); + if (dmin > dmax) errors.push(`${label}: Min Download must be <= Max Download`); + if (umin > umax) errors.push(`${label}: Min Upload must be <= Max Upload`); + }); + } + + if (errors.length === 0) { + return true; + } + + alert("Validation errors:\n" + errors.join("\n")); + return false; +} + +function updateConfigFromUi() { + const dyn = window.config.dynamic_circuits; + dyn.enabled = !!document.getElementById("dynamicCircuitsEnabled")?.checked; + dyn.enable_unknown_ip_promotion = !!document.getElementById("unknownIpPromotionEnabled")?.checked; + + const ttlValue = document.getElementById("dynamicCircuitsTtlSeconds")?.value; + dyn.ttl_seconds = parsePositiveInt(ttlValue) ?? DEFAULT_TTL_SECONDS; + + // Normalize IP ranges in-place so saves canonicalize shorthand. + if (Array.isArray(dyn.ranges)) { + dyn.ranges = dyn.ranges.map((rule) => ({ + ...rule, + name: String(rule?.name ?? "").trim(), + ip_range: normalizeIpRangeInput(rule?.ip_range), + attach_to: String(rule?.attach_to ?? "").trim(), + })); + } +} + +function finalizeConfigForSave() { + const dyn = window.config.dynamic_circuits; + if (!dyn || typeof dyn !== "object") return; + if (!Array.isArray(dyn.ranges)) return; + + dyn.ranges = dyn.ranges.map((rule) => ({ + ...rule, + download_min_mbps: parseFloat(String(rule?.download_min_mbps ?? "").trim()), + upload_min_mbps: parseFloat(String(rule?.upload_min_mbps ?? "").trim()), + download_max_mbps: parseFloat(String(rule?.download_max_mbps ?? "").trim()), + upload_max_mbps: parseFloat(String(rule?.upload_max_mbps ?? "").trim()), + })); +} + +// Render the configuration menu. +renderConfigMenu("dynamic_circuits"); + +loadConfig(() => { + ensureDynamicCircuitsConfig(window.config); + + const dyn = window.config.dynamic_circuits; + document.getElementById("dynamicCircuitsEnabled").checked = dyn.enabled ?? false; + document.getElementById("dynamicCircuitsTtlSeconds").value = dyn.ttl_seconds ?? DEFAULT_TTL_SECONDS; + document.getElementById("unknownIpPromotionEnabled").checked = dyn.enable_unknown_ip_promotion ?? false; + + // Load network nodes for the attach_to datalist (UX-only). + loadNetworkJson( + (networkJson) => { + if (typeof networkJson === "object" && networkJson !== null) { + setNodeDatalist(networkJson); + } + }, + () => {}, + ); + + renderRangesTable(); + + document.getElementById("addDynamicRange").addEventListener("click", () => { + window.config.dynamic_circuits.ranges.push(getDefaultRule()); + renderRangesTable(); + }); + + document.getElementById("saveButton").addEventListener("click", () => { + updateConfigFromUi(); + if (!validateConfig()) return; + finalizeConfigForSave(); + saveConfig(() => { + alert("Configuration saved successfully!"); + }); + }); +}); diff --git a/src/rust/lqosd/src/node_manager/js_build/src/shaped-devices.js b/src/rust/lqosd/src/node_manager/js_build/src/shaped-devices.js index 049ca1aa..f485de9b 100644 --- a/src/rust/lqosd/src/node_manager/js_build/src/shaped-devices.js +++ b/src/rust/lqosd/src/node_manager/js_build/src/shaped-devices.js @@ -13,6 +13,7 @@ let shapedDevices = []; let devicesPerPage = 24; let page = 0; let searchTerm = ""; +let shapedDevicesKind = "Static"; let metricElsByCircuitId = new Map(); const latestByCircuitId = new Map(); const planByCircuitId = new Map(); @@ -78,6 +79,52 @@ function countCircuits() { return totalCircuits; } +function updateKindTabs() { + const staticTab = document.getElementById("sdTabStatic"); + const dynamicTab = document.getElementById("sdTabDynamic"); + if (!staticTab || !dynamicTab) { + return; + } + const isDynamic = shapedDevicesKind === "Dynamic"; + staticTab.classList.toggle("active", !isDynamic); + dynamicTab.classList.toggle("active", isDynamic); + staticTab.setAttribute("aria-selected", (!isDynamic).toString()); + dynamicTab.setAttribute("aria-selected", isDynamic.toString()); +} + +function renderCounts() { + const countEl = $("#count"); + const circuitEl = $("#countCircuit"); + if (shapedDevicesKind === "Dynamic") { + countEl.text(totalRows + " dynamic circuits"); + circuitEl.text(""); + circuitEl.addClass("d-none"); + } else { + countEl.text(totalRows + " devices"); + circuitEl.text(totalCircuits + " circuits"); + circuitEl.removeClass("d-none"); + } +} + +function setShapedDevicesKind(kind) { + if (!kind || (kind !== "Static" && kind !== "Dynamic")) { + kind = "Static"; + } + if (shapedDevicesKind === kind) { + return; + } + shapedDevicesKind = kind; + page = 0; + searchTerm = ""; + const searchBox = document.getElementById("sdSearch"); + if (searchBox) { + searchBox.value = ""; + } + circuitMetricsWatchSignature = null; + updateKindTabs(); + requestShapedDevicesPage(); +} + function filterDevices() { page = 0; requestShapedDevicesPage(); @@ -550,6 +597,7 @@ function currentShapedDevicesPageQuery() { const query = { page, page_size: devicesPerPage, + kind: shapedDevicesKind, }; if (searchTerm && searchTerm.trim() !== "") { query.search = searchTerm; @@ -610,13 +658,13 @@ async function requestShapedDevicesPage() { }); renderCards(); requestCircuitMetricsWatch(true); - $("#count").text(totalRows + " devices"); - $("#countCircuit").text(totalCircuits + " circuits"); + renderCounts(); } catch (_error) { shapedDevices = []; totalRows = 0; totalCircuits = 0; renderCards(); + renderCounts(); } } @@ -676,7 +724,16 @@ function handleCircuitMetrics(msg) { wsClient.on("CircuitMetricsSnapshot", handleCircuitMetrics); wsClient.on("CircuitMetricsUpdate", handleCircuitMetrics); wsClient.on("join", () => { + updateKindTabs(); requestShapedDevicesPage(); }); +document.getElementById("sdTabStatic")?.addEventListener("click", () => { + setShapedDevicesKind("Static"); +}); +document.getElementById("sdTabDynamic")?.addEventListener("click", () => { + setShapedDevicesKind("Dynamic"); +}); + +updateKindTabs(); requestShapedDevicesPage(); diff --git a/src/rust/lqosd/src/node_manager/local_api/circuit.rs b/src/rust/lqosd/src/node_manager/local_api/circuit.rs index 90df0bd8..479a1eb1 100644 --- a/src/rust/lqosd/src/node_manager/local_api/circuit.rs +++ b/src/rust/lqosd/src/node_manager/local_api/circuit.rs @@ -1,7 +1,5 @@ use crate::node_manager::local_api::ethernet_caps::ethernet_advisory_for_circuit; -use crate::shaped_devices_tracker::{ - SHAPED_DEVICES, effective_parent_for_circuit, resolve_parent_node_reference, -}; +use crate::shaped_devices_tracker::effective_parent_for_circuit; use lqos_config::{CircuitEthernetMetadata, ShapedDevice}; use serde::{Deserialize, Serialize}; @@ -50,9 +48,10 @@ fn canonical_parent_node(devices: &mut [ShapedDevice]) -> Option CircuitQueueStatsMode { pub fn circuit_by_id_data(id: &str) -> Option { let safe_id = id.to_lowercase().trim().to_string(); - let reader = SHAPED_DEVICES.load(); - let mut devices: Vec = reader - .devices - .iter() - .filter(|d| d.circuit_id.to_lowercase().trim() == safe_id) - .cloned() - .collect(); + let catalog = lqos_network_devices::shaped_devices_catalog(); + let mut devices: Vec = catalog.devices_for_circuit_id(&safe_id); + + if devices.is_empty() { + let catalog = lqos_network_devices::network_devices_catalog(); + if let Some(device) = catalog.dynamic_device_by_circuit_id(&safe_id) { + devices.push(device.clone()); + } + } if devices.is_empty() { None diff --git a/src/rust/lqosd/src/node_manager/local_api/circuit_activity.rs b/src/rust/lqosd/src/node_manager/local_api/circuit_activity.rs index 38d7f2ef..86f94de8 100644 --- a/src/rust/lqosd/src/node_manager/local_api/circuit_activity.rs +++ b/src/rust/lqosd/src/node_manager/local_api/circuit_activity.rs @@ -1,6 +1,3 @@ -use crate::shaped_devices_tracker::{ - SHAPED_DEVICE_HASH_CACHE, SHAPED_DEVICES, shaped_device_from_hashes_or_ip, -}; use crate::throughput_tracker::flow_data::{ALL_FLOWS, FlowbeeLocalData, get_asn_name_and_country}; use lqos_utils::hash_to_i64; use lqos_utils::units::{DownUpOrder, TcpRetransmitSample}; @@ -166,13 +163,13 @@ fn sanitized_plan_ceiling_bps(plan_mbps: f32) -> u32 { } fn circuit_display_rate_ceiling_bps( - shaped: &lqos_config::ConfigShapedDevices, + catalog: &lqos_network_devices::NetworkDevicesCatalog, circuit_hash: i64, ) -> Option> { let mut max_down_mbps = 0.0_f32; let mut max_up_mbps = 0.0_f32; - for device in &shaped.devices { + for device in catalog.iter_all_devices() { if device.circuit_hash != circuit_hash { continue; } @@ -222,9 +219,8 @@ fn flow_qoo(local: &FlowbeeLocalData) -> DownUpOrder> { fn flow_snapshot_rows(circuit_id: &str) -> Vec { let circuit_hash = hash_to_i64(circuit_id); - let shaped = SHAPED_DEVICES.load(); - let cache = SHAPED_DEVICE_HASH_CACHE.load(); - let display_rate_ceiling = circuit_display_rate_ceiling_bps(&shaped, circuit_hash); + let catalog = lqos_network_devices::network_devices_catalog(); + let display_rate_ceiling = circuit_display_rate_ceiling_bps(&catalog, circuit_hash); let Ok(now) = time_since_boot() else { return Vec::new(); }; @@ -239,13 +235,13 @@ fn flow_snapshot_rows(circuit_id: &str) -> Vec { if local.last_seen < recent_cutoff { return None; } - let device = shaped_device_from_hashes_or_ip( - &shaped, - &cache, - &key.local_ip, - local.device_hash, - local.circuit_hash, - ); + let device = catalog + .device_by_hashes(local.device_hash, local.circuit_hash) + .or_else(|| { + catalog + .device_longest_match_for_ip(&key.local_ip) + .map(|(_, device)| device) + }); let matches_desired = local.circuit_hash == Some(circuit_hash) || device.is_some_and(|device| device.circuit_id == circuit_id); if !matches_desired { diff --git a/src/rust/lqosd/src/node_manager/local_api/circuit_count.rs b/src/rust/lqosd/src/node_manager/local_api/circuit_count.rs index 14bcd335..77edc861 100644 --- a/src/rust/lqosd/src/node_manager/local_api/circuit_count.rs +++ b/src/rust/lqosd/src/node_manager/local_api/circuit_count.rs @@ -1,4 +1,3 @@ -use crate::shaped_devices_tracker::SHAPED_DEVICES; use crate::throughput_tracker::THROUGHPUT_TRACKER; use lqos_utils::unix_time::time_since_boot; use serde::Serialize; @@ -35,23 +34,24 @@ pub fn circuit_count_data() -> CircuitCount { .filter_map(|(_k, d)| d.circuit_id.clone()) .collect(); - // Get configured circuits from ShapedDevices - let shaped_devices = SHAPED_DEVICES.load(); - let configured_circuits: HashSet<&str> = shaped_devices - .devices - .iter() - .map(|device| device.circuit_id.as_str()) - .collect(); + // Get configured circuits from ShapedDevices + dynamic overlay + let catalog = lqos_network_devices::network_devices_catalog(); + let configured_count = catalog + .iter_all_devices() + .map(|device| device.circuit_id.trim()) + .filter(|id| !id.is_empty()) + .collect::>() + .len(); // Use active count if > 0, otherwise fall back to configured count let count = if !active_circuits.is_empty() { active_circuits.len() } else { - configured_circuits.len() + configured_count }; CircuitCount { count, - configured_count: configured_circuits.len(), + configured_count, } } diff --git a/src/rust/lqosd/src/node_manager/local_api/config.rs b/src/rust/lqosd/src/node_manager/local_api/config.rs index 3d9f23eb..8a58560a 100644 --- a/src/rust/lqosd/src/node_manager/local_api/config.rs +++ b/src/rust/lqosd/src/node_manager/local_api/config.rs @@ -1,6 +1,5 @@ use crate::node_manager::auth::LoginResult; use crate::node_manager::local_api::network_mode::NetworkModeInspection; -use crate::shaped_devices_tracker::SHAPED_DEVICES; use crate::shaping_runtime::ShapingRuntimeStatus; use axum::http::StatusCode; use default_net::get_interfaces; @@ -10,7 +9,6 @@ use lqos_utils::hash_to_i64; use serde::{Deserialize, Serialize}; use serde_json::Value; use std::collections::{BTreeMap, BTreeSet, HashSet}; -use std::sync::Arc; type TopologySourceIntegration = (&'static str, fn(&Config) -> bool); @@ -382,7 +380,7 @@ pub fn network_json_data() -> Value { } pub fn all_shaped_devices_data() -> Vec { - SHAPED_DEVICES.load().devices.clone() + lqos_network_devices::shaped_devices_catalog().clone_all_devices() } /// Returns the enabled integration names that act as the source of truth for @@ -626,7 +624,11 @@ fn persist_shaped_devices(mut devices: Vec) -> Result<(), String> copied .write_csv("ShapedDevices.csv") .map_err(|e| format!("Unable to write ShapedDevices.csv: {e}"))?; - SHAPED_DEVICES.store(Arc::new(copied)); + lqos_network_devices::apply_shaped_devices_snapshot( + "node_manager:persist_shaped_devices", + copied, + ) + .map_err(|e| format!("Unable to publish ShapedDevices.csv snapshot: {e}"))?; Ok(()) } @@ -663,6 +665,8 @@ pub fn update_network_and_devices_data( ensure_topology_editor_unlocked()?; persist_network_json(&network_json)?; persist_shaped_devices(shaped_devices)?; + lqos_network_devices::request_reload_network_json("node_manager:update_network_and_devices") + .map_err(|e| format!("Unable to reload network.json: {e}"))?; Ok(()) } @@ -682,6 +686,8 @@ pub fn update_network_json_only_data( ensure_topology_editor_unlocked()?; persist_network_json(&network_json)?; + lqos_network_devices::request_reload_network_json("node_manager:update_network_json_only") + .map_err(|e| format!("Unable to reload network.json: {e}"))?; Ok(()) } @@ -696,10 +702,8 @@ pub fn get_shaped_device_data( return Err(StatusCode::FORBIDDEN); } let wanted = device_id.trim(); - Ok(SHAPED_DEVICES - .load() - .devices - .iter() + Ok(lqos_network_devices::shaped_devices_catalog() + .iter_devices() .find(|device| device.device_id == wanted) .cloned()) } @@ -716,7 +720,7 @@ pub fn create_shaped_device_data( return Err("Unauthorized".to_string()); } ensure_topology_editor_unlocked()?; - let mut devices = SHAPED_DEVICES.load().devices.clone(); + let mut devices = lqos_network_devices::shaped_devices_catalog().clone_all_devices(); devices.push(device.clone()); persist_shaped_devices(devices)?; let created = get_shaped_device_data(login, device.device_id.clone()) @@ -739,7 +743,7 @@ pub fn update_shaped_device_data( return Err("Unauthorized".to_string()); } ensure_topology_editor_unlocked()?; - let mut devices = SHAPED_DEVICES.load().devices.clone(); + let mut devices = lqos_network_devices::shaped_devices_catalog().clone_all_devices(); let wanted = original_device_id.trim(); let Some(index) = devices.iter().position(|row| row.device_id == wanted) else { return Err("Not found".to_string()); @@ -763,7 +767,7 @@ pub fn delete_shaped_device_data(login: LoginResult, device_id: String) -> Resul } ensure_topology_editor_unlocked()?; let wanted = device_id.trim(); - let mut devices = SHAPED_DEVICES.load().devices.clone(); + let mut devices = lqos_network_devices::shaped_devices_catalog().clone_all_devices(); let before = devices.len(); devices.retain(|device| device.device_id != wanted); if devices.len() == before { diff --git a/src/rust/lqosd/src/node_manager/local_api/cpu_affinity.rs b/src/rust/lqosd/src/node_manager/local_api/cpu_affinity.rs index 28e345e2..86cd77ca 100644 --- a/src/rust/lqosd/src/node_manager/local_api/cpu_affinity.rs +++ b/src/rust/lqosd/src/node_manager/local_api/cpu_affinity.rs @@ -1,7 +1,6 @@ use crate::node_manager::local_api::network_tree_lite::{ NetworkTreeLiteNode, network_tree_lite_data, }; -use crate::shaped_devices_tracker::SHAPED_DEVICES; use crate::system_stats::{CPU_USAGE, NUM_CPUS}; use lqos_config::{ShapingCpuDetection, detect_shaping_cpus, load_config}; use serde::Serialize; @@ -558,10 +557,10 @@ fn derive_runtime_node_placements( } fn direct_circuit_counts_by_node() -> HashMap { - let shaped = SHAPED_DEVICES.load(); + let catalog = lqos_network_devices::shaped_devices_catalog(); let mut circuits_by_node: HashMap> = HashMap::new(); - for device in &shaped.devices { + for device in catalog.iter_devices() { let node_name = device.parent_node.trim(); if node_name.is_empty() { continue; diff --git a/src/rust/lqosd/src/node_manager/local_api/device_counts.rs b/src/rust/lqosd/src/node_manager/local_api/device_counts.rs index be93916a..91367657 100644 --- a/src/rust/lqosd/src/node_manager/local_api/device_counts.rs +++ b/src/rust/lqosd/src/node_manager/local_api/device_counts.rs @@ -1,5 +1,4 @@ use crate::node_manager::local_api::unknown_ips::get_unknown_ips; -use crate::shaped_devices_tracker::SHAPED_DEVICES; use serde::Serialize; use std::collections::BTreeSet; @@ -11,16 +10,15 @@ pub struct DeviceCount { } pub fn device_count() -> DeviceCount { - let shaped_devices = SHAPED_DEVICES.load(); + let shaped_devices = lqos_network_devices::shaped_devices_catalog(); let mapped_circuits = shaped_devices - .devices - .iter() + .iter_devices() .map(|device| device.circuit_hash) .collect::>() .len(); DeviceCount { - shaped_devices: shaped_devices.devices.len(), + shaped_devices: shaped_devices.devices_len(), unknown_ips: get_unknown_ips().len(), mapped_circuits, } diff --git a/src/rust/lqosd/src/node_manager/local_api/directories.rs b/src/rust/lqosd/src/node_manager/local_api/directories.rs index 63a5e525..b66534f5 100644 --- a/src/rust/lqosd/src/node_manager/local_api/directories.rs +++ b/src/rust/lqosd/src/node_manager/local_api/directories.rs @@ -1,5 +1,4 @@ use crate::node_manager::local_api::network_tree; -use crate::shaped_devices_tracker::SHAPED_DEVICES; use serde::{Deserialize, Deserializer, Serialize}; use std::collections::{BTreeMap, BTreeSet}; @@ -85,10 +84,10 @@ pub fn circuit_directory_page(query: CircuitDirectoryQuery) -> CircuitDirectoryP let page = query.page.unwrap_or(0); let page_size = normalized_page_size(&query); let search = query.search.as_deref().unwrap_or("").trim().to_lowercase(); - let devices = SHAPED_DEVICES.load(); + let catalog = lqos_network_devices::network_devices_catalog(); let mut circuits: BTreeMap = BTreeMap::new(); - for device in &devices.devices { + for device in catalog.iter_all_devices() { let circuit_id = device.circuit_id.trim().to_string(); if circuit_id.is_empty() { continue; @@ -210,10 +209,10 @@ pub fn treeguard_metadata_summary() -> TreeGuardMetadataSummary { }) .count(); - let devices = SHAPED_DEVICES.load(); + let catalog = lqos_network_devices::network_devices_catalog(); let mut circuit_ids = BTreeSet::new(); let mut fq_codel_circuit_ids = BTreeSet::new(); - for device in &devices.devices { + for device in catalog.iter_all_devices() { let circuit_id = device.circuit_id.trim().to_string(); if circuit_id.is_empty() { continue; diff --git a/src/rust/lqosd/src/node_manager/local_api/ethernet_caps.rs b/src/rust/lqosd/src/node_manager/local_api/ethernet_caps.rs index 1066ed96..4fd1bbad 100644 --- a/src/rust/lqosd/src/node_manager/local_api/ethernet_caps.rs +++ b/src/rust/lqosd/src/node_manager/local_api/ethernet_caps.rs @@ -1,4 +1,3 @@ -use crate::shaped_devices_tracker::SHAPED_DEVICES; use lqos_config::{CircuitEthernetMetadata, CircuitEthernetMetadataFile, load_config}; use serde::{Deserialize, Deserializer, Serialize}; use std::collections::HashMap; @@ -139,9 +138,9 @@ fn load_advisory_file() -> Option { } fn parent_node_by_circuit_id() -> HashMap { - let devices = SHAPED_DEVICES.load(); + let catalog = lqos_network_devices::shaped_devices_catalog(); let mut parent_nodes = HashMap::new(); - for device in &devices.devices { + for device in catalog.iter_devices() { let circuit_id = device.circuit_id.trim(); if circuit_id.is_empty() { continue; diff --git a/src/rust/lqosd/src/node_manager/local_api/executive_cache.rs b/src/rust/lqosd/src/node_manager/local_api/executive_cache.rs index 7ccca272..ae7f80b5 100644 --- a/src/rust/lqosd/src/node_manager/local_api/executive_cache.rs +++ b/src/rust/lqosd/src/node_manager/local_api/executive_cache.rs @@ -4,7 +4,6 @@ use crate::node_manager::local_api::executive::{ ExecutiveOversubscribedSiteRow, ExecutiveRttBlocks, ExecutiveScalarBlocks, ExecutiveSplitBlocks, ExecutiveTopAsnRow, ExecutiveTreeLocator, }; -use crate::shaped_devices_tracker::{NETWORK_JSON, SHAPED_DEVICES}; use crate::throughput_tracker::{ THROUGHPUT_TRACKER, asn_heatmaps, circuit_heatmaps, executive_summary_header, global_heatmap, site_heatmaps, @@ -595,23 +594,24 @@ fn node_path_names(idx: usize, nodes: &[lqos_config::NetworkJsonNode]) -> Option } fn node_locator_map() -> FxHashMap { - let reader = NETWORK_JSON.read(); - let nodes = reader.get_nodes_when_ready(); - let mut locators = FxHashMap::default(); - for (idx, node) in nodes.iter().enumerate() { - if node.name == "Root" || locators.contains_key(&node.name) { - continue; + lqos_network_devices::with_network_json_read(|net_json| { + let nodes = net_json.get_nodes_when_ready(); + let mut locators = FxHashMap::default(); + for (idx, node) in nodes.iter().enumerate() { + if node.name == "Root" || locators.contains_key(&node.name) { + continue; + } + locators.insert( + node.name.clone(), + ExecutiveTreeLocator { + node_id: node.id.clone(), + node_path: node_path_names(idx, nodes), + parent_index: Some(idx), + }, + ); } - locators.insert( - node.name.clone(), - ExecutiveTreeLocator { - node_id: node.id.clone(), - node_path: node_path_names(idx, nodes), - parent_index: Some(idx), - }, - ); - } - locators + locators + }) } fn entity_snapshots( @@ -686,30 +686,17 @@ fn entity_snapshots( fn build_oversubscribed_sites( entities: &[ExecutiveEntitySnapshot], ) -> Vec { - let devices = SHAPED_DEVICES.load(); - let mut circuit_map: std::collections::HashMap = - std::collections::HashMap::new(); - for device in &devices.devices { - let entry = circuit_map - .entry(device.circuit_id.clone()) - .or_insert_with(|| (device.parent_node.clone(), 0.0_f32, 0.0_f32)); - entry.1 = entry.1.max(device.download_max_mbps); - entry.2 = entry.2.max(device.upload_max_mbps); - } - - let reader = NETWORK_JSON.read(); - let nodes = reader.get_nodes_when_ready(); - if nodes.is_empty() { - return Vec::new(); - } - - let mut children: Vec> = vec![Vec::new(); nodes.len()]; - for (idx, node) in nodes.iter().enumerate() { - if let Some(parent) = node.immediate_parent - && parent < children.len() - { - children[parent].push(idx); - } + let mut circuit_caps = + lqos_network_devices::shaped_devices_catalog().circuit_rate_caps_by_circuit_id(); + for circuit in lqos_network_devices::dynamic_circuits_snapshot().iter() { + let shaped = &circuit.shaped; + circuit_caps.entry(shaped.circuit_id.clone()).or_insert_with(|| { + lqos_network_devices::CircuitRateCaps { + parent_node: shaped.parent_node.clone(), + download_max_mbps: shaped.download_max_mbps, + upload_max_mbps: shaped.upload_max_mbps, + } + }); } let entity_map = entities @@ -718,85 +705,101 @@ fn build_oversubscribed_sites( .map(|entity| (entity.label.clone(), entity)) .collect::>(); - let mut results = Vec::new(); - for (idx, node) in nodes.iter().enumerate() { - if node.name == "Root" { - continue; - } - let node_type = node.node_type.as_deref().unwrap_or("").to_ascii_lowercase(); - if node_type != "site" && !node_type.is_empty() && node_type != "ap" { - continue; + lqos_network_devices::with_network_json_read(|net_json| { + let nodes = net_json.get_nodes_when_ready(); + if nodes.is_empty() { + return Vec::new(); } - let mut stack = vec![idx]; - let mut visited = FxHashSet::default(); - let mut descendant_names = FxHashSet::default(); - while let Some(next_idx) = stack.pop() { - if !visited.insert(next_idx) { + let mut children: Vec> = vec![Vec::new(); nodes.len()]; + for (idx, node) in nodes.iter().enumerate() { + if let Some(parent) = node.immediate_parent + && parent < children.len() + { + children[parent].push(idx); + } + } + + let mut results = Vec::new(); + for (idx, node) in nodes.iter().enumerate() { + if node.name == "Root" { continue; } - if let Some(name) = nodes.get(next_idx).map(|entry| entry.name.clone()) { - descendant_names.insert(name); + let node_type = node.node_type.as_deref().unwrap_or("").to_ascii_lowercase(); + if node_type != "site" && !node_type.is_empty() && node_type != "ap" { + continue; } - if let Some(child_nodes) = children.get(next_idx) { - for child in child_nodes { - stack.push(*child); + + let mut stack = vec![idx]; + let mut visited = FxHashSet::default(); + let mut descendant_names = FxHashSet::default(); + while let Some(next_idx) = stack.pop() { + if !visited.insert(next_idx) { + continue; + } + if let Some(name) = nodes.get(next_idx).map(|entry| entry.name.clone()) { + descendant_names.insert(name); + } + if let Some(child_nodes) = children.get(next_idx) { + for child in child_nodes { + stack.push(*child); + } } } - } - let cap_down = node.max_throughput.0 as f32; - let cap_up = node.max_throughput.1 as f32; + let cap_down = node.max_throughput.0 as f32; + let cap_up = node.max_throughput.1 as f32; - let mut sub_down = 0.0_f32; - let mut sub_up = 0.0_f32; - for (parent_name, circuit_down, circuit_up) in circuit_map.values() { - if descendant_names.contains(parent_name) { - sub_down += *circuit_down; - sub_up += *circuit_up; + let mut sub_down = 0.0_f32; + let mut sub_up = 0.0_f32; + for caps in circuit_caps.values() { + if descendant_names.contains(&caps.parent_node) { + sub_down += caps.download_max_mbps; + sub_up += caps.upload_max_mbps; + } } + + let Some(stats) = entity_map.get(&node.name) else { + continue; + }; + let (avg_down_util, _) = average_with_count(&stats.heatmap.download); + let (avg_up_util, _) = average_with_count(&stats.heatmap.upload); + let median_rtt_ms = median_value(&stats.heatmap.rtt); + let ratio_down = (cap_down > 0.0).then_some(sub_down / cap_down); + let ratio_up = (cap_up > 0.0).then_some(sub_up / cap_up); + let ratio_max = match (ratio_down, ratio_up) { + (Some(down), Some(up)) => Some(down.max(up)), + (Some(down), None) => Some(down), + (None, Some(up)) => Some(up), + (None, None) => None, + }; + + results.push(ExecutiveOversubscribedSiteRow { + row_key: stats.row_key.clone(), + site_name: node.name.clone(), + tree: stats.tree.clone(), + ratio_down, + ratio_up, + ratio_max, + cap_down, + cap_up, + sub_down, + sub_up, + avg_down_util, + avg_up_util, + median_rtt_ms, + }); } - let Some(stats) = entity_map.get(&node.name) else { - continue; - }; - let (avg_down_util, _) = average_with_count(&stats.heatmap.download); - let (avg_up_util, _) = average_with_count(&stats.heatmap.upload); - let median_rtt_ms = median_value(&stats.heatmap.rtt); - let ratio_down = (cap_down > 0.0).then_some(sub_down / cap_down); - let ratio_up = (cap_up > 0.0).then_some(sub_up / cap_up); - let ratio_max = match (ratio_down, ratio_up) { - (Some(down), Some(up)) => Some(down.max(up)), - (Some(down), None) => Some(down), - (None, Some(up)) => Some(up), - (None, None) => None, - }; - - results.push(ExecutiveOversubscribedSiteRow { - row_key: stats.row_key.clone(), - site_name: node.name.clone(), - tree: stats.tree.clone(), - ratio_down, - ratio_up, - ratio_max, - cap_down, - cap_up, - sub_down, - sub_up, - avg_down_util, - avg_up_util, - median_rtt_ms, + results.sort_by(|left, right| { + right + .ratio_max + .unwrap_or(0.0) + .total_cmp(&left.ratio_max.unwrap_or(0.0)) }); - } - - results.sort_by(|left, right| { - right - .ratio_max - .unwrap_or(0.0) - .total_cmp(&left.ratio_max.unwrap_or(0.0)) - }); - results.truncate(EXECUTIVE_OVERSUBSCRIBED_LIMIT); - results + results.truncate(EXECUTIVE_OVERSUBSCRIBED_LIMIT); + results + }) } /// Invalidates the shared executive cache so the next read rebuilds immediately. diff --git a/src/rust/lqosd/src/node_manager/local_api/flow_explorer.rs b/src/rust/lqosd/src/node_manager/local_api/flow_explorer.rs index 87444806..e4a2f35c 100644 --- a/src/rust/lqosd/src/node_manager/local_api/flow_explorer.rs +++ b/src/rust/lqosd/src/node_manager/local_api/flow_explorer.rs @@ -1,3 +1,4 @@ +use crate::throughput_tracker::THROUGHPUT_TRACKER; use crate::throughput_tracker::flow_data::{ AsnCountryListEntry, AsnListEntry, AsnProtocolListEntry, FlowAnalysis, FlowbeeLocalData, RECENT_FLOWS, RttData, diff --git a/src/rust/lqosd/src/node_manager/local_api/search.rs b/src/rust/lqosd/src/node_manager/local_api/search.rs index 8c6d6cc8..b1cb6ca0 100644 --- a/src/rust/lqosd/src/node_manager/local_api/search.rs +++ b/src/rust/lqosd/src/node_manager/local_api/search.rs @@ -1,5 +1,6 @@ -use crate::shaped_devices_tracker::{NETWORK_JSON, SHAPED_DEVICES}; use ip_network::IpNetwork; +use ip_network::{Ipv4Network, Ipv6Network}; +use lqos_utils::XdpIpAddress; use serde::{Deserialize, Serialize}; use std::collections::HashSet; use std::net::{IpAddr, Ipv4Addr}; @@ -69,6 +70,53 @@ pub enum SearchResult { }, } +fn dynamic_longest_match_for_ip( + ip: IpAddr, + dynamic: &[lqos_network_devices::DynamicCircuit], +) -> Option<(IpNetwork, &lqos_config::ShapedDevice)> { + let mut best: Option<(IpNetwork, &lqos_config::ShapedDevice, u8)> = None; + + for circuit in dynamic { + let dev = &circuit.shaped; + match ip { + IpAddr::V4(ip4) => { + for (addr, prefix) in &dev.ipv4 { + let Some(prefix_u8) = u8::try_from(*prefix).ok() else { + continue; + }; + let Ok(net) = Ipv4Network::new(*addr, prefix_u8) else { + continue; + }; + if !net.contains(ip4) { + continue; + } + if best.as_ref().map(|(_, _, p)| *p).unwrap_or(0) < prefix_u8 { + best = Some((IpNetwork::V4(net), dev, prefix_u8)); + } + } + } + IpAddr::V6(ip6) => { + for (addr, prefix) in &dev.ipv6 { + let Some(prefix_u8) = u8::try_from(*prefix).ok() else { + continue; + }; + let Ok(net) = Ipv6Network::new(*addr, prefix_u8) else { + continue; + }; + if !net.contains(ip6) { + continue; + } + if best.as_ref().map(|(_, _, p)| *p).unwrap_or(0) < prefix_u8 { + best = Some((IpNetwork::V6(net), dev, prefix_u8)); + } + } + } + } + } + + best.map(|(net, dev, _)| (net, dev)) +} + pub fn search_results(search: SearchRequest) -> Vec { const MAX_RESULTS: usize = 50; let mut results: Vec = Vec::new(); @@ -78,6 +126,8 @@ pub fn search_results(search: SearchRequest) -> Vec { let term_lc = raw_term.to_lowercase(); let exact_ip: Option = raw_term.parse::().ok(); let looks_like_ip_prefix = raw_term.contains('.') || raw_term.contains(':'); + let catalog = lqos_network_devices::shaped_devices_catalog(); + let dynamic_snapshot = lqos_network_devices::dynamic_circuits_snapshot(); // Helper to add results with de-dup and cap fn push_result( @@ -103,13 +153,20 @@ pub fn search_results(search: SearchRequest) -> Vec { // First pass: exact IP matches using the LPM trie if let Some(ip) = exact_ip { - let sd_reader = SHAPED_DEVICES.load(); - let query_v6 = match ip { - IpAddr::V4(v4) => v4.to_ipv6_mapped(), - IpAddr::V6(v6) => v6, - }; - if let Some((net, &idx)) = sd_reader.trie.longest_match(query_v6) - && let Some(dev) = sd_reader.devices.get(idx) + let xdp_ip = XdpIpAddress::from_ip(ip); + if let Some((net, dev)) = catalog.device_longest_match_for_ip(&xdp_ip) { + let name = format!("{} ({})", dev.device_name, pretty_net(&net)); + push_result( + &mut results, + &mut seen, + SearchResult::Device { + circuit_id: dev.circuit_id.clone(), + name, + circuit_name: dev.circuit_name.clone(), + }, + MAX_RESULTS, + ); + } else if let Some((net, dev)) = dynamic_longest_match_for_ip(ip, dynamic_snapshot.as_ref()) { let name = format!("{} ({})", dev.device_name, pretty_net(&net)); push_result( @@ -143,14 +200,11 @@ pub fn search_results(search: SearchRequest) -> Vec { IpNetwork::V6(v6net) => Some(IpNetwork::V6(v6net)), }; if let Some(query_v6) = net_v6.as_ref() { - let sd_reader = SHAPED_DEVICES.load(); - for (n, &idx) in sd_reader.trie.iter() { + for (n, dev) in catalog.iter_ip_mappings() { if results.len() >= MAX_RESULTS { break; } - if ipv6_overlap(&n, query_v6) - && let Some(dev) = sd_reader.devices.get(idx) - { + if ipv6_overlap(&n, query_v6) { let name = format!("{} ({})", dev.device_name, pretty_net(&n)); push_result( &mut results, @@ -164,19 +218,79 @@ pub fn search_results(search: SearchRequest) -> Vec { ); } } + for circuit in dynamic_snapshot.iter() { + if results.len() >= MAX_RESULTS { + break; + } + let dev = &circuit.shaped; + for (addr, prefix) in &dev.ipv4 { + if results.len() >= MAX_RESULTS { + break; + } + let Some(prefix_u8) = u8::try_from(*prefix).ok() else { + continue; + }; + let Ok(v4net) = Ipv4Network::new(*addr, prefix_u8) else { + continue; + }; + let addr = v4net.network_address().to_ipv6_mapped(); + let mapped_pref = prefix_u8.saturating_add(96); + let Ok(v6net) = Ipv6Network::new(addr, mapped_pref) else { + continue; + }; + let n = IpNetwork::V6(v6net); + if ipv6_overlap(&n, query_v6) { + let name = + format!("{} ({})", dev.device_name, pretty_net(&n)); + push_result( + &mut results, + &mut seen, + SearchResult::Device { + circuit_id: dev.circuit_id.clone(), + name, + circuit_name: dev.circuit_name.clone(), + }, + MAX_RESULTS, + ); + } + } + for (addr, prefix) in &dev.ipv6 { + if results.len() >= MAX_RESULTS { + break; + } + let Some(prefix_u8) = u8::try_from(*prefix).ok() else { + continue; + }; + let Ok(v6net) = Ipv6Network::new(*addr, prefix_u8) else { + continue; + }; + let n = IpNetwork::V6(v6net); + if ipv6_overlap(&n, query_v6) { + let name = + format!("{} ({})", dev.device_name, pretty_net(&n)); + push_result( + &mut results, + &mut seen, + SearchResult::Device { + circuit_id: dev.circuit_id.clone(), + name, + circuit_name: dev.circuit_name.clone(), + }, + MAX_RESULTS, + ); + } + } + } } } } else { // Fallback: textual prefix (e.g., "10.1.") - let sd_reader = SHAPED_DEVICES.load(); - for (n, &idx) in sd_reader.trie.iter() { + for (n, dev) in catalog.iter_ip_mappings() { if results.len() >= MAX_RESULTS { break; } let s = pretty_net(&n); - if s.starts_with(raw_term) - && let Some(dev) = sd_reader.devices.get(idx) - { + if s.starts_with(raw_term) { let name = format!("{} ({})", dev.device_name, s); push_result( &mut results, @@ -190,13 +304,70 @@ pub fn search_results(search: SearchRequest) -> Vec { ); } } + for circuit in dynamic_snapshot.iter() { + if results.len() >= MAX_RESULTS { + break; + } + let dev = &circuit.shaped; + for (addr, prefix) in &dev.ipv4 { + if results.len() >= MAX_RESULTS { + break; + } + let Some(prefix_u8) = u8::try_from(*prefix).ok() else { + continue; + }; + let Ok(net) = Ipv4Network::new(*addr, prefix_u8) else { + continue; + }; + let n = IpNetwork::V4(net); + let s = pretty_net(&n); + if s.starts_with(raw_term) { + let name = format!("{} ({})", dev.device_name, s); + push_result( + &mut results, + &mut seen, + SearchResult::Device { + circuit_id: dev.circuit_id.clone(), + name, + circuit_name: dev.circuit_name.clone(), + }, + MAX_RESULTS, + ); + } + } + for (addr, prefix) in &dev.ipv6 { + if results.len() >= MAX_RESULTS { + break; + } + let Some(prefix_u8) = u8::try_from(*prefix).ok() else { + continue; + }; + let Ok(net) = Ipv6Network::new(*addr, prefix_u8) else { + continue; + }; + let n = IpNetwork::V6(net); + let s = pretty_net(&n); + if s.starts_with(raw_term) { + let name = format!("{} ({})", dev.device_name, s); + push_result( + &mut results, + &mut seen, + SearchResult::Device { + circuit_id: dev.circuit_id.clone(), + name, + circuit_name: dev.circuit_name.clone(), + }, + MAX_RESULTS, + ); + } + } + } } } // Third pass: Circuit/Device name substring matches if results.len() < MAX_RESULTS && term_lc.len() >= 3 { - let sd_reader = SHAPED_DEVICES.load(); - for sd in sd_reader.devices.iter() { + for sd in catalog.iter_devices() { if results.len() >= MAX_RESULTS { break; } @@ -229,27 +400,63 @@ pub fn search_results(search: SearchRequest) -> Vec { ); } } + + for circuit in dynamic_snapshot.iter() { + if results.len() >= MAX_RESULTS { + break; + } + let sd = &circuit.shaped; + let circuit_name_lc = sd.circuit_name.to_lowercase(); + if circuit_name_lc.contains(&term_lc) { + push_result( + &mut results, + &mut seen, + SearchResult::Circuit { + id: sd.circuit_id.clone(), + name: sd.circuit_name.clone(), + }, + MAX_RESULTS, + ); + } + if results.len() >= MAX_RESULTS { + break; + } + let device_name_lc = sd.device_name.to_lowercase(); + if device_name_lc.contains(&term_lc) { + push_result( + &mut results, + &mut seen, + SearchResult::Device { + circuit_id: sd.circuit_id.clone(), + name: sd.device_name.clone(), + circuit_name: sd.circuit_name.clone(), + }, + MAX_RESULTS, + ); + } + } } // Fourth pass: Site name substring matches if results.len() < MAX_RESULTS && term_lc.len() >= 3 { - let net_reader = NETWORK_JSON.read(); - for (idx, n) in net_reader.get_nodes_when_ready().iter().enumerate() { - if results.len() >= MAX_RESULTS { - break; + lqos_network_devices::with_network_json_read(|net_reader| { + for (idx, n) in net_reader.get_nodes_when_ready().iter().enumerate() { + if results.len() >= MAX_RESULTS { + break; + } + if n.name.to_lowercase().contains(&term_lc) { + push_result( + &mut results, + &mut seen, + SearchResult::Site { + idx, + name: n.name.clone(), + }, + MAX_RESULTS, + ); + } } - if n.name.to_lowercase().contains(&term_lc) { - push_result( - &mut results, - &mut seen, - SearchResult::Site { - idx, - name: n.name.clone(), - }, - MAX_RESULTS, - ); - } - } + }); } results diff --git a/src/rust/lqosd/src/node_manager/local_api/shaped_device_api.rs b/src/rust/lqosd/src/node_manager/local_api/shaped_device_api.rs index 411ed7ae..bb559fc9 100644 --- a/src/rust/lqosd/src/node_manager/local_api/shaped_device_api.rs +++ b/src/rust/lqosd/src/node_manager/local_api/shaped_device_api.rs @@ -1,6 +1,5 @@ -use crate::shaped_devices_tracker::SHAPED_DEVICES; use lqos_config::ShapedDevice; pub fn all_shaped_devices_data() -> Vec { - SHAPED_DEVICES.load().devices.clone() + lqos_network_devices::shaped_devices_catalog().clone_all_devices() } diff --git a/src/rust/lqosd/src/node_manager/local_api/shaped_devices_page.rs b/src/rust/lqosd/src/node_manager/local_api/shaped_devices_page.rs index 684159b8..08efe5e3 100644 --- a/src/rust/lqosd/src/node_manager/local_api/shaped_devices_page.rs +++ b/src/rust/lqosd/src/node_manager/local_api/shaped_devices_page.rs @@ -1,4 +1,3 @@ -use crate::shaped_devices_tracker::SHAPED_DEVICES; use lqos_config::ShapedDevice; use serde::{Deserialize, Deserializer, Serialize}; use std::collections::HashSet; @@ -30,6 +29,16 @@ pub struct ShapedDevicesPageQuery { pub page_size: Option, #[serde(default, deserialize_with = "deserialize_optional_string")] pub search: Option, + /// Which inventory surface to display. + #[serde(default)] + pub kind: Option, +} + +/// Which shaped-device inventory source to query. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub enum ShapedDevicesPageKind { + Static, + Dynamic, } /// A server-paged slice of shaped devices plus total result counts. @@ -53,37 +62,48 @@ pub fn shaped_devices_page(query: ShapedDevicesPageQuery) -> ShapedDevicesPage { let page = query.page.unwrap_or(0); let page_size = normalized_page_size(&query); let search = query.search.as_deref().unwrap_or("").trim().to_lowercase(); - let devices = SHAPED_DEVICES.load(); + let kind = query.kind.clone().unwrap_or(ShapedDevicesPageKind::Static); - let mut filtered: Vec = devices - .devices - .iter() - .filter(|device| { - if search.is_empty() { - return true; - } - device.device_name.to_lowercase().contains(&search) - || device.circuit_name.to_lowercase().contains(&search) - || device.parent_node.to_lowercase().contains(&search) - || device.circuit_id.to_lowercase().contains(&search) - || device.device_id.to_lowercase().contains(&search) - || device.mac.to_lowercase().contains(&search) - || device.comment.to_lowercase().contains(&search) - || device - .sqm_override - .as_deref() - .unwrap_or("") - .to_lowercase() - .contains(&search) - || device.ipv4.iter().any(|(addr, prefix)| { - format!("{addr}/{prefix}").to_lowercase().contains(&search) - }) - || device.ipv6.iter().any(|(addr, prefix)| { - format!("{addr}/{prefix}").to_lowercase().contains(&search) - }) - }) - .cloned() - .collect(); + let matches_search = |device: &ShapedDevice| { + if search.is_empty() { + return true; + } + device.device_name.to_lowercase().contains(&search) + || device.circuit_name.to_lowercase().contains(&search) + || device.parent_node.to_lowercase().contains(&search) + || device.circuit_id.to_lowercase().contains(&search) + || device.device_id.to_lowercase().contains(&search) + || device.mac.to_lowercase().contains(&search) + || device.comment.to_lowercase().contains(&search) + || device + .sqm_override + .as_deref() + .unwrap_or("") + .to_lowercase() + .contains(&search) + || device + .ipv4 + .iter() + .any(|(addr, prefix)| format!("{addr}/{prefix}").to_lowercase().contains(&search)) + || device + .ipv6 + .iter() + .any(|(addr, prefix)| format!("{addr}/{prefix}").to_lowercase().contains(&search)) + }; + + let mut filtered: Vec = match kind { + ShapedDevicesPageKind::Static => lqos_network_devices::shaped_devices_catalog() + .iter_devices() + .filter(|device| matches_search(device)) + .cloned() + .collect(), + ShapedDevicesPageKind::Dynamic => lqos_network_devices::dynamic_circuits_snapshot() + .iter() + .map(|circuit| &circuit.shaped) + .filter(|device| matches_search(device)) + .cloned() + .collect(), + }; filtered.sort_by(|left, right| { left.circuit_name .cmp(&right.circuit_name) @@ -114,6 +134,7 @@ pub fn shaped_devices_page(query: ShapedDevicesPageQuery) -> ShapedDevicesPage { } else { query.search }, + kind: Some(kind), }, total_rows, total_circuits, diff --git a/src/rust/lqosd/src/node_manager/local_api/throughput_attribution_debug.rs b/src/rust/lqosd/src/node_manager/local_api/throughput_attribution_debug.rs index d807491b..88f846bb 100644 --- a/src/rust/lqosd/src/node_manager/local_api/throughput_attribution_debug.rs +++ b/src/rust/lqosd/src/node_manager/local_api/throughput_attribution_debug.rs @@ -1,7 +1,3 @@ -use crate::shaped_devices_tracker::{ - SHAPED_DEVICE_HASH_CACHE, SHAPED_DEVICES, ShapedDeviceMatchSource, - shaped_device_match_from_hashes_or_ip, -}; use crate::throughput_tracker::THROUGHPUT_TRACKER; use axum::Json; use serde::Serialize; @@ -9,6 +5,13 @@ use std::time::Duration; const RECENT_WINDOW_SECONDS: u64 = 5 * 60; +#[derive(Clone, Copy, Debug, Serialize, PartialEq, Eq)] +pub(crate) enum ShapedDeviceMatchSource { + DeviceHash, + CircuitHash, + IpFallback, +} + #[derive(Debug, Serialize, PartialEq, Eq)] pub(crate) struct ThroughputAttributionDebug { window_seconds: u64, @@ -61,8 +64,7 @@ pub(crate) fn throughput_attribution_debug_data() -> ThroughputAttributionDebug let now = Duration::from(time_since_boot).as_nanos() as u64; let recent_cutoff_nanos = RECENT_WINDOW_SECONDS * 1_000_000_000; - let shaped = SHAPED_DEVICES.load(); - let cache = SHAPED_DEVICE_HASH_CACHE.load(); + let catalog = lqos_network_devices::network_devices_catalog(); let raw = THROUGHPUT_TRACKER.raw_data.lock(); let mut stats = ThroughputAttributionDebug { @@ -108,13 +110,7 @@ pub(crate) fn throughput_attribution_debug_data() -> ThroughputAttributionDebug stats.recent_entries_missing_both_hashes += 1; } - match shaped_device_match_from_hashes_or_ip( - &shaped, - &cache, - ip, - entry.device_hash, - entry.circuit_hash, - ) { + match match_source_for_entry(&catalog, ip, entry.device_hash, entry.circuit_hash) { Some((_device, ShapedDeviceMatchSource::DeviceHash)) => { stats.recent_entries_resolved_by_device_hash += 1; } @@ -144,3 +140,26 @@ pub(crate) fn throughput_attribution_debug_data() -> ThroughputAttributionDebug stats } + +fn match_source_for_entry<'a>( + catalog: &'a lqos_network_devices::NetworkDevicesCatalog, + ip: &lqos_utils::XdpIpAddress, + device_hash: Option, + circuit_hash: Option, +) -> Option<(&'a lqos_config::ShapedDevice, ShapedDeviceMatchSource)> { + if let Some(hash) = device_hash { + if let Some(device) = catalog.device_by_hashes(Some(hash), None) { + return Some((device, ShapedDeviceMatchSource::DeviceHash)); + } + } + + if let Some(hash) = circuit_hash { + if let Some(device) = catalog.device_by_hashes(None, Some(hash)) { + return Some((device, ShapedDeviceMatchSource::CircuitHash)); + } + } + + catalog + .device_longest_match_for_ip(ip) + .map(|(_net, device)| (device, ShapedDeviceMatchSource::IpFallback)) +} diff --git a/src/rust/lqosd/src/node_manager/local_api/tree_attached_circuits.rs b/src/rust/lqosd/src/node_manager/local_api/tree_attached_circuits.rs index 58001820..886a75e3 100644 --- a/src/rust/lqosd/src/node_manager/local_api/tree_attached_circuits.rs +++ b/src/rust/lqosd/src/node_manager/local_api/tree_attached_circuits.rs @@ -1,10 +1,8 @@ use crate::node_manager::local_api::ethernet_caps::{EthernetCapBadge, ethernet_cap_badge_map}; use crate::shaped_devices_tracker::circuit_live::fresh_circuit_live_snapshot; -use crate::shaped_devices_tracker::{ - NETWORK_JSON, SHAPED_DEVICES, effective_parent_for_circuit, resolve_parent_node_reference, -}; +use crate::shaped_devices_tracker::effective_parent_for_circuit; use fxhash::{FxHashMap, FxHashSet}; -use lqos_config::{ConfigShapedDevices, ShapedDevice}; +use lqos_config::ShapedDevice; use lqos_utils::units::{DownUpOrder, TcpRetransmitSample}; use serde::{Deserialize, Deserializer, Serialize}; use std::net::{Ipv4Addr, Ipv6Addr}; @@ -101,54 +99,55 @@ fn normalized_page_size(query: &TreeAttachedCircuitsQuery) -> usize { .clamp(1, MAX_ATTACHED_CIRCUITS_PAGE_SIZE) } -fn resolve_selected_node_index(query: &TreeAttachedCircuitsQuery) -> Option { - let reader = NETWORK_JSON.read(); - let nodes = reader.get_nodes_when_ready(); - - if let Some(node_id) = query.node_id.as_deref() - && let Some(node) = nodes - .iter() - .position(|node| node.id.as_deref() == Some(node_id)) - { - return Some(node); - } - - let node_path = query.node_path.as_ref()?; - if node_path.is_empty() { - return None; - } - nodes.iter().enumerate().find_map(|(idx, node)| { - let path = if node.name == "Root" { - vec!["Root".to_string()] - } else { - let parent_indexes = if node.parents.is_empty() { - vec![node.immediate_parent.unwrap_or(0)] - } else { - node.parents.clone() - }; - let mut names = Vec::new(); - for idx in parent_indexes { - let parent = nodes.get(idx)?; - names.push(parent.name.clone()); - } - if names.last() != Some(&node.name) { - names.push(node.name.clone()); - } - names - }; - if &path == node_path { Some(idx) } else { None } - }) -} - fn build_selected_node_matcher(query: &TreeAttachedCircuitsQuery) -> Option { - let selected_idx = resolve_selected_node_index(query)?; - let reader = NETWORK_JSON.read(); - let nodes = reader.get_nodes_when_ready(); - let selected = nodes.get(selected_idx)?; + lqos_network_devices::with_network_json_read(|net_json| { + let nodes = net_json.get_nodes_when_ready(); - Some(SelectedNodeMatcher { - node_name: selected.name.clone(), - node_id: selected.id.clone(), + if let Some(node_id) = query.node_id.as_deref() + && let Some(node) = nodes + .iter() + .find(|node| node.id.as_deref() == Some(node_id)) + { + return Some(SelectedNodeMatcher { + node_name: node.name.clone(), + node_id: node.id.clone(), + }); + } + + let node_path = query.node_path.as_ref()?; + if node_path.is_empty() { + return None; + } + + nodes.iter().find_map(|node| { + let path = if node.name == "Root" { + vec!["Root".to_string()] + } else { + let parent_indexes = if node.parents.is_empty() { + vec![node.immediate_parent.unwrap_or(0)] + } else { + node.parents.clone() + }; + let mut names = Vec::new(); + for idx in parent_indexes { + let parent = nodes.get(idx)?; + names.push(parent.name.clone()); + } + if names.last() != Some(&node.name) { + names.push(node.name.clone()); + } + names + }; + + if &path == node_path { + Some(SelectedNodeMatcher { + node_name: node.name.clone(), + node_id: node.id.clone(), + }) + } else { + None + } + }) }) } @@ -192,7 +191,10 @@ fn effective_or_canonical_parent(device: &ShapedDevice) -> Option<(String, Optio return Some((runtime_parent.name, runtime_parent.id)); } - resolve_parent_node_reference(&device.parent_node, device.parent_node_id.as_deref()) + lqos_network_devices::resolve_parent_node_reference( + &device.parent_node, + device.parent_node_id.as_deref(), + ) .map(|resolved| (resolved.name, resolved.id)) .or_else(|| { let trimmed = device.parent_node.trim(); @@ -204,13 +206,13 @@ fn effective_or_canonical_parent(device: &ShapedDevice) -> Option<(String, Optio }) } -fn aggregate_attached_circuit_rows( +fn aggregate_attached_circuit_rows<'a>( matcher: &SelectedNodeMatcher, - shaped_devices: &ConfigShapedDevices, + shaped_devices: impl IntoIterator, ) -> Vec { let mut rows: FxHashMap = FxHashMap::default(); - for device in &shaped_devices.devices { + for device in shaped_devices { if device.circuit_id.trim().is_empty() { continue; } @@ -271,11 +273,11 @@ pub fn tree_attached_circuits(query: TreeAttachedCircuitsQuery) -> TreeAttachedC let snapshot = fresh_circuit_live_snapshot(); let ethernet_badges = ethernet_cap_badge_map(); - let shaped_devices = SHAPED_DEVICES.load(); + let catalog = lqos_network_devices::network_devices_catalog(); let mut rows: Vec = matcher .as_ref() .map(|matcher| { - aggregate_attached_circuit_rows(matcher, &shaped_devices) + aggregate_attached_circuit_rows(matcher, catalog.iter_all_devices()) .into_iter() .map(|row| { let live = snapshot.by_circuit_id.get(&row.circuit_id); @@ -391,6 +393,7 @@ pub fn tree_attached_circuits(query: TreeAttachedCircuitsQuery) -> TreeAttachedC mod tests { use super::*; use std::str::FromStr; + use lqos_config::ConfigShapedDevices; fn sample_device( circuit_id: &str, @@ -471,7 +474,7 @@ mod tests { ..ConfigShapedDevices::default() }; - let rows = aggregate_attached_circuit_rows(&matcher, &shaped_devices); + let rows = aggregate_attached_circuit_rows(&matcher, shaped_devices.devices.iter()); assert_eq!(rows.len(), 1); assert_eq!(rows[0].circuit_id, "circuit-1"); assert_eq!(rows[0].circuit_name, "Circuit One"); diff --git a/src/rust/lqosd/src/node_manager/static2/config_dynamic_circuits.html b/src/rust/lqosd/src/node_manager/static2/config_dynamic_circuits.html new file mode 100644 index 00000000..08cda7db --- /dev/null +++ b/src/rust/lqosd/src/node_manager/static2/config_dynamic_circuits.html @@ -0,0 +1,100 @@ +
+
+ +
+
+
+
+
Dynamic Circuits
+
+ Configure dynamic circuit promotion and default shaping rates for unknown IPs. This section is optional and safe to omit on systems that do not use dynamic circuits yet. +
+
+
+ +
+
+
+
Operation
+
Enable the subsystem and choose retention and promotion behavior.
+ +
+
+
+ + +
Master switch for dynamic circuit tracking and promotion.
+
+
+ +
+ +
+ + seconds +
+
How long dynamic circuit state remains valid without being seen.
+
+
+
+
+ +
+
+
Range Rules
+
Define the IP networks that should receive default rates and an optional topology attachment.
+ +
+
+ + +
When enabled, unknown IP traffic is evaluated against the range rules below.
+
+ +
+ +
+
+ + + +
+ + + + + + + + + + + + +
Name + IP Range + + Min MbpsMax MbpsAttach ToActions
+
+
+
+
+
+ +
+ +
+
+
+ + diff --git a/src/rust/lqosd/src/node_manager/static2/node_manager.css b/src/rust/lqosd/src/node_manager/static2/node_manager.css index a6afb409..88007e43 100644 --- a/src/rust/lqosd/src/node_manager/static2/node_manager.css +++ b/src/rust/lqosd/src/node_manager/static2/node_manager.css @@ -2218,6 +2218,54 @@ table tr td a { color: var(--bs-secondary-color) !important; } +/* Responsive inline tables for config editors */ + +@media (max-width: 767.98px) { + .lqos-config-responsive-table thead { + display: none; + } + + .lqos-config-responsive-table tbody tr { + display: block; + border: 1px solid var(--lqos-border); + border-radius: var(--lqos-radius-md); + background: color-mix(in srgb, var(--lqos-surface) 70%, transparent); + padding: 0.65rem 0.75rem; + margin-bottom: 0.75rem; + } + + .lqos-config-responsive-table tbody td { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + padding: 0.35rem 0; + border: 0; + } + + .lqos-config-responsive-table tbody td::before { + content: attr(data-label); + flex: 0 0 7.25rem; + color: var(--bs-secondary-color); + font-weight: 600; + font-size: 0.85rem; + } + + .lqos-config-responsive-table tbody td > * { + flex: 1 1 auto; + min-width: 0; + } + + .lqos-config-responsive-table tbody td[colspan] { + display: block; + padding: 0.25rem 0; + } + + .lqos-config-responsive-table tbody td[colspan]::before { + content: ""; + } +} + .lqos-config-network-editor { display: grid; gap: 0.9rem; diff --git a/src/rust/lqosd/src/node_manager/static2/shaped_devices.html b/src/rust/lqosd/src/node_manager/static2/shaped_devices.html index 3ecd8a54..db1f9d8a 100644 --- a/src/rust/lqosd/src/node_manager/static2/shaped_devices.html +++ b/src/rust/lqosd/src/node_manager/static2/shaped_devices.html @@ -1,6 +1,22 @@
-
Shaped Devices ? ?
+
+ Circuits + ? + ? +
+
@@ -9,4 +25,4 @@
- \ No newline at end of file + diff --git a/src/rust/lqosd/src/node_manager/static_pages.rs b/src/rust/lqosd/src/node_manager/static_pages.rs index f59fd50c..ab8286a2 100644 --- a/src/rust/lqosd/src/node_manager/static_pages.rs +++ b/src/rust/lqosd/src/node_manager/static_pages.rs @@ -65,6 +65,7 @@ pub(super) fn static_routes() -> Result { "config_lts.html", "config_iprange.html", "config_flows.html", + "config_dynamic_circuits.html", "config_integration.html", "config_splynx.html", "config_netzur.html", diff --git a/src/rust/lqosd/src/node_manager/ws/single_user_channels/circuit.rs b/src/rust/lqosd/src/node_manager/ws/single_user_channels/circuit.rs index 1ca8b94a..b5a1eccd 100644 --- a/src/rust/lqosd/src/node_manager/ws/single_user_channels/circuit.rs +++ b/src/rust/lqosd/src/node_manager/ws/single_user_channels/circuit.rs @@ -2,33 +2,32 @@ use crate::node_manager::local_api::circuit_activity::{CircuitSummaryData, circu use crate::node_manager::ws::messages::{CircuitDevicesResult, WsResponse, encode_ws_message}; use crate::node_manager::ws::ticker::all_circuits; use crate::rtt_exclusions; -use crate::shaped_devices_tracker::SHAPED_DEVICES; use crate::throughput_tracker::{circuit_current_qoo, circuit_current_rtt_p50_nanos}; use lqos_bus::{BusRequest, Circuit}; +use lqos_utils::hash_to_i64; use lqos_utils::units::{DownUpOrder, down_up_retransmit_sample}; use std::time::Duration; use tokio::time::MissedTickBehavior; use tracing::info; fn qoo_score_for_circuit(circuit: &str) -> Option { - circuit_hash_for_id(circuit).and_then(|hash| { - let qoo = circuit_current_qoo(hash); - match (qoo.down, qoo.up) { - (Some(d), Some(u)) => Some(d.min(u)), - (Some(d), None) => Some(d), - (None, Some(u)) => Some(u), - (None, None) => None, - } - }) + let circuit_hash = circuit_hash_for_id(circuit)?; + let qoo = circuit_current_qoo(circuit_hash); + match (qoo.down, qoo.up) { + (Some(d), Some(u)) => Some(d.min(u)), + (Some(d), None) => Some(d), + (None, Some(u)) => Some(u), + (None, None) => None, + } } fn circuit_hash_for_id(circuit: &str) -> Option { - let shaped = SHAPED_DEVICES.load(); - shaped - .devices - .iter() - .find(|d| d.circuit_id == circuit) - .map(|d| d.circuit_hash) + let trimmed = circuit.trim(); + if trimmed.is_empty() { + None + } else { + Some(hash_to_i64(trimmed)) + } } fn summarize_circuit_devices(circuit: &str, devices: &[Circuit]) -> CircuitSummaryData { diff --git a/src/rust/lqosd/src/node_manager/ws/ticker/bakery.rs b/src/rust/lqosd/src/node_manager/ws/ticker/bakery.rs index d3e6bc55..037697f4 100644 --- a/src/rust/lqosd/src/node_manager/ws/ticker/bakery.rs +++ b/src/rust/lqosd/src/node_manager/ws/ticker/bakery.rs @@ -5,7 +5,6 @@ use crate::node_manager::ws::messages::{ }; use crate::node_manager::ws::publish_subscribe::PubSub; use crate::node_manager::ws::published_channels::PublishedChannels; -use crate::shaped_devices_tracker::NETWORK_JSON; use lqos_bakery::{ BakeryActivityEntry as BakeryActivitySnapshot, BakeryApplyType, BakeryMode, BakeryPreflightSnapshot, BakeryRuntimeNodeOperationAction, BakeryRuntimeNodeOperationStatus, @@ -79,11 +78,12 @@ fn map_preflight(snapshot: BakeryPreflightSnapshot) -> BakeryPreflightData { } fn resolve_site_name(site_hash: i64) -> Option { - let reader = NETWORK_JSON.read(); - reader - .get_nodes_when_ready() - .iter() - .find_map(|node| (hash_to_i64(&node.name) == site_hash).then(|| node.name.clone())) + lqos_network_devices::with_network_json_read(|net_json| { + net_json + .get_nodes_when_ready() + .iter() + .find_map(|node| (hash_to_i64(&node.name) == site_hash).then(|| node.name.clone())) + }) } fn resolve_runtime_site_name(site_hash: i64) -> Option { diff --git a/src/rust/lqosd/src/node_manager/ws/ticker/circuit_capacity.rs b/src/rust/lqosd/src/node_manager/ws/ticker/circuit_capacity.rs index b1834423..f05da9be 100644 --- a/src/rust/lqosd/src/node_manager/ws/ticker/circuit_capacity.rs +++ b/src/rust/lqosd/src/node_manager/ws/ticker/circuit_capacity.rs @@ -1,7 +1,6 @@ use crate::node_manager::ws::messages::{CircuitCapacityRow, WsResponse}; use crate::node_manager::ws::publish_subscribe::PubSub; use crate::node_manager::ws::published_channels::PublishedChannels; -use crate::shaped_devices_tracker::SHAPED_DEVICES; use crate::throughput_tracker::THROUGHPUT_TRACKER; use lqos_utils::units::DownUpOrder; use std::collections::HashMap; @@ -47,14 +46,13 @@ pub async fn circuit_capacity(channels: Arc) { }); // Map circuits to capacities - let shaped_devices = SHAPED_DEVICES.load(); + let catalog = lqos_network_devices::network_devices_catalog(); let capacities: Vec = { circuits .iter() .filter_map(|(circuit_id, accumulator)| { - if let Some(device) = shaped_devices - .devices - .iter() + if let Some(device) = catalog + .iter_all_devices() .find(|sd| sd.circuit_id == *circuit_id) { let down_mbps = (accumulator.bytes.down as f64 * 8.0) / 1_000_000.0; diff --git a/src/rust/lqosd/src/node_manager/ws/ticker/ipstats_conversion.rs b/src/rust/lqosd/src/node_manager/ws/ticker/ipstats_conversion.rs index 802133a9..4126631c 100644 --- a/src/rust/lqosd/src/node_manager/ws/ticker/ipstats_conversion.rs +++ b/src/rust/lqosd/src/node_manager/ws/ticker/ipstats_conversion.rs @@ -1,6 +1,6 @@ -use crate::shaped_devices_tracker::SHAPED_DEVICES; use lqos_bus::{IpStats, TcHandle}; -use lqos_config::{ConfigShapedDevices, ShapedDevice}; +use lqos_config::ShapedDevice; +use lqos_network_devices::NetworkDevicesCatalog; use lqos_utils::XdpIpAddress; use lqos_utils::units::{DownUpOrder, TcpRetransmitSample}; use serde::{Deserialize, Serialize}; @@ -12,19 +12,20 @@ fn truncate_by_chars(input: &str, max_chars: usize) -> String { fn shaped_device_for_ip_stats<'a>( stat: &IpStats, - shaped_devices: &'a ConfigShapedDevices, + catalog: &'a NetworkDevicesCatalog, ) -> Option<&'a ShapedDevice> { if !stat.circuit_id.is_empty() - && let Some(circuit) = shaped_devices - .devices - .iter() + && let Some(circuit) = catalog + .iter_all_devices() .find(|sd| sd.circuit_id == stat.circuit_id) { return Some(circuit); } let ip = stat.ip_address.parse::().ok()?; - shaped_devices.get_device_from_ip(&XdpIpAddress::from_ip(ip)) + catalog + .device_longest_match_for_ip(&XdpIpAddress::from_ip(ip)) + .map(|(_net, device)| device) } #[derive(Serialize, Deserialize, Clone, Debug)] @@ -52,8 +53,8 @@ impl From<&IpStats> for IpStatsWithPlan { tcp_retransmit_sample: i.tcp_retransmit_sample, }; - let shaped_devices_reader = SHAPED_DEVICES.load(); - if let Some(circuit) = shaped_device_for_ip_stats(i, &shaped_devices_reader) { + let catalog = lqos_network_devices::network_devices_catalog(); + if let Some(circuit) = shaped_device_for_ip_stats(i, &catalog) { if result.circuit_id.is_empty() { result.circuit_id = circuit.circuit_id.clone(); } @@ -80,6 +81,7 @@ mod tests { use lqos_config::{ConfigShapedDevices, ShapedDevice}; use lqos_utils::units::{DownUpOrder, TcpRetransmitSample}; use std::net::Ipv4Addr; + use std::sync::Arc; #[test] fn truncates_ascii_to_exact_length() { @@ -114,6 +116,13 @@ mod tests { ..Default::default() }]); + let shaped_catalog = + lqos_network_devices::ShapedDevicesCatalog::from_shaped_devices(Arc::new(shaped)); + let catalog = lqos_network_devices::NetworkDevicesCatalog::from_snapshots( + shaped_catalog, + Arc::new(Vec::new()), + ); + let stat = IpStats { ip_address: "192.168.1.10".to_string(), circuit_id: String::new(), @@ -127,7 +136,7 @@ mod tests { ), }; - let matched = shaped_device_for_ip_stats(&stat, &shaped).expect("lookup should resolve"); + let matched = shaped_device_for_ip_stats(&stat, &catalog).expect("lookup should resolve"); assert_eq!(matched.circuit_id, "circuit-1"); } } diff --git a/src/rust/lqosd/src/node_manager/ws/ticker/tree_capacity.rs b/src/rust/lqosd/src/node_manager/ws/ticker/tree_capacity.rs index 79be5824..6b680c25 100644 --- a/src/rust/lqosd/src/node_manager/ws/ticker/tree_capacity.rs +++ b/src/rust/lqosd/src/node_manager/ws/ticker/tree_capacity.rs @@ -1,7 +1,7 @@ use crate::node_manager::ws::messages::{NodeCapacity, WsResponse}; use crate::node_manager::ws::publish_subscribe::PubSub; use crate::node_manager::ws::published_channels::PublishedChannels; -use crate::shaped_devices_tracker::{NETWORK_JSON, node_to_transport}; +use crate::shaped_devices_tracker::node_to_transport; use std::sync::Arc; pub async fn tree_capacity(channels: Arc) { @@ -12,40 +12,42 @@ pub async fn tree_capacity(channels: Arc) { return; } let capacities: Vec = { - let net_json = NETWORK_JSON.read(); - net_json - .get_nodes_when_ready() - .iter() - .enumerate() - .map(|(id, node)| { - let node = node_to_transport(node); - let down = node.current_throughput.0 as f64 * 8.0 / 1_000_000.0; - let up = node.current_throughput.1 as f64 * 8.0 / 1_000_000.0; - let effective_max = node.effective_max_throughput.unwrap_or(node.max_throughput); - let max_down = effective_max.0; - let max_up = effective_max.1; - let median_rtt = if node.rtts.is_empty() { - 0.0 - } else { - let n = node.rtts.len() / 2; - if node.rtts.len().is_multiple_of(2) { - (node.rtts[n - 1] + node.rtts[n]) / 2.0 + lqos_network_devices::with_network_json_read(|net_json| { + net_json + .get_nodes_when_ready() + .iter() + .enumerate() + .map(|(id, node)| { + let node = node_to_transport(node); + let down = node.current_throughput.0 as f64 * 8.0 / 1_000_000.0; + let up = node.current_throughput.1 as f64 * 8.0 / 1_000_000.0; + let effective_max = + node.effective_max_throughput.unwrap_or(node.max_throughput); + let max_down = effective_max.0; + let max_up = effective_max.1; + let median_rtt = if node.rtts.is_empty() { + 0.0 } else { - node.rtts[n] - } - }; + let n = node.rtts.len() / 2; + if node.rtts.len().is_multiple_of(2) { + (node.rtts[n - 1] + node.rtts[n]) / 2.0 + } else { + node.rtts[n] + } + }; - NodeCapacity { - id, - name: node.name.clone(), - down, - up, - max_down, - max_up, - median_rtt, - } - }) - .collect() + NodeCapacity { + id, + name: node.name.clone(), + down, + up, + max_down, + max_up, + median_rtt, + } + }) + .collect() + }) }; let message = WsResponse::TreeCapacity { data: capacities }; diff --git a/src/rust/lqosd/src/node_manager/ws/ticker/tree_summary_l2.rs b/src/rust/lqosd/src/node_manager/ws/ticker/tree_summary_l2.rs index beb35304..b6fcbcd7 100644 --- a/src/rust/lqosd/src/node_manager/ws/ticker/tree_summary_l2.rs +++ b/src/rust/lqosd/src/node_manager/ws/ticker/tree_summary_l2.rs @@ -1,7 +1,7 @@ use crate::node_manager::ws::messages::WsResponse; use crate::node_manager::ws::publish_subscribe::PubSub; use crate::node_manager::ws::published_channels::PublishedChannels; -use crate::shaped_devices_tracker::{NETWORK_JSON, node_to_transport}; +use crate::shaped_devices_tracker::node_to_transport; use lqos_config::NetworkJsonTransport; use std::collections::BTreeMap; use std::sync::Arc; @@ -24,38 +24,39 @@ pub async fn tree_summary_l2(channels: Arc) { // Build top-N second-level children across all first-level parents let grouped: Vec<(usize, Vec<(usize, NetworkJsonTransport)>)> = { - let net_json = NETWORK_JSON.read(); - let nodes = net_json.get_nodes_when_ready(); - // Collect candidates as (parent_idx, child_idx, transport, total_bytes_per_sec) - let mut candidates: Vec<(usize, usize, NetworkJsonTransport, u64)> = Vec::new(); + lqos_network_devices::with_network_json_read(|net_json| { + let nodes = net_json.get_nodes_when_ready(); + // Collect candidates as (parent_idx, child_idx, transport, total_bytes_per_sec) + let mut candidates: Vec<(usize, usize, NetworkJsonTransport, u64)> = Vec::new(); - // Identify first-level parents (immediate_parent == Some(0)) - for (p_idx, p_node) in nodes.iter().enumerate() { - if p_node.immediate_parent == Some(0) { - // For each child-of-child under this parent - for (c_idx, c_node) in nodes.iter().enumerate() { - if c_node.immediate_parent == Some(p_idx) { - let t = node_to_transport(c_node); - let total = t.current_throughput.0 + t.current_throughput.1; - candidates.push((p_idx, c_idx, t, total)); + // Identify first-level parents (immediate_parent == Some(0)) + for (p_idx, p_node) in nodes.iter().enumerate() { + if p_node.immediate_parent == Some(0) { + // For each child-of-child under this parent + for (c_idx, c_node) in nodes.iter().enumerate() { + if c_node.immediate_parent == Some(p_idx) { + let t = node_to_transport(c_node); + let total = t.current_throughput.0 + t.current_throughput.1; + candidates.push((p_idx, c_idx, t, total)); + } } } } - } - // Sort by total throughput descending and cap to N - candidates.sort_by(|a, b| b.3.cmp(&a.3)); - let n: usize = 10; - if candidates.len() > n { - candidates.truncate(n); - } + // Sort by total throughput descending and cap to N + candidates.sort_by(|a, b| b.3.cmp(&a.3)); + let n: usize = 10; + if candidates.len() > n { + candidates.truncate(n); + } - // Group by parent id - let mut map: BTreeMap> = BTreeMap::new(); - for (p_idx, c_idx, t, _total) in candidates.into_iter() { - map.entry(p_idx).or_default().push((c_idx, t)); - } - map.into_iter().collect() + // Group by parent id + let mut map: BTreeMap> = BTreeMap::new(); + for (p_idx, c_idx, t, _total) in candidates.into_iter() { + map.entry(p_idx).or_default().push((c_idx, t)); + } + map.into_iter().collect() + }) }; let message = WsResponse::TreeSummaryL2 { data: grouped }; diff --git a/src/rust/lqosd/src/shaped_devices_tracker/circuit_live.rs b/src/rust/lqosd/src/shaped_devices_tracker/circuit_live.rs index 38c06261..1bf4ad6b 100644 --- a/src/rust/lqosd/src/shaped_devices_tracker/circuit_live.rs +++ b/src/rust/lqosd/src/shaped_devices_tracker/circuit_live.rs @@ -9,10 +9,7 @@ use std::net::IpAddr; use std::sync::Arc; use std::time::{SystemTime, UNIX_EPOCH}; -use super::{ - CIRCUIT_LIVE_LAST_REFRESH_SECS, CIRCUIT_LIVE_REFRESH_LOCK, CIRCUIT_LIVE_SNAPSHOT, - SHAPED_DEVICE_HASH_CACHE, SHAPED_DEVICES, shaped_device_from_hashes_or_ip, -}; +use super::{CIRCUIT_LIVE_LAST_REFRESH_SECS, CIRCUIT_LIVE_REFRESH_LOCK, CIRCUIT_LIVE_SNAPSHOT}; /// Per-circuit live metrics aggregated from the device-level throughput tracker. #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] @@ -87,18 +84,13 @@ pub fn rebuild_circuit_live_snapshot() -> Arc { }; let kernel_now: std::time::Duration = kernel_now.into(); - let shaped_devices = SHAPED_DEVICES.load(); - let cache = SHAPED_DEVICE_HASH_CACHE.load(); + let catalog = lqos_network_devices::network_devices_catalog(); let mut by_circuit_id: FxHashMap = FxHashMap::default(); for (ip_key, data) in THROUGHPUT_TRACKER.raw_data.lock().iter() { - let device = shaped_device_from_hashes_or_ip( - &shaped_devices, - &cache, - ip_key, - data.device_hash, - data.circuit_hash, - ); + let device = catalog + .device_by_hashes(data.device_hash, data.circuit_hash) + .or_else(|| catalog.device_longest_match_for_ip(ip_key).map(|(_, dev)| dev)); let Some(device) = device else { continue; }; diff --git a/src/rust/lqosd/src/shaped_devices_tracker/mod.rs b/src/rust/lqosd/src/shaped_devices_tracker/mod.rs index 00ff1ab8..606afa56 100644 --- a/src/rust/lqosd/src/shaped_devices_tracker/mod.rs +++ b/src/rust/lqosd/src/shaped_devices_tracker/mod.rs @@ -1,17 +1,15 @@ use crate::node_manager::local_api::network_tree_lite::NetworkTreeLiteNode; use crate::treeguard::actor::is_runtime_virtualized_node; -use anyhow::Result; +use anyhow::{Context, Result}; use arc_swap::ArcSwap; use fxhash::{FxHashMap, FxHashSet}; use lqos_bus::{BusResponse, Circuit}; use lqos_config::{ ConfigShapedDevices, NetworkJsonNode, NetworkJsonTransport, ShapedDevice, - TopologyRuntimeStatusFile, TopologyShapingInputsFile, load_config, topology_import_path, - topology_runtime_status_path, + TopologyRuntimeStatusFile, TopologyShapingInputsFile, load_config, + topology_runtime_status_path, topology_shaping_inputs_path, }; use lqos_queue_tracker::EFFECTIVE_NODE_RATES; -use lqos_topology_compile::TopologyImportFile; -use lqos_utils::XdpIpAddress; use lqos_utils::file_watcher::FileWatcher; use lqos_utils::hash_to_i64; use lqos_utils::rtt::{FlowbeeEffectiveDirection, RttBucket}; @@ -26,82 +24,10 @@ use std::sync::atomic::AtomicU64; use std::time::Duration; use tracing::{debug, error, info, warn}; -// Removed rate_for_plan() function - no longer needed with f32 plan structures -const SHAPED_DEVICES_RELOAD_RETRY_DELAY_MS: u64 = 500; -const SHAPED_DEVICES_RELOAD_ATTEMPTS: usize = 2; - pub mod circuit_live; -mod netjson; use crate::throughput_tracker::THROUGHPUT_TRACKER; pub use circuit_live::CircuitLiveSnapshot; -pub use netjson::*; -pub static SHAPED_DEVICES: Lazy> = - Lazy::new(|| ArcSwap::new(Arc::new(ConfigShapedDevices::default()))); - -#[derive(Debug, Default)] -pub struct ShapedDeviceHashCache { - by_device_hash: FxHashMap, - by_circuit_hash: FxHashMap, -} - -impl ShapedDeviceHashCache { - fn from_devices(devices: &[ShapedDevice]) -> Self { - let mut by_device_hash = FxHashMap::default(); - by_device_hash.reserve(devices.len()); - let mut by_circuit_hash = FxHashMap::default(); - by_circuit_hash.reserve(devices.len()); - for (idx, dev) in devices.iter().enumerate() { - by_device_hash.insert(dev.device_hash, idx); - by_circuit_hash.entry(dev.circuit_hash).or_insert(idx); - } - Self { - by_device_hash, - by_circuit_hash, - } - } - - pub fn index_by_device_hash( - &self, - shaped: &ConfigShapedDevices, - device_hash: i64, - ) -> Option { - if let Some(idx) = self.by_device_hash.get(&device_hash).copied() - && shaped - .devices - .get(idx) - .is_some_and(|d| d.device_hash == device_hash) - { - return Some(idx); - } - shaped - .devices - .iter() - .position(|d| d.device_hash == device_hash) - } - - pub fn index_by_circuit_hash( - &self, - shaped: &ConfigShapedDevices, - circuit_hash: i64, - ) -> Option { - if let Some(idx) = self.by_circuit_hash.get(&circuit_hash).copied() - && shaped - .devices - .get(idx) - .is_some_and(|d| d.circuit_hash == circuit_hash) - { - return Some(idx); - } - shaped - .devices - .iter() - .position(|d| d.circuit_hash == circuit_hash) - } -} - -pub static SHAPED_DEVICE_HASH_CACHE: Lazy> = - Lazy::new(|| ArcSwap::new(Arc::new(ShapedDeviceHashCache::default()))); pub static CIRCUIT_LIVE_SNAPSHOT: Lazy> = Lazy::new(|| ArcSwap::new(Arc::new(CircuitLiveSnapshot::default()))); pub static CIRCUIT_LIVE_LAST_REFRESH_SECS: AtomicU64 = AtomicU64::new(0); @@ -109,13 +35,6 @@ pub static CIRCUIT_LIVE_REFRESH_LOCK: Lazy> = Lazy::new(|| Mutex::new( pub static EFFECTIVE_CIRCUIT_PARENTS: Lazy>> = Lazy::new(|| ArcSwap::new(Arc::new(FxHashMap::default()))); -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(crate) enum ShapedDeviceMatchSource { - DeviceHash, - CircuitHash, - IpFallback, -} - #[derive(Clone, Debug, PartialEq, Eq)] pub struct RuntimeCircuitParent { pub name: String, @@ -130,43 +49,6 @@ pub(crate) fn invalidate_executive_cache_snapshot() { crate::node_manager::invalidate_executive_cache_snapshot(); } -pub(crate) fn shaped_device_match_from_hashes_or_ip<'a>( - shaped: &'a ConfigShapedDevices, - cache: &ShapedDeviceHashCache, - ip: &XdpIpAddress, - device_hash: Option, - circuit_hash: Option, -) -> Option<(&'a ShapedDevice, ShapedDeviceMatchSource)> { - if let Some(device_hash) = device_hash - && let Some(idx) = cache.index_by_device_hash(shaped, device_hash) - && let Some(device) = shaped.devices.get(idx) - { - return Some((device, ShapedDeviceMatchSource::DeviceHash)); - } - - if let Some(circuit_hash) = circuit_hash - && let Some(idx) = cache.index_by_circuit_hash(shaped, circuit_hash) - && let Some(device) = shaped.devices.get(idx) - { - return Some((device, ShapedDeviceMatchSource::CircuitHash)); - } - - shaped - .get_device_from_ip(ip) - .map(|device| (device, ShapedDeviceMatchSource::IpFallback)) -} - -pub(crate) fn shaped_device_from_hashes_or_ip<'a>( - shaped: &'a ConfigShapedDevices, - cache: &ShapedDeviceHashCache, - ip: &XdpIpAddress, - device_hash: Option, - circuit_hash: Option, -) -> Option<&'a ShapedDevice> { - shaped_device_match_from_hashes_or_ip(shaped, cache, ip, device_hash, circuit_hash) - .map(|(device, _)| device) -} - fn normalize_circuit_id_key(circuit_id: &str) -> Option { let trimmed = circuit_id.trim(); if trimmed.is_empty() { @@ -226,6 +108,26 @@ fn active_runtime_shaping_inputs_path(config: &lqos_config::Config) -> Result Result> { + if let Some(active_path) = active_runtime_shaping_inputs_path(config)? { + if active_path.exists() { + let raw = std::fs::read_to_string(&active_path)?; + let shaping_inputs = serde_json::from_str(&raw)?; + return Ok(Some(shaping_inputs)); + } + } + + let fallback_path = topology_shaping_inputs_path(config); + if !fallback_path.exists() { + return Ok(None); + } + let raw = std::fs::read_to_string(&fallback_path)?; + let shaping_inputs = serde_json::from_str(&raw)?; + Ok(Some(shaping_inputs)) +} + fn parse_ipv4_entry(value: &str) -> Option<(Ipv4Addr, u32)> { let trimmed = value.trim(); if trimmed.is_empty() { @@ -308,6 +210,89 @@ fn shaped_devices_from_runtime_inputs( shaped } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum ShapedDevicesLoadSource { + RuntimeShapingInputs, + TopologyImport, + ShapedDevicesCsv, +} + +fn integration_ingress_enabled(config: &lqos_config::Config) -> bool { + config.uisp_integration.enable_uisp + || config.splynx_integration.enable_splynx + || config + .netzur_integration + .as_ref() + .is_some_and(|integration| integration.enable_netzur) + || config + .visp_integration + .as_ref() + .is_some_and(|integration| integration.enable_visp) + || config.powercode_integration.enable_powercode + || config.sonar_integration.enable_sonar + || config + .wispgate_integration + .as_ref() + .is_some_and(|integration| integration.enable_wispgate) +} + +fn load_ready_runtime_shaping_inputs( + config: &lqos_config::Config, +) -> Result> { + let Some(active_path) = active_runtime_shaping_inputs_path(config)? else { + return Ok(None); + }; + if !active_path.exists() { + return Ok(None); + } + let raw = std::fs::read_to_string(&active_path) + .with_context(|| format!("Unable to read shaping inputs at {}", active_path.display()))?; + let shaping_inputs = serde_json::from_str(&raw).with_context(|| { + format!( + "Unable to decode shaping inputs JSON at {}", + active_path.display() + ) + })?; + Ok(Some(shaping_inputs)) +} + +fn load_shaped_devices_from_preferred_source( + config: &lqos_config::Config, +) -> Result<(ConfigShapedDevices, ShapedDevicesLoadSource)> { + if let Some(shaping_inputs) = load_ready_runtime_shaping_inputs(config)? { + return Ok(( + shaped_devices_from_runtime_inputs(&shaping_inputs), + ShapedDevicesLoadSource::RuntimeShapingInputs, + )); + } + + if integration_ingress_enabled(config) { + match lqos_topology_compile::TopologyImportFile::load(config) { + Ok(Some(topology_import)) => { + let shaped_devices = topology_import.into_imported_bundle().shaped_devices; + if !shaped_devices.devices.is_empty() { + return Ok((shaped_devices, ShapedDevicesLoadSource::TopologyImport)); + } + debug!( + "topology_import.json contained 0 shaped devices; falling back to ShapedDevices.csv" + ); + } + Ok(None) => { + debug!("topology_import.json missing; falling back to ShapedDevices.csv"); + } + Err(err) => { + debug!( + "Unable to load topology_import.json ({err}); falling back to ShapedDevices.csv" + ); + } + } + } + + let shaped_devices = ConfigShapedDevices::load_for_config(config) + .context("Unable to load ShapedDevices.csv")?; + Ok((shaped_devices, ShapedDevicesLoadSource::ShapedDevicesCsv)) +} + fn load_shaping_inputs() { let Ok(config) = load_config() else { warn!("Unable to load LibreQoS config while loading shaping_inputs.json"); @@ -393,7 +378,7 @@ fn node_to_transport_with_summary( fn build_network_tree_summaries( nodes: &[NetworkJsonNode], - shaped_devices: &ConfigShapedDevices, + shaped_devices: &lqos_network_devices::NetworkDevicesCatalog, ) -> Vec { let mut summaries = vec![NetworkTreeSummary::default(); nodes.len()]; let mut direct_circuits = vec![FxHashSet::default(); nodes.len()]; @@ -404,7 +389,7 @@ fn build_network_tree_summaries( node_index_by_name.entry(node.name.as_str()).or_insert(idx); } - for device in &shaped_devices.devices { + for device in shaped_devices.iter_all_devices() { let Some(node_idx) = node_index_by_name.get(device.parent_node.as_str()).copied() else { continue; }; @@ -436,226 +421,61 @@ fn build_network_tree_summaries( summaries } -fn publish_shaped_devices(new_file: ConfigShapedDevices) { - debug!("ShapedDevices.csv loaded"); - let cache = ShapedDeviceHashCache::from_devices(&new_file.devices); - SHAPED_DEVICES.store(Arc::new(new_file)); - SHAPED_DEVICE_HASH_CACHE.store(Arc::new(cache)); - invalidate_circuit_live_snapshot(); - invalidate_executive_cache_snapshot(); - let nj = NETWORK_JSON.read(); - crate::throughput_tracker::THROUGHPUT_TRACKER.refresh_circuit_ids(&nj); -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum ShapedDevicesLoadSource { - RuntimeShapingInputs, - TopologyImport, - ShapedDevicesCsv, -} - -impl ShapedDevicesLoadSource { - const fn label(self) -> &'static str { - match self { - Self::RuntimeShapingInputs => "runtime shaping inputs", - Self::TopologyImport => "topology_import.json", - Self::ShapedDevicesCsv => "ShapedDevices.csv", - } - } -} - -fn integration_ingress_enabled(config: &lqos_config::Config) -> bool { - config.uisp_integration.enable_uisp - || config.splynx_integration.enable_splynx - || config - .netzur_integration - .as_ref() - .is_some_and(|integration| integration.enable_netzur) - || config - .visp_integration - .as_ref() - .is_some_and(|integration| integration.enable_visp) - || config.powercode_integration.enable_powercode - || config.sonar_integration.enable_sonar - || config - .wispgate_integration - .as_ref() - .is_some_and(|integration| integration.enable_wispgate) -} - -fn load_active_runtime_shaping_inputs( - config: &lqos_config::Config, -) -> Result> { - let Some(active_path) = active_runtime_shaping_inputs_path(config)? else { - return Ok(None); - }; - if !active_path.exists() { - return Ok(None); - } - - let raw = std::fs::read_to_string(&active_path)?; - let shaping_inputs = serde_json::from_str(&raw)?; - Ok(Some(shaping_inputs)) -} - -fn load_shaped_devices_from_preferred_source( - config: &lqos_config::Config, -) -> Result<(ConfigShapedDevices, ShapedDevicesLoadSource)> { - if let Some(shaping_inputs) = load_active_runtime_shaping_inputs(config)? { - return Ok(( - shaped_devices_from_runtime_inputs(&shaping_inputs), - ShapedDevicesLoadSource::RuntimeShapingInputs, - )); - } - - if integration_ingress_enabled(config) - && let Some(topology_import) = TopologyImportFile::load(config)? - { - return Ok(( - topology_import.into_imported_bundle().shaped_devices, - ShapedDevicesLoadSource::TopologyImport, - )); - } - Ok(( - ConfigShapedDevices::load_for_config(config)?, - ShapedDevicesLoadSource::ShapedDevicesCsv, - )) -} - -fn load_shaped_devices() { - debug!("Shaped-device ingress has changed. Attempting to load it."); - let Ok(config) = load_config() else { - warn!("Unable to load LibreQoS config while loading shaped devices"); - return; - }; - for attempt in 1..=SHAPED_DEVICES_RELOAD_ATTEMPTS { - match load_shaped_devices_from_preferred_source(config.as_ref()) { - Ok((new_file, source)) => { - debug!("Loaded shaped devices from {}", source.label()); - publish_shaped_devices(new_file); - return; - } - Err(err) => { - if attempt < SHAPED_DEVICES_RELOAD_ATTEMPTS { - warn!( - "Shaped-device ingress reload attempt {attempt}/{} failed: {err}. Retrying after {} ms.", - SHAPED_DEVICES_RELOAD_ATTEMPTS, SHAPED_DEVICES_RELOAD_RETRY_DELAY_MS - ); - std::thread::sleep(Duration::from_millis(SHAPED_DEVICES_RELOAD_RETRY_DELAY_MS)); - } else { - let current = SHAPED_DEVICES.load(); - warn!( - "Shaped-device ingress reload failed after {} attempts: {err}. Keeping last-known-good data with {} devices.", - SHAPED_DEVICES_RELOAD_ATTEMPTS, - current.devices.len() - ); - } - } - } - } -} - -pub fn shaped_devices_watcher() -> Result<()> { - std::thread::Builder::new() - .name("ShapedDevices Watcher".to_string()) - .spawn(|| { - debug!("Watching for shaped-device ingress changes"); - if let Err(e) = watch_for_shaped_devices_changing() { - error!("Error watching for shaped-device ingress: {:?}", e); - } - })?; - Ok(()) -} - -fn watch_shaped_devices_hint_path(watch_name: &str, watch_path: PathBuf) -> Result<()> { - let mut watcher = FileWatcher::new(watch_name, watch_path); - watcher.set_file_exists_callback(load_shaped_devices); - watcher.set_file_created_callback(load_shaped_devices); - watcher.set_file_changed_callback(load_shaped_devices); - loop { - let result = watcher.watch(); - info!("{watch_name} watcher returned: {result:?}"); - } -} - -/// Watches runtime status and source-ingress hints, then reevaluates the preferred shaped-device -/// source. The reload path always prefers active runtime shaping inputs over source ingress files. -fn watch_for_shaped_devices_changing() -> Result<()> { - let config = load_config()?; - let runtime_status_path = topology_runtime_status_path(config.as_ref()); - let csv_path = ConfigShapedDevices::path_for_config(config.as_ref()); - let topology_import = topology_import_path(config.as_ref()); - - std::thread::Builder::new() - .name("ShapedDevices Runtime Watcher".to_string()) - .spawn(move || { - if let Err(err) = - watch_shaped_devices_hint_path("topology_runtime_status.json", runtime_status_path) - { - error!("Error watching topology_runtime_status.json: {err:?}"); - } - })?; - - std::thread::Builder::new() - .name("ShapedDevices CSV Hint Watcher".to_string()) - .spawn(move || { - if let Err(err) = watch_shaped_devices_hint_path("ShapedDevices.csv", csv_path) { - error!("Error watching ShapedDevices.csv: {err:?}"); - } - })?; - - watch_shaped_devices_hint_path("topology_import.json", topology_import) -} pub fn get_one_network_map_layer(parent_idx: usize) -> BusResponse { - let net_json = NETWORK_JSON.read(); - let nodes_ref = net_json.get_nodes_when_ready(); - let shaped_devices = SHAPED_DEVICES.load(); - let summaries = build_network_tree_summaries(nodes_ref, shaped_devices.as_ref()); - if let Some(parent) = nodes_ref.get(parent_idx) { - let mut nodes = vec![( - parent_idx, - node_to_transport_with_summary( - parent, - summaries.get(parent_idx).copied().unwrap_or_default(), - ), - )]; - nodes.extend( - nodes_ref - .iter() - .enumerate() - .filter(|(_, node)| node.immediate_parent == Some(parent_idx)) - .map(|(i, node)| { - ( - i, - node_to_transport_with_summary( - node, - summaries.get(i).copied().unwrap_or_default(), - ), - ) - }), - ); - BusResponse::NetworkMap(nodes) - } else { - BusResponse::Fail("No such node".to_string()) - } + lqos_network_devices::with_network_json_read(|net_json| { + let nodes_ref = net_json.get_nodes_when_ready(); + let shaped_devices = lqos_network_devices::network_devices_catalog(); + let summaries = build_network_tree_summaries(nodes_ref, &shaped_devices); + if let Some(parent) = nodes_ref.get(parent_idx) { + let mut nodes = vec![( + parent_idx, + node_to_transport_with_summary( + parent, + summaries.get(parent_idx).copied().unwrap_or_default(), + ), + )]; + nodes.extend( + nodes_ref + .iter() + .enumerate() + .filter(|(_, node)| node.immediate_parent == Some(parent_idx)) + .map(|(i, node)| { + ( + i, + node_to_transport_with_summary( + node, + summaries.get(i).copied().unwrap_or_default(), + ), + ) + }), + ); + BusResponse::NetworkMap(nodes) + } else { + BusResponse::Fail("No such node".to_string()) + } + }) } pub fn full_network_map_snapshot() -> Vec<(usize, NetworkJsonTransport)> { - let nj = NETWORK_JSON.read(); - let nodes = nj.get_nodes_when_ready(); - let shaped_devices = SHAPED_DEVICES.load(); - let summaries = build_network_tree_summaries(nodes, shaped_devices.as_ref()); - nodes - .iter() - .enumerate() - .map(|(i, n)| { - ( - i, - node_to_transport_with_summary(n, summaries.get(i).copied().unwrap_or_default()), - ) - }) - .collect() + lqos_network_devices::with_network_json_read(|net_json| { + let nodes = net_json.get_nodes_when_ready(); + let shaped_devices = lqos_network_devices::network_devices_catalog(); + let summaries = build_network_tree_summaries(nodes, &shaped_devices); + nodes + .iter() + .enumerate() + .map(|(i, n)| { + ( + i, + node_to_transport_with_summary( + n, + summaries.get(i).copied().unwrap_or_default(), + ), + ) + }) + .collect() + }) } fn node_to_transport_lite(node: &NetworkJsonNode) -> NetworkTreeLiteNode { @@ -718,13 +538,14 @@ fn node_to_transport_lite(node: &NetworkJsonNode) -> NetworkTreeLiteNode { /// Returns a lightweight live snapshot of the network tree for pages that do not need the full /// `NetworkJsonTransport` payload. pub fn full_network_map_lite_snapshot() -> Vec<(usize, NetworkTreeLiteNode)> { - let nj = NETWORK_JSON.read(); - let nodes = nj.get_nodes_when_ready(); - nodes - .iter() - .enumerate() - .map(|(i, n)| (i, node_to_transport_lite(n))) - .collect() + lqos_network_devices::with_network_json_read(|net_json| { + let nodes = net_json.get_nodes_when_ready(); + nodes + .iter() + .enumerate() + .map(|(i, n)| (i, node_to_transport_lite(n))) + .collect() + }) } pub fn get_full_network_map() -> BusResponse { @@ -732,209 +553,155 @@ pub fn get_full_network_map() -> BusResponse { } pub fn get_top_n_root_queues(n_queues: usize) -> BusResponse { - let net_json = NETWORK_JSON.read(); - let nodes_ref = net_json.get_nodes_when_ready(); - let shaped_devices = SHAPED_DEVICES.load(); - let summaries = build_network_tree_summaries(nodes_ref, shaped_devices.as_ref()); - if let Some(parent) = nodes_ref.first() { - let mut nodes = vec![( - 0, - node_to_transport_with_summary(parent, summaries.first().copied().unwrap_or_default()), - )]; - nodes.extend( - nodes_ref - .iter() - .enumerate() - .filter(|(idx, node)| *idx != 0 && node.immediate_parent == Some(0)) - .map(|(idx, node)| { - ( - idx, - node_to_transport_with_summary( - node, - summaries.get(idx).copied().unwrap_or_default(), - ), - ) - }), - ); - // Remove the top-level entry for root - nodes.remove(0); - // Sort by total bandwidth (up + down) descending - nodes.sort_by(|a, b| { - let total_a = a.1.current_throughput.0 + a.1.current_throughput.1; - let total_b = b.1.current_throughput.0 + b.1.current_throughput.1; - total_b.cmp(&total_a) - }); - // Summarize everything after n_queues - if nodes.len() > n_queues { - let mut other_bw = (0, 0); - let mut other_packets = (0, 0); - let mut other_tcp_packets = (0, 0); - let mut other_tcp_retransmit_packets = (0, 0); - let mut other_udp_packets = (0, 0); - let mut other_icmp_packets = (0, 0); - let mut other_xmit = (0, 0); - let mut other_marks = (0, 0); - let mut other_drops = (0, 0); - nodes.drain(n_queues..).for_each(|n| { - other_bw.0 += n.1.current_throughput.0; - other_bw.1 += n.1.current_throughput.1; - other_packets.0 += n.1.current_packets.0; - other_packets.1 += n.1.current_packets.1; - other_tcp_packets.0 += n.1.current_tcp_packets.0; - other_tcp_packets.1 += n.1.current_tcp_packets.1; - other_tcp_retransmit_packets.0 += n.1.current_tcp_retransmit_packets.0; - other_tcp_retransmit_packets.1 += n.1.current_tcp_retransmit_packets.1; - other_udp_packets.0 += n.1.current_udp_packets.0; - other_udp_packets.1 += n.1.current_udp_packets.1; - other_icmp_packets.0 += n.1.current_icmp_packets.0; - other_icmp_packets.1 += n.1.current_icmp_packets.1; - other_xmit.0 += n.1.current_retransmits.0; - other_xmit.1 += n.1.current_retransmits.1; - other_marks.0 += n.1.current_marks.0; - other_marks.1 += n.1.current_marks.1; - other_drops.0 += n.1.current_drops.0; - other_drops.1 += n.1.current_drops.1; - }); - - nodes.push(( + lqos_network_devices::with_network_json_read(|net_json| { + let nodes_ref = net_json.get_nodes_when_ready(); + let shaped_devices = lqos_network_devices::network_devices_catalog(); + let summaries = build_network_tree_summaries(nodes_ref, &shaped_devices); + if let Some(parent) = nodes_ref.first() { + let mut nodes = vec![( 0, - NetworkJsonTransport { - name: "Others".into(), - id: None, - is_virtual: false, - runtime_virtualized: false, - max_throughput: (0.0, 0.0), - configured_max_throughput: (0.0, 0.0), - effective_max_throughput: None, - current_throughput: other_bw, - current_packets: other_packets, - current_tcp_packets: other_tcp_packets, - current_tcp_retransmit_packets: other_tcp_retransmit_packets, - current_udp_packets: other_udp_packets, - current_icmp_packets: other_icmp_packets, - current_retransmits: other_xmit, - current_marks: other_marks, - current_drops: other_drops, - rtts: Vec::new(), - qoo: (None, None), - parents: Vec::new(), - immediate_parent: None, - node_type: None, - latitude: None, - longitude: None, - active_attachment_name: None, - subtree_site_count: 0, - subtree_circuit_count: 0, - subtree_device_count: 0, - }, - )); + node_to_transport_with_summary( + parent, + summaries.first().copied().unwrap_or_default(), + ), + )]; + nodes.extend( + nodes_ref + .iter() + .enumerate() + .filter(|(idx, node)| *idx != 0 && node.immediate_parent == Some(0)) + .map(|(idx, node)| { + ( + idx, + node_to_transport_with_summary( + node, + summaries.get(idx).copied().unwrap_or_default(), + ), + ) + }), + ); + // Remove the top-level entry for root + nodes.remove(0); + // Sort by total bandwidth (up + down) descending + nodes.sort_by(|a, b| { + let total_a = a.1.current_throughput.0 + a.1.current_throughput.1; + let total_b = b.1.current_throughput.0 + b.1.current_throughput.1; + total_b.cmp(&total_a) + }); + // Summarize everything after n_queues + if nodes.len() > n_queues { + let mut other_bw = (0, 0); + let mut other_packets = (0, 0); + let mut other_tcp_packets = (0, 0); + let mut other_tcp_retransmit_packets = (0, 0); + let mut other_udp_packets = (0, 0); + let mut other_icmp_packets = (0, 0); + let mut other_xmit = (0, 0); + let mut other_marks = (0, 0); + let mut other_drops = (0, 0); + nodes.drain(n_queues..).for_each(|n| { + other_bw.0 += n.1.current_throughput.0; + other_bw.1 += n.1.current_throughput.1; + other_packets.0 += n.1.current_packets.0; + other_packets.1 += n.1.current_packets.1; + other_tcp_packets.0 += n.1.current_tcp_packets.0; + other_tcp_packets.1 += n.1.current_tcp_packets.1; + other_tcp_retransmit_packets.0 += n.1.current_tcp_retransmit_packets.0; + other_tcp_retransmit_packets.1 += n.1.current_tcp_retransmit_packets.1; + other_udp_packets.0 += n.1.current_udp_packets.0; + other_udp_packets.1 += n.1.current_udp_packets.1; + other_icmp_packets.0 += n.1.current_icmp_packets.0; + other_icmp_packets.1 += n.1.current_icmp_packets.1; + other_xmit.0 += n.1.current_retransmits.0; + other_xmit.1 += n.1.current_retransmits.1; + other_marks.0 += n.1.current_marks.0; + other_marks.1 += n.1.current_marks.1; + other_drops.0 += n.1.current_drops.0; + other_drops.1 += n.1.current_drops.1; + }); + + nodes.push(( + 0, + NetworkJsonTransport { + name: "Others".into(), + id: None, + is_virtual: false, + runtime_virtualized: false, + max_throughput: (0.0, 0.0), + configured_max_throughput: (0.0, 0.0), + effective_max_throughput: None, + current_throughput: other_bw, + current_packets: other_packets, + current_tcp_packets: other_tcp_packets, + current_tcp_retransmit_packets: other_tcp_retransmit_packets, + current_udp_packets: other_udp_packets, + current_icmp_packets: other_icmp_packets, + current_retransmits: other_xmit, + current_marks: other_marks, + current_drops: other_drops, + rtts: Vec::new(), + qoo: (None, None), + parents: Vec::new(), + immediate_parent: None, + node_type: None, + latitude: None, + longitude: None, + active_attachment_name: None, + subtree_site_count: 0, + subtree_circuit_count: 0, + subtree_device_count: 0, + }, + )); + } + BusResponse::NetworkMap(nodes) + } else { + BusResponse::Fail("No such node".to_string()) } - BusResponse::NetworkMap(nodes) - } else { - BusResponse::Fail("No such node".to_string()) - } -} - -pub fn map_node_names(nodes: &[usize]) -> BusResponse { - let mut result = Vec::new(); - let reader = NETWORK_JSON.read(); - nodes.iter().for_each(|id| { - if let Some(node) = reader.get_nodes_when_ready().get(*id) { - result.push((*id, node.name.clone())); - } - }); - BusResponse::NodeNames(result) -} - -/// Canonical parent-node metadata resolved from `network.json`. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct ResolvedParentNode { - /// Canonical node name from `network.json`. - pub name: String, - /// Optional stable node identifier from `network.json` metadata. - pub id: Option, -} - -/// Resolve a shaped-device parent reference into canonical `network.json` -/// parent metadata, preferring a stable node ID when one is available. -pub fn resolve_parent_node_reference( - parent_node: &str, - parent_node_id: Option<&str>, -) -> Option { - let trimmed_id = parent_node_id.map(str::trim).filter(|id| !id.is_empty()); - let trimmed = parent_node.trim(); - if trimmed.is_empty() && trimmed_id.is_none() { - return None; - } - - let reader = NETWORK_JSON.read(); - let nodes = reader.get_nodes_when_ready(); - - if let Some(parent_node_id) = trimmed_id - && let Some(node) = nodes - .iter() - .find(|node| node.id.as_deref() == Some(parent_node_id)) - { - return Some(ResolvedParentNode { - name: node.name.clone(), - id: node.id.clone(), - }); - } - - if let Some(node) = nodes.iter().find(|node| node.name == trimmed) { - return Some(ResolvedParentNode { - name: node.name.clone(), - id: node.id.clone(), - }); - } - - nodes.iter().find_map(|node| { - node.active_attachment_name - .as_deref() - .filter(|alias| alias.trim() == trimmed) - .map(|_| ResolvedParentNode { - name: node.name.clone(), - id: node.id.clone(), - }) }) } -/// Resolve a shaped-device parent node or active attachment alias into canonical `network.json` -/// parent metadata. -pub fn resolve_parent_node(parent_node: &str) -> Option { - resolve_parent_node_reference(parent_node, None) +pub fn map_node_names(nodes: &[usize]) -> BusResponse { + lqos_network_devices::with_network_json_read(|net_json| { + let mut result = Vec::new(); + nodes.iter().for_each(|id| { + if let Some(node) = net_json.get_nodes_when_ready().get(*id) { + result.push((*id, node.name.clone())); + } + }); + BusResponse::NodeNames(result) + }) } pub fn resolve_parent_node_alias(parent_node: &str) -> Option { - resolve_parent_node(parent_node).map(|resolved| resolved.name) + lqos_network_devices::resolve_parent_node(parent_node).map(|resolved| resolved.name) } pub fn get_funnel(circuit_id: &str) -> BusResponse { - let reader = NETWORK_JSON.read(); - if let Some(index) = reader.get_index_for_name(circuit_id) { - // Reverse the scanning order and skip the last entry (the parent) - let mut result = Vec::new(); - for idx in reader.get_nodes_when_ready()[index] - .parents - .iter() - .rev() - .skip(1) - { - result.push(( - *idx, - node_to_transport(&reader.get_nodes_when_ready()[*idx]), - )); + lqos_network_devices::with_network_json_read(|net_json| { + if let Some(index) = net_json.get_index_for_name(circuit_id) { + // Reverse the scanning order and skip the last entry (the parent) + let mut result = Vec::new(); + for idx in net_json.get_nodes_when_ready()[index] + .parents + .iter() + .rev() + .skip(1) + { + result.push(( + *idx, + node_to_transport(&net_json.get_nodes_when_ready()[*idx]), + )); + } + return BusResponse::NetworkMap(result); } - return BusResponse::NetworkMap(result); - } - BusResponse::Fail("Unknown Node".into()) + BusResponse::Fail("Unknown Node".into()) + }) } pub fn get_all_circuits() -> BusResponse { if let Ok(kernel_now) = time_since_boot() { - let devices = SHAPED_DEVICES.load(); - let cache = SHAPED_DEVICE_HASH_CACHE.load(); + let catalog = lqos_network_devices::network_devices_catalog(); let data = THROUGHPUT_TRACKER .raw_data .lock() @@ -957,13 +724,9 @@ pub fn get_all_circuits() -> BusResponse { let mut parent_node = None; // Plan is expressed in Mbps as f32 let mut plan: DownUpOrder = DownUpOrder { down: 0.0, up: 0.0 }; - let device = shaped_device_from_hashes_or_ip( - &devices, - &cache, - k, - v.device_hash, - v.circuit_hash, - ); + let device = catalog + .device_by_hashes(v.device_hash, v.circuit_hash) + .or_else(|| catalog.device_longest_match_for_ip(k).map(|(_, dev)| dev)); if let Some(device) = device { if circuit_id.as_deref().unwrap_or_default().is_empty() { circuit_id = Some(device.circuit_id.clone()); @@ -1053,22 +816,18 @@ pub fn get_all_circuits() -> BusResponse { pub fn get_circuit_by_id(desired_circuit_id: String) -> BusResponse { if let Ok(kernel_now) = time_since_boot() { let desired_hash = hash_to_i64(&desired_circuit_id); - let devices = SHAPED_DEVICES.load(); - let cache = SHAPED_DEVICE_HASH_CACHE.load(); + let catalog = lqos_network_devices::network_devices_catalog(); let data = THROUGHPUT_TRACKER .raw_data .lock() .iter() .filter_map(|(k, v)| { - let device = shaped_device_from_hashes_or_ip( - &devices, - &cache, - k, - v.device_hash, - v.circuit_hash, - ); + let device = catalog + .device_by_hashes(v.device_hash, v.circuit_hash) + .or_else(|| catalog.device_longest_match_for_ip(k).map(|(_, dev)| dev)); let matches_desired = v.circuit_hash == Some(desired_hash) || v.circuit_id.as_deref() == Some(desired_circuit_id.as_str()) + || device.is_some_and(|device| device.circuit_hash == desired_hash) || device.is_some_and(|device| device.circuit_id == desired_circuit_id); if !matches_desired { return None; @@ -1185,6 +944,7 @@ mod tests { TopologyShapingDeviceInput, TopologyShapingInputsFile, }; use lqos_topology_compile::{ImportedTopologyBundle, TopologyImportFile}; + use lqos_utils::XdpIpAddress; use serde_json::json; use std::fs; use std::net::Ipv4Addr; @@ -1274,99 +1034,6 @@ mod tests { assert!(map.is_empty()); } - #[test] - fn shaped_device_from_hashes_or_ip_falls_back_to_ip() { - let mut shaped = ConfigShapedDevices::default(); - shaped.replace_with_new_data(vec![ShapedDevice { - circuit_id: "circuit-1".to_string(), - circuit_name: "Circuit Alpha".to_string(), - device_id: "device-1".to_string(), - parent_node: "Parent-A".to_string(), - ipv4: vec![(Ipv4Addr::new(192, 168, 1, 10), 32)], - ..Default::default() - }]); - let cache = ShapedDeviceHashCache::default(); - let ip = XdpIpAddress::from_ip("192.168.1.10".parse().expect("test IP should parse")); - - let device = - shaped_device_from_hashes_or_ip(&shaped, &cache, &ip, None, None).expect("match"); - - assert_eq!(device.circuit_id, "circuit-1"); - assert_eq!(device.circuit_name, "Circuit Alpha"); - } - - #[test] - fn shaped_device_match_prefers_device_hash() { - let mut shaped = ConfigShapedDevices::default(); - shaped.replace_with_new_data(vec![ShapedDevice { - circuit_id: "circuit-1".to_string(), - circuit_name: "Circuit Alpha".to_string(), - device_id: "device-1".to_string(), - parent_node: "Parent-A".to_string(), - ipv4: vec![(Ipv4Addr::new(192, 168, 1, 10), 32)], - ..Default::default() - }]); - let cache = ShapedDeviceHashCache::from_devices(&shaped.devices); - let ip = XdpIpAddress::from_ip("192.168.1.10".parse().expect("test IP should parse")); - - let (_device, source) = shaped_device_match_from_hashes_or_ip( - &shaped, - &cache, - &ip, - Some(hash_to_i64("device-1")), - None, - ) - .expect("match"); - - assert_eq!(source, ShapedDeviceMatchSource::DeviceHash); - } - - #[test] - fn shaped_device_match_uses_circuit_hash_when_device_hash_missing() { - let mut shaped = ConfigShapedDevices::default(); - shaped.replace_with_new_data(vec![ShapedDevice { - circuit_id: "circuit-1".to_string(), - circuit_name: "Circuit Alpha".to_string(), - device_id: "device-1".to_string(), - parent_node: "Parent-A".to_string(), - ipv4: vec![(Ipv4Addr::new(192, 168, 1, 10), 32)], - ..Default::default() - }]); - let cache = ShapedDeviceHashCache::from_devices(&shaped.devices); - let ip = XdpIpAddress::from_ip("192.168.1.10".parse().expect("test IP should parse")); - - let (_device, source) = shaped_device_match_from_hashes_or_ip( - &shaped, - &cache, - &ip, - None, - Some(hash_to_i64("circuit-1")), - ) - .expect("match"); - - assert_eq!(source, ShapedDeviceMatchSource::CircuitHash); - } - - #[test] - fn shaped_device_match_reports_ip_fallback() { - let mut shaped = ConfigShapedDevices::default(); - shaped.replace_with_new_data(vec![ShapedDevice { - circuit_id: "circuit-1".to_string(), - circuit_name: "Circuit Alpha".to_string(), - device_id: "device-1".to_string(), - parent_node: "Parent-A".to_string(), - ipv4: vec![(Ipv4Addr::new(192, 168, 1, 10), 32)], - ..Default::default() - }]); - let cache = ShapedDeviceHashCache::from_devices(&shaped.devices); - let ip = XdpIpAddress::from_ip("192.168.1.10".parse().expect("test IP should parse")); - - let (_device, source) = - shaped_device_match_from_hashes_or_ip(&shaped, &cache, &ip, None, None).expect("match"); - - assert_eq!(source, ShapedDeviceMatchSource::IpFallback); - } - #[test] fn shaped_devices_from_runtime_inputs_uses_effective_parent_and_ip_lookup() { let shaping_inputs = TopologyShapingInputsFile { @@ -1612,4 +1279,50 @@ mod tests { assert_eq!(loaded.devices.len(), 1); assert_eq!(loaded.devices[0].circuit_id, "import-circuit"); } + + #[test] + fn load_shaped_devices_falls_back_to_csv_when_topology_import_is_empty() { + let lqos_directory = unique_temp_dir("lqosd-shaped-devices-topology-import-empty"); + let state_directory = lqos_directory.join("state"); + fs::create_dir_all(state_directory.join("topology")).expect("topology dir should exist"); + + write_shaped_devices_csv( + &lqos_directory.join("ShapedDevices.csv"), + "csv-circuit", + "192.0.2.10/32", + ); + + let imported = ImportedTopologyBundle { + source: "test/import".to_string(), + generated_unix: Some(123), + ingress_identity: Some("import-base".to_string()), + native_canonical: None, + native_editor: None, + parent_candidates: None, + compatibility_network_json: json!({}), + shaped_devices: ConfigShapedDevices::default(), + circuit_anchors: CircuitAnchorsFile::default(), + ethernet_advisories: Vec::new(), + }; + TopologyImportFile::from_imported_bundle(&imported, "full") + .save(&Config { + lqos_directory: lqos_directory.to_string_lossy().to_string(), + state_directory: Some(state_directory.to_string_lossy().to_string()), + ..Config::default() + }) + .expect("topology import should save"); + + let mut config = Config { + lqos_directory: lqos_directory.to_string_lossy().to_string(), + state_directory: Some(state_directory.to_string_lossy().to_string()), + ..Config::default() + }; + config.uisp_integration.enable_uisp = true; + + let (loaded, source) = load_shaped_devices_from_preferred_source(&config) + .expect("CSV fallback should load"); + assert_eq!(source, ShapedDevicesLoadSource::ShapedDevicesCsv); + assert_eq!(loaded.devices.len(), 1); + assert_eq!(loaded.devices[0].circuit_id, "csv-circuit"); + } } diff --git a/src/rust/lqosd/src/shaped_devices_tracker/netjson.rs b/src/rust/lqosd/src/shaped_devices_tracker/netjson.rs deleted file mode 100644 index 8ea5ae08..00000000 --- a/src/rust/lqosd/src/shaped_devices_tracker/netjson.rs +++ /dev/null @@ -1,61 +0,0 @@ -use anyhow::Result; -use lqos_config::NetworkJson; -use lqos_utils::file_watcher::FileWatcher; -use once_cell::sync::Lazy; -use parking_lot::RwLock; -use std::path::PathBuf; -use tracing::{debug, error, info, warn}; - -pub static NETWORK_JSON: Lazy> = - Lazy::new(|| RwLock::new(NetworkJson::default())); - -pub fn network_json_watcher() -> Result<()> { - std::thread::Builder::new() - .name("Active Network Tree Watcher".to_string()) - .spawn(|| { - debug!("Watching for active network tree changes"); - if let Err(e) = watch_for_network_json_changing() { - error!("Error watching for active network tree changes: {:?}", e); - } - })?; - Ok(()) -} - -fn active_network_tree_path() -> Result { - NetworkJson::path() - .map_err(|_| anyhow::Error::msg("Unable to create path for active network tree")) -} - -/// Fires up a Linux file system watcher that notifies -/// when the active runtime tree changes and triggers a reload. -fn watch_for_network_json_changing() -> Result<()> { - let watch_path = active_network_tree_path()?; - let watch_name = watch_path - .file_name() - .and_then(|name| name.to_str()) - .unwrap_or("active network tree") - .to_string(); - - let mut watcher = FileWatcher::new(&watch_name, watch_path); - watcher.set_file_exists_callback(load_network_json); - watcher.set_file_created_callback(load_network_json); - watcher.set_file_changed_callback(load_network_json); - loop { - let result = watcher.watch(); - info!("active network tree watcher returned: {result:?}"); - } -} - -fn load_network_json() { - let njs = NetworkJson::load(); - if let Ok(mut njs) = njs { - let mut nj = NETWORK_JSON.write(); - njs.carry_forward_heatmaps_from(&nj); - *nj = njs; - super::invalidate_circuit_live_snapshot(); - super::invalidate_executive_cache_snapshot(); - crate::throughput_tracker::THROUGHPUT_TRACKER.refresh_circuit_ids(&nj); - } else { - warn!("Unable to load active runtime network tree"); - } -} diff --git a/src/rust/lqosd/src/throughput_tracker/flow_data/flow_analysis/finished_flows.rs b/src/rust/lqosd/src/throughput_tracker/flow_data/flow_analysis/finished_flows.rs index 854abbf0..6070720e 100644 --- a/src/rust/lqosd/src/throughput_tracker/flow_data/flow_analysis/finished_flows.rs +++ b/src/rust/lqosd/src/throughput_tracker/flow_data/flow_analysis/finished_flows.rs @@ -2,7 +2,6 @@ use super::{ FlowAnalysis, FlowbeeEffectiveDirection, get_asn_lat_lon, get_asn_name_and_country, get_asn_name_by_id, }; -use crate::shaped_devices_tracker::SHAPED_DEVICES; use crate::throughput_tracker::flow_data::FlowbeeLocalData; use allocative_derive::Allocative; use crossbeam_channel::Sender; @@ -476,9 +475,7 @@ fn enqueue(key: FlowbeeKey, data: FlowbeeLocalData, analysis: FlowAnalysis) { .get(&key.local_ip) .and_then(|te| te.circuit_hash) .or_else(|| { - SHAPED_DEVICES - .load() - .get_circuit_hash_from_ip(&key.local_ip) + lqos_network_devices::shaped_devices_catalog().circuit_hash_for_ip(&key.local_ip) }) }); diff --git a/src/rust/lqosd/src/throughput_tracker/mod.rs b/src/rust/lqosd/src/throughput_tracker/mod.rs index 4f9553f9..ba2977e5 100644 --- a/src/rust/lqosd/src/throughput_tracker/mod.rs +++ b/src/rust/lqosd/src/throughput_tracker/mod.rs @@ -11,7 +11,7 @@ use self::throughput_entry::ThroughputEntry; use crate::system_stats::SystemStats; use crate::throughput_tracker::flow_data::FlowbeeEffectiveDirection; use crate::{ - shaped_devices_tracker::{NETWORK_JSON, SHAPED_DEVICE_HASH_CACHE, SHAPED_DEVICES}, + lts2_sys::{get_lts_license_status, shared_types::LtsStatus}, stats::TIME_TO_POLL_HOSTS, throughput_tracker::tracking_data::{FlowApplyContext, ThroughputTracker}, }; @@ -75,34 +75,18 @@ pub(crate) fn circuit_current_qoo(circuit_hash: i64) -> DownUpOrder> } } -fn shaped_device_for_entry<'a>( - shaped: &'a lqos_config::ConfigShapedDevices, - cache: &crate::shaped_devices_tracker::ShapedDeviceHashCache, - ip: &XdpIpAddress, - entry: &ThroughputEntry, -) -> Option<&'a lqos_config::ShapedDevice> { - entry - .device_hash - .and_then(|hash| cache.index_by_device_hash(shaped, hash)) - .or_else(|| { - entry - .circuit_hash - .and_then(|hash| cache.index_by_circuit_hash(shaped, hash)) - }) - .and_then(|idx| shaped.devices.get(idx)) - .or_else(|| shaped.get_device_from_ip(ip)) -} - fn resolve_circuit_metadata_for_entry( - shaped: &lqos_config::ConfigShapedDevices, - cache: &crate::shaped_devices_tracker::ShapedDeviceHashCache, + catalog: &lqos_network_devices::NetworkDevicesCatalog, ip: &XdpIpAddress, entry: &ThroughputEntry, ) -> (String, String) { let mut circuit_id = entry.circuit_id.clone().unwrap_or_default(); let mut circuit_name = String::new(); - if let Some(device) = shaped_device_for_entry(shaped, cache, ip, entry) { + if let Some(device) = catalog + .device_by_hashes(entry.device_hash, entry.circuit_hash) + .or_else(|| catalog.device_longest_match_for_ip(ip).map(|(_, dev)| dev)) + { if circuit_id.is_empty() { circuit_id = device.circuit_id.clone(); } @@ -113,13 +97,12 @@ fn resolve_circuit_metadata_for_entry( } pub(crate) fn resolve_circuit_metadata_for_ip(ip: &XdpIpAddress) -> (String, String) { - let shaped = SHAPED_DEVICES.load(); - let cache = SHAPED_DEVICE_HASH_CACHE.load(); + let catalog = lqos_network_devices::network_devices_catalog(); let throughput = THROUGHPUT_TRACKER.raw_data.lock(); let Some(entry) = throughput.get(ip) else { return (String::new(), String::new()); }; - resolve_circuit_metadata_for_entry(&shaped, &cache, ip, entry) + resolve_circuit_metadata_for_entry(&catalog, ip, entry) } /// Create the throughput monitor thread, and begin polling for @@ -270,59 +253,60 @@ fn throughput_task( // Formerly a "spawn blocking" blob { - let mut net_json_calc = NETWORK_JSON.write(); - timer_metrics.update_cycle = timer_metrics.start.elapsed().as_secs_f64(); - net_json_calc.zero_throughput_and_rtt(); - timer_metrics.zero_throughput_and_rtt = timer_metrics.start.elapsed().as_secs_f64(); - THROUGHPUT_TRACKER.copy_previous_and_reset_rtt(); - timer_metrics.copy_previous_and_reset_rtt = timer_metrics.start.elapsed().as_secs_f64(); - THROUGHPUT_TRACKER - .apply_new_throughput_counters(&mut net_json_calc, bakery_sender.clone()); - timer_metrics.apply_new_throughput_counters = - timer_metrics.start.elapsed().as_secs_f64(); - THROUGHPUT_TRACKER.apply_flow_data(FlowApplyContext { - timeout_seconds, - sender: netflow_sender.clone(), - net_json_calc: &mut net_json_calc, - rtt_circuit_tracker: &mut rtt_circuit_tracker, - rtt_by_circuit: &mut rtt_by_circuit, - tcp_retries: &mut tcp_retries, - tcp_retry_packets: &mut tcp_retry_packets, - expired_keys: &mut expired_flows, + lqos_network_devices::with_network_json_write(|net_json_calc| { + timer_metrics.update_cycle = timer_metrics.start.elapsed().as_secs_f64(); + net_json_calc.zero_throughput_and_rtt(); + timer_metrics.zero_throughput_and_rtt = timer_metrics.start.elapsed().as_secs_f64(); + THROUGHPUT_TRACKER.copy_previous_and_reset_rtt(); + timer_metrics.copy_previous_and_reset_rtt = + timer_metrics.start.elapsed().as_secs_f64(); + THROUGHPUT_TRACKER + .apply_new_throughput_counters(net_json_calc, bakery_sender.clone()); + timer_metrics.apply_new_throughput_counters = + timer_metrics.start.elapsed().as_secs_f64(); + THROUGHPUT_TRACKER.apply_flow_data(FlowApplyContext { + timeout_seconds, + sender: netflow_sender.clone(), + net_json_calc, + rtt_circuit_tracker: &mut rtt_circuit_tracker, + rtt_by_circuit: &mut rtt_by_circuit, + tcp_retries: &mut tcp_retries, + tcp_retry_packets: &mut tcp_retry_packets, + expired_keys: &mut expired_flows, + }); + CIRCUIT_RTT_BUFFERS.store(Arc::new(rtt_by_circuit.clone())); + THROUGHPUT_TRACKER.record_circuit_heatmaps(); + let enable_site_heatmaps = lqos_config::load_config() + .map(|config| config.enable_site_heatmaps) + .unwrap_or(true); + net_json_calc.record_site_heatmaps(enable_site_heatmaps); + + // Clean up work tables + rtt_circuit_tracker.clear(); + rtt_by_circuit.clear(); + tcp_retries.clear(); + tcp_retry_packets.clear(); + expired_flows.clear(); + rtt_circuit_tracker.shrink_to_fit(); + rtt_by_circuit.shrink_to_fit(); + tcp_retries.shrink_to_fit(); + tcp_retry_packets.shrink_to_fit(); + expired_flows.shrink_to_fit(); + + timer_metrics.apply_flow_data = timer_metrics.start.elapsed().as_secs_f64(); + if bakery_reload_in_progress { + debug!( + "Throughput monitor: skipping queue-stat application during Bakery full reload" + ); + } else { + THROUGHPUT_TRACKER.apply_queue_stats(net_json_calc); + } + timer_metrics.apply_queue_stats = timer_metrics.start.elapsed().as_secs_f64(); + THROUGHPUT_TRACKER.update_totals(); + timer_metrics.update_totals = timer_metrics.start.elapsed().as_secs_f64(); + THROUGHPUT_TRACKER.next_cycle(); + timer_metrics.next_cycle = timer_metrics.start.elapsed().as_secs_f64(); }); - CIRCUIT_RTT_BUFFERS.store(Arc::new(rtt_by_circuit.clone())); - THROUGHPUT_TRACKER.record_circuit_heatmaps(); - let enable_site_heatmaps = lqos_config::load_config() - .map(|config| config.enable_site_heatmaps) - .unwrap_or(true); - net_json_calc.record_site_heatmaps(enable_site_heatmaps); - - // Clean up work tables - rtt_circuit_tracker.clear(); - rtt_by_circuit.clear(); - tcp_retries.clear(); - tcp_retry_packets.clear(); - expired_flows.clear(); - rtt_circuit_tracker.shrink_to_fit(); - rtt_by_circuit.shrink_to_fit(); - tcp_retries.shrink_to_fit(); - tcp_retry_packets.shrink_to_fit(); - expired_flows.shrink_to_fit(); - - timer_metrics.apply_flow_data = timer_metrics.start.elapsed().as_secs_f64(); - if bakery_reload_in_progress { - debug!( - "Throughput monitor: skipping queue-stat application during Bakery full reload" - ); - } else { - THROUGHPUT_TRACKER.apply_queue_stats(&mut net_json_calc); - } - timer_metrics.apply_queue_stats = timer_metrics.start.elapsed().as_secs_f64(); - THROUGHPUT_TRACKER.update_totals(); - timer_metrics.update_totals = timer_metrics.start.elapsed().as_secs_f64(); - THROUGHPUT_TRACKER.next_cycle(); - timer_metrics.next_cycle = timer_metrics.start.elapsed().as_secs_f64(); - std::mem::drop(net_json_calc); timer_metrics.finish_update_cycle = timer_metrics.start.elapsed().as_secs_f64(); let duration_ms = start.elapsed().as_micros(); TIME_TO_POLL_HOSTS.store(duration_ms as u64, std::sync::atomic::Ordering::Relaxed); @@ -441,8 +425,7 @@ pub fn top_n(start: u32, end: u32) -> BusResponse { let tp_cycle = THROUGHPUT_TRACKER .cycle .load(std::sync::atomic::Ordering::Relaxed); - let shaped = SHAPED_DEVICES.load(); - let shaped_cache = SHAPED_DEVICE_HASH_CACHE.load(); + let catalog = lqos_network_devices::network_devices_catalog(); THROUGHPUT_TRACKER .raw_data .lock() @@ -451,7 +434,7 @@ pub fn top_n(start: u32, end: u32) -> BusResponse { .filter(|(_k, d)| retire_check(tp_cycle, d.most_recent_cycle)) .map(|(k, te)| { let (circuit_id, _circuit_name) = - resolve_circuit_metadata_for_entry(&shaped, &shaped_cache, k, te); + resolve_circuit_metadata_for_entry(&catalog, k, te); ( *k, te.actual_bytes_per_second, @@ -489,8 +472,7 @@ pub fn top_n_up(start: u32, end: u32) -> BusResponse { let tp_cycle = THROUGHPUT_TRACKER .cycle .load(std::sync::atomic::Ordering::Relaxed); - let shaped = SHAPED_DEVICES.load(); - let shaped_cache = SHAPED_DEVICE_HASH_CACHE.load(); + let catalog = lqos_network_devices::network_devices_catalog(); THROUGHPUT_TRACKER .raw_data .lock() @@ -499,7 +481,7 @@ pub fn top_n_up(start: u32, end: u32) -> BusResponse { .filter(|(_k, d)| retire_check(tp_cycle, d.most_recent_cycle)) .map(|(k, te)| { let (circuit_id, _circuit_name) = - resolve_circuit_metadata_for_entry(&shaped, &shaped_cache, k, te); + resolve_circuit_metadata_for_entry(&catalog, k, te); ( *k, te.actual_bytes_per_second, @@ -541,9 +523,9 @@ pub fn circuit_heatmaps() -> BusResponse { return BusResponse::CircuitHeatmaps(Vec::new()); } - let devices = SHAPED_DEVICES.load(); + let catalog = lqos_network_devices::network_devices_catalog(); let mut circuit_meta: FxHashMap = FxHashMap::default(); - devices.devices.iter().for_each(|device| { + catalog.iter_all_devices().for_each(|device| { circuit_meta .entry(device.circuit_hash) .or_insert_with(|| (device.circuit_id.clone(), device.circuit_name.clone())); @@ -580,25 +562,26 @@ pub fn site_heatmaps() -> BusResponse { return BusResponse::SiteHeatmaps(Vec::new()); } - let reader = NETWORK_JSON.read(); - let mut rows: Vec = reader - .get_nodes_when_ready() - .iter() - .filter_map(|node| { - if node.name == "Root" || node.name.parse::().is_ok() { - return None; - } - node.heatmap.as_ref().map(|heatmap| SiteHeatmapData { - site_name: node.name.clone(), - node_type: node.node_type.clone(), - depth: node.parents.len().saturating_sub(1), - blocks: heatmap.blocks(), - qoq_blocks: node.qoq_heatmap.as_ref().map(|heatmap| heatmap.blocks()), + lqos_network_devices::with_network_json_read(|net_json| { + let mut rows: Vec = net_json + .get_nodes_when_ready() + .iter() + .filter_map(|node| { + if node.name == "Root" || node.name.parse::().is_ok() { + return None; + } + node.heatmap.as_ref().map(|heatmap| SiteHeatmapData { + site_name: node.name.clone(), + node_type: node.node_type.clone(), + depth: node.parents.len().saturating_sub(1), + blocks: heatmap.blocks(), + qoq_blocks: node.qoq_heatmap.as_ref().map(|heatmap| heatmap.blocks()), + }) }) - }) - .collect(); - rows.sort_by(|a, b| a.site_name.cmp(&b.site_name)); - BusResponse::SiteHeatmaps(rows) + .collect(); + rows.sort_by(|a, b| a.site_name.cmp(&b.site_name)); + BusResponse::SiteHeatmaps(rows) + }) } /// Retrieve per-ASN heatmap data for the executive summary. @@ -640,8 +623,7 @@ pub fn worst_n(start: u32, end: u32) -> BusResponse { let tp_cycle = THROUGHPUT_TRACKER .cycle .load(std::sync::atomic::Ordering::Relaxed); - let shaped = SHAPED_DEVICES.load(); - let shaped_cache = SHAPED_DEVICE_HASH_CACHE.load(); + let catalog = lqos_network_devices::network_devices_catalog(); THROUGHPUT_TRACKER .raw_data .lock() @@ -656,7 +638,7 @@ pub fn worst_n(start: u32, end: u32) -> BusResponse { .filter(|(_k, te)| te.median_latency().is_some()) .map(|(k, te)| { let (circuit_id, _circuit_name) = - resolve_circuit_metadata_for_entry(&shaped, &shaped_cache, k, te); + resolve_circuit_metadata_for_entry(&catalog, k, te); ( *k, te.actual_bytes_per_second, @@ -694,8 +676,7 @@ pub fn worst_n_retransmits(start: u32, end: u32) -> BusResponse { let tp_cycle = THROUGHPUT_TRACKER .cycle .load(std::sync::atomic::Ordering::Relaxed); - let shaped = SHAPED_DEVICES.load(); - let shaped_cache = SHAPED_DEVICE_HASH_CACHE.load(); + let catalog = lqos_network_devices::network_devices_catalog(); THROUGHPUT_TRACKER .raw_data .lock() @@ -705,7 +686,7 @@ pub fn worst_n_retransmits(start: u32, end: u32) -> BusResponse { .filter(|(_k, te)| te.median_latency().is_some()) .map(|(k, te)| { let (circuit_id, _circuit_name) = - resolve_circuit_metadata_for_entry(&shaped, &shaped_cache, k, te); + resolve_circuit_metadata_for_entry(&catalog, k, te); ( *k, te.actual_bytes_per_second, @@ -751,8 +732,7 @@ pub fn best_n(start: u32, end: u32) -> BusResponse { let tp_cycle = THROUGHPUT_TRACKER .cycle .load(std::sync::atomic::Ordering::Relaxed); - let shaped = SHAPED_DEVICES.load(); - let shaped_cache = SHAPED_DEVICE_HASH_CACHE.load(); + let catalog = lqos_network_devices::network_devices_catalog(); THROUGHPUT_TRACKER .raw_data .lock() @@ -767,7 +747,7 @@ pub fn best_n(start: u32, end: u32) -> BusResponse { .filter(|(_k, te)| te.median_latency().is_some()) .map(|(k, te)| { let (circuit_id, _circuit_name) = - resolve_circuit_metadata_for_entry(&shaped, &shaped_cache, k, te); + resolve_circuit_metadata_for_entry(&catalog, k, te); ( *k, te.actual_bytes_per_second, @@ -991,21 +971,20 @@ fn current_host_counts() -> (u32, u32) { /// Gather headline metrics for the Executive Summary header cards. pub fn executive_summary_header() -> BusResponse { - let devices = SHAPED_DEVICES.load(); - let circuit_count = devices - .devices - .iter() - .map(|device| device.circuit_hash) - .collect::>() - .len() as u64; - let device_count = devices.devices.len() as u64; + let catalog = lqos_network_devices::network_devices_catalog(); + let mut circuits: FxHashSet = FxHashSet::default(); + let mut device_count = 0_u64; + for device in catalog.iter_all_devices() { + device_count = device_count.saturating_add(1); + circuits.insert(device.circuit_hash); + } + let circuit_count = circuits.len() as u64; - let site_count = { - let reader = NETWORK_JSON.read(); - let total_nodes = reader.get_nodes_when_ready().len(); + let site_count = lqos_network_devices::with_network_json_read(|net_json| { + let total_nodes = net_json.get_nodes_when_ready().len(); // Remove the synthetic root node when counting sites. total_nodes.saturating_sub(1) as u64 - }; + }); let (total_hosts, shaped_hosts) = current_host_counts(); let mapped_ip_count = shaped_hosts as u64; @@ -1206,8 +1185,7 @@ pub fn top_flows(n: u32, flow_type: TopFlowType) -> BusResponse { } } - let shaped = SHAPED_DEVICES.load(); - let shaped_cache = SHAPED_DEVICE_HASH_CACHE.load(); + let catalog = lqos_network_devices::network_devices_catalog(); let throughput = THROUGHPUT_TRACKER.raw_data.lock(); let result = table @@ -1215,12 +1193,9 @@ pub fn top_flows(n: u32, flow_type: TopFlowType) -> BusResponse { .take(n as usize) .map(|(ip, flow)| { let geo = get_asn_name_and_country(ip.remote_ip.as_ip()); - let (circuit_id, circuit_name) = throughput .get(&ip.local_ip) - .map(|te| { - resolve_circuit_metadata_for_entry(&shaped, &shaped_cache, &ip.local_ip, te) - }) + .map(|te| resolve_circuit_metadata_for_entry(&catalog, &ip.local_ip, te)) .unwrap_or_default(); lqos_bus::FlowbeeSummaryData { @@ -1424,12 +1399,12 @@ mod compatibility_tests { CIRCUIT_RTT_BUFFERS, Lts2Circuit, circuit_current_qoo, circuit_current_rtt_p50_nanos, resolve_circuit_metadata_for_entry, }; - use crate::shaped_devices_tracker::ShapedDeviceHashCache; use crate::throughput_tracker::flow_data::{FlowbeeEffectiveDirection, RttData}; use crate::throughput_tracker::throughput_entry::ThroughputEntry; use fxhash::FxHashMap; use lqos_bus::TcHandle; use lqos_config::{ConfigShapedDevices, ShapedDevice}; + use lqos_network_devices::{NetworkDevicesCatalog, ShapedDevicesCatalog}; use lqos_utils::XdpIpAddress; use lqos_utils::qoo::QoqScores; use lqos_utils::qoq_heatmap::TemporalQoqHeatmap; @@ -1504,7 +1479,8 @@ mod compatibility_tests { ipv4: vec![(Ipv4Addr::new(192, 168, 1, 10), 32)], ..Default::default() }]); - let cache = ShapedDeviceHashCache::default(); + let shaped_catalog = ShapedDevicesCatalog::from_shaped_devices(Arc::new(shaped)); + let catalog = NetworkDevicesCatalog::from_snapshots(shaped_catalog, Arc::new(Vec::new())); let ip = XdpIpAddress::from_ip("192.168.1.10".parse().expect("test IP should parse")); let entry = ThroughputEntry { circuit_id: None, @@ -1538,8 +1514,7 @@ mod compatibility_tests { qoq: QoqScores::default(), }; - let (circuit_id, circuit_name) = - resolve_circuit_metadata_for_entry(&shaped, &cache, &ip, &entry); + let (circuit_id, circuit_name) = resolve_circuit_metadata_for_entry(&catalog, &ip, &entry); assert_eq!(circuit_id, "circuit-1"); assert_eq!(circuit_name, "Circuit Alpha"); diff --git a/src/rust/lqosd/src/throughput_tracker/stats_submission.rs b/src/rust/lqosd/src/throughput_tracker/stats_submission.rs index 60c15b60..3e3a9766 100644 --- a/src/rust/lqosd/src/throughput_tracker/stats_submission.rs +++ b/src/rust/lqosd/src/throughput_tracker/stats_submission.rs @@ -1,7 +1,4 @@ use crate::lts2_sys::shared_types::{CircuitCakeDrops, CircuitCakeMarks}; -use crate::shaped_devices_tracker::{ - NETWORK_JSON, SHAPED_DEVICE_HASH_CACHE, SHAPED_DEVICES, shaped_device_from_hashes_or_ip, -}; use crate::system_stats::SystemStats; use crate::throughput_tracker::flow_data::ALL_FLOWS; use crate::throughput_tracker::flow_data::FlowbeeEffectiveDirection; @@ -79,13 +76,14 @@ fn queue_metrics_available(config: &lqos_config::Config) -> bool { } fn resolved_circuit_hash_for_submission( - shaped: &lqos_config::ConfigShapedDevices, - cache: &crate::shaped_devices_tracker::ShapedDeviceHashCache, + catalog: &lqos_network_devices::NetworkDevicesCatalog, ip: &XdpIpAddress, entry: &ThroughputEntry, ) -> Option { entry.circuit_hash.or_else(|| { - shaped_device_from_hashes_or_ip(shaped, cache, ip, entry.device_hash, entry.circuit_hash) + catalog + .device_by_hashes(entry.device_hash, entry.circuit_hash) + .or_else(|| catalog.device_longest_match_for_ip(ip).map(|(_, dev)| dev)) .map(|device| device.circuit_hash) }) } @@ -321,23 +319,14 @@ pub(crate) fn submit_throughput_stats( let mut circuit_throughput: FxHashMap = FxHashMap::default(); let mut circuit_retransmits: FxHashMap> = FxHashMap::default(); - let shaped_devices = SHAPED_DEVICES.load(); - let shaped_cache = SHAPED_DEVICE_HASH_CACHE.load(); + let catalog = lqos_network_devices::network_devices_catalog(); const CRAZY_LIMIT: u64 = 8; // 8x the max bandwidth - let plan_lookup: FxHashMap = shaped_devices - .devices - .iter() - // Bandwidth: mbps * 1_000_000 to bytes - .map(|d| { - ( - d.circuit_hash, - ( - d.download_max_mbps.round() as u64 * 1_000_000 * CRAZY_LIMIT, - d.upload_max_mbps.round() as u64 * 1_000_000 * CRAZY_LIMIT, - ), - ) - }) - .collect(); + let mut plan_lookup: FxHashMap = FxHashMap::default(); + for device in catalog.iter_all_devices() { + let entry = plan_lookup.entry(device.circuit_hash).or_insert((0, 0)); + entry.0 = entry.0.max(device.download_max_mbps.round() as u64 * 1_000_000 * CRAZY_LIMIT); + entry.1 = entry.1.max(device.upload_max_mbps.round() as u64 * 1_000_000 * CRAZY_LIMIT); + } THROUGHPUT_TRACKER .raw_data @@ -345,7 +334,7 @@ pub(crate) fn submit_throughput_stats( .iter() .filter_map(|(ip, h)| { let circuit_hash = - resolved_circuit_hash_for_submission(&shaped_devices, &shaped_cache, ip, h)?; + resolved_circuit_hash_for_submission(&catalog, ip, h)?; h.actual_bytes_per_second .not_zero() .then_some((circuit_hash, h)) @@ -388,7 +377,7 @@ pub(crate) fn submit_throughput_stats( .iter() .filter_map(|(ip, h)| { let circuit_hash = - resolved_circuit_hash_for_submission(&shaped_devices, &shaped_cache, ip, h)?; + resolved_circuit_hash_for_submission(&catalog, ip, h)?; (h.tcp_retransmits.not_zero() && !crazy_values.contains(&circuit_hash)) .then_some((circuit_hash, h)) }) @@ -496,10 +485,9 @@ pub(crate) fn submit_throughput_stats( } // Network tree stats - let tree = { - let reader = NETWORK_JSON.read(); - reader.get_nodes_when_ready().clone() - }; + let tree = lqos_network_devices::with_network_json_read(|net_json| { + net_json.get_nodes_when_ready().clone() + }); let mut site_throughput: Vec = Vec::new(); let mut site_retransmits: Vec = Vec::new(); let mut site_rtt: Vec = Vec::new(); @@ -648,7 +636,24 @@ fn combined_devices_network_hash() -> anyhow::Result { let sd_path = std::path::Path::new(&cfg.lqos_directory).join("ShapedDevices.csv"); read_to_string(sd_path)? }; - let combined = format!("{}\n{}", nj_as_string, sd_as_string); + let dynamic_snapshot = lqos_network_devices::dynamic_circuits_snapshot(); + let mut dynamic_devices: Vec = dynamic_snapshot + .iter() + .map(|circuit| circuit.shaped.clone()) + .collect(); + dynamic_devices.sort_by(|a, b| { + ( + a.circuit_id.trim().to_ascii_lowercase(), + a.device_id.trim().to_ascii_lowercase(), + ) + .cmp(&( + b.circuit_id.trim().to_ascii_lowercase(), + b.device_id.trim().to_ascii_lowercase(), + )) + }); + let dynamic_as_string = serde_json::to_string(&dynamic_devices)?; + + let combined = format!("{}\n{}\n{}", nj_as_string, sd_as_string, dynamic_as_string); let hash = hash_to_i64(&combined); Ok(hash) @@ -668,13 +673,13 @@ static LTS2_HASH: AtomicI64 = AtomicI64::new(0); /// transmission to Insight fn load_local_shaped_devices() -> anyhow::Result> { let cfg = load_config()?; + let dynamic_snapshot = lqos_network_devices::dynamic_circuits_snapshot(); if integration_ingress_enabled(cfg.as_ref()) { let topology_import = TopologyImportFile::load(cfg.as_ref())? .ok_or_else(|| anyhow::anyhow!("topology_import.json does not exist"))?; - return Ok(topology_import - .into_imported_bundle() - .shaped_devices - .devices); + let mut devices = topology_import.into_imported_bundle().shaped_devices.devices; + devices.extend(dynamic_snapshot.iter().map(|circuit| circuit.shaped.clone())); + return Ok(devices); } let sd_path = Path::new(&cfg.lqos_directory).join("ShapedDevices.csv"); if !sd_path.exists() { @@ -695,6 +700,7 @@ fn load_local_shaped_devices() -> anyhow::Result> { anyhow::bail!("Error reading ShapedDevices.csv"); } } + devices.extend(dynamic_snapshot.iter().map(|circuit| circuit.shaped.clone())); Ok(devices) } @@ -723,10 +729,12 @@ mod tests { use crate::throughput_tracker::throughput_entry::ThroughputEntry; use lqos_bus::TcHandle; use lqos_config::{ConfigShapedDevices, QueueMode, ShapedDevice}; + use lqos_network_devices::{NetworkDevicesCatalog, ShapedDevicesCatalog}; use lqos_utils::XdpIpAddress; use lqos_utils::qoo::QoqScores; use lqos_utils::units::DownUpOrder; use std::net::Ipv4Addr; + use std::sync::Arc; #[test] fn test_rate_for_submission_small_rates() { @@ -793,9 +801,12 @@ mod tests { device_id: "device-1".to_string(), parent_node: "Parent-A".to_string(), ipv4: vec![(Ipv4Addr::new(192, 168, 1, 10), 32)], + circuit_hash: hash_to_i64("circuit-1"), + device_hash: hash_to_i64("device-1"), ..Default::default() }]); - let cache = crate::shaped_devices_tracker::ShapedDeviceHashCache::default(); + let shaped_catalog = ShapedDevicesCatalog::from_shaped_devices(Arc::new(shaped)); + let catalog = NetworkDevicesCatalog::from_snapshots(shaped_catalog, Arc::new(Vec::new())); let ip = XdpIpAddress::from_ip("192.168.1.10".parse().expect("test IP should parse")); let entry = ThroughputEntry { circuit_id: Some("circuit-1".to_string()), @@ -829,7 +840,7 @@ mod tests { qoq: QoqScores::default(), }; - let circuit_hash = resolved_circuit_hash_for_submission(&shaped, &cache, &ip, &entry); + let circuit_hash = resolved_circuit_hash_for_submission(&catalog, &ip, &entry); assert_eq!(circuit_hash, Some(hash_to_i64("circuit-1"))); } diff --git a/src/rust/lqosd/src/throughput_tracker/tracking_data.rs b/src/rust/lqosd/src/throughput_tracker/tracking_data.rs index 2d828d82..c6472062 100644 --- a/src/rust/lqosd/src/throughput_tracker/tracking_data.rs +++ b/src/rust/lqosd/src/throughput_tracker/tracking_data.rs @@ -8,11 +8,10 @@ use super::{ }; use crate::throughput_tracker::CIRCUIT_RTT_BUFFERS; use crate::{ - shaped_devices_tracker::{SHAPED_DEVICE_HASH_CACHE, SHAPED_DEVICES}, stats::HIGH_WATERMARK, throughput_tracker::flow_data::{FlowbeeEffectiveDirection, expire_rtt_flows, flowbee_rtt_map}, }; -use fxhash::FxHashMap; +use fxhash::{FxHashMap, FxHashSet}; use lqos_bakery::BakeryCommands; use lqos_bus::TcHandle; use lqos_config::NetworkJson; @@ -172,10 +171,12 @@ impl ThroughputTracker { return; } - let shaped_devices = SHAPED_DEVICES.load(); + let catalog = lqos_network_devices::network_devices_catalog(); let mut capacity_lookup: FxHashMap = FxHashMap::default(); - capacity_lookup.reserve(shaped_devices.devices.len()); - shaped_devices.devices.iter().for_each(|device| { + capacity_lookup.reserve( + catalog.shaped_devices().devices_len() + catalog.dynamic_circuits().len(), + ); + catalog.iter_all_devices().for_each(|device| { let entry = capacity_lookup .entry(device.circuit_hash) .or_insert((device.download_max_mbps, device.upload_max_mbps)); @@ -409,45 +410,36 @@ impl ThroughputTracker { } fn shaped_device_for_ip_or_hashes<'a>( - shaped: &'a lqos_config::ConfigShapedDevices, - cache: &crate::shaped_devices_tracker::ShapedDeviceHashCache, + catalog: &'a lqos_network_devices::NetworkDevicesCatalog, ip: &XdpIpAddress, device_hash: Option, circuit_hash: Option, ) -> Option<&'a lqos_config::ShapedDevice> { - if let Some(device_hash) = device_hash - && let Some(idx) = cache.index_by_device_hash(shaped, device_hash) - { - return shaped.devices.get(idx); + if let Some(device) = catalog.device_by_hashes(device_hash, circuit_hash) { + return Some(device); } - if let Some(circuit_hash) = circuit_hash - && let Some(idx) = cache.index_by_circuit_hash(shaped, circuit_hash) - { - return shaped.devices.get(idx); - } - shaped.get_device_from_ip(ip) + catalog + .device_longest_match_for_ip(ip) + .map(|(_net, device)| device) } fn lookup_network_parents_from_ip_or_hashes( - shaped: &lqos_config::ConfigShapedDevices, - cache: &crate::shaped_devices_tracker::ShapedDeviceHashCache, + catalog: &lqos_network_devices::NetworkDevicesCatalog, ip: &XdpIpAddress, device_hash: Option, circuit_hash: Option, lock: &NetworkJson, ) -> Option> { - Self::shaped_device_for_ip_or_hashes(shaped, cache, ip, device_hash, circuit_hash) + Self::shaped_device_for_ip_or_hashes(catalog, ip, device_hash, circuit_hash) .and_then(|device| lock.get_parents_for_circuit_id(&device.parent_node)) } pub(crate) fn refresh_circuit_ids(&self, lock: &NetworkJson) { - let shaped = SHAPED_DEVICES.load(); - let cache = SHAPED_DEVICE_HASH_CACHE.load(); + let catalog = lqos_network_devices::network_devices_catalog(); let mut raw_data = self.raw_data.lock(); raw_data.iter_mut().for_each(|(ip, data)| { let shaped_device = Self::shaped_device_for_ip_or_hashes( - &shaped, - &cache, + &catalog, ip, data.device_hash, data.circuit_hash, @@ -473,12 +465,56 @@ impl ThroughputTracker { net_json_calc: &mut NetworkJson, bakery_sender: crossbeam_channel::Sender, ) { + const MAX_UNKNOWN_OBSERVATIONS_PER_TICK: usize = 256; + + let mut reported_dynamic_device_hashes: FxHashSet = FxHashSet::default(); + let mut reported_dynamic_circuit_hashes: FxHashSet = FxHashSet::default(); + let mut observations: Vec = Vec::new(); + let mut changed_circuits = HashSet::new(); let self_cycle = self.cycle.load(std::sync::atomic::Ordering::Relaxed); - let shaped = SHAPED_DEVICES.load(); - let cache = SHAPED_DEVICE_HASH_CACHE.load(); + let catalog = lqos_network_devices::network_devices_catalog(); let mut raw_data = self.raw_data.lock(); + + #[allow(clippy::too_many_arguments)] + fn record_dynamic_observation( + observations: &mut Vec, + reported_dynamic_device_hashes: &mut FxHashSet, + reported_dynamic_circuit_hashes: &mut FxHashSet, + catalog: &lqos_network_devices::NetworkDevicesCatalog, + ip: XdpIpAddress, + device_hash: Option, + circuit_hash: Option, + ) { + if catalog.dynamic_circuits().is_empty() { + return; + } + + if let Some(device_hash) = device_hash + && catalog.is_dynamic_device_hash(device_hash) + && reported_dynamic_device_hashes.insert(device_hash) + { + observations.push(lqos_network_devices::CircuitObservation { + ip, + device_hash: Some(device_hash), + circuit_hash, + }); + return; + } + + if let Some(circuit_hash) = circuit_hash + && catalog.is_dynamic_circuit_hash(circuit_hash) + && reported_dynamic_circuit_hashes.insert(circuit_hash) + { + observations.push(lqos_network_devices::CircuitObservation { + ip, + device_hash, + circuit_hash: Some(circuit_hash), + }); + } + } + throughput_for_each(&mut |xdp_ip, counts| { let reduced = ReducedHostCounters::from_counters(counts); if let Some(entry) = raw_data.get_mut(xdp_ip) { @@ -493,8 +529,7 @@ impl ThroughputTracker { entry.tc_handle = reduced.tc_handle; let shaped_device = Self::shaped_device_for_ip_or_hashes( - &shaped, - &cache, + &catalog, xdp_ip, reduced.device_hash, reduced.circuit_hash, @@ -510,10 +545,35 @@ impl ThroughputTracker { entry.circuit_hash = resolved_circuit_hash; entry.device_hash = resolved_device_hash; if hashes_changed { + let shaped_device = Self::shaped_device_for_ip_or_hashes( + &catalog, + xdp_ip, + entry.device_hash, + entry.circuit_hash, + ); entry.circuit_id = shaped_device.map(|d| d.circuit_id.clone()); entry.network_json_parents = shaped_device.and_then(|device| { net_json_calc.get_parents_for_circuit_id(&device.parent_node) }); + + if shaped_device.is_none() { + record_dynamic_observation( + &mut observations, + &mut reported_dynamic_device_hashes, + &mut reported_dynamic_circuit_hashes, + &catalog, + *xdp_ip, + entry.device_hash, + entry.circuit_hash, + ); + if observations.len() < MAX_UNKNOWN_OBSERVATIONS_PER_TICK { + observations.push(lqos_network_devices::CircuitObservation { + ip: *xdp_ip, + device_hash: entry.device_hash, + circuit_hash: entry.circuit_hash, + }); + } + } } if entry.packets != entry.prev_packets { entry.most_recent_cycle = self_cycle; @@ -522,6 +582,25 @@ impl ThroughputTracker { changed_circuits.insert(circuit_hash); } + record_dynamic_observation( + &mut observations, + &mut reported_dynamic_device_hashes, + &mut reported_dynamic_circuit_hashes, + &catalog, + *xdp_ip, + entry.device_hash, + entry.circuit_hash, + ); + if shaped_device.is_none() + && observations.len() < MAX_UNKNOWN_OBSERVATIONS_PER_TICK + { + observations.push(lqos_network_devices::CircuitObservation { + ip: *xdp_ip, + device_hash: entry.device_hash, + circuit_hash: entry.circuit_hash, + }); + } + if let Some(parents) = &entry.network_json_parents { net_json_calc.add_throughput_cycle( parents, @@ -574,8 +653,7 @@ impl ThroughputTracker { } } else { let shaped_device = Self::shaped_device_for_ip_or_hashes( - &shaped, - &cache, + &catalog, xdp_ip, reduced.device_hash, reduced.circuit_hash, @@ -587,6 +665,24 @@ impl ThroughputTracker { .device_hash .or_else(|| shaped_device.map(|device| device.device_hash)); let circuit_id = shaped_device.map(|d| d.circuit_id.clone()); + + record_dynamic_observation( + &mut observations, + &mut reported_dynamic_device_hashes, + &mut reported_dynamic_circuit_hashes, + &catalog, + *xdp_ip, + device_hash, + circuit_hash, + ); + if shaped_device.is_none() && observations.len() < MAX_UNKNOWN_OBSERVATIONS_PER_TICK { + observations.push(lqos_network_devices::CircuitObservation { + ip: *xdp_ip, + device_hash, + circuit_hash, + }); + } + // Call the Bakery Queue Creation for new circuits if let Some(circuit_hash) = circuit_hash && let Ok(config) = lqos_config::load_config() @@ -610,8 +706,7 @@ impl ThroughputTracker { circuit_hash, device_hash, network_json_parents: Self::lookup_network_parents_from_ip_or_hashes( - &shaped, - &cache, + &catalog, xdp_ip, device_hash, circuit_hash, @@ -647,6 +742,10 @@ impl ThroughputTracker { } }); + if !observations.is_empty() { + lqos_network_devices::report_observations(&observations); + } + if !changed_circuits.is_empty() && let Err(e) = bakery_sender.send(BakeryCommands::OnCircuitActivity { circuit_ids: changed_circuits, @@ -1319,20 +1418,19 @@ fn tcp_retransmit_loss_proxy(retransmits: u64, packets: u64) -> Option PathBuf { let unique = format!( "{}-{}-{}", @@ -1349,10 +1447,18 @@ mod tests { fn write_test_config(runtime_dir: &Path) -> PathBuf { let config_path = runtime_dir.join("lqos.test.toml"); - let raw = include_str!("../../../lqos_config/src/etc/v15/example.toml").replace( - "lqos_directory = \"/opt/libreqos/src\"", - &format!("lqos_directory = \"{}\"", runtime_dir.display()), - ); + let raw = include_str!("../../../lqos_config/src/etc/v15/example.toml") + .replace( + "lqos_directory = \"/opt/libreqos/src\"", + &format!("lqos_directory = \"{}\"", runtime_dir.display()), + ) + .replace( + "state_directory = \"/opt/libreqos/state\"", + &format!( + "state_directory = \"{}\"", + runtime_dir.join("state").display() + ), + ); std::fs::write(&config_path, raw).expect("test config should be written"); config_path } @@ -1361,7 +1467,6 @@ mod tests { _guard: MutexGuard<'static, ()>, old_lqos_config: Option, old_lqos_directory: Option, - old_shaped: Arc, } impl HeatmapTestContext { @@ -1373,7 +1478,6 @@ mod tests { let config_path = write_test_config(&runtime_dir); let old_lqos_config = std::env::var_os("LQOS_CONFIG"); let old_lqos_directory = std::env::var_os("LQOS_DIRECTORY"); - let old_shaped = SHAPED_DEVICES.load_full(); unsafe { std::env::set_var("LQOS_CONFIG", &config_path); std::env::set_var("LQOS_DIRECTORY", &runtime_dir); @@ -1383,7 +1487,6 @@ mod tests { _guard: guard, old_lqos_config, old_lqos_directory, - old_shaped, } } } @@ -1398,7 +1501,6 @@ mod tests { Some(value) => unsafe { std::env::set_var("LQOS_DIRECTORY", value) }, None => unsafe { std::env::remove_var("LQOS_DIRECTORY") }, } - SHAPED_DEVICES.store(self.old_shaped.clone()); lqos_config::clear_cached_config(); } } @@ -1459,12 +1561,12 @@ mod tests { ipv4: vec![(Ipv4Addr::new(192, 168, 1, 10), 32)], ..Default::default() }]); - let cache = ShapedDeviceHashCache::default(); + let shaped_catalog = ShapedDevicesCatalog::from_shaped_devices(Arc::new(shaped)); + let catalog = NetworkDevicesCatalog::from_snapshots(shaped_catalog, Arc::new(Vec::new())); let ip = XdpIpAddress::from_ip("192.168.1.10".parse().expect("test IP should parse")); - let matched = - ThroughputTracker::shaped_device_for_ip_or_hashes(&shaped, &cache, &ip, None, None) - .expect("lookup should resolve by IP"); + let matched = ThroughputTracker::shaped_device_for_ip_or_hashes(&catalog, &ip, None, None) + .expect("lookup should resolve by IP"); assert_eq!(matched.circuit_hash, hash_to_i64("circuit-1")); assert_eq!(matched.device_hash, hash_to_i64("device-1")); @@ -1473,17 +1575,7 @@ mod tests { #[test] fn circuit_and_global_heatmaps_suppress_low_confidence_retransmits() { let _ctx = HeatmapTestContext::new("retransmit-suppressed"); - let mut shaped = ConfigShapedDevices::default(); - shaped.replace_with_new_data(vec![ShapedDevice { - circuit_id: "circuit-1".to_string(), - device_id: "device-1".to_string(), - parent_node: "Parent-A".to_string(), - download_max_mbps: 100.0, - upload_max_mbps: 100.0, - ..Default::default() - }]); - let circuit_hash = shaped.devices[0].circuit_hash; - SHAPED_DEVICES.store(Arc::new(shaped)); + let circuit_hash = hash_to_i64("circuit-1"); let tracker = ThroughputTracker::new(); tracker.raw_data.lock().insert( @@ -1519,17 +1611,7 @@ mod tests { #[test] fn circuit_and_global_heatmaps_keep_qualified_retransmits() { let _ctx = HeatmapTestContext::new("retransmit-qualified"); - let mut shaped = ConfigShapedDevices::default(); - shaped.replace_with_new_data(vec![ShapedDevice { - circuit_id: "circuit-2".to_string(), - device_id: "device-2".to_string(), - parent_node: "Parent-B".to_string(), - download_max_mbps: 100.0, - upload_max_mbps: 100.0, - ..Default::default() - }]); - let circuit_hash = shaped.devices[0].circuit_hash; - SHAPED_DEVICES.store(Arc::new(shaped)); + let circuit_hash = hash_to_i64("circuit-2"); let tracker = ThroughputTracker::new(); tracker.raw_data.lock().insert( diff --git a/src/rust/lqosd/src/treeguard/actor.rs b/src/rust/lqosd/src/treeguard/actor.rs index 7f29fe33..0ffb2c65 100644 --- a/src/rust/lqosd/src/treeguard/actor.rs +++ b/src/rust/lqosd/src/treeguard/actor.rs @@ -5,7 +5,6 @@ use crate::node_manager::ws::messages::{TreeguardActivityEntry, TreeguardStatusData}; use crate::shaped_devices_tracker::circuit_live::fresh_circuit_live_snapshot; -use crate::shaped_devices_tracker::{NETWORK_JSON, SHAPED_DEVICES}; use crate::system_stats::SystemStats; use crate::throughput_tracker::CIRCUIT_RTT_BUFFERS; use crate::treeguard::TreeguardError; @@ -17,14 +16,14 @@ use crate::treeguard::{bakery, decisions, overrides}; use crossbeam_channel::{Receiver, Sender}; use fxhash::{FxHashMap, FxHashSet}; use lqos_bakery::{BakeryRuntimeNodeOperationFailureReason, BakeryRuntimeNodeOperationStatus}; -use lqos_config::{NetworkJsonNode, ShapedDevice, load_config}; +use lqos_config::{NetworkJsonNode, load_config}; use lqos_overrides::{NetworkAdjustment, OverrideFile, OverrideLayer, OverrideStore}; use lqos_utils::hash_to_i64; use lqos_utils::units::DownUpOrder; use lqos_utils::unix_time::{time_since_boot, unix_now}; use parking_lot::RwLock; use std::collections::{BTreeMap, VecDeque}; -use std::sync::{Arc, OnceLock}; +use std::sync::OnceLock; use std::time::{Duration, Instant}; use tracing::{debug, info, warn}; @@ -269,25 +268,25 @@ fn empty_status_snapshot() -> TreeguardStatusData { fn current_topology_totals() -> (usize, usize) { let total_nodes = { - let reader = NETWORK_JSON.read(); - reader - .get_nodes_when_ready() - .iter() - .filter(|n| n.name != "Root") - .count() + lqos_network_devices::with_network_json_read(|net_json| { + net_json + .get_nodes_when_ready() + .iter() + .filter(|n| n.name != "Root") + .count() + }) }; - let total_circuits = { - let shaped = SHAPED_DEVICES.load(); - let mut circuits: FxHashSet<&str> = FxHashSet::default(); - for d in shaped.devices.iter() { - let id = d.circuit_id.trim(); - if !id.is_empty() { - circuits.insert(id); - } + let catalog = lqos_network_devices::network_devices_catalog(); + let mut circuit_ids: FxHashSet<&str> = FxHashSet::default(); + for device in catalog.iter_all_devices() { + let circuit_id = device.circuit_id.trim(); + if circuit_id.is_empty() { + continue; } - circuits.len() - }; + circuit_ids.insert(circuit_id); + } + let total_circuits = circuit_ids.len(); (total_nodes, total_circuits) } @@ -304,9 +303,11 @@ fn direct_child_site_counts_by_node(nodes: &[NetworkJsonNode]) -> Vec { counts } -fn direct_circuit_counts_by_node(shaped_devices: &[ShapedDevice]) -> FxHashMap { +fn direct_circuit_counts_by_node( + shaped_devices: &lqos_network_devices::NetworkDevicesCatalog, +) -> FxHashMap { let mut circuits_by_node: FxHashMap> = FxHashMap::default(); - for device in shaped_devices { + for device in shaped_devices.iter_all_devices() { let node_name = device.parent_node.trim(); let circuit_id = device.circuit_id.trim(); if node_name.is_empty() || circuit_id.is_empty() { @@ -452,7 +453,8 @@ struct CircuitInventoryEntry { #[derive(Clone, Debug, Default)] struct CircuitInventory { - shaped_devices_ptr: usize, + shaped_devices_generation: u64, + dynamic_signature: u64, circuit_ids: Vec, entries: FxHashMap, all_device_ids: FxHashSet, @@ -606,21 +608,57 @@ fn select_link_virtualization_candidates( fn ensure_circuit_inventory( runtime_state: &mut TreeguardRuntimeState, - shaped: &Arc, + shaped: &lqos_network_devices::NetworkDevicesCatalog, ) { - let shaped_devices_ptr = Arc::as_ptr(shaped) as usize; - if runtime_state.circuit_inventory.shaped_devices_ptr == shaped_devices_ptr { + use std::hash::{Hash, Hasher}; + + fn dynamic_overlay_signature(dynamic: &[lqos_network_devices::DynamicCircuit]) -> u64 { + use fxhash::FxHasher; + + let mut entry_sigs: Vec = Vec::with_capacity(dynamic.len()); + for circuit in dynamic { + let dev = &circuit.shaped; + let mut h = FxHasher::default(); + dev.circuit_id.trim().hash(&mut h); + dev.device_id.trim().hash(&mut h); + dev.parent_node.trim().hash(&mut h); + dev.download_max_mbps.to_bits().hash(&mut h); + dev.upload_max_mbps.to_bits().hash(&mut h); + dev.sqm_override + .as_deref() + .unwrap_or("") + .trim() + .hash(&mut h); + entry_sigs.push(h.finish()); + } + entry_sigs.sort_unstable(); + + let mut outer = FxHasher::default(); + entry_sigs.hash(&mut outer); + outer.finish() + } + + let generation = shaped.shaped_devices().generation(); + let dynamic_signature = dynamic_overlay_signature(shaped.dynamic_circuits()); + if runtime_state.circuit_inventory.shaped_devices_generation == generation + && runtime_state.circuit_inventory.dynamic_signature == dynamic_signature + { return; } - runtime_state.circuit_inventory = build_circuit_inventory(shaped.as_ref()); + runtime_state.circuit_inventory = build_circuit_inventory(shaped, dynamic_signature); runtime_state.circuit_batch_cursor = 0; } -fn build_circuit_inventory(shaped: &lqos_config::ConfigShapedDevices) -> CircuitInventory { +fn build_circuit_inventory( + shaped: &lqos_network_devices::NetworkDevicesCatalog, + dynamic_signature: u64, +) -> CircuitInventory { let mut circuits_by_device_id: FxHashMap> = FxHashMap::default(); - circuits_by_device_id.reserve(shaped.devices.len()); - for device in shaped.devices.iter() { + circuits_by_device_id.reserve( + shaped.shaped_devices().devices_len() + shaped.dynamic_circuits().len(), + ); + for device in shaped.iter_all_devices() { let device_id = device.device_id.trim(); let circuit_id = device.circuit_id.trim(); if device_id.is_empty() || circuit_id.is_empty() { @@ -645,10 +683,10 @@ fn build_circuit_inventory(shaped: &lqos_config::ConfigShapedDevices) -> Circuit .collect(); let mut by_circuit_id: FxHashMap> = FxHashMap::default(); - by_circuit_id.reserve(shaped.devices.len()); + by_circuit_id.reserve(shaped.shaped_devices().devices_len() + shaped.dynamic_circuits().len()); let mut all_device_ids = FxHashSet::default(); - all_device_ids.reserve(shaped.devices.len()); - for device in shaped.devices.iter() { + all_device_ids.reserve(shaped.shaped_devices().devices_len() + shaped.dynamic_circuits().len()); + for device in shaped.iter_all_devices() { if device.circuit_id.trim().is_empty() { continue; } @@ -724,7 +762,8 @@ fn build_circuit_inventory(shaped: &lqos_config::ConfigShapedDevices) -> Circuit } CircuitInventory { - shaped_devices_ptr: shaped as *const _ as usize, + shaped_devices_generation: shaped.shaped_devices().generation(), + dynamic_signature, circuit_ids, entries, all_device_ids, @@ -837,7 +876,7 @@ fn run_tick( return; } - let shaped = SHAPED_DEVICES.load(); + let shaped = lqos_network_devices::network_devices_catalog(); ensure_circuit_inventory(runtime_state, &shaped); let link_states = &mut runtime_state.link_states; @@ -898,12 +937,13 @@ fn run_tick( } else { let mut enrolled: FxHashSet = tg.links.nodes.iter().cloned().collect(); if top_level_auto_virtualize { - let reader = NETWORK_JSON.read(); - for node in reader.get_nodes_when_ready().iter() { - if node.name != "Root" && node.immediate_parent == Some(0) { - enrolled.insert(node.name.clone()); + lqos_network_devices::with_network_json_read(|net_json| { + for node in net_json.get_nodes_when_ready().iter() { + if node.name != "Root" && node.immediate_parent == Some(0) { + enrolled.insert(node.name.clone()); + } } - } + }); } enrolled.len() }; @@ -1029,397 +1069,419 @@ fn run_tick( link_states.remove(&node_name); } } else { - let reader = NETWORK_JSON.read(); - let top_level_nodes: FxHashSet = if top_level_auto_virtualize && !tg.links.all_nodes - { - reader - .get_nodes_when_ready() - .iter() - .filter(|n| n.name != "Root" && n.immediate_parent == Some(0)) - .map(|n| n.name.clone()) - .collect() - } else { - FxHashSet::default() - }; + lqos_network_devices::with_network_json_read(|net_json| { + let top_level_nodes: FxHashSet = + if top_level_auto_virtualize && !tg.links.all_nodes { + net_json + .get_nodes_when_ready() + .iter() + .filter(|n| n.name != "Root" && n.immediate_parent == Some(0)) + .map(|n| n.name.clone()) + .collect() + } else { + FxHashSet::default() + }; - // Reconcile nodes removed from allowlist, or removed from network.json. - let treeguard_nodes_with_overrides: FxHashSet = treeguard_overrides_snapshot - .as_ref() - .map(|of| { - of.network_adjustments() + // Reconcile nodes removed from allowlist, or removed from network.json. + let treeguard_nodes_with_overrides: FxHashSet = treeguard_overrides_snapshot + .as_ref() + .map(|of| { + of.network_adjustments() + .iter() + .filter_map(|adj| match adj { + NetworkAdjustment::SetNodeVirtual { node_name, .. } => { + Some(node_name.clone()) + } + _ => None, + }) + .collect() + }) + .unwrap_or_default(); + + let mut removed: FxHashSet = if tg.links.all_nodes { + let current: FxHashSet<&str> = net_json + .get_nodes_when_ready() .iter() - .filter_map(|adj| match adj { - NetworkAdjustment::SetNodeVirtual { node_name, .. } => { - Some(node_name.clone()) - } - _ => None, - }) - .collect() - }) - .unwrap_or_default(); - - let mut removed: FxHashSet = if tg.links.all_nodes { - let current: FxHashSet<&str> = reader - .get_nodes_when_ready() - .iter() - .filter(|n| n.name != "Root") - .map(|n| n.name.as_str()) - .collect(); - treeguard_nodes_with_overrides - .iter() - .filter(|n| !current.contains(n.as_str())) - .cloned() - .collect() - } else { - treeguard_nodes_with_overrides - .iter() - .filter(|n| !allowlisted_nodes.contains(*n) && !top_level_nodes.contains(*n)) - .cloned() - .collect() - }; - if tg.links.all_nodes { - let current: FxHashSet<&str> = reader - .get_nodes_when_ready() - .iter() - .filter(|n| n.name != "Root") - .map(|n| n.name.as_str()) - .collect(); - removed.extend( - runtime_virtualized_nodes + .filter(|n| n.name != "Root") + .map(|n| n.name.as_str()) + .collect(); + treeguard_nodes_with_overrides .iter() .filter(|n| !current.contains(n.as_str())) - .cloned(), - ); - } else { - removed.extend( - runtime_virtualized_nodes + .cloned() + .collect() + } else { + treeguard_nodes_with_overrides .iter() .filter(|n| !allowlisted_nodes.contains(*n) && !top_level_nodes.contains(*n)) - .cloned(), - ); - } - for node_name in removed { - clear_legacy_treeguard_virtual_override( - status, - activity, - now_unix, - &node_name, - "Node removed from allowlist", - ); - restore_runtime_virtualization_if_needed( - status, - activity, - now_unix, - &node_name, - "Node removed from allowlist", - tg.dry_run, - runtime_virtualized_nodes, - pending_link_operations, - link_virtualization_backoff_until_unix, - link_states, - ); - managed_nodes.remove(&node_name); - link_states.remove(&node_name); - } - let nodes = reader.get_nodes_when_ready(); - let parent_by_index: Vec> = - nodes.iter().map(|node| node.immediate_parent).collect(); - let subtree_node_counts = build_subtree_node_counts(&parent_by_index); - let direct_child_site_counts = direct_child_site_counts_by_node(nodes); - let direct_circuit_counts = direct_circuit_counts_by_node(&shaped.devices); - let retained_runtime_branch_nodes: FxHashSet = - lqos_bakery::bakery_runtime_node_branch_snapshots() - .into_iter() - .map(|snapshot| snapshot.site_name) - .collect(); - let existing_virtualized_indices: FxHashSet = nodes - .iter() - .enumerate() - .filter_map(|(index, node)| { - (runtime_virtualized_nodes.contains(&node.name) - || retained_runtime_branch_nodes.contains(&node.name)) - .then_some(index) - }) - .collect(); - - let mut enrolled_nodes: Vec = if tg.links.all_nodes { - nodes - .iter() - .filter(|node| node.name != "Root") - .map(|node| node.name.clone()) - .collect() - } else { - let mut enrolled = tg.links.nodes.clone(); - if top_level_auto_virtualize { - enrolled.extend(top_level_nodes.iter().cloned()); + .cloned() + .collect() + }; + if tg.links.all_nodes { + let current: FxHashSet<&str> = net_json + .get_nodes_when_ready() + .iter() + .filter(|n| n.name != "Root") + .map(|n| n.name.as_str()) + .collect(); + removed.extend( + runtime_virtualized_nodes + .iter() + .filter(|n| !current.contains(n.as_str())) + .cloned(), + ); + } else { + removed.extend( + runtime_virtualized_nodes + .iter() + .filter(|n| { + !allowlisted_nodes.contains(*n) && !top_level_nodes.contains(*n) + }) + .cloned(), + ); } - enrolled.sort(); - enrolled.dedup(); - enrolled - }; + for node_name in removed { + clear_legacy_treeguard_virtual_override( + status, + activity, + now_unix, + &node_name, + "Node removed from allowlist", + ); + restore_runtime_virtualization_if_needed( + status, + activity, + now_unix, + &node_name, + "Node removed from allowlist", + tg.dry_run, + runtime_virtualized_nodes, + pending_link_operations, + link_virtualization_backoff_until_unix, + link_states, + ); + managed_nodes.remove(&node_name); + link_states.remove(&node_name); + } + let nodes = net_json.get_nodes_when_ready(); + let parent_by_index: Vec> = + nodes.iter().map(|node| node.immediate_parent).collect(); + let subtree_node_counts = build_subtree_node_counts(&parent_by_index); + let direct_child_site_counts = direct_child_site_counts_by_node(nodes); + let direct_circuit_counts = direct_circuit_counts_by_node(&shaped); + let retained_runtime_branch_nodes: FxHashSet = + lqos_bakery::bakery_runtime_node_branch_snapshots() + .into_iter() + .map(|snapshot| snapshot.site_name) + .collect(); + let existing_virtualized_indices: FxHashSet = nodes + .iter() + .enumerate() + .filter_map(|(index, node)| { + (runtime_virtualized_nodes.contains(&node.name) + || retained_runtime_branch_nodes.contains(&node.name)) + .then_some(index) + }) + .collect(); - if tg.links.all_nodes { - enrolled_nodes.sort(); - } + let mut enrolled_nodes: Vec = if tg.links.all_nodes { + nodes + .iter() + .filter(|node| node.name != "Root") + .map(|node| node.name.clone()) + .collect() + } else { + let mut enrolled = tg.links.nodes.clone(); + if top_level_auto_virtualize { + enrolled.extend(top_level_nodes.iter().cloned()); + } + enrolled.sort(); + enrolled.dedup(); + enrolled + }; - let mut pending_link_decisions = Vec::new(); + if tg.links.all_nodes { + enrolled_nodes.sort(); + } - for node_name in enrolled_nodes.iter() { - if operator_virtual_node_overrides.contains(node_name) { - status.warnings.push(format!( + let mut pending_link_decisions = Vec::new(); + + for node_name in enrolled_nodes.iter() { + if operator_virtual_node_overrides.contains(node_name) { + status.warnings.push(format!( "TreeGuard links: node '{node_name}' has an operator virtual override; TreeGuard will not manage it." )); - clear_legacy_treeguard_virtual_override( - status, - activity, - now_unix, - node_name, - "Operator override present; TreeGuard will not manage this node.", - ); - restore_runtime_virtualization_if_needed( - status, - activity, - now_unix, - node_name, - "Operator override present; TreeGuard will not manage this node.", - tg.dry_run, - runtime_virtualized_nodes, - pending_link_operations, - link_virtualization_backoff_until_unix, - link_states, - ); - managed_nodes.remove(node_name); - link_states.remove(node_name); - continue; - } + clear_legacy_treeguard_virtual_override( + status, + activity, + now_unix, + node_name, + "Operator override present; TreeGuard will not manage this node.", + ); + restore_runtime_virtualization_if_needed( + status, + activity, + now_unix, + node_name, + "Operator override present; TreeGuard will not manage this node.", + tg.dry_run, + runtime_virtualized_nodes, + pending_link_operations, + link_virtualization_backoff_until_unix, + link_states, + ); + managed_nodes.remove(node_name); + link_states.remove(node_name); + continue; + } - let Some(index) = reader.get_index_for_name(node_name) else { - status.warnings.push(format!( - "TreeGuard links allowlist: node '{node_name}' not found in network.json." - )); - clear_legacy_treeguard_virtual_override( - status, - activity, - now_unix, - node_name, - "Node no longer exists in network.json", - ); - restore_runtime_virtualization_if_needed( - status, - activity, - now_unix, - node_name, - "Node no longer exists in network.json", - tg.dry_run, - runtime_virtualized_nodes, - pending_link_operations, - link_virtualization_backoff_until_unix, - link_states, - ); - managed_nodes.remove(node_name); - link_states.remove(node_name); - continue; - }; - let Some(node) = nodes.get(index) else { - status.warnings.push(format!( - "TreeGuard links allowlist: node '{node_name}' index not present." - )); - clear_legacy_treeguard_virtual_override( - status, - activity, - now_unix, - node_name, - "Node index no longer exists in network.json", - ); - restore_runtime_virtualization_if_needed( - status, - activity, - now_unix, - node_name, - "Node index no longer exists in network.json", - tg.dry_run, - runtime_virtualized_nodes, - pending_link_operations, - link_virtualization_backoff_until_unix, - link_states, - ); - managed_nodes.remove(node_name); - link_states.remove(node_name); - continue; - }; + let Some(index) = net_json.get_index_for_name(node_name) else { + status.warnings.push(format!( + "TreeGuard links allowlist: node '{node_name}' not found in network.json." + )); + clear_legacy_treeguard_virtual_override( + status, + activity, + now_unix, + node_name, + "Node no longer exists in network.json", + ); + restore_runtime_virtualization_if_needed( + status, + activity, + now_unix, + node_name, + "Node no longer exists in network.json", + tg.dry_run, + runtime_virtualized_nodes, + pending_link_operations, + link_virtualization_backoff_until_unix, + link_states, + ); + managed_nodes.remove(node_name); + link_states.remove(node_name); + continue; + }; + let Some(node) = nodes.get(index) else { + status.warnings.push(format!( + "TreeGuard links allowlist: node '{node_name}' index not present." + )); + clear_legacy_treeguard_virtual_override( + status, + activity, + now_unix, + node_name, + "Node index no longer exists in network.json", + ); + restore_runtime_virtualization_if_needed( + status, + activity, + now_unix, + node_name, + "Node index no longer exists in network.json", + tg.dry_run, + runtime_virtualized_nodes, + pending_link_operations, + link_virtualization_backoff_until_unix, + link_states, + ); + managed_nodes.remove(node_name); + link_states.remove(node_name); + continue; + }; - if node.virtual_node { - status.warnings.push(format!( + if node.virtual_node { + status.warnings.push(format!( "TreeGuard links: node '{node_name}' is marked virtual in base network.json; TreeGuard will not manage it." )); - clear_legacy_treeguard_virtual_override( - status, - activity, - now_unix, - node_name, - "Node is marked virtual in base network.json; TreeGuard refuses to manage base-virtual nodes.", - ); - restore_runtime_virtualization_if_needed( - status, - activity, - now_unix, - node_name, - "Node is marked virtual in base network.json; TreeGuard refuses to manage base-virtual nodes.", - tg.dry_run, - runtime_virtualized_nodes, - pending_link_operations, - link_virtualization_backoff_until_unix, - link_states, - ); - managed_nodes.remove(node_name); - link_states.remove(node_name); - continue; - } + clear_legacy_treeguard_virtual_override( + status, + activity, + now_unix, + node_name, + "Node is marked virtual in base network.json; TreeGuard refuses to manage base-virtual nodes.", + ); + restore_runtime_virtualization_if_needed( + status, + activity, + now_unix, + node_name, + "Node is marked virtual in base network.json; TreeGuard refuses to manage base-virtual nodes.", + tg.dry_run, + runtime_virtualized_nodes, + pending_link_operations, + link_virtualization_backoff_until_unix, + link_states, + ); + managed_nodes.remove(node_name); + link_states.remove(node_name); + continue; + } - let cap_down = node.max_throughput.0; - let cap_up = node.max_throughput.1; - if cap_down <= 0.0 || cap_up <= 0.0 { - status.warnings.push(format!( + let cap_down = node.max_throughput.0; + let cap_up = node.max_throughput.1; + if cap_down <= 0.0 || cap_up <= 0.0 { + status.warnings.push(format!( "TreeGuard links: node '{node_name}' has unknown capacity; no changes will be made." )); - continue; - } - - let bytes_down = node.current_throughput.get_down() as f64; - let bytes_up = node.current_throughput.get_up() as f64; - let mbps_down = (bytes_down * 8.0) / 1_000_000.0; - let mbps_up = (bytes_up * 8.0) / 1_000_000.0; - let util_down_pct = (mbps_down / cap_down) * 100.0; - let util_up_pct = (mbps_up / cap_up) * 100.0; - - let state = link_states.entry(node_name.clone()).or_insert_with(|| { - let mut state = LinkState::default(); - if runtime_virtualized_nodes.contains(node_name) { - state.desired = LinkVirtualState::Virtual; + continue; } - state - }); - prune_recent_changes(&mut state.recent_changes_unix, now_unix); - let topology_fingerprint = LinkTopologyFingerprint { - direct_child_sites: direct_child_site_counts.get(index).copied().unwrap_or(0), - direct_circuits: direct_circuit_counts.get(node_name).copied().unwrap_or(0), - }; - if clear_structural_ineligible_if_topology_changed(state, topology_fingerprint) { - link_virtualization_backoff_until_unix.remove(node_name); - } - state.topology_fingerprint = topology_fingerprint; - let ewma_down = state - .down - .util_ewma_pct - .update(util_down_pct, UTIL_EWMA_ALPHA); - let ewma_up = state.up.util_ewma_pct.update(util_up_pct, UTIL_EWMA_ALPHA); + let bytes_down = node.current_throughput.get_down() as f64; + let bytes_up = node.current_throughput.get_up() as f64; + let mbps_down = (bytes_down * 8.0) / 1_000_000.0; + let mbps_up = (bytes_up * 8.0) / 1_000_000.0; + let util_down_pct = (mbps_down / cap_down) * 100.0; + let util_up_pct = (mbps_up / cap_up) * 100.0; - update_idle_since( - &mut state.down.idle_since_unix, - now_unix, - ewma_down, - tg.links.idle_util_pct as f64, - ); - update_idle_since( - &mut state.up.idle_since_unix, - now_unix, - ewma_up, - tg.links.idle_util_pct as f64, - ); - - let sustained_idle = is_sustained_idle( - now_unix, - state.down.idle_since_unix, - state.up.idle_since_unix, - tg.links.idle_min_minutes, - ); - - let is_top_level = top_level_auto_virtualize && node.immediate_parent == Some(0); - let top_level_safe_util_pct = tg.links.top_level_safe_util_pct.clamp(0.0, 100.0) as f64; - if is_top_level { - update_below_since( - &mut state.down.top_level_safe_since_unix, - now_unix, - ewma_down, - top_level_safe_util_pct, - ); - update_below_since( - &mut state.up.top_level_safe_since_unix, - now_unix, - ewma_up, - top_level_safe_util_pct, - ); - update_above_since( - &mut state.down.top_level_emergency_since_unix, - now_unix, - ewma_down, - TOP_LEVEL_EMERGENCY_UTIL_PCT, - ); - update_above_since( - &mut state.up.top_level_emergency_since_unix, - now_unix, - ewma_up, - TOP_LEVEL_EMERGENCY_UTIL_PCT, - ); - } - - let rtt_missing = match now_nanos_since_boot { - None => true, - Some(now_nanos) => { - if node.rtt_buffer.last_seen == 0 { - true - } else { - let age_nanos = now_nanos.saturating_sub(node.rtt_buffer.last_seen); - age_nanos - >= u64::from(tg.links.rtt_missing_seconds).saturating_mul(1_000_000_000) + let state = link_states.entry(node_name.clone()).or_insert_with(|| { + let mut state = LinkState::default(); + if runtime_virtualized_nodes.contains(node_name) { + state.desired = LinkVirtualState::Virtual; } - } - }; - - let qoo = node - .qoq_heatmap - .as_ref() - .map(|heatmap| { - let blocks = heatmap.blocks(); - let latest = |values: &[Option]| values.iter().rev().find_map(|v| *v); - DownUpOrder { - down: latest(&blocks.download_total), - up: latest(&blocks.upload_total), - } - }) - .unwrap_or(DownUpOrder { - down: None, - up: None, + state }); + prune_recent_changes(&mut state.recent_changes_unix, now_unix); + let topology_fingerprint = LinkTopologyFingerprint { + direct_child_sites: direct_child_site_counts.get(index).copied().unwrap_or(0), + direct_circuits: direct_circuit_counts.get(node_name).copied().unwrap_or(0), + }; + if clear_structural_ineligible_if_topology_changed(state, topology_fingerprint) { + link_virtualization_backoff_until_unix.remove(node_name); + } + state.topology_fingerprint = topology_fingerprint; - let util_ewma_pct = DownUpOrder { - down: ewma_down, - up: ewma_up, - }; - - let decision = if is_top_level { - let sustained_safe = is_sustained_window( - now_unix, - state.down.top_level_safe_since_unix, - state.up.top_level_safe_since_unix, - TOP_LEVEL_SAFE_SUSTAIN_MINUTES, - ); - let emergency_util_sustained = state + let ewma_down = state .down - .top_level_emergency_since_unix - .is_some_and(|since| { - now_unix.saturating_sub(since) >= TOP_LEVEL_EMERGENCY_SUSTAIN_SECONDS + .util_ewma_pct + .update(util_down_pct, UTIL_EWMA_ALPHA); + let ewma_up = state.up.util_ewma_pct.update(util_up_pct, UTIL_EWMA_ALPHA); + + update_idle_since( + &mut state.down.idle_since_unix, + now_unix, + ewma_down, + tg.links.idle_util_pct as f64, + ); + update_idle_since( + &mut state.up.idle_since_unix, + now_unix, + ewma_up, + tg.links.idle_util_pct as f64, + ); + + let sustained_idle = is_sustained_idle( + now_unix, + state.down.idle_since_unix, + state.up.idle_since_unix, + tg.links.idle_min_minutes, + ); + + let is_top_level = top_level_auto_virtualize && node.immediate_parent == Some(0); + let top_level_safe_util_pct = + tg.links.top_level_safe_util_pct.clamp(0.0, 100.0) as f64; + if is_top_level { + update_below_since( + &mut state.down.top_level_safe_since_unix, + now_unix, + ewma_down, + top_level_safe_util_pct, + ); + update_below_since( + &mut state.up.top_level_safe_since_unix, + now_unix, + ewma_up, + top_level_safe_util_pct, + ); + update_above_since( + &mut state.down.top_level_emergency_since_unix, + now_unix, + ewma_down, + TOP_LEVEL_EMERGENCY_UTIL_PCT, + ); + update_above_since( + &mut state.up.top_level_emergency_since_unix, + now_unix, + ewma_up, + TOP_LEVEL_EMERGENCY_UTIL_PCT, + ); + } + + let rtt_missing = match now_nanos_since_boot { + None => true, + Some(now_nanos) => { + if node.rtt_buffer.last_seen == 0 { + true + } else { + let age_nanos = now_nanos.saturating_sub(node.rtt_buffer.last_seen); + age_nanos + >= u64::from(tg.links.rtt_missing_seconds) + .saturating_mul(1_000_000_000) + } + } + }; + + let qoo = node + .qoq_heatmap + .as_ref() + .map(|heatmap| { + let blocks = heatmap.blocks(); + let latest = |values: &[Option]| values.iter().rev().find_map(|v| *v); + DownUpOrder { + down: latest(&blocks.download_total), + up: latest(&blocks.upload_total), + } }) - || state - .up + .unwrap_or(DownUpOrder { + down: None, + up: None, + }); + + let util_ewma_pct = DownUpOrder { + down: ewma_down, + up: ewma_up, + }; + + let decision = if is_top_level { + let sustained_safe = is_sustained_window( + now_unix, + state.down.top_level_safe_since_unix, + state.up.top_level_safe_since_unix, + TOP_LEVEL_SAFE_SUSTAIN_MINUTES, + ); + let emergency_util_sustained = state + .down .top_level_emergency_since_unix .is_some_and(|since| { now_unix.saturating_sub(since) >= TOP_LEVEL_EMERGENCY_SUSTAIN_SECONDS - }); - decisions::decide_top_level_link_virtualization( - decisions::TopLevelLinkVirtualizationInput { + }) + || state + .up + .top_level_emergency_since_unix + .is_some_and(|since| { + now_unix.saturating_sub(since) + >= TOP_LEVEL_EMERGENCY_SUSTAIN_SECONDS + }); + decisions::decide_top_level_link_virtualization( + decisions::TopLevelLinkVirtualizationInput { + now_unix, + cpu_max_pct, + cpu_cfg: &tg.cpu, + links_cfg: &tg.links, + qoo_cfg: &tg.qoo, + rtt_missing, + qoo, + util_ewma_pct, + safe_util_pct: top_level_safe_util_pct, + sustained_safe, + emergency_util_sustained, + state, + }, + ) + } else { + decisions::decide_link_virtualization(decisions::LinkVirtualizationInput { now_unix, + allowlisted: tg.links.all_nodes || allowlisted_nodes.contains(node_name), cpu_max_pct, cpu_cfg: &tg.cpu, links_cfg: &tg.links, @@ -1427,34 +1489,17 @@ fn run_tick( rtt_missing, qoo, util_ewma_pct, - safe_util_pct: top_level_safe_util_pct, - sustained_safe, - emergency_util_sustained, + sustained_idle, state, - }, - ) - } else { - decisions::decide_link_virtualization(decisions::LinkVirtualizationInput { - now_unix, - allowlisted: tg.links.all_nodes || allowlisted_nodes.contains(node_name), - cpu_max_pct, - cpu_cfg: &tg.cpu, - links_cfg: &tg.links, - qoo_cfg: &tg.qoo, - rtt_missing, - qoo, - util_ewma_pct, - sustained_idle, - state, - }) - }; + }) + }; - if let decisions::LinkVirtualDecision::Set(target) = decision - && target != state.desired - { - if let Some(reason) = latched_structural_ineligible_reason(state, target) { - let details = structural_failure_reason_label(reason); - warning_limiter.push( + if let decisions::LinkVirtualDecision::Set(target) = decision + && target != state.desired + { + if let Some(reason) = latched_structural_ineligible_reason(state, target) { + let details = structural_failure_reason_label(reason); + warning_limiter.push( status, format!( "runtime_structural_ineligible|{}|{:?}", @@ -1467,103 +1512,103 @@ fn run_tick( "TreeGuard runtime structural-ineligible warnings for reason={details}" ), ); - continue; - } - let reason = if is_top_level { - match target { - LinkVirtualState::Virtual => format!( - "Top-level safe: sustained utilization below {:.1}% for {} minutes", - top_level_safe_util_pct, TOP_LEVEL_SAFE_SUSTAIN_MINUTES - ), - LinkVirtualState::Physical => { - if state - .down - .top_level_emergency_since_unix - .is_some_and(|since| { - now_unix.saturating_sub(since) - >= TOP_LEVEL_EMERGENCY_SUSTAIN_SECONDS - }) - || state - .up + continue; + } + let reason = if is_top_level { + match target { + LinkVirtualState::Virtual => format!( + "Top-level safe: sustained utilization below {:.1}% for {} minutes", + top_level_safe_util_pct, TOP_LEVEL_SAFE_SUSTAIN_MINUTES + ), + LinkVirtualState::Physical => { + if state + .down .top_level_emergency_since_unix .is_some_and(|since| { now_unix.saturating_sub(since) >= TOP_LEVEL_EMERGENCY_SUSTAIN_SECONDS }) - { - format!( - "Top-level emergency restore: utilization >= {:.1}% for {}s", - TOP_LEVEL_EMERGENCY_UTIL_PCT, - TOP_LEVEL_EMERGENCY_SUSTAIN_SECONDS - ) - } else { - format!( - "Top-level restore: utilization above {:.1}%", - top_level_safe_util_pct - ) + || state.up.top_level_emergency_since_unix.is_some_and( + |since| { + now_unix.saturating_sub(since) + >= TOP_LEVEL_EMERGENCY_SUSTAIN_SECONDS + }, + ) + { + format!( + "Top-level emergency restore: utilization >= {:.1}% for {}s", + TOP_LEVEL_EMERGENCY_UTIL_PCT, + TOP_LEVEL_EMERGENCY_SUSTAIN_SECONDS + ) + } else { + format!( + "Top-level restore: utilization above {:.1}%", + top_level_safe_util_pct + ) + } } } - } - } else { - "Decision policy matched".to_string() - }; - pending_link_decisions.push(PendingLinkVirtualizationDecision { - node_name: node_name.clone(), - node_index: index, - target, - reason, - subtree_nodes: subtree_node_counts[index], - current_subtree_throughput_mbps: mbps_down.max(mbps_up), - explicit_allowlist: allowlisted_nodes.contains(node_name), - is_top_level, - value_score: link_virtualization_value_score( + } else { + "Decision policy matched".to_string() + }; + pending_link_decisions.push(PendingLinkVirtualizationDecision { + node_name: node_name.clone(), + node_index: index, + target, + reason, + subtree_nodes: subtree_node_counts[index], + current_subtree_throughput_mbps: mbps_down.max(mbps_up), + explicit_allowlist: allowlisted_nodes.contains(node_name), is_top_level, - subtree_node_counts[index], - cap_down, - cap_up, - ), - }); + value_score: link_virtualization_value_score( + is_top_level, + subtree_node_counts[index], + cap_down, + cap_up, + ), + }); + } + + managed_nodes.insert(node_name.clone()); } - managed_nodes.insert(node_name.clone()); - } - - let (selected_link_decisions, deferred_link_decisions, skipped_low_value_decisions) = - select_link_virtualization_candidates( - pending_link_decisions, - &parent_by_index, - &existing_virtualized_indices, - runtime_virtualized_nodes.len(), - ); - if deferred_link_decisions > 0 { - status.warnings.push(format!( + let (selected_link_decisions, deferred_link_decisions, skipped_low_value_decisions) = + select_link_virtualization_candidates( + pending_link_decisions, + &parent_by_index, + &existing_virtualized_indices, + runtime_virtualized_nodes.len(), + ); + if deferred_link_decisions > 0 { + status.warnings.push(format!( "TreeGuard links: deferred {deferred_link_decisions} lower-value or over-budget node virtualization changes this tick." )); - } - if skipped_low_value_decisions > 0 { - status.warnings.push(format!( + } + if skipped_low_value_decisions > 0 { + status.warnings.push(format!( "TreeGuard links: skipped {skipped_low_value_decisions} low-value automatic node virtualization candidates this tick because the subtree was too small for its current throughput." )); - } + } - for decision in selected_link_decisions { - let Some(state) = link_states.get_mut(&decision.node_name) else { - continue; - }; - apply_link_virtualization_decision( - status, - activity, - now_unix, - &decision.node_name, - decision.target, - decision.is_top_level, - decision.reason, - tg.dry_run, - state, - pending_link_operations, - link_virtualization_backoff_until_unix, - ); - } + for decision in selected_link_decisions { + let Some(state) = link_states.get_mut(&decision.node_name) else { + continue; + }; + apply_link_virtualization_decision( + status, + activity, + now_unix, + &decision.node_name, + decision.target, + decision.is_top_level, + decision.reason, + tg.dry_run, + state, + pending_link_operations, + link_virtualization_backoff_until_unix, + ); + } + }); } // --- Circuit sampling + decisions (SQM switching) --- @@ -3271,7 +3316,6 @@ mod tests { try_consume_circuit_change_budget, }; use crate::node_manager::ws::messages::TreeguardActivityEntry; - use crate::shaped_devices_tracker::{NETWORK_JSON, SHAPED_DEVICES}; use crate::system_stats::SystemStats; use crate::throughput_tracker::CIRCUIT_RTT_BUFFERS; use crate::treeguard::decisions; @@ -4097,6 +4141,13 @@ mod tests { "lqos_directory = \"/opt/libreqos/src\"", &format!("lqos_directory = \"{}\"", runtime_dir.display()), ) + .replace( + "state_directory = \"/opt/libreqos/state\"", + &format!( + "state_directory = \"{}\"", + runtime_dir.join("state").display() + ), + ) .replace("use_xdp_bridge = true", "use_xdp_bridge = false") .replace( "[treeguard.links]\nenabled = true", @@ -4192,11 +4243,14 @@ mod tests { }]; let mut shaped = ConfigShapedDevices::default(); shaped.replace_with_new_data(devices.clone()); - let old_shaped = SHAPED_DEVICES.load_full(); - SHAPED_DEVICES.store(Arc::new(shaped)); + let old_shaped = lqos_network_devices::swap_shaped_devices_snapshot( + "treeguard:test:run_tick_end_to_end", + Arc::new(shaped), + ); - let old_network = NETWORK_JSON.read().nodes.clone(); - NETWORK_JSON.write().nodes = Vec::new(); + let old_network = + lqos_network_devices::with_network_json_read(|net_json| net_json.nodes.clone()); + lqos_network_devices::with_network_json_write(|net_json| net_json.nodes = Vec::new()); let old_queue_structure = QUEUE_STRUCTURE.load_full(); QUEUE_STRUCTURE.store(Arc::new(QueueStructure { @@ -4305,8 +4359,11 @@ mod tests { assert_eq!(circuit_hash, lqos_utils::hash_to_i64("circuit-3")); assert_eq!(sqm_override, Some("fq_codel/fq_codel".to_string())); - SHAPED_DEVICES.store(old_shaped); - NETWORK_JSON.write().nodes = old_network; + let _ = lqos_network_devices::swap_shaped_devices_snapshot( + "treeguard:test:run_tick_end_to_end:restore", + old_shaped, + ); + lqos_network_devices::with_network_json_write(|net_json| net_json.nodes = old_network); QUEUE_STRUCTURE.store(old_queue_structure); CIRCUIT_RTT_BUFFERS.store(old_rtt); } diff --git a/src/rust/lqosd/src/validation.rs b/src/rust/lqosd/src/validation.rs index de6b5516..a32c141b 100644 --- a/src/rust/lqosd/src/validation.rs +++ b/src/rust/lqosd/src/validation.rs @@ -1,45 +1,9 @@ use lqos_bus::BusResponse; -use lqos_config::ConfigShapedDevices; -use lqos_topology_compile::TopologyImportFile; pub fn validate_shaped_devices_csv() -> BusResponse { - let result: anyhow::Result = match lqos_config::load_config() { - Ok(config) => { - if integration_ingress_enabled(config.as_ref()) { - match TopologyImportFile::load(config.as_ref()) { - Ok(Some(topology_import)) => { - Ok(topology_import.into_imported_bundle().shaped_devices) - } - Ok(None) => Ok(ConfigShapedDevices::default()), - Err(err) => Err(err), - } - } else { - ConfigShapedDevices::load().map_err(anyhow::Error::from) - } - } - Err(err) => Err(anyhow::Error::from(err)), - }; + let result = lqos_network_devices::load_shaped_devices(); match result { Ok(..) => BusResponse::Ack, Err(e) => BusResponse::ShapedDevicesValidation(format!("{e:#?}")), } } - -fn integration_ingress_enabled(config: &lqos_config::Config) -> bool { - config.uisp_integration.enable_uisp - || config.splynx_integration.enable_splynx - || config - .netzur_integration - .as_ref() - .is_some_and(|integration| integration.enable_netzur) - || config - .visp_integration - .as_ref() - .is_some_and(|integration| integration.enable_visp) - || config.powercode_integration.enable_powercode - || config.sonar_integration.enable_sonar - || config - .wispgate_integration - .as_ref() - .is_some_and(|integration| integration.enable_wispgate) -} diff --git a/src/rust/lqtop/src/widgets/top_flows.rs b/src/rust/lqtop/src/widgets/top_flows.rs index b33eeafc..9ea551d8 100644 --- a/src/rust/lqtop/src/widgets/top_flows.rs +++ b/src/rust/lqtop/src/widgets/top_flows.rs @@ -3,6 +3,23 @@ use lqos_bus::{BusResponse, FlowbeeSummaryData}; use lqos_utils::packet_scale::scale_bits; use ratatui::prelude::*; +fn truncate_by_chars(input: &str, max_chars: usize) -> String { + input.chars().take(max_chars).collect() +} + +fn flow_circuit_label(flow: &FlowbeeSummaryData) -> String { + let candidate = if !flow.circuit_name.trim().is_empty() { + flow.circuit_name.trim() + } else { + flow.circuit_id.trim() + }; + if candidate.is_empty() { + "-".to_string() + } else { + truncate_by_chars(candidate, 28) + } +} + pub struct TopFlows { bus_link: tokio::sync::mpsc::Sender, rx: std::sync::mpsc::Receiver, @@ -38,6 +55,7 @@ impl TopWidget for TopFlows { fn render_to_frame(&mut self, frame: &mut Frame) { let mut t = TableHelper::new([ + "Circuit", "Src IP", "Dst IP", "Type", @@ -49,6 +67,7 @@ impl TopWidget for TopFlows { ]); for flow in self.flows.iter() { t.add_row([ + flow_circuit_label(flow), flow.local_ip.to_string(), flow.remote_ip.to_string(), flow.analysis.to_string(), diff --git a/src/rust/lqtop/src/widgets/top_hosts.rs b/src/rust/lqtop/src/widgets/top_hosts.rs index b35ddc8b..20097ced 100644 --- a/src/rust/lqtop/src/widgets/top_hosts.rs +++ b/src/rust/lqtop/src/widgets/top_hosts.rs @@ -3,6 +3,19 @@ use lqos_bus::{BusResponse, IpStats}; use lqos_utils::packet_scale::{scale_bits, scale_packets}; use ratatui::prelude::*; +fn truncate_by_chars(input: &str, max_chars: usize) -> String { + input.chars().take(max_chars).collect() +} + +fn circuit_label(circuit_id: &str) -> String { + let trimmed = circuit_id.trim(); + if trimmed.is_empty() { + "-".to_string() + } else { + truncate_by_chars(trimmed, 28) + } +} + pub struct TopHosts { bus_link: tokio::sync::mpsc::Sender, rx: std::sync::mpsc::Receiver, @@ -38,6 +51,7 @@ impl TopWidget for TopHosts { fn render_to_frame(&mut self, frame: &mut Frame) { let mut t = TableHelper::new([ + "Circuit", "IP Address", "Down (bps)", "Up (bps)", @@ -49,6 +63,7 @@ impl TopWidget for TopHosts { for host in self.stats.iter() { t.add_row([ + circuit_label(&host.circuit_id), host.ip_address.to_string(), scale_bits(host.bits_per_second.down), scale_bits(host.bits_per_second.up),