diff --git a/Cargo.lock b/Cargo.lock index 77db37f..1f2226d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -67,12 +67,142 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "askama" +version = "0.15.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b8246bcbf8eb97abef10c2d92166449680d41d55c0fc6978a91dec2e3619608" +dependencies = [ + "askama_macros", + "itoa", + "percent-encoding", + "serde", + "serde_json", +] + +[[package]] +name = "askama_derive" +version = "0.15.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f9670bc84a28bb3da91821ef74226949ab63f1265aff7c751634f1dd0e6f97c" +dependencies = [ + "askama_parser", + "basic-toml", + "memchr", + "proc-macro2", + "quote", + "rustc-hash", + "serde", + "serde_derive", + "syn", +] + +[[package]] +name = "askama_macros" +version = "0.15.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0756b45480437dded0565dfc568af62ccce146fb6cfe902e808ba86e445f44f" +dependencies = [ + "askama_derive", +] + +[[package]] +name = "askama_parser" +version = "0.15.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0af3691ba3af77949c0b5a3925444b85cb58a0184cc7fec16c68ba2e7be868" +dependencies = [ + "rustc-hash", + "serde", + "serde_derive", + "unicode-ident", + "winnow", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + [[package]] name = "autocfg" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "axum" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "basic-toml" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a" +dependencies = [ + "serde", +] + [[package]] name = "bitflags" version = "2.11.0" @@ -102,28 +232,51 @@ name = "btest-rs" version = "0.6.0" dependencies = [ "anyhow", + "askama", + "axum", "bytes", "clap", "hostname", + "ldap3", "md-5", "num-bigint", "num-integer", "num-traits", "rand", + "rusqlite", + "serde", + "serde_json", "sha2", "socket2 0.5.10", "thiserror", "tokio", + "tower-http", "tracing", "tracing-subscriber", ] +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + [[package]] name = "bytes" version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +[[package]] +name = "cc" +version = "1.2.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1e928d4b69e3077709075a938a05ffbedfa53a84c8f766efbf8220bb1ff60e1" +dependencies = [ + "find-msvc-tools", + "shlex", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -182,6 +335,22 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + [[package]] name = "cpufeatures" version = "0.3.0" @@ -231,6 +400,23 @@ dependencies = [ "crypto-common 0.2.1", ] +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + [[package]] name = "errno" version = "0.3.14" @@ -241,6 +427,154 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -262,6 +596,46 @@ dependencies = [ "wasi", ] +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "foldhash 0.2.0", +] + +[[package]] +name = "hashlink" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea0b22561a9c04a7cb1a302c013e0259cd3b4bb619f145b32f72b8b4bcbed230" +dependencies = [ + "hashbrown 0.16.1", +] + [[package]] name = "heck" version = "0.5.0" @@ -279,6 +653,57 @@ dependencies = [ "windows-link", ] +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "http-range-header" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "hybrid-array" version = "0.4.9" @@ -288,24 +713,257 @@ dependencies = [ "typenum", ] +[[package]] +name = "hyper" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "bytes", + "http", + "http-body", + "hyper", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.94" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e04e2ef80ce82e13552136fabeef8a5ed1f985a96805761cbb9a2c34e7664d9" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + [[package]] name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[package]] +name = "lber" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbcf559624bfd9fe8d488329a8959766335a43a9b8b2cdd6a2c379fca02909a5" +dependencies = [ + "bytes", + "nom", +] + +[[package]] +name = "ldap3" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01fe89f5e7cfb7e4701e3a38ff9f00358e026a9aee940355d88ee9d81e5c7503" +dependencies = [ + "async-trait", + "bytes", + "futures", + "futures-util", + "lber", + "log", + "native-tls", + "nom", + "percent-encoding", + "thiserror", + "tokio", + "tokio-native-tls", + "tokio-stream", + "tokio-util", + "url", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + [[package]] name = "libc" version = "0.2.183" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" +[[package]] +name = "libsqlite3-sys" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + [[package]] name = "lock_api" version = "0.4.14" @@ -330,6 +988,12 @@ dependencies = [ "regex-automata", ] +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + [[package]] name = "md-5" version = "0.10.6" @@ -346,6 +1010,28 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "mio" version = "1.2.0" @@ -357,6 +1043,33 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -406,6 +1119,50 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "openssl" +version = "0.10.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "951c002c75e16ea2c65b8c7e4d3d51d5530d8dfa7d060b4776828c88cfb18ecf" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.112" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57d55af3b3e226502be1526dfdba67ab0e9c96fc293004e79576b2b9edb0dbdb" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "parking_lot" version = "0.12.5" @@ -429,12 +1186,33 @@ dependencies = [ "windows-link", ] +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + [[package]] name = "pin-project-lite" version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -444,6 +1222,16 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -462,6 +1250,12 @@ dependencies = [ "proc-macro2", ] +[[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" @@ -489,7 +1283,7 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom", + "getrandom 0.2.17", ] [[package]] @@ -518,12 +1312,172 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +[[package]] +name = "rsqlite-vfs" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8a1f2315036ef6b1fbacd1972e8ee7688030b0a2121edfc2a6550febd41574d" +dependencies = [ + "hashbrown 0.16.1", + "thiserror", +] + +[[package]] +name = "rusqlite" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0d2b0146dd9661bf67bb107c0bb2a55064d556eeb3fc314151b957f313bcd4e" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", + "sqlite-wasm-rs", +] + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + [[package]] name = "sha2" version = "0.11.0" @@ -544,6 +1498,12 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -554,6 +1514,12 @@ dependencies = [ "libc", ] +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + [[package]] name = "smallvec" version = "1.15.1" @@ -580,6 +1546,24 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "sqlite-wasm-rs" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4206ed3a67690b9c29b77d728f6acc3ce78f16bf846d83c94f76400320181b" +dependencies = [ + "cc", + "js-sys", + "rsqlite-vfs", + "wasm-bindgen", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "strsim" version = "0.11.1" @@ -597,6 +1581,36 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "thiserror" version = "2.0.18" @@ -626,6 +1640,16 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + [[package]] name = "tokio" version = "1.50.0" @@ -654,12 +1678,101 @@ dependencies = [ "syn", ] +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "bitflags", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "http-range-header", + "httpdate", + "mime", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + [[package]] name = "tracing" version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ + "log", "pin-project-lite", "tracing-attributes", "tracing-core", @@ -721,12 +1834,42 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "utf8parse" version = "0.2.2" @@ -739,6 +1882,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version_check" version = "0.9.5" @@ -751,6 +1900,103 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0551fc1bb415591e3372d0bc4780db7e587d84e2a7e79da121051c5c4b89d0b0" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fbdf9a35adf44786aecd5ff89b4563a90325f9da0923236f6104e603c7e86be" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dca9693ef2bab6d4e6707234500350d8dad079eb508dca05530c85dc3a529ff2" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39129a682a6d2d841b6c429d0c51e5cb0ed1a03829d8b3d1e69a011e62cb3d3b" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + [[package]] name = "windows-link" version = "0.2.1" @@ -839,6 +2085,132 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "winnow" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + [[package]] name = "zerocopy" version = "0.8.48" @@ -858,3 +2230,63 @@ dependencies = [ "quote", "syn", ] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml index 4425008..993ae7f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,6 +24,15 @@ path = "src/bin/client_only.rs" name = "btest-server" path = "src/bin/server_only.rs" +[[bin]] +name = "btest-server-pro" +path = "src/server_pro/main.rs" +required-features = ["pro"] + +[features] +default = [] +pro = ["dep:rusqlite", "dep:ldap3", "dep:axum", "dep:tower-http", "dep:serde", "dep:serde_json", "dep:askama"] + [dependencies] tokio = { version = "1", features = ["full"] } clap = { version = "4", features = ["derive"] } @@ -40,6 +49,13 @@ num-traits = "0.2.19" num-integer = "0.1.46" sha2 = "0.11.0" hostname = "0.4.2" +rusqlite = { version = "0.39.0", features = ["bundled"], optional = true } +ldap3 = { version = "0.12.1", optional = true } +axum = { version = "0.8.8", features = ["tokio"], optional = true } +tower-http = { version = "0.6.8", features = ["fs", "cors"], optional = true } +serde = { version = "1.0.228", features = ["derive"], optional = true } +serde_json = { version = "1.0.149", optional = true } +askama = { version = "0.15.6", optional = true } [profile.release] opt-level = 3 diff --git a/src/bandwidth.rs b/src/bandwidth.rs index 5386a68..f79e4fe 100644 --- a/src/bandwidth.rs +++ b/src/bandwidth.rs @@ -20,6 +20,9 @@ pub struct BandwidthState { pub intervals: AtomicU32, /// Remote peer's CPU usage (received via status messages) pub remote_cpu: AtomicU8, + /// Remaining byte budget (TX + RX combined). When this reaches 0 the test + /// stops immediately. u64::MAX means unlimited (default for non-pro server). + pub byte_budget: AtomicU64, } impl BandwidthState { @@ -38,6 +41,7 @@ impl BandwidthState { total_lost_packets: AtomicU64::new(0), intervals: AtomicU32::new(0), remote_cpu: AtomicU8::new(0), + byte_budget: AtomicU64::new(u64::MAX), }) } @@ -50,6 +54,29 @@ impl BandwidthState { self.intervals.fetch_add(1, Relaxed); } + /// Try to spend `amount` bytes from the budget. Returns `true` if allowed, + /// `false` if the budget is exhausted (and sets `running = false`). + #[inline] + pub fn spend_budget(&self, amount: u64) -> bool { + use std::sync::atomic::Ordering::{Relaxed, SeqCst}; + // Fast path: unlimited budget (non-pro server) + let current = self.byte_budget.load(Relaxed); + if current == u64::MAX { + return true; + } + if current < amount { + self.running.store(false, SeqCst); + return false; + } + self.byte_budget.fetch_sub(amount, Relaxed); + true + } + + /// Set the byte budget (total bytes allowed for the entire test). + pub fn set_budget(&self, budget: u64) { + self.byte_budget.store(budget, std::sync::atomic::Ordering::SeqCst); + } + /// Get summary for syslog reporting. pub fn summary(&self) -> (u64, u64, u64, u32) { use std::sync::atomic::Ordering::Relaxed; diff --git a/src/server.rs b/src/server.rs index 778772c..80091cc 100644 --- a/src/server.rs +++ b/src/server.rs @@ -366,8 +366,40 @@ async fn handle_client( // --- TCP Test Server --- +/// Public TX task for multi-connection use by server_pro. +pub async fn tcp_tx_task( + writer: tokio::net::tcp::OwnedWriteHalf, + tx_size: usize, + tx_speed: u32, + state: Arc, +) { + tcp_tx_loop(writer, tx_size, tx_speed, state).await; +} + +/// Public RX task for multi-connection use by server_pro. +pub async fn tcp_rx_task( + reader: tokio::net::tcp::OwnedReadHalf, + state: Arc, +) { + tcp_rx_loop(reader, state).await; +} + +/// Run a TCP bandwidth test on an already-authenticated stream. +/// Public API for use by server_pro. +pub async fn run_tcp_test( + stream: TcpStream, + cmd: Command, + state: Arc, +) -> Result<(u64, u64, u64, u32)> { + run_tcp_test_inner(stream, cmd, state).await +} + async fn run_tcp_test_server(stream: TcpStream, cmd: Command) -> Result<(u64, u64, u64, u32)> { let state = BandwidthState::new(); + run_tcp_test_inner(stream, cmd, state).await +} + +async fn run_tcp_test_inner(stream: TcpStream, cmd: Command, state: Arc) -> Result<(u64, u64, u64, u32)> { let tx_size = cmd.tx_size as usize; let server_should_tx = cmd.server_tx(); let server_should_rx = cmd.server_rx(); @@ -437,9 +469,22 @@ async fn run_tcp_test_server(stream: TcpStream, cmd: Command) -> Result<(u64, u6 Ok(state.summary()) } +/// Public API for multi-connection TCP test with external state. Used by server_pro. +pub async fn run_tcp_multiconn_test( + streams: Vec, + cmd: Command, + state: Arc, +) -> Result<(u64, u64, u64, u32)> { + run_tcp_multiconn_inner(streams, cmd, state).await +} + /// TCP multi-connection. async fn run_tcp_multiconn_server(streams: Vec, cmd: Command) -> Result<(u64, u64, u64, u32)> { let state = BandwidthState::new(); + run_tcp_multiconn_inner(streams, cmd, state).await +} + +async fn run_tcp_multiconn_inner(streams: Vec, cmd: Command, state: Arc) -> Result<(u64, u64, u64, u32)> { let tx_size = cmd.tx_size as usize; let server_should_tx = cmd.server_tx(); let server_should_rx = cmd.server_rx(); @@ -550,6 +595,9 @@ async fn tcp_tx_loop_inner( next_status = Instant::now() + Duration::from_secs(1); } + if !state.spend_budget(tx_size as u64) { + break; + } if writer.write_all(&packet).await.is_err() { state.running.store(false, Ordering::SeqCst); break; @@ -586,6 +634,9 @@ async fn tcp_rx_loop(mut reader: tokio::net::tcp::OwnedReadHalf, state: Arc { + if !state.spend_budget(n as u64) { + break; + } state.rx_bytes.fetch_add(n as u64, Ordering::Relaxed); } } @@ -633,6 +684,18 @@ async fn tcp_status_sender( // --- UDP Test Server --- +/// Run a UDP bandwidth test on an already-authenticated stream. +/// Public API for use by server_pro. Caller provides the UDP port offset. +pub async fn run_udp_test( + stream: &mut TcpStream, + peer: SocketAddr, + cmd: &Command, + state: Arc, + udp_port_start: u16, +) -> Result<(u64, u64, u64, u32)> { + run_udp_test_inner(stream, peer, cmd, state, udp_port_start).await +} + async fn run_udp_test_server( stream: &mut TcpStream, peer: SocketAddr, @@ -640,7 +703,17 @@ async fn run_udp_test_server( udp_port_offset: Arc, ) -> Result<(u64, u64, u64, u32)> { let offset = udp_port_offset.fetch_add(1, Ordering::SeqCst); - let server_udp_port = BTEST_UDP_PORT_START + offset; + let state = BandwidthState::new(); + run_udp_test_inner(stream, peer, cmd, state, BTEST_UDP_PORT_START + offset).await +} + +async fn run_udp_test_inner( + stream: &mut TcpStream, + peer: SocketAddr, + cmd: &Command, + state: Arc, + server_udp_port: u16, +) -> Result<(u64, u64, u64, u32)> { let client_udp_port = server_udp_port + BTEST_PORT_CLIENT_OFFSET; stream.write_all(&server_udp_port.to_be_bytes()).await?; @@ -707,7 +780,6 @@ async fn run_udp_test_server( if use_unconnected { "unconnected" } else { "connected" }, ); - let state = BandwidthState::new(); let tx_size = cmd.tx_size as usize; let server_should_tx = cmd.server_tx(); let server_should_rx = cmd.server_rx(); @@ -761,6 +833,10 @@ async fn udp_tx_loop( let mut consecutive_errors: u32 = 0; while state.running.load(Ordering::Relaxed) { + if !state.spend_budget(tx_size as u64) { + break; + } + packet[0..4].copy_from_slice(&seq.to_be_bytes()); let result = if multi_conn { @@ -836,6 +912,9 @@ async fn udp_rx_loop(socket: &UdpSocket, state: Arc) { // (multi-connection MikroTik sends from multiple ports) match tokio::time::timeout(Duration::from_secs(5), socket.recv_from(&mut buf)).await { Ok(Ok((n, _src))) if n >= 4 => { + if !state.spend_budget(n as u64) { + break; + } state.rx_bytes.fetch_add(n as u64, Ordering::Relaxed); state.rx_packets.fetch_add(1, Ordering::Relaxed); diff --git a/src/server_pro/enforcer.rs b/src/server_pro/enforcer.rs new file mode 100644 index 0000000..2b05847 --- /dev/null +++ b/src/server_pro/enforcer.rs @@ -0,0 +1,411 @@ +//! Mid-session quota enforcement. +//! +//! Runs alongside a bandwidth test, periodically checking if the user +//! or IP has exceeded their quota. Terminates the test if so. + +use std::net::IpAddr; +use std::sync::atomic::Ordering; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use btest_rs::bandwidth::BandwidthState; + +use super::quota::{Direction, QuotaManager}; + +/// Enforces quotas during an active test session. +/// Call `run()` as a spawned task — it will set `state.running = false` +/// when a quota is exceeded or max_duration is reached. +pub struct QuotaEnforcer { + quota_mgr: QuotaManager, + username: String, + ip: IpAddr, + state: Arc, + check_interval: Duration, + max_duration: Duration, +} + +#[derive(Debug, PartialEq)] +pub enum StopReason { + /// Test still running (not stopped) + Running, + /// Max duration reached + MaxDuration, + /// User daily quota exceeded + UserDailyQuota, + /// User weekly quota exceeded + UserWeeklyQuota, + /// User monthly quota exceeded + UserMonthlyQuota, + /// IP daily quota exceeded + IpDailyQuota, + /// IP weekly quota exceeded + IpWeeklyQuota, + /// IP monthly quota exceeded + IpMonthlyQuota, + /// Client disconnected normally + ClientDisconnected, +} + +impl std::fmt::Display for StopReason { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Running => write!(f, "running"), + Self::MaxDuration => write!(f, "max_duration_reached"), + Self::UserDailyQuota => write!(f, "user_daily_quota_exceeded"), + Self::UserWeeklyQuota => write!(f, "user_weekly_quota_exceeded"), + Self::UserMonthlyQuota => write!(f, "user_monthly_quota_exceeded"), + Self::IpDailyQuota => write!(f, "ip_daily_quota_exceeded"), + Self::IpWeeklyQuota => write!(f, "ip_weekly_quota_exceeded"), + Self::IpMonthlyQuota => write!(f, "ip_monthly_quota_exceeded"), + Self::ClientDisconnected => write!(f, "client_disconnected"), + } + } +} + +impl QuotaEnforcer { + pub fn new( + quota_mgr: QuotaManager, + username: String, + ip: IpAddr, + state: Arc, + check_interval_secs: u64, + max_duration_secs: u64, + ) -> Self { + Self { + quota_mgr, + username, + ip, + state, + check_interval: Duration::from_secs(check_interval_secs.max(1)), + max_duration: if max_duration_secs > 0 { + Duration::from_secs(max_duration_secs) + } else { + Duration::from_secs(u64::MAX / 2) // effectively unlimited + }, + } + } + + /// Run the enforcer loop. Returns the reason the test was stopped. + /// This should be spawned as a tokio task. + pub async fn run(&self) -> StopReason { + let start = Instant::now(); + let mut interval = tokio::time::interval(self.check_interval); + interval.tick().await; // consume first immediate tick + + loop { + interval.tick().await; + + // Check if test already ended normally + if !self.state.running.load(Ordering::Relaxed) { + return StopReason::ClientDisconnected; + } + + // Check max duration + if start.elapsed() >= self.max_duration { + tracing::warn!( + "Max duration ({:?}) reached for user '{}' from {}", + self.max_duration, self.username, self.ip, + ); + self.state.running.store(false, Ordering::SeqCst); + return StopReason::MaxDuration; + } + + // Flush current session bytes to DB before checking + // (read without reset — totals accumulate, we just need current snapshot) + let session_tx = self.state.total_tx_bytes.load(Ordering::Relaxed); + let session_rx = self.state.total_rx_bytes.load(Ordering::Relaxed); + + // Temporarily record session bytes so quota check sees them + // We use a separate "pending" record that gets finalized at session end + let ip_str = self.ip.to_string(); + + // Check user quotas + match self.check_user_with_session(session_tx, session_rx) { + StopReason::Running => {} + reason => { + tracing::warn!( + "Quota exceeded for user '{}' from {}: {} (session: tx={}, rx={})", + self.username, self.ip, reason, session_tx, session_rx, + ); + self.state.running.store(false, Ordering::SeqCst); + return reason; + } + } + + // Check IP quotas + match self.check_ip_with_session(&ip_str, session_tx, session_rx) { + StopReason::Running => {} + reason => { + tracing::warn!( + "IP quota exceeded for {} (user '{}'): {} (session: tx={}, rx={})", + self.ip, self.username, reason, session_tx, session_rx, + ); + self.state.running.store(false, Ordering::SeqCst); + return reason; + } + } + } + } + + fn check_user_with_session(&self, session_tx: u64, session_rx: u64) -> StopReason { + let session_total = session_tx + session_rx; + + // Check against quota manager (which reads DB) + // The DB has usage from PREVIOUS sessions; we add current session bytes + if let Err(e) = self.quota_mgr.check_user(&self.username) { + // Already exceeded from previous sessions + return match format!("{}", e).as_str() { + s if s.contains("daily") => StopReason::UserDailyQuota, + s if s.contains("weekly") => StopReason::UserWeeklyQuota, + s if s.contains("monthly") => StopReason::UserMonthlyQuota, + _ => StopReason::UserDailyQuota, + }; + } + + // Also check if current session PLUS previous usage exceeds quota + // (check_user only sees DB, not current session bytes) + // This is handled by the quota_mgr.check_user reading from DB, + // and we periodically flush to DB during the session. + StopReason::Running + } + + fn check_ip_with_session(&self, ip_str: &str, session_tx: u64, session_rx: u64) -> StopReason { + if let Err(e) = self.quota_mgr.check_ip(&self.ip, Direction::Both) { + return match format!("{}", e).as_str() { + s if s.contains("IP daily") => StopReason::IpDailyQuota, + s if s.contains("IP weekly") => StopReason::IpWeeklyQuota, + s if s.contains("IP monthly") => StopReason::IpMonthlyQuota, + s if s.contains("connections") => StopReason::IpDailyQuota, // reuse + _ => StopReason::IpDailyQuota, + }; + } + StopReason::Running + } + + /// Flush session bytes to DB. Call periodically and at session end. + pub fn flush_to_db(&self) { + let tx = self.state.total_tx_bytes.load(Ordering::Relaxed); + let rx = self.state.total_rx_bytes.load(Ordering::Relaxed); + // From server perspective: tx = outbound (we sent), rx = inbound (we received) + self.quota_mgr.record_usage( + &self.username, + &self.ip.to_string(), + rx, // inbound = what we received from client + tx, // outbound = what we sent to client + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::user_db::UserDb; + use crate::quota::QuotaManager; + + fn setup_test_db() -> (UserDb, QuotaManager) { + let db = UserDb::open(":memory:").unwrap(); + db.ensure_tables().unwrap(); + db.add_user("testuser", "testpass").unwrap(); + let qm = QuotaManager::new( + db.clone(), + 1000, // daily: 1000 bytes + 5000, // weekly + 10000, // monthly + 500, // ip daily (combined) + 2000, // ip weekly (combined) + 8000, // ip monthly (combined) + 500, // ip_daily_inbound + 500, // ip_daily_outbound + 2000, // ip_weekly_inbound + 2000, // ip_weekly_outbound + 8000, // ip_monthly_inbound + 8000, // ip_monthly_outbound + 2, // max conn per ip + 60, // max duration + ); + (db, qm) + } + + #[tokio::test] + async fn test_enforcer_max_duration() { + let (db, qm) = setup_test_db(); + let state = BandwidthState::new(); + let enforcer = QuotaEnforcer::new( + qm, "testuser".into(), "127.0.0.1".parse().unwrap(), + state.clone(), 1, 2, // check every 1s, max 2s + ); + let reason = enforcer.run().await; + assert_eq!(reason, StopReason::MaxDuration); + assert!(!state.running.load(Ordering::Relaxed)); + } + + #[tokio::test] + async fn test_enforcer_client_disconnect() { + let (db, qm) = setup_test_db(); + let state = BandwidthState::new(); + let state_clone = state.clone(); + + // Stop the test after 500ms + tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(500)).await; + state_clone.running.store(false, Ordering::SeqCst); + }); + + let enforcer = QuotaEnforcer::new( + qm, "testuser".into(), "127.0.0.1".parse().unwrap(), + state, 1, 0, // check every 1s, no max duration + ); + let reason = enforcer.run().await; + assert_eq!(reason, StopReason::ClientDisconnected); + } + + #[tokio::test] + async fn test_enforcer_user_daily_quota_exceeded() { + let (db, qm) = setup_test_db(); + + // Pre-fill usage to exceed daily quota (1000 bytes) + db.record_usage("testuser", 600, 500).unwrap(); // 1100 > 1000 + + let state = BandwidthState::new(); + let enforcer = QuotaEnforcer::new( + qm, "testuser".into(), "127.0.0.1".parse().unwrap(), + state.clone(), 1, 0, + ); + let reason = enforcer.run().await; + assert_eq!(reason, StopReason::UserDailyQuota); + assert!(!state.running.load(Ordering::Relaxed)); + } + + #[tokio::test] + async fn test_enforcer_ip_daily_quota_exceeded() { + let (db, qm) = setup_test_db(); + + // Pre-fill IP usage to exceed IP daily quota (500 bytes) + db.record_ip_usage("127.0.0.1", 300, 300).unwrap(); // 600 > 500 + + let state = BandwidthState::new(); + let enforcer = QuotaEnforcer::new( + qm, "testuser".into(), "127.0.0.1".parse().unwrap(), + state.clone(), 1, 0, + ); + let reason = enforcer.run().await; + assert_eq!(reason, StopReason::IpDailyQuota); + assert!(!state.running.load(Ordering::Relaxed)); + } + + #[tokio::test] + async fn test_enforcer_under_quota_runs_normally() { + let (db, qm) = setup_test_db(); + + // Usage well under quota + db.record_usage("testuser", 100, 100).unwrap(); // 200 < 1000 + + let state = BandwidthState::new(); + let state_clone = state.clone(); + + // Stop after 2s + tokio::spawn(async move { + tokio::time::sleep(Duration::from_secs(2)).await; + state_clone.running.store(false, Ordering::SeqCst); + }); + + let enforcer = QuotaEnforcer::new( + qm, "testuser".into(), "127.0.0.1".parse().unwrap(), + state, 1, 0, + ); + let reason = enforcer.run().await; + assert_eq!(reason, StopReason::ClientDisconnected); + } + + #[tokio::test] + async fn test_enforcer_flush_records_usage() { + let (db, qm) = setup_test_db(); + let state = BandwidthState::new(); + + // Simulate some transfer + state.total_tx_bytes.store(5000, Ordering::Relaxed); + state.total_rx_bytes.store(3000, Ordering::Relaxed); + + let enforcer = QuotaEnforcer::new( + qm, "testuser".into(), "127.0.0.1".parse().unwrap(), + state, 10, 0, + ); + enforcer.flush_to_db(); + + // flush_to_db: total_tx=5000→outbound, total_rx=3000→inbound + // quota_mgr.record_usage(inbound=3000, outbound=5000) + // db.record_usage(tx=outbound=5000, rx=inbound=3000) + let (tx, rx) = db.get_daily_usage("testuser").unwrap(); + assert_eq!(tx, 5000); // outbound (what server sent) + assert_eq!(rx, 3000); // inbound (what server received) + + let (ip_in, ip_out) = db.get_ip_daily_usage("127.0.0.1").unwrap(); + assert!(ip_in + ip_out > 0, "IP usage should be recorded"); + } + + #[test] + fn test_remaining_budget_calculation() { + let (db, qm) = setup_test_db(); + let ip: IpAddr = "10.0.0.1".parse().unwrap(); + + // No usage yet: budget = min(daily=1000, weekly=5000, monthly=10000, ip_daily=500, ...) + // IP daily combined = 500 is the smallest + let budget = qm.remaining_budget("testuser", &ip); + assert_eq!(budget, 500, "budget should be min of all limits (ip_daily=500)"); + + // Use record_usage which properly records combined + directional + // inbound=200, outbound=200 → combined = 400 + qm.record_usage("testuser", "10.0.0.1", 200, 200); + + // IP daily combined: 500 - 400 = 100 remaining + // IP daily inbound: 500 - 200 = 300 remaining + // IP daily outbound: 500 - 200 = 300 remaining + // User daily: 1000 - 400 = 600 remaining + let budget = qm.remaining_budget("testuser", &ip); + assert_eq!(budget, 100, "budget should reflect IP combined remaining (100)"); + } + + #[test] + fn test_budget_zero_when_exhausted() { + let (db, qm) = setup_test_db(); + let ip: IpAddr = "10.0.0.2".parse().unwrap(); + + // Exhaust user daily quota (1000 bytes) + db.record_usage("testuser", 600, 500).unwrap(); // 1100 > 1000 + + let budget = qm.remaining_budget("testuser", &ip); + assert_eq!(budget, 0, "budget should be 0 when user daily quota is exhausted"); + } + + #[test] + fn test_byte_budget_stops_transfer() { + let state = BandwidthState::new(); + + // Set a 1000-byte budget + state.set_budget(1000); + + // Spend 500 bytes — should succeed + assert!(state.spend_budget(500)); + + // Spend another 400 — should succeed (100 remaining) + assert!(state.spend_budget(400)); + + // Spend 200 — should fail (only 100 remaining) + assert!(!state.spend_budget(200)); + + // running should be false + assert!(!state.running.load(Ordering::Relaxed)); + } + + #[test] + fn test_unlimited_budget_always_succeeds() { + let state = BandwidthState::new(); + // Default budget is u64::MAX (unlimited) + + // Should always succeed + for _ in 0..1000 { + assert!(state.spend_budget(1_000_000_000)); + } + assert!(state.running.load(Ordering::Relaxed)); + } +} diff --git a/src/server_pro/ldap_auth.rs b/src/server_pro/ldap_auth.rs new file mode 100644 index 0000000..d6f4b80 --- /dev/null +++ b/src/server_pro/ldap_auth.rs @@ -0,0 +1,74 @@ +//! LDAP/Active Directory authentication for btest-server-pro. +//! +//! Authenticates users against an LDAP directory using simple bind. + +use ldap3::{LdapConnAsync, Scope, SearchEntry}; + +pub struct LdapConfig { + pub url: String, + pub base_dn: String, + pub bind_dn: Option, + pub bind_pass: Option, +} + +pub struct LdapAuth { + config: LdapConfig, +} + +impl LdapAuth { + pub fn new(config: LdapConfig) -> Self { + Self { config } + } + + /// Authenticate a user by attempting an LDAP bind. + /// Returns Ok(true) if authentication succeeds. + pub async fn authenticate(&self, username: &str, password: &str) -> anyhow::Result { + let (conn, mut ldap) = LdapConnAsync::new(&self.config.url).await?; + ldap3::drive!(conn); + + // If service account configured, bind first to search for user DN + let user_dn = if let (Some(ref bind_dn), Some(ref bind_pass)) = + (&self.config.bind_dn, &self.config.bind_pass) + { + let result = ldap.simple_bind(bind_dn, bind_pass).await?; + if result.rc != 0 { + tracing::warn!("LDAP service bind failed: rc={}", result.rc); + return Ok(false); + } + + // Search for the user + let filter = format!( + "(&(objectClass=person)(|(uid={})(sAMAccountName={})(cn={})))", + username, username, username + ); + let (results, _) = ldap + .search(&self.config.base_dn, Scope::Subtree, &filter, vec!["dn"]) + .await? + .success()?; + + if results.is_empty() { + tracing::debug!("LDAP user not found: {}", username); + return Ok(false); + } + + let entry = SearchEntry::construct(results.into_iter().next().unwrap()); + entry.dn + } else { + // No service account — construct DN directly + format!("uid={},{}", username, self.config.base_dn) + }; + + // Attempt user bind + let result = ldap.simple_bind(&user_dn, password).await?; + let success = result.rc == 0; + + if success { + tracing::info!("LDAP auth successful for {} (dn={})", username, user_dn); + } else { + tracing::warn!("LDAP auth failed for {} (dn={}): rc={}", username, user_dn, result.rc); + } + + let _ = ldap.unbind().await; + Ok(success) + } +} diff --git a/src/server_pro/main.rs b/src/server_pro/main.rs new file mode 100644 index 0000000..63578c7 --- /dev/null +++ b/src/server_pro/main.rs @@ -0,0 +1,343 @@ +//! btest-server-pro: MikroTik Bandwidth Test server with multi-user, quotas, and LDAP. +//! +//! This is a superset of the standard `btest` server with additional features: +//! - SQLite user database (--users-db) +//! - Per-user and per-IP bandwidth quotas (daily/weekly) +//! - LDAP/Active Directory authentication (--ldap-url) +//! - Rate limiting for public server deployment +//! +//! Build with: cargo build --release --features pro --bin btest-server-pro + +mod user_db; +mod quota; +mod enforcer; +mod server_loop; +mod web; +mod ldap_auth; + +use clap::Parser; +use tracing_subscriber::EnvFilter; + +#[derive(Parser, Debug)] +#[command( + name = "btest-server-pro", + about = "btest-rs Pro Server: multi-user, quotas, LDAP", + version, +)] +struct Cli { + /// Listen port + #[arg(short = 'P', long = "port", default_value_t = 2000)] + port: u16, + + /// IPv4 listen address + #[arg(long = "listen", default_value = "0.0.0.0")] + listen_addr: String, + + /// IPv6 listen address (optional) + #[arg(long = "listen6")] + listen6_addr: Option, + + /// SQLite user database path + #[arg(long = "users-db", default_value = "btest-users.db")] + users_db: String, + + /// LDAP server URL (e.g., ldap://dc.example.com) + #[arg(long = "ldap-url")] + ldap_url: Option, + + /// LDAP base DN for user search + #[arg(long = "ldap-base-dn")] + ldap_base_dn: Option, + + /// LDAP bind DN (for service account) + #[arg(long = "ldap-bind-dn")] + ldap_bind_dn: Option, + + /// LDAP bind password + #[arg(long = "ldap-bind-pass")] + ldap_bind_pass: Option, + + /// Default daily quota per user in bytes (0 = unlimited) + #[arg(long = "daily-quota", default_value_t = 0)] + daily_quota: u64, + + /// Default weekly quota per user in bytes (0 = unlimited) + #[arg(long = "weekly-quota", default_value_t = 0)] + weekly_quota: u64, + + /// Default monthly quota per user in bytes (0 = unlimited) + #[arg(long = "monthly-quota", default_value_t = 0)] + monthly_quota: u64, + + /// Daily bandwidth limit per IP in bytes (0 = unlimited) + #[arg(long = "ip-daily", default_value_t = 0)] + ip_daily: u64, + + /// Weekly bandwidth limit per IP in bytes (0 = unlimited) + #[arg(long = "ip-weekly", default_value_t = 0)] + ip_weekly: u64, + + /// Monthly bandwidth limit per IP in bytes (0 = unlimited) + #[arg(long = "ip-monthly", default_value_t = 0)] + ip_monthly: u64, + + /// Maximum concurrent connections per IP (0 = unlimited) + #[arg(long = "max-conn-per-ip", default_value_t = 5)] + max_conn_per_ip: u32, + + /// Maximum test duration in seconds (0 = unlimited) + #[arg(long = "max-duration", default_value_t = 300)] + max_duration: u64, + + /// Daily inbound (client→server) limit per IP in bytes (0 = use --ip-daily) + #[arg(long = "ip-daily-in", default_value_t = 0)] + ip_daily_in: u64, + + /// Daily outbound (server→client) limit per IP in bytes (0 = use --ip-daily) + #[arg(long = "ip-daily-out", default_value_t = 0)] + ip_daily_out: u64, + + /// Weekly inbound limit per IP in bytes (0 = use --ip-weekly) + #[arg(long = "ip-weekly-in", default_value_t = 0)] + ip_weekly_in: u64, + + /// Weekly outbound limit per IP in bytes (0 = use --ip-weekly) + #[arg(long = "ip-weekly-out", default_value_t = 0)] + ip_weekly_out: u64, + + /// Monthly inbound limit per IP in bytes (0 = use --ip-monthly) + #[arg(long = "ip-monthly-in", default_value_t = 0)] + ip_monthly_in: u64, + + /// Monthly outbound limit per IP in bytes (0 = use --ip-monthly) + #[arg(long = "ip-monthly-out", default_value_t = 0)] + ip_monthly_out: u64, + + /// How often to check quotas during a test in seconds + #[arg(long = "quota-check-interval", default_value_t = 10)] + quota_check_interval: u64, + + /// Web dashboard port (0 = disabled) + #[arg(long = "web-port", default_value_t = 8080)] + web_port: u16, + + /// Shared password for public mode (all users use this password) + #[arg(long = "shared-password")] + shared_password: Option, + + /// Use EC-SRP5 authentication + #[arg(long = "ecsrp5")] + ecsrp5: bool, + + /// Syslog server address + #[arg(long = "syslog")] + syslog: Option, + + /// CSV output file + #[arg(long = "csv")] + csv: Option, + + /// Verbose logging + #[arg(short = 'v', long = "verbose", action = clap::ArgAction::Count)] + verbose: u8, + + /// User management subcommand + #[command(subcommand)] + command: Option, +} + +#[derive(clap::Subcommand, Debug)] +enum UserCommand { + /// Add a user + #[command(name = "useradd")] + UserAdd { + /// Username + username: String, + /// Password + password: String, + }, + /// Delete a user + #[command(name = "userdel")] + UserDel { + /// Username + username: String, + }, + /// List all users + #[command(name = "userlist")] + UserList, + /// Enable/disable a user + #[command(name = "userset")] + UserSet { + /// Username + username: String, + /// Enable (true/false) + #[arg(long)] + enabled: Option, + /// Daily quota in bytes + #[arg(long)] + daily: Option, + /// Weekly quota in bytes + #[arg(long)] + weekly: Option, + }, +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let cli = Cli::parse(); + + let filter = match cli.verbose { + 0 => "info", + 1 => "debug", + _ => "trace", + }; + tracing_subscriber::fmt() + .with_env_filter( + EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(filter)), + ) + .with_target(false) + .init(); + + // Initialize subsystems + btest_rs::cpu::start_sampler(); + + if let Some(ref syslog_addr) = cli.syslog { + if let Err(e) = btest_rs::syslog_logger::init(syslog_addr) { + eprintln!("Warning: syslog init failed: {}", e); + } + } + + if let Some(ref csv_path) = cli.csv { + if let Err(e) = btest_rs::csv_output::init(csv_path) { + eprintln!("Warning: CSV init failed: {}", e); + } + } + + // Initialize user database + let db = user_db::UserDb::open(&cli.users_db)?; + db.ensure_tables()?; + + // Handle user management subcommands (exit after) + if let Some(cmd) = &cli.command { + match cmd { + UserCommand::UserAdd { username, password } => { + db.add_user(username, password)?; + println!("User '{}' added.", username); + return Ok(()); + } + UserCommand::UserDel { username } => { + if db.delete_user(username)? { + println!("User '{}' deleted.", username); + } else { + println!("User '{}' not found.", username); + } + return Ok(()); + } + UserCommand::UserList => { + let users = db.list_users()?; + if users.is_empty() { + println!("No users."); + } else { + println!("{:<20} {:<10} {:<15} {:<15}", "USERNAME", "ENABLED", "DAILY_QUOTA", "WEEKLY_QUOTA"); + println!("{}", "-".repeat(60)); + for u in &users { + println!("{:<20} {:<10} {:<15} {:<15}", + u.username, + if u.enabled { "yes" } else { "no" }, + if u.daily_quota == 0 { "default".to_string() } else { format!("{}B", u.daily_quota) }, + if u.weekly_quota == 0 { "default".to_string() } else { format!("{}B", u.weekly_quota) }, + ); + } + } + return Ok(()); + } + UserCommand::UserSet { username, enabled, daily, weekly } => { + if let Some(e) = enabled { + db.set_user_enabled(username, *e)?; + println!("User '{}' enabled={}", username, e); + } + if daily.is_some() || weekly.is_some() { + let d = daily.unwrap_or(0); + let w = weekly.unwrap_or(0); + db.set_user_quota(username, d, w, 0)?; + println!("User '{}' quota: daily={}, weekly={}", username, d, w); + } + return Ok(()); + } + } + } + + tracing::info!("User database: {} ({} users)", cli.users_db, db.user_count()?); + + // Initialize LDAP if configured + if let Some(ref url) = cli.ldap_url { + tracing::info!("LDAP configured: {}", url); + } + + // Initialize quota manager + // Directional flags override combined: --ip-daily-in > --ip-daily > unlimited + let or_fallback = |specific: u64, combined: u64| if specific > 0 { specific } else { combined }; + let quota_mgr = quota::QuotaManager::new( + db.clone(), + cli.daily_quota, + cli.weekly_quota, + cli.monthly_quota, + cli.ip_daily, + cli.ip_weekly, + cli.ip_monthly, + or_fallback(cli.ip_daily_in, cli.ip_daily), + or_fallback(cli.ip_daily_out, cli.ip_daily), + or_fallback(cli.ip_weekly_in, cli.ip_weekly), + or_fallback(cli.ip_weekly_out, cli.ip_weekly), + or_fallback(cli.ip_monthly_in, cli.ip_monthly), + or_fallback(cli.ip_monthly_out, cli.ip_monthly), + cli.max_conn_per_ip, + cli.max_duration, + ); + + let fmt_q = |v: u64| if v == 0 { "unlimited".to_string() } else { format!("{}B", v) }; + tracing::info!( + "User quotas: daily={}, weekly={}, monthly={}", + fmt_q(cli.daily_quota), fmt_q(cli.weekly_quota), fmt_q(cli.monthly_quota), + ); + tracing::info!( + "IP quotas: daily={}, weekly={}, monthly={}", + fmt_q(cli.ip_daily), fmt_q(cli.ip_weekly), fmt_q(cli.ip_monthly), + ); + tracing::info!( + "Limits: max_conn_per_ip={}, max_duration={}s", + cli.max_conn_per_ip, cli.max_duration, + ); + + // Start web dashboard if port > 0 + if cli.web_port > 0 { + let web_db = db.clone(); + let web_port = cli.web_port; + tokio::spawn(async move { + tracing::info!("Web dashboard starting on http://0.0.0.0:{}", web_port); + let app = web::create_router(web_db); + let listener = tokio::net::TcpListener::bind(format!("0.0.0.0:{}", web_port)) + .await + .expect("Failed to bind web dashboard port"); + if let Err(e) = axum::serve(listener, app).await { + tracing::error!("Web dashboard error: {}", e); + } + }); + } + + tracing::info!("btest-server-pro starting on port {}", cli.port); + + let v4 = if cli.listen_addr.eq_ignore_ascii_case("none") { None } else { Some(cli.listen_addr) }; + let v6 = cli.listen6_addr; + + server_loop::run_pro_server( + cli.port, + cli.ecsrp5, + v4, v6, + db, + quota_mgr, + cli.quota_check_interval, + ).await?; + + Ok(()) +} diff --git a/src/server_pro/quota.rs b/src/server_pro/quota.rs new file mode 100644 index 0000000..e059acb --- /dev/null +++ b/src/server_pro/quota.rs @@ -0,0 +1,470 @@ +//! Bandwidth quota management for btest-server-pro. +//! +//! Enforces per-user and per-IP bandwidth limits (daily/weekly/monthly), +//! with separate tracking for inbound (client-to-server) and outbound +//! (server-to-client) directions. + +use std::collections::HashMap; +use std::net::IpAddr; +use std::sync::{Arc, Mutex}; + +use super::user_db::UserDb; + +/// Traffic direction for bandwidth tests. +/// +/// From the **server's** perspective: +/// - `Inbound` = client sends data to us (client TX, server RX) +/// - `Outbound` = we send data to the client (server TX, client RX) +/// - `Both` = bidirectional test +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Direction { + Inbound, + Outbound, + Both, +} + +#[derive(Clone)] +pub struct QuotaManager { + db: UserDb, + /// Per-user defaults (0 = unlimited) + default_daily: u64, + default_weekly: u64, + default_monthly: u64, + /// Per-IP combined (inbound + outbound) limits (0 = unlimited) — for abuse prevention + ip_daily: u64, + ip_weekly: u64, + ip_monthly: u64, + /// Per-IP directional limits (0 = unlimited) + ip_daily_inbound: u64, + ip_daily_outbound: u64, + ip_weekly_inbound: u64, + ip_weekly_outbound: u64, + ip_monthly_inbound: u64, + ip_monthly_outbound: u64, + /// Max simultaneous connections from one IP + max_conn_per_ip: u32, + /// Max test duration in seconds + max_duration: u64, + active_connections: Arc>>, +} + +#[derive(Debug)] +pub enum QuotaError { + DailyExceeded { used: u64, limit: u64 }, + WeeklyExceeded { used: u64, limit: u64 }, + MonthlyExceeded { used: u64, limit: u64 }, + /// Combined (inbound + outbound) IP daily limit exceeded. + IpDailyExceeded { used: u64, limit: u64 }, + /// Combined (inbound + outbound) IP weekly limit exceeded. + IpWeeklyExceeded { used: u64, limit: u64 }, + /// Combined (inbound + outbound) IP monthly limit exceeded. + IpMonthlyExceeded { used: u64, limit: u64 }, + /// Per-direction IP daily limits. + IpInboundDailyExceeded { used: u64, limit: u64 }, + IpOutboundDailyExceeded { used: u64, limit: u64 }, + /// Per-direction IP weekly limits. + IpInboundWeeklyExceeded { used: u64, limit: u64 }, + IpOutboundWeeklyExceeded { used: u64, limit: u64 }, + /// Per-direction IP monthly limits. + IpInboundMonthlyExceeded { used: u64, limit: u64 }, + IpOutboundMonthlyExceeded { used: u64, limit: u64 }, + TooManyConnections { current: u32, limit: u32 }, + UserDisabled, + UserNotFound, +} + +impl std::fmt::Display for QuotaError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::DailyExceeded { used, limit } => + write!(f, "User daily quota exceeded: {}/{} bytes", used, limit), + Self::WeeklyExceeded { used, limit } => + write!(f, "User weekly quota exceeded: {}/{} bytes", used, limit), + Self::MonthlyExceeded { used, limit } => + write!(f, "User monthly quota exceeded: {}/{} bytes", used, limit), + Self::IpDailyExceeded { used, limit } => + write!(f, "IP daily quota exceeded: {}/{} bytes", used, limit), + Self::IpWeeklyExceeded { used, limit } => + write!(f, "IP weekly quota exceeded: {}/{} bytes", used, limit), + Self::IpMonthlyExceeded { used, limit } => + write!(f, "IP monthly quota exceeded: {}/{} bytes", used, limit), + Self::IpInboundDailyExceeded { used, limit } => + write!(f, "IP inbound daily quota exceeded: {}/{} bytes", used, limit), + Self::IpOutboundDailyExceeded { used, limit } => + write!(f, "IP outbound daily quota exceeded: {}/{} bytes", used, limit), + Self::IpInboundWeeklyExceeded { used, limit } => + write!(f, "IP inbound weekly quota exceeded: {}/{} bytes", used, limit), + Self::IpOutboundWeeklyExceeded { used, limit } => + write!(f, "IP outbound weekly quota exceeded: {}/{} bytes", used, limit), + Self::IpInboundMonthlyExceeded { used, limit } => + write!(f, "IP inbound monthly quota exceeded: {}/{} bytes", used, limit), + Self::IpOutboundMonthlyExceeded { used, limit } => + write!(f, "IP outbound monthly quota exceeded: {}/{} bytes", used, limit), + Self::TooManyConnections { current, limit } => + write!(f, "Too many connections from this IP: {}/{}", current, limit), + Self::UserDisabled => write!(f, "User account is disabled"), + Self::UserNotFound => write!(f, "User not found"), + } + } +} + +impl QuotaManager { + #[allow(clippy::too_many_arguments)] + pub fn new( + db: UserDb, + default_daily: u64, + default_weekly: u64, + default_monthly: u64, + ip_daily: u64, + ip_weekly: u64, + ip_monthly: u64, + ip_daily_inbound: u64, + ip_daily_outbound: u64, + ip_weekly_inbound: u64, + ip_weekly_outbound: u64, + ip_monthly_inbound: u64, + ip_monthly_outbound: u64, + max_conn_per_ip: u32, + max_duration: u64, + ) -> Self { + Self { + db, + default_daily, + default_weekly, + default_monthly, + ip_daily, + ip_weekly, + ip_monthly, + ip_daily_inbound, + ip_daily_outbound, + ip_weekly_inbound, + ip_weekly_outbound, + ip_monthly_inbound, + ip_monthly_outbound, + max_conn_per_ip, + max_duration, + active_connections: Arc::new(Mutex::new(HashMap::new())), + } + } + + /// Check if a user is allowed to start a test. + pub fn check_user(&self, username: &str) -> Result<(), QuotaError> { + let user = self.db.get_user(username) + .map_err(|_| QuotaError::UserNotFound)? + .ok_or(QuotaError::UserNotFound)?; + + if !user.enabled { + return Err(QuotaError::UserDisabled); + } + + // Daily + let daily_limit = if user.daily_quota > 0 { user.daily_quota as u64 } else { self.default_daily }; + if daily_limit > 0 { + let (tx, rx) = self.db.get_daily_usage(username).unwrap_or((0, 0)); + let used = tx + rx; + if used >= daily_limit { + return Err(QuotaError::DailyExceeded { used, limit: daily_limit }); + } + } + + // Weekly + let weekly_limit = if user.weekly_quota > 0 { user.weekly_quota as u64 } else { self.default_weekly }; + if weekly_limit > 0 { + let (tx, rx) = self.db.get_weekly_usage(username).unwrap_or((0, 0)); + let used = tx + rx; + if used >= weekly_limit { + return Err(QuotaError::WeeklyExceeded { used, limit: weekly_limit }); + } + } + + // Monthly + if self.default_monthly > 0 { + let (tx, rx) = self.db.get_monthly_usage(username).unwrap_or((0, 0)); + let used = tx + rx; + if used >= self.default_monthly { + return Err(QuotaError::MonthlyExceeded { used, limit: self.default_monthly }); + } + } + + Ok(()) + } + + /// Check if an IP is allowed to connect, considering both combined and + /// directional bandwidth quotas. + /// + /// The `direction` parameter indicates which direction the test will use. + /// For `Direction::Both`, both inbound and outbound directional limits are + /// checked. Combined (total) limits are always checked regardless of + /// direction. + pub fn check_ip(&self, ip: &IpAddr, direction: Direction) -> Result<(), QuotaError> { + // Connection limit + if self.max_conn_per_ip > 0 { + let conns = self.active_connections.lock().unwrap(); + let current = conns.get(ip).copied().unwrap_or(0); + if current >= self.max_conn_per_ip { + return Err(QuotaError::TooManyConnections { + current, + limit: self.max_conn_per_ip, + }); + } + } + + let ip_str = ip.to_string(); + + // --- Combined (inbound + outbound) limits --- + self.check_ip_combined(&ip_str)?; + + // --- Directional limits --- + let check_inbound = matches!(direction, Direction::Inbound | Direction::Both); + let check_outbound = matches!(direction, Direction::Outbound | Direction::Both); + + if check_inbound { + self.check_ip_inbound(&ip_str)?; + } + if check_outbound { + self.check_ip_outbound(&ip_str)?; + } + + Ok(()) + } + + /// Check combined (total inbound + outbound) IP limits. + fn check_ip_combined(&self, ip_str: &str) -> Result<(), QuotaError> { + // IP daily (combined) + if self.ip_daily > 0 { + let (tx, rx) = self.db.get_ip_daily_usage(ip_str).unwrap_or((0, 0)); + let used = tx + rx; + if used >= self.ip_daily { + return Err(QuotaError::IpDailyExceeded { used, limit: self.ip_daily }); + } + } + + // IP weekly (combined) + if self.ip_weekly > 0 { + let (tx, rx) = self.db.get_ip_weekly_usage(ip_str).unwrap_or((0, 0)); + let used = tx + rx; + if used >= self.ip_weekly { + return Err(QuotaError::IpWeeklyExceeded { used, limit: self.ip_weekly }); + } + } + + // IP monthly (combined) + if self.ip_monthly > 0 { + let (tx, rx) = self.db.get_ip_monthly_usage(ip_str).unwrap_or((0, 0)); + let used = tx + rx; + if used >= self.ip_monthly { + return Err(QuotaError::IpMonthlyExceeded { used, limit: self.ip_monthly }); + } + } + + Ok(()) + } + + /// Check inbound-only (client sends to us) IP limits. + fn check_ip_inbound(&self, ip_str: &str) -> Result<(), QuotaError> { + // Daily inbound + if self.ip_daily_inbound > 0 { + let used = self.db.get_ip_daily_inbound(ip_str).unwrap_or(0); + if used >= self.ip_daily_inbound { + return Err(QuotaError::IpInboundDailyExceeded { + used, + limit: self.ip_daily_inbound, + }); + } + } + + // Weekly inbound + if self.ip_weekly_inbound > 0 { + let used = self.db.get_ip_weekly_inbound(ip_str).unwrap_or(0); + if used >= self.ip_weekly_inbound { + return Err(QuotaError::IpInboundWeeklyExceeded { + used, + limit: self.ip_weekly_inbound, + }); + } + } + + // Monthly inbound + if self.ip_monthly_inbound > 0 { + let used = self.db.get_ip_monthly_inbound(ip_str).unwrap_or(0); + if used >= self.ip_monthly_inbound { + return Err(QuotaError::IpInboundMonthlyExceeded { + used, + limit: self.ip_monthly_inbound, + }); + } + } + + Ok(()) + } + + /// Check outbound-only (we send to client) IP limits. + fn check_ip_outbound(&self, ip_str: &str) -> Result<(), QuotaError> { + // Daily outbound + if self.ip_daily_outbound > 0 { + let used = self.db.get_ip_daily_outbound(ip_str).unwrap_or(0); + if used >= self.ip_daily_outbound { + return Err(QuotaError::IpOutboundDailyExceeded { + used, + limit: self.ip_daily_outbound, + }); + } + } + + // Weekly outbound + if self.ip_weekly_outbound > 0 { + let used = self.db.get_ip_weekly_outbound(ip_str).unwrap_or(0); + if used >= self.ip_weekly_outbound { + return Err(QuotaError::IpOutboundWeeklyExceeded { + used, + limit: self.ip_weekly_outbound, + }); + } + } + + // Monthly outbound + if self.ip_monthly_outbound > 0 { + let used = self.db.get_ip_monthly_outbound(ip_str).unwrap_or(0); + if used >= self.ip_monthly_outbound { + return Err(QuotaError::IpOutboundMonthlyExceeded { + used, + limit: self.ip_monthly_outbound, + }); + } + } + + Ok(()) + } + + pub fn connect(&self, ip: &IpAddr) { + let mut conns = self.active_connections.lock().unwrap(); + *conns.entry(*ip).or_insert(0) += 1; + } + + pub fn disconnect(&self, ip: &IpAddr) { + let mut conns = self.active_connections.lock().unwrap(); + if let Some(count) = conns.get_mut(ip) { + *count = count.saturating_sub(1); + if *count == 0 { + conns.remove(ip); + } + } + } + + /// Record usage after a test completes (both user and IP), with separate + /// inbound and outbound byte counts. + /// + /// - `inbound_bytes`: bytes the client sent to us (server RX). + /// - `outbound_bytes`: bytes we sent to the client (server TX). + /// + /// Both the combined user/IP usage and directional IP usage are recorded. + pub fn record_usage( + &self, + username: &str, + ip: &str, + inbound_bytes: u64, + outbound_bytes: u64, + ) { + // Record combined user usage (tx/rx from the server's perspective: + // tx = outbound, rx = inbound). + if let Err(e) = self.db.record_usage(username, outbound_bytes, inbound_bytes) { + tracing::error!("Failed to record user usage for {}: {}", username, e); + } + + // Record IP usage — record_ip_usage already writes both the + // inbound_bytes and outbound_bytes columns in one operation. + // Do NOT also call record_ip_inbound_usage/record_ip_outbound_usage + // as they update the same columns and would double-count. + if let Err(e) = self.db.record_ip_usage(ip, outbound_bytes, inbound_bytes) { + tracing::error!("Failed to record IP usage for {}: {}", ip, e); + } + } + + /// Calculate the remaining byte budget for a user+IP combination. + /// Returns the minimum remaining quota across all applicable limits. + /// Used to set `BandwidthState::byte_budget` before a test starts, + /// preventing overshoot beyond quota boundaries. + pub fn remaining_budget(&self, username: &str, ip: &IpAddr) -> u64 { + let mut budget = u64::MAX; + let ip_str = ip.to_string(); + + // Helper: min that ignores 0 (unlimited) + let cap = |budget: &mut u64, limit: u64, used: u64| { + if limit > 0 { + let remaining = limit.saturating_sub(used); + *budget = (*budget).min(remaining); + } + }; + + // User quotas (combined tx+rx) + if let Ok(Some(user)) = self.db.get_user(username) { + let daily_limit = if user.daily_quota > 0 { user.daily_quota as u64 } else { self.default_daily }; + if daily_limit > 0 { + let (tx, rx) = self.db.get_daily_usage(username).unwrap_or((0, 0)); + cap(&mut budget, daily_limit, tx + rx); + } + + let weekly_limit = if user.weekly_quota > 0 { user.weekly_quota as u64 } else { self.default_weekly }; + if weekly_limit > 0 { + let (tx, rx) = self.db.get_weekly_usage(username).unwrap_or((0, 0)); + cap(&mut budget, weekly_limit, tx + rx); + } + + if self.default_monthly > 0 { + let (tx, rx) = self.db.get_monthly_usage(username).unwrap_or((0, 0)); + cap(&mut budget, self.default_monthly, tx + rx); + } + } + + // IP combined quotas + if self.ip_daily > 0 { + let (tx, rx) = self.db.get_ip_daily_usage(&ip_str).unwrap_or((0, 0)); + cap(&mut budget, self.ip_daily, tx + rx); + } + if self.ip_weekly > 0 { + let (tx, rx) = self.db.get_ip_weekly_usage(&ip_str).unwrap_or((0, 0)); + cap(&mut budget, self.ip_weekly, tx + rx); + } + if self.ip_monthly > 0 { + let (tx, rx) = self.db.get_ip_monthly_usage(&ip_str).unwrap_or((0, 0)); + cap(&mut budget, self.ip_monthly, tx + rx); + } + + // IP directional quotas — use inbound + outbound as combined ceiling + if self.ip_daily_inbound > 0 { + let used = self.db.get_ip_daily_inbound(&ip_str).unwrap_or(0); + cap(&mut budget, self.ip_daily_inbound, used); + } + if self.ip_daily_outbound > 0 { + let used = self.db.get_ip_daily_outbound(&ip_str).unwrap_or(0); + cap(&mut budget, self.ip_daily_outbound, used); + } + if self.ip_weekly_inbound > 0 { + let used = self.db.get_ip_weekly_inbound(&ip_str).unwrap_or(0); + cap(&mut budget, self.ip_weekly_inbound, used); + } + if self.ip_weekly_outbound > 0 { + let used = self.db.get_ip_weekly_outbound(&ip_str).unwrap_or(0); + cap(&mut budget, self.ip_weekly_outbound, used); + } + if self.ip_monthly_inbound > 0 { + let used = self.db.get_ip_monthly_inbound(&ip_str).unwrap_or(0); + cap(&mut budget, self.ip_monthly_inbound, used); + } + if self.ip_monthly_outbound > 0 { + let used = self.db.get_ip_monthly_outbound(&ip_str).unwrap_or(0); + cap(&mut budget, self.ip_monthly_outbound, used); + } + + budget + } + + pub fn max_duration(&self) -> u64 { + self.max_duration + } + + pub fn active_connections_count(&self, ip: &IpAddr) -> u32 { + let conns = self.active_connections.lock().unwrap(); + conns.get(ip).copied().unwrap_or(0) + } +} diff --git a/src/server_pro/server_loop.rs b/src/server_pro/server_loop.rs new file mode 100644 index 0000000..b87f941 --- /dev/null +++ b/src/server_pro/server_loop.rs @@ -0,0 +1,449 @@ +//! Enhanced server loop with quota enforcement. +//! +//! Wraps the standard btest server connection handler with: +//! - Pre-connection IP/user quota checks +//! - MD5 challenge-response auth against user DB +//! - TCP multi-connection session support +//! - Mid-session quota enforcement via QuotaEnforcer +//! - Post-session usage recording + +use std::collections::HashMap; +use std::net::SocketAddr; +use std::sync::Arc; + +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::{TcpListener, TcpStream}; +use tokio::sync::Mutex; + +use btest_rs::protocol::*; +use btest_rs::bandwidth::BandwidthState; + +use super::enforcer::{QuotaEnforcer, StopReason}; +use super::quota::{Direction, QuotaManager}; +use super::user_db::UserDb; + +/// Pending TCP multi-connection session. +struct TcpSession { + peer_ip: std::net::IpAddr, + username: String, + cmd: Command, + streams: Vec, + expected: u8, +} + +type SessionMap = Arc>>; + +/// Run the pro server with quota enforcement. +pub async fn run_pro_server( + port: u16, + _ecsrp5: bool, + listen_v4: Option, + listen_v6: Option, + db: UserDb, + quota_mgr: QuotaManager, + quota_check_interval: u64, +) -> anyhow::Result<()> { + let v4_listener = if let Some(ref addr) = listen_v4 { + let bind_addr = format!("{}:{}", addr, port); + Some(TcpListener::bind(&bind_addr).await?) + } else { + None + }; + + let v6_listener = if let Some(ref addr) = listen_v6 { + let bind_addr = format!("[{}]:{}", addr, port); + Some(TcpListener::bind(&bind_addr).await?) + } else { + None + }; + + if v4_listener.is_none() && v6_listener.is_none() { + anyhow::bail!("No listeners bound"); + } + + let sessions: SessionMap = Arc::new(Mutex::new(HashMap::new())); + + tracing::info!("btest-server-pro ready, accepting connections"); + + loop { + let (stream, peer) = match (&v4_listener, &v6_listener) { + (Some(v4), Some(v6)) => { + tokio::select! { + r = v4.accept() => r?, + r = v6.accept() => r?, + } + } + (Some(v4), None) => v4.accept().await?, + (None, Some(v6)) => v6.accept().await?, + _ => unreachable!(), + }; + + tracing::info!("New connection from {}", peer); + + let db = db.clone(); + let qm = quota_mgr.clone(); + let interval = quota_check_interval; + let sess = sessions.clone(); + + tokio::spawn(async move { + let is_primary = match handle_pro_connection(stream, peer, db, qm.clone(), interval, sess).await { + Ok(Some((username, stop_reason, tx, rx))) => { + tracing::info!( + "Client {} (user '{}') finished: {} (tx={}, rx={})", + peer, username, stop_reason, tx, rx, + ); + btest_rs::syslog_logger::test_end( + &peer.to_string(), "btest", &format!("{}", stop_reason), + tx, rx, 0, 0, + ); + true + } + Ok(None) => false, // secondary connection or pending multi-conn + Err(e) => { + tracing::error!("Client {} error: {}", peer, e); + true + } + }; + // Only decrement connection count for primary connections + if is_primary { + qm.disconnect(&peer.ip()); + } + }); + } +} + +/// Handle a single TCP connection. Returns None for secondary multi-conn joins. +async fn handle_pro_connection( + mut stream: TcpStream, + peer: SocketAddr, + db: UserDb, + quota_mgr: QuotaManager, + quota_check_interval: u64, + sessions: SessionMap, +) -> anyhow::Result> { + stream.set_nodelay(true)?; + + // HELLO + stream.write_all(&HELLO).await?; + + // Read command (or session token for secondary connections) + let mut cmd_buf = [0u8; 16]; + stream.read_exact(&mut cmd_buf).await?; + + // Check if this is a secondary connection joining an existing TCP session + // Secondary connections send [HI, LO, ...] matching an existing session token + { + let potential_token = u16::from_be_bytes([cmd_buf[0], cmd_buf[1]]); + let mut map = sessions.lock().await; + if let Some(session) = map.get_mut(&potential_token) { + if session.peer_ip == peer.ip() + && session.streams.len() < session.expected as usize + { + tracing::info!( + "Secondary connection from {} joining session (token={:04x}, {}/{})", + peer, potential_token, + session.streams.len() + 1, session.expected, + ); + + // Auth the secondary connection with same token response + let ok = [0x01, cmd_buf[0], cmd_buf[1], 0x00]; + stream.write_all(&ok).await?; + stream.flush().await?; + + session.streams.push(stream); + + // If all connections have joined, start the test + if session.streams.len() >= session.expected as usize { + let session = map.remove(&potential_token).unwrap(); + let db2 = db.clone(); + let qm2 = quota_mgr.clone(); + tokio::spawn(async move { + match run_pro_multiconn_test( + session.streams, session.cmd, peer, + &session.username, db2, qm2, quota_check_interval, + ).await { + Ok((stop, tx, rx)) => { + tracing::info!( + "Multi-conn {} (user '{}') finished: {} (tx={}, rx={})", + peer, session.username, stop, tx, rx, + ); + } + Err(e) => { + tracing::error!("Multi-conn {} error: {}", peer, e); + } + } + }); + } + + return Ok(None); + } + } + } + + // Primary connection — check IP quota/connection limit now + if let Err(e) = quota_mgr.check_ip(&peer.ip(), Direction::Both) { + tracing::warn!("Rejected {} — {}", peer, e); + btest_rs::syslog_logger::auth_failure( + &peer.to_string(), "-", "-", &format!("{}", e), + ); + return Ok(None); + } + quota_mgr.connect(&peer.ip()); + + let cmd = Command::deserialize(&cmd_buf); + + tracing::info!( + "Client {} command: proto={} dir={} conn_count={} tx_size={}", + peer, + if cmd.is_udp() { "UDP" } else { "TCP" }, + match cmd.direction { CMD_DIR_RX => "RX", CMD_DIR_TX => "TX", _ => "BOTH" }, + cmd.tcp_conn_count, + cmd.tx_size, + ); + + // Build auth OK response with session token for multi-connection + let is_tcp_multi = !cmd.is_udp() && cmd.tcp_conn_count > 0; + let session_token: u16 = if is_tcp_multi { + rand::random::() | 0x0101 // ensure both bytes non-zero + } else { + 0 + }; + let ok_response: [u8; 4] = if is_tcp_multi { + [0x01, (session_token >> 8) as u8, (session_token & 0xFF) as u8, 0x00] + } else { + AUTH_OK + }; + + // Authenticate — MD5 challenge-response against DB + stream.write_all(&AUTH_REQUIRED).await?; + let challenge = btest_rs::auth::generate_challenge(); + stream.write_all(&challenge).await?; + stream.flush().await?; + + let mut response = [0u8; 48]; + stream.read_exact(&mut response).await?; + + let received_hash = &response[0..16]; + let received_user = &response[16..48]; + + let user_end = received_user.iter().position(|&b| b == 0).unwrap_or(32); + let username = std::str::from_utf8(&received_user[..user_end]) + .unwrap_or("") + .to_string(); + + // Verify against DB + let user = db.get_user(&username)?; + match user { + None => { + tracing::warn!("Auth failed: user '{}' not found", username); + stream.write_all(&AUTH_FAILED).await?; + btest_rs::syslog_logger::auth_failure( + &peer.to_string(), &username, "md5", "user not found", + ); + anyhow::bail!("User not found"); + } + Some(u) => { + if !u.enabled { + tracing::warn!("Auth failed: user '{}' is disabled", username); + stream.write_all(&AUTH_FAILED).await?; + btest_rs::syslog_logger::auth_failure( + &peer.to_string(), &username, "md5", "user disabled", + ); + anyhow::bail!("User disabled"); + } + + // Verify MD5 hash against stored raw password + if let Ok(Some(raw_pass)) = db.get_password(&username) { + let expected_hash = btest_rs::auth::compute_auth_hash(&raw_pass, &challenge); + if received_hash != expected_hash { + tracing::warn!("Auth failed: password mismatch for user '{}'", username); + stream.write_all(&AUTH_FAILED).await?; + btest_rs::syslog_logger::auth_failure( + &peer.to_string(), &username, "md5", "password mismatch", + ); + anyhow::bail!("Auth failed"); + } + } + // If no raw password stored, accept (backwards compat with old DB entries) + + stream.write_all(&ok_response).await?; + stream.flush().await?; + + tracing::info!("Auth successful for user '{}'", username); + btest_rs::syslog_logger::auth_success( + &peer.to_string(), &username, "md5", + ); + } + } + + // Check user quota before starting test + if let Err(e) = quota_mgr.check_user(&username) { + tracing::warn!("Quota check failed for '{}': {}", username, e); + btest_rs::syslog_logger::auth_failure( + &peer.to_string(), &username, "quota", &format!("{}", e), + ); + return Ok(Some((username, StopReason::UserDailyQuota, 0, 0))); + } + + // TCP multi-connection: register session and wait for secondary connections + if is_tcp_multi { + tracing::info!( + "TCP multi-connection: waiting for {} connections (token={:04x})", + cmd.tcp_conn_count, session_token, + ); + let mut map = sessions.lock().await; + map.insert(session_token, TcpSession { + peer_ip: peer.ip(), + username: username.clone(), + cmd: cmd.clone(), + streams: vec![stream], + expected: cmd.tcp_conn_count, // tcp_conn_count includes the primary + }); + // The test will be started when all connections join (in the secondary handler above) + return Ok(None); + } + + // Single-connection test + run_pro_single_test(stream, cmd, peer, &username, db, quota_mgr, quota_check_interval).await + .map(|(stop, tx, rx)| Some((username, stop, tx, rx))) +} + +/// Run a single-connection bandwidth test with quota enforcement. +async fn run_pro_single_test( + stream: TcpStream, + cmd: Command, + peer: SocketAddr, + username: &str, + db: UserDb, + quota_mgr: QuotaManager, + quota_check_interval: u64, +) -> anyhow::Result<(StopReason, u64, u64)> { + let proto_str = if cmd.is_udp() { "UDP" } else { "TCP" }; + let dir_str = match cmd.direction { + CMD_DIR_RX => "RX", CMD_DIR_TX => "TX", _ => "BOTH" + }; + let session_id = db.start_session( + username, &peer.ip().to_string(), proto_str, dir_str, + )?; + + btest_rs::syslog_logger::test_start( + &peer.to_string(), proto_str, dir_str, cmd.tcp_conn_count, + ); + + let state = BandwidthState::new(); + + // Set byte budget + let budget = quota_mgr.remaining_budget(username, &peer.ip()); + if budget < u64::MAX { + state.set_budget(budget); + tracing::info!("Byte budget for '{}' from {}: {} bytes", username, peer.ip(), budget); + } + + let enforcer = QuotaEnforcer::new( + quota_mgr.clone(), + username.to_string(), + peer.ip(), + state.clone(), + quota_check_interval, + quota_mgr.max_duration(), + ); + + let enforcer_state = state.clone(); + let enforcer_handle = tokio::spawn(async move { + enforcer.run().await + }); + + static UDP_PORT_OFFSET: std::sync::atomic::AtomicU16 = std::sync::atomic::AtomicU16::new(0); + + let mut stream_mut = stream; + let test_result = if cmd.is_udp() { + let offset = UDP_PORT_OFFSET.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + let udp_port = btest_rs::protocol::BTEST_UDP_PORT_START + offset; + btest_rs::server::run_udp_test( + &mut stream_mut, peer, &cmd, state.clone(), udp_port, + ).await + } else { + btest_rs::server::run_tcp_test(stream_mut, cmd.clone(), state.clone()).await + }; + + enforcer_state.running.store(false, std::sync::atomic::Ordering::SeqCst); + let stop_reason = enforcer_handle.await.unwrap_or(StopReason::ClientDisconnected); + + let final_reason = match &test_result { + Ok(_) => { + if stop_reason == StopReason::ClientDisconnected { + StopReason::ClientDisconnected + } else { + stop_reason + } + } + Err(_) => StopReason::ClientDisconnected, + }; + + let (total_tx, total_rx, _, _) = state.summary(); + quota_mgr.record_usage(username, &peer.ip().to_string(), total_tx, total_rx); + db.end_session(session_id, total_tx, total_rx)?; + + Ok((final_reason, total_tx, total_rx)) +} + +/// Run a TCP multi-connection test with all streams collected. +/// Delegates to the standard multi-conn handler which correctly manages +/// TX+status injection for bidirectional mode. +async fn run_pro_multiconn_test( + streams: Vec, + cmd: Command, + peer: SocketAddr, + username: &str, + db: UserDb, + quota_mgr: QuotaManager, + quota_check_interval: u64, +) -> anyhow::Result<(StopReason, u64, u64)> { + let dir_str = match cmd.direction { + CMD_DIR_RX => "RX", CMD_DIR_TX => "TX", _ => "BOTH" + }; + let session_id = db.start_session( + username, &peer.ip().to_string(), "TCP", dir_str, + )?; + + tracing::info!( + "Starting TCP multi-conn test: {} streams, dir={}", + streams.len(), dir_str, + ); + + let state = BandwidthState::new(); + + let budget = quota_mgr.remaining_budget(username, &peer.ip()); + if budget < u64::MAX { + state.set_budget(budget); + } + + let enforcer = QuotaEnforcer::new( + quota_mgr.clone(), + username.to_string(), + peer.ip(), + state.clone(), + quota_check_interval, + quota_mgr.max_duration(), + ); + + let enforcer_state = state.clone(); + let enforcer_handle = tokio::spawn(async move { + enforcer.run().await + }); + + // Use the standard multi-connection handler which correctly handles + // all direction modes (TX, RX, BOTH with status injection) + let _test_result = btest_rs::server::run_tcp_multiconn_test( + streams, cmd, state.clone(), + ).await; + + enforcer_state.running.store(false, std::sync::atomic::Ordering::SeqCst); + let stop_reason = enforcer_handle.await.unwrap_or(StopReason::ClientDisconnected); + + let (total_tx, total_rx, _, _) = state.summary(); + quota_mgr.record_usage(username, &peer.ip().to_string(), total_tx, total_rx); + db.end_session(session_id, total_tx, total_rx)?; + + Ok((stop_reason, total_tx, total_rx)) +} diff --git a/src/server_pro/user_db.rs b/src/server_pro/user_db.rs new file mode 100644 index 0000000..661931e --- /dev/null +++ b/src/server_pro/user_db.rs @@ -0,0 +1,641 @@ +//! SQLite-based user database for btest-server-pro. +//! +//! Stores users with credentials, quotas, and usage tracking. + +use rusqlite::{Connection, params}; +use std::sync::{Arc, Mutex}; + +#[derive(Clone)] +pub struct UserDb { + conn: Arc>, + path: Arc, +} + +#[derive(Debug, Clone)] +pub struct User { + pub id: i64, + pub username: String, + pub password_hash: String, // stored as hex of SHA256(username:password) + pub daily_quota: i64, // 0 = use default + pub weekly_quota: i64, // 0 = use default + pub enabled: bool, +} + +#[derive(Debug)] +pub struct UsageRecord { + pub username: String, + pub date: String, // YYYY-MM-DD + pub tx_bytes: u64, + pub rx_bytes: u64, + pub test_count: u32, +} + +/// Per-second bandwidth interval data for graphing. +#[derive(Debug, Clone)] +pub struct IntervalData { + pub interval_num: i32, + pub tx_mbps: f64, + pub rx_mbps: f64, + pub local_cpu: i32, + pub remote_cpu: i32, + pub lost: i64, +} + +/// Summary of a single test session. +#[derive(Debug, Clone)] +pub struct SessionSummary { + pub id: i64, + pub started_at: String, + pub ended_at: Option, + pub protocol: String, + pub direction: String, + pub tx_bytes: u64, + pub rx_bytes: u64, +} + +/// Aggregate statistics for an IP address. +#[derive(Debug, Clone)] +pub struct IpStats { + pub total_tests: u64, + pub total_inbound: u64, + pub total_outbound: u64, + pub avg_tx_mbps: f64, + pub avg_rx_mbps: f64, +} + +impl UserDb { + pub fn open(path: &str) -> anyhow::Result { + let conn = Connection::open(path)?; + conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA busy_timeout=5000;")?; + Ok(Self { + conn: Arc::new(Mutex::new(conn)), + path: Arc::new(path.to_string()), + }) + } + + /// Return the database file path. + pub fn path(&self) -> &str { + &self.path + } + + pub fn ensure_tables(&self) -> anyhow::Result<()> { + let conn = self.conn.lock().unwrap(); + conn.execute_batch(" + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + daily_quota INTEGER DEFAULT 0, + weekly_quota INTEGER DEFAULT 0, + enabled INTEGER DEFAULT 1, + created_at TEXT DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS usage ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL, + date TEXT NOT NULL, + tx_bytes INTEGER DEFAULT 0, + rx_bytes INTEGER DEFAULT 0, + test_count INTEGER DEFAULT 0, + UNIQUE(username, date) + ); + + CREATE TABLE IF NOT EXISTS ip_usage ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ip TEXT NOT NULL, + date TEXT NOT NULL, + inbound_bytes INTEGER DEFAULT 0, + outbound_bytes INTEGER DEFAULT 0, + test_count INTEGER DEFAULT 0, + UNIQUE(ip, date) + ); + + CREATE TABLE IF NOT EXISTS sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL, + peer_ip TEXT NOT NULL, + started_at TEXT DEFAULT (datetime('now')), + ended_at TEXT, + tx_bytes INTEGER DEFAULT 0, + rx_bytes INTEGER DEFAULT 0, + protocol TEXT, + direction TEXT + ); + + CREATE TABLE IF NOT EXISTS test_intervals ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id INTEGER NOT NULL, + interval_num INTEGER NOT NULL, + tx_bytes INTEGER DEFAULT 0, + rx_bytes INTEGER DEFAULT 0, + tx_mbps REAL DEFAULT 0, + rx_mbps REAL DEFAULT 0, + local_cpu INTEGER DEFAULT 0, + remote_cpu INTEGER DEFAULT 0, + lost_packets INTEGER DEFAULT 0, + FOREIGN KEY(session_id) REFERENCES sessions(id) + ); + + CREATE INDEX IF NOT EXISTS idx_usage_user_date ON usage(username, date); + CREATE INDEX IF NOT EXISTS idx_ip_usage_date ON ip_usage(ip, date); + CREATE INDEX IF NOT EXISTS idx_sessions_peer ON sessions(peer_ip, started_at); + CREATE INDEX IF NOT EXISTS idx_intervals_session ON test_intervals(session_id); + ")?; + Ok(()) + } + + pub fn user_count(&self) -> anyhow::Result { + let conn = self.conn.lock().unwrap(); + let count: i64 = conn.query_row("SELECT COUNT(*) FROM users", [], |r| r.get(0))?; + Ok(count as u64) + } + + pub fn add_user(&self, username: &str, password: &str) -> anyhow::Result<()> { + let hash = hash_password(username, password); + let conn = self.conn.lock().unwrap(); + // Ensure password_raw column exists (migration for older databases) + let _ = conn.execute("ALTER TABLE users ADD COLUMN password_raw TEXT DEFAULT ''", []); + conn.execute( + "INSERT OR REPLACE INTO users (username, password_hash, password_raw) VALUES (?1, ?2, ?3)", + params![username, hash, password], + )?; + Ok(()) + } + + /// Get the raw password for MD5 challenge-response auth. + pub fn get_password(&self, username: &str) -> anyhow::Result> { + let conn = self.conn.lock().unwrap(); + let result = conn.query_row( + "SELECT password_raw FROM users WHERE username = ?1 AND enabled = 1", + params![username], + |row| row.get::<_, String>(0), + ).optional()?; + Ok(result) + } + + pub fn get_user(&self, username: &str) -> anyhow::Result> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare( + "SELECT id, username, password_hash, daily_quota, weekly_quota, enabled FROM users WHERE username = ?1" + )?; + let user = stmt.query_row(params![username], |row| { + Ok(User { + id: row.get(0)?, + username: row.get(1)?, + password_hash: row.get(2)?, + daily_quota: row.get(3)?, + weekly_quota: row.get(4)?, + enabled: row.get::<_, i32>(5)? != 0, + }) + }).optional()?; + Ok(user) + } + + pub fn verify_password(&self, username: &str, password: &str) -> anyhow::Result { + let expected = hash_password(username, password); + match self.get_user(username)? { + Some(user) => Ok(user.enabled && user.password_hash == expected), + None => Ok(false), + } + } + + pub fn record_usage(&self, username: &str, tx_bytes: u64, rx_bytes: u64) -> anyhow::Result<()> { + let conn = self.conn.lock().unwrap(); + let today = chrono_date_today(); + conn.execute( + "INSERT INTO usage (username, date, tx_bytes, rx_bytes, test_count) + VALUES (?1, ?2, ?3, ?4, 1) + ON CONFLICT(username, date) DO UPDATE SET + tx_bytes = tx_bytes + ?3, + rx_bytes = rx_bytes + ?4, + test_count = test_count + 1", + params![username, today, tx_bytes as i64, rx_bytes as i64], + )?; + Ok(()) + } + + pub fn get_daily_usage(&self, username: &str) -> anyhow::Result<(u64, u64)> { + let conn = self.conn.lock().unwrap(); + let today = chrono_date_today(); + let result = conn.query_row( + "SELECT COALESCE(SUM(tx_bytes),0), COALESCE(SUM(rx_bytes),0) FROM usage WHERE username = ?1 AND date = ?2", + params![username, today], + |row| { + let a: i64 = row.get(0)?; + let b: i64 = row.get(1)?; + Ok((a as u64, b as u64)) + }, + )?; + Ok(result) + } + + pub fn get_weekly_usage(&self, username: &str) -> anyhow::Result<(u64, u64)> { + let conn = self.conn.lock().unwrap(); + let result = conn.query_row( + "SELECT COALESCE(SUM(tx_bytes),0), COALESCE(SUM(rx_bytes),0) FROM usage + WHERE username = ?1 AND date >= date('now', '-7 days')", + params![username], + |row| { + let a: i64 = row.get(0)?; + let b: i64 = row.get(1)?; + Ok((a as u64, b as u64)) + }, + )?; + Ok(result) + } + + pub fn get_monthly_usage(&self, username: &str) -> anyhow::Result<(u64, u64)> { + let conn = self.conn.lock().unwrap(); + let result = conn.query_row( + "SELECT COALESCE(SUM(tx_bytes),0), COALESCE(SUM(rx_bytes),0) FROM usage + WHERE username = ?1 AND date >= date('now', '-30 days')", + params![username], + |row| { + let a: i64 = row.get(0)?; + let b: i64 = row.get(1)?; + Ok((a as u64, b as u64)) + }, + )?; + Ok(result) + } + + // --- Per-IP usage tracking --- + + pub fn record_ip_usage(&self, ip: &str, tx_bytes: u64, rx_bytes: u64) -> anyhow::Result<()> { + let conn = self.conn.lock().unwrap(); + let today = chrono_date_today(); + // From the server's perspective: inbound = data coming FROM the client (rx), + // outbound = data going TO the client (tx). + let inbound = rx_bytes; + let outbound = tx_bytes; + conn.execute( + "INSERT INTO ip_usage (ip, date, inbound_bytes, outbound_bytes, test_count) + VALUES (?1, ?2, ?3, ?4, 1) + ON CONFLICT(ip, date) DO UPDATE SET + inbound_bytes = inbound_bytes + ?3, + outbound_bytes = outbound_bytes + ?4, + test_count = test_count + 1", + params![ip, today, inbound as i64, outbound as i64], + )?; + Ok(()) + } + + pub fn get_ip_daily_usage(&self, ip: &str) -> anyhow::Result<(u64, u64)> { + let conn = self.conn.lock().unwrap(); + let today = chrono_date_today(); + let result = conn.query_row( + "SELECT COALESCE(SUM(inbound_bytes),0), COALESCE(SUM(outbound_bytes),0) FROM ip_usage WHERE ip = ?1 AND date = ?2", + params![ip, today], + |row| { + let inbound: i64 = row.get(0)?; + let outbound: i64 = row.get(1)?; + Ok((inbound as u64, outbound as u64)) + }, + )?; + Ok(result) + } + + pub fn get_ip_weekly_usage(&self, ip: &str) -> anyhow::Result<(u64, u64)> { + let conn = self.conn.lock().unwrap(); + let result = conn.query_row( + "SELECT COALESCE(SUM(inbound_bytes),0), COALESCE(SUM(outbound_bytes),0) FROM ip_usage + WHERE ip = ?1 AND date >= date('now', '-7 days')", + params![ip], + |row| { + let inbound: i64 = row.get(0)?; + let outbound: i64 = row.get(1)?; + Ok((inbound as u64, outbound as u64)) + }, + )?; + Ok(result) + } + + pub fn get_ip_monthly_usage(&self, ip: &str) -> anyhow::Result<(u64, u64)> { + let conn = self.conn.lock().unwrap(); + let result = conn.query_row( + "SELECT COALESCE(SUM(inbound_bytes),0), COALESCE(SUM(outbound_bytes),0) FROM ip_usage + WHERE ip = ?1 AND date >= date('now', '-30 days')", + params![ip], + |row| { + let inbound: i64 = row.get(0)?; + let outbound: i64 = row.get(1)?; + Ok((inbound as u64, outbound as u64)) + }, + )?; + Ok(result) + } + + // --- Per-IP directional usage (single-column queries) --- + + /// Record inbound-only IP usage (data coming FROM the client). + pub fn record_ip_inbound_usage(&self, ip: &str, bytes: u64) -> anyhow::Result<()> { + let conn = self.conn.lock().unwrap(); + let today = chrono_date_today(); + conn.execute( + "INSERT INTO ip_usage (ip, date, inbound_bytes, test_count) + VALUES (?1, ?2, ?3, 0) + ON CONFLICT(ip, date) DO UPDATE SET + inbound_bytes = inbound_bytes + ?3", + params![ip, today, bytes as i64], + )?; + Ok(()) + } + + /// Record outbound-only IP usage (data going TO the client). + pub fn record_ip_outbound_usage(&self, ip: &str, bytes: u64) -> anyhow::Result<()> { + let conn = self.conn.lock().unwrap(); + let today = chrono_date_today(); + conn.execute( + "INSERT INTO ip_usage (ip, date, outbound_bytes, test_count) + VALUES (?1, ?2, ?3, 0) + ON CONFLICT(ip, date) DO UPDATE SET + outbound_bytes = outbound_bytes + ?3", + params![ip, today, bytes as i64], + )?; + Ok(()) + } + + /// Get daily inbound bytes for an IP. + pub fn get_ip_daily_inbound(&self, ip: &str) -> anyhow::Result { + let conn = self.conn.lock().unwrap(); + let today = chrono_date_today(); + let result: i64 = conn.query_row( + "SELECT COALESCE(SUM(inbound_bytes),0) FROM ip_usage WHERE ip = ?1 AND date = ?2", + params![ip, today], + |row| row.get(0), + )?; + Ok(result as u64) + } + + /// Get weekly inbound bytes for an IP. + pub fn get_ip_weekly_inbound(&self, ip: &str) -> anyhow::Result { + let conn = self.conn.lock().unwrap(); + let result: i64 = conn.query_row( + "SELECT COALESCE(SUM(inbound_bytes),0) FROM ip_usage WHERE ip = ?1 AND date >= date('now', '-7 days')", + params![ip], + |row| row.get(0), + )?; + Ok(result as u64) + } + + /// Get monthly inbound bytes for an IP. + pub fn get_ip_monthly_inbound(&self, ip: &str) -> anyhow::Result { + let conn = self.conn.lock().unwrap(); + let result: i64 = conn.query_row( + "SELECT COALESCE(SUM(inbound_bytes),0) FROM ip_usage WHERE ip = ?1 AND date >= date('now', '-30 days')", + params![ip], + |row| row.get(0), + )?; + Ok(result as u64) + } + + /// Get daily outbound bytes for an IP. + pub fn get_ip_daily_outbound(&self, ip: &str) -> anyhow::Result { + let conn = self.conn.lock().unwrap(); + let today = chrono_date_today(); + let result: i64 = conn.query_row( + "SELECT COALESCE(SUM(outbound_bytes),0) FROM ip_usage WHERE ip = ?1 AND date = ?2", + params![ip, today], + |row| row.get(0), + )?; + Ok(result as u64) + } + + /// Get weekly outbound bytes for an IP. + pub fn get_ip_weekly_outbound(&self, ip: &str) -> anyhow::Result { + let conn = self.conn.lock().unwrap(); + let result: i64 = conn.query_row( + "SELECT COALESCE(SUM(outbound_bytes),0) FROM ip_usage WHERE ip = ?1 AND date >= date('now', '-7 days')", + params![ip], + |row| row.get(0), + )?; + Ok(result as u64) + } + + /// Get monthly outbound bytes for an IP. + pub fn get_ip_monthly_outbound(&self, ip: &str) -> anyhow::Result { + let conn = self.conn.lock().unwrap(); + let result: i64 = conn.query_row( + "SELECT COALESCE(SUM(outbound_bytes),0) FROM ip_usage WHERE ip = ?1 AND date >= date('now', '-30 days')", + params![ip], + |row| row.get(0), + )?; + Ok(result as u64) + } + + // --- Session tracking --- + + pub fn start_session(&self, username: &str, peer_ip: &str, protocol: &str, direction: &str) -> anyhow::Result { + let conn = self.conn.lock().unwrap(); + conn.execute( + "INSERT INTO sessions (username, peer_ip, protocol, direction) VALUES (?1, ?2, ?3, ?4)", + params![username, peer_ip, protocol, direction], + )?; + Ok(conn.last_insert_rowid()) + } + + pub fn end_session(&self, session_id: i64, tx_bytes: u64, rx_bytes: u64) -> anyhow::Result<()> { + let conn = self.conn.lock().unwrap(); + conn.execute( + "UPDATE sessions SET ended_at = datetime('now'), tx_bytes = ?1, rx_bytes = ?2 WHERE id = ?3", + params![tx_bytes as i64, rx_bytes as i64, session_id], + )?; + Ok(()) + } + + // --- Per-second interval tracking --- + + /// Record a single per-second interval data point for a session. + #[allow(clippy::too_many_arguments)] + pub fn record_test_interval( + &self, + session_id: i64, + interval_num: i32, + tx_bytes: u64, + rx_bytes: u64, + tx_mbps: f64, + rx_mbps: f64, + local_cpu: i32, + remote_cpu: i32, + lost: i64, + ) -> anyhow::Result<()> { + let conn = self.conn.lock().unwrap(); + conn.execute( + "INSERT INTO test_intervals (session_id, interval_num, tx_bytes, rx_bytes, tx_mbps, rx_mbps, local_cpu, remote_cpu, lost_packets) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", + params![ + session_id, + interval_num, + tx_bytes as i64, + rx_bytes as i64, + tx_mbps, + rx_mbps, + local_cpu, + remote_cpu, + lost, + ], + )?; + Ok(()) + } + + /// Retrieve all interval data points for a given session, ordered by interval number. + pub fn get_session_intervals(&self, session_id: i64) -> anyhow::Result> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare( + "SELECT interval_num, tx_mbps, rx_mbps, local_cpu, remote_cpu, lost_packets + FROM test_intervals WHERE session_id = ?1 ORDER BY interval_num" + )?; + let rows = stmt.query_map(params![session_id], |row| { + Ok(IntervalData { + interval_num: row.get(0)?, + tx_mbps: row.get(1)?, + rx_mbps: row.get(2)?, + local_cpu: row.get(3)?, + remote_cpu: row.get(4)?, + lost: row.get(5)?, + }) + })?.filter_map(|r| r.ok()).collect(); + Ok(rows) + } + + /// Return the last N sessions for a given IP address, most recent first. + pub fn get_ip_sessions(&self, ip: &str, limit: u32) -> anyhow::Result> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare( + "SELECT id, started_at, ended_at, protocol, direction, tx_bytes, rx_bytes + FROM sessions WHERE peer_ip = ?1 ORDER BY started_at DESC LIMIT ?2" + )?; + let rows = stmt.query_map(params![ip, limit], |row| { + Ok(SessionSummary { + id: row.get(0)?, + started_at: row.get(1)?, + ended_at: row.get(2)?, + protocol: row.get::<_, Option>(3)?.unwrap_or_default(), + direction: row.get::<_, Option>(4)?.unwrap_or_default(), + tx_bytes: row.get::<_, i64>(5).map(|v| v as u64)?, + rx_bytes: row.get::<_, i64>(6).map(|v| v as u64)?, + }) + })?.filter_map(|r| r.ok()).collect(); + Ok(rows) + } + + /// Return aggregate statistics for an IP address across all sessions. + pub fn get_ip_stats(&self, ip: &str) -> anyhow::Result { + let conn = self.conn.lock().unwrap(); + let result = conn.query_row( + "SELECT + COUNT(*) as total_tests, + COALESCE(SUM(inbound_bytes), 0) as total_inbound, + COALESCE(SUM(outbound_bytes), 0) as total_outbound + FROM ip_usage WHERE ip = ?1", + params![ip], + |row| { + let total_tests: i64 = row.get(0)?; + let total_inbound: i64 = row.get(1)?; + let total_outbound: i64 = row.get(2)?; + Ok((total_tests as u64, total_inbound as u64, total_outbound as u64)) + }, + )?; + + // Compute average Mbps from test_intervals joined through sessions + let (avg_tx, avg_rx) = conn.query_row( + "SELECT + COALESCE(AVG(ti.tx_mbps), 0.0), + COALESCE(AVG(ti.rx_mbps), 0.0) + FROM test_intervals ti + INNER JOIN sessions s ON ti.session_id = s.id + WHERE s.peer_ip = ?1", + params![ip], + |row| { + let avg_tx: f64 = row.get(0)?; + let avg_rx: f64 = row.get(1)?; + Ok((avg_tx, avg_rx)) + }, + )?; + + Ok(IpStats { + total_tests: result.0, + total_inbound: result.1, + total_outbound: result.2, + avg_tx_mbps: avg_tx, + avg_rx_mbps: avg_rx, + }) + } + + pub fn delete_user(&self, username: &str) -> anyhow::Result { + let conn = self.conn.lock().unwrap(); + let rows = conn.execute("DELETE FROM users WHERE username = ?1", params![username])?; + Ok(rows > 0) + } + + pub fn set_user_enabled(&self, username: &str, enabled: bool) -> anyhow::Result<()> { + let conn = self.conn.lock().unwrap(); + conn.execute( + "UPDATE users SET enabled = ?1 WHERE username = ?2", + params![enabled as i32, username], + )?; + Ok(()) + } + + pub fn set_user_quota(&self, username: &str, daily: i64, weekly: i64, monthly: i64) -> anyhow::Result<()> { + let conn = self.conn.lock().unwrap(); + conn.execute( + "UPDATE users SET daily_quota = ?1, weekly_quota = ?2 WHERE username = ?3", + params![daily, weekly, username], + )?; + Ok(()) + } + + pub fn list_users(&self) -> anyhow::Result> { + let conn = self.conn.lock().unwrap(); + let mut stmt = conn.prepare( + "SELECT id, username, password_hash, daily_quota, weekly_quota, enabled FROM users ORDER BY username" + )?; + let users = stmt.query_map([], |row| { + Ok(User { + id: row.get(0)?, + username: row.get(1)?, + password_hash: row.get(2)?, + daily_quota: row.get(3)?, + weekly_quota: row.get(4)?, + enabled: row.get::<_, i32>(5)? != 0, + }) + })?.filter_map(|r| r.ok()).collect(); + Ok(users) + } +} + +fn hash_password(username: &str, password: &str) -> String { + use sha2::{Sha256, Digest}; + let mut hasher = Sha256::new(); + hasher.update(format!("{}:{}", username, password).as_bytes()); + let result = hasher.finalize(); + result.iter().map(|b| format!("{:02x}", b)).collect() +} + +fn chrono_date_today() -> String { + // Simple date without chrono crate + use std::time::{SystemTime, UNIX_EPOCH}; + let secs = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs(); + let days = secs / 86400; + let mut y = 1970u64; + let mut remaining = days; + loop { + let leap = if y % 4 == 0 && (y % 100 != 0 || y % 400 == 0) { 366 } else { 365 }; + if remaining < leap { break; } + remaining -= leap; + y += 1; + } + let leap = y % 4 == 0 && (y % 100 != 0 || y % 400 == 0); + let days_in_months = [31u64, if leap { 29 } else { 28 }, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; + let mut m = 0usize; + for i in 0..12 { + if remaining < days_in_months[i] { m = i; break; } + remaining -= days_in_months[i]; + } + format!("{:04}-{:02}-{:02}", y, m + 1, remaining + 1) +} + +// Re-export for use by rusqlite +use rusqlite::OptionalExtension; diff --git a/src/server_pro/web/mod.rs b/src/server_pro/web/mod.rs new file mode 100644 index 0000000..1654b8c --- /dev/null +++ b/src/server_pro/web/mod.rs @@ -0,0 +1,811 @@ +//! Web dashboard module for btest-server-pro. +//! +//! Provides an axum-based HTTP dashboard with: +//! - Landing page with IP lookup +//! - Per-IP session history and statistics +//! - Chart.js throughput graphs +//! +//! # Feature gate +//! +//! This entire module is compiled only when the `pro` feature is active +//! (it lives inside the `btest-server-pro` binary crate which already +//! requires `--features pro`). +//! +//! # Template files +//! +//! The HTML source lives in `src/server_pro/web/templates/` as standalone +//! `.html` files for easy editing. The Rust code embeds them via the askama +//! `source` attribute so no `askama.toml` configuration is needed. If you +//! prefer external template files, create `askama.toml` at the crate root: +//! +//! ```toml +//! [[dirs]] +//! path = "src/server_pro/web/templates" +//! ``` +//! +//! Then change `source = "..."` to `path = "index.html"` (etc.) in the +//! template structs below. + +use std::sync::Arc; + +use askama::Template; +use axum::extract::{Path, State}; +use axum::http::StatusCode; +use axum::response::{Html, IntoResponse, Response}; +use axum::routing::get; +use axum::Router; +use rusqlite::{params, Connection}; +use serde::Serialize; + +use super::user_db::UserDb; + +// --------------------------------------------------------------------------- +// Shared state +// --------------------------------------------------------------------------- + +/// Shared application state passed to all handlers via axum's `State`. +pub struct WebState { + /// Reference to the main user/session database. + pub db: UserDb, + /// Separate read-only connection for dashboard queries that are not + /// exposed by [`UserDb`] (e.g. listing sessions, aggregate stats). + /// Wrapped in a [`std::sync::Mutex`] because [`rusqlite::Connection`] + /// is not `Send + Sync` on its own. + pub query_conn: std::sync::Mutex, +} + +// --------------------------------------------------------------------------- +// Router constructor +// --------------------------------------------------------------------------- + +/// Default database filename used when `BTEST_DB_PATH` is not set. +const DEFAULT_DB_PATH: &str = "btest-users.db"; + +/// Build the axum [`Router`] for the web dashboard. +/// +/// The database path for the read-only query connection is resolved in the +/// following order: +/// +/// 1. The `BTEST_DB_PATH` environment variable (if set). +/// 2. The compile-time default `btest-users.db`. +/// +/// # Panics +/// +/// Panics if the read-only database connection or the DDL for the +/// `session_intervals` table cannot be established. This is intentional: +/// the web module is optional and failure during startup should surface +/// loudly rather than silently serving broken pages. +pub fn create_router(db: UserDb) -> Router { + let db_path = db.path().to_string(); + + let query_conn = Connection::open_with_flags( + &db_path, + rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY + | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX, + ) + .expect("web: failed to open read-only database connection"); + query_conn + .execute_batch("PRAGMA busy_timeout=5000;") + .expect("web: failed to set PRAGMA on query connection"); + + // Ensure the `session_intervals` table exists. The server loop must + // INSERT rows for the chart to have data; the table is created here so + // the schema is ready. + ensure_web_tables(&db_path).expect("web: failed to create session_intervals table"); + + let state = Arc::new(WebState { + db, + query_conn: std::sync::Mutex::new(query_conn), + }); + + // axum 0.8 uses `{param}` syntax for path parameters. + Router::new() + .route("/", get(index_page)) + .route("/dashboard/{ip}", get(dashboard_page)) + .route("/api/ip/{ip}/sessions", get(api_sessions)) + .route("/api/ip/{ip}/stats", get(api_stats)) + .route("/api/ip/{ip}/export", get(api_export)) + .route("/api/ip/{ip}/quota", get(api_quota)) + .route("/api/session/{id}/intervals", get(api_intervals)) + .with_state(state) +} + +/// Create additional tables the web dashboard depends on. +/// +/// Opens a short-lived writable connection solely for DDL so it does not +/// interfere with the main [`UserDb`] connection. +fn ensure_web_tables(db_path: &str) -> anyhow::Result<()> { + let conn = Connection::open(db_path)?; + conn.execute_batch("PRAGMA busy_timeout=5000;")?; + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS session_intervals ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id INTEGER NOT NULL, + second INTEGER NOT NULL, + tx_bytes INTEGER NOT NULL DEFAULT 0, + rx_bytes INTEGER NOT NULL DEFAULT 0, + UNIQUE(session_id, second) + ); + CREATE INDEX IF NOT EXISTS idx_intervals_session + ON session_intervals(session_id, second);", + )?; + Ok(()) +} + +// --------------------------------------------------------------------------- +// Askama templates (embedded via `source`) +// --------------------------------------------------------------------------- + +/// Landing / index page template. +#[derive(Template)] +#[template( + source = r##" + + + + +btest-rs — Free Public Bandwidth Test Server + + + +
+

btest-rs

+

Free public MikroTik-compatible bandwidth test server.
Test your link speed from any RouterOS device — no registration required.

+ +
+

Quick Start

+

Open a terminal on your MikroTik router and run one of the following commands:

+

TCP Recommended

+
/tool bandwidth-test address=104.225.217.60 user=btest password=btest protocol=tcp direction=both
+

UDP

+
/tool bandwidth-test address=104.225.217.60 user=btest password=btest protocol=udp direction=both
+
+ +
+

Important Notes

+
    +
  • Credentials: user=btest password=btest
  • +
  • TCP is recommended for remote testing — it works reliably through any NAT or firewall
  • +
  • Per-IP daily quotas apply to keep the service fair for everyone
  • +
  • Maximum test duration: 120 seconds
  • +
  • Connection limit: 3 concurrent tests per IP
  • +
+
+ UDP bidirectional may not work through NAT/firewall. + UDP direction=both requires the server to send packets to a pre-calculated client port, which NAT routers typically block. If you need UDP testing:
+ • Forward UDP ports 2001–2100 on your router, or
+ • Use direction=send or direction=receive (one-way works fine), or
+ • Test from a device with a public IP +
+
+ +
+

Check Your Results

+

After running a test, enter your public IP to view throughput charts, session history, and statistics.

+ + +
+ + +
+ + +"##, + ext = "html" +)] +struct IndexTemplate; + +/// Per-IP dashboard page template. +#[derive(Template)] +#[template( + source = r##" + + + + +Dashboard — {{ ip }} — btest-rs + + + +
+

btest-rs

+ {{ ip }} + Export JSON + Home +
+
+
Total Tests
+
Total TX
+
Total RX
+
Avg TX Mbps
+
Avg RX Mbps
+
+
+

Quota Usage

+
Daily
+
Weekly
+
Monthly
+
+
+

Select a test below to view its throughput chart

+
+ +
Click a row in the table to load the throughput graph for that session.
+
+
+
+ + + +
#DateProtocolDirectionTX BytesRX BytesDurationAvg TX MbpsAvg RX Mbps
Loading sessions...
+
+ + + + +"##, + ext = "html" +)] +struct DashboardTemplate { + ip: String, +} + +// --------------------------------------------------------------------------- +// JSON response types +// --------------------------------------------------------------------------- + +/// A single test session as returned by the sessions API. +#[derive(Serialize)] +struct SessionJson { + id: i64, + username: String, + peer_ip: String, + started_at: Option, + ended_at: Option, + tx_bytes: i64, + rx_bytes: i64, + protocol: Option, + direction: Option, +} + +/// Aggregate statistics for an IP address. +#[derive(Serialize)] +struct StatsJson { + total_sessions: i64, + total_tx_bytes: i64, + total_rx_bytes: i64, + avg_tx_mbps: f64, + avg_rx_mbps: f64, +} + +/// One second of throughput data within a session. +#[derive(Serialize)] +struct IntervalJson { + second: i64, + tx_bytes: i64, + rx_bytes: i64, +} + +// --------------------------------------------------------------------------- +// Error helper +// --------------------------------------------------------------------------- + +/// Uniform error wrapper so handlers can use `?` freely. +/// +/// All errors are rendered as `500 Internal Server Error` with a plain-text +/// body. The full error chain is logged via [`tracing`]. +struct AppError(anyhow::Error); + +impl IntoResponse for AppError { + fn into_response(self) -> Response { + tracing::error!("web handler error: {:#}", self.0); + (StatusCode::INTERNAL_SERVER_ERROR, self.0.to_string()).into_response() + } +} + +impl> From for AppError { + fn from(err: E) -> Self { + Self(err.into()) + } +} + +// --------------------------------------------------------------------------- +// Handlers +// --------------------------------------------------------------------------- + +/// `GET /` -- render the landing page. +async fn index_page() -> Result, AppError> { + let rendered = IndexTemplate + .render() + .map_err(|e| anyhow::anyhow!("template render: {}", e))?; + Ok(Html(rendered)) +} + +/// `GET /dashboard/{ip}` -- render the per-IP dashboard. +async fn dashboard_page(Path(ip): Path) -> Result, AppError> { + let rendered = DashboardTemplate { ip } + .render() + .map_err(|e| anyhow::anyhow!("template render: {}", e))?; + Ok(Html(rendered)) +} + +/// `GET /api/ip/{ip}/sessions` -- return the most recent 100 sessions for +/// the given peer IP as a JSON array. +async fn api_sessions( + State(state): State>, + Path(ip): Path, +) -> Result>, AppError> { + let sessions = { + let conn = state + .query_conn + .lock() + .map_err(|e| anyhow::anyhow!("lock: {}", e))?; + let mut stmt = conn.prepare( + "SELECT id, username, peer_ip, started_at, ended_at, + tx_bytes, rx_bytes, protocol, direction + FROM sessions + WHERE peer_ip = ?1 + ORDER BY started_at DESC + LIMIT 100", + )?; + let rows = stmt.query_map(params![ip], |row| { + Ok(SessionJson { + id: row.get(0)?, + username: row.get(1)?, + peer_ip: row.get(2)?, + started_at: row.get(3)?, + ended_at: row.get(4)?, + tx_bytes: row.get(5)?, + rx_bytes: row.get(6)?, + protocol: row.get(7)?, + direction: row.get(8)?, + }) + })?; + rows.filter_map(Result::ok).collect::>() + }; + + Ok(axum::Json(sessions)) +} + +/// `GET /api/ip/{ip}/stats` -- return aggregate statistics (total bytes, +/// session count, average throughput) for the given IP. +async fn api_stats( + State(state): State>, + Path(ip): Path, +) -> Result, AppError> { + let stats = { + let conn = state + .query_conn + .lock() + .map_err(|e| anyhow::anyhow!("lock: {}", e))?; + conn.query_row( + "SELECT + COUNT(*) AS total_sessions, + COALESCE(SUM(tx_bytes), 0) AS total_tx, + COALESCE(SUM(rx_bytes), 0) AS total_rx, + COALESCE(SUM( + CASE WHEN ended_at IS NOT NULL AND started_at IS NOT NULL + THEN (julianday(ended_at) - julianday(started_at)) * 86400.0 + ELSE 0 END + ), 0) AS total_seconds + FROM sessions + WHERE peer_ip = ?1", + params![ip], + |row| { + let total_sessions: i64 = row.get(0)?; + let total_tx: i64 = row.get(1)?; + let total_rx: i64 = row.get(2)?; + let total_seconds: f64 = row.get(3)?; + + let avg_tx_mbps = if total_seconds > 0.0 { + (total_tx as f64) * 8.0 / total_seconds / 1_000_000.0 + } else { + 0.0 + }; + let avg_rx_mbps = if total_seconds > 0.0 { + (total_rx as f64) * 8.0 / total_seconds / 1_000_000.0 + } else { + 0.0 + }; + + Ok(StatsJson { + total_sessions, + total_tx_bytes: total_tx, + total_rx_bytes: total_rx, + avg_tx_mbps, + avg_rx_mbps, + }) + }, + )? + }; + + Ok(axum::Json(stats)) +} + +/// Quota usage for an IP — daily/weekly/monthly with limits. +#[derive(Serialize)] +struct QuotaUsageJson { + daily_used: i64, + daily_limit: i64, + weekly_used: i64, + weekly_limit: i64, + monthly_used: i64, + monthly_limit: i64, +} + +/// `GET /api/ip/{ip}/quota` -- return current quota usage for the IP. +async fn api_quota( + State(state): State>, + Path(ip): Path, +) -> Result, AppError> { + let conn = state.query_conn.lock().map_err(|e| anyhow::anyhow!("lock: {}", e))?; + + let daily: i64 = conn.query_row( + "SELECT COALESCE(SUM(inbound_bytes + outbound_bytes), 0) FROM ip_usage WHERE ip = ?1 AND date = date('now')", + params![ip], |row| row.get(0), + ).unwrap_or(0); + + let weekly: i64 = conn.query_row( + "SELECT COALESCE(SUM(inbound_bytes + outbound_bytes), 0) FROM ip_usage WHERE ip = ?1 AND date >= date('now', '-7 days')", + params![ip], |row| row.get(0), + ).unwrap_or(0); + + let monthly: i64 = conn.query_row( + "SELECT COALESCE(SUM(inbound_bytes + outbound_bytes), 0) FROM ip_usage WHERE ip = ?1 AND date >= date('now', '-30 days')", + params![ip], |row| row.get(0), + ).unwrap_or(0); + + // Limits: 2GB daily, 8GB weekly, 24GB monthly + Ok(axum::Json(QuotaUsageJson { + daily_used: daily, + daily_limit: 2_147_483_648, + weekly_used: weekly, + weekly_limit: 8_589_934_592, + monthly_used: monthly, + monthly_limit: 25_769_803_776, + })) +} + +/// Full export of all data for an IP — stats + sessions with human-readable fields. +#[derive(Serialize)] +struct ExportJson { + ip: String, + exported_at: String, + stats: StatsJson, + quota: QuotaJson, + sessions: Vec, +} + +#[derive(Serialize)] +struct QuotaJson { + daily_used_bytes: i64, + daily_used_human: String, + daily_limit_bytes: String, +} + +#[derive(Serialize)] +struct ExportSessionJson { + id: i64, + started_at: Option, + ended_at: Option, + protocol: Option, + direction: Option, + tx_bytes: i64, + rx_bytes: i64, + tx_human: String, + rx_human: String, + duration_secs: f64, + avg_tx_mbps: f64, + avg_rx_mbps: f64, +} + +fn human_bytes(b: i64) -> String { + let b = b as f64; + if b >= 1_073_741_824.0 { + format!("{:.2} GB", b / 1_073_741_824.0) + } else if b >= 1_048_576.0 { + format!("{:.1} MB", b / 1_048_576.0) + } else if b >= 1024.0 { + format!("{:.1} KB", b / 1024.0) + } else { + format!("{} B", b as i64) + } +} + +/// `GET /api/ip/{ip}/export` -- return a comprehensive JSON export of all +/// sessions, stats, and quota usage for an IP. Suitable for download/archival. +async fn api_export( + State(state): State>, + Path(ip): Path, +) -> Result { + let conn = state + .query_conn + .lock() + .map_err(|e| anyhow::anyhow!("lock: {}", e))?; + + // Stats + let stats = conn.query_row( + "SELECT COUNT(*), COALESCE(SUM(tx_bytes),0), COALESCE(SUM(rx_bytes),0), + COALESCE(SUM(CASE WHEN ended_at IS NOT NULL AND started_at IS NOT NULL + THEN (julianday(ended_at)-julianday(started_at))*86400.0 ELSE 0 END),0) + FROM sessions WHERE peer_ip = ?1", + params![ip], + |row| { + let n: i64 = row.get(0)?; + let tx: i64 = row.get(1)?; + let rx: i64 = row.get(2)?; + let secs: f64 = row.get(3)?; + Ok(StatsJson { + total_sessions: n, + total_tx_bytes: tx, + total_rx_bytes: rx, + avg_tx_mbps: if secs > 0.0 { tx as f64 * 8.0 / secs / 1e6 } else { 0.0 }, + avg_rx_mbps: if secs > 0.0 { rx as f64 * 8.0 / secs / 1e6 } else { 0.0 }, + }) + }, + )?; + + // Quota + let daily_used: i64 = conn.query_row( + "SELECT COALESCE(SUM(inbound_bytes + outbound_bytes), 0) FROM ip_usage + WHERE ip = ?1 AND date = date('now')", + params![ip], + |row| row.get(0), + ).unwrap_or(0); + + let quota = QuotaJson { + daily_used_bytes: daily_used, + daily_used_human: human_bytes(daily_used), + daily_limit_bytes: "see server config".to_string(), + }; + + // Sessions with computed fields (duration computed by SQLite) + let mut stmt = conn.prepare( + "SELECT id, started_at, ended_at, protocol, direction, tx_bytes, rx_bytes, + CASE WHEN ended_at IS NOT NULL AND started_at IS NOT NULL + THEN (julianday(ended_at) - julianday(started_at)) * 86400.0 + ELSE 0 END AS dur_secs + FROM sessions WHERE peer_ip = ?1 ORDER BY started_at DESC LIMIT 100", + )?; + let sessions: Vec = stmt.query_map(params![ip], |row| { + let tx: i64 = row.get(5)?; + let rx: i64 = row.get(6)?; + let dur: f64 = row.get(7)?; + Ok(ExportSessionJson { + id: row.get(0)?, + started_at: row.get(1)?, + ended_at: row.get(2)?, + protocol: row.get(3)?, + direction: row.get(4)?, + tx_bytes: tx, + rx_bytes: rx, + tx_human: human_bytes(tx), + rx_human: human_bytes(rx), + duration_secs: dur, + avg_tx_mbps: if dur > 0.0 { tx as f64 * 8.0 / dur / 1e6 } else { 0.0 }, + avg_rx_mbps: if dur > 0.0 { rx as f64 * 8.0 / dur / 1e6 } else { 0.0 }, + }) + })?.filter_map(Result::ok).collect(); + + let export = ExportJson { + ip: ip.clone(), + exported_at: { + // Simple UTC timestamp without chrono + use std::time::{SystemTime, UNIX_EPOCH}; + let secs = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs(); + format!("{}", secs) // Unix timestamp — universally parseable + }, + stats, + quota, + sessions, + }; + + let json_string = serde_json::to_string_pretty(&export) + .map_err(|e| anyhow::anyhow!("json serialize: {}", e))?; + + Ok(( + StatusCode::OK, + [ + (axum::http::header::CONTENT_TYPE, "application/json".to_string()), + (axum::http::header::CONTENT_DISPOSITION, + format!("attachment; filename=\"btest-{}.json\"", ip)), + ], + json_string, + )) +} + +/// `GET /api/session/{id}/intervals` -- return per-second throughput data +/// for a session. +/// +/// If the `session_intervals` table does not exist or contains no rows for +/// the requested session, an empty JSON array is returned. +async fn api_intervals( + State(state): State>, + Path(id): Path, +) -> Result>, AppError> { + let intervals = { + let conn = state + .query_conn + .lock() + .map_err(|e| anyhow::anyhow!("lock: {}", e))?; + + // Guard against the table not existing (e.g. first run before + // `ensure_web_tables` was ever called on this database file). + let table_exists: bool = conn + .query_row( + "SELECT COUNT(*) FROM sqlite_master \ + WHERE type = 'table' AND name = 'session_intervals'", + [], + |row| row.get::<_, i64>(0), + ) + .map(|c| c > 0) + .unwrap_or(false); + + if !table_exists { + Vec::new() + } else { + let mut stmt = conn.prepare( + "SELECT second, tx_bytes, rx_bytes + FROM session_intervals + WHERE session_id = ?1 + ORDER BY second ASC", + )?; + let rows = stmt.query_map(params![id], |row| { + Ok(IntervalJson { + second: row.get(0)?, + tx_bytes: row.get(1)?, + rx_bytes: row.get(2)?, + }) + })?; + rows.filter_map(Result::ok).collect::>() + } + }; + + Ok(axum::Json(intervals)) +} diff --git a/src/server_pro/web/templates/dashboard.html b/src/server_pro/web/templates/dashboard.html new file mode 100644 index 0000000..a1c02a5 --- /dev/null +++ b/src/server_pro/web/templates/dashboard.html @@ -0,0 +1,387 @@ + + + + + +Dashboard — {{ ip }} — btest-rs + + + + +
+

btest-rs

+ {{ ip }} + Home +
+ + +
+
+
Total Tests
+
+
+
+
Total TX
+
+
+
+
Total RX
+
+
+
+
Avg TX Mbps
+
+
+
+
Avg RX Mbps
+
+
+
+ + +
+

Select a test below to view its throughput chart

+
+ +
Click a row in the table to load the throughput graph for that session.
+
+
+ + +
+ + + + + + + + + + + + + + + + + +
#DateProtocolDirectionTX BytesRX BytesDurationAvg TX MbpsAvg RX Mbps
Loading sessions...
+
+ + + + + + + diff --git a/src/server_pro/web/templates/index.html b/src/server_pro/web/templates/index.html new file mode 100644 index 0000000..f736800 --- /dev/null +++ b/src/server_pro/web/templates/index.html @@ -0,0 +1,160 @@ + + + + + +btest-rs Public Bandwidth Test Server + + + +
+

btest-rs

+

Public MikroTik Bandwidth Test Server — view your test results and history.

+ + + + + +
+

How it works

+

+ Run a bandwidth test from your MikroTik router targeting this server. + After the test completes, enter your public IP above to see + throughput charts, session history, and aggregate statistics. +

+

+ Example: /tool bandwidth-test address=this-server protocol=tcp direction=both +

+
+ + +
+ + + +