commit fd6fb5cca03a03d8b0273b009f851cc6b433c84a Author: iven Date: Sun Apr 5 00:57:51 2026 +0800 feat: 初始化项目基础架构和核心功能 - 添加项目基础结构:Cargo.toml、.gitignore、设备UID和密钥文件 - 实现前端Vue3项目结构:路由、登录页面、设备管理页面 - 添加核心协议定义(crates/protocol):设备状态、资产、USB事件等 - 实现客户端监控模块:系统状态收集、资产收集 - 实现服务端基础API和插件系统 - 添加数据库迁移脚本:设备管理、资产跟踪、告警系统等 - 实现前端设备状态展示和基本交互 - 添加使用时长统计和水印功能插件 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1083918 --- /dev/null +++ b/.gitignore @@ -0,0 +1,36 @@ +# Rust build artifacts +target/ + +# Database files +*.db +*.db-journal +*.db-wal +*.db-shm + +# Configuration with secrets +config.toml + +# Environment variables +.env +.env.* + +# Logs +*.log + +# Frontend +web/node_modules/ +web/dist/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Plans (development artifacts) +plans/ diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..ef3c2cf --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,3923 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "async-compression" +version = "0.4.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0f9ee0f6e02ffd7ad5816e9464499fba7b3effd01123b515c41d1697c43dad1" +dependencies = [ + "compression-codecs", + "compression-core", + "pin-project-lite", + "tokio", +] + +[[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 = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + +[[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 = "aws-lc-rs" +version = "1.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a054912289d18629dc78375ba2c3726a3afe3ff71b4edba9dedfca0e3446d1fc" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.39.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83a25cf98105baa966497416dbd42565ce3a8cf8dbfd59803ec9ad46f3126399" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + +[[package]] +name = "axum" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" +dependencies = [ + "async-trait", + "axum-core", + "base64", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sha1", + "sync_wrapper", + "tokio", + "tokio-tungstenite", + "tower 0.5.3", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bcrypt" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e65938ed058ef47d92cf8b346cc76ef48984572ade631927e9937b5ffc7662c7" +dependencies = [ + "base64", + "blowfish", + "getrandom 0.2.17", + "subtle", + "zeroize", +] + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +dependencies = [ + "serde_core", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "blowfish" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e412e2cd0f2b2d93e02543ceae7917b3c70331573df19ee046bcbc35e45e87d7" +dependencies = [ + "byteorder", + "cipher", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[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", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "compression-codecs" +version = "0.4.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb7b51a7d9c967fc26773061ba86150f19c50c0d65c887cb1fbe295fd16619b7" +dependencies = [ + "compression-core", + "flate2", + "memchr", +] + +[[package]] +name = "compression-core" +version = "0.4.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75984efb6ed102a0d42db99afb6c1948f0380d1d91808d5529916e6c08b49d8d" + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[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.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "csm-client" +version = "0.1.0" +dependencies = [ + "anyhow", + "chrono", + "csm-protocol", + "hex", + "hmac", + "hostname", + "rustls", + "rustls-pemfile", + "rustls-pki-types", + "serde", + "serde_json", + "sha2", + "sysinfo", + "thiserror 1.0.69", + "tokio", + "tokio-rustls", + "tracing", + "tracing-subscriber", + "uuid", + "webpki-roots 0.26.11", + "windows 0.54.0", + "windows-service", +] + +[[package]] +name = "csm-protocol" +version = "0.1.0" +dependencies = [ + "anyhow", + "chrono", + "serde", + "serde_json", + "thiserror 1.0.69", + "uuid", +] + +[[package]] +name = "csm-server" +version = "0.1.0" +dependencies = [ + "anyhow", + "axum", + "bcrypt", + "chrono", + "csm-protocol", + "hex", + "hmac", + "include_dir", + "jsonwebtoken", + "lettre", + "reqwest", + "rustls", + "rustls-pemfile", + "rustls-pki-types", + "serde", + "serde_json", + "sha2", + "sqlx", + "thiserror 1.0.69", + "tokio", + "tokio-rustls", + "toml", + "tower 0.4.13", + "tower-http 0.5.2", + "tracing", + "tracing-subscriber", + "uuid", +] + +[[package]] +name = "data-encoding" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "subtle", +] + +[[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 = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +dependencies = [ + "serde", +] + +[[package]] +name = "email-encoding" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9298e6504d9b9e780ed3f7dfd43a61be8cd0e09eb07f7706a945b0072b6670b6" +dependencies = [ + "base64", + "memchr", +] + +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "etcetera" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" +dependencies = [ + "cfg-if", + "home", + "windows-sys 0.48.0", +] + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[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 = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "flume" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[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 = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[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-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[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-core", + "futures-io", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + +[[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 6.0.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "h2" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "hostname" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd" +dependencies = [ + "cfg-if", + "libc", + "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 = "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", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots 1.0.6", +] + +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.62.2", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +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 = "include_dir" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "923d117408f1e49d914f1a379a309cffe4f18c05cf4e3d12e613a15fc81bd0dd" +dependencies = [ + "include_dir_macros", +] + +[[package]] +name = "include_dir_macros" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cab85a7ed0bd5f0e76d93846e0147172bed2e2d3f859bcc33a8d9699cad1a75" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "indexmap" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45a8a2b9cb3e0b0c1803dbb0758ffac5de2f425b23c28f518faabd9d805342ff" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "iri-string" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.94" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e04e2ef80ce82e13552136fabeef8a5ed1f985a96805761cbb9a2c34e7664d9" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "jsonwebtoken" +version = "9.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" +dependencies = [ + "base64", + "js-sys", + "pem", + "ring", + "serde", + "serde_json", + "simple_asn1", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "lettre" +version = "0.11.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "471816f3e24b85e820dee02cde962379ea1a669e5242f19c61bcbcffedf4c4fb" +dependencies = [ + "async-trait", + "base64", + "email-encoding", + "email_address", + "fastrand", + "futures-io", + "futures-util", + "hostname", + "httpdate", + "idna", + "mime", + "nom", + "percent-encoding", + "quoted_printable", + "rustls", + "socket2", + "tokio", + "tokio-rustls", + "url", + "webpki-roots 1.0.6", +] + +[[package]] +name = "libc" +version = "0.2.184" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08" +dependencies = [ + "bitflags", + "libc", + "plain", + "redox_syscall 0.7.3", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + +[[package]] +name = "memchr" +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 = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "wasi", + "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 = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "ntapi" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" +dependencies = [ + "winapi", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.5", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-conv" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[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" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[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 = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.2", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "quoted_printable" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "478e0585659a122aa407eb7e3c0e1fa51b1d8a870038bd29f0cf4a8551eea972" + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rayon" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_syscall" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "encoding_rs", + "futures-core", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-tls", + "hyper-util", + "js-sys", + "log", + "mime", + "native-tls", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-native-tls", + "tokio-rustls", + "tower 0.5.3", + "tower-http 0.6.8", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots 1.0.6", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[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 = "rustls" +version = "0.23.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +dependencies = [ + "aws-lc-rs", + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + +[[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 0.10.1", + "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_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[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 = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "simple_asn1" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror 2.0.18", + "time", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +dependencies = [ + "serde", +] + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "sqlx" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" +dependencies = [ + "base64", + "bytes", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.15.5", + "hashlink", + "indexmap", + "log", + "memchr", + "once_cell", + "percent-encoding", + "serde", + "serde_json", + "sha2", + "smallvec", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tracing", + "url", +] + +[[package]] +name = "sqlx-macros" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" +dependencies = [ + "dotenvy", + "either", + "heck", + "hex", + "once_cell", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2", + "sqlx-core", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", + "syn", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" +dependencies = [ + "atoi", + "base64", + "bitflags", + "byteorder", + "bytes", + "crc", + "digest", + "dotenvy", + "either", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "generic-array", + "hex", + "hkdf", + "hmac", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "percent-encoding", + "rand 0.8.5", + "rsa", + "serde", + "sha1", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 2.0.18", + "tracing", + "whoami", +] + +[[package]] +name = "sqlx-postgres" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" +dependencies = [ + "atoi", + "base64", + "bitflags", + "byteorder", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-util", + "hex", + "hkdf", + "hmac", + "home", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "rand 0.8.5", + "serde", + "serde_json", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 2.0.18", + "tracing", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" +dependencies = [ + "atoi", + "flume", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "serde_urlencoded", + "sqlx-core", + "thiserror 2.0.18", + "tracing", + "url", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "sysinfo" +version = "0.30.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a5b4ddaee55fb2bea2bf0e5000747e5f5c0de765e5a5ff87f4cd106439f4bb3" +dependencies = [ + "cfg-if", + "core-foundation-sys", + "libc", + "ntapi", + "once_cell", + "rayon", + "windows 0.52.0", +] + +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[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 = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" +dependencies = [ + "proc-macro2", + "quote", + "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-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite", +] + +[[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 = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "tower" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" +dependencies = [ + "tower-layer", + "tower-service", + "tracing", +] + +[[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.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5" +dependencies = [ + "async-compression", + "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-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "iri-string", + "pin-project-lite", + "tower 0.5.3", + "tower-layer", + "tower-service", +] + +[[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", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-serde" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1" +dependencies = [ + "serde", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "serde", + "serde_json", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", + "tracing-serde", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a" +dependencies = [ + "byteorder", + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.8.5", + "sha1", + "thiserror 1.0.69", + "utf-8", +] + +[[package]] +name = "typenum" +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-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[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 = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +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 = "wasite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" + +[[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-futures" +version = "0.4.67" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03623de6905b7206edd0a75f69f747f134b7f0a2323392d664448bf2d3c5d87e" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[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 = "web-sys" +version = "0.3.94" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd70027e39b12f0849461e08ffc50b9cd7688d942c1c8e3c7b22273236b4dd0a" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.6", +] + +[[package]] +name = "webpki-roots" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "whoami" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" +dependencies = [ + "libredox", + "wasite", +] + +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e48a53791691ab099e5e2ad123536d0fff50652600abaf43bbf952894110d0be" +dependencies = [ + "windows-core 0.52.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows" +version = "0.54.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9252e5725dbed82865af151df558e754e4a3c2c30818359eb17465f1346a1b49" +dependencies = [ + "windows-core 0.54.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.54.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12661b9c89351d684a50a8a643ce5f608e20243b9fb84687800163429f161d65" +dependencies = [ + "windows-result 0.1.2", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result 0.4.1", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result 0.4.1", + "windows-strings", +] + +[[package]] +name = "windows-result" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-service" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d24d6bcc7f734a4091ecf8d7a64c5f7d7066f45585c1861eba06449909609c8a" +dependencies = [ + "bitflags", + "widestring", + "windows-sys 0.52.0", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +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.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +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 new file mode 100644 index 0000000..f814f46 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,29 @@ +[workspace] +resolver = "2" +members = [ + "crates/protocol", + "crates/server", + "crates/client", +] + +[workspace.package] +version = "0.1.0" +edition = "2021" +license = "MIT" + +[workspace.dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" +thiserror = "1" +tokio = { version = "1", features = ["full"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } +anyhow = "1" +uuid = { version = "1", features = ["v4", "serde"] } +chrono = { version = "0.4", features = ["serde"] } + +[profile.release] +lto = true +strip = true +codegen-units = 1 +opt-level = "s" diff --git a/crates/client/Cargo.toml b/crates/client/Cargo.toml new file mode 100644 index 0000000..3c32ff3 --- /dev/null +++ b/crates/client/Cargo.toml @@ -0,0 +1,48 @@ +[package] +name = "csm-client" +version.workspace = true +edition.workspace = true + +[dependencies] +csm-protocol = { path = "../protocol" } +tokio = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } +anyhow = { workspace = true } +uuid = { workspace = true } +chrono = { workspace = true } +thiserror = { workspace = true } +sysinfo = "0.30" +tokio-rustls = "0.26" +rustls = { version = "0.23", default-features = false, features = ["ring", "std"] } +rustls-pki-types = "1" +webpki-roots = "0.26" +rustls-pemfile = "2" +hmac = "0.12" +sha2 = "0.10" +hex = "0.4" + +[target.'cfg(target_os = "windows")'.dependencies] +windows = { version = "0.54", features = [ + "Win32_Foundation", + "Win32_System_SystemInformation", + "Win32_System_Registry", + "Win32_System_IO", + "Win32_Security", + "Win32_NetworkManagement_IpHelper", + "Win32_Storage_FileSystem", + "Win32_UI_WindowsAndMessaging", + "Win32_UI_Input_KeyboardAndMouse", + "Win32_System_Threading", + "Win32_System_Diagnostics_ToolHelp", + "Win32_System_LibraryLoader", + "Win32_System_Performance", + "Win32_Graphics_Gdi", +] } +windows-service = "0.7" +hostname = "0.4" + +[target.'cfg(not(target_os = "windows"))'.dependencies] +hostname = "0.4" diff --git a/crates/client/src/asset/mod.rs b/crates/client/src/asset/mod.rs new file mode 100644 index 0000000..4fcd417 --- /dev/null +++ b/crates/client/src/asset/mod.rs @@ -0,0 +1,54 @@ +use csm_protocol::{Frame, MessageType, HardwareAsset}; +use std::time::Duration; +use tokio::sync::mpsc::Sender; +use tracing::{info, error}; +use sysinfo::System; + +pub async fn start_collecting(tx: Sender, device_uid: String) { + let interval = Duration::from_secs(86400); // Once per day + + // Initial collection on startup + if let Err(e) = collect_and_send(&tx, &device_uid).await { + error!("Initial asset collection failed: {}", e); + } + + loop { + tokio::time::sleep(interval).await; + + if let Err(e) = collect_and_send(&tx, &device_uid).await { + error!("Asset collection failed: {}", e); + } + } +} + +async fn collect_and_send(tx: &Sender, device_uid: &str) -> anyhow::Result<()> { + let hardware = collect_hardware(device_uid)?; + let frame = Frame::new_json(MessageType::AssetReport, &hardware)?; + tx.send(frame).await.map_err(|e| anyhow::anyhow!("Channel send failed: {}", e))?; + info!("Asset report sent for {}", device_uid); + Ok(()) +} + +fn collect_hardware(device_uid: &str) -> anyhow::Result { + let mut sys = System::new_all(); + sys.refresh_all(); + + let cpu_model = sys.cpus().first() + .map(|c| c.brand().to_string()) + .unwrap_or_else(|| "Unknown".to_string()); + + let cpu_cores = sys.cpus().len() as u32; + let memory_total_mb = sys.total_memory() / 1024 / 1024; // bytes to MB (sysinfo 0.30) + + Ok(HardwareAsset { + device_uid: device_uid.to_string(), + cpu_model, + cpu_cores, + memory_total_mb: memory_total_mb as u64, + disk_model: "Unknown".to_string(), + disk_total_mb: 0, + gpu_model: None, + motherboard: None, + serial_number: None, + }) +} diff --git a/crates/client/src/main.rs b/crates/client/src/main.rs new file mode 100644 index 0000000..7bd3f9d --- /dev/null +++ b/crates/client/src/main.rs @@ -0,0 +1,206 @@ +use anyhow::Result; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::Duration; +use tracing::{info, error, warn}; +use csm_protocol::{Frame, ClientConfig, UsbPolicyPayload}; + +mod monitor; +mod asset; +mod usb; +mod network; +mod watermark; +mod usage_timer; +mod usb_audit; +mod popup_blocker; +mod software_blocker; +mod web_filter; + +/// Shared shutdown flag +static SHUTDOWN: AtomicBool = AtomicBool::new(false); + +/// Client configuration +struct ClientState { + device_uid: String, + server_addr: String, + config: ClientConfig, + device_secret: Option, + registration_token: String, + /// Whether to use TLS when connecting to the server + use_tls: bool, +} + +#[tokio::main] +async fn main() -> Result<()> { + tracing_subscriber::fmt() + .with_env_filter("csm_client=info") + .init(); + + info!("CSM Client starting..."); + + // Load or generate device identity + let device_uid = load_or_create_device_uid()?; + info!("Device UID: {}", device_uid); + + // Load server address + let server_addr = std::env::var("CSM_SERVER") + .unwrap_or_else(|_| "127.0.0.1:9999".to_string()); + + let state = ClientState { + device_uid, + server_addr, + config: ClientConfig::default(), + device_secret: load_device_secret(), + registration_token: std::env::var("CSM_REGISTRATION_TOKEN").unwrap_or_default(), + use_tls: std::env::var("CSM_USE_TLS").as_deref() == Ok("true"), + }; + + // TODO: Register as Windows Service on Windows + // For development, run directly + + // Main event loop + run(state).await +} + +async fn run(state: ClientState) -> Result<()> { + let (data_tx, mut data_rx) = tokio::sync::mpsc::channel::(1024); + + // Spawn Ctrl+C handler + tokio::spawn(async { + tokio::signal::ctrl_c().await.ok(); + info!("Received Ctrl+C, initiating graceful shutdown..."); + SHUTDOWN.store(true, Ordering::SeqCst); + }); + + // Create plugin config channels + let (watermark_tx, watermark_rx) = tokio::sync::watch::channel(None); + let (web_filter_tx, web_filter_rx) = tokio::sync::watch::channel(web_filter::WebFilterConfig::default()); + let (software_blocker_tx, software_blocker_rx) = tokio::sync::watch::channel(software_blocker::SoftwareBlockerConfig::default()); + let (popup_blocker_tx, popup_blocker_rx) = tokio::sync::watch::channel(popup_blocker::PopupBlockerConfig::default()); + let (usb_audit_tx, usb_audit_rx) = tokio::sync::watch::channel(usb_audit::UsbAuditConfig::default()); + let (usage_timer_tx, usage_timer_rx) = tokio::sync::watch::channel(usage_timer::UsageConfig::default()); + let (usb_policy_tx, usb_policy_rx) = tokio::sync::watch::channel(None::); + + let plugins = network::PluginChannels { + watermark_tx, + web_filter_tx, + software_blocker_tx, + popup_blocker_tx, + usb_audit_tx, + usage_timer_tx, + usb_policy_tx, + }; + + // Spawn core monitoring tasks + let monitor_tx = data_tx.clone(); + let uid = state.device_uid.clone(); + tokio::spawn(async move { + monitor::start_collecting(monitor_tx, uid).await; + }); + + let asset_tx = data_tx.clone(); + let uid = state.device_uid.clone(); + tokio::spawn(async move { + asset::start_collecting(asset_tx, uid).await; + }); + + let usb_tx = data_tx.clone(); + let uid = state.device_uid.clone(); + tokio::spawn(async move { + usb::start_monitoring(usb_tx, uid, usb_policy_rx).await; + }); + + // Spawn plugin tasks + tokio::spawn(async move { + watermark::start(watermark_rx).await; + }); + + let usage_data_tx = data_tx.clone(); + let usage_uid = state.device_uid.clone(); + tokio::spawn(async move { + usage_timer::start(usage_timer_rx, usage_data_tx, usage_uid).await; + }); + + let audit_data_tx = data_tx.clone(); + let audit_uid = state.device_uid.clone(); + tokio::spawn(async move { + usb_audit::start(usb_audit_rx, audit_data_tx, audit_uid).await; + }); + + tokio::spawn(async move { + popup_blocker::start(popup_blocker_rx).await; + }); + + let sw_data_tx = data_tx.clone(); + let sw_uid = state.device_uid.clone(); + tokio::spawn(async move { + software_blocker::start(software_blocker_rx, sw_data_tx, sw_uid).await; + }); + + tokio::spawn(async move { + web_filter::start(web_filter_rx).await; + }); + + // Connect to server with reconnect + let mut backoff = Duration::from_secs(1); + let max_backoff = Duration::from_secs(60); + + loop { + if SHUTDOWN.load(Ordering::SeqCst) { + info!("Shutting down gracefully..."); + break Ok(()); + } + + match network::connect_and_run(&state, &mut data_rx, &plugins).await { + Ok(()) => { + warn!("Disconnected from server, reconnecting..."); + // Use a short fixed delay for clean disconnects (server-initiated), + // but don't reset to zero to prevent connection storms + tokio::time::sleep(Duration::from_secs(2)).await; + } + Err(e) => { + error!("Connection error: {}, reconnecting...", e); + tokio::time::sleep(backoff).await; + backoff = (backoff * 2).min(max_backoff); + } + } + + // Drain stale frames that accumulated during disconnection + let drained = data_rx.try_recv().ok().map(|_| 1).unwrap_or(0); + if drained > 0 { + let mut count = drained; + while data_rx.try_recv().is_ok() { + count += 1; + } + warn!("Drained {} stale frames from channel", count); + } + } +} + +fn load_or_create_device_uid() -> Result { + // In production, store in Windows Credential Store or local config + // For now, use a simple file + let uid_file = "device_uid.txt"; + if std::path::Path::new(uid_file).exists() { + let uid = std::fs::read_to_string(uid_file)?; + Ok(uid.trim().to_string()) + } else { + let uid = uuid::Uuid::new_v4().to_string(); + std::fs::write(uid_file, &uid)?; + Ok(uid) + } +} + +/// Load persisted device_secret from disk (if available) +pub fn load_device_secret() -> Option { + let secret_file = "device_secret.txt"; + let secret = std::fs::read_to_string(secret_file).ok()?; + let trimmed = secret.trim().to_string(); + if trimmed.is_empty() { None } else { Some(trimmed) } +} + +/// Persist device_secret to disk +pub fn save_device_secret(secret: &str) { + if let Err(e) = std::fs::write("device_secret.txt", secret) { + warn!("Failed to persist device_secret: {}", e); + } +} diff --git a/crates/client/src/monitor/mod.rs b/crates/client/src/monitor/mod.rs new file mode 100644 index 0000000..1a1d93c --- /dev/null +++ b/crates/client/src/monitor/mod.rs @@ -0,0 +1,86 @@ +use anyhow::Result; +use csm_protocol::{Frame, MessageType, DeviceStatus, ProcessInfo}; +use std::time::Duration; +use tokio::sync::mpsc::Sender; +use tracing::{info, error, debug}; +use sysinfo::System; + +pub async fn start_collecting(tx: Sender, device_uid: String) { + let interval = Duration::from_secs(60); + + loop { + // Run blocking sysinfo collection on a dedicated thread + let uid_clone = device_uid.clone(); + let result = tokio::task::spawn_blocking(move || { + collect_system_status(&uid_clone) + }).await; + + match result { + Ok(Ok(status)) => { + if let Ok(frame) = Frame::new_json(MessageType::StatusReport, &status) { + debug!("Sending status report: cpu={:.1}%, mem={:.1}%", status.cpu_usage, status.memory_usage); + if tx.send(frame).await.is_err() { + info!("Monitor channel closed, exiting"); + break; + } + } + } + Ok(Err(e)) => { + error!("Failed to collect system status: {}", e); + } + Err(e) => { + error!("Monitor task join error: {}", e); + } + } + + tokio::time::sleep(interval).await; + } +} + +fn collect_system_status(device_uid: &str) -> Result { + let mut sys = System::new_all(); + sys.refresh_all(); + + // Brief wait for CPU usage to stabilize + std::thread::sleep(Duration::from_millis(200)); + sys.refresh_all(); + + let cpu_usage = sys.global_cpu_info().cpu_usage() as f64; + + let total_memory = sys.total_memory() / 1024 / 1024; // Convert bytes to MB (sysinfo 0.30 returns bytes) + let used_memory = sys.used_memory() / 1024 / 1024; + let memory_usage = if total_memory > 0 { + (used_memory as f64 / total_memory as f64) * 100.0 + } else { + 0.0 + }; + + // Top processes by CPU + let mut processes: Vec = sys.processes() + .iter() + .map(|(_, p)| { + ProcessInfo { + name: p.name().to_string(), + pid: p.pid().as_u32(), + cpu_usage: p.cpu_usage() as f64, + memory_mb: p.memory() / 1024 / 1024, // bytes to MB (sysinfo 0.30) + } + }) + .collect(); + + processes.sort_by(|a, b| b.cpu_usage.partial_cmp(&a.cpu_usage).unwrap_or(std::cmp::Ordering::Equal)); + processes.truncate(10); + + Ok(DeviceStatus { + device_uid: device_uid.to_string(), + cpu_usage, + memory_usage, + memory_total_mb: total_memory as u64, + disk_usage: 0.0, // TODO: implement disk usage via Windows API + disk_total_mb: 0, + network_rx_rate: 0, + network_tx_rate: 0, + running_procs: sys.processes().len() as u32, + top_processes: processes, + }) +} diff --git a/crates/client/src/network/mod.rs b/crates/client/src/network/mod.rs new file mode 100644 index 0000000..6974626 --- /dev/null +++ b/crates/client/src/network/mod.rs @@ -0,0 +1,380 @@ +use anyhow::Result; +use std::time::Duration; +use tokio::net::TcpStream; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tracing::{info, debug, warn}; +use csm_protocol::{Frame, MessageType, RegisterRequest, RegisterResponse, HeartbeatPayload, WatermarkConfigPayload, UsbPolicyPayload}; +use hmac::{Hmac, Mac}; +use sha2::Sha256; + +use crate::ClientState; + +/// Holds senders for all plugin config channels +pub struct PluginChannels { + pub watermark_tx: tokio::sync::watch::Sender>, + pub web_filter_tx: tokio::sync::watch::Sender, + pub software_blocker_tx: tokio::sync::watch::Sender, + pub popup_blocker_tx: tokio::sync::watch::Sender, + pub usb_audit_tx: tokio::sync::watch::Sender, + pub usage_timer_tx: tokio::sync::watch::Sender, + pub usb_policy_tx: tokio::sync::watch::Sender>, +} + +/// Connect to server and run the main communication loop +pub async fn connect_and_run( + state: &ClientState, + data_rx: &mut tokio::sync::mpsc::Receiver, + plugins: &PluginChannels, +) -> Result<()> { + let tcp_stream = TcpStream::connect(&state.server_addr).await?; + info!("TCP connected to {}", state.server_addr); + + if state.use_tls { + let tls_stream = wrap_tls(tcp_stream, &state.server_addr).await?; + run_comm_loop(tls_stream, state, data_rx, plugins).await + } else { + run_comm_loop(tcp_stream, state, data_rx, plugins).await + } +} + +/// Wrap a TCP stream with TLS. +/// Supports custom CA certificate via CSM_TLS_CA_CERT env var (path to PEM file). +/// Supports skipping verification via CSM_TLS_SKIP_VERIFY=true (development only). +async fn wrap_tls(stream: TcpStream, server_addr: &str) -> Result> { + let mut root_store = rustls::RootCertStore::empty(); + + // Load custom CA certificate if specified + if let Ok(ca_path) = std::env::var("CSM_TLS_CA_CERT") { + let ca_pem = std::fs::read(&ca_path) + .map_err(|e| anyhow::anyhow!("Failed to read CA cert {}: {}", ca_path, e))?; + let certs = rustls_pemfile::certs(&mut &ca_pem[..]) + .collect::, _>>() + .map_err(|e| anyhow::anyhow!("Failed to parse CA cert: {:?}", e))?; + for cert in certs { + root_store.add(cert)?; + } + info!("Loaded custom CA certificates from {}", ca_path); + } + + // Always include system roots as fallback + root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned()); + + let config = if std::env::var("CSM_TLS_SKIP_VERIFY").as_deref() == Ok("true") { + warn!("TLS certificate verification DISABLED — do not use in production!"); + rustls::ClientConfig::builder() + .dangerous() + .with_custom_certificate_verifier(std::sync::Arc::new(NoVerifier)) + .with_no_client_auth() + } else { + rustls::ClientConfig::builder() + .with_root_certificates(root_store) + .with_no_client_auth() + }; + + let connector = tokio_rustls::TlsConnector::from(std::sync::Arc::new(config)); + + // Extract hostname from server_addr (strip port) + let domain = server_addr.split(':').next().unwrap_or("localhost").to_string(); + let server_name = rustls_pki_types::ServerName::try_from(domain.clone()) + .map_err(|e| anyhow::anyhow!("Invalid TLS server name '{}': {:?}", domain, e))?; + + let tls_stream = connector.connect(server_name, stream).await?; + info!("TLS handshake completed with {}", domain); + Ok(tls_stream) +} + +/// A no-op certificate verifier for development use (CSM_TLS_SKIP_VERIFY=true). +#[derive(Debug)] +struct NoVerifier; + +impl rustls::client::danger::ServerCertVerifier for NoVerifier { + fn verify_server_cert( + &self, + _end_entity: &rustls_pki_types::CertificateDer, + _intermediates: &[rustls_pki_types::CertificateDer], + _server_name: &rustls_pki_types::ServerName, + _ocsp_response: &[u8], + _now: rustls_pki_types::UnixTime, + ) -> Result { + Ok(rustls::client::danger::ServerCertVerified::assertion()) + } + + fn verify_tls12_signature( + &self, + _message: &[u8], + _cert: &rustls_pki_types::CertificateDer, + _dss: &rustls::DigitallySignedStruct, + ) -> Result { + Ok(rustls::client::danger::HandshakeSignatureValid::assertion()) + } + + fn verify_tls13_signature( + &self, + _message: &[u8], + _cert: &rustls_pki_types::CertificateDer, + _dss: &rustls::DigitallySignedStruct, + ) -> Result { + Ok(rustls::client::danger::HandshakeSignatureValid::assertion()) + } + + fn supported_verify_schemes(&self) -> Vec { + vec![ + rustls::SignatureScheme::RSA_PKCS1_SHA256, + rustls::SignatureScheme::RSA_PKCS1_SHA384, + rustls::SignatureScheme::RSA_PKCS1_SHA512, + rustls::SignatureScheme::ECDSA_NISTP256_SHA256, + rustls::SignatureScheme::ECDSA_NISTP384_SHA384, + rustls::SignatureScheme::RSA_PSS_SHA256, + rustls::SignatureScheme::RSA_PSS_SHA384, + rustls::SignatureScheme::RSA_PSS_SHA512, + rustls::SignatureScheme::ED25519, + ] + } +} + +/// Main communication loop over any read+write stream +async fn run_comm_loop( + mut stream: S, + state: &ClientState, + data_rx: &mut tokio::sync::mpsc::Receiver, + plugins: &PluginChannels, +) -> Result<()> +where + S: AsyncReadExt + AsyncWriteExt + Unpin, +{ + // Send registration + let register = RegisterRequest { + device_uid: state.device_uid.clone(), + hostname: hostname::get() + .map(|h| h.to_string_lossy().to_string()) + .unwrap_or_else(|_| "unknown".to_string()), + registration_token: state.registration_token.clone(), + os_version: get_os_info(), + mac_address: None, + }; + + let frame = Frame::new_json(MessageType::Register, ®ister)?; + stream.write_all(&frame.encode()).await?; + info!("Registration request sent"); + + let mut buffer = vec![0u8; 65536]; + let mut read_buf = Vec::with_capacity(65536); + // Clamp heartbeat interval to sane range [5, 3600] to prevent CPU spin or effective disable + let heartbeat_secs = state.config.heartbeat_interval_secs.clamp(5, 3600); + let mut heartbeat_interval = tokio::time::interval(Duration::from_secs(heartbeat_secs)); + heartbeat_interval.tick().await; // Skip first tick + + // HMAC key — set after receiving RegisterResponse + let mut device_secret: Option = state.device_secret.clone(); + + loop { + tokio::select! { + // Read from server + result = stream.read(&mut buffer) => { + let n = result?; + if n == 0 { + return Err(anyhow::anyhow!("Server closed connection")); + } + read_buf.extend_from_slice(&buffer[..n]); + + // Guard against unbounded buffer growth from a malicious server + if read_buf.len() > 1_048_576 { + return Err(anyhow::anyhow!("Read buffer exceeded 1MB, server may be malicious")); + } + + // Process complete frames + loop { + match Frame::decode(&read_buf)? { + Some(frame) => { + let consumed = frame.encoded_size(); + read_buf.drain(..consumed); + // Capture device_secret from registration response + if frame.msg_type == MessageType::RegisterResponse { + if let Ok(resp) = frame.decode_payload::() { + device_secret = Some(resp.device_secret.clone()); + crate::save_device_secret(&resp.device_secret); + info!("Device secret received and persisted, HMAC enabled for heartbeats"); + } + } + handle_server_message(frame, plugins)?; + } + None => break, // Incomplete frame, wait for more data + } + } + } + + // Send queued data + frame = data_rx.recv() => { + let frame = frame.ok_or_else(|| anyhow::anyhow!("Channel closed"))?; + stream.write_all(&frame.encode()).await?; + } + + // Heartbeat + _ = heartbeat_interval.tick() => { + let timestamp = chrono::Utc::now().to_rfc3339(); + let hmac_value = compute_hmac(device_secret.as_deref(), &state.device_uid, ×tamp); + let heartbeat = HeartbeatPayload { + device_uid: state.device_uid.clone(), + timestamp, + hmac: hmac_value, + }; + let frame = Frame::new_json(MessageType::Heartbeat, &heartbeat)?; + stream.write_all(&frame.encode()).await?; + debug!("Heartbeat sent (hmac={})", !heartbeat.hmac.is_empty()); + } + } + } +} + +fn handle_server_message(frame: Frame, plugins: &PluginChannels) -> Result<()> { + match frame.msg_type { + MessageType::RegisterResponse => { + let resp: RegisterResponse = frame.decode_payload() + .map_err(|e| anyhow::anyhow!("Invalid registration response: {}", e))?; + info!("Registration accepted by server (server version: {})", resp.config.server_version); + } + MessageType::PolicyUpdate => { + let policy: serde_json::Value = frame.decode_payload() + .map_err(|e| anyhow::anyhow!("Invalid policy update: {}", e))?; + info!("Received policy update: {}", policy); + } + MessageType::ConfigUpdate => { + info!("Received config update"); + } + MessageType::TaskExecute => { + warn!("Task execution requested (not yet implemented)"); + } + MessageType::WatermarkConfig => { + let config: WatermarkConfigPayload = frame.decode_payload() + .map_err(|e| anyhow::anyhow!("Invalid watermark config: {}", e))?; + info!("Received watermark config: enabled={}", config.enabled); + plugins.watermark_tx.send(Some(config))?; + } + MessageType::UsbPolicyUpdate => { + let policy: UsbPolicyPayload = frame.decode_payload() + .map_err(|e| anyhow::anyhow!("Invalid USB policy: {}", e))?; + info!("Received USB policy: type={}, enabled={}", policy.policy_type, policy.enabled); + plugins.usb_policy_tx.send(Some(policy))?; + } + MessageType::WebFilterRuleUpdate => { + let payload: serde_json::Value = frame.decode_payload() + .map_err(|e| anyhow::anyhow!("Invalid web filter update: {}", e))?; + info!("Received web filter rules update"); + let rules: Vec = payload.get("rules") + .and_then(|r| serde_json::from_value(r.clone()).ok()) + .unwrap_or_default(); + let config = crate::web_filter::WebFilterConfig { enabled: true, rules }; + plugins.web_filter_tx.send(config)?; + } + MessageType::SoftwareBlacklist => { + let payload: serde_json::Value = frame.decode_payload() + .map_err(|e| anyhow::anyhow!("Invalid software blacklist: {}", e))?; + info!("Received software blacklist update"); + let blacklist: Vec = payload.get("blacklist") + .and_then(|r| serde_json::from_value(r.clone()).ok()) + .unwrap_or_default(); + let config = crate::software_blocker::SoftwareBlockerConfig { enabled: true, blacklist }; + plugins.software_blocker_tx.send(config)?; + } + MessageType::PopupRules => { + let payload: serde_json::Value = frame.decode_payload() + .map_err(|e| anyhow::anyhow!("Invalid popup rules: {}", e))?; + info!("Received popup blocker rules update"); + let rules: Vec = payload.get("rules") + .and_then(|r| serde_json::from_value(r.clone()).ok()) + .unwrap_or_default(); + let config = crate::popup_blocker::PopupBlockerConfig { enabled: true, rules }; + plugins.popup_blocker_tx.send(config)?; + } + MessageType::PluginEnable => { + let payload: csm_protocol::PluginControlPayload = frame.decode_payload() + .map_err(|e| anyhow::anyhow!("Invalid plugin enable: {}", e))?; + info!("Plugin enabled: {}", payload.plugin_name); + // Route to appropriate plugin channel based on plugin_name + handle_plugin_control(&payload, plugins, true)?; + } + MessageType::PluginDisable => { + let payload: csm_protocol::PluginControlPayload = frame.decode_payload() + .map_err(|e| anyhow::anyhow!("Invalid plugin disable: {}", e))?; + info!("Plugin disabled: {}", payload.plugin_name); + handle_plugin_control(&payload, plugins, false)?; + } + _ => { + debug!("Unhandled message type: {:?}", frame.msg_type); + } + } + Ok(()) +} + +fn handle_plugin_control( + payload: &csm_protocol::PluginControlPayload, + plugins: &PluginChannels, + enabled: bool, +) -> Result<()> { + match payload.plugin_name.as_str() { + "watermark" => { + if !enabled { + // Send disabled config to remove overlay + plugins.watermark_tx.send(None)?; + } + // When enabling, server will push the actual config next + } + "web_filter" => { + if !enabled { + // Clear hosts rules on disable + plugins.web_filter_tx.send(crate::web_filter::WebFilterConfig { enabled: false, rules: vec![] })?; + } + // When enabling, server will push rules + } + "software_blocker" => { + if !enabled { + plugins.software_blocker_tx.send(crate::software_blocker::SoftwareBlockerConfig { enabled: false, blacklist: vec![] })?; + } + } + "popup_blocker" => { + if !enabled { + plugins.popup_blocker_tx.send(crate::popup_blocker::PopupBlockerConfig { enabled: false, rules: vec![] })?; + } + } + "usb_audit" => { + if !enabled { + plugins.usb_audit_tx.send(crate::usb_audit::UsbAuditConfig { enabled: false, monitored_extensions: vec![] })?; + } + } + "usage_timer" => { + if !enabled { + plugins.usage_timer_tx.send(crate::usage_timer::UsageConfig { enabled: false, ..Default::default() })?; + } + } + _ => { + warn!("Unknown plugin: {}", payload.plugin_name); + } + } + Ok(()) +} + +/// Compute HMAC-SHA256 for heartbeat verification. +/// Format: HMAC-SHA256(device_secret, "{device_uid}\n{timestamp}") +fn compute_hmac(secret: Option<&str>, device_uid: &str, timestamp: &str) -> String { + let secret = match secret { + Some(s) if !s.is_empty() => s, + _ => return String::new(), + }; + + type HmacSha256 = Hmac; + + let message = format!("{}\n{}", device_uid, timestamp); + let mut mac = match HmacSha256::new_from_slice(secret.as_bytes()) { + Ok(m) => m, + Err(_) => return String::new(), + }; + mac.update(message.as_bytes()); + hex::encode(mac.finalize().into_bytes()) +} + +fn get_os_info() -> String { + use sysinfo::System; + let name = System::name().unwrap_or_else(|| "Unknown".to_string()); + let version = System::os_version().unwrap_or_else(|| "Unknown".to_string()); + format!("{} {}", name, version) +} diff --git a/crates/client/src/network/mod.rs.tmp.575580.1775308681874 b/crates/client/src/network/mod.rs.tmp.575580.1775308681874 new file mode 100644 index 0000000..01b7703 --- /dev/null +++ b/crates/client/src/network/mod.rs.tmp.575580.1775308681874 @@ -0,0 +1,370 @@ +use anyhow::Result; +use std::time::Duration; +use tokio::net::TcpStream; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tracing::{info, debug, warn}; +use csm_protocol::{Frame, MessageType, RegisterRequest, RegisterResponse, HeartbeatPayload, WatermarkConfigPayload}; +use hmac::{Hmac, Mac}; +use sha2::Sha256; + +use crate::ClientState; + +/// Maximum accumulated read buffer size per connection (8 MB) +const MAX_READ_BUF_SIZE: usize = 8 * 1024 * 1024; + +/// Holds senders for all plugin config channels +pub struct PluginChannels { + pub watermark_tx: tokio::sync::watch::Sender>, + pub web_filter_tx: tokio::sync::watch::Sender, + pub software_blocker_tx: tokio::sync::watch::Sender, + pub popup_blocker_tx: tokio::sync::watch::Sender, + pub usb_audit_tx: tokio::sync::watch::Sender, + pub usage_timer_tx: tokio::sync::watch::Sender, +} + +/// Connect to server and run the main communication loop +pub async fn connect_and_run( + state: &ClientState, + data_rx: &mut tokio::sync::mpsc::Receiver, + plugins: &PluginChannels, +) -> Result<()> { + let tcp_stream = TcpStream::connect(&state.server_addr).await?; + info!("TCP connected to {}", state.server_addr); + + if state.use_tls { + let tls_stream = wrap_tls(tcp_stream, &state.server_addr).await?; + run_comm_loop(tls_stream, state, data_rx, plugins).await + } else { + run_comm_loop(tcp_stream, state, data_rx, plugins).await + } +} + +/// Wrap a TCP stream with TLS. +/// Supports custom CA certificate via CSM_TLS_CA_CERT env var (path to PEM file). +/// Supports skipping verification via CSM_TLS_SKIP_VERIFY=true (development only). +async fn wrap_tls(stream: TcpStream, server_addr: &str) -> Result> { + let mut root_store = rustls::RootCertStore::empty(); + + // Load custom CA certificate if specified + if let Ok(ca_path) = std::env::var("CSM_TLS_CA_CERT") { + let ca_pem = std::fs::read(&ca_path) + .map_err(|e| anyhow::anyhow!("Failed to read CA cert {}: {}", ca_path, e))?; + let certs = rustls_pemfile::certs(&mut &ca_pem[..]) + .collect::, _>>() + .map_err(|e| anyhow::anyhow!("Failed to parse CA cert: {:?}", e))?; + for cert in certs { + root_store.add(cert)?; + } + info!("Loaded custom CA certificates from {}", ca_path); + } + + // Always include system roots as fallback + root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned()); + + let config = if std::env::var("CSM_TLS_SKIP_VERIFY").as_deref() == Ok("true") { + warn!("TLS certificate verification DISABLED — do not use in production!"); + rustls::ClientConfig::builder() + .dangerous() + .with_custom_certificate_verifier(std::sync::Arc::new(NoVerifier)) + .with_no_client_auth() + } else { + rustls::ClientConfig::builder() + .with_root_certificates(root_store) + .with_no_client_auth() + }; + + let connector = tokio_rustls::TlsConnector::from(std::sync::Arc::new(config)); + + // Extract hostname from server_addr (strip port) + let domain = server_addr.split(':').next().unwrap_or("localhost").to_string(); + let server_name = rustls_pki_types::ServerName::try_from(domain.clone()) + .map_err(|e| anyhow::anyhow!("Invalid TLS server name '{}': {:?}", domain, e))?; + + let tls_stream = connector.connect(server_name, stream).await?; + info!("TLS handshake completed with {}", domain); + Ok(tls_stream) +} + +/// A no-op certificate verifier for development use (CSM_TLS_SKIP_VERIFY=true). +#[derive(Debug)] +struct NoVerifier; + +impl rustls::client::danger::ServerCertVerifier for NoVerifier { + fn verify_server_cert( + &self, + _end_entity: &rustls_pki_types::CertificateDer, + _intermediates: &[rustls_pki_types::CertificateDer], + _server_name: &rustls_pki_types::ServerName, + _ocsp_response: &[u8], + _now: rustls_pki_types::UnixTime, + ) -> Result { + Ok(rustls::client::danger::ServerCertVerified::assertion()) + } + + fn verify_tls12_signature( + &self, + _message: &[u8], + _cert: &rustls_pki_types::CertificateDer, + _dss: &rustls::DigitallySignedStruct, + ) -> Result { + Ok(rustls::client::danger::HandshakeSignatureValid::assertion()) + } + + fn verify_tls13_signature( + &self, + _message: &[u8], + _cert: &rustls_pki_types::CertificateDer, + _dss: &rustls::DigitallySignedStruct, + ) -> Result { + Ok(rustls::client::danger::HandshakeSignatureValid::assertion()) + } + + fn supported_verify_schemes(&self) -> Vec { + vec![ + rustls::SignatureScheme::RSA_PKCS1_SHA256, + rustls::SignatureScheme::RSA_PKCS1_SHA384, + rustls::SignatureScheme::RSA_PKCS1_SHA512, + rustls::SignatureScheme::ECDSA_NISTP256_SHA256, + rustls::SignatureScheme::ECDSA_NISTP384_SHA384, + rustls::SignatureScheme::RSA_PSS_SHA256, + rustls::SignatureScheme::RSA_PSS_SHA384, + rustls::SignatureScheme::RSA_PSS_SHA512, + rustls::SignatureScheme::ED25519, + ] + } +} + +/// Main communication loop over any read+write stream +async fn run_comm_loop( + mut stream: S, + state: &ClientState, + data_rx: &mut tokio::sync::mpsc::Receiver, + plugins: &PluginChannels, +) -> Result<()> +where + S: AsyncReadExt + AsyncWriteExt + Unpin, +{ + // Send registration + let register = RegisterRequest { + device_uid: state.device_uid.clone(), + hostname: hostname::get() + .map(|h| h.to_string_lossy().to_string()) + .unwrap_or_else(|_| "unknown".to_string()), + registration_token: state.registration_token.clone(), + os_version: get_os_info(), + mac_address: None, + }; + + let frame = Frame::new_json(MessageType::Register, ®ister)?; + stream.write_all(&frame.encode()).await?; + info!("Registration request sent"); + + let mut buffer = vec![0u8; 65536]; + let mut read_buf = Vec::with_capacity(65536); + let heartbeat_secs = state.config.heartbeat_interval_secs; + let mut heartbeat_interval = tokio::time::interval(Duration::from_secs(heartbeat_secs)); + heartbeat_interval.tick().await; // Skip first tick + + // HMAC key — set after receiving RegisterResponse + let mut device_secret: Option = state.device_secret.clone(); + + loop { + tokio::select! { + // Read from server + result = stream.read(&mut buffer) => { + let n = result?; + if n == 0 { + return Err(anyhow::anyhow!("Server closed connection")); + } + read_buf.extend_from_slice(&buffer[..n]); + + // Process complete frames + loop { + match Frame::decode(&read_buf)? { + Some(frame) => { + let consumed = frame.encoded_size(); + read_buf.drain(..consumed); + // Capture device_secret from registration response + if frame.msg_type == MessageType::RegisterResponse { + if let Ok(resp) = frame.decode_payload::() { + device_secret = Some(resp.device_secret.clone()); + crate::save_device_secret(&resp.device_secret); + info!("Device secret received and persisted, HMAC enabled for heartbeats"); + } + } + handle_server_message(frame, plugins)?; + } + None => break, // Incomplete frame, wait for more data + } + } + } + + // Send queued data + frame = data_rx.recv() => { + let frame = frame.ok_or_else(|| anyhow::anyhow!("Channel closed"))?; + stream.write_all(&frame.encode()).await?; + } + + // Heartbeat + _ = heartbeat_interval.tick() => { + let timestamp = chrono::Utc::now().to_rfc3339(); + let hmac_value = compute_hmac(device_secret.as_deref(), &state.device_uid, ×tamp); + let heartbeat = HeartbeatPayload { + device_uid: state.device_uid.clone(), + timestamp, + hmac: hmac_value, + }; + let frame = Frame::new_json(MessageType::Heartbeat, &heartbeat)?; + stream.write_all(&frame.encode()).await?; + debug!("Heartbeat sent (hmac={})", !heartbeat.hmac.is_empty()); + } + } + } +} + +fn handle_server_message(frame: Frame, plugins: &PluginChannels) -> Result<()> { + match frame.msg_type { + MessageType::RegisterResponse => { + let resp: RegisterResponse = frame.decode_payload() + .map_err(|e| anyhow::anyhow!("Invalid registration response: {}", e))?; + info!("Registration accepted by server (server version: {})", resp.config.server_version); + } + MessageType::PolicyUpdate => { + let policy: serde_json::Value = frame.decode_payload() + .map_err(|e| anyhow::anyhow!("Invalid policy update: {}", e))?; + info!("Received policy update: {}", policy); + } + MessageType::ConfigUpdate => { + info!("Received config update"); + } + MessageType::TaskExecute => { + warn!("Task execution requested (not yet implemented)"); + } + MessageType::WatermarkConfig => { + let config: WatermarkConfigPayload = frame.decode_payload() + .map_err(|e| anyhow::anyhow!("Invalid watermark config: {}", e))?; + info!("Received watermark config: enabled={}", config.enabled); + plugins.watermark_tx.send(Some(config))?; + } + MessageType::WebFilterRuleUpdate => { + let payload: serde_json::Value = frame.decode_payload() + .map_err(|e| anyhow::anyhow!("Invalid web filter update: {}", e))?; + info!("Received web filter rules update"); + let rules: Vec = payload.get("rules") + .and_then(|r| serde_json::from_value(r.clone()).ok()) + .unwrap_or_default(); + let config = crate::web_filter::WebFilterConfig { enabled: true, rules }; + plugins.web_filter_tx.send(config)?; + } + MessageType::SoftwareBlacklist => { + let payload: serde_json::Value = frame.decode_payload() + .map_err(|e| anyhow::anyhow!("Invalid software blacklist: {}", e))?; + info!("Received software blacklist update"); + let blacklist: Vec = payload.get("blacklist") + .and_then(|r| serde_json::from_value(r.clone()).ok()) + .unwrap_or_default(); + let config = crate::software_blocker::SoftwareBlockerConfig { enabled: true, blacklist }; + plugins.software_blocker_tx.send(config)?; + } + MessageType::PopupRules => { + let payload: serde_json::Value = frame.decode_payload() + .map_err(|e| anyhow::anyhow!("Invalid popup rules: {}", e))?; + info!("Received popup blocker rules update"); + let rules: Vec = payload.get("rules") + .and_then(|r| serde_json::from_value(r.clone()).ok()) + .unwrap_or_default(); + let config = crate::popup_blocker::PopupBlockerConfig { enabled: true, rules }; + plugins.popup_blocker_tx.send(config)?; + } + MessageType::PluginEnable => { + let payload: csm_protocol::PluginControlPayload = frame.decode_payload() + .map_err(|e| anyhow::anyhow!("Invalid plugin enable: {}", e))?; + info!("Plugin enabled: {}", payload.plugin_name); + // Route to appropriate plugin channel based on plugin_name + handle_plugin_control(&payload, plugins, true)?; + } + MessageType::PluginDisable => { + let payload: csm_protocol::PluginControlPayload = frame.decode_payload() + .map_err(|e| anyhow::anyhow!("Invalid plugin disable: {}", e))?; + info!("Plugin disabled: {}", payload.plugin_name); + handle_plugin_control(&payload, plugins, false)?; + } + _ => { + debug!("Unhandled message type: {:?}", frame.msg_type); + } + } + Ok(()) +} + +fn handle_plugin_control( + payload: &csm_protocol::PluginControlPayload, + plugins: &PluginChannels, + enabled: bool, +) -> Result<()> { + match payload.plugin_name.as_str() { + "watermark" => { + if !enabled { + // Send disabled config to remove overlay + plugins.watermark_tx.send(None)?; + } + // When enabling, server will push the actual config next + } + "web_filter" => { + if !enabled { + // Clear hosts rules on disable + plugins.web_filter_tx.send(crate::web_filter::WebFilterConfig { enabled: false, rules: vec![] })?; + } + // When enabling, server will push rules + } + "software_blocker" => { + if !enabled { + plugins.software_blocker_tx.send(crate::software_blocker::SoftwareBlockerConfig { enabled: false, blacklist: vec![] })?; + } + } + "popup_blocker" => { + if !enabled { + plugins.popup_blocker_tx.send(crate::popup_blocker::PopupBlockerConfig { enabled: false, rules: vec![] })?; + } + } + "usb_audit" => { + if !enabled { + plugins.usb_audit_tx.send(crate::usb_audit::UsbAuditConfig { enabled: false, monitored_extensions: vec![] })?; + } + } + "usage_timer" => { + if !enabled { + plugins.usage_timer_tx.send(crate::usage_timer::UsageConfig { enabled: false, ..Default::default() })?; + } + } + _ => { + warn!("Unknown plugin: {}", payload.plugin_name); + } + } + Ok(()) +} + +/// Compute HMAC-SHA256 for heartbeat verification. +/// Format: HMAC-SHA256(device_secret, "{device_uid}\n{timestamp}") +fn compute_hmac(secret: Option<&str>, device_uid: &str, timestamp: &str) -> String { + let secret = match secret { + Some(s) if !s.is_empty() => s, + _ => return String::new(), + }; + + type HmacSha256 = Hmac; + + let message = format!("{}\n{}", device_uid, timestamp); + let mut mac = match HmacSha256::new_from_slice(secret.as_bytes()) { + Ok(m) => m, + Err(_) => return String::new(), + }; + mac.update(message.as_bytes()); + hex::encode(mac.finalize().into_bytes()) +} + +fn get_os_info() -> String { + use sysinfo::System; + let name = System::name().unwrap_or_else(|| "Unknown".to_string()); + let version = System::os_version().unwrap_or_else(|| "Unknown".to_string()); + format!("{} {}", name, version) +} diff --git a/crates/client/src/popup_blocker/mod.rs b/crates/client/src/popup_blocker/mod.rs new file mode 100644 index 0000000..d9eefe5 --- /dev/null +++ b/crates/client/src/popup_blocker/mod.rs @@ -0,0 +1,254 @@ +use tokio::sync::watch; +use tracing::{info, debug}; +use serde::Deserialize; + +/// Popup blocker rule from server +#[derive(Debug, Clone, Deserialize)] +pub struct PopupRule { + pub id: i64, + pub rule_type: String, + pub window_title: Option, + pub window_class: Option, + pub process_name: Option, +} + +/// Popup blocker configuration +#[derive(Debug, Clone, Default)] +pub struct PopupBlockerConfig { + pub enabled: bool, + pub rules: Vec, +} + +/// Context passed to EnumWindows callback via LPARAM +struct ScanContext { + rules: Vec, + blocked_count: u32, +} + +/// Start popup blocker plugin. +/// Periodically enumerates windows and closes those matching rules. +pub async fn start(mut config_rx: watch::Receiver) { + info!("Popup blocker plugin started"); + let mut config = PopupBlockerConfig::default(); + let mut scan_interval = tokio::time::interval(std::time::Duration::from_secs(2)); + scan_interval.tick().await; + + loop { + tokio::select! { + result = config_rx.changed() => { + if result.is_err() { + break; + } + let new_config = config_rx.borrow_and_update().clone(); + info!("Popup blocker config updated: enabled={}, rules={}", new_config.enabled, new_config.rules.len()); + config = new_config; + } + _ = scan_interval.tick() => { + if !config.enabled || config.rules.is_empty() { + continue; + } + scan_and_block(&config.rules); + } + } + } +} + +fn scan_and_block(rules: &[PopupRule]) { + #[cfg(target_os = "windows")] + { + use windows::Win32::UI::WindowsAndMessaging::EnumWindows; + use windows::Win32::Foundation::LPARAM; + + let mut ctx = ScanContext { + rules: rules.to_vec(), + blocked_count: 0, + }; + + unsafe { + let _ = EnumWindows( + Some(enum_windows_callback), + LPARAM(&mut ctx as *mut ScanContext as isize), + ); + } + if ctx.blocked_count > 0 { + debug!("Popup scan blocked {} windows", ctx.blocked_count); + } + } + #[cfg(not(target_os = "windows"))] + { + let _ = rules; + } +} + +#[cfg(target_os = "windows")] +unsafe extern "system" fn enum_windows_callback( + hwnd: windows::Win32::Foundation::HWND, + lparam: windows::Win32::Foundation::LPARAM, +) -> windows::Win32::Foundation::BOOL { + use windows::Win32::UI::WindowsAndMessaging::*; + use windows::Win32::Foundation::*; + + // Only consider visible, top-level windows without an owner + if !IsWindowVisible(hwnd).as_bool() { + return BOOL(1); + } + + // Skip windows that have an owner (they're child dialogs, not popups) + if GetWindow(hwnd, GW_OWNER).0 != 0 { + return BOOL(1); + } + + // Get window title + let mut title_buf = [0u16; 512]; + let title_len = GetWindowTextW(hwnd, &mut title_buf); + let title_len = title_len.max(0) as usize; + let title = String::from_utf16_lossy(&title_buf[..title_len]); + + // Skip windows with empty titles + if title.is_empty() { + return BOOL(1); + } + + // Get class name + let mut class_buf = [0u16; 256]; + let class_len = GetClassNameW(hwnd, &mut class_buf); + let class_len = class_len.max(0) as usize; + let class_name = String::from_utf16_lossy(&class_buf[..class_len]); + + // Get process name from PID + let mut pid: u32 = 0; + GetWindowThreadProcessId(hwnd, Some(&mut pid)); + let process_name = get_process_name(pid); + + // Recover the ScanContext from LPARAM + let ctx = &mut *(lparam.0 as *mut ScanContext); + + // Check against each rule + for rule in &ctx.rules { + if rule.rule_type != "block" { + continue; + } + + let matches = rule_matches(rule, &title, &class_name, &process_name); + if matches { + let _ = PostMessageW(hwnd, WM_CLOSE, WPARAM(0), LPARAM(0)); + ctx.blocked_count += 1; + info!( + "Blocked popup: title='{}' class='{}' process='{}' (rule_id={})", + title, class_name, process_name, rule.id + ); + break; // One match is enough per window + } + } + + BOOL(1) // Continue enumeration +} + +fn rule_matches(rule: &PopupRule, title: &str, class_name: &str, process_name: &str) -> bool { + let title_match = match &rule.window_title { + Some(pattern) => pattern_match(pattern, title), + None => true, // No title filter = match all + }; + + let class_match = match &rule.window_class { + Some(pattern) => pattern_match(pattern, class_name), + None => true, + }; + + let process_match = match &rule.process_name { + Some(pattern) => pattern_match(pattern, process_name), + None => true, + }; + + title_match && class_match && process_match +} + +/// Simple case-insensitive wildcard pattern matching. +/// Supports `*` as wildcard (matches any characters). +fn pattern_match(pattern: &str, text: &str) -> bool { + let p = pattern.to_lowercase(); + let t = text.to_lowercase(); + + if !p.contains('*') { + return t.contains(&p); + } + + let parts: Vec<&str> = p.split('*').collect(); + if parts.is_empty() { + return true; + } + + let mut pos = 0usize; + let mut matched_any = false; + + for (i, part) in parts.iter().enumerate() { + if part.is_empty() { + // Leading empty = pattern starts with * → no start anchor + // Trailing empty = pattern ends with * → no end anchor + continue; + } + + matched_any = true; + + if i == 0 && !parts[0].is_empty() { + // Pattern starts with literal → must match at start + if !t.starts_with(part) { + return false; + } + pos = part.len(); + } else { + // Find this segment anywhere after current position + match t[pos..].find(part) { + Some(idx) => pos += idx + part.len(), + None => return false, + } + } + } + + // If pattern ends with literal (no trailing *), must match at end + if matched_any && !parts.last().map_or(true, |p| p.is_empty()) { + return t.ends_with(parts.last().unwrap()); + } + true +} + +#[cfg(target_os = "windows")] +fn get_process_name(pid: u32) -> String { + use windows::Win32::System::Diagnostics::ToolHelp::*; + use windows::Win32::Foundation::CloseHandle; + + unsafe { + let snapshot = match CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) { + Ok(s) => s, + Err(_) => return format!("pid:{}", pid), + }; + + let mut entry = PROCESSENTRY32W { + dwSize: std::mem::size_of::() as u32, + ..Default::default() + }; + + if Process32FirstW(snapshot, &mut entry).is_ok() { + loop { + if entry.th32ProcessID == pid { + let name = String::from_utf16_lossy( + &entry.szExeFile.iter().take_while(|&&c| c != 0).copied().collect::>() + ); + let _ = CloseHandle(snapshot); + return name; + } + entry.dwSize = std::mem::size_of::() as u32; + if Process32NextW(snapshot, &mut entry).is_err() { + break; + } + } + } + let _ = CloseHandle(snapshot); + format!("pid:{}", pid) + } +} + +#[cfg(not(target_os = "windows"))] +fn get_process_name(pid: u32) -> String { + format!("pid:{}", pid) +} diff --git a/crates/client/src/software_blocker/mod.rs b/crates/client/src/software_blocker/mod.rs new file mode 100644 index 0000000..f8c6ffd --- /dev/null +++ b/crates/client/src/software_blocker/mod.rs @@ -0,0 +1,254 @@ +use tokio::sync::watch; +use tracing::{info, warn}; +use csm_protocol::{Frame, MessageType, SoftwareViolationReport}; +use serde::Deserialize; + +/// System-critical processes that must never be killed regardless of server rules. +/// Killing any of these would cause system instability or a BSOD. +const PROTECTED_PROCESSES: &[&str] = &[ + "system", + "system idle process", + "svchost.exe", + "lsass.exe", + "csrss.exe", + "wininit.exe", + "winlogon.exe", + "services.exe", + "dwm.exe", + "explorer.exe", + "taskhostw.exe", + "registry", + "smss.exe", + "conhost.exe", +]; + +/// Software blacklist entry from server +#[derive(Debug, Clone, Deserialize)] +pub struct BlacklistEntry { + pub id: i64, + pub name_pattern: String, + pub action: String, +} + +/// Software blocker configuration +#[derive(Debug, Clone, Default)] +pub struct SoftwareBlockerConfig { + pub enabled: bool, + pub blacklist: Vec, +} + +/// Start software blocker plugin. +/// Periodically scans running processes against the blacklist. +pub async fn start( + mut config_rx: watch::Receiver, + data_tx: tokio::sync::mpsc::Sender, + device_uid: String, +) { + info!("Software blocker plugin started"); + let mut config = SoftwareBlockerConfig::default(); + let mut scan_interval = tokio::time::interval(std::time::Duration::from_secs(10)); + scan_interval.tick().await; + + loop { + tokio::select! { + result = config_rx.changed() => { + if result.is_err() { + break; + } + let new_config = config_rx.borrow_and_update().clone(); + info!("Software blocker config updated: enabled={}, blacklist={}", new_config.enabled, new_config.blacklist.len()); + config = new_config; + } + _ = scan_interval.tick() => { + if !config.enabled || config.blacklist.is_empty() { + continue; + } + scan_processes(&config.blacklist, &data_tx, &device_uid).await; + } + } + } +} + +async fn scan_processes( + blacklist: &[BlacklistEntry], + data_tx: &tokio::sync::mpsc::Sender, + device_uid: &str, +) { + let running = get_running_processes_with_pids(); + + for entry in blacklist { + for (process_name, pid) in &running { + if pattern_matches(&entry.name_pattern, process_name) { + // Never kill system-critical processes + if is_protected_process(process_name) { + warn!("Blacklisted match '{}' skipped — system-critical process (pid={})", process_name, pid); + continue; + } + + warn!("Blacklisted software detected: {} (action: {})", process_name, entry.action); + + // Report violation to server + // Map action to DB-compatible values: "block" -> "blocked_install", "alert" -> "alerted" + let action_taken = match entry.action.as_str() { + "block" => "blocked_install", + "alert" => "alerted", + other => other, + }; + let violation = SoftwareViolationReport { + device_uid: device_uid.to_string(), + software_name: process_name.clone(), + action_taken: action_taken.to_string(), + timestamp: chrono::Utc::now().to_rfc3339(), + }; + if let Ok(frame) = Frame::new_json(MessageType::SoftwareViolation, &violation) { + let _ = data_tx.send(frame).await; + } + + // Kill the process directly by captured PID (avoids TOCTOU race) + if entry.action == "block" { + kill_process_by_pid(*pid, process_name); + } + } + } + } +} + +fn pattern_matches(pattern: &str, name: &str) -> bool { + let pattern_lower = pattern.to_lowercase(); + let name_lower = name.to_lowercase(); + // Support wildcard patterns + if pattern_lower.contains('*') { + let parts: Vec<&str> = pattern_lower.split('*').collect(); + let mut pos = 0; + + for (i, part) in parts.iter().enumerate() { + if part.is_empty() { + continue; + } + if i == 0 && !parts[0].is_empty() { + // Pattern starts with literal → must match at start + if !name_lower.starts_with(part) { + return false; + } + pos = part.len(); + } else { + match name_lower[pos..].find(part) { + Some(idx) => pos += idx + part.len(), + None => return false, + } + } + } + // If pattern ends with literal (no trailing *), must match at end + if !parts.last().map_or(true, |p| p.is_empty()) { + return name_lower.ends_with(parts.last().unwrap()); + } + true + } else { + name_lower.contains(&pattern_lower) + } +} + +/// Get all running processes with their PIDs (single snapshot, no TOCTOU) +fn get_running_processes_with_pids() -> Vec<(String, u32)> { + #[cfg(target_os = "windows")] + { + use windows::Win32::System::Diagnostics::ToolHelp::*; + use windows::Win32::Foundation::CloseHandle; + + let mut procs = Vec::new(); + unsafe { + let snapshot = match CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) { + Ok(s) => s, + Err(_) => return procs, + }; + + let mut entry = PROCESSENTRY32W { + dwSize: std::mem::size_of::() as u32, + ..Default::default() + }; + + if Process32FirstW(snapshot, &mut entry).is_ok() { + loop { + let name = String::from_utf16_lossy( + &entry.szExeFile.iter().take_while(|&&c| c != 0).copied().collect::>() + ); + procs.push((name, entry.th32ProcessID)); + entry.dwSize = std::mem::size_of::() as u32; + if !Process32NextW(snapshot, &mut entry).is_ok() { + break; + } + } + } + let _ = CloseHandle(snapshot); + } + procs + } + #[cfg(not(target_os = "windows"))] + { + Vec::new() + } +} + +fn kill_process_by_pid(pid: u32, expected_name: &str) { + #[cfg(target_os = "windows")] + { + use windows::Win32::System::Threading::{OpenProcess, TerminateProcess, PROCESS_TERMINATE}; + use windows::Win32::Foundation::CloseHandle; + use windows::Win32::System::Diagnostics::ToolHelp::*; + + unsafe { + // Verify the PID still belongs to the expected process (prevent PID reuse kills) + let snapshot = match CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) { + Ok(s) => s, + Err(_) => return, + }; + let mut entry = PROCESSENTRY32W { + dwSize: std::mem::size_of::() as u32, + ..Default::default() + }; + let mut name_matches = false; + if Process32FirstW(snapshot, &mut entry).is_ok() { + loop { + if entry.th32ProcessID == pid { + let current_name = String::from_utf16_lossy( + &entry.szExeFile.iter().take_while(|&&c| c != 0).copied().collect::>() + ); + name_matches = current_name.to_lowercase() == expected_name.to_lowercase(); + break; + } + entry.dwSize = std::mem::size_of::() as u32; + if !Process32NextW(snapshot, &mut entry).is_ok() { + break; + } + } + } + let _ = CloseHandle(snapshot); + + if !name_matches { + warn!("Skipping kill: PID {} no longer matches expected process '{}'", pid, expected_name); + return; + } + + if let Ok(handle) = OpenProcess(PROCESS_TERMINATE, false, pid) { + let terminated = TerminateProcess(handle, 1).is_ok(); + let _ = CloseHandle(handle); + if terminated { + warn!("Killed process: {} (pid={})", expected_name, pid); + } else { + warn!("TerminateProcess failed for: {} (pid={})", expected_name, pid); + } + } else { + warn!("OpenProcess failed for: {} (pid={}) — insufficient privileges?", expected_name, pid); + } + } + } + #[cfg(not(target_os = "windows"))] + { + let _ = (pid, expected_name); + } +} + +fn is_protected_process(name: &str) -> bool { + let lower = name.to_lowercase(); + PROTECTED_PROCESSES.iter().any(|p| lower == **p || lower.ends_with(&format!("/{}", p).replace('/', "\\"))) +} diff --git a/crates/client/src/usage_timer/mod.rs b/crates/client/src/usage_timer/mod.rs new file mode 100644 index 0000000..06f494c --- /dev/null +++ b/crates/client/src/usage_timer/mod.rs @@ -0,0 +1,197 @@ +use std::time::{Duration, Instant}; +use tokio::sync::watch; +use tracing::{info, debug}; +use csm_protocol::{Frame, MessageType, UsageDailyReport, AppUsageEntry}; + +/// Usage tracking configuration pushed from server +#[derive(Debug, Clone, Default)] +pub struct UsageConfig { + pub enabled: bool, + pub idle_threshold_secs: u64, + pub report_interval_secs: u64, +} + +/// Start the usage timer plugin. +/// Tracks active/idle time and foreground application usage. +pub async fn start( + mut config_rx: watch::Receiver, + data_tx: tokio::sync::mpsc::Sender, + device_uid: String, +) { + info!("Usage timer plugin started"); + + let mut config = UsageConfig { + enabled: false, + idle_threshold_secs: 300, // 5 minutes default + report_interval_secs: 300, + }; + + let mut report_interval = tokio::time::interval(Duration::from_secs(60)); + report_interval.tick().await; + + let mut active_secs: u64 = 0; + let mut idle_secs: u64 = 0; + let mut app_usage: std::collections::HashMap = std::collections::HashMap::new(); + let mut last_tick = Instant::now(); + + loop { + tokio::select! { + result = config_rx.changed() => { + if result.is_err() { + break; + } + let new_config = config_rx.borrow_and_update().clone(); + if new_config.enabled != config.enabled { + info!("Usage timer enabled: {}", new_config.enabled); + } + config = new_config; + if config.enabled { + report_interval = tokio::time::interval(Duration::from_secs(config.report_interval_secs)); + report_interval.tick().await; + last_tick = Instant::now(); + } + } + _ = report_interval.tick() => { + if !config.enabled { + continue; + } + + // Measure actual elapsed time since last tick + let now = Instant::now(); + let elapsed_secs = now.duration_since(last_tick).as_secs(); + last_tick = now; + + let idle_ms = get_idle_millis(); + let is_idle = idle_ms as u64 > config.idle_threshold_secs * 1000; + + if is_idle { + idle_secs += elapsed_secs; + } else { + active_secs += elapsed_secs; + } + + // Track foreground app + if let Some(app) = get_foreground_app_name() { + *app_usage.entry(app).or_insert(0) += elapsed_secs; + } + + // Report usage to server + if active_secs > 0 || idle_secs > 0 { + let report = UsageDailyReport { + device_uid: device_uid.clone(), + date: chrono::Local::now().format("%Y-%m-%d").to_string(), + total_active_minutes: (active_secs / 60) as u32, + total_idle_minutes: (idle_secs / 60) as u32, + first_active_at: None, + last_active_at: Some(chrono::Utc::now().to_rfc3339()), + }; + if let Ok(frame) = Frame::new_json(MessageType::UsageReport, &report) { + if data_tx.send(frame).await.is_err() { + break; + } + } + + // Report per-app usage + for (app, secs) in &app_usage { + let entry = AppUsageEntry { + device_uid: device_uid.clone(), + date: chrono::Local::now().format("%Y-%m-%d").to_string(), + app_name: app.clone(), + usage_minutes: (secs / 60) as u32, + }; + if let Ok(frame) = Frame::new_json(MessageType::AppUsageReport, &entry) { + let _ = data_tx.send(frame).await; + } + } + + // Reset counters after reporting + active_secs = 0; + idle_secs = 0; + app_usage.clear(); + } + + debug!("Usage report sent (idle_ms={}, elapsed={}s)", idle_ms, elapsed_secs); + } + } + } +} + +/// Get system idle time in milliseconds using GetLastInputInfo + GetTickCount64. +/// Both use the same time base. LASTINPUTINFO.dwTime is u32 (GetTickCount legacy), +/// so we take the low 32 bits of GetTickCount64 for correct wrapping comparison. +fn get_idle_millis() -> u32 { + #[cfg(target_os = "windows")] + { + use windows::Win32::UI::Input::KeyboardAndMouse::{GetLastInputInfo, LASTINPUTINFO}; + + unsafe { + let mut lii = LASTINPUTINFO { + cbSize: std::mem::size_of::() as u32, + dwTime: 0, + }; + if GetLastInputInfo(&mut lii).as_bool() { + // GetTickCount64 returns u64, but dwTime is u32. + // Take low 32 bits so wrapping_sub produces correct idle delta. + let tick_low32 = (windows::Win32::System::SystemInformation::GetTickCount64() & 0xFFFFFFFF) as u32; + return tick_low32.wrapping_sub(lii.dwTime); + } + } + 0 + } + #[cfg(not(target_os = "windows"))] + { + 0 + } +} + +/// Get the foreground window's process name using CreateToolhelp32Snapshot +fn get_foreground_app_name() -> Option { + #[cfg(target_os = "windows")] + { + use windows::Win32::UI::WindowsAndMessaging::{GetForegroundWindow, GetWindowThreadProcessId}; + use windows::Win32::System::Diagnostics::ToolHelp::*; + use windows::Win32::Foundation::CloseHandle; + + unsafe { + let hwnd = GetForegroundWindow(); + if hwnd.0 == 0 { + return None; + } + + let mut pid: u32 = 0; + GetWindowThreadProcessId(hwnd, Some(&mut pid)); + if pid == 0 { + return None; + } + + // Get process name via CreateToolhelp32Snapshot + let snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0).ok()?; + let mut entry = PROCESSENTRY32W { + dwSize: std::mem::size_of::() as u32, + ..Default::default() + }; + + if Process32FirstW(snapshot, &mut entry).is_ok() { + loop { + if entry.th32ProcessID == pid { + let name = String::from_utf16_lossy( + &entry.szExeFile.iter().take_while(|&&c| c != 0).copied().collect::>() + ); + let _ = CloseHandle(snapshot); + return Some(name); + } + entry.dwSize = std::mem::size_of::() as u32; + if !Process32NextW(snapshot, &mut entry).is_ok() { + break; + } + } + } + let _ = CloseHandle(snapshot); + None + } + } + #[cfg(not(target_os = "windows"))] + { + None + } +} diff --git a/crates/client/src/usb/mod.rs b/crates/client/src/usb/mod.rs new file mode 100644 index 0000000..f21a202 --- /dev/null +++ b/crates/client/src/usb/mod.rs @@ -0,0 +1,249 @@ +use csm_protocol::{Frame, MessageType, UsbEvent, UsbEventType, UsbPolicyPayload, UsbDeviceRule}; +use std::time::Duration; +use tokio::sync::{mpsc::Sender, watch}; +use tracing::{info, warn}; + +/// Start USB monitoring with policy enforcement. +/// Monitors for removable drive insertions/removals and enforces USB policies. +pub async fn start_monitoring( + tx: Sender, + device_uid: String, + mut policy_rx: watch::Receiver>, +) { + info!("USB monitoring started for {}", device_uid); + + let mut current_policy: Option = None; + let mut known_drives: Vec = Vec::new(); + let interval = Duration::from_secs(10); + + loop { + // Check for policy updates (non-blocking) + if policy_rx.has_changed().unwrap_or(false) { + let new_policy = policy_rx.borrow_and_update().clone(); + let policy_desc = new_policy.as_ref() + .map(|p| format!("type={}, enabled={}", p.policy_type, p.enabled)) + .unwrap_or_else(|| "None".to_string()); + info!("USB policy updated: {}", policy_desc); + current_policy = new_policy; + + // Re-check all currently mounted drives against new policy + let drives = detect_removable_drives(); + for drive in &drives { + if should_block_drive(drive, ¤t_policy) { + warn!("USB device {} blocked by policy, ejecting...", drive); + if let Err(e) = eject_drive(drive) { + warn!("Failed to eject {}: {}", drive, e); + } else { + info!("Successfully ejected {}", drive); + } + // Report blocked event + send_usb_event(&tx, &device_uid, UsbEventType::Blocked, drive).await; + } + } + } + + let current_drives = detect_removable_drives(); + + // Detect new drives (inserted) + for drive in ¤t_drives { + if !known_drives.iter().any(|d: &String| d == drive) { + info!("USB device inserted: {}", drive); + + // Check against policy before reporting + if should_block_drive(drive, ¤t_policy) { + warn!("USB device {} blocked by policy, ejecting...", drive); + if let Err(e) = eject_drive(drive) { + warn!("Failed to eject {}: {}", drive, e); + } else { + info!("Successfully ejected {}", drive); + } + send_usb_event(&tx, &device_uid, UsbEventType::Blocked, drive).await; + continue; + } + + send_usb_event(&tx, &device_uid, UsbEventType::Inserted, drive).await; + } + } + + // Detect removed drives + for drive in &known_drives { + if !current_drives.iter().any(|d| d == drive) { + info!("USB device removed: {}", drive); + send_usb_event(&tx, &device_uid, UsbEventType::Removed, drive).await; + } + } + + known_drives = current_drives; + tokio::time::sleep(interval).await; + } +} + +async fn send_usb_event( + tx: &Sender, + device_uid: &str, + event_type: UsbEventType, + drive: &str, +) { + let event = UsbEvent { + device_uid: device_uid.to_string(), + event_type, + vendor_id: None, + product_id: None, + serial: None, + device_name: Some(drive.to_string()), + }; + + if let Ok(frame) = Frame::new_json(MessageType::UsbEvent, &event) { + tx.send(frame).await.ok(); + } +} + +/// Check if a drive should be blocked based on the current policy. +fn should_block_drive(drive: &str, policy: &Option) -> bool { + let policy = match policy { + Some(p) if p.enabled => p, + _ => return false, + }; + + match policy.policy_type.as_str() { + "all_block" => true, + "blacklist" => { + // Block if any rule matches + policy.rules.iter().any(|rule| device_matches_rule(drive, rule)) + } + "whitelist" => { + // Block if NOT in whitelist (empty whitelist = block all) + if policy.rules.is_empty() { + return true; + } + !policy.rules.iter().any(|rule| device_matches_rule(drive, rule)) + } + _ => false, + } +} + +/// Check if a device matches a rule pattern. +/// Currently matches by device_name (drive letter path) since that's what we detect. +fn device_matches_rule(drive: &str, rule: &UsbDeviceRule) -> bool { + // Match by device name (drive root path like "E:\") + if let Some(ref name) = rule.device_name { + if drive.eq_ignore_ascii_case(name) || drive.eq_ignore_ascii_case(&name.replace('\\', "")) { + return true; + } + } + // vendor_id, product_id, serial matching would require WMI or SetupDi queries + // For now, these are placeholder checks + let _ = (drive, rule); + false +} + +/// DRIVE_REMOVABLE = 2 in Windows API +const DRIVE_REMOVABLE: u32 = 2; + +fn detect_removable_drives() -> Vec { + let mut drives = Vec::new(); + + #[cfg(target_os = "windows")] + { + for letter in b'A'..=b'Z' { + let root = format!("{}:\\", letter as char); + let root_wide: Vec = root.encode_utf16().chain(std::iter::once(0)).collect(); + unsafe { + let drive_type = windows::Win32::Storage::FileSystem::GetDriveTypeW( + windows::core::PCWSTR(root_wide.as_ptr()), + ); + if drive_type == DRIVE_REMOVABLE { + drives.push(root); + } + } + } + } + + #[cfg(not(target_os = "windows"))] + { + let _ = &drives; // Suppress unused warning on non-Windows + } + + drives +} + +/// Eject a removable drive using Windows API. +/// Opens the volume handle, dismounts the filesystem, and ejects the media. +#[cfg(target_os = "windows")] +fn eject_drive(drive: &str) -> Result<(), anyhow::Error> { + use windows::Win32::Storage::FileSystem::*; + use windows::Win32::System::IO::DeviceIoControl; + use windows::Win32::Foundation::*; + use windows::core::PCWSTR; + + // IOCTL control codes (not exposed directly in windows crate) + const FSCTL_DISMOUNT_VOLUME: u32 = 0x00090020; + const IOCTL_STORAGE_EJECT_MEDIA: u32 = 0x002D4808; + + // Extract drive letter from path like "E:\" + let letter = drive.chars().next().unwrap_or('A'); + let path = format!("\\\\.\\{}:\0", letter); + let path_wide: Vec = path.encode_utf16().collect(); + + unsafe { + let handle = CreateFileW( + PCWSTR(path_wide.as_ptr()), + GENERIC_READ.0, + FILE_SHARE_READ | FILE_SHARE_WRITE, + None, + OPEN_EXISTING, + FILE_ATTRIBUTE_NORMAL, + None, + )?; + + if handle.is_invalid() { + return Err(anyhow::anyhow!("Failed to open volume handle for {}", drive)); + } + + // Dismount the filesystem + let mut bytes_returned = 0u32; + let dismount_ok = DeviceIoControl( + handle, + FSCTL_DISMOUNT_VOLUME, + None, + 0, + None, + 0, + Some(&mut bytes_returned), + None, + ).is_ok(); + + if !dismount_ok { + warn!("FSCTL_DISMOUNT_VOLUME failed for {}", drive); + } + + // Eject the media + let eject_ok = DeviceIoControl( + handle, + IOCTL_STORAGE_EJECT_MEDIA, + None, + 0, + None, + 0, + Some(&mut bytes_returned), + None, + ).is_ok(); + + if !eject_ok { + warn!("IOCTL_STORAGE_EJECT_MEDIA failed for {}", drive); + } + + let _ = CloseHandle(handle); + + if !dismount_ok && !eject_ok { + Err(anyhow::anyhow!("Failed to eject {}", drive)) + } else { + Ok(()) + } + } +} + +#[cfg(not(target_os = "windows"))] +fn eject_drive(_drive: &str) -> Result<(), anyhow::Error> { + Ok(()) +} diff --git a/crates/client/src/usb_audit/mod.rs b/crates/client/src/usb_audit/mod.rs new file mode 100644 index 0000000..2d9003a --- /dev/null +++ b/crates/client/src/usb_audit/mod.rs @@ -0,0 +1,190 @@ +use tokio::sync::watch; +use tracing::{info, debug, warn}; +use csm_protocol::{Frame, MessageType, UsbFileOpEntry}; +use std::collections::{HashMap, HashSet}; + +/// USB file audit configuration +#[derive(Debug, Clone, Default)] +pub struct UsbAuditConfig { + pub enabled: bool, + pub monitored_extensions: Vec, +} + +/// Start USB file audit plugin. +/// Detects removable drives and monitors file changes via periodic scanning. +pub async fn start( + mut config_rx: watch::Receiver, + data_tx: tokio::sync::mpsc::Sender, + device_uid: String, +) { + info!("USB file audit plugin started"); + let mut config = UsbAuditConfig::default(); + let mut active_drives: HashSet = HashSet::new(); + // Track file listings per drive to detect changes + let mut drive_snapshots: HashMap> = HashMap::new(); + + let mut interval = tokio::time::interval(std::time::Duration::from_secs(5)); + interval.tick().await; + + loop { + tokio::select! { + result = config_rx.changed() => { + if result.is_err() { + break; + } + let new_config = config_rx.borrow_and_update().clone(); + info!("USB audit config updated: enabled={}", new_config.enabled); + config = new_config; + } + _ = interval.tick() => { + if !config.enabled { + continue; + } + + let drives = detect_removable_drives(); + + // Detect new drives + for drive in &drives { + if !active_drives.contains(drive) { + info!("New removable drive detected: {}", drive); + // Take initial snapshot in blocking thread to avoid freezing + let drive_clone = drive.clone(); + let files = tokio::task::spawn_blocking(move || scan_drive_files(&drive_clone)).await; + match files { + Ok(files) => { drive_snapshots.insert(drive.clone(), files); } + Err(e) => { warn!("Failed to scan drive {}: {}", drive, e); } + } + } + } + + // Scan existing drives for changes (each in a blocking thread) + for drive in &drives { + let drive_clone = drive.clone(); + let current_files = match tokio::task::spawn_blocking(move || scan_drive_files(&drive_clone)).await { + Ok(files) => files, + Err(e) => { warn!("Drive scan failed for {}: {}", drive, e); continue; } + }; + if let Some(prev_files) = drive_snapshots.get_mut(drive) { + // Find new files (created) + for file in ¤t_files { + if !prev_files.contains(file) { + report_file_op(&data_tx, &device_uid, drive, file, "create", &config.monitored_extensions).await; + } + } + // Find deleted files + for file in prev_files.iter() { + if !current_files.contains(file) { + report_file_op(&data_tx, &device_uid, drive, file, "delete", &config.monitored_extensions).await; + } + } + *prev_files = current_files; + } else { + drive_snapshots.insert(drive.clone(), current_files); + } + } + + // Clean up removed drives + active_drives.retain(|d| drives.contains(d)); + drive_snapshots.retain(|d, _| drives.contains(d)); + } + } + } +} + +async fn report_file_op( + data_tx: &tokio::sync::mpsc::Sender, + device_uid: &str, + drive: &str, + file_path: &str, + operation: &str, + ext_filter: &[String], +) { + // Check extension filter + let should_report = if ext_filter.is_empty() { + true + } else { + let file_ext = std::path::Path::new(file_path) + .extension() + .and_then(|e| e.to_str()) + .map(|e| e.to_lowercase()); + file_ext.map_or(true, |ext| { + ext_filter.iter().any(|f| f.to_lowercase() == ext) + }) + }; + + if should_report { + let entry = UsbFileOpEntry { + device_uid: device_uid.to_string(), + usb_serial: None, + drive_letter: Some(drive.to_string()), + operation: operation.to_string(), + file_path: file_path.to_string(), + file_size: None, + timestamp: chrono::Utc::now().to_rfc3339(), + }; + + if let Ok(frame) = Frame::new_json(MessageType::UsbFileOp, &entry) { + let _ = data_tx.send(frame).await; + } + debug!("USB file op: {} {}", operation, file_path); + } +} + +/// Recursively scan a drive and return all file paths +fn scan_drive_files(drive: &str) -> HashSet { + let mut files = HashSet::new(); + let max_depth = 3; // Limit recursion depth for performance + scan_dir_recursive(drive, &mut files, 0, max_depth); + files +} + +fn scan_dir_recursive(dir: &str, files: &mut HashSet, depth: usize, max_depth: usize) { + if depth >= max_depth { + return; + } + if let Ok(entries) = std::fs::read_dir(dir) { + for entry in entries.flatten() { + let path = entry.path(); + let path_str = path.to_string_lossy().to_string(); + if path.is_dir() { + scan_dir_recursive(&path_str, files, depth + 1, max_depth); + } else { + files.insert(path_str); + } + } + } +} + +fn detect_removable_drives() -> Vec { + #[cfg(target_os = "windows")] + { + use windows::Win32::Storage::FileSystem::GetDriveTypeW; + use windows::core::PCWSTR; + + let mut drives = Vec::new(); + let mask = unsafe { windows::Win32::Storage::FileSystem::GetLogicalDrives() }; + let mut mask = mask as u32; + let mut letter = b'A'; + + while mask != 0 { + if mask & 1 != 0 { + let drive_letter = format!("{}:\\", letter as char); + let drive_wide: Vec = drive_letter.encode_utf16().chain(std::iter::once(0)).collect(); + let drive_type = unsafe { + GetDriveTypeW(PCWSTR(drive_wide.as_ptr())) + }; + // DRIVE_REMOVABLE = 2 + if drive_type == 2 { + drives.push(drive_letter); + } + } + mask >>= 1; + letter += 1; + } + drives + } + #[cfg(not(target_os = "windows"))] + { + Vec::new() + } +} diff --git a/crates/client/src/watermark/mod.rs b/crates/client/src/watermark/mod.rs new file mode 100644 index 0000000..0bc6779 --- /dev/null +++ b/crates/client/src/watermark/mod.rs @@ -0,0 +1,313 @@ +use std::sync::{Arc, Mutex}; +use tokio::sync::watch; +use tracing::{info, warn, error}; +use csm_protocol::WatermarkConfigPayload; + +/// Watermark overlay state +struct WatermarkState { + enabled: bool, + content: String, + font_size: u32, + opacity: f64, + color: String, + angle: i32, + hwnd: Option, +} + +impl Default for WatermarkState { + fn default() -> Self { + Self { + enabled: false, + content: String::new(), + font_size: 14, + opacity: 0.15, + color: "#808080".to_string(), + angle: -30, + hwnd: None, + } + } +} + +static WATERMARK_STATE: std::sync::OnceLock>> = std::sync::OnceLock::new(); + +const WM_USER_UPDATE: u32 = 0x0401; +const OVERLAY_CLASS_NAME: &str = "CSM_WatermarkOverlay"; + +/// Start the watermark plugin, listening for config updates. +pub async fn start(mut rx: watch::Receiver>) { + info!("Watermark plugin started"); + + let state = WATERMARK_STATE.get_or_init(|| Arc::new(Mutex::new(WatermarkState::default()))); + let state_clone = state.clone(); + + // Spawn a dedicated thread for the Win32 message loop + std::thread::spawn(move || { + #[cfg(target_os = "windows")] + run_overlay_message_loop(state_clone); + #[cfg(not(target_os = "windows"))] + { + let _ = state_clone; + info!("Watermark overlay not supported on this platform"); + } + }); + + loop { + tokio::select! { + result = rx.changed() => { + if result.is_err() { + warn!("Watermark config channel closed"); + break; + } + let config = rx.borrow_and_update().clone(); + if let Some(cfg) = config { + info!("Watermark config updated: enabled={}, content='{}'", cfg.enabled, cfg.content); + + { + let mut s = state.lock().unwrap_or_else(|e| e.into_inner()); + s.enabled = cfg.enabled; + s.content = cfg.content.clone(); + s.font_size = cfg.font_size; + s.opacity = cfg.opacity; + s.color = cfg.color.clone(); + s.angle = cfg.angle; + } + + // Post message to overlay window thread + #[cfg(target_os = "windows")] + post_overlay_update(); + } + } + } + } +} + +#[cfg(target_os = "windows")] +fn post_overlay_update() { + use windows::Win32::UI::WindowsAndMessaging::{FindWindowA, PostMessageW}; + use windows::Win32::Foundation::{WPARAM, LPARAM}; + use windows::core::PCSTR; + + unsafe { + let class_name = format!("{}\0", OVERLAY_CLASS_NAME); + let hwnd = FindWindowA(PCSTR(class_name.as_ptr()), PCSTR::null()); + if hwnd.0 != 0 { + let _ = PostMessageW(hwnd, WM_USER_UPDATE, WPARAM(0), LPARAM(0)); + } + } +} + +#[cfg(target_os = "windows")] +fn run_overlay_message_loop(state: Arc>) { + use windows::Win32::UI::WindowsAndMessaging::*; + use windows::Win32::Graphics::Gdi::*; + use windows::Win32::Foundation::*; + use windows::Win32::System::LibraryLoader::GetModuleHandleA; + use windows::core::PCSTR; + + // Register window class + let class_name = format!("{}\0", OVERLAY_CLASS_NAME); + let instance = unsafe { GetModuleHandleA(PCSTR::null()) }.unwrap_or_default(); + let instance: HINSTANCE = instance.into(); + + let wc = WNDCLASSA { + style: CS_HREDRAW | CS_VREDRAW, + lpfnWndProc: Some(watermark_wnd_proc), + hInstance: instance, + lpszClassName: PCSTR(class_name.as_ptr()), + hbrBackground: unsafe { CreateSolidBrush(COLORREF(0x00000000)) }, + ..Default::default() + }; + + unsafe { RegisterClassA(&wc); } + + let screen_w = unsafe { GetSystemMetrics(SM_CXSCREEN) }; + let screen_h = unsafe { GetSystemMetrics(SM_CYSCREEN) }; + + // WS_EX_LAYERED | WS_EX_TRANSPARENT | WS_EX_TOPMOST | WS_EX_TOOLWINDOW + let ex_style = WS_EX_LAYERED | WS_EX_TRANSPARENT | WS_EX_TOPMOST | WS_EX_TOOLWINDOW; + let style = WS_POPUP; + + let hwnd = unsafe { + CreateWindowExA( + ex_style, + PCSTR(class_name.as_ptr()), + PCSTR("CSM Watermark\0".as_ptr()), + style, + 0, 0, screen_w, screen_h, + HWND::default(), + HMENU::default(), + instance, + None, + ) + }; + + if hwnd.0 == 0 { + error!("Failed to create watermark overlay window"); + return; + } + + { + let mut s = state.lock().unwrap_or_else(|e| e.into_inner()); + s.hwnd = Some(hwnd.0); + } + + info!("Watermark overlay window created (hwnd={})", hwnd.0); + + // Post initial update to self — ensures overlay shows even if config + // arrived before window creation completed (race between threads) + unsafe { + let _ = PostMessageW(hwnd, WM_USER_UPDATE, WPARAM(0), LPARAM(0)); + } + + // Message loop + let mut msg = MSG::default(); + unsafe { + loop { + match GetMessageA(&mut msg, HWND::default(), 0, 0) { + BOOL(0) | BOOL(-1) => break, + _ => { + TranslateMessage(&msg); + DispatchMessageA(&msg); + } + } + } + } +} + +#[cfg(target_os = "windows")] +unsafe extern "system" fn watermark_wnd_proc( + hwnd: windows::Win32::Foundation::HWND, + msg: u32, + wparam: windows::Win32::Foundation::WPARAM, + lparam: windows::Win32::Foundation::LPARAM, +) -> windows::Win32::Foundation::LRESULT { + use windows::Win32::UI::WindowsAndMessaging::*; + use windows::Win32::Graphics::Gdi::*; + use windows::Win32::Foundation::*; + + match msg { + WM_PAINT => { + if let Some(state) = WATERMARK_STATE.get() { + let s = state.lock().unwrap_or_else(|e| e.into_inner()); + if s.enabled && !s.content.is_empty() { + paint_watermark(hwnd, &s); + } + } + LRESULT(0) + } + WM_ERASEBKGND => { + // Fill with black (will be color-keyed to transparent) + let hdc = HDC(wparam.0 as isize); + unsafe { + let rect = { + let mut r = RECT::default(); + let _ = GetClientRect(hwnd, &mut r); + r + }; + let brush = CreateSolidBrush(COLORREF(0x00000000)); + let _ = FillRect(hdc, &rect, brush); + let _ = DeleteObject(brush); + } + LRESULT(1) + } + m if m == WM_USER_UPDATE => { + if let Some(state) = WATERMARK_STATE.get() { + let s = state.lock().unwrap_or_else(|e| e.into_inner()); + if s.enabled { + let _ = SetWindowPos( + hwnd, HWND_TOPMOST, + 0, 0, + GetSystemMetrics(SM_CXSCREEN), + GetSystemMetrics(SM_CYSCREEN), + SWP_SHOWWINDOW, + ); + let alpha = (s.opacity * 255.0).clamp(0.0, 255.0) as u8; + // Color key black background to transparent, apply alpha to text + let _ = SetLayeredWindowAttributes(hwnd, COLORREF(0), alpha, LWA_COLORKEY | LWA_ALPHA); + let _ = InvalidateRect(hwnd, None, true); + } else { + let _ = ShowWindow(hwnd, SW_HIDE); + } + } + LRESULT(0) + } + WM_DESTROY => { + PostQuitMessage(0); + LRESULT(0) + } + _ => DefWindowProcA(hwnd, msg, wparam, lparam), + } +} + +#[cfg(target_os = "windows")] +fn paint_watermark(hwnd: windows::Win32::Foundation::HWND, state: &WatermarkState) { + use windows::Win32::Graphics::Gdi::*; + use windows::Win32::UI::WindowsAndMessaging::*; + use windows::core::PCSTR; + + unsafe { + let mut ps = PAINTSTRUCT::default(); + let hdc = BeginPaint(hwnd, &mut ps); + + let color = parse_color(&state.color); + let font_size = state.font_size.max(1); + + // Create font with rotation + let font = CreateFontA( + (font_size as i32) * 2, + 0, + (state.angle as i32) * 10, + 0, + FW_NORMAL.0 as i32, + 0, 0, 0, + DEFAULT_CHARSET.0 as u32, + OUT_DEFAULT_PRECIS.0 as u32, + CLIP_DEFAULT_PRECIS.0 as u32, + DEFAULT_QUALITY.0 as u32, + DEFAULT_PITCH.0 as u32 | FF_DONTCARE.0 as u32, + PCSTR("Arial\0".as_ptr()), + ); + + let old_font = SelectObject(hdc, font); + + let _ = SetBkMode(hdc, TRANSPARENT); + let _ = SetTextColor(hdc, color); + + // Draw tiled watermark text + let screen_w = GetSystemMetrics(SM_CXSCREEN); + let screen_h = GetSystemMetrics(SM_CYSCREEN); + + let content_bytes: Vec = state.content.bytes().chain(std::iter::once(0)).collect(); + let text_slice = &content_bytes[..content_bytes.len().saturating_sub(1)]; + + let spacing_x = 400i32; + let spacing_y = 200i32; + + let mut y = -100i32; + while y < screen_h + 100 { + let mut x = -200i32; + while x < screen_w + 200 { + let _ = TextOutA(hdc, x, y, text_slice); + x += spacing_x; + } + y += spacing_y; + } + + SelectObject(hdc, old_font); + let _ = DeleteObject(font); + EndPaint(hwnd, &ps); + } +} + +fn parse_color(hex: &str) -> windows::Win32::Foundation::COLORREF { + use windows::Win32::Foundation::COLORREF; + let hex = hex.trim_start_matches('#'); + if hex.len() == 6 { + let r = u8::from_str_radix(&hex[0..2], 16).unwrap_or(128); + let g = u8::from_str_radix(&hex[2..4], 16).unwrap_or(128); + let b = u8::from_str_radix(&hex[4..6], 16).unwrap_or(128); + COLORREF((b as u32) << 16 | (g as u32) << 8 | (r as u32)) + } else { + COLORREF(0x00808080) + } +} diff --git a/crates/client/src/web_filter/mod.rs b/crates/client/src/web_filter/mod.rs new file mode 100644 index 0000000..1a8ae35 --- /dev/null +++ b/crates/client/src/web_filter/mod.rs @@ -0,0 +1,169 @@ +use tokio::sync::watch; +use tracing::{info, warn, debug}; +use serde::Deserialize; +use std::io; + +/// Web filter rule from server +#[derive(Debug, Clone, Deserialize)] +pub struct WebFilterRule { + pub id: i64, + pub rule_type: String, + pub pattern: String, +} + +/// Web filter configuration +#[derive(Debug, Clone, Default)] +pub struct WebFilterConfig { + pub enabled: bool, + pub rules: Vec, +} + +/// Start web filter plugin. +/// Manages the hosts file to block/allow URLs based on server rules. +pub async fn start( + mut config_rx: watch::Receiver, +) { + info!("Web filter plugin started"); + let mut _config = WebFilterConfig::default(); + + loop { + tokio::select! { + result = config_rx.changed() => { + if result.is_err() { + break; + } + let new_config = config_rx.borrow_and_update().clone(); + info!("Web filter config updated: enabled={}, rules={}", new_config.enabled, new_config.rules.len()); + + if new_config.enabled { + match apply_hosts_rules(&new_config.rules) { + Ok(()) => info!("Web filter hosts rules applied ({} rules)", new_config.rules.len()), + Err(e) => warn!("Failed to apply hosts rules: {}", e), + } + } else { + match clear_hosts_rules() { + Ok(()) => info!("Web filter hosts rules cleared"), + Err(e) => warn!("Failed to clear hosts rules: {}", e), + } + } + _config = new_config; + } + } + } +} + +const HOSTS_MARKER_START: &str = "# CSM_WEB_FILTER_START"; +const HOSTS_MARKER_END: &str = "# CSM_WEB_FILTER_END"; + +fn apply_hosts_rules(rules: &[WebFilterRule]) -> io::Result<()> { + let hosts_path = get_hosts_path(); + let original = std::fs::read_to_string(&hosts_path)?; + + // Remove existing CSM block + let clean = remove_csm_block(&original); + + // Build new block with block rules + let block_rules: Vec<&WebFilterRule> = rules.iter() + .filter(|r| r.rule_type == "block") + .filter(|r| is_valid_hosts_entry(&r.pattern)) + .collect(); + + if block_rules.is_empty() { + atomic_write(&hosts_path, &clean)?; + return Ok(()); + } + + let mut new_block = format!("{}\n", HOSTS_MARKER_START); + for rule in &block_rules { + // Redirect blocked domains to 127.0.0.1 + new_block.push_str(&format!("127.0.0.1 {}\n", rule.pattern)); + } + new_block.push_str(HOSTS_MARKER_END); + new_block.push('\n'); + + let new_content = format!("{}{}", clean, new_block); + atomic_write(&hosts_path, &new_content)?; + + debug!("Applied {} web filter rules to hosts file", block_rules.len()); + Ok(()) +} + +fn clear_hosts_rules() -> io::Result<()> { + let hosts_path = get_hosts_path(); + let original = std::fs::read_to_string(&hosts_path)?; + let clean = remove_csm_block(&original); + atomic_write(&hosts_path, &clean)?; + Ok(()) +} + +/// Write content to a file atomically. +/// On Windows, hosts file may be locked by the DNS cache service, +/// so we use direct overwrite with truncation instead of rename. +fn atomic_write(path: &str, content: &str) -> io::Result<()> { + use std::io::Write; + let mut file = std::fs::OpenOptions::new() + .write(true) + .truncate(true) + .create(true) + .open(path)?; + file.write_all(content.as_bytes())?; + file.sync_data()?; + Ok(()) +} + +fn remove_csm_block(content: &str) -> String { + // Handle paired markers (normal case) + let start_idx = content.find(HOSTS_MARKER_START); + let end_idx = content.find(HOSTS_MARKER_END); + + match (start_idx, end_idx) { + (Some(s), Some(e)) if e > s => { + let mut result = String::new(); + result.push_str(&content[..s]); + result.push_str(&content[e + HOSTS_MARKER_END.len()..]); + return result.trim_end().to_string() + "\n"; + } + _ => {} + } + + // Handle orphan markers: remove any lone START or END marker line + let lines: Vec<&str> = content.lines().collect(); + let cleaned: Vec<&str> = lines.iter() + .filter(|line| { + let trimmed = line.trim(); + trimmed != HOSTS_MARKER_START && trimmed != HOSTS_MARKER_END + }) + .copied() + .collect(); + + let mut result = cleaned.join("\n"); + if !result.ends_with('\n') { + result.push('\n'); + } + result +} + +fn get_hosts_path() -> String { + #[cfg(target_os = "windows")] + { + r"C:\Windows\System32\drivers\etc\hosts".to_string() + } + #[cfg(not(target_os = "windows"))] + { + "/etc/hosts".to_string() + } +} + +/// Validate that a pattern is safe to write to the hosts file. +/// Rejects patterns with whitespace, control chars, or comment markers. +fn is_valid_hosts_entry(pattern: &str) -> bool { + if pattern.is_empty() { + return false; + } + // Reject if contains whitespace, control chars, or comment markers + if pattern.chars().any(|c| c.is_whitespace() || c.is_control() || c == '#') { + return false; + } + // Must look like a hostname (alphanumeric, dots, hyphens, underscores, asterisks) + pattern.chars().all(|c| c.is_alphanumeric() || c == '.' || c == '-' || c == '_' || c == '*') +} diff --git a/crates/protocol/Cargo.toml b/crates/protocol/Cargo.toml new file mode 100644 index 0000000..71e9d99 --- /dev/null +++ b/crates/protocol/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "csm-protocol" +version.workspace = true +edition.workspace = true + +[dependencies] +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +uuid = { workspace = true } +chrono = { workspace = true } +anyhow = { workspace = true } diff --git a/crates/protocol/src/device.rs b/crates/protocol/src/device.rs new file mode 100644 index 0000000..56b4ec5 --- /dev/null +++ b/crates/protocol/src/device.rs @@ -0,0 +1,108 @@ +use serde::{Deserialize, Serialize}; + +/// Real-time device status report +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct DeviceStatus { + pub device_uid: String, + pub cpu_usage: f64, + pub memory_usage: f64, + pub memory_total_mb: u64, + pub disk_usage: f64, + pub disk_total_mb: u64, + pub network_rx_rate: u64, + pub network_tx_rate: u64, + pub running_procs: u32, + pub top_processes: Vec, +} + +/// Top process information +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct ProcessInfo { + pub name: String, + pub pid: u32, + pub cpu_usage: f64, + pub memory_mb: u64, +} + +/// Hardware asset information +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct HardwareAsset { + pub device_uid: String, + pub cpu_model: String, + pub cpu_cores: u32, + pub memory_total_mb: u64, + pub disk_model: String, + pub disk_total_mb: u64, + pub gpu_model: Option, + pub motherboard: Option, + pub serial_number: Option, +} + +/// Software asset information +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct SoftwareAsset { + pub device_uid: String, + pub name: String, + pub version: Option, + pub publisher: Option, + pub install_date: Option, + pub install_path: Option, +} + +/// USB device event +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct UsbEvent { + pub device_uid: String, + pub event_type: UsbEventType, + pub vendor_id: Option, + pub product_id: Option, + pub serial: Option, + pub device_name: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +#[serde(rename_all = "lowercase")] +pub enum UsbEventType { + Inserted, + Removed, + Blocked, +} + +/// Asset change event +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct AssetChange { + pub device_uid: String, + pub change_type: AssetChangeType, + pub change_detail: serde_json::Value, +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum AssetChangeType { + Hardware, + SoftwareAdded, + SoftwareRemoved, +} + +/// USB policy (Server → Client) +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct UsbPolicy { + pub policy_id: i64, + pub policy_type: UsbPolicyType, + pub allowed_devices: Vec, +} + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum UsbPolicyType { + AllBlock, + Whitelist, + Blacklist, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct UsbDevicePattern { + pub vendor_id: Option, + pub product_id: Option, + pub serial: Option, +} diff --git a/crates/protocol/src/lib.rs b/crates/protocol/src/lib.rs new file mode 100644 index 0000000..7ff89dd --- /dev/null +++ b/crates/protocol/src/lib.rs @@ -0,0 +1,27 @@ +pub mod message; +pub mod device; + +// Re-export constants from message module +pub use message::{MAGIC, PROTOCOL_VERSION, FRAME_HEADER_SIZE, MAX_PAYLOAD_SIZE}; + +// Core frame & message types +pub use message::{ + Frame, FrameError, MessageType, + RegisterRequest, RegisterResponse, ClientConfig, + HeartbeatPayload, TaskExecutePayload, ConfigUpdateType, +}; + +// Device status & asset types +pub use device::{ + DeviceStatus, ProcessInfo, HardwareAsset, SoftwareAsset, + UsbEvent, UsbEventType, AssetChange, AssetChangeType, + UsbPolicy, UsbPolicyType, UsbDevicePattern, +}; + +// Plugin message payloads +pub use message::{ + WebAccessLogEntry, UsageDailyReport, AppUsageEntry, + SoftwareViolationReport, UsbFileOpEntry, + WatermarkConfigPayload, PluginControlPayload, + UsbPolicyPayload, UsbDeviceRule, +}; diff --git a/crates/protocol/src/message.rs b/crates/protocol/src/message.rs new file mode 100644 index 0000000..2988a4a --- /dev/null +++ b/crates/protocol/src/message.rs @@ -0,0 +1,417 @@ +use serde::{Deserialize, Serialize}; + +/// Protocol magic bytes: "CSM\0" +pub const MAGIC: [u8; 4] = [0x43, 0x53, 0x4D, 0x00]; + +/// Current protocol version +pub const PROTOCOL_VERSION: u8 = 0x01; + +/// Frame header size: magic(4) + version(1) + type(1) + length(4) +pub const FRAME_HEADER_SIZE: usize = 10; + +/// Maximum payload size: 4 MB — prevents memory exhaustion from malicious frames +pub const MAX_PAYLOAD_SIZE: usize = 4 * 1024 * 1024; + +/// Binary message types for client-server communication +#[repr(u8)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum MessageType { + // Client → Server (Core) + Heartbeat = 0x01, + Register = 0x02, + StatusReport = 0x03, + AssetReport = 0x04, + AssetChange = 0x05, + UsbEvent = 0x06, + AlertAck = 0x07, + + // Server → Client (Core) + RegisterResponse = 0x08, + PolicyUpdate = 0x10, + ConfigUpdate = 0x11, + TaskExecute = 0x12, + + // Plugin: Web Filter (上网拦截) + WebFilterRuleUpdate = 0x20, + WebAccessLog = 0x21, + + // Plugin: Usage Timer (时长记录) + UsageReport = 0x30, + AppUsageReport = 0x31, + + // Plugin: Software Blocker (软件禁止安装) + SoftwareBlacklist = 0x40, + SoftwareViolation = 0x41, + + // Plugin: Popup Blocker (弹窗拦截) + PopupRules = 0x50, + + // Plugin: USB File Audit (U盘文件操作记录) + UsbFileOp = 0x60, + + // Plugin: Screen Watermark (水印管理) + WatermarkConfig = 0x70, + + // Plugin: USB Policy (U盘管控策略) + UsbPolicyUpdate = 0x71, + + // Plugin control + PluginEnable = 0x80, + PluginDisable = 0x81, +} + +impl TryFrom for MessageType { + type Error = String; + + fn try_from(value: u8) -> Result { + match value { + 0x01 => Ok(Self::Heartbeat), + 0x02 => Ok(Self::Register), + 0x03 => Ok(Self::StatusReport), + 0x04 => Ok(Self::AssetReport), + 0x05 => Ok(Self::AssetChange), + 0x06 => Ok(Self::UsbEvent), + 0x07 => Ok(Self::AlertAck), + 0x08 => Ok(Self::RegisterResponse), + 0x10 => Ok(Self::PolicyUpdate), + 0x11 => Ok(Self::ConfigUpdate), + 0x12 => Ok(Self::TaskExecute), + 0x20 => Ok(Self::WebFilterRuleUpdate), + 0x21 => Ok(Self::WebAccessLog), + 0x30 => Ok(Self::UsageReport), + 0x31 => Ok(Self::AppUsageReport), + 0x40 => Ok(Self::SoftwareBlacklist), + 0x41 => Ok(Self::SoftwareViolation), + 0x50 => Ok(Self::PopupRules), + 0x60 => Ok(Self::UsbFileOp), + 0x70 => Ok(Self::WatermarkConfig), + 0x71 => Ok(Self::UsbPolicyUpdate), + 0x80 => Ok(Self::PluginEnable), + 0x81 => Ok(Self::PluginDisable), + _ => Err(format!("Unknown message type: 0x{:02X}", value)), + } + } +} + +/// A wire-format frame for transmission over TCP +#[derive(Debug, Clone)] +pub struct Frame { + pub version: u8, + pub msg_type: MessageType, + pub payload: Vec, +} + +impl Frame { + /// Create a new frame with the current protocol version + pub fn new(msg_type: MessageType, payload: Vec) -> Self { + Self { + version: PROTOCOL_VERSION, + msg_type, + payload, + } + } + + /// Create a new frame with JSON-serialized payload + pub fn new_json(msg_type: MessageType, data: &T) -> anyhow::Result { + let payload = serde_json::to_vec(data)?; + Ok(Self::new(msg_type, payload)) + } + + /// Encode frame to bytes for transmission + /// Format: MAGIC(4) + VERSION(1) + TYPE(1) + LENGTH(4) + PAYLOAD(var) + pub fn encode(&self) -> Vec { + let mut buf = Vec::with_capacity(FRAME_HEADER_SIZE + self.payload.len()); + buf.extend_from_slice(&MAGIC); + buf.push(self.version); + buf.push(self.msg_type as u8); + buf.extend_from_slice(&(self.payload.len() as u32).to_be_bytes()); + buf.extend_from_slice(&self.payload); + buf + } + + /// Decode frame from bytes. Returns Ok(Some(frame)) when a complete frame is available. + pub fn decode(data: &[u8]) -> Result, FrameError> { + if data.len() < FRAME_HEADER_SIZE { + return Ok(None); + } + + if data[0..4] != MAGIC { + return Err(FrameError::InvalidMagic); + } + + let version = data[4]; + let msg_type_byte = data[5]; + let payload_len = u32::from_be_bytes([data[6], data[7], data[8], data[9]]) as usize; + + if payload_len > MAX_PAYLOAD_SIZE { + return Err(FrameError::PayloadTooLarge(payload_len)); + } + + if data.len() < FRAME_HEADER_SIZE + payload_len { + return Ok(None); + } + + let msg_type = MessageType::try_from(msg_type_byte) + .map_err(|e| FrameError::UnknownMessageType(msg_type_byte, e))?; + + let payload = data[FRAME_HEADER_SIZE..FRAME_HEADER_SIZE + payload_len].to_vec(); + + Ok(Some(Frame { + version, + msg_type, + payload, + })) + } + + /// Deserialize the payload as JSON + pub fn decode_payload Deserialize<'de>>(&self) -> Result { + serde_json::from_slice(&self.payload) + } + + /// Total encoded size of this frame + pub fn encoded_size(&self) -> usize { + FRAME_HEADER_SIZE + self.payload.len() + } +} + +#[derive(Debug, thiserror::Error)] +pub enum FrameError { + #[error("Invalid magic bytes in frame header")] + InvalidMagic, + #[error("Unknown message type: 0x{0:02X} - {1}")] + UnknownMessageType(u8, String), + #[error("Payload too large: {0} bytes (max {})", MAX_PAYLOAD_SIZE)] + PayloadTooLarge(usize), + #[error("IO error: {0}")] + Io(#[from] std::io::Error), +} + +// ==================== Core Message Payloads ==================== + +/// Registration request payload +#[derive(Debug, Serialize, Deserialize)] +pub struct RegisterRequest { + pub device_uid: String, + pub hostname: String, + pub registration_token: String, + pub os_version: String, + pub mac_address: Option, +} + +/// Registration response payload +#[derive(Debug, Serialize, Deserialize)] +pub struct RegisterResponse { + pub device_secret: String, + pub config: ClientConfig, +} + +/// Server-pushed client configuration +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct ClientConfig { + pub heartbeat_interval_secs: u64, + pub status_report_interval_secs: u64, + pub asset_report_interval_secs: u64, + pub server_version: String, +} + +impl Default for ClientConfig { + fn default() -> Self { + Self { + heartbeat_interval_secs: 30, + status_report_interval_secs: 60, + asset_report_interval_secs: 86400, + server_version: env!("CARGO_PKG_VERSION").to_string(), + } + } +} + +/// Heartbeat payload (minimal) +#[derive(Debug, Serialize, Deserialize)] +pub struct HeartbeatPayload { + pub device_uid: String, + pub timestamp: String, + pub hmac: String, +} + +/// Task execution request (Server → Client) +#[derive(Debug, Serialize, Deserialize)] +pub struct TaskExecutePayload { + pub task_type: String, + pub params: serde_json::Value, +} + +/// Config update types (Server → Client) +#[derive(Debug, Serialize, Deserialize)] +pub enum ConfigUpdateType { + UpdateIntervals { heartbeat: u64, status: u64, asset: u64 }, + TlsCertRotate, + SelfDestruct, +} + +// ==================== Plugin Message Payloads ==================== + +/// Plugin: Web Access Log entry (Client → Server) +#[derive(Debug, Serialize, Deserialize)] +pub struct WebAccessLogEntry { + pub device_uid: String, + pub url: String, + pub action: String, // "allowed" | "blocked" + pub timestamp: String, +} + +/// Plugin: Daily Usage Report (Client → Server) +#[derive(Debug, Serialize, Deserialize)] +pub struct UsageDailyReport { + pub device_uid: String, + pub date: String, + pub total_active_minutes: u32, + pub total_idle_minutes: u32, + pub first_active_at: Option, + pub last_active_at: Option, +} + +/// Plugin: App Usage Report (Client → Server) +#[derive(Debug, Serialize, Deserialize)] +pub struct AppUsageEntry { + pub device_uid: String, + pub date: String, + pub app_name: String, + pub usage_minutes: u32, +} + +/// Plugin: Software Violation (Client → Server) +#[derive(Debug, Serialize, Deserialize)] +pub struct SoftwareViolationReport { + pub device_uid: String, + pub software_name: String, + pub action_taken: String, // "blocked_install" | "auto_uninstalled" | "alerted" + pub timestamp: String, +} + +/// Plugin: USB File Operation (Client → Server) +#[derive(Debug, Serialize, Deserialize)] +pub struct UsbFileOpEntry { + pub device_uid: String, + pub usb_serial: Option, + pub drive_letter: Option, + pub operation: String, // "create" | "delete" | "rename" | "modify" + pub file_path: String, + pub file_size: Option, + pub timestamp: String, +} + +/// Plugin: Watermark Config (Server → Client) +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct WatermarkConfigPayload { + pub content: String, + pub font_size: u32, + pub opacity: f64, + pub color: String, + pub angle: i32, + pub enabled: bool, +} + +/// Plugin enable/disable command (Server → Client) +#[derive(Debug, Serialize, Deserialize)] +pub struct PluginControlPayload { + pub plugin_name: String, + pub enabled: bool, +} + +/// Plugin: USB Policy Config (Server → Client) +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct UsbPolicyPayload { + pub policy_type: String, // "all_block" | "whitelist" | "blacklist" + pub enabled: bool, + pub rules: Vec, +} + +/// A single USB device rule for whitelist/blacklist matching +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct UsbDeviceRule { + pub vendor_id: Option, + pub product_id: Option, + pub serial: Option, + pub device_name: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_frame_encode_decode_roundtrip() { + let original = Frame::new(MessageType::Heartbeat, b"test payload".to_vec()); + let encoded = original.encode(); + let decoded = Frame::decode(&encoded).unwrap().unwrap(); + + assert_eq!(decoded.version, PROTOCOL_VERSION); + assert_eq!(decoded.msg_type, MessageType::Heartbeat); + assert_eq!(decoded.payload, b"test payload"); + } + + #[test] + fn test_frame_decode_incomplete_data() { + let data = [0x43, 0x53, 0x4D, 0x01, 0x01]; + let result = Frame::decode(&data).unwrap(); + assert!(result.is_none()); + } + + #[test] + fn test_frame_decode_invalid_magic() { + let data = [0x00, 0x00, 0x00, 0x00, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00]; + let result = Frame::decode(&data); + assert!(matches!(result, Err(FrameError::InvalidMagic))); + } + + #[test] + fn test_json_frame_roundtrip() { + let heartbeat = HeartbeatPayload { + device_uid: "test-uid".to_string(), + timestamp: "2026-04-03T12:00:00Z".to_string(), + hmac: "abc123".to_string(), + }; + + let frame = Frame::new_json(MessageType::Heartbeat, &heartbeat).unwrap(); + let encoded = frame.encode(); + let decoded = Frame::decode(&encoded).unwrap().unwrap(); + let parsed: HeartbeatPayload = decoded.decode_payload().unwrap(); + + assert_eq!(parsed.device_uid, "test-uid"); + assert_eq!(parsed.hmac, "abc123"); + } + + #[test] + fn test_plugin_message_types_roundtrip() { + let types = [ + MessageType::WebAccessLog, + MessageType::UsageReport, + MessageType::AppUsageReport, + MessageType::SoftwareViolation, + MessageType::UsbFileOp, + MessageType::WatermarkConfig, + MessageType::PluginEnable, + MessageType::PluginDisable, + ]; + + for mt in types { + let frame = Frame::new(mt, vec![1, 2, 3]); + let encoded = frame.encode(); + let decoded = Frame::decode(&encoded).unwrap().unwrap(); + assert_eq!(decoded.msg_type, mt); + } + } + + #[test] + fn test_frame_decode_payload_too_large() { + // Craft a header that claims a 10 MB payload + let mut data = Vec::with_capacity(FRAME_HEADER_SIZE); + data.extend_from_slice(&MAGIC); + data.push(PROTOCOL_VERSION); + data.push(MessageType::Heartbeat as u8); + data.extend_from_slice(&(10 * 1024 * 1024u32).to_be_bytes()); + // Don't actually include the payload — the size check should reject first + let result = Frame::decode(&data); + assert!(matches!(result, Err(FrameError::PayloadTooLarge(_)))); + } +} diff --git a/crates/server/Cargo.toml b/crates/server/Cargo.toml new file mode 100644 index 0000000..6ff8bf8 --- /dev/null +++ b/crates/server/Cargo.toml @@ -0,0 +1,51 @@ +[package] +name = "csm-server" +version.workspace = true +edition.workspace = true + +[dependencies] +csm-protocol = { path = "../protocol" } + +# Async runtime +tokio = { workspace = true } + +# Web framework +axum = { version = "0.7", features = ["ws"] } +tower-http = { version = "0.5", features = ["cors", "fs", "trace", "compression-gzip", "set-header"] } +tower = "0.4" + +# Database +sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite"] } + +# TLS +rustls = "0.23" +tokio-rustls = "0.26" +rustls-pemfile = "2" +rustls-pki-types = "1" + +# Serialization +serde = { workspace = true } +serde_json = { workspace = true } + +# Auth +jsonwebtoken = "9" +bcrypt = "0.15" + +# Notifications +lettre = { version = "0.11", default-features = false, features = ["tokio1-rustls-tls", "smtp-transport", "builder", "hostname"] } +reqwest = { version = "0.12", features = ["json", "rustls-tls"] } + +# Config & logging +toml = "0.8" +tracing = { workspace = true } +tracing-subscriber = { workspace = true } +anyhow = { workspace = true } + +# Utilities +uuid = { workspace = true } +chrono = { workspace = true } +thiserror = { workspace = true } +include_dir = "0.7" +hmac = "0.12" +sha2 = "0.10" +hex = "0.4" diff --git a/crates/server/src/alert.rs b/crates/server/src/alert.rs new file mode 100644 index 0000000..07e0109 --- /dev/null +++ b/crates/server/src/alert.rs @@ -0,0 +1,118 @@ +use crate::AppState; +use tracing::{info, warn, error}; + +/// Background task for data cleanup and alert processing +pub async fn cleanup_task(state: AppState) { + let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(3600)); + + loop { + interval.tick().await; + + // Cleanup old status history + if let Err(e) = sqlx::query( + "DELETE FROM device_status_history WHERE reported_at < datetime('now', ?)" + ) + .bind(format!("-{} days", state.config.retention.status_history_days)) + .execute(&state.db) + .await + { + error!("Failed to cleanup status history: {}", e); + } + + // Cleanup old USB events + if let Err(e) = sqlx::query( + "DELETE FROM usb_events WHERE event_time < datetime('now', ?)" + ) + .bind(format!("-{} days", state.config.retention.usb_events_days)) + .execute(&state.db) + .await + { + error!("Failed to cleanup USB events: {}", e); + } + + // Cleanup handled alert records + if let Err(e) = sqlx::query( + "DELETE FROM alert_records WHERE handled = 1 AND triggered_at < datetime('now', ?)" + ) + .bind(format!("-{} days", state.config.retention.alert_records_days)) + .execute(&state.db) + .await + { + error!("Failed to cleanup alert records: {}", e); + } + + // Mark devices as offline if no heartbeat for 2 minutes + if let Err(e) = sqlx::query( + "UPDATE devices SET status = 'offline' WHERE status = 'online' AND last_heartbeat < datetime('now', '-2 minutes')" + ) + .execute(&state.db) + .await + { + error!("Failed to mark stale devices offline: {}", e); + } + + // SQLite WAL checkpoint + if let Err(e) = sqlx::query("PRAGMA wal_checkpoint(TRUNCATE)") + .execute(&state.db) + .await + { + warn!("WAL checkpoint failed: {}", e); + } + + info!("Cleanup cycle completed"); + } +} + +/// Send email notification +pub async fn send_email( + smtp_config: &crate::config::SmtpConfig, + to: &str, + subject: &str, + body: &str, +) -> anyhow::Result<()> { + use lettre::message::header::ContentType; + use lettre::{Message, SmtpTransport, Transport}; + use lettre::transport::smtp::authentication::Credentials; + + let email = Message::builder() + .from(smtp_config.from.parse()?) + .to(to.parse()?) + .subject(subject) + .header(ContentType::TEXT_HTML) + .body(body.to_string())?; + + let creds = Credentials::new( + smtp_config.username.clone(), + smtp_config.password.clone(), + ); + + let mailer = SmtpTransport::starttls_relay(&smtp_config.host)? + .port(smtp_config.port) + .credentials(creds) + .build(); + + mailer.send(&email)?; + Ok(()) +} + +/// Shared HTTP client for webhook notifications. +/// Lazily initialized once and reused across calls to benefit from connection pooling. +static WEBHOOK_CLIENT: std::sync::OnceLock = std::sync::OnceLock::new(); + +fn webhook_client() -> &'static reqwest::Client { + WEBHOOK_CLIENT.get_or_init(|| { + reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(10)) + .build() + .unwrap_or_else(|_| reqwest::Client::new()) + }) +} + +/// Send webhook notification +pub async fn send_webhook(url: &str, payload: &serde_json::Value) -> anyhow::Result<()> { + webhook_client().post(url) + .json(payload) + .send() + .await?; + Ok(()) +} diff --git a/crates/server/src/api/alerts.rs b/crates/server/src/api/alerts.rs new file mode 100644 index 0000000..7d94ea4 --- /dev/null +++ b/crates/server/src/api/alerts.rs @@ -0,0 +1,243 @@ +use axum::{extract::{State, Path, Query, Json}, http::StatusCode}; +use serde::Deserialize; +use sqlx::Row; + +use crate::AppState; +use super::ApiResponse; +use super::auth::Claims; + +#[derive(Debug, Deserialize)] +pub struct AlertRecordListParams { + pub device_uid: Option, + pub alert_type: Option, + pub severity: Option, + pub handled: Option, + pub page: Option, + pub page_size: Option, +} + +pub async fn list_rules( + State(state): State, +) -> Json> { + let rows = sqlx::query( + "SELECT id, name, rule_type, condition, severity, enabled, notify_email, notify_webhook, created_at, updated_at + FROM alert_rules ORDER BY created_at DESC" + ) + .fetch_all(&state.db) + .await; + + match rows { + Ok(records) => { + let items: Vec = records.iter().map(|r| serde_json::json!({ + "id": r.get::("id"), + "name": r.get::("name"), + "rule_type": r.get::("rule_type"), + "condition": r.get::("condition"), + "severity": r.get::("severity"), + "enabled": r.get::("enabled"), + "notify_email": r.get::, _>("notify_email"), + "notify_webhook": r.get::, _>("notify_webhook"), + "created_at": r.get::("created_at"), + "updated_at": r.get::("updated_at"), + })).collect(); + Json(ApiResponse::ok(serde_json::json!({ + "rules": items, + }))) + } + Err(e) => Json(ApiResponse::internal_error("query alert rules", e)), + } +} + +pub async fn list_records( + State(state): State, + Query(params): Query, +) -> Json> { + let limit = params.page_size.unwrap_or(20).min(100); + let offset = params.page.unwrap_or(1).saturating_sub(1) * limit; + + // Normalize empty strings to None (Axum deserializes `key=` as Some("")) + let device_uid = params.device_uid.as_deref().filter(|s| !s.is_empty()).map(String::from); + let alert_type = params.alert_type.as_deref().filter(|s| !s.is_empty()).map(String::from); + let severity = params.severity.as_deref().filter(|s| !s.is_empty()).map(String::from); + let handled = params.handled; + + let rows = sqlx::query( + "SELECT id, rule_id, device_uid, alert_type, severity, detail, handled, handled_by, handled_at, triggered_at + FROM alert_records WHERE 1=1 + AND (? IS NULL OR device_uid = ?) + AND (? IS NULL OR alert_type = ?) + AND (? IS NULL OR severity = ?) + AND (? IS NULL OR handled = ?) + ORDER BY triggered_at DESC LIMIT ? OFFSET ?" + ) + .bind(&device_uid).bind(&device_uid) + .bind(&alert_type).bind(&alert_type) + .bind(&severity).bind(&severity) + .bind(&handled).bind(&handled) + .bind(limit).bind(offset) + .fetch_all(&state.db) + .await; + + match rows { + Ok(records) => { + let items: Vec = records.iter().map(|r| serde_json::json!({ + "id": r.get::("id"), + "rule_id": r.get::, _>("rule_id"), + "device_uid": r.get::, _>("device_uid"), + "alert_type": r.get::("alert_type"), + "severity": r.get::("severity"), + "detail": r.get::("detail"), + "handled": r.get::("handled"), + "handled_by": r.get::, _>("handled_by"), + "handled_at": r.get::, _>("handled_at"), + "triggered_at": r.get::("triggered_at"), + })).collect(); + Json(ApiResponse::ok(serde_json::json!({ + "records": items, + "page": params.page.unwrap_or(1), + "page_size": limit, + }))) + } + Err(e) => Json(ApiResponse::internal_error("query alert records", e)), + } +} + +#[derive(Debug, Deserialize)] +pub struct CreateRuleRequest { + pub name: String, + pub rule_type: String, + pub condition: String, + pub severity: Option, + pub notify_email: Option, + pub notify_webhook: Option, +} + +pub async fn create_rule( + State(state): State, + Json(body): Json, +) -> (StatusCode, Json>) { + let severity = body.severity.unwrap_or_else(|| "medium".to_string()); + + let result = sqlx::query( + "INSERT INTO alert_rules (name, rule_type, condition, severity, notify_email, notify_webhook) + VALUES (?, ?, ?, ?, ?, ?)" + ) + .bind(&body.name) + .bind(&body.rule_type) + .bind(&body.condition) + .bind(&severity) + .bind(&body.notify_email) + .bind(&body.notify_webhook) + .execute(&state.db) + .await; + + match result { + Ok(r) => (StatusCode::CREATED, Json(ApiResponse::ok(serde_json::json!({ + "id": r.last_insert_rowid(), + })))), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::internal_error("create alert rule", e))), + } +} + +#[derive(Debug, Deserialize)] +pub struct UpdateRuleRequest { + pub name: Option, + pub rule_type: Option, + pub condition: Option, + pub severity: Option, + pub enabled: Option, + pub notify_email: Option, + pub notify_webhook: Option, +} + +pub async fn update_rule( + State(state): State, + Path(id): Path, + Json(body): Json, +) -> Json> { + let existing = sqlx::query("SELECT * FROM alert_rules WHERE id = ?") + .bind(id) + .fetch_optional(&state.db) + .await; + + let existing = match existing { + Ok(Some(row)) => row, + Ok(None) => return Json(ApiResponse::error("Rule not found")), + Err(e) => return Json(ApiResponse::internal_error("query alert rule", e)), + }; + + let name = body.name.unwrap_or_else(|| existing.get::("name")); + let rule_type = body.rule_type.unwrap_or_else(|| existing.get::("rule_type")); + let condition = body.condition.unwrap_or_else(|| existing.get::("condition")); + let severity = body.severity.unwrap_or_else(|| existing.get::("severity")); + let enabled = body.enabled.unwrap_or_else(|| existing.get::("enabled")); + let notify_email = body.notify_email.or_else(|| existing.get::, _>("notify_email")); + let notify_webhook = body.notify_webhook.or_else(|| existing.get::, _>("notify_webhook")); + + let result = sqlx::query( + "UPDATE alert_rules SET name = ?, rule_type = ?, condition = ?, severity = ?, enabled = ?, + notify_email = ?, notify_webhook = ?, updated_at = datetime('now') WHERE id = ?" + ) + .bind(&name) + .bind(&rule_type) + .bind(&condition) + .bind(&severity) + .bind(enabled) + .bind(¬ify_email) + .bind(¬ify_webhook) + .bind(id) + .execute(&state.db) + .await; + + match result { + Ok(_) => Json(ApiResponse::ok(serde_json::json!({"updated": true}))), + Err(e) => Json(ApiResponse::internal_error("update alert rule", e)), + } +} + +pub async fn delete_rule( + State(state): State, + Path(id): Path, +) -> Json> { + let result = sqlx::query("DELETE FROM alert_rules WHERE id = ?") + .bind(id) + .execute(&state.db) + .await; + + match result { + Ok(r) => { + if r.rows_affected() > 0 { + Json(ApiResponse::ok(serde_json::json!({"deleted": true}))) + } else { + Json(ApiResponse::error("Rule not found")) + } + } + Err(e) => Json(ApiResponse::internal_error("delete alert rule", e)), + } +} + +pub async fn handle_record( + State(state): State, + Path(id): Path, + claims: axum::Extension, +) -> Json> { + let handled_by = &claims.username; + let result = sqlx::query( + "UPDATE alert_records SET handled = 1, handled_by = ?, handled_at = datetime('now') WHERE id = ?" + ) + .bind(handled_by) + .bind(id) + .execute(&state.db) + .await; + + match result { + Ok(r) => { + if r.rows_affected() > 0 { + Json(ApiResponse::ok(serde_json::json!({"handled": true}))) + } else { + Json(ApiResponse::error("Alert record not found")) + } + } + Err(e) => Json(ApiResponse::internal_error("handle alert record", e)), + } +} diff --git a/crates/server/src/api/assets.rs b/crates/server/src/api/assets.rs new file mode 100644 index 0000000..2d86be3 --- /dev/null +++ b/crates/server/src/api/assets.rs @@ -0,0 +1,143 @@ +use axum::{extract::{State, Query}, Json}; +use serde::Deserialize; +use sqlx::Row; +use crate::AppState; +use super::{ApiResponse, Pagination}; + +#[derive(Debug, Deserialize)] +pub struct AssetListParams { + pub device_uid: Option, + pub search: Option, + pub page: Option, + pub page_size: Option, +} + +pub async fn list_hardware( + State(state): State, + Query(params): Query, +) -> Json> { + let limit = params.page_size.unwrap_or(20).min(100); + let offset = params.page.unwrap_or(1).saturating_sub(1) * limit; + + // Normalize empty strings to None + let device_uid = params.device_uid.as_deref().filter(|s| !s.is_empty()).map(String::from); + let search = params.search.as_deref().filter(|s| !s.is_empty()).map(String::from); + + let rows = sqlx::query( + "SELECT id, device_uid, cpu_model, cpu_cores, memory_total_mb, disk_model, disk_total_mb, + gpu_model, motherboard, serial_number, reported_at + FROM hardware_assets WHERE 1=1 + AND (? IS NULL OR device_uid = ?) + AND (? IS NULL OR cpu_model LIKE '%' || ? || '%' OR gpu_model LIKE '%' || ? || '%') + ORDER BY reported_at DESC LIMIT ? OFFSET ?" + ) + .bind(&device_uid).bind(&device_uid) + .bind(&search).bind(&search).bind(&search) + .bind(limit).bind(offset) + .fetch_all(&state.db) + .await; + + match rows { + Ok(records) => { + let items: Vec = records.iter().map(|r| serde_json::json!({ + "id": r.get::("id"), + "device_uid": r.get::("device_uid"), + "cpu_model": r.get::("cpu_model"), + "cpu_cores": r.get::("cpu_cores"), + "memory_total_mb": r.get::("memory_total_mb"), + "disk_model": r.get::("disk_model"), + "disk_total_mb": r.get::("disk_total_mb"), + "gpu_model": r.get::, _>("gpu_model"), + "motherboard": r.get::, _>("motherboard"), + "serial_number": r.get::, _>("serial_number"), + "reported_at": r.get::("reported_at"), + })).collect(); + Json(ApiResponse::ok(serde_json::json!({ + "hardware": items, + "page": params.page.unwrap_or(1), + "page_size": limit, + }))) + } + Err(e) => Json(ApiResponse::internal_error("query hardware assets", e)), + } +} + +pub async fn list_software( + State(state): State, + Query(params): Query, +) -> Json> { + let limit = params.page_size.unwrap_or(20).min(100); + let offset = params.page.unwrap_or(1).saturating_sub(1) * limit; + + // Normalize empty strings to None + let device_uid = params.device_uid.as_deref().filter(|s| !s.is_empty()).map(String::from); + let search = params.search.as_deref().filter(|s| !s.is_empty()).map(String::from); + + let rows = sqlx::query( + "SELECT id, device_uid, name, version, publisher, install_date, install_path + FROM software_assets WHERE 1=1 + AND (? IS NULL OR device_uid = ?) + AND (? IS NULL OR name LIKE '%' || ? || '%' OR publisher LIKE '%' || ? || '%') + ORDER BY name ASC LIMIT ? OFFSET ?" + ) + .bind(&device_uid).bind(&device_uid) + .bind(&search).bind(&search).bind(&search) + .bind(limit).bind(offset) + .fetch_all(&state.db) + .await; + + match rows { + Ok(records) => { + let items: Vec = records.iter().map(|r| serde_json::json!({ + "id": r.get::("id"), + "device_uid": r.get::("device_uid"), + "name": r.get::("name"), + "version": r.get::, _>("version"), + "publisher": r.get::, _>("publisher"), + "install_date": r.get::, _>("install_date"), + "install_path": r.get::, _>("install_path"), + })).collect(); + Json(ApiResponse::ok(serde_json::json!({ + "software": items, + "page": params.page.unwrap_or(1), + "page_size": limit, + }))) + } + Err(e) => Json(ApiResponse::internal_error("query software assets", e)), + } +} + +pub async fn list_changes( + State(state): State, + Query(page): Query, +) -> Json> { + let offset = page.offset(); + let limit = page.limit(); + + let rows = sqlx::query( + "SELECT id, device_uid, change_type, change_detail, detected_at + FROM asset_changes ORDER BY detected_at DESC LIMIT ? OFFSET ?" + ) + .bind(limit) + .bind(offset) + .fetch_all(&state.db) + .await; + + match rows { + Ok(records) => { + let items: Vec = records.iter().map(|r| serde_json::json!({ + "id": r.get::("id"), + "device_uid": r.get::("device_uid"), + "change_type": r.get::("change_type"), + "change_detail": r.get::("change_detail"), + "detected_at": r.get::("detected_at"), + })).collect(); + Json(ApiResponse::ok(serde_json::json!({ + "changes": items, + "page": page.page.unwrap_or(1), + "page_size": limit, + }))) + } + Err(e) => Json(ApiResponse::internal_error("query asset changes", e)), + } +} diff --git a/crates/server/src/api/auth.rs b/crates/server/src/api/auth.rs new file mode 100644 index 0000000..ed95a9e --- /dev/null +++ b/crates/server/src/api/auth.rs @@ -0,0 +1,295 @@ +use axum::{extract::State, Json, http::StatusCode, extract::Request, middleware::Next, response::Response}; +use serde::{Deserialize, Serialize}; +use jsonwebtoken::{encode, decode, Header, EncodingKey, DecodingKey, Validation}; +use std::sync::Arc; +use std::collections::HashMap; +use std::time::Instant; +use tokio::sync::Mutex; +use crate::AppState; +use super::ApiResponse; + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct Claims { + pub sub: i64, + pub username: String, + pub role: String, + pub exp: u64, + pub iat: u64, + pub token_type: String, + /// Random family ID for refresh token rotation detection + pub family: String, +} + +#[derive(Debug, Deserialize)] +pub struct LoginRequest { + pub username: String, + pub password: String, +} + +#[derive(Debug, Serialize)] +pub struct LoginResponse { + pub access_token: String, + pub refresh_token: String, + pub user: UserInfo, +} + +#[derive(Debug, Serialize, sqlx::FromRow)] +pub struct UserInfo { + pub id: i64, + pub username: String, + pub role: String, +} + +#[derive(Debug, Deserialize)] +pub struct RefreshRequest { + pub refresh_token: String, +} + +/// In-memory rate limiter for login attempts +#[derive(Clone, Default)] +pub struct LoginRateLimiter { + attempts: Arc>>, +} + +impl LoginRateLimiter { + pub fn new() -> Self { + Self::default() + } + + /// Returns true if the request should be rate-limited + pub async fn is_limited(&self, key: &str) -> bool { + let mut attempts = self.attempts.lock().await; + let now = Instant::now(); + let window = std::time::Duration::from_secs(300); // 5-minute window + let max_attempts = 10u32; + + if let Some((first_attempt, count)) = attempts.get_mut(key) { + if now.duration_since(*first_attempt) > window { + // Window expired, reset + *first_attempt = now; + *count = 1; + false + } else if *count >= max_attempts { + true // Rate limited + } else { + *count += 1; + false + } + } else { + attempts.insert(key.to_string(), (now, 1)); + // Cleanup old entries periodically + if attempts.len() > 1000 { + let cutoff = now - window; + attempts.retain(|_, (t, _)| *t > cutoff); + } + false + } + } +} + +pub async fn login( + State(state): State, + Json(req): Json, +) -> Result<(StatusCode, Json>), StatusCode> { + // Rate limit check + if state.login_limiter.is_limited(&req.username).await { + return Ok((StatusCode::TOO_MANY_REQUESTS, Json(ApiResponse::error("Too many login attempts. Try again later.")))); + } + + let user: Option = sqlx::query_as::<_, UserInfo>( + "SELECT id, username, role FROM users WHERE username = ?" + ) + .bind(&req.username) + .fetch_optional(&state.db) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + let user = match user { + Some(u) => u, + None => return Ok((StatusCode::UNAUTHORIZED, Json(ApiResponse::error("Invalid credentials")))), + }; + + let hash: String = sqlx::query_scalar::<_, String>( + "SELECT password FROM users WHERE id = ?" + ) + .bind(user.id) + .fetch_one(&state.db) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + if !bcrypt::verify(&req.password, &hash).unwrap_or(false) { + return Ok((StatusCode::UNAUTHORIZED, Json(ApiResponse::error("Invalid credentials")))); + } + + let now = chrono::Utc::now().timestamp() as u64; + let family = uuid::Uuid::new_v4().to_string(); + let access_token = create_token(&user, "access", state.config.auth.access_token_ttl_secs, now, &state.config.auth.jwt_secret, &family)?; + let refresh_token = create_token(&user, "refresh", state.config.auth.refresh_token_ttl_secs, now, &state.config.auth.jwt_secret, &family)?; + + // Audit log + let _ = sqlx::query( + "INSERT INTO admin_audit_log (user_id, action, detail) VALUES (?, 'login', ?)" + ) + .bind(user.id) + .bind(format!("User {} logged in", user.username)) + .execute(&state.db) + .await; + + Ok((StatusCode::OK, Json(ApiResponse::ok(LoginResponse { + access_token, + refresh_token, + user, + })))) +} + +pub async fn refresh( + State(state): State, + Json(req): Json, +) -> Result<(StatusCode, Json>), StatusCode> { + let claims = decode::( + &req.refresh_token, + &DecodingKey::from_secret(state.config.auth.jwt_secret.as_bytes()), + &Validation::default(), + ) + .map_err(|_| StatusCode::UNAUTHORIZED)?; + + if claims.claims.token_type != "refresh" { + return Ok((StatusCode::UNAUTHORIZED, Json(ApiResponse::error("Invalid token type")))); + } + + // Check if this refresh token family has been revoked (reuse detection) + let revoked: bool = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM revoked_token_families WHERE family = ?" + ) + .bind(&claims.claims.family) + .fetch_one(&state.db) + .await + .unwrap_or(0) > 0; + + if revoked { + // Token reuse detected — revoke entire family and force re-login + tracing::warn!("Refresh token reuse detected for user {} family {}", claims.claims.sub, claims.claims.family); + let _ = sqlx::query("DELETE FROM refresh_tokens WHERE user_id = ?") + .bind(claims.claims.sub) + .execute(&state.db) + .await; + return Ok((StatusCode::UNAUTHORIZED, Json(ApiResponse::error("Token reuse detected. Please log in again.")))); + } + + let user = UserInfo { + id: claims.claims.sub, + username: claims.claims.username, + role: claims.claims.role, + }; + + // Rotate: new family for each refresh + let new_family = uuid::Uuid::new_v4().to_string(); + let now = chrono::Utc::now().timestamp() as u64; + let access_token = create_token(&user, "access", state.config.auth.access_token_ttl_secs, now, &state.config.auth.jwt_secret, &new_family)?; + let refresh_token = create_token(&user, "refresh", state.config.auth.refresh_token_ttl_secs, now, &state.config.auth.jwt_secret, &new_family)?; + + // Revoke old family + let _ = sqlx::query("INSERT OR IGNORE INTO revoked_token_families (family, user_id, revoked_at) VALUES (?, ?, datetime('now'))") + .bind(&claims.claims.family) + .bind(claims.claims.sub) + .execute(&state.db) + .await; + + Ok((StatusCode::OK, Json(ApiResponse::ok(LoginResponse { + access_token, + refresh_token, + user, + })))) +} + +fn create_token(user: &UserInfo, token_type: &str, ttl: u64, now: u64, secret: &str, family: &str) -> Result { + let claims = Claims { + sub: user.id, + username: user.username.clone(), + role: user.role.clone(), + exp: now + ttl, + iat: now, + token_type: token_type.to_string(), + family: family.to_string(), + }; + + encode( + &Header::default(), + &claims, + &EncodingKey::from_secret(secret.as_bytes()), + ) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR) +} + +/// Axum middleware: require valid JWT access token +pub async fn require_auth( + State(state): State, + mut request: Request, + next: Next, +) -> Result { + let auth_header = request.headers() + .get("Authorization") + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.strip_prefix("Bearer ")); + + let token = match auth_header { + Some(t) => t, + None => return Err(StatusCode::UNAUTHORIZED), + }; + + let claims = decode::( + token, + &DecodingKey::from_secret(state.config.auth.jwt_secret.as_bytes()), + &Validation::default(), + ) + .map_err(|_| StatusCode::UNAUTHORIZED)?; + + if claims.claims.token_type != "access" { + return Err(StatusCode::UNAUTHORIZED); + } + + // Inject claims into request extensions for handlers to use + request.extensions_mut().insert(claims.claims); + + Ok(next.run(request).await) +} + +/// Axum middleware: require admin role for write operations + audit log +pub async fn require_admin( + State(state): State, + request: Request, + next: Next, +) -> Result { + let claims = request.extensions() + .get::() + .ok_or(StatusCode::UNAUTHORIZED)?; + + if claims.role != "admin" { + return Err(StatusCode::FORBIDDEN); + } + + // Capture audit info before running handler + let method = request.method().clone(); + let path = request.uri().path().to_string(); + let user_id = claims.sub; + let username = claims.username.clone(); + + let response = next.run(request).await; + + // Record admin action to audit log (fire and forget — don't block response) + let status = response.status(); + if status.is_success() { + let action = format!("{} {}", method, path); + let detail = format!("by {}", username); + let _ = sqlx::query( + "INSERT INTO admin_audit_log (user_id, action, detail) VALUES (?, ?, ?)" + ) + .bind(user_id) + .bind(&action) + .bind(&detail) + .execute(&state.db) + .await; + } + + Ok(response) +} diff --git a/crates/server/src/api/devices.rs b/crates/server/src/api/devices.rs new file mode 100644 index 0000000..c8bdcf4 --- /dev/null +++ b/crates/server/src/api/devices.rs @@ -0,0 +1,263 @@ +use axum::{extract::{State, Path, Query}, Json}; +use serde::{Deserialize, Serialize}; +use sqlx::Row; +use crate::AppState; +use super::{ApiResponse, Pagination}; + +#[derive(Debug, Deserialize)] +pub struct DeviceListParams { + pub status: Option, + pub group: Option, + pub search: Option, + pub page: Option, + pub page_size: Option, +} + +#[derive(Debug, Serialize, sqlx::FromRow)] +pub struct DeviceRow { + pub id: i64, + pub device_uid: String, + pub hostname: String, + pub ip_address: String, + pub mac_address: Option, + pub os_version: Option, + pub client_version: Option, + pub status: String, + pub last_heartbeat: Option, + pub registered_at: Option, + pub group_name: Option, +} + +pub async fn list( + State(state): State, + Query(params): Query, +) -> Json> { + let limit = params.page_size.unwrap_or(20).min(100); + let offset = params.page.unwrap_or(1).saturating_sub(1) * limit; + + // Normalize empty strings to None (Axum deserializes `status=` as Some("")) + let status = params.status.as_deref().filter(|s| !s.is_empty()).map(String::from); + let group = params.group.as_deref().filter(|s| !s.is_empty()).map(String::from); + let search = params.search.as_deref().filter(|s| !s.is_empty()).map(String::from); + + let devices = sqlx::query_as::<_, DeviceRow>( + "SELECT id, device_uid, hostname, ip_address, mac_address, os_version, client_version, + status, last_heartbeat, registered_at, group_name + FROM devices WHERE 1=1 + AND (? IS NULL OR status = ?) + AND (? IS NULL OR group_name = ?) + AND (? IS NULL OR hostname LIKE '%' || ? || '%' OR ip_address LIKE '%' || ? || '%') + ORDER BY registered_at DESC LIMIT ? OFFSET ?" + ) + .bind(&status).bind(&status) + .bind(&group).bind(&group) + .bind(&search).bind(&search).bind(&search) + .bind(limit).bind(offset) + .fetch_all(&state.db) + .await; + + let total: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM devices WHERE 1=1 + AND (? IS NULL OR status = ?) + AND (? IS NULL OR group_name = ?) + AND (? IS NULL OR hostname LIKE '%' || ? || '%' OR ip_address LIKE '%' || ? || '%')" + ) + .bind(&status).bind(&status) + .bind(&group).bind(&group) + .bind(&search).bind(&search).bind(&search) + .fetch_one(&state.db) + .await + .unwrap_or(0); + + match devices { + Ok(rows) => Json(ApiResponse::ok(serde_json::json!({ + "devices": rows, + "total": total, + "page": params.page.unwrap_or(1), + "page_size": limit, + }))), + Err(e) => Json(ApiResponse::internal_error("query devices", e)), + } +} + +pub async fn get_detail( + State(state): State, + Path(uid): Path, +) -> Json> { + let device = sqlx::query_as::<_, DeviceRow>( + "SELECT id, device_uid, hostname, ip_address, mac_address, os_version, client_version, + status, last_heartbeat, registered_at, group_name + FROM devices WHERE device_uid = ?" + ) + .bind(&uid) + .fetch_optional(&state.db) + .await; + + match device { + Ok(Some(d)) => Json(ApiResponse::ok(serde_json::to_value(d).unwrap_or_default())), + Ok(None) => Json(ApiResponse::error("Device not found")), + Err(e) => Json(ApiResponse::internal_error("query devices", e)), + } +} + +#[derive(Debug, Serialize, sqlx::FromRow)] +struct StatusRow { + pub cpu_usage: f64, + pub memory_usage: f64, + pub memory_total_mb: i64, + pub disk_usage: f64, + pub disk_total_mb: i64, + pub network_rx_rate: i64, + pub network_tx_rate: i64, + pub running_procs: i32, + pub top_processes: Option, + pub reported_at: String, +} + +pub async fn get_status( + State(state): State, + Path(uid): Path, +) -> Json> { + let status = sqlx::query_as::<_, StatusRow>( + "SELECT cpu_usage, memory_usage, memory_total_mb, disk_usage, disk_total_mb, + network_rx_rate, network_tx_rate, running_procs, top_processes, reported_at + FROM device_status WHERE device_uid = ?" + ) + .bind(&uid) + .fetch_optional(&state.db) + .await; + + match status { + Ok(Some(s)) => { + let mut val = serde_json::to_value(&s).unwrap_or_default(); + // Parse top_processes JSON string back to array + if let Some(tp_str) = &s.top_processes { + if let Ok(tp) = serde_json::from_str::(tp_str) { + val["top_processes"] = tp; + } + } + Json(ApiResponse::ok(val)) + } + Ok(None) => Json(ApiResponse::error("No status data found")), + Err(e) => Json(ApiResponse::internal_error("query devices", e)), + } +} + +pub async fn get_history( + State(state): State, + Path(uid): Path, + Query(page): Query, +) -> Json> { + let offset = page.offset(); + let limit = page.limit(); + + let rows = sqlx::query( + "SELECT cpu_usage, memory_usage, disk_usage, running_procs, reported_at + FROM device_status_history WHERE device_uid = ? + ORDER BY reported_at DESC LIMIT ? OFFSET ?" + ) + .bind(&uid) + .bind(limit) + .bind(offset) + .fetch_all(&state.db) + .await; + + match rows { + Ok(records) => { + let items: Vec = records.iter().map(|r| { + serde_json::json!({ + "cpu_usage": r.get::("cpu_usage"), + "memory_usage": r.get::("memory_usage"), + "disk_usage": r.get::("disk_usage"), + "running_procs": r.get::("running_procs"), + "reported_at": r.get::("reported_at"), + }) + }).collect(); + Json(ApiResponse::ok(serde_json::json!({ + "history": items, + "page": page.page.unwrap_or(1), + "page_size": limit, + }))) + } + Err(e) => Json(ApiResponse::internal_error("query devices", e)), + } +} + +pub async fn remove( + State(state): State, + Path(uid): Path, +) -> Json> { + // If client is connected, send self-destruct command + let frame = csm_protocol::Frame::new_json( + csm_protocol::MessageType::ConfigUpdate, + &serde_json::json!({"type": "SelfDestruct"}), + ).ok(); + + if let Some(frame) = frame { + state.clients.send_to(&uid, frame.encode()).await; + } + + // Delete device and all associated data in a transaction + let mut tx = match state.db.begin().await { + Ok(tx) => tx, + Err(e) => return Json(ApiResponse::internal_error("begin transaction", e)), + }; + + // Delete status history + if let Err(e) = sqlx::query("DELETE FROM device_status_history WHERE device_uid = ?") + .bind(&uid) + .execute(&mut *tx) + .await + { + return Json(ApiResponse::internal_error("remove device history", e)); + } + + // Delete current status + if let Err(e) = sqlx::query("DELETE FROM device_status WHERE device_uid = ?") + .bind(&uid) + .execute(&mut *tx) + .await + { + return Json(ApiResponse::internal_error("remove device status", e)); + } + + // Delete plugin-related data + let cleanup_tables = [ + "hardware_assets", + "usb_events", + "usb_file_operations", + "usage_daily", + "app_usage_daily", + "software_violations", + "web_access_log", + "popup_block_stats", + ]; + for table in &cleanup_tables { + if let Err(e) = sqlx::query(&format!("DELETE FROM {} WHERE device_uid = ?", table)) + .bind(&uid) + .execute(&mut *tx) + .await + { + tracing::warn!("Failed to clean {} for device {}: {}", table, uid, e); + } + } + + // Finally delete the device itself + let delete_result = sqlx::query("DELETE FROM devices WHERE device_uid = ?") + .bind(&uid) + .execute(&mut *tx) + .await; + + match delete_result { + Ok(r) if r.rows_affected() > 0 => { + if let Err(e) = tx.commit().await { + return Json(ApiResponse::internal_error("commit device deletion", e)); + } + state.clients.unregister(&uid).await; + tracing::info!(device_uid = %uid, "Device and all associated data deleted"); + Json(ApiResponse::ok(())) + } + Ok(_) => Json(ApiResponse::error("Device not found")), + Err(e) => Json(ApiResponse::internal_error("remove device", e)), + } +} diff --git a/crates/server/src/api/mod.rs b/crates/server/src/api/mod.rs new file mode 100644 index 0000000..083f014 --- /dev/null +++ b/crates/server/src/api/mod.rs @@ -0,0 +1,120 @@ +use axum::{routing::{get, post, put, delete}, Router, Json, extract::State, middleware}; +use serde::{Deserialize, Serialize}; +use crate::AppState; + +pub mod auth; +pub mod devices; +pub mod assets; +pub mod usb; +pub mod alerts; +pub mod plugins; + +pub fn routes(state: AppState) -> Router { + let public = Router::new() + .route("/api/auth/login", post(auth::login)) + .route("/api/auth/refresh", post(auth::refresh)) + .route("/health", get(health_check)) + .with_state(state.clone()); + + // Read-only routes (any authenticated user) + let read_routes = Router::new() + // Devices + .route("/api/devices", get(devices::list)) + .route("/api/devices/:uid", get(devices::get_detail)) + .route("/api/devices/:uid/status", get(devices::get_status)) + .route("/api/devices/:uid/history", get(devices::get_history)) + // Assets + .route("/api/assets/hardware", get(assets::list_hardware)) + .route("/api/assets/software", get(assets::list_software)) + .route("/api/assets/changes", get(assets::list_changes)) + // USB (read) + .route("/api/usb/events", get(usb::list_events)) + .route("/api/usb/policies", get(usb::list_policies)) + // Alerts (read) + .route("/api/alerts/rules", get(alerts::list_rules)) + .route("/api/alerts/records", get(alerts::list_records)) + // Plugin read routes + .merge(plugins::read_routes()) + .layer(middleware::from_fn_with_state(state.clone(), auth::require_auth)); + + // Write routes (admin only) + let write_routes = Router::new() + // Devices + .route("/api/devices/:uid", delete(devices::remove)) + // USB (write) + .route("/api/usb/policies", post(usb::create_policy)) + .route("/api/usb/policies/:id", put(usb::update_policy).delete(usb::delete_policy)) + // Alerts (write) + .route("/api/alerts/rules", post(alerts::create_rule)) + .route("/api/alerts/rules/:id", put(alerts::update_rule).delete(alerts::delete_rule)) + .route("/api/alerts/records/:id/handle", put(alerts::handle_record)) + // Plugin write routes (already has require_admin layer internally) + .merge(plugins::write_routes()) + // Layer order: outer (require_admin) runs AFTER inner (require_auth) + // so require_auth sets Claims extension first, then require_admin checks it + .layer(middleware::from_fn_with_state(state.clone(), auth::require_admin)) + .layer(middleware::from_fn_with_state(state.clone(), auth::require_auth)); + + // WebSocket has its own JWT auth via query parameter + let ws_router = Router::new() + .route("/ws", get(crate::ws::ws_handler)) + .with_state(state.clone()); + + Router::new() + .merge(public) + .merge(read_routes) + .merge(write_routes) + .merge(ws_router) +} + +#[derive(Serialize)] +struct HealthResponse { + status: &'static str, +} + +async fn health_check() -> Json { + Json(HealthResponse { + status: "ok", + }) +} + +/// Standard API response envelope +#[derive(Serialize)] +pub struct ApiResponse { + pub success: bool, + pub data: Option, + pub error: Option, +} + +impl ApiResponse { + pub fn ok(data: T) -> Self { + Self { success: true, data: Some(data), error: None } + } + + pub fn error(msg: impl Into) -> Self { + Self { success: false, data: None, error: Some(msg.into()) } + } + + /// Log internal error and return sanitized message to client + pub fn internal_error(context: &str, e: impl std::fmt::Display) -> Self { + tracing::error!("{}: {}", context, e); + Self { success: false, data: None, error: Some("Internal server error".to_string()) } + } +} + +/// Pagination parameters +#[derive(Debug, Deserialize)] +pub struct Pagination { + pub page: Option, + pub page_size: Option, +} + +impl Pagination { + pub fn offset(&self) -> u32 { + self.page.unwrap_or(1).saturating_sub(1) * self.limit() + } + + pub fn limit(&self) -> u32 { + self.page_size.unwrap_or(20).min(100) + } +} diff --git a/crates/server/src/api/plugins/mod.rs b/crates/server/src/api/plugins/mod.rs new file mode 100644 index 0000000..a1ba5ae --- /dev/null +++ b/crates/server/src/api/plugins/mod.rs @@ -0,0 +1,49 @@ +pub mod web_filter; +pub mod usage_timer; +pub mod software_blocker; +pub mod popup_blocker; +pub mod usb_file_audit; +pub mod watermark; + +use axum::{Router, routing::{get, post, put}}; +use crate::AppState; + +/// Read-only plugin routes (accessible by admin + viewer) +pub fn read_routes() -> Router { + Router::new() + // Web Filter + .route("/api/plugins/web-filter/rules", get(web_filter::list_rules)) + .route("/api/plugins/web-filter/log", get(web_filter::list_access_log)) + // Usage Timer + .route("/api/plugins/usage-timer/daily", get(usage_timer::list_daily)) + .route("/api/plugins/usage-timer/app-usage", get(usage_timer::list_app_usage)) + .route("/api/plugins/usage-timer/leaderboard", get(usage_timer::leaderboard)) + // Software Blocker + .route("/api/plugins/software-blocker/blacklist", get(software_blocker::list_blacklist)) + .route("/api/plugins/software-blocker/violations", get(software_blocker::list_violations)) + // Popup Blocker + .route("/api/plugins/popup-blocker/rules", get(popup_blocker::list_rules)) + .route("/api/plugins/popup-blocker/stats", get(popup_blocker::list_stats)) + // USB File Audit + .route("/api/plugins/usb-file-audit/log", get(usb_file_audit::list_operations)) + .route("/api/plugins/usb-file-audit/summary", get(usb_file_audit::summary)) + // Watermark + .route("/api/plugins/watermark/config", get(watermark::get_config_list)) +} + +/// Write plugin routes (admin only — require_admin middleware applied by caller) +pub fn write_routes() -> Router { + Router::new() + // Web Filter + .route("/api/plugins/web-filter/rules", post(web_filter::create_rule)) + .route("/api/plugins/web-filter/rules/:id", put(web_filter::update_rule).delete(web_filter::delete_rule)) + // Software Blocker + .route("/api/plugins/software-blocker/blacklist", post(software_blocker::add_to_blacklist)) + .route("/api/plugins/software-blocker/blacklist/:id", put(software_blocker::update_blacklist).delete(software_blocker::remove_from_blacklist)) + // Popup Blocker + .route("/api/plugins/popup-blocker/rules", post(popup_blocker::create_rule)) + .route("/api/plugins/popup-blocker/rules/:id", put(popup_blocker::update_rule).delete(popup_blocker::delete_rule)) + // Watermark + .route("/api/plugins/watermark/config", post(watermark::create_config)) + .route("/api/plugins/watermark/config/:id", put(watermark::update_config).delete(watermark::delete_config)) +} diff --git a/crates/server/src/api/plugins/popup_blocker.rs b/crates/server/src/api/plugins/popup_blocker.rs new file mode 100644 index 0000000..d62bf9c --- /dev/null +++ b/crates/server/src/api/plugins/popup_blocker.rs @@ -0,0 +1,155 @@ +use axum::{extract::{State, Path, Json}, http::StatusCode}; +use serde::Deserialize; +use sqlx::Row; +use csm_protocol::MessageType; +use crate::AppState; +use crate::api::ApiResponse; +use crate::tcp::push_to_targets; + +#[derive(Debug, Deserialize)] +pub struct CreateRuleRequest { + pub rule_type: String, // "block" | "allow" + pub window_title: Option, + pub window_class: Option, + pub process_name: Option, + pub target_type: Option, + pub target_id: Option, +} + +pub async fn list_rules(State(state): State) -> Json> { + match sqlx::query("SELECT id, rule_type, window_title, window_class, process_name, target_type, target_id, enabled, created_at FROM popup_filter_rules ORDER BY created_at DESC") + .fetch_all(&state.db).await { + Ok(rows) => Json(ApiResponse::ok(serde_json::json!({"rules": rows.iter().map(|r| serde_json::json!({ + "id": r.get::("id"), "rule_type": r.get::("rule_type"), + "window_title": r.get::,_>("window_title"), + "window_class": r.get::,_>("window_class"), + "process_name": r.get::,_>("process_name"), + "target_type": r.get::("target_type"), "target_id": r.get::,_>("target_id"), + "enabled": r.get::("enabled"), "created_at": r.get::("created_at") + })).collect::>()}))), + Err(e) => Json(ApiResponse::internal_error("query popup filter rules", e)), + } +} + +pub async fn create_rule(State(state): State, Json(req): Json) -> (StatusCode, Json>) { + let target_type = req.target_type.unwrap_or_else(|| "global".to_string()); + + // Validate inputs + if !matches!(req.rule_type.as_str(), "block" | "allow") { + return (StatusCode::BAD_REQUEST, Json(ApiResponse::error("rule_type must be 'block' or 'allow'"))); + } + if !matches!(target_type.as_str(), "global" | "device" | "group") { + return (StatusCode::BAD_REQUEST, Json(ApiResponse::error("invalid target_type"))); + } + let has_filter = req.window_title.as_ref().map_or(false, |s| !s.is_empty()) + || req.window_class.as_ref().map_or(false, |s| !s.is_empty()) + || req.process_name.as_ref().map_or(false, |s| !s.is_empty()); + if !has_filter { + return (StatusCode::BAD_REQUEST, Json(ApiResponse::error("at least one filter (window_title/window_class/process_name) required"))); + } + + match sqlx::query("INSERT INTO popup_filter_rules (rule_type, window_title, window_class, process_name, target_type, target_id) VALUES (?,?,?,?,?,?)") + .bind(&req.rule_type).bind(&req.window_title).bind(&req.window_class).bind(&req.process_name).bind(&target_type).bind(&req.target_id) + .execute(&state.db).await { + Ok(r) => { + let new_id = r.last_insert_rowid(); + let rules = fetch_popup_rules_for_push(&state.db, &target_type, req.target_id.as_deref()).await; + push_to_targets(&state.db, &state.clients, MessageType::PopupRules, &serde_json::json!({"rules": rules}), &target_type, req.target_id.as_deref()).await; + (StatusCode::CREATED, Json(ApiResponse::ok(serde_json::json!({"id": new_id})))) + } + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::internal_error("create popup filter rule", e))), + } +} + +#[derive(Debug, Deserialize)] +pub struct UpdateRuleRequest { pub window_title: Option, pub window_class: Option, pub process_name: Option, pub enabled: Option } + +pub async fn update_rule(State(state): State, Path(id): Path, Json(body): Json) -> Json> { + let existing = sqlx::query("SELECT * FROM popup_filter_rules WHERE id = ?") + .bind(id) + .fetch_optional(&state.db) + .await; + + let existing = match existing { + Ok(Some(row)) => row, + Ok(None) => return Json(ApiResponse::error("Not found")), + Err(e) => return Json(ApiResponse::internal_error("query popup filter rule", e)), + }; + + let window_title = body.window_title.or_else(|| existing.get::, _>("window_title")); + let window_class = body.window_class.or_else(|| existing.get::, _>("window_class")); + let process_name = body.process_name.or_else(|| existing.get::, _>("process_name")); + let enabled = body.enabled.unwrap_or_else(|| existing.get::("enabled")); + + let result = sqlx::query("UPDATE popup_filter_rules SET window_title = ?, window_class = ?, process_name = ?, enabled = ? WHERE id = ?") + .bind(&window_title) + .bind(&window_class) + .bind(&process_name) + .bind(enabled) + .bind(id) + .execute(&state.db) + .await; + + match result { + Ok(r) if r.rows_affected() > 0 => { + let target_type_val: String = existing.get("target_type"); + let target_id_val: Option = existing.get("target_id"); + let rules = fetch_popup_rules_for_push(&state.db, &target_type_val, target_id_val.as_deref()).await; + push_to_targets(&state.db, &state.clients, MessageType::PopupRules, &serde_json::json!({"rules": rules}), &target_type_val, target_id_val.as_deref()).await; + Json(ApiResponse::ok(())) + } + Ok(_) => Json(ApiResponse::error("Not found")), + Err(e) => Json(ApiResponse::internal_error("update popup filter rule", e)), + } +} + +pub async fn delete_rule(State(state): State, Path(id): Path) -> Json> { + let existing = sqlx::query("SELECT target_type, target_id FROM popup_filter_rules WHERE id = ?") + .bind(id).fetch_optional(&state.db).await; + let (target_type, target_id) = match existing { + Ok(Some(row)) => (row.get::("target_type"), row.get::, _>("target_id")), + _ => return Json(ApiResponse::error("Not found")), + }; + match sqlx::query("DELETE FROM popup_filter_rules WHERE id=?").bind(id).execute(&state.db).await { + Ok(r) if r.rows_affected() > 0 => { + let rules = fetch_popup_rules_for_push(&state.db, &target_type, target_id.as_deref()).await; + push_to_targets(&state.db, &state.clients, MessageType::PopupRules, &serde_json::json!({"rules": rules}), &target_type, target_id.as_deref()).await; + Json(ApiResponse::ok(())) + } + _ => Json(ApiResponse::error("Not found")), + } +} + +pub async fn list_stats(State(state): State) -> Json> { + match sqlx::query("SELECT device_uid, blocked_count, date FROM popup_block_stats ORDER BY date DESC LIMIT 30") + .fetch_all(&state.db).await { + Ok(rows) => Json(ApiResponse::ok(serde_json::json!({"stats": rows.iter().map(|r| serde_json::json!({ + "device_uid": r.get::("device_uid"), "blocked_count": r.get::("blocked_count"), + "date": r.get::("date") + })).collect::>()}))), + Err(e) => Json(ApiResponse::internal_error("query popup block stats", e)), + } +} + +async fn fetch_popup_rules_for_push( + db: &sqlx::SqlitePool, + target_type: &str, + target_id: Option<&str>, +) -> Vec { + let query = match target_type { + "device" => sqlx::query( + "SELECT id, rule_type, window_title, window_class, process_name FROM popup_filter_rules WHERE enabled = 1 AND (target_type = 'global' OR (target_type = 'device' AND target_id = ?))" + ).bind(target_id), + _ => sqlx::query( + "SELECT id, rule_type, window_title, window_class, process_name FROM popup_filter_rules WHERE enabled = 1 AND target_type = 'global'" + ), + }; + query.fetch_all(db).await + .map(|rows| rows.iter().map(|r| serde_json::json!({ + "id": r.get::("id"), "rule_type": r.get::("rule_type"), + "window_title": r.get::,_>("window_title"), + "window_class": r.get::,_>("window_class"), + "process_name": r.get::,_>("process_name"), + })).collect()) + .unwrap_or_default() +} diff --git a/crates/server/src/api/plugins/software_blocker.rs b/crates/server/src/api/plugins/software_blocker.rs new file mode 100644 index 0000000..8fbc34f --- /dev/null +++ b/crates/server/src/api/plugins/software_blocker.rs @@ -0,0 +1,155 @@ +use axum::{extract::{State, Path, Query, Json}, http::StatusCode}; +use serde::Deserialize; +use sqlx::Row; +use csm_protocol::MessageType; +use crate::AppState; +use crate::api::ApiResponse; +use crate::tcp::push_to_targets; + +#[derive(Debug, Deserialize)] +pub struct CreateBlacklistRequest { + pub name_pattern: String, + pub category: Option, + pub action: Option, + pub target_type: Option, + pub target_id: Option, +} + +pub async fn list_blacklist(State(state): State) -> Json> { + match sqlx::query("SELECT id, name_pattern, category, action, target_type, target_id, enabled, created_at FROM software_blacklist ORDER BY created_at DESC") + .fetch_all(&state.db).await { + Ok(rows) => Json(ApiResponse::ok(serde_json::json!({"blacklist": rows.iter().map(|r| serde_json::json!({ + "id": r.get::("id"), "name_pattern": r.get::("name_pattern"), + "category": r.get::,_>("category"), "action": r.get::("action"), + "target_type": r.get::("target_type"), "target_id": r.get::,_>("target_id"), + "enabled": r.get::("enabled"), "created_at": r.get::("created_at") + })).collect::>()}))), + Err(e) => Json(ApiResponse::internal_error("query software blacklist", e)), + } +} + +pub async fn add_to_blacklist(State(state): State, Json(req): Json) -> (StatusCode, Json>) { + let action = req.action.unwrap_or_else(|| "block".to_string()); + let target_type = req.target_type.unwrap_or_else(|| "global".to_string()); + + // Validate inputs + if req.name_pattern.trim().is_empty() || req.name_pattern.len() > 255 { + return (StatusCode::BAD_REQUEST, Json(ApiResponse::error("name_pattern must be 1-255 chars"))); + } + if !matches!(action.as_str(), "block" | "alert") { + return (StatusCode::BAD_REQUEST, Json(ApiResponse::error("action must be 'block' or 'alert'"))); + } + if let Some(ref cat) = req.category { + if !matches!(cat.as_str(), "game" | "social" | "vpn" | "mining" | "custom") { + return (StatusCode::BAD_REQUEST, Json(ApiResponse::error("invalid category"))); + } + } + if !matches!(target_type.as_str(), "global" | "device" | "group") { + return (StatusCode::BAD_REQUEST, Json(ApiResponse::error("invalid target_type"))); + } + + match sqlx::query("INSERT INTO software_blacklist (name_pattern, category, action, target_type, target_id) VALUES (?,?,?,?,?)") + .bind(&req.name_pattern).bind(&req.category).bind(&action).bind(&target_type).bind(&req.target_id) + .execute(&state.db).await { + Ok(r) => { + let new_id = r.last_insert_rowid(); + let blacklist = fetch_blacklist_for_push(&state.db, &target_type, req.target_id.as_deref()).await; + push_to_targets(&state.db, &state.clients, MessageType::SoftwareBlacklist, &serde_json::json!({"blacklist": blacklist}), &target_type, req.target_id.as_deref()).await; + (StatusCode::CREATED, Json(ApiResponse::ok(serde_json::json!({"id": new_id})))) + } + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::internal_error("add software blacklist entry", e))), + } +} + +#[derive(Debug, Deserialize)] +pub struct UpdateBlacklistRequest { pub name_pattern: Option, pub action: Option, pub enabled: Option } + +pub async fn update_blacklist(State(state): State, Path(id): Path, Json(body): Json) -> Json> { + let existing = sqlx::query("SELECT * FROM software_blacklist WHERE id = ?") + .bind(id) + .fetch_optional(&state.db) + .await; + + let existing = match existing { + Ok(Some(row)) => row, + Ok(None) => return Json(ApiResponse::error("Not found")), + Err(e) => return Json(ApiResponse::internal_error("query software blacklist", e)), + }; + + let name_pattern = body.name_pattern.unwrap_or_else(|| existing.get::("name_pattern")); + let action = body.action.unwrap_or_else(|| existing.get::("action")); + let enabled = body.enabled.unwrap_or_else(|| existing.get::("enabled")); + + let result = sqlx::query("UPDATE software_blacklist SET name_pattern = ?, action = ?, enabled = ? WHERE id = ?") + .bind(&name_pattern) + .bind(&action) + .bind(enabled) + .bind(id) + .execute(&state.db) + .await; + + match result { + Ok(r) if r.rows_affected() > 0 => { + let target_type_val: String = existing.get("target_type"); + let target_id_val: Option = existing.get("target_id"); + let blacklist = fetch_blacklist_for_push(&state.db, &target_type_val, target_id_val.as_deref()).await; + push_to_targets(&state.db, &state.clients, MessageType::SoftwareBlacklist, &serde_json::json!({"blacklist": blacklist}), &target_type_val, target_id_val.as_deref()).await; + Json(ApiResponse::ok(())) + } + Ok(_) => Json(ApiResponse::error("Not found")), + Err(e) => Json(ApiResponse::internal_error("update software blacklist", e)), + } +} + +pub async fn remove_from_blacklist(State(state): State, Path(id): Path) -> Json> { + let existing = sqlx::query("SELECT target_type, target_id FROM software_blacklist WHERE id = ?") + .bind(id).fetch_optional(&state.db).await; + let (target_type, target_id) = match existing { + Ok(Some(row)) => (row.get::("target_type"), row.get::, _>("target_id")), + _ => return Json(ApiResponse::error("Not found")), + }; + match sqlx::query("DELETE FROM software_blacklist WHERE id=?").bind(id).execute(&state.db).await { + Ok(r) if r.rows_affected() > 0 => { + let blacklist = fetch_blacklist_for_push(&state.db, &target_type, target_id.as_deref()).await; + push_to_targets(&state.db, &state.clients, MessageType::SoftwareBlacklist, &serde_json::json!({"blacklist": blacklist}), &target_type, target_id.as_deref()).await; + Json(ApiResponse::ok(())) + } + _ => Json(ApiResponse::error("Not found")), + } +} + +#[derive(Debug, Deserialize)] +pub struct ViolationFilters { pub device_uid: Option } + +pub async fn list_violations(State(state): State, Query(f): Query) -> Json> { + match sqlx::query("SELECT id, device_uid, software_name, action_taken, timestamp FROM software_violations WHERE (? IS NULL OR device_uid=?) ORDER BY timestamp DESC LIMIT 200") + .bind(&f.device_uid).bind(&f.device_uid) + .fetch_all(&state.db).await { + Ok(rows) => Json(ApiResponse::ok(serde_json::json!({"violations": rows.iter().map(|r| serde_json::json!({ + "id": r.get::("id"), "device_uid": r.get::("device_uid"), + "software_name": r.get::("software_name"), "action_taken": r.get::("action_taken"), + "timestamp": r.get::("timestamp") + })).collect::>()}))), + Err(e) => Json(ApiResponse::internal_error("query software violations", e)), + } +} + +async fn fetch_blacklist_for_push( + db: &sqlx::SqlitePool, + target_type: &str, + target_id: Option<&str>, +) -> Vec { + let query = match target_type { + "device" => sqlx::query( + "SELECT id, name_pattern, action FROM software_blacklist WHERE enabled = 1 AND (target_type = 'global' OR (target_type = 'device' AND target_id = ?))" + ).bind(target_id), + _ => sqlx::query( + "SELECT id, name_pattern, action FROM software_blacklist WHERE enabled = 1 AND target_type = 'global'" + ), + }; + query.fetch_all(db).await + .map(|rows| rows.iter().map(|r| serde_json::json!({ + "id": r.get::("id"), "name_pattern": r.get::("name_pattern"), "action": r.get::("action") + })).collect()) + .unwrap_or_default() +} diff --git a/crates/server/src/api/plugins/usage_timer.rs b/crates/server/src/api/plugins/usage_timer.rs new file mode 100644 index 0000000..2a94e00 --- /dev/null +++ b/crates/server/src/api/plugins/usage_timer.rs @@ -0,0 +1,60 @@ +use axum::{extract::{State, Query}, Json}; +use serde::Deserialize; +use sqlx::Row; +use crate::AppState; +use crate::api::ApiResponse; + +#[derive(Debug, Deserialize)] +pub struct DailyFilters { pub device_uid: Option, pub start_date: Option, pub end_date: Option } + +pub async fn list_daily(State(state): State, Query(f): Query) -> Json> { + match sqlx::query( + "SELECT id, device_uid, date, total_active_minutes, total_idle_minutes, first_active_at, last_active_at + FROM usage_daily WHERE (? IS NULL OR device_uid=?) AND (? IS NULL OR date>=?) AND (? IS NULL OR date<=?) + ORDER BY date DESC LIMIT 90") + .bind(&f.device_uid).bind(&f.device_uid) + .bind(&f.start_date).bind(&f.start_date) + .bind(&f.end_date).bind(&f.end_date) + .fetch_all(&state.db).await { + Ok(rows) => Json(ApiResponse::ok(serde_json::json!({"daily": rows.iter().map(|r| serde_json::json!({ + "id": r.get::("id"), "device_uid": r.get::("device_uid"), + "date": r.get::("date"), "total_active_minutes": r.get::("total_active_minutes"), + "total_idle_minutes": r.get::("total_idle_minutes"), + "first_active_at": r.get::,_>("first_active_at"), + "last_active_at": r.get::,_>("last_active_at") + })).collect::>()}))), + Err(e) => Json(ApiResponse::internal_error("query daily usage", e)), + } +} + +#[derive(Debug, Deserialize)] +pub struct AppUsageFilters { pub device_uid: Option, pub date: Option } + +pub async fn list_app_usage(State(state): State, Query(f): Query) -> Json> { + match sqlx::query( + "SELECT id, device_uid, date, app_name, usage_minutes FROM app_usage_daily + WHERE (? IS NULL OR device_uid=?) AND (? IS NULL OR date=?) + ORDER BY usage_minutes DESC LIMIT 100") + .bind(&f.device_uid).bind(&f.device_uid) + .bind(&f.date).bind(&f.date) + .fetch_all(&state.db).await { + Ok(rows) => Json(ApiResponse::ok(serde_json::json!({"app_usage": rows.iter().map(|r| serde_json::json!({ + "id": r.get::("id"), "device_uid": r.get::("device_uid"), + "date": r.get::("date"), "app_name": r.get::("app_name"), + "usage_minutes": r.get::("usage_minutes") + })).collect::>()}))), + Err(e) => Json(ApiResponse::internal_error("query app usage", e)), + } +} + +pub async fn leaderboard(State(state): State) -> Json> { + match sqlx::query( + "SELECT device_uid, SUM(total_active_minutes) as total_minutes FROM usage_daily + WHERE date >= date('now', '-7 days') GROUP BY device_uid ORDER BY total_minutes DESC LIMIT 20") + .fetch_all(&state.db).await { + Ok(rows) => Json(ApiResponse::ok(serde_json::json!({"leaderboard": rows.iter().map(|r| serde_json::json!({ + "device_uid": r.get::("device_uid"), "total_minutes": r.get::("total_minutes") + })).collect::>()}))), + Err(e) => Json(ApiResponse::internal_error("query usage leaderboard", e)), + } +} diff --git a/crates/server/src/api/plugins/usb_file_audit.rs b/crates/server/src/api/plugins/usb_file_audit.rs new file mode 100644 index 0000000..0fae23d --- /dev/null +++ b/crates/server/src/api/plugins/usb_file_audit.rs @@ -0,0 +1,47 @@ +use axum::{extract::{State, Query}, Json}; +use serde::Deserialize; +use sqlx::Row; +use crate::AppState; +use crate::api::ApiResponse; + +#[derive(Debug, Deserialize)] +pub struct LogFilters { + pub device_uid: Option, + pub operation: Option, + pub usb_serial: Option, +} + +pub async fn list_operations(State(state): State, Query(f): Query) -> Json> { + match sqlx::query( + "SELECT id, device_uid, usb_serial, drive_letter, operation, file_path, file_size, timestamp + FROM usb_file_operations WHERE (? IS NULL OR device_uid=?) AND (? IS NULL OR operation=?) AND (? IS NULL OR usb_serial=?) + ORDER BY timestamp DESC LIMIT 200") + .bind(&f.device_uid).bind(&f.device_uid) + .bind(&f.operation).bind(&f.operation) + .bind(&f.usb_serial).bind(&f.usb_serial) + .fetch_all(&state.db).await { + Ok(rows) => Json(ApiResponse::ok(serde_json::json!({"operations": rows.iter().map(|r| serde_json::json!({ + "id": r.get::("id"), "device_uid": r.get::("device_uid"), + "usb_serial": r.get::,_>("usb_serial"), "drive_letter": r.get::,_>("drive_letter"), + "operation": r.get::("operation"), "file_path": r.get::("file_path"), + "file_size": r.get::,_>("file_size"), "timestamp": r.get::("timestamp") + })).collect::>()}))), + Err(e) => Json(ApiResponse::internal_error("query USB file operations", e)), + } +} + +pub async fn summary(State(state): State) -> Json> { + match sqlx::query( + "SELECT device_uid, COUNT(*) as op_count, COUNT(DISTINCT usb_serial) as usb_count, + MIN(timestamp) as first_op, MAX(timestamp) as last_op + FROM usb_file_operations WHERE timestamp >= datetime('now', '-7 days') + GROUP BY device_uid ORDER BY op_count DESC LIMIT 50") + .fetch_all(&state.db).await { + Ok(rows) => Json(ApiResponse::ok(serde_json::json!({"summary": rows.iter().map(|r| serde_json::json!({ + "device_uid": r.get::("device_uid"), "op_count": r.get::("op_count"), + "usb_count": r.get::("usb_count"), "first_op": r.get::("first_op"), + "last_op": r.get::("last_op") + })).collect::>()}))), + Err(e) => Json(ApiResponse::internal_error("query USB file audit summary", e)), + } +} diff --git a/crates/server/src/api/plugins/watermark.rs b/crates/server/src/api/plugins/watermark.rs new file mode 100644 index 0000000..1e54e8e --- /dev/null +++ b/crates/server/src/api/plugins/watermark.rs @@ -0,0 +1,186 @@ +use axum::{extract::{State, Path, Json}, http::StatusCode}; +use serde::Deserialize; +use sqlx::Row; +use csm_protocol::{MessageType, WatermarkConfigPayload}; +use crate::AppState; +use crate::api::ApiResponse; +use crate::tcp::push_to_targets; + +#[derive(Debug, Deserialize)] +pub struct CreateConfigRequest { + pub target_type: Option, + pub target_id: Option, + pub content: Option, + pub font_size: Option, + pub opacity: Option, + pub color: Option, + pub angle: Option, + pub enabled: Option, +} + +pub async fn get_config_list(State(state): State) -> Json> { + match sqlx::query("SELECT id, target_type, target_id, content, font_size, opacity, color, angle, enabled, updated_at FROM watermark_config ORDER BY id") + .fetch_all(&state.db).await { + Ok(rows) => Json(ApiResponse::ok(serde_json::json!({"configs": rows.iter().map(|r| serde_json::json!({ + "id": r.get::("id"), "target_type": r.get::("target_type"), + "target_id": r.get::,_>("target_id"), "content": r.get::("content"), + "font_size": r.get::("font_size"), "opacity": r.get::("opacity"), + "color": r.get::("color"), "angle": r.get::("angle"), + "enabled": r.get::("enabled"), "updated_at": r.get::("updated_at") + })).collect::>()}))), + Err(e) => Json(ApiResponse::internal_error("query watermark configs", e)), + } +} + +pub async fn create_config( + State(state): State, + Json(req): Json, +) -> (StatusCode, Json>) { + let target_type = req.target_type.unwrap_or_else(|| "global".to_string()); + let content = req.content.unwrap_or_else(|| "{company} | {username} | {date}".to_string()); + let font_size = req.font_size.unwrap_or(14).clamp(8, 72) as i32; + let opacity = req.opacity.unwrap_or(0.15).clamp(0.01, 1.0); + let color = req.color.unwrap_or_else(|| "#808080".to_string()); + let angle = req.angle.unwrap_or(-30); + let enabled = req.enabled.unwrap_or(true); + + // Validate inputs + if content.len() > 200 { + return (StatusCode::BAD_REQUEST, Json(ApiResponse::error("content too long (max 200 chars)"))); + } + if !is_valid_hex_color(&color) { + return (StatusCode::BAD_REQUEST, Json(ApiResponse::error("invalid color format (expected #RRGGBB)"))); + } + if !matches!(target_type.as_str(), "global" | "device" | "group") { + return (StatusCode::BAD_REQUEST, Json(ApiResponse::error("invalid target_type"))); + } + + match sqlx::query("INSERT INTO watermark_config (target_type, target_id, content, font_size, opacity, color, angle, enabled) VALUES (?,?,?,?,?,?,?,?)") + .bind(&target_type).bind(&req.target_id).bind(&content).bind(font_size).bind(opacity).bind(&color).bind(angle).bind(enabled) + .execute(&state.db).await { + Ok(r) => { + let new_id = r.last_insert_rowid(); + // Push to online clients + let config = WatermarkConfigPayload { + content: content.clone(), + font_size: font_size as u32, + opacity, + color: color.clone(), + angle, + enabled, + }; + push_to_targets(&state.db, &state.clients, MessageType::WatermarkConfig, &config, &target_type, req.target_id.as_deref()).await; + (StatusCode::CREATED, Json(ApiResponse::ok(serde_json::json!({"id": new_id})))) + } + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::internal_error("create watermark config", e))), + } +} + +#[derive(Debug, Deserialize)] +pub struct UpdateConfigRequest { + pub content: Option, pub font_size: Option, pub opacity: Option, + pub color: Option, pub angle: Option, pub enabled: Option, +} + +pub async fn update_config( + State(state): State, + Path(id): Path, + Json(body): Json, +) -> Json> { + let existing = sqlx::query("SELECT * FROM watermark_config WHERE id = ?") + .bind(id) + .fetch_optional(&state.db) + .await; + + let existing = match existing { + Ok(Some(row)) => row, + Ok(None) => return Json(ApiResponse::error("Not found")), + Err(e) => return Json(ApiResponse::internal_error("query watermark config", e)), + }; + + let content = body.content.unwrap_or_else(|| existing.get::("content")); + let font_size = body.font_size.map(|v| v.clamp(8, 72) as i32).unwrap_or_else(|| existing.get::("font_size")); + let opacity = body.opacity.map(|v| v.clamp(0.01, 1.0)).unwrap_or_else(|| existing.get::("opacity")); + let color = body.color.unwrap_or_else(|| existing.get::("color")); + let angle = body.angle.unwrap_or_else(|| existing.get::("angle")); + let enabled = body.enabled.unwrap_or_else(|| existing.get::("enabled")); + + // Validate inputs + if content.len() > 200 { + return Json(ApiResponse::error("content too long (max 200 chars)")); + } + if !is_valid_hex_color(&color) { + return Json(ApiResponse::error("invalid color format (expected #RRGGBB)")); + } + + let result = sqlx::query( + "UPDATE watermark_config SET content = ?, font_size = ?, opacity = ?, color = ?, angle = ?, enabled = ?, updated_at = datetime('now') WHERE id = ?" + ) + .bind(&content) + .bind(font_size) + .bind(opacity) + .bind(&color) + .bind(angle) + .bind(enabled) + .bind(id) + .execute(&state.db) + .await; + + match result { + Ok(r) if r.rows_affected() > 0 => { + // Push updated config to online clients + let config = WatermarkConfigPayload { + content: content.clone(), + font_size: font_size as u32, + opacity, + color: color.clone(), + angle, + enabled, + }; + let target_type_val: String = existing.get("target_type"); + let target_id_val: Option = existing.get("target_id"); + push_to_targets(&state.db, &state.clients, MessageType::WatermarkConfig, &config, &target_type_val, target_id_val.as_deref()).await; + Json(ApiResponse::ok(())) + } + Ok(_) => Json(ApiResponse::error("Not found")), + Err(e) => Json(ApiResponse::internal_error("update watermark config", e)), + } +} + +pub async fn delete_config(State(state): State, Path(id): Path) -> Json> { + // Fetch existing config to get target info for push + let existing = sqlx::query("SELECT target_type, target_id FROM watermark_config WHERE id = ?") + .bind(id) + .fetch_optional(&state.db) + .await; + + let (target_type, target_id) = match existing { + Ok(Some(row)) => (row.get::("target_type"), row.get::, _>("target_id")), + _ => return Json(ApiResponse::error("Not found")), + }; + + match sqlx::query("DELETE FROM watermark_config WHERE id=?").bind(id).execute(&state.db).await { + Ok(r) if r.rows_affected() > 0 => { + // Push disabled watermark to clients + let disabled = WatermarkConfigPayload { + content: String::new(), + font_size: 0, + opacity: 0.0, + color: String::new(), + angle: 0, + enabled: false, + }; + push_to_targets(&state.db, &state.clients, MessageType::WatermarkConfig, &disabled, &target_type, target_id.as_deref()).await; + Json(ApiResponse::ok(())) + } + _ => Json(ApiResponse::error("Not found")), + } +} + +/// Validate a hex color string (#RRGGBB format) +fn is_valid_hex_color(color: &str) -> bool { + if color.len() != 7 || !color.starts_with('#') { + return false; + } + color[1..].chars().all(|c| c.is_ascii_hexdigit()) +} diff --git a/crates/server/src/api/plugins/web_filter.rs b/crates/server/src/api/plugins/web_filter.rs new file mode 100644 index 0000000..cf8e11f --- /dev/null +++ b/crates/server/src/api/plugins/web_filter.rs @@ -0,0 +1,156 @@ +use axum::{extract::{State, Path, Query, Json}, http::StatusCode}; +use serde::Deserialize; +use sqlx::Row; +use csm_protocol::MessageType; +use crate::AppState; +use crate::api::ApiResponse; +use crate::tcp::push_to_targets; + +#[derive(Debug, Deserialize)] +pub struct RuleFilters { pub rule_type: Option, pub enabled: Option } + +#[derive(Debug, Deserialize)] +pub struct CreateRuleRequest { + pub rule_type: String, + pub pattern: String, + pub target_type: Option, + pub target_id: Option, + pub enabled: Option, +} + +pub async fn list_rules(State(state): State) -> Json> { + match sqlx::query("SELECT id, rule_type, pattern, target_type, target_id, enabled, created_at FROM web_filter_rules ORDER BY created_at DESC") + .fetch_all(&state.db).await { + Ok(rows) => Json(ApiResponse::ok(serde_json::json!({ "rules": rows.iter().map(|r| serde_json::json!({ + "id": r.get::("id"), "rule_type": r.get::("rule_type"), + "pattern": r.get::("pattern"), "target_type": r.get::("target_type"), + "target_id": r.get::,_>("target_id"), "enabled": r.get::("enabled"), + "created_at": r.get::("created_at") + })).collect::>() }))), + Err(e) => Json(ApiResponse::internal_error("query web filter rules", e)), + } +} + +pub async fn create_rule(State(state): State, Json(req): Json) -> (StatusCode, Json>) { + let enabled = req.enabled.unwrap_or(true); + let target_type = req.target_type.unwrap_or_else(|| "global".to_string()); + + // Validate inputs + if !matches!(req.rule_type.as_str(), "blacklist" | "whitelist" | "category") { + return (StatusCode::BAD_REQUEST, Json(ApiResponse::error("invalid rule_type (expected blacklist|whitelist|category)"))); + } + if req.pattern.trim().is_empty() || req.pattern.len() > 255 { + return (StatusCode::BAD_REQUEST, Json(ApiResponse::error("pattern must be 1-255 chars"))); + } + if !matches!(target_type.as_str(), "global" | "device" | "group") { + return (StatusCode::BAD_REQUEST, Json(ApiResponse::error("invalid target_type"))); + } + + match sqlx::query("INSERT INTO web_filter_rules (rule_type, pattern, target_type, target_id, enabled) VALUES (?,?,?,?,?)") + .bind(&req.rule_type).bind(&req.pattern).bind(&target_type).bind(&req.target_id).bind(enabled) + .execute(&state.db).await { + Ok(r) => { + let new_id = r.last_insert_rowid(); + let rules = fetch_rules_for_push(&state.db, &target_type, req.target_id.as_deref()).await; + push_to_targets(&state.db, &state.clients, MessageType::WebFilterRuleUpdate, &serde_json::json!({"rules": rules}), &target_type, req.target_id.as_deref()).await; + (StatusCode::CREATED, Json(ApiResponse::ok(serde_json::json!({"id": new_id})))) + } + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::internal_error("create web filter rule", e))), + } +} + +#[derive(Debug, Deserialize)] +pub struct UpdateRuleRequest { pub rule_type: Option, pub pattern: Option, pub enabled: Option } + +pub async fn update_rule(State(state): State, Path(id): Path, Json(body): Json) -> Json> { + let existing = sqlx::query("SELECT * FROM web_filter_rules WHERE id = ?") + .bind(id) + .fetch_optional(&state.db) + .await; + + let existing = match existing { + Ok(Some(row)) => row, + Ok(None) => return Json(ApiResponse::error("Not found")), + Err(e) => return Json(ApiResponse::internal_error("query web filter rule", e)), + }; + + let rule_type = body.rule_type.unwrap_or_else(|| existing.get::("rule_type")); + let pattern = body.pattern.unwrap_or_else(|| existing.get::("pattern")); + let enabled = body.enabled.unwrap_or_else(|| existing.get::("enabled")); + + let result = sqlx::query("UPDATE web_filter_rules SET rule_type = ?, pattern = ?, enabled = ? WHERE id = ?") + .bind(&rule_type) + .bind(&pattern) + .bind(enabled) + .bind(id) + .execute(&state.db) + .await; + + match result { + Ok(r) if r.rows_affected() > 0 => { + let target_type_val: String = existing.get("target_type"); + let target_id_val: Option = existing.get("target_id"); + let rules = fetch_rules_for_push(&state.db, &target_type_val, target_id_val.as_deref()).await; + push_to_targets(&state.db, &state.clients, MessageType::WebFilterRuleUpdate, &serde_json::json!({"rules": rules}), &target_type_val, target_id_val.as_deref()).await; + Json(ApiResponse::ok(())) + } + Ok(_) => Json(ApiResponse::error("Not found")), + Err(e) => Json(ApiResponse::internal_error("update web filter rule", e)), + } +} + +pub async fn delete_rule(State(state): State, Path(id): Path) -> Json> { + let existing = sqlx::query("SELECT target_type, target_id FROM web_filter_rules WHERE id = ?") + .bind(id).fetch_optional(&state.db).await; + let (target_type, target_id) = match existing { + Ok(Some(row)) => (row.get::("target_type"), row.get::, _>("target_id")), + _ => return Json(ApiResponse::error("Not found")), + }; + match sqlx::query("DELETE FROM web_filter_rules WHERE id=?").bind(id).execute(&state.db).await { + Ok(r) if r.rows_affected() > 0 => { + let rules = fetch_rules_for_push(&state.db, &target_type, target_id.as_deref()).await; + push_to_targets(&state.db, &state.clients, MessageType::WebFilterRuleUpdate, &serde_json::json!({"rules": rules}), &target_type, target_id.as_deref()).await; + Json(ApiResponse::ok(())) + } + _ => Json(ApiResponse::error("Not found")), + } +} + +#[derive(Debug, Deserialize)] +pub struct LogFilters { pub device_uid: Option, pub action: Option } + +pub async fn list_access_log(State(state): State, Query(f): Query) -> Json> { + match sqlx::query("SELECT id, device_uid, url, action, timestamp FROM web_access_log WHERE (? IS NULL OR device_uid=?) AND (? IS NULL OR action=?) ORDER BY timestamp DESC LIMIT 200") + .bind(&f.device_uid).bind(&f.device_uid).bind(&f.action).bind(&f.action) + .fetch_all(&state.db).await { + Ok(rows) => Json(ApiResponse::ok(serde_json::json!({"log": rows.iter().map(|r| serde_json::json!({ + "id": r.get::("id"), "device_uid": r.get::("device_uid"), + "url": r.get::("url"), "action": r.get::("action"), + "timestamp": r.get::("timestamp") + })).collect::>() }))), + Err(e) => Json(ApiResponse::internal_error("query web access log", e)), + } +} + +/// Fetch enabled web filter rules applicable to a given target scope. +/// For "device" targets, includes both global rules and device-specific rules +/// (matching the logic used during registration push in tcp.rs). +async fn fetch_rules_for_push( + db: &sqlx::SqlitePool, + target_type: &str, + target_id: Option<&str>, +) -> Vec { + let query = match target_type { + "device" => sqlx::query( + "SELECT id, rule_type, pattern FROM web_filter_rules WHERE enabled = 1 AND (target_type = 'global' OR (target_type = 'device' AND target_id = ?))" + ).bind(target_id), + _ => sqlx::query( + "SELECT id, rule_type, pattern FROM web_filter_rules WHERE enabled = 1 AND target_type = 'global'" + ), + }; + query.fetch_all(db).await + .map(|rows| rows.iter().map(|r| serde_json::json!({ + "id": r.get::("id"), "rule_type": r.get::("rule_type"), "pattern": r.get::("pattern") + })).collect()) + .unwrap_or_default() +} diff --git a/crates/server/src/api/usb.rs b/crates/server/src/api/usb.rs new file mode 100644 index 0000000..9c0ae9f --- /dev/null +++ b/crates/server/src/api/usb.rs @@ -0,0 +1,246 @@ +use axum::{extract::{State, Path, Query, Json}, http::StatusCode}; +use serde::Deserialize; +use sqlx::Row; + +use crate::AppState; +use super::ApiResponse; +use crate::tcp::push_to_targets; +use csm_protocol::{MessageType, UsbPolicyPayload, UsbDeviceRule}; + +#[derive(Debug, Deserialize)] +pub struct UsbEventListParams { + pub device_uid: Option, + pub event_type: Option, + pub page: Option, + pub page_size: Option, +} + +pub async fn list_events( + State(state): State, + Query(params): Query, +) -> Json> { + let limit = params.page_size.unwrap_or(20).min(100); + let offset = params.page.unwrap_or(1).saturating_sub(1) * limit; + + // Normalize empty strings to None + let device_uid = params.device_uid.as_deref().filter(|s| !s.is_empty()).map(String::from); + let event_type = params.event_type.as_deref().filter(|s| !s.is_empty()).map(String::from); + + let rows = sqlx::query( + "SELECT id, device_uid, vendor_id, product_id, serial_number, device_name, event_type, event_time + FROM usb_events WHERE 1=1 + AND (? IS NULL OR device_uid = ?) + AND (? IS NULL OR event_type = ?) + ORDER BY event_time DESC LIMIT ? OFFSET ?" + ) + .bind(&device_uid).bind(&device_uid) + .bind(&event_type).bind(&event_type) + .bind(limit).bind(offset) + .fetch_all(&state.db) + .await; + + match rows { + Ok(records) => { + let items: Vec = records.iter().map(|r| serde_json::json!({ + "id": r.get::("id"), + "device_uid": r.get::("device_uid"), + "vendor_id": r.get::, _>("vendor_id"), + "product_id": r.get::, _>("product_id"), + "serial_number": r.get::, _>("serial_number"), + "device_name": r.get::, _>("device_name"), + "event_type": r.get::("event_type"), + "event_time": r.get::("event_time"), + })).collect(); + Json(ApiResponse::ok(serde_json::json!({ + "events": items, + "page": params.page.unwrap_or(1), + "page_size": limit, + }))) + } + Err(e) => Json(ApiResponse::internal_error("query usb events", e)), + } +} + +pub async fn list_policies( + State(state): State, +) -> Json> { + let rows = sqlx::query( + "SELECT id, name, policy_type, target_group, rules, enabled, created_at, updated_at + FROM usb_policies ORDER BY created_at DESC" + ) + .fetch_all(&state.db) + .await; + + match rows { + Ok(records) => { + let items: Vec = records.iter().map(|r| serde_json::json!({ + "id": r.get::("id"), + "name": r.get::("name"), + "policy_type": r.get::("policy_type"), + "target_group": r.get::, _>("target_group"), + "rules": r.get::("rules"), + "enabled": r.get::("enabled"), + "created_at": r.get::("created_at"), + "updated_at": r.get::("updated_at"), + })).collect(); + Json(ApiResponse::ok(serde_json::json!({ + "policies": items, + }))) + } + Err(e) => Json(ApiResponse::internal_error("query usb policies", e)), + } +} + +#[derive(Debug, Deserialize)] +pub struct CreatePolicyRequest { + pub name: String, + pub policy_type: String, + pub target_group: Option, + pub rules: String, + pub enabled: Option, +} + +pub async fn create_policy( + State(state): State, + Json(body): Json, +) -> (StatusCode, Json>) { + let enabled = body.enabled.unwrap_or(1); + + let result = sqlx::query( + "INSERT INTO usb_policies (name, policy_type, target_group, rules, enabled) VALUES (?, ?, ?, ?, ?)" + ) + .bind(&body.name) + .bind(&body.policy_type) + .bind(&body.target_group) + .bind(&body.rules) + .bind(enabled) + .execute(&state.db) + .await; + + match result { + Ok(r) => { + let new_id = r.last_insert_rowid(); + // Push USB policy to matching online clients + if enabled == 1 { + let payload = build_usb_policy_payload(&body.policy_type, true, &body.rules); + let target_group = body.target_group.as_deref(); + push_to_targets(&state.db, &state.clients, MessageType::UsbPolicyUpdate, &payload, "group", target_group).await; + } + (StatusCode::CREATED, Json(ApiResponse::ok(serde_json::json!({ + "id": new_id, + })))) + } + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiResponse::internal_error("create usb policy", e))), + } +} + +#[derive(Debug, Deserialize)] +pub struct UpdatePolicyRequest { + pub name: Option, + pub policy_type: Option, + pub target_group: Option, + pub rules: Option, + pub enabled: Option, +} + +pub async fn update_policy( + State(state): State, + Path(id): Path, + Json(body): Json, +) -> Json> { + // Fetch existing policy + let existing = sqlx::query("SELECT * FROM usb_policies WHERE id = ?") + .bind(id) + .fetch_optional(&state.db) + .await; + + let existing = match existing { + Ok(Some(row)) => row, + Ok(None) => return Json(ApiResponse::error("Policy not found")), + Err(e) => return Json(ApiResponse::internal_error("query usb policy", e)), + }; + + let name = body.name.unwrap_or_else(|| existing.get::("name")); + let policy_type = body.policy_type.unwrap_or_else(|| existing.get::("policy_type")); + let target_group = body.target_group.or_else(|| existing.get::, _>("target_group")); + let rules = body.rules.unwrap_or_else(|| existing.get::("rules")); + let enabled = body.enabled.unwrap_or_else(|| existing.get::("enabled")); + + let result = sqlx::query( + "UPDATE usb_policies SET name = ?, policy_type = ?, target_group = ?, rules = ?, enabled = ?, updated_at = datetime('now') WHERE id = ?" + ) + .bind(&name) + .bind(&policy_type) + .bind(&target_group) + .bind(&rules) + .bind(enabled) + .bind(id) + .execute(&state.db) + .await; + + match result { + Ok(_) => { + // Push updated USB policy to matching online clients + let payload = build_usb_policy_payload(&policy_type, enabled == 1, &rules); + let target_group = target_group.as_deref(); + push_to_targets(&state.db, &state.clients, MessageType::UsbPolicyUpdate, &payload, "group", target_group).await; + Json(ApiResponse::ok(serde_json::json!({"updated": true}))) + } + Err(e) => Json(ApiResponse::internal_error("update usb policy", e)), + } +} + +pub async fn delete_policy( + State(state): State, + Path(id): Path, +) -> Json> { + // Fetch existing policy to get target info for push + let existing = sqlx::query("SELECT target_group FROM usb_policies WHERE id = ?") + .bind(id) + .fetch_optional(&state.db) + .await; + + let target_group = match existing { + Ok(Some(row)) => row.get::, _>("target_group"), + _ => return Json(ApiResponse::error("Policy not found")), + }; + + let result = sqlx::query("DELETE FROM usb_policies WHERE id = ?") + .bind(id) + .execute(&state.db) + .await; + + match result { + Ok(r) => { + if r.rows_affected() > 0 { + // Push disabled policy to clients + let disabled = UsbPolicyPayload { + policy_type: String::new(), + enabled: false, + rules: vec![], + }; + push_to_targets(&state.db, &state.clients, MessageType::UsbPolicyUpdate, &disabled, "group", target_group.as_deref()).await; + Json(ApiResponse::ok(serde_json::json!({"deleted": true}))) + } else { + Json(ApiResponse::error("Policy not found")) + } + } + Err(e) => Json(ApiResponse::internal_error("delete usb policy", e)), + } +} + +/// Build a UsbPolicyPayload from raw policy fields +fn build_usb_policy_payload(policy_type: &str, enabled: bool, rules_json: &str) -> UsbPolicyPayload { + let raw_rules: Vec = serde_json::from_str(rules_json).unwrap_or_default(); + let rules: Vec = raw_rules.iter().map(|r| UsbDeviceRule { + vendor_id: r.get("vendor_id").and_then(|v| v.as_str().map(String::from)), + product_id: r.get("product_id").and_then(|v| v.as_str().map(String::from)), + serial: r.get("serial").and_then(|v| v.as_str().map(String::from)), + device_name: r.get("device_name").and_then(|v| v.as_str().map(String::from)), + }).collect(); + UsbPolicyPayload { + policy_type: policy_type.to_string(), + enabled, + rules, + } +} diff --git a/crates/server/src/audit.rs b/crates/server/src/audit.rs new file mode 100644 index 0000000..5af4d8c --- /dev/null +++ b/crates/server/src/audit.rs @@ -0,0 +1,28 @@ +use sqlx::SqlitePool; +use tracing::debug; + +/// Record an admin audit log entry. +pub async fn audit_log( + db: &SqlitePool, + user_id: i64, + action: &str, + target_type: Option<&str>, + target_id: Option<&str>, + detail: Option<&str>, +) { + let result = sqlx::query( + "INSERT INTO admin_audit_log (user_id, action, target_type, target_id, detail) VALUES (?, ?, ?, ?, ?)" + ) + .bind(user_id) + .bind(action) + .bind(target_type) + .bind(target_id) + .bind(detail) + .execute(db) + .await; + + match result { + Ok(_) => debug!("Audit: user={} action={} target={}/{}", user_id, action, target_type.unwrap_or("-"), target_id.unwrap_or("-")), + Err(e) => tracing::warn!("Failed to write audit log: {}", e), + } +} diff --git a/crates/server/src/config.rs b/crates/server/src/config.rs new file mode 100644 index 0000000..6d80bb6 --- /dev/null +++ b/crates/server/src/config.rs @@ -0,0 +1,134 @@ +use anyhow::Result; +use serde::{Deserialize, Serialize}; +use std::path::Path; + +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct AppConfig { + pub server: ServerConfig, + pub database: DatabaseConfig, + pub auth: AuthConfig, + pub retention: RetentionConfig, + #[serde(default)] + pub notify: NotifyConfig, + /// Token required for device registration. Empty = any token accepted. + #[serde(default)] + pub registration_token: String, +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct ServerConfig { + pub http_addr: String, + pub tcp_addr: String, + /// Allowed CORS origins. Empty = same-origin only (no CORS headers). + #[serde(default)] + pub cors_origins: Vec, + /// Optional TLS configuration for the TCP listener. + #[serde(default)] + pub tls: Option, +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct TlsConfig { + /// Path to the server certificate (PEM format) + pub cert_path: String, + /// Path to the server private key (PEM format) + pub key_path: String, +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct DatabaseConfig { + pub path: String, +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct AuthConfig { + pub jwt_secret: String, + #[serde(default = "default_access_ttl")] + pub access_token_ttl_secs: u64, + #[serde(default = "default_refresh_ttl")] + pub refresh_token_ttl_secs: u64, +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct RetentionConfig { + #[serde(default = "default_status_history_days")] + pub status_history_days: u32, + #[serde(default = "default_usb_events_days")] + pub usb_events_days: u32, + #[serde(default = "default_asset_changes_days")] + pub asset_changes_days: u32, + #[serde(default = "default_alert_records_days")] + pub alert_records_days: u32, + #[serde(default = "default_audit_log_days")] + pub audit_log_days: u32, +} + +#[derive(Debug, Deserialize, Serialize, Clone, Default)] +pub struct NotifyConfig { + #[serde(default)] + pub smtp: Option, + #[serde(default)] + pub webhook_urls: Vec, +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct SmtpConfig { + pub host: String, + pub port: u16, + pub username: String, + pub password: String, + pub from: String, +} + +impl AppConfig { + pub async fn load(path: &str) -> Result { + if Path::new(path).exists() { + let content = tokio::fs::read_to_string(path).await?; + let config: AppConfig = toml::from_str(&content)?; + Ok(config) + } else { + let config = default_config(); + // Write default config for reference + let toml_str = toml::to_string_pretty(&config)?; + tokio::fs::write(path, &toml_str).await?; + tracing::warn!("Created default config at {}", path); + Ok(config) + } + } +} + +fn default_access_ttl() -> u64 { 1800 } // 30 minutes +fn default_refresh_ttl() -> u64 { 604800 } // 7 days +fn default_status_history_days() -> u32 { 7 } +fn default_usb_events_days() -> u32 { 90 } +fn default_asset_changes_days() -> u32 { 365 } +fn default_alert_records_days() -> u32 { 90 } +fn default_audit_log_days() -> u32 { 365 } + +pub fn default_config() -> AppConfig { + AppConfig { + server: ServerConfig { + http_addr: "0.0.0.0:8080".into(), + tcp_addr: "0.0.0.0:9999".into(), + cors_origins: vec![], + tls: None, + }, + database: DatabaseConfig { + path: "./csm.db".into(), + }, + auth: AuthConfig { + jwt_secret: uuid::Uuid::new_v4().to_string(), + access_token_ttl_secs: default_access_ttl(), + refresh_token_ttl_secs: default_refresh_ttl(), + }, + retention: RetentionConfig { + status_history_days: default_status_history_days(), + usb_events_days: default_usb_events_days(), + asset_changes_days: default_asset_changes_days(), + alert_records_days: default_alert_records_days(), + audit_log_days: default_audit_log_days(), + }, + notify: NotifyConfig::default(), + registration_token: uuid::Uuid::new_v4().to_string(), + } +} diff --git a/crates/server/src/db.rs b/crates/server/src/db.rs new file mode 100644 index 0000000..df8b073 --- /dev/null +++ b/crates/server/src/db.rs @@ -0,0 +1,118 @@ +use sqlx::SqlitePool; +use anyhow::Result; + +/// Database repository for device operations +pub struct DeviceRepo; + +impl DeviceRepo { + pub async fn upsert_status(pool: &SqlitePool, device_uid: &str, status: &csm_protocol::DeviceStatus) -> Result<()> { + let top_procs_json = serde_json::to_string(&status.top_processes)?; + + // Update latest snapshot + sqlx::query( + "INSERT INTO device_status (device_uid, cpu_usage, memory_usage, memory_total_mb, disk_usage, disk_total_mb, network_rx_rate, network_tx_rate, running_procs, top_processes, reported_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now')) + ON CONFLICT(device_uid) DO UPDATE SET + cpu_usage = excluded.cpu_usage, + memory_usage = excluded.memory_usage, + memory_total_mb = excluded.memory_total_mb, + disk_usage = excluded.disk_usage, + disk_total_mb = excluded.disk_total_mb, + network_rx_rate = excluded.network_rx_rate, + network_tx_rate = excluded.network_tx_rate, + running_procs = excluded.running_procs, + top_processes = excluded.top_processes, + reported_at = datetime('now'), + updated_at = datetime('now')" + ) + .bind(device_uid) + .bind(status.cpu_usage) + .bind(status.memory_usage) + .bind(status.memory_total_mb as i64) + .bind(status.disk_usage) + .bind(status.disk_total_mb as i64) + .bind(status.network_rx_rate as i64) + .bind(status.network_tx_rate as i64) + .bind(status.running_procs as i32) + .bind(&top_procs_json) + .execute(pool) + .await?; + + // Insert into history + sqlx::query( + "INSERT INTO device_status_history (device_uid, cpu_usage, memory_usage, disk_usage, network_rx_rate, network_tx_rate, running_procs, reported_at) + VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now'))" + ) + .bind(device_uid) + .bind(status.cpu_usage) + .bind(status.memory_usage) + .bind(status.disk_usage) + .bind(status.network_rx_rate as i64) + .bind(status.network_tx_rate as i64) + .bind(status.running_procs as i32) + .execute(pool) + .await?; + + // Update device heartbeat + sqlx::query( + "UPDATE devices SET status = 'online', last_heartbeat = datetime('now') WHERE device_uid = ?" + ) + .bind(device_uid) + .execute(pool) + .await?; + + Ok(()) + } + + pub async fn insert_usb_event(pool: &SqlitePool, event: &csm_protocol::UsbEvent) -> Result { + let result = sqlx::query( + "INSERT INTO usb_events (device_uid, vendor_id, product_id, serial_number, device_name, event_type) + VALUES (?, ?, ?, ?, ?, ?)" + ) + .bind(&event.device_uid) + .bind(&event.vendor_id) + .bind(&event.product_id) + .bind(&event.serial) + .bind(&event.device_name) + .bind(match event.event_type { + csm_protocol::UsbEventType::Inserted => "inserted", + csm_protocol::UsbEventType::Removed => "removed", + csm_protocol::UsbEventType::Blocked => "blocked", + }) + .execute(pool) + .await?; + + Ok(result.last_insert_rowid()) + } + + pub async fn upsert_hardware(pool: &SqlitePool, asset: &csm_protocol::HardwareAsset) -> Result<()> { + sqlx::query( + "INSERT INTO hardware_assets (device_uid, cpu_model, cpu_cores, memory_total_mb, disk_model, disk_total_mb, gpu_model, motherboard, serial_number, reported_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now')) + ON CONFLICT(device_uid) DO UPDATE SET + cpu_model = excluded.cpu_model, + cpu_cores = excluded.cpu_cores, + memory_total_mb = excluded.memory_total_mb, + disk_model = excluded.disk_model, + disk_total_mb = excluded.disk_total_mb, + gpu_model = excluded.gpu_model, + motherboard = excluded.motherboard, + serial_number = excluded.serial_number, + reported_at = datetime('now'), + updated_at = datetime('now')" + ) + .bind(&asset.device_uid) + .bind(&asset.cpu_model) + .bind(asset.cpu_cores as i32) + .bind(asset.memory_total_mb as i64) + .bind(&asset.disk_model) + .bind(asset.disk_total_mb as i64) + .bind(&asset.gpu_model) + .bind(&asset.motherboard) + .bind(&asset.serial_number) + .execute(pool) + .await?; + + Ok(()) + } +} diff --git a/crates/server/src/main.rs b/crates/server/src/main.rs new file mode 100644 index 0000000..b7974fd --- /dev/null +++ b/crates/server/src/main.rs @@ -0,0 +1,264 @@ +use anyhow::Result; +use axum::Router; +use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions, SqliteJournalMode}; +use std::path::Path; +use std::str::FromStr; +use std::sync::Arc; +use tokio::net::TcpListener; +use tower_http::cors::{CorsLayer, Any}; +use tower_http::trace::TraceLayer; +use tower_http::compression::CompressionLayer; +use tower_http::set_header::SetResponseHeaderLayer; +use tracing::{info, warn, error}; + +mod api; +mod audit; +mod config; +mod db; +mod tcp; +mod ws; +mod alert; + +use config::AppConfig; + +/// Application shared state +#[derive(Clone)] +pub struct AppState { + pub db: sqlx::SqlitePool, + pub config: Arc, + pub clients: Arc, + pub ws_hub: Arc, + pub login_limiter: Arc, +} + +#[tokio::main] +async fn main() -> Result<()> { + // Initialize tracing + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "csm_server=info,tower_http=info".into()), + ) + .json() + .init(); + + info!("CSM Server starting..."); + + // Load configuration + let config = AppConfig::load("config.toml").await?; + let config = Arc::new(config); + + // Initialize database + let db = init_database(&config.database.path).await?; + run_migrations(&db).await?; + info!("Database initialized at {}", config.database.path); + + // Ensure default admin exists + ensure_default_admin(&db).await?; + + // Initialize shared state + let clients = Arc::new(tcp::ClientRegistry::new()); + let ws_hub = Arc::new(ws::WsHub::new()); + + let state = AppState { + db: db.clone(), + config: config.clone(), + clients: clients.clone(), + ws_hub: ws_hub.clone(), + login_limiter: Arc::new(api::auth::LoginRateLimiter::new()), + }; + + // Start background tasks + let cleanup_state = state.clone(); + tokio::spawn(async move { + alert::cleanup_task(cleanup_state).await; + }); + + // Start TCP listener for client connections + let tcp_state = state.clone(); + let tcp_addr = config.server.tcp_addr.clone(); + tokio::spawn(async move { + if let Err(e) = tcp::start_tcp_server(tcp_addr, tcp_state).await { + error!("TCP server error: {}", e); + } + }); + + // Build HTTP router + let app = Router::new() + .merge(api::routes(state.clone())) + .layer( + build_cors_layer(&config.server.cors_origins), + ) + .layer(TraceLayer::new_for_http()) + .layer(CompressionLayer::new()) + // Security headers + .layer(SetResponseHeaderLayer::if_not_present( + axum::http::header::X_CONTENT_TYPE_OPTIONS, + axum::http::HeaderValue::from_static("nosniff"), + )) + .layer(SetResponseHeaderLayer::if_not_present( + axum::http::header::X_FRAME_OPTIONS, + axum::http::HeaderValue::from_static("DENY"), + )) + .layer(SetResponseHeaderLayer::if_not_present( + axum::http::header::HeaderName::from_static("x-xss-protection"), + axum::http::HeaderValue::from_static("1; mode=block"), + )) + .layer(SetResponseHeaderLayer::if_not_present( + axum::http::header::HeaderName::from_static("referrer-policy"), + axum::http::HeaderValue::from_static("strict-origin-when-cross-origin"), + )) + .layer(SetResponseHeaderLayer::if_not_present( + axum::http::header::HeaderName::from_static("content-security-policy"), + axum::http::HeaderValue::from_static("default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' ws: wss:"), + )) + .with_state(state); + + // Start HTTP server + let http_addr = &config.server.http_addr; + info!("HTTP server listening on {}", http_addr); + let listener = TcpListener::bind(http_addr).await?; + axum::serve(listener, app).await?; + + Ok(()) +} + +async fn init_database(db_path: &str) -> Result { + // Ensure parent directory exists for file-based databases + // Strip sqlite: prefix if present for directory creation + let file_path = db_path.strip_prefix("sqlite:").unwrap_or(db_path); + // Strip query parameters + let file_path = file_path.split('?').next().unwrap_or(file_path); + if let Some(parent) = Path::new(file_path).parent() { + if !parent.as_os_str().is_empty() { + tokio::fs::create_dir_all(parent).await?; + } + } + + let options = SqliteConnectOptions::from_str(db_path)? + .journal_mode(SqliteJournalMode::Wal) + .synchronous(sqlx::sqlite::SqliteSynchronous::Normal) + .busy_timeout(std::time::Duration::from_secs(5)) + .foreign_keys(true); + + let pool = SqlitePoolOptions::new() + .max_connections(8) + .connect_with(options) + .await?; + + // Set pragmas on each connection + sqlx::query("PRAGMA cache_size = -64000") + .execute(&pool) + .await?; + sqlx::query("PRAGMA wal_autocheckpoint = 1000") + .execute(&pool) + .await?; + + Ok(pool) +} + +async fn run_migrations(pool: &sqlx::SqlitePool) -> Result<()> { + // Embedded migrations - run in order + let migrations = [ + include_str!("../../../migrations/001_init.sql"), + include_str!("../../../migrations/002_assets.sql"), + include_str!("../../../migrations/003_usb.sql"), + include_str!("../../../migrations/004_alerts.sql"), + include_str!("../../../migrations/005_plugins_web_filter.sql"), + include_str!("../../../migrations/006_plugins_usage_timer.sql"), + include_str!("../../../migrations/007_plugins_software_blocker.sql"), + include_str!("../../../migrations/008_plugins_popup_blocker.sql"), + include_str!("../../../migrations/009_plugins_usb_file_audit.sql"), + include_str!("../../../migrations/010_plugins_watermark.sql"), + include_str!("../../../migrations/011_token_security.sql"), + ]; + + // Create migrations tracking table + sqlx::query( + "CREATE TABLE IF NOT EXISTS _migrations (id INTEGER PRIMARY KEY, name TEXT NOT NULL UNIQUE, applied_at TEXT NOT NULL DEFAULT (datetime('now')))" + ) + .execute(pool) + .await?; + + for (i, migration_sql) in migrations.iter().enumerate() { + let name = format!("{:03}", i + 1); + let exists: bool = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM _migrations WHERE name = ?" + ) + .bind(&name) + .fetch_one(pool) + .await? > 0; + + if !exists { + info!("Running migration: {}", name); + sqlx::query(migration_sql) + .execute(pool) + .await?; + sqlx::query("INSERT INTO _migrations (name) VALUES (?)") + .bind(&name) + .execute(pool) + .await?; + } + } + + Ok(()) +} + +async fn ensure_default_admin(pool: &sqlx::SqlitePool) -> Result<()> { + let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM users") + .fetch_one(pool) + .await?; + + if count == 0 { + // Generate a random 16-character alphanumeric password + let random_password: String = { + use std::fmt::Write; + let bytes = uuid::Uuid::new_v4(); + let mut s = String::with_capacity(16); + for byte in bytes.as_bytes().iter().take(16) { + write!(s, "{:02x}", byte).unwrap(); + } + s + }; + + let password_hash = bcrypt::hash(&random_password, 12)?; + sqlx::query( + "INSERT INTO users (username, password, role) VALUES (?, ?, 'admin')" + ) + .bind("admin") + .bind(&password_hash) + .execute(pool) + .await?; + + warn!("Created default admin user (username: admin)"); + // Print password directly to stderr — bypasses tracing JSON formatter + eprintln!("============================================================"); + eprintln!(" Generated admin password: {}", random_password); + eprintln!(" *** Save this password now — it will NOT be shown again! ***"); + eprintln!("============================================================"); + } + + Ok(()) +} + +/// Build CORS layer from configured origins. +/// If cors_origins is empty, no CORS headers are sent (same-origin only). +/// If origins are specified, only those are allowed. +fn build_cors_layer(origins: &[String]) -> CorsLayer { + use axum::http::HeaderValue; + + let allowed_origins: Vec = origins.iter() + .filter_map(|o| o.parse::().ok()) + .collect(); + + if allowed_origins.is_empty() { + // No CORS — production safe by default + CorsLayer::new() + } else { + CorsLayer::new() + .allow_origin(tower_http::cors::AllowOrigin::list(allowed_origins)) + .allow_methods(Any) + .allow_headers(Any) + .max_age(std::time::Duration::from_secs(3600)) + } +} diff --git a/crates/server/src/tcp.rs b/crates/server/src/tcp.rs new file mode 100644 index 0000000..3607ca5 --- /dev/null +++ b/crates/server/src/tcp.rs @@ -0,0 +1,844 @@ +use std::collections::HashMap; +use std::sync::Arc; +use std::time::Instant; +use tokio::sync::RwLock; +use tokio::net::{TcpListener, TcpStream}; +use tracing::{info, warn, debug}; +use hmac::{Hmac, Mac}; +use sha2::Sha256; +use csm_protocol::{Frame, MessageType, PROTOCOL_VERSION}; +use crate::AppState; + +/// Maximum frames per second per connection before rate-limiting kicks in +const RATE_LIMIT_WINDOW_SECS: u64 = 5; +const RATE_LIMIT_MAX_FRAMES: usize = 100; + +/// Per-connection rate limiter using a sliding window of frame timestamps +struct RateLimiter { + timestamps: Vec, +} + +impl RateLimiter { + fn new() -> Self { + Self { timestamps: Vec::with_capacity(RATE_LIMIT_MAX_FRAMES) } + } + + /// Returns false if the connection is rate-limited + fn check(&mut self) -> bool { + let now = Instant::now(); + let cutoff = now - std::time::Duration::from_secs(RATE_LIMIT_WINDOW_SECS); + + // Evict timestamps outside the window + self.timestamps.retain(|t| *t > cutoff); + + if self.timestamps.len() >= RATE_LIMIT_MAX_FRAMES { + return false; + } + + self.timestamps.push(now); + true + } +} + +/// Push a plugin config frame to all online clients matching the target scope. +/// target_type: "global" | "device" | "group" +/// target_id: device_uid or group_name (None for global) +pub async fn push_to_targets( + db: &sqlx::SqlitePool, + clients: &crate::tcp::ClientRegistry, + msg_type: MessageType, + payload: &impl serde::Serialize, + target_type: &str, + target_id: Option<&str>, +) { + let frame = match Frame::new_json(msg_type, payload) { + Ok(f) => f.encode(), + Err(e) => { + warn!("Failed to encode plugin push frame: {}", e); + return; + } + }; + + let online = clients.list_online().await; + let mut pushed_count = 0usize; + + // For group targeting, resolve group members from DB once + let group_members: Option> = if target_type == "group" { + if let Some(group_name) = target_id { + sqlx::query_scalar::<_, String>( + "SELECT device_uid FROM devices WHERE group_name = ?" + ) + .bind(group_name) + .fetch_all(db) + .await + .ok() + .into() + } else { + None + } + } else { + None + }; + + for uid in &online { + let should_push = match target_type { + "global" => true, + "device" => target_id.map_or(false, |id| id == uid), + "group" => { + if let Some(members) = &group_members { + members.contains(uid) + } else { + false + } + } + other => { + warn!("Unknown target_type '{}', skipping push", other); + false + } + }; + if should_push { + if clients.send_to(uid, frame.clone()).await { + pushed_count += 1; + } + } + } + debug!("Pushed {:?} to {}/{} online clients (target={})", msg_type, pushed_count, online.len(), target_type); +} + +/// Push all active plugin configs to a newly registered client. +pub async fn push_all_plugin_configs( + db: &sqlx::SqlitePool, + clients: &crate::tcp::ClientRegistry, + device_uid: &str, +) { + use sqlx::Row; + + // Watermark configs — only push the highest-priority enabled config (device > group > global) + if let Ok(rows) = sqlx::query( + "SELECT content, font_size, opacity, color, angle, enabled FROM watermark_config WHERE enabled = 1 AND (target_type = 'global' OR (target_type = 'device' AND target_id = ?) OR (target_type = 'group' AND target_id = (SELECT group_name FROM devices WHERE device_uid = ?))) ORDER BY CASE WHEN target_type = 'device' THEN 0 WHEN target_type = 'group' THEN 1 ELSE 2 END LIMIT 1" + ) + .bind(device_uid) + .bind(device_uid) + .bind(device_uid) + .fetch_all(db).await + { + if let Some(row) = rows.first() { + let config = csm_protocol::WatermarkConfigPayload { + content: row.get("content"), + font_size: row.get::("font_size") as u32, + opacity: row.get("opacity"), + color: row.get("color"), + angle: row.get::("angle"), + enabled: row.get("enabled"), + }; + if let Ok(frame) = Frame::new_json(MessageType::WatermarkConfig, &config) { + clients.send_to(device_uid, frame.encode()).await; + } + } + } + + // Web filter rules + if let Ok(rows) = sqlx::query( + "SELECT id, rule_type, pattern FROM web_filter_rules WHERE enabled = 1 AND (target_type = 'global' OR (target_type = 'device' AND target_id = ?) OR (target_type = 'group' AND target_id = (SELECT group_name FROM devices WHERE device_uid = ?)))" + ) + .bind(device_uid) + .bind(device_uid) + .fetch_all(db).await + { + let rules: Vec = rows.iter().map(|r| serde_json::json!({ + "id": r.get::("id"), + "rule_type": r.get::("rule_type"), + "pattern": r.get::("pattern"), + })).collect(); + if !rules.is_empty() { + if let Ok(frame) = Frame::new_json(MessageType::WebFilterRuleUpdate, &serde_json::json!({"rules": rules})) { + clients.send_to(device_uid, frame.encode()).await; + } + } + } + + // Software blacklist + if let Ok(rows) = sqlx::query( + "SELECT id, name_pattern, action FROM software_blacklist WHERE enabled = 1 AND (target_type = 'global' OR (target_type = 'device' AND target_id = ?) OR (target_type = 'group' AND target_id = (SELECT group_name FROM devices WHERE device_uid = ?)))" + ) + .bind(device_uid) + .bind(device_uid) + .fetch_all(db).await + { + let entries: Vec = rows.iter().map(|r| serde_json::json!({ + "id": r.get::("id"), + "name_pattern": r.get::("name_pattern"), + "action": r.get::("action"), + })).collect(); + if !entries.is_empty() { + if let Ok(frame) = Frame::new_json(MessageType::SoftwareBlacklist, &serde_json::json!({"blacklist": entries})) { + clients.send_to(device_uid, frame.encode()).await; + } + } + } + + // Popup blocker rules + if let Ok(rows) = sqlx::query( + "SELECT id, rule_type, window_title, window_class, process_name FROM popup_filter_rules WHERE enabled = 1 AND (target_type = 'global' OR (target_type = 'device' AND target_id = ?) OR (target_type = 'group' AND target_id = (SELECT group_name FROM devices WHERE device_uid = ?)))" + ) + .bind(device_uid) + .bind(device_uid) + .fetch_all(db).await + { + let rules: Vec = rows.iter().map(|r| serde_json::json!({ + "id": r.get::("id"), + "rule_type": r.get::("rule_type"), + "window_title": r.get::, _>("window_title"), + "window_class": r.get::, _>("window_class"), + "process_name": r.get::, _>("process_name"), + })).collect(); + if !rules.is_empty() { + if let Ok(frame) = Frame::new_json(MessageType::PopupRules, &serde_json::json!({"rules": rules})) { + clients.send_to(device_uid, frame.encode()).await; + } + } + } + + // USB policies — push highest-priority enabled policy for the device's group + if let Ok(rows) = sqlx::query( + "SELECT policy_type, rules, enabled FROM usb_policies WHERE enabled = 1 AND target_group = (SELECT group_name FROM devices WHERE device_uid = ?) ORDER BY CASE WHEN policy_type = 'all_block' THEN 0 WHEN policy_type = 'blacklist' THEN 1 ELSE 2 END LIMIT 1" + ) + .bind(device_uid) + .fetch_all(db).await + { + if let Some(row) = rows.first() { + let policy_type: String = row.get("policy_type"); + let rules_json: String = row.get("rules"); + let rules: Vec = serde_json::from_str(&rules_json).unwrap_or_default(); + let payload = csm_protocol::UsbPolicyPayload { + policy_type, + enabled: true, + rules: rules.iter().map(|r| csm_protocol::UsbDeviceRule { + vendor_id: r.get("vendor_id").and_then(|v| v.as_str().map(String::from)), + product_id: r.get("product_id").and_then(|v| v.as_str().map(String::from)), + serial: r.get("serial").and_then(|v| v.as_str().map(String::from)), + device_name: r.get("device_name").and_then(|v| v.as_str().map(String::from)), + }).collect(), + }; + if let Ok(frame) = Frame::new_json(MessageType::UsbPolicyUpdate, &payload) { + clients.send_to(device_uid, frame.encode()).await; + } + } + } + + info!("Pushed all plugin configs to newly registered device {}", device_uid); +} + +/// Maximum accumulated read buffer size per connection (8 MB) +const MAX_READ_BUF_SIZE: usize = 8 * 1024 * 1024; + +/// Registry of all connected client sessions +#[derive(Clone, Default)] +pub struct ClientRegistry { + sessions: Arc>>>>>, +} + +impl ClientRegistry { + pub fn new() -> Self { + Self::default() + } + + pub async fn register(&self, device_uid: String, tx: Arc>>) { + self.sessions.write().await.insert(device_uid, tx); + } + + pub async fn unregister(&self, device_uid: &str) { + self.sessions.write().await.remove(device_uid); + } + + pub async fn send_to(&self, device_uid: &str, data: Vec) -> bool { + if let Some(tx) = self.sessions.read().await.get(device_uid) { + tx.send(data).await.is_ok() + } else { + false + } + } + + pub async fn count(&self) -> usize { + self.sessions.read().await.len() + } + + pub async fn list_online(&self) -> Vec { + self.sessions.read().await.keys().cloned().collect() + } +} + +/// Start the TCP server for client connections (optionally with TLS) +pub async fn start_tcp_server(addr: String, state: AppState) -> anyhow::Result<()> { + let listener = TcpListener::bind(&addr).await?; + + // Build TLS acceptor if configured + let tls_acceptor = build_tls_acceptor(&state.config.server.tls)?; + + if tls_acceptor.is_some() { + info!("TCP server listening on {} (TLS enabled)", addr); + } else { + info!("TCP server listening on {} (plaintext)", addr); + } + + loop { + let (stream, peer_addr) = listener.accept().await?; + let state = state.clone(); + let acceptor = tls_acceptor.clone(); + + tokio::spawn(async move { + debug!("New TCP connection from {}", peer_addr); + match acceptor { + Some(acceptor) => { + match acceptor.accept(stream).await { + Ok(tls_stream) => { + if let Err(e) = handle_client_tls(tls_stream, state).await { + warn!("Client {} TLS error: {}", peer_addr, e); + } + } + Err(e) => warn!("TLS handshake failed for {}: {}", peer_addr, e), + } + } + None => { + if let Err(e) = handle_client(stream, state).await { + warn!("Client {} error: {}", peer_addr, e); + } + } + } + }); + } +} + +fn build_tls_acceptor( + tls_config: &Option, +) -> anyhow::Result> { + let config = match tls_config { + Some(c) => c, + None => return Ok(None), + }; + + let cert_pem = std::fs::read(&config.cert_path) + .map_err(|e| anyhow::anyhow!("Failed to read TLS cert {}: {}", config.cert_path, e))?; + let key_pem = std::fs::read(&config.key_path) + .map_err(|e| anyhow::anyhow!("Failed to read TLS key {}: {}", config.key_path, e))?; + + let certs: Vec = rustls_pemfile::certs(&mut &cert_pem[..]) + .collect::, _>>() + .map_err(|e| anyhow::anyhow!("Failed to parse TLS cert: {:?}", e))? + .into_iter() + .map(|c| c.into()) + .collect(); + + let key = rustls_pemfile::private_key(&mut &key_pem[..]) + .map_err(|e| anyhow::anyhow!("Failed to parse TLS key: {:?}", e))? + .ok_or_else(|| anyhow::anyhow!("No private key found in {}", config.key_path))?; + + let server_config = rustls::ServerConfig::builder() + .with_no_client_auth() + .with_single_cert(certs, key) + .map_err(|e| anyhow::anyhow!("Failed to build TLS config: {}", e))?; + + Ok(Some(tokio_rustls::TlsAcceptor::from(Arc::new(server_config)))) +} + +/// Cleanup on client disconnect: unregister from client map, mark offline, notify WS. +async fn cleanup_on_disconnect(state: &AppState, device_uid: &Option) { + if let Some(uid) = device_uid { + state.clients.unregister(uid).await; + sqlx::query("UPDATE devices SET status = 'offline' WHERE device_uid = ?") + .bind(uid) + .execute(&state.db) + .await + .ok(); + + state.ws_hub.broadcast(serde_json::json!({ + "type": "device_state", + "device_uid": uid, + "status": "offline" + }).to_string()).await; + + info!("Device disconnected: {}", uid); + } +} + +/// Compute HMAC-SHA256 for heartbeat verification. +/// Format: HMAC-SHA256(device_secret, "{device_uid}\n{timestamp}") → hex-encoded +fn compute_hmac(secret: &str, device_uid: &str, timestamp: &str) -> String { + type HmacSha256 = Hmac; + + let message = format!("{}\n{}", device_uid, timestamp); + let mut mac = match HmacSha256::new_from_slice(secret.as_bytes()) { + Ok(m) => m, + Err(_) => return String::new(), + }; + mac.update(message.as_bytes()); + hex::encode(mac.finalize().into_bytes()) +} + +/// Verify that a frame sender is a registered device and that the claimed device_uid +/// matches the one registered on this connection. Returns true if valid. +fn verify_device_uid(device_uid: &Option, msg_type: &str, claimed_uid: &str) -> bool { + match device_uid { + Some(uid) if *uid == claimed_uid => true, + Some(uid) => { + warn!("{} device_uid mismatch: expected {:?}, got {}", msg_type, uid, claimed_uid); + false + } + None => { + warn!("{} from unregistered connection", msg_type); + false + } + } +} + +/// Process a single decoded frame. Shared by both plaintext and TLS handlers. +async fn process_frame( + frame: Frame, + state: &AppState, + device_uid: &mut Option, + tx: &Arc>>, +) -> anyhow::Result<()> { + match frame.msg_type { + MessageType::Register => { + let req: csm_protocol::RegisterRequest = frame.decode_payload() + .map_err(|e| anyhow::anyhow!("Invalid registration payload: {}", e))?; + + info!("Device registration attempt: {} ({})", req.hostname, req.device_uid); + + // Validate registration token against configured token + let expected_token = &state.config.registration_token; + if !expected_token.is_empty() { + if req.registration_token.is_empty() || req.registration_token != *expected_token { + warn!("Registration rejected for {}: invalid token", req.device_uid); + let err_frame = Frame::new_json(MessageType::RegisterResponse, + &serde_json::json!({"error": "invalid_registration_token"}))?; + tx.send(err_frame.encode()).await.ok(); + return Ok(()); + } + } + + // Check if device already exists with a secret (reconnection scenario) + let existing_secret: Option = sqlx::query_scalar( + "SELECT device_secret FROM devices WHERE device_uid = ?" + ) + .bind(&req.device_uid) + .fetch_optional(&state.db) + .await + .ok() + .flatten(); + + let device_secret = match existing_secret { + // Existing device — keep the same secret, don't rotate + Some(secret) if !secret.is_empty() => secret, + // New device — generate a fresh secret + _ => uuid::Uuid::new_v4().to_string(), + }; + + sqlx::query( + "INSERT INTO devices (device_uid, hostname, ip_address, mac_address, os_version, device_secret, status) \ + VALUES (?, ?, '0.0.0.0', ?, ?, ?, 'online') \ + ON CONFLICT(device_uid) DO UPDATE SET hostname=excluded.hostname, os_version=excluded.os_version, \ + mac_address=excluded.mac_address, status='online'" + ) + .bind(&req.device_uid) + .bind(&req.hostname) + .bind(&req.mac_address) + .bind(&req.os_version) + .bind(&device_secret) + .execute(&state.db) + .await + .map_err(|e| anyhow::anyhow!("DB error during registration: {}", e))?; + + *device_uid = Some(req.device_uid.clone()); + // If this device was already connected on a different session, evict the old one + // The new register() call will replace it in the hashmap + state.clients.register(req.device_uid.clone(), tx.clone()).await; + + // Send registration response + let config = csm_protocol::ClientConfig::default(); + let response = csm_protocol::RegisterResponse { + device_secret, + config, + }; + let resp_frame = Frame::new_json(MessageType::RegisterResponse, &response)?; + tx.send(resp_frame.encode()).await?; + + info!("Device registered successfully: {} ({})", req.hostname, req.device_uid); + + // Push all active plugin configs to newly registered client + push_all_plugin_configs(&state.db, &state.clients, &req.device_uid).await; + } + + MessageType::Heartbeat => { + let heartbeat: csm_protocol::HeartbeatPayload = frame.decode_payload() + .map_err(|e| anyhow::anyhow!("Invalid heartbeat: {}", e))?; + + if !verify_device_uid(device_uid, "Heartbeat", &heartbeat.device_uid) { + return Ok(()); + } + + // Verify HMAC — reject if secret exists but HMAC is missing or wrong + let secret: Option = sqlx::query_scalar( + "SELECT device_secret FROM devices WHERE device_uid = ?" + ) + .bind(&heartbeat.device_uid) + .fetch_optional(&state.db) + .await + .map_err(|e| { + warn!("DB error fetching device_secret for {}: {}", heartbeat.device_uid, e); + anyhow::anyhow!("DB error during HMAC verification") + })?; + + if let Some(ref secret) = secret { + if !secret.is_empty() { + if heartbeat.hmac.is_empty() { + warn!("Heartbeat missing HMAC for device {}", heartbeat.device_uid); + return Ok(()); + } + // Constant-time HMAC verification using hmac::Mac::verify_slice + let message = format!("{}\n{}", heartbeat.device_uid, heartbeat.timestamp); + let mut mac = Hmac::::new_from_slice(secret.as_bytes()) + .map_err(|_| anyhow::anyhow!("HMAC key error"))?; + mac.update(message.as_bytes()); + let provided_bytes = hex::decode(&heartbeat.hmac).unwrap_or_default(); + if mac.verify_slice(&provided_bytes).is_err() { + warn!("Heartbeat HMAC mismatch for device {}", heartbeat.device_uid); + return Ok(()); + } + } + } + + debug!("Heartbeat from {} (hmac verified)", heartbeat.device_uid); + + // Update device status in DB + sqlx::query("UPDATE devices SET status = 'online', last_heartbeat = datetime('now') WHERE device_uid = ?") + .bind(&heartbeat.device_uid) + .execute(&state.db) + .await + .ok(); + + // Push to WebSocket subscribers + state.ws_hub.broadcast(serde_json::json!({ + "type": "device_state", + "device_uid": heartbeat.device_uid, + "status": "online" + }).to_string()).await; + } + + MessageType::StatusReport => { + let status: csm_protocol::DeviceStatus = frame.decode_payload() + .map_err(|e| anyhow::anyhow!("Invalid status report: {}", e))?; + + if !verify_device_uid(device_uid, "StatusReport", &status.device_uid) { + return Ok(()); + } + + crate::db::DeviceRepo::upsert_status(&state.db, &status.device_uid, &status).await?; + + // Push to WebSocket subscribers + state.ws_hub.broadcast(serde_json::json!({ + "type": "device_status", + "device_uid": status.device_uid, + "cpu": status.cpu_usage, + "memory": status.memory_usage, + "disk": status.disk_usage + }).to_string()).await; + } + + MessageType::UsbEvent => { + let event: csm_protocol::UsbEvent = frame.decode_payload() + .map_err(|e| anyhow::anyhow!("Invalid USB event: {}", e))?; + + if !verify_device_uid(device_uid, "UsbEvent", &event.device_uid) { + return Ok(()); + } + + crate::db::DeviceRepo::insert_usb_event(&state.db, &event).await?; + + state.ws_hub.broadcast(serde_json::json!({ + "type": "usb_event", + "device_uid": event.device_uid, + "event": event.event_type, + "usb_name": event.device_name + }).to_string()).await; + } + + MessageType::AssetReport => { + let asset: csm_protocol::HardwareAsset = frame.decode_payload() + .map_err(|e| anyhow::anyhow!("Invalid asset report: {}", e))?; + + if !verify_device_uid(device_uid, "AssetReport", &asset.device_uid) { + return Ok(()); + } + + crate::db::DeviceRepo::upsert_hardware(&state.db, &asset).await?; + } + + MessageType::UsageReport => { + let report: csm_protocol::UsageDailyReport = frame.decode_payload() + .map_err(|e| anyhow::anyhow!("Invalid usage report: {}", e))?; + + if !verify_device_uid(device_uid, "UsageReport", &report.device_uid) { + return Ok(()); + } + + sqlx::query( + "INSERT INTO usage_daily (device_uid, date, total_active_minutes, total_idle_minutes, first_active_at, last_active_at) \ + VALUES (?, ?, ?, ?, ?, ?) \ + ON CONFLICT(device_uid, date) DO UPDATE SET \ + total_active_minutes = excluded.total_active_minutes, \ + total_idle_minutes = excluded.total_idle_minutes, \ + first_active_at = excluded.first_active_at, \ + last_active_at = excluded.last_active_at" + ) + .bind(&report.device_uid) + .bind(&report.date) + .bind(report.total_active_minutes as i32) + .bind(report.total_idle_minutes as i32) + .bind(&report.first_active_at) + .bind(&report.last_active_at) + .execute(&state.db) + .await + .map_err(|e| anyhow::anyhow!("DB error inserting usage report: {}", e))?; + + debug!("Usage report saved for device {}", report.device_uid); + } + + MessageType::AppUsageReport => { + let report: csm_protocol::AppUsageEntry = frame.decode_payload() + .map_err(|e| anyhow::anyhow!("Invalid app usage report: {}", e))?; + + if !verify_device_uid(device_uid, "AppUsageReport", &report.device_uid) { + return Ok(()); + } + + sqlx::query( + "INSERT INTO app_usage_daily (device_uid, date, app_name, usage_minutes) \ + VALUES (?, ?, ?, ?) \ + ON CONFLICT(device_uid, date, app_name) DO UPDATE SET \ + usage_minutes = MAX(usage_minutes, excluded.usage_minutes)" + ) + .bind(&report.device_uid) + .bind(&report.date) + .bind(&report.app_name) + .bind(report.usage_minutes as i32) + .execute(&state.db) + .await + .map_err(|e| anyhow::anyhow!("DB error inserting app usage: {}", e))?; + + debug!("App usage saved: {} -> {} ({} min)", report.device_uid, report.app_name, report.usage_minutes); + } + + MessageType::SoftwareViolation => { + let report: csm_protocol::SoftwareViolationReport = frame.decode_payload() + .map_err(|e| anyhow::anyhow!("Invalid software violation: {}", e))?; + + if !verify_device_uid(device_uid, "SoftwareViolation", &report.device_uid) { + return Ok(()); + } + + sqlx::query( + "INSERT INTO software_violations (device_uid, software_name, action_taken, timestamp) VALUES (?, ?, ?, ?)" + ) + .bind(&report.device_uid) + .bind(&report.software_name) + .bind(&report.action_taken) + .bind(&report.timestamp) + .execute(&state.db) + .await + .map_err(|e| anyhow::anyhow!("DB error inserting software violation: {}", e))?; + + info!("Software violation: {} tried to run {} -> {}", report.device_uid, report.software_name, report.action_taken); + + state.ws_hub.broadcast(serde_json::json!({ + "type": "software_violation", + "device_uid": report.device_uid, + "software_name": report.software_name, + "action_taken": report.action_taken + }).to_string()).await; + } + + MessageType::UsbFileOp => { + let entry: csm_protocol::UsbFileOpEntry = frame.decode_payload() + .map_err(|e| anyhow::anyhow!("Invalid USB file op: {}", e))?; + + if !verify_device_uid(device_uid, "UsbFileOp", &entry.device_uid) { + return Ok(()); + } + + sqlx::query( + "INSERT INTO usb_file_operations (device_uid, usb_serial, drive_letter, operation, file_path, file_size, timestamp) VALUES (?, ?, ?, ?, ?, ?, ?)" + ) + .bind(&entry.device_uid) + .bind(&entry.usb_serial) + .bind(&entry.drive_letter) + .bind(&entry.operation) + .bind(&entry.file_path) + .bind(entry.file_size.map(|s| s as i64)) + .bind(&entry.timestamp) + .execute(&state.db) + .await + .map_err(|e| anyhow::anyhow!("DB error inserting USB file op: {}", e))?; + + debug!("USB file op: {} {} on {}", entry.operation, entry.file_path, entry.device_uid); + } + + MessageType::WebAccessLog => { + let entry: csm_protocol::WebAccessLogEntry = frame.decode_payload() + .map_err(|e| anyhow::anyhow!("Invalid web access log: {}", e))?; + + if !verify_device_uid(device_uid, "WebAccessLog", &entry.device_uid) { + return Ok(()); + } + + sqlx::query( + "INSERT INTO web_access_log (device_uid, url, action, timestamp) VALUES (?, ?, ?, ?)" + ) + .bind(&entry.device_uid) + .bind(&entry.url) + .bind(&entry.action) + .bind(&entry.timestamp) + .execute(&state.db) + .await + .map_err(|e| anyhow::anyhow!("DB error inserting web access log: {}", e))?; + + debug!("Web access log: {} {} {}", entry.device_uid, entry.action, entry.url); + } + + _ => { + debug!("Unhandled message type: {:?}", frame.msg_type); + } + } + + Ok(()) +} + +/// Handle a single client TCP connection +async fn handle_client(stream: TcpStream, state: AppState) -> anyhow::Result<()> { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + // Set read timeout to detect stale connections + let _ = stream.set_nodelay(true); + + let (mut reader, mut writer) = stream.into_split(); + let (tx, mut rx) = tokio::sync::mpsc::channel::>(256); + let tx = Arc::new(tx); + + let mut buffer = vec![0u8; 65536]; + let mut read_buf = Vec::with_capacity(65536); + let mut device_uid: Option = None; + let mut rate_limiter = RateLimiter::new(); + + // Writer task: forwards messages from channel to TCP stream + let write_task = tokio::spawn(async move { + while let Some(data) = rx.recv().await { + if writer.write_all(&data).await.is_err() { + break; + } + } + }); + + // Reader loop + 'reader: loop { + let n = reader.read(&mut buffer).await?; + if n == 0 { + break; // Connection closed + } + read_buf.extend_from_slice(&buffer[..n]); + + // Guard against unbounded buffer growth + if read_buf.len() > MAX_READ_BUF_SIZE { + warn!("Connection exceeded max buffer size, dropping"); + break; + } + + // Process complete frames + while let Some(frame) = Frame::decode(&read_buf)? { + let frame_size = frame.encoded_size(); + // Remove consumed bytes without reallocating + read_buf.drain(..frame_size); + + // Rate limit check + if !rate_limiter.check() { + warn!("Rate limit exceeded for device {:?}, dropping connection", device_uid); + break 'reader; + } + + // Verify protocol version + if frame.version != PROTOCOL_VERSION { + warn!("Unsupported protocol version: 0x{:02X}", frame.version); + continue; + } + + if let Err(e) = process_frame(frame, &state, &mut device_uid, &tx).await { + warn!("Frame processing error: {}", e); + } + } + } + + cleanup_on_disconnect(&state, &device_uid).await; + write_task.abort(); + Ok(()) +} + +/// Handle a TLS-wrapped client connection +async fn handle_client_tls( + stream: tokio_rustls::server::TlsStream, + state: AppState, +) -> anyhow::Result<()> { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + let (mut reader, mut writer) = tokio::io::split(stream); + let (tx, mut rx) = tokio::sync::mpsc::channel::>(256); + let tx = Arc::new(tx); + + let mut buffer = vec![0u8; 65536]; + let mut read_buf = Vec::with_capacity(65536); + let mut device_uid: Option = None; + let mut rate_limiter = RateLimiter::new(); + + let write_task = tokio::spawn(async move { + while let Some(data) = rx.recv().await { + if writer.write_all(&data).await.is_err() { + break; + } + } + }); + + // Reader loop — same logic as plaintext handler + 'reader: loop { + let n = reader.read(&mut buffer).await?; + if n == 0 { + break; + } + read_buf.extend_from_slice(&buffer[..n]); + + if read_buf.len() > MAX_READ_BUF_SIZE { + warn!("TLS connection exceeded max buffer size, dropping"); + break; + } + + while let Some(frame) = Frame::decode(&read_buf)? { + let frame_size = frame.encoded_size(); + read_buf.drain(..frame_size); + + if frame.version != PROTOCOL_VERSION { + warn!("Unsupported protocol version: 0x{:02X}", frame.version); + continue; + } + + if !rate_limiter.check() { + warn!("Rate limit exceeded for TLS device {:?}, dropping connection", device_uid); + break 'reader; + } + + if let Err(e) = process_frame(frame, &state, &mut device_uid, &tx).await { + warn!("Frame processing error: {}", e); + } + } + } + + cleanup_on_disconnect(&state, &device_uid).await; + write_task.abort(); + Ok(()) +} diff --git a/crates/server/src/ws.rs b/crates/server/src/ws.rs new file mode 100644 index 0000000..6c93aa1 --- /dev/null +++ b/crates/server/src/ws.rs @@ -0,0 +1,125 @@ +use axum::extract::ws::{WebSocket, WebSocketUpgrade, Message}; +use axum::response::IntoResponse; +use axum::extract::Query; +use jsonwebtoken::{decode, Validation, DecodingKey}; +use serde::Deserialize; +use tokio::sync::broadcast; +use std::sync::Arc; +use tracing::{debug, warn}; +use crate::api::auth::Claims; +use crate::AppState; + +/// WebSocket hub for broadcasting real-time events to admin browsers +#[derive(Clone)] +pub struct WsHub { + tx: broadcast::Sender, +} + +impl WsHub { + pub fn new() -> Self { + let (tx, _) = broadcast::channel(1024); + Self { tx } + } + + pub async fn broadcast(&self, message: String) { + if self.tx.send(message).is_err() { + debug!("No WebSocket subscribers to receive broadcast"); + } + } + + pub fn subscribe(&self) -> broadcast::Receiver { + self.tx.subscribe() + } +} + +#[derive(Debug, Deserialize)] +pub struct WsAuthParams { + pub token: Option, +} + +/// HTTP upgrade handler for WebSocket connections +/// Validates JWT token from query parameter before upgrading +pub async fn ws_handler( + ws: WebSocketUpgrade, + Query(params): Query, + axum::extract::State(state): axum::extract::State, +) -> impl IntoResponse { + let token = match params.token { + Some(t) => t, + None => { + warn!("WebSocket connection rejected: no token provided"); + return (axum::http::StatusCode::UNAUTHORIZED, "Missing token").into_response(); + } + }; + + let claims = match decode::( + &token, + &DecodingKey::from_secret(state.config.auth.jwt_secret.as_bytes()), + &Validation::default(), + ) { + Ok(c) => c.claims, + Err(e) => { + warn!("WebSocket connection rejected: invalid token - {}", e); + return (axum::http::StatusCode::UNAUTHORIZED, "Invalid token").into_response(); + } + }; + + if claims.token_type != "access" { + warn!("WebSocket connection rejected: not an access token"); + return (axum::http::StatusCode::UNAUTHORIZED, "Invalid token type").into_response(); + } + + let hub = state.ws_hub.clone(); + ws.on_upgrade(move |socket| handle_socket(socket, claims, hub)) +} + +async fn handle_socket(mut socket: WebSocket, claims: Claims, hub: Arc) { + debug!("WebSocket client connected: user={}", claims.username); + + let welcome = serde_json::json!({ + "type": "connected", + "message": "CSM real-time feed active", + "user": claims.username + }); + if socket.send(Message::Text(welcome.to_string())).await.is_err() { + return; + } + + // Subscribe to broadcast hub for real-time events + let mut rx = hub.subscribe(); + + loop { + tokio::select! { + // Forward broadcast messages to WebSocket client + msg = rx.recv() => { + match msg { + Ok(text) => { + if socket.send(Message::Text(text)).await.is_err() { + break; + } + } + Err(broadcast::error::RecvError::Lagged(n)) => { + debug!("WebSocket client lagged {} messages, continuing", n); + } + Err(broadcast::error::RecvError::Closed) => break, + } + } + // Handle incoming WebSocket messages (ping/close) + msg = socket.recv() => { + match msg { + Some(Ok(Message::Ping(data))) => { + if socket.send(Message::Pong(data)).await.is_err() { + break; + } + } + Some(Ok(Message::Close(_))) => break, + Some(Err(_)) => break, + None => break, + _ => {} + } + } + } + } + + debug!("WebSocket client disconnected: user={}", claims.username); +} diff --git a/device_secret.txt b/device_secret.txt new file mode 100644 index 0000000..04ada70 --- /dev/null +++ b/device_secret.txt @@ -0,0 +1 @@ +6958a73b-ccd7-4790-82b4-1e88863d84ef \ No newline at end of file diff --git a/device_uid.txt b/device_uid.txt new file mode 100644 index 0000000..e6acb39 --- /dev/null +++ b/device_uid.txt @@ -0,0 +1 @@ +a9e9f62a-c682-48fb-b1f4-b1429236ea92 \ No newline at end of file diff --git a/docs/superpowers/specs/2026-04-03-csm-enterprise-terminal-manager-design.md b/docs/superpowers/specs/2026-04-03-csm-enterprise-terminal-manager-design.md new file mode 100644 index 0000000..a7ea825 --- /dev/null +++ b/docs/superpowers/specs/2026-04-03-csm-enterprise-terminal-manager-design.md @@ -0,0 +1,1251 @@ +# CSM - Enterprise Terminal Management System Design + +> **Date:** 2026-04-03 +> **Status:** Draft +> **Scope:** MVP (50-500 Windows terminals, on-premise deployment) + +## 1. Overview + +CSM (Corporate System Manager) is a locally deployable enterprise terminal management system inspired by Tencent PC Manager Small Team Edition. It provides IT operations teams with centralized visibility and control over company Windows computers. + +### Key Design Decisions + +| Decision | Choice | Rationale | +| ------------------ | ------------------------------------ | ----------------------------------------------------------------------- | +| System positioning | IT operations management | Compliance-friendly, employee-visible client, focused on ops efficiency | +| Deployment scale | 50-500 terminals | Single-server deployment, monolithic architecture | +| Target OS | Windows 7+ only | Simplifies client development (single platform API) | +| Database | SQLite | Zero-ops, single-file, sufficient for small-scale | +| Management UI | Web (embedded SPA) | Browser-based access from any device, no desktop app needed | +| Notification | Email (SMTP) + Webhook | Fully local, no third-party dependencies | +| Architecture | Axum monolith with embedded frontend | Single binary deployment, zero configuration | + +### MVP Features (Phase 1) + +1. **Terminal Status Monitoring** - CPU/memory/disk/process real-time monitoring, online/offline tracking +2. **USB Device Control** - USB event logging, whitelist/blacklist policies, read-only enforcement +3. **Asset Management** - Hardware/software inventory auto-collection, change tracking + +### Plugin Modules (Phase 2 — Tencent Manager Parity) + +These modules are modeled after Tencent PC Manager Small Team Edition's plugin marketplace. Each is an independent module that can be enabled/disabled per device group. + +| # | Plugin | Description | Windows | Complexity | +| - | ----------------------------- | --------------------------------------------------------------------------------- | ------- | ---------- | +| 1 | **Web Filter (上网拦截)** | URL blacklist/whitelist, category-based filtering, browsing time control | ✅ | Medium | +| 2 | **Usage Timer (时长记录)** | Track computer usage duration, application usage statistics, idle detection | ✅ | Low | +| 3 | **Software Blocker (软件禁止安装)** | Blacklist-based software install prevention, unauthorized software auto-uninstall | ✅ | Medium | +| 4 | **Popup Blocker (弹窗拦截)** | Intercept popup ads and notifications, configurable allow-list | ✅ | Low | +| 5 | **USB File Audit (U盘文件操作记录)** | Log all file copy/delete/rename operations on USB storage devices | ✅ | Medium | +| 6 | **Screen Watermark (水印管理)** | Display customizable screen watermark (company name, username, timestamp) | ✅ | Medium | + +### Future Features (Phase 3+) + +- Software distribution (remote install/uninstall) +- Scheduled antivirus scanning +- Vulnerability patching +- Remote desktop assistance +- System announcements +- Scheduled shutdown + +## 2. System Architecture + +### 2.1 Cargo Workspace Structure + +``` +csm/ +├── Cargo.toml # Workspace root +├── crates/ +│ ├── protocol/ # Shared protocol definitions +│ │ └── src/ +│ │ ├── lib.rs +│ │ ├── message.rs # Message types (enum-based) +│ │ └── device.rs # Device data structures +│ │ +│ ├── client/ # Windows client (Service + System Tray) +│ │ └── src/ +│ │ ├── main.rs # Entry point + Windows Service registration +│ │ ├── tray.rs # System tray icon +│ │ ├── monitor/ # Status monitoring +│ │ │ ├── mod.rs +│ │ │ ├── system.rs # CPU/memory/disk/network via Windows API +│ │ │ └── process.rs # Process enumeration +│ │ ├── asset/ # Asset collection +│ │ │ ├── mod.rs +│ │ │ ├── hardware.rs # Hardware info (WMI queries) +│ │ │ └── software.rs # Software list (registry reading) +│ │ ├── usb/ # USB control +│ │ │ ├── mod.rs +│ │ │ ├── monitor.rs # WM_DEVICECHANGE listener +│ │ │ └── policy.rs # Policy enforcement +│ │ └── network/ # Communication +│ │ ├── mod.rs +│ │ ├── connection.rs # TCP + TLS connection +│ │ └── heartbeat.rs # Keep-alive with exponential backoff +│ │ +│ └── server/ # Server (Axum + embedded frontend) +│ └── src/ +│ ├── main.rs # Entry point +│ ├── api/ # REST API handlers +│ │ ├── mod.rs +│ │ ├── auth.rs # JWT authentication +│ │ ├── devices.rs # Device CRUD + status queries +│ │ ├── assets.rs # Asset queries + change history +│ │ ├── usb.rs # USB policy management +│ │ └── alerts.rs # Alert rules + records +│ ├── ws/ # WebSocket real-time push +│ │ ├── mod.rs +│ │ └── handler.rs # Device status updates, USB events +│ ├── tcp/ # Client connection management +│ │ ├── mod.rs +│ │ └── session.rs # Per-client session handler +│ ├── db/ # Database layer +│ │ ├── mod.rs +│ │ ├── schema.rs # Refdb migration definitions +│ │ └── repository.rs # Query functions +│ ├── alert/ # Alert engine +│ │ ├── mod.rs +│ │ ├── engine.rs # Rule matching against incoming events +│ │ └── notify.rs # Email (lettre) + Webhook dispatch +│ └── config.rs # TOML-based configuration +│ +├── web/ # Vue.js management frontend +│ ├── package.json +│ ├── vite.config.ts +│ └── src/ +│ ├── views/ +│ │ ├── Dashboard.vue # Overview: online stats, alerts, recent events +│ │ ├── Devices.vue # Device list with real-time status +│ │ ├── DeviceDetail.vue # Single device detail (hardware, software, history) +│ │ ├── Assets.vue # Asset inventory and change log +│ │ ├── UsbPolicy.vue # USB policy configuration +│ │ ├── UsbEvents.vue # USB event timeline +│ │ ├── Alerts.vue # Alert center +│ │ ├── AlertRules.vue # Alert rule configuration +│ │ ├── Login.vue # Authentication +│ │ └── Settings.vue # System settings (notifications, users) +│ ├── components/ +│ │ ├── DeviceStatusBadge.vue +│ │ ├── CpuChart.vue +│ │ ├── MemoryChart.vue +│ │ └── AlertCard.vue +│ ├── stores/ # Pinia state management +│ │ ├── auth.ts +│ │ ├── devices.ts +│ │ └── alerts.ts +│ └── router/ +│ └── index.ts +│ +├── migrations/ # SQLite migration SQL files +│ ├── 001_init.sql # Users, devices, device_status +│ ├── 002_assets.sql # Hardware/software assets, asset_changes +│ ├── 003_usb.sql # USB events, USB policies +│ ├── 004_alerts.sql # Alert rules, alert records +│ ├── 005_plugins_web_filter.sql # Web filter rules + access log +│ ├── 006_plugins_usage_timer.sql # Daily usage + app usage +│ ├── 007_plugins_software_blocker.sql # Software blacklist + violations +│ ├── 008_plugins_popup_blocker.sql # Popup filter rules +│ ├── 009_plugins_usb_file_audit.sql # USB file operations log +│ └── 010_plugins_watermark.sql # Watermark config +│ +└── docs/ +``` + +### 2.2 Communication Architecture + +``` +Client (Windows Service, system tray visible) + │ + ├─ TCP+TLS persistent ──→ Server :9999 (client gateway) + │ ├─ Heartbeat (30s interval, ~50 bytes each) + │ ├─ StatusReport (every 60s, ~500 bytes JSON) + │ ├─ AssetReport (on change or forced collection) + │ ├─ UsbEvent (on device change notification) + │ └─ Receives: PolicyUpdate, ConfigUpdate, TaskExecute + │ + └─ Initial registration ──→ POST /api/register (over HTTPS) + +Admin (Browser) + │ + ├─ HTTPS ──→ Server :8080 (web management) + │ ├─ REST API (CRUD operations) + │ └─ Static file serving (Vue.js SPA from embedded dist/) + │ + └─ WSS ──→ Server :8080/ws (real-time push) + ├─ Device status updates (online/offline, metric changes) + ├─ USB alerts (unauthorized device, policy violations) + └─ New device registration notifications +``` + +### 2.3 Single Binary Deployment + +The server binary embeds: + +- Vue.js SPA (compiled `dist/` via `include_dir!` macro from `include_dir` crate) +- SQLite database (auto-created on first run) +- Default configuration (overridable via `config.toml`) + +Deployment process: + +```bash +# 1. Copy single binary +scp csm-server target-server:/usr/local/bin/ + +# 2. Create config +cat > config.toml << EOF +[server] +http_addr = "0.0.0.0:8080" +tcp_addr = "0.0.0.0:9999" + +[database] +path = "./csm.db" + +[auth] +jwt_secret = "change-me-in-production" + +[notify.smtp] +host = "smtp.company.com" +port = 587 +username = "alerts@company.com" +password = "secret" +from = "CSM Alerts " +EOF + +# 3. Run +./csm-server --config config.toml +``` + +## 3. Communication Protocol + +### 3.1 Binary Frame Format + +``` +┌──────────┬──────────┬──────────┬──────────────┐ +│ Magic(4B)│ Ver(1B) │ Type(1B) │ Length(4B) │ +│ 0x43534D │ 0x01 │ │ payload size │ +├──────────┴──────────┴──────────┴──────────────┤ +│ Payload (variable) │ +│ JSON-encoded message body │ +└─────────────────────────────────────────────────┘ +``` + +- Magic bytes: `0x43 0x53 0x4D` = "CSM" in ASCII +- Protocol version: `0x01` (current). Both sides verify version during registration handshake. If versions differ, the server sends a `ConfigUpdate(ProtocolVersion)` with the highest common version, or rejects the connection if incompatible. + +### 3.2 Message Types + +```rust +#[repr(u8)] +pub enum MessageType { + // Client → Server + Heartbeat = 0x01, + Register = 0x02, + StatusReport = 0x03, + AssetReport = 0x04, + AssetChange = 0x05, + UsbEvent = 0x06, + AlertAck = 0x07, + + // Server → Client + PolicyUpdate = 0x10, + ConfigUpdate = 0x11, + TaskExecute = 0x12, +} +``` + +### 3.3 Bandwidth Estimation (500 terminals) + +| Message | Frequency | Size | Bandwidth | +| ------------------------ | --------- | ------ | ------------- | +| Heartbeat | 30s | \~50B | \~8 KB/s | +| StatusReport | 60s | \~500B | \~4 KB/s | +| UsbEvent | On event | \~300B | Negligible | +| AssetReport | On change | \~5KB | Negligible | +| **Total (steady state)** |
|
| **\~12 KB/s** | + +## 4. Database Schema (SQLite) + +### 4.1 Core Tables + +**users** - Administrator accounts + +```sql +CREATE TABLE users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE, + password TEXT NOT NULL, -- bcrypt hash + role TEXT NOT NULL DEFAULT 'admin', + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); +``` + +**devices** - Registered terminals + +```sql +CREATE TABLE devices ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + device_uid TEXT NOT NULL UNIQUE, -- Client-generated UUID + hostname TEXT NOT NULL, + ip_address TEXT NOT NULL, + mac_address TEXT, + os_version TEXT, + client_version TEXT, + status TEXT NOT NULL DEFAULT 'offline', + last_heartbeat TEXT, + registered_at TEXT NOT NULL DEFAULT (datetime('now')), + group_name TEXT DEFAULT 'default' +); +``` + +**device\_status** - Latest status snapshot (upsert on each heartbeat) + +```sql +CREATE TABLE device_status ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + device_uid TEXT NOT NULL REFERENCES devices(device_uid), + cpu_usage REAL, + memory_usage REAL, + memory_total INTEGER, + disk_usage REAL, + disk_total INTEGER, + network_rx_rate INTEGER, + network_tx_rate INTEGER, + running_procs INTEGER, + top_processes TEXT, -- JSON array + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (device_uid) REFERENCES devices(device_uid) +); +``` + +### 4.2 Asset Tables + +**hardware\_assets** - Hardware inventory (one row per device) + +```sql +CREATE TABLE hardware_assets ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + device_uid TEXT NOT NULL REFERENCES devices(device_uid), + cpu_model TEXT, + cpu_cores INTEGER, + memory_total INTEGER, + disk_model TEXT, + disk_total INTEGER, + gpu_model TEXT, + motherboard TEXT, + serial_number TEXT, + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(device_uid) +); +``` + +**software\_assets** - Software inventory (many rows per device) + +```sql +CREATE TABLE software_assets ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + device_uid TEXT NOT NULL REFERENCES devices(device_uid), + name TEXT NOT NULL, + version TEXT, + publisher TEXT, + install_date TEXT, + install_path TEXT, + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(device_uid, name, version) +); +``` + +**asset\_changes** - Change audit log + +```sql +CREATE TABLE asset_changes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + device_uid TEXT NOT NULL REFERENCES devices(device_uid), + change_type TEXT NOT NULL, -- 'hardware' | 'software_added' | 'software_removed' + change_detail TEXT NOT NULL, -- JSON: {field, old_value, new_value} + changed_at TEXT NOT NULL DEFAULT (datetime('now')) +); +``` + +### 4.3 USB Control Tables + +**usb\_events** - USB device activity log + +```sql +CREATE TABLE usb_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + device_uid TEXT NOT NULL REFERENCES devices(device_uid), + usb_vendor_id TEXT, + usb_product_id TEXT, + usb_serial TEXT, + usb_name TEXT, + event_type TEXT NOT NULL, -- 'inserted' | 'removed' | 'blocked' + event_time TEXT NOT NULL DEFAULT (datetime('now')) +); +``` + +**usb\_policies** - USB access policies + +```sql +CREATE TABLE usb_policies ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + policy_type TEXT NOT NULL, -- 'whitelist' | 'blacklist' | 'readonly' | 'all_block' + target_type TEXT NOT NULL, -- 'global' | 'group' | 'device' + target_id TEXT, -- Group name or device UID (NULL for global) + rules TEXT NOT NULL, -- JSON: vendor_id/product_id/serial patterns + enabled INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); +``` + +### 4.4 Alert Tables + +**alert\_rules** - Alert rule definitions + +```sql +CREATE TABLE alert_rules ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + rule_type TEXT NOT NULL, -- 'device_offline' | 'cpu_high' | 'memory_high' | 'disk_high' | 'usb_unauthorized' | 'asset_change' + condition TEXT NOT NULL, -- JSON: threshold, duration, etc. + notify_method TEXT NOT NULL, -- 'email' | 'webhook' | 'both' + notify_target TEXT NOT NULL, -- Email address or webhook URL + enabled INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); +``` + +**alert\_records** - Alert history + +```sql +CREATE TABLE alert_records ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + rule_id INTEGER REFERENCES alert_rules(id), + device_uid TEXT, + alert_type TEXT NOT NULL, + severity TEXT NOT NULL DEFAULT 'warning', + content TEXT NOT NULL, + notified INTEGER NOT NULL DEFAULT 0, + handled INTEGER NOT NULL DEFAULT 0, + handler TEXT, + handle_remark TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); +``` + +### 4.5 Indexes + +```sql +CREATE INDEX idx_devices_status ON devices(status); +CREATE INDEX idx_device_status_uid ON device_status(device_uid); +CREATE INDEX idx_software_device ON software_assets(device_uid); +CREATE INDEX idx_usb_events_device_time ON usb_events(device_uid, event_time); +CREATE INDEX idx_usb_policies_target ON usb_policies(target_type, target_id); +CREATE INDEX idx_alert_records_time ON alert_records(created_at); +CREATE INDEX idx_asset_changes_time ON asset_changes(changed_at); +CREATE INDEX idx_alert_records_device ON alert_records(device_uid, created_at); +CREATE INDEX idx_asset_changes_device ON asset_changes(device_uid); +``` + +### 4.6 Status History (Time-Series) + +The `device_status` table only holds the latest snapshot. For history charts, a separate table stores periodic samples with automatic pruning. + +```sql +-- Status history samples (insert every 60s per device, auto-pruned after 7 days) +CREATE TABLE device_status_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + device_uid TEXT NOT NULL REFERENCES devices(device_uid), + cpu_usage REAL, + memory_usage REAL, + disk_usage REAL, + network_rx_rate INTEGER, + network_tx_rate INTEGER, + running_procs INTEGER, + sampled_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX idx_status_history_device_time ON device_status_history(device_uid, sampled_at); +``` + +**Retention policy**: A background task runs every hour and deletes rows where `sampled_at < datetime('now', '-7 days')`. The 7-day retention window is configurable via `config.toml`. + +### 4.7 SQLite Configuration + +SQLite requires specific configuration for concurrent server workloads: + +```sql +-- Applied on every connection via SQLx setup +PRAGMA journal_mode = WAL; -- Write-Ahead Logging: allows concurrent reads during writes +PRAGMA synchronous = NORMAL; -- Balance between safety and performance (WAL handles durability) +PRAGMA busy_timeout = 5000; -- Wait up to 5s for locked database instead of immediate SQLITE_BUSY +PRAGMA wal_autocheckpoint = 1000; -- Auto-checkpoint WAL every 1000 pages +PRAGMA cache_size = -64000; -- 64MB page cache +PRAGMA foreign_keys = ON; -- Enforce foreign key constraints +``` + +**Connection pooling (sqlx)**: + +- Max connections: 8 (sufficient for 500 devices — SQLite serializes writes anyway) +- Write operations are funneled through a single `tokio::sync::Mutex` to prevent write contention. Reads are unrestricted (WAL allows concurrent reads). +- Status report writes (the highest-frequency write) use batch INSERT with `INSERT OR REPLACE` to minimize lock duration. + +## 5. Client Design (Windows Service) + +### 5.1 Runtime Model + +``` +main.rs + ├─ windows_service::start() # Register as Windows Service + ├─ tray_icon::show() # System tray with status icon + ├─ tokio::spawn(monitor_task) # Collect system metrics every 60s + ├─ tokio::spawn(asset_task) # Collect assets on startup + schedule + ├─ tokio::spawn(usb_task) # Listen for WM_DEVICECHANGE + ├─ tokio::spawn(network_task) # TCP connection + message loop + └─ tokio::spawn(config_watcher) # Watch for config/policy updates +``` + +### 5.2 Status Monitoring (monitor/system.rs) + +- **CPU**: `GetSystemTimes()` or `NtQuerySystemInformation()` for per-CPU usage +- **Memory**: `GlobalMemoryStatusEx()` for total/available +- **Disk**: `GetDiskFreeSpaceExW()` for each drive +- **Network**: `GetIfTable2()` from `iphlpapi` for bytes sent/received +- **Processes**: `CreateToolhelp32Snapshot()` for enumeration, top 10 by CPU/memory +- **Collection interval**: 60 seconds (configurable) +- **Payload**: \~500 bytes JSON per report + +### 5.3 Asset Collection (asset/) + +- **Hardware**: WMI queries (`Win32_Processor`, `Win32_PhysicalMemory`, `Win32_DiskDrive`, `Win32_VideoController`, `Win32_BaseBoard`) +- **Software**: Registry enumeration (`HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall`) +- **Change detection**: Hash comparison on collected data; only send deltas after initial full report +- **Collection trigger**: On startup, daily scheduled, or server-initiated + +### 5.4 USB Control (usb/) + +- **Detection**: `RegisterDeviceNotification()` for `WM_DEVICECHANGE` messages +- **Policy enforcement**: + - `all_block`: Set `HKLM\SYSTEM\CurrentControlSet\Services\USBSTOR\Start = 4` (disabled) + - `whitelist`: Allow only specific vendor\_id/product\_id combinations + - `blacklist`: Block specific devices + - `readonly`: **Deferred to Phase 3** — enforcing USB read-only at the OS level requires a kernel-mode storage filter driver, which is a significant development effort (WHQL signing, driver testing). For MVP, only block/allow modes are supported. +- **Event logging**: Every insert/remove event sent to server + +### 5.5 Client Lifecycle + +1. **Installation**: MSI installer or standalone exe with `--install` flag +2. **First run**: Generate `device_uid` (UUID v4), register with server (using registration token), full asset report +3. **Normal operation**: + - TCP+TLS connection to server (auto-reconnect with exponential backoff: 1s, 2s, 4s, ..., max 60s) + - Heartbeat every 30s + - Status report every 60s + - USB events immediately + - Asset changes on detection +4. **Policy sync**: Server pushes policy updates; client acknowledges and applies +5. **Update**: Server can trigger client self-update (download new binary, verify Ed25519 signature, atomic replace) +6. **Offline resilience**: Client buffers up to 1000 events locally (sled embedded database) when server is unreachable. Buffered events are sent on reconnection. If buffer overflows, oldest events are dropped. Alert events are prioritized over status reports. +7. **De-provisioning**: When admin deletes a device via `DELETE /api/devices/:uid`: + - Server adds device\_uid to revocation list + - If client is connected, server sends `ConfigUpdate(SelfDestruct)` → client stops its Windows Service and removes its credentials from the Windows Credential Store + - If client is offline, it is rejected on next reconnection attempt (HMAC verification fails because device record is deleted) + +### 5.6 Device Groups + +Device groups are managed as a first-class entity (not ad-hoc strings): + +```sql +CREATE TABLE device_groups ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + description TEXT, + parent_id INTEGER REFERENCES device_groups(id), -- Optional hierarchy + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); +``` + +The `devices.group_name` column references `device_groups.name`. This enables proper group management, hierarchical grouping, and group-level policy application in the web UI. + +## 6. Server Design (Axum) + +### 6.1 HTTP API Routes + +**Authentication model**: All routes except `/api/auth/*` require a valid JWT in the `Authorization: Bearer ` header. The JWT middleware (`axum::middleware::from_fn(auth_guard)`) validates the token and injects the user's role into request extensions. Routes marked `[viewer]` are accessible to both `admin` and `viewer` roles; unmarked routes require `admin` role. + +``` +# Public (no auth required) +POST /api/auth/login # JWT token generation +POST /api/auth/refresh # Refresh access token +POST /api/register # Client registration (uses registration token, not JWT) + +# Devices [viewer for GET, admin for DELETE] +GET /api/devices # List all devices (pagination, filters) +GET /api/devices/:uid # Device detail +GET /api/devices/:uid/status # Current status metrics +GET /api/devices/:uid/history # Status history (time range query) +DELETE /api/devices/:uid # Remove device (de-provision) + +# Assets [viewer] +GET /api/assets/hardware # Hardware inventory (filters) +GET /api/assets/software # Software inventory (filters) +GET /api/assets/changes # Asset change log + +# USB Control [viewer for GET, admin for CUD] +GET /api/usb/events # USB event log +GET /api/usb/policies # List USB policies +POST /api/usb/policies # Create policy +PUT /api/usb/policies/:id # Update policy +DELETE /api/usb/policies/:id # Delete policy + +# Alerts [viewer for GET, admin for CUD] +GET /api/alerts/rules # List alert rules +POST /api/alerts/rules # Create rule +PUT /api/alerts/rules/:id # Update rule +DELETE /api/alerts/rules/:id # Delete rule +GET /api/alerts/records # Alert history (filters) +PUT /api/alerts/records/:id/handle # Handle an alert + +# Settings [admin only] +GET /api/settings # System settings +PUT /api/settings # Update settings +POST /api/settings/registration-token # Generate client registration token +PUT /api/settings/tls # Upload new TLS certificate + +# Admin [admin only] +GET /api/admin/audit-log # Admin audit trail +POST /api/admin/users # Create user +PUT /api/admin/users/:id # Update user + +# WebSocket (auth via first message) +GET /ws # WebSocket real-time updates + +# Health & Operations (no auth, for load balancer / monitoring) +GET /health # Returns { status: "ok", uptime, connected_clients, db_size } +``` + +**Server operational monitoring**: The `/health` endpoint returns JSON with the server's own metrics: + +```json +{ + "status": "ok", + "uptime_seconds": 86400, + "connected_clients": 342, + "db_size_bytes": 52428800, + "pending_alerts": 3, + "version": "1.0.0" +} +``` + +### 6.2 WebSocket Events (Server → Browser) + +```typescript +// Device status update +{ type: "device_status", device_uid: "...", cpu: 45.2, memory: 62.1, ... } + +// Device online/offline +{ type: "device_state", device_uid: "...", status: "online" | "offline" } + +// USB event +{ type: "usb_event", device_uid: "...", event: "inserted", usb_name: "..." } + +// New alert +{ type: "alert", alert_id: 123, severity: "warning", content: "..." } + +// New device registered +{ type: "device_new", device_uid: "...", hostname: "..." } +``` + +### 6.3 Alert Engine + +**Rule evaluation** happens in the TCP session handler, immediately when data arrives: + +``` +Incoming StatusReport → parse metrics → check alert rules (in-memory cache) → + if threshold breached: + 1. INSERT into alert_records + 2. dispatch notification (email/webhook) asynchronously + 3. push WebSocket event to connected admins + +Incoming UsbEvent → check USB policies → + if unauthorized: + 1. Send PolicyUpdate to client (block device) + 2. INSERT into alert_records + 3. dispatch notification + 4. push WebSocket event +``` + +**Alert cooldown**: Same rule + same device triggers at most once per 5 minutes (configurable) to prevent alert storms. + +## 7. Web Frontend Design + +### 7.1 Technology Stack + +- **Vue.js 3** with Composition API + TypeScript +- **Vite** for development and build +- **Vue Router 4** for SPA routing +- **Pinia** for state management +- **Element Plus** for UI components +- **ECharts 5** for charts (CPU/memory/disk history) +- **Axios** for HTTP requests + +### 7.2 Key Pages + +**Dashboard**: Overview cards (device count, online/offline, alerts), recent USB events timeline, top alert devices, real-time updates via WebSocket. + +**Devices**: Table with hostname, IP, group, status badge (green/red), CPU/memory mini-sparklines. Click row → DeviceDetail with hardware info, software list, metric history charts. + +**Assets**: Two tabs - hardware inventory (filterable table) and software inventory (searchable). Change log view with diff highlighting. + +**USB Control**: Policy editor (drag-and-drop rule ordering), event timeline with device/USB details, policy test simulator. + +**Alerts**: Split view - rules on left, alert list on right. Severity badges (info/warning/critical). Inline handling with notes. + +**Settings**: SMTP config, Webhook URLs, user management, system info. + +### 7.3 Build Integration + +The Vue.js SPA builds to `web/dist/` and is embedded into the server binary at compile time: + +```rust +// server/src/main.rs +use include_dir::{include_dir, Dir}; + +static FRONTEND: Dir = include_dir!("$CARGO_MANIFEST_DIR/../../web/dist"); + +// Serve embedded SPA +app.route("/", get(|| async { /* serve index.html */ })) + .route("/assets/{*path}", get(|| async { /* serve static files */ })) + .fallback_service(ServeDir::from(FRONTEND)) +``` + +## 8. Security Design + +### 8.1 Communication Security + +- **Client ↔ Server**: TLS 1.3 over TCP (rustls, no OpenSSL dependency) +- **Browser ↔ Server**: HTTPS (rustls), JWT tokens in HTTP-only cookies +- **Certificate lifecycle**: + - **Initial setup**: Server generates a self-signed CA + server certificate on first run, stored in `config/cert.pem` + `config/key.pem` + - **Client trust**: The CA certificate fingerprint is embedded in the client installer at build time. On first connection, the client verifies the server cert against this known CA. This prevents MITM attacks without requiring a public CA. + - **Certificate rotation**: Admin uploads new cert via `PUT /api/settings/tls`. Server signals all connected clients via `ConfigUpdate(TlsCertRotate)`. Clients download the new CA cert over the existing trusted TLS connection before reconnecting with the new cert. Old cert remains valid for a 24-hour overlap window. + - **Custom CA**: Admin can replace the self-signed CA with a company PKI cert at any time via the settings API or config file (PEM format). + +### 8.2 Authentication & Authorization + +#### Admin Authentication (Browser → Server) + +- **Login**: Username + bcrypt password → JWT access token (30min expiry) + refresh token (7 days, stored in HTTP-only Secure cookie) +- **JWT transport**: Access token in `Authorization: Bearer ` header +- **Token refresh**: `POST /api/auth/refresh` with refresh token cookie → new access token +- **RBAC**: Two roles - `admin` (full access) and `viewer` (read-only) +- **Rate limiting**: Login endpoint limited to 5 attempts per minute per IP +- **WebSocket auth**: Client sends `{ type: "auth", token: "" }` as the first message after connection. Server closes the connection if auth fails within 5 seconds. + +#### Client Authentication (Agent → Server) + +- **Registration flow**: + 1. Admin generates a registration token via `POST /api/settings/registration-token` (returns a single-use token valid for 24h) + 2. Token is embedded in the client installer or provided during manual setup + 3. Client sends `Register` message with payload: `{ device_uid, hostname, registration_token, os_info }` + 4. Server validates the registration token, creates the device record, and returns a device-specific secret (random 256-bit key) + 5. Client stores the secret in the Windows Credential Store (encrypted with DPAPI) + 6. All subsequent connections include a HMAC-SHA256 signature of the message using this device secret +- **Session auth**: After registration, every message from the client includes `{ device_uid, timestamp, hmac }` where `hmac = HMAC-SHA256(device_secret, timestamp + payload)`. Server verifies HMAC before processing. +- **De-provisioning**: `DELETE /api/devices/:uid` revokes the device's record. Server adds the device\_uid to a revocation list (in-memory + SQLite). If the decommissioned client reconnects, the server sends a `ConfigUpdate(SelfDestruct)` command that causes the client to stop its service and delete its local credentials. + +### 8.3 Data Security + +- **Passwords**: bcrypt with cost factor 12 +- **JWT secrets**: Configurable, random if not set +- **Database**: SQLite file permissions 0600 (owner read/write only) +- **Logs**: No sensitive data in logs (no passwords, no full content) +- **HTTPS enforced**: HTTP requests redirect to HTTPS + +### 8.4 Client Integrity + +- **Code signing**: Recommended to sign client binary with company certificate +- **Anti-tampering**: Client verifies its own SHA-256 checksum on startup against the value stored in the Windows Credential Store (set during installation) +- **Secure update**: Server provides update with Ed25519 signature. Client verifies signature before replacing binary. Update is atomic: download to temp file → verify → rename (crash-safe on Windows via `MoveFileEx` with `MOVEFILE_REPLACE_EXISTING`) + +### 8.5 Admin Audit Trail + +All admin actions are logged for compliance: + +```sql +CREATE TABLE admin_audit_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id), + action TEXT NOT NULL, -- 'login' | 'create_policy' | 'delete_device' | etc. + target_type TEXT, -- 'device' | 'policy' | 'rule' | 'user' | 'setting' + target_id TEXT, + detail TEXT, -- JSON: what changed + ip_address TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX idx_audit_log_user_time ON admin_audit_log(user_id, created_at); +CREATE INDEX idx_audit_log_time ON admin_audit_log(created_at); +``` + +## 9. Technology Stack Summary + +### 9.1 Shared (protocol crate) + +| Dependency | Version | Purpose | +| ----------- | ------- | ----------------------- | +| serde | 1 | Serialization framework | +| serde\_json | 1 | JSON serialization | +| thiserror | 1 | Error types | + +### 9.2 Client (Windows) + +| Dependency | Version | Purpose | +| --------------- | -------- | ---------------------------------------------- | +| tokio | 1 (full) | Async runtime | +| windows | 0.54 | Windows API bindings (modern, replaces winapi) | +| wmi | 0.14 | WMI queries for hardware info | +| rustls | 0.23 | TLS (no OpenSSL) | +| sysinfo | 0.30 | Cross-platform system info (fallback) | +| serde | 1 | Message serialization | +| tracing | 0.1 | Structured logging | +| anyhow | 1 | Error handling | +| uuid | 1 | Device UID generation | +| windows-service | 0.7 | Windows Service integration | + +### 9.3 Server + +| Dependency | Version | Purpose | +| ------------------ | ------------ | --------------------------------------- | +| axum | 0.7 | Web framework | +| tokio | 1 (full) | Async runtime | +| sqlx | 0.7 (sqlite) | Database (compile-time checked queries) | +| rustls | 0.23 | TLS | +| tower-http | 0.5 | CORS, compression, static file serving | +| serde | 1 | JSON serialization | +| tracing | 0.1 | Structured logging | +| tracing-subscriber | 0.3 | Log formatting | +| anyhow | 1 | Error handling | +| jsonwebtoken | 9 | JWT token handling | +| bcrypt | 0.15 | Password hashing | +| lettre | 0.11 | Email sending (SMTP) | +| reqwest | 0.12 | Webhook HTTP calls | +| include\_dir | 0.7 | Embed frontend into binary | +| toml | 0.8 | Configuration file parsing | +| uuid | 1 | ID generation | + +### 9.4 Web Frontend + +| Package | Version | Purpose | +| ------------ | ------- | ------------------------- | +| vue | ^3.4 | UI framework | +| vue-router | ^4.2 | SPA routing | +| pinia | ^2.1 | State management | +| element-plus | ^2.5 | UI components | +| echarts | ^5.4 | Charts and visualizations | +| axios | ^1.6 | HTTP client | +| typescript | ^5.3 | Type safety | +| vite | ^5.0 | Build tool | +| @vueuse/core | ^10.7 | Composable utilities | + +## 10. Plugin Modules (Phase 2) — Detailed Design + +Each plugin is an independent client-side module with its own server API and database tables. Plugins can be enabled/disabled per device group via the admin web UI. + +### 10.1 Plugin Architecture + +``` +Client Plugin System +├── plugin_manager.rs # Load/start/stop plugins based on server policy +├── plugins/ +│ ├── web_filter/ # 上网拦截 +│ │ ├── mod.rs +│ │ ├── hosts.rs # Modify hosts file for DNS blocking +│ │ └── proxy.rs # Local proxy for URL filtering +│ ├── usage_timer/ # 时长记录 +│ │ ├── mod.rs +│ │ ├── tracker.rs # Track active/idle time +│ │ └── reporter.rs # Aggregate and report +│ ├── software_blocker/ # 软件禁止安装 +│ │ ├── mod.rs +│ │ ├── registry_watch.rs # Monitor registry for new installations +│ │ └── process_kill.rs # Kill blacklisted processes +│ ├── popup_blocker/ # 弹窗拦截 +│ │ ├── mod.rs +│ │ └── window_filter.rs # Enumerate windows, hide known popup patterns +│ ├── usb_file_audit/ # U盘文件操作记录 +│ │ ├── mod.rs +│ │ └── file_watcher.rs # Monitor file ops on removable drives +│ └── screen_watermark/ # 水印管理 +│ ├── mod.rs +│ └── overlay.rs # Transparent overlay window +``` + +### 10.2 Plugin: Web Filter (上网拦截) + +**Purpose**: Block or allow specific URLs/websites based on admin-defined rules. + +**Client Implementation**: + +- Method 1: **DNS-level blocking** — Modify `C:\Windows\System32\drivers\etc\hosts` to redirect blocked domains to `127.0.0.1` +- Method 2: **Windows Filtering Platform (WFP)** — Use WFP API to intercept and block HTTP/HTTPS connections at the network level +- Preferred: WFP approach (more reliable, doesn't interfere with hosts file) + +**Server-side**: + +```sql +-- Web filter rules +CREATE TABLE web_filter_rules ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + rule_type TEXT NOT NULL, -- 'blacklist' | 'whitelist' | 'category' + pattern TEXT NOT NULL, -- Domain, URL pattern, or category name + target_type TEXT NOT NULL, -- 'global' | 'group' | 'device' + target_id TEXT, + enabled INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +-- Web access log (for reporting) +CREATE TABLE web_access_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + device_uid TEXT NOT NULL REFERENCES devices(device_uid), + url TEXT NOT NULL, + action TEXT NOT NULL, -- 'allowed' | 'blocked' + timestamp TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX idx_web_access_log_device_time ON web_access_log(device_uid, timestamp); +CREATE INDEX idx_web_access_log_time ON web_access_log(timestamp); +``` + +**API**: + +``` +GET/POST/PUT/DELETE /api/plugins/web-filter/rules +GET /api/plugins/web-filter/log +``` + +### 10.3 Plugin: Usage Timer (时长记录) + +**Purpose**: Track when computers are actively used vs idle, and which applications are used. + +**Client Implementation**: + +- **Idle detection**: `GetLastInputInfo()` to detect user idle time +- **Active window tracking**: `GetForegroundWindow()` + `GetWindowText()` every 5 seconds +- **Session tracking**: Log login/logoff events via Windows Session Change notifications +- **Report**: Aggregate daily usage data (total active time, per-app time) sent to server every 10 minutes + +**Server-side**: + +```sql +-- Daily usage summary per device +CREATE TABLE usage_daily ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + device_uid TEXT NOT NULL REFERENCES devices(device_uid), + date TEXT NOT NULL, -- 'YYYY-MM-DD' + total_active_minutes INTEGER, + total_idle_minutes INTEGER, + first_active_at TEXT, + last_active_at TEXT, + UNIQUE(device_uid, date) +); + +-- Per-app usage (aggregated daily) +CREATE TABLE app_usage_daily ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + device_uid TEXT NOT NULL REFERENCES devices(device_uid), + date TEXT NOT NULL, + app_name TEXT NOT NULL, + usage_minutes INTEGER, + UNIQUE(device_uid, date, app_name) +); +``` + +**API**: + +``` +GET /api/plugins/usage-timer/daily # Daily usage summary +GET /api/plugins/usage-timer/app-usage # Per-app usage breakdown +GET /api/plugins/usage-timer/leaderboard # Usage ranking across devices +``` + +### 10.4 Plugin: Software Blocker (软件禁止安装) + +**Purpose**: Prevent installation of blacklisted software; auto-uninstall if detected. + +**Client Implementation**: + +- **Registry monitoring**: Watch `HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall` for new entries +- **Process monitoring**: Periodically scan running processes against blacklist +- **Enforcement**: + 1. Detect blacklisted software installation → terminate installer process + 2. Report to server → admin gets alert + 3. Optionally auto-uninstall via `msiexec /x` or `UninstallString` from registry + +**Server-side**: + +```sql +-- Software blacklist +CREATE TABLE software_blacklist ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name_pattern TEXT NOT NULL, -- Regex or glob pattern to match software name + category TEXT, -- 'game', 'social', 'vpn', 'custom' + action TEXT NOT NULL DEFAULT 'block', -- 'block' | 'alert' + target_type TEXT NOT NULL, -- 'global' | 'group' | 'device' + target_id TEXT, + enabled INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +-- Software violation log +CREATE TABLE software_violations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + device_uid TEXT NOT NULL REFERENCES devices(device_uid), + software_name TEXT NOT NULL, + action_taken TEXT NOT NULL, -- 'blocked_install' | 'auto_uninstalled' | 'alerted' + timestamp TEXT NOT NULL DEFAULT (datetime('now')) +); +``` + +**API**: + +``` +GET/POST/PUT/DELETE /api/plugins/software-blocker/blacklist +GET /api/plugins/software-blocker/violations +``` + +### 10.5 Plugin: Popup Blocker (弹窗拦截) + +**Purpose**: Suppress popup ads and unwanted notification windows. + +**Client Implementation**: + +- **Window enumeration**: `EnumWindows()` to list all visible windows +- **Pattern matching**: Match window title/class against known popup patterns (configurable from server) +- **Suppression**: `ShowWindow(hwnd, SW_HIDE)` or `PostMessage(hwnd, WM_CLOSE, 0, 0)` for matched popups +- **Built-in list**: Common popup patterns (Flash ads, browser notifications, update nags) +- **Allow list**: Admin can specify windows that should never be blocked + +**Server-side**: + +```sql +-- Popup filter rules +CREATE TABLE popup_filter_rules ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + rule_type TEXT NOT NULL, -- 'block' | 'allow' + window_title TEXT, -- Pattern for window title + window_class TEXT, -- Pattern for window class name + process_name TEXT, -- Pattern for process name + target_type TEXT NOT NULL, + target_id TEXT, + enabled INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); +``` + +**API**: + +``` +GET/POST/PUT/DELETE /api/plugins/popup-blocker/rules +GET /api/plugins/popup-blocker/stats # Block count statistics +``` + +### 10.6 Plugin: USB File Audit (U盘文件操作记录) + +**Purpose**: Log all file operations (copy, delete, rename, move) on USB storage devices. + +**Client Implementation**: + +- **Drive detection**: Monitor for new removable drives via `WM_DEVICECHANGE` + `DBT_DEVICEARRIVAL` +- **File monitoring**: `ReadDirectoryChangesW()` on the detected USB drive letter +- **Logged operations**: File name, operation type (create/delete/rename), file size, timestamp +- **Report**: Batch send to server every 30 seconds while USB is mounted + +**Server-side**: + +```sql +-- USB file operation log +CREATE TABLE usb_file_operations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + device_uid TEXT NOT NULL REFERENCES devices(device_uid), + usb_serial TEXT, -- Identify which USB device + operation TEXT NOT NULL, -- 'create' | 'delete' | 'rename' | 'modify' + file_path TEXT NOT NULL, -- Path on USB drive + file_size INTEGER, + timestamp TEXT NOT NULL DEFAULT (datetime('now')) +); +``` + +**API**: + +``` +GET /api/plugins/usb-file-audit/log # File operation log +GET /api/plugins/usb-file-audit/summary # Per-device USB file activity summary +``` + +### 10.7 Plugin: Screen Watermark (水印管理) + +**Purpose**: Display a transparent, non-removable watermark overlay on the screen showing company name, username, and timestamp. + +**Client Implementation**: + +- **Overlay window**: Create a layered, transparent, click-through window (`WS_EX_LAYERED | WS_EX_TRANSPARENT | WS_EX_TOPMOST`) covering the entire screen +- **Rendering**: GDI+ or Direct2D to draw semi-transparent text (company name, `{username}`, `{hostname}`, `{date}`, `{time}`) +- **Persistence**: Re-create overlay on display change events (`WM_DISPLAYCHANGE`) +- **Tamper resistance**: Monitor overlay window existence, re-create if closed + +**Server-side**: + +```sql +-- Watermark configuration +CREATE TABLE watermark_config ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + target_type TEXT NOT NULL, -- 'global' | 'group' | 'device' + target_id TEXT, + content TEXT NOT NULL, -- Template: "Company: {company} | User: {username} | {date}" + font_size INTEGER DEFAULT 14, + opacity REAL DEFAULT 0.15, -- 0.0 to 1.0 + color TEXT DEFAULT '#808080', + angle INTEGER DEFAULT -30, -- Rotation angle in degrees + enabled INTEGER NOT NULL DEFAULT 1, + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); +``` + +**API**: + +``` +GET/POST/PUT/DELETE /api/plugins/watermark/config +``` + +### 10.8 Plugin Message Types (Protocol Extension) + +```rust +// Additional message types for plugins +#[repr(u8)] +pub enum PluginMessageType { + // Web Filter + WebFilterRuleUpdate = 0x20, + WebAccessLog = 0x21, + + // Usage Timer + UsageReport = 0x30, + AppUsageReport = 0x31, + + // Software Blocker + SoftwareBlacklist = 0x40, + SoftwareViolation = 0x41, + + // Popup Blocker + PopupRules = 0x50, + + // USB File Audit + UsbFileOp = 0x60, + + // Screen Watermark + WatermarkConfig = 0x70, + + // Generic plugin control + PluginEnable = 0x80, + PluginDisable = 0x81, +} +``` + +## 11. Build & Deploy + +### 11.1 Build Process + +```bash +# Build frontend +cd web && npm install && npm run build + +# Build server (embeds frontend) +cd crates/server && cargo build --release + +# Build client +cd crates/client && cargo build --release --target x86_64-pc-windows-msvc +``` + +### 11.2 Deployment Checklist + +**Server:** + +- [ ] Copy `csm-server.exe` to target directory +- [ ] Create `config.toml` with production values +- [ ] Generate JWT secret: `openssl rand -hex 32` +- [ ] Configure SMTP credentials for alert emails +- [ ] Set up TLS certificate (or use self-signed for testing) +- [ ] Create initial admin user: `csm-server --init-admin` +- [ ] Start as Windows Service or use `nssm` to register + +**Client:** + +- [ ] Build MSI installer (via `cargo-wix` or WiX Toolset) +- [ ] Configure server address in installer +- [ ] Deploy via GPO, SCCM, or manual installation +- [ ] Verify client appears in web dashboard + +## 12. Performance Targets + +| Metric | Target | Notes | +| ----------------- | ------------ | ---------------------------------------- | +| Client CPU usage | < 2% | Steady state, excluding asset collection | +| Client memory | < 50MB | Including all monitoring modules | +| Client disk | < 10MB | Binary + local cache | +| Server memory | < 200MB | 500 connected clients | +| Server CPU | < 5% | Steady state with 500 clients | +| API response time | < 100ms | Typical CRUD operations | +| WebSocket latency | < 500ms | Alert to browser | +| Database size | \~500MB/year | 500 devices, 90-day retention | +| Deployment size | \~20MB | Server binary with embedded frontend | + +## 13. Data Retention & Cleanup + +A background task (`tokio::spawn(cleanup_task)`) runs every hour and prunes data based on configurable retention windows: + +| Table | Default Retention | Cleanup Method | +| -------------------------- | ----------------------------- | ----------------------------------------------------------------------- | +| `device_status` | Current only (upsert) | N/A | +| `device_status_history` | 7 days | `DELETE WHERE sampled_at < datetime('now', '-7 days')` | +| `usb_events` | 90 days | `DELETE WHERE event_time < datetime('now', '-90 days')` | +| `asset_changes` | 365 days (forever by default) | `DELETE WHERE changed_at < datetime('now', '-365 days')` | +| `alert_records` | 90 days (unhandled kept) | `DELETE WHERE handled = 1 AND created_at < datetime('now', '-90 days')` | +| `admin_audit_log` | 365 days | `DELETE WHERE created_at < datetime('now', '-365 days')` | +| `web_access_log` (P2) | 30 days | High-volume, aggressive pruning | +| `usb_file_operations` (P2) | 90 days | Standard retention | + +Retention windows are configurable via `config.toml`: + +```toml +[retention] +status_history_days = 7 +usb_events_days = 90 +asset_changes_days = 365 +alert_records_days = 90 +audit_log_days = 365 +``` + +**SQLite backup**: Admin can trigger a manual backup via `POST /api/settings/backup`, which creates a timestamped copy of the SQLite file using `sqlite3 .backup` command (via sqlx `VACUUM INTO`). Auto-backup can be configured to run daily. + +## 14. Error Handling & Resilience + +### Client-Side Failure Modes + +| Failure | Detection | Recovery | +| ------------------------------ | ----------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | +| Server unreachable | TCP connect timeout (10s) | Exponential backoff reconnect (1s → 60s max). Buffer events locally (up to 1000). | +| TLS certificate changed | Cert mismatch on handshake | If new cert is signed by known CA, accept and update local cache. If unknown, alert admin via tray notification and retry in 5 minutes. | +| Client binary update fails | Hash/signature verification failure | Discard update, keep running version, report failure to server. | +| Local sled database corruption | sled auto-recovery on open | If unrecoverable, clear local buffer and re-register with server. | +| Service crash | Windows Service recovery options | Configured to auto-restart after 30 seconds (via Windows Service recovery settings). | + +### Server-Side Failure Modes + +| Failure | Detection | Recovery | +| ---------------------------- | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | +| SQLite database lock | `SQLITE_BUSY` error | `busy_timeout = 5000ms` handles most cases. If still fails after 5s, log error and return 503 to API client. Client data is buffered and retried. | +| SQLite database corruption | sqlx connection error on startup | Attempt `PRAGMA integrity_check`. If failed, restore from latest backup. If no backup, reinitialize schema (data loss). | +| Email notification failure | SMTP connection error | Retry 3 times with 30s interval. Log failure. Alert is still stored in DB (just not delivered). Admin can see undelivered alerts in the UI. | +| Webhook notification failure | HTTP timeout or non-2xx response | Retry 3 times with exponential backoff (1s, 5s, 30s). Log failure. Same as email — alert is stored regardless. | +| WebSocket disconnect | Client heartbeat timeout (60s) | Remove session from active viewers list. Client reconnects automatically. No data loss — missed events are fetched via REST API on reconnect. | + diff --git a/login_resp.json b/login_resp.json new file mode 100644 index 0000000..7e5587f --- /dev/null +++ b/login_resp.json @@ -0,0 +1 @@ +{"success":true,"data":{"access_token":"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOjEsInVzZXJuYW1lIjoiYWRtaW4iLCJyb2xlIjoiYWRtaW4iLCJleHAiOjE3NzUyNjIxMzYsImlhdCI6MTc3NTI2MDMzNiwidG9rZW5fdHlwZSI6ImFjY2VzcyIsImZhbWlseSI6ImUxMTdjNDU0LTgxMGUtNDYxOC1hNjg5LWFkZGUyODI3MTI0MiJ9.DiZPv622vMCgkVszVjq41EIz19Yi0LMhAEiPDs7J5MY","refresh_token":"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOjEsInVzZXJuYW1lIjoiYWRtaW4iLCJyb2xlIjoiYWRtaW4iLCJleHAiOjE3NzU4NjUxMzYsImlhdCI6MTc3NTI2MDMzNiwidG9rZW5fdHlwZSI6InJlZnJlc2giLCJmYW1pbHkiOiJlMTE3YzQ1NC04MTBlLTQ2MTgtYTY4OS1hZGRlMjgyNzEyNDIifQ.LvLQK2qmdxXrTxPUgoRbKFsvTCbeJwNjisdMenPrSuM","user":{"id":1,"username":"admin","role":"admin"}},"error":null} \ No newline at end of file diff --git a/migrations/001_init.sql b/migrations/001_init.sql new file mode 100644 index 0000000..1932347 --- /dev/null +++ b/migrations/001_init.sql @@ -0,0 +1,70 @@ +-- 001_init.sql: Core tables (users, devices, device_status) + +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE, + password TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'admin' CHECK(role IN ('admin', 'viewer')), + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS devices ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + device_uid TEXT NOT NULL UNIQUE, + hostname TEXT NOT NULL, + ip_address TEXT NOT NULL, + mac_address TEXT, + os_version TEXT, + client_version TEXT, + device_secret TEXT, -- HMAC key for message authentication + status TEXT NOT NULL DEFAULT 'offline' CHECK(status IN ('online', 'offline')), + last_heartbeat TEXT, + registered_at TEXT NOT NULL DEFAULT (datetime('now')), + group_name TEXT DEFAULT 'default' +); + +CREATE TABLE IF NOT EXISTS device_status ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + device_uid TEXT NOT NULL REFERENCES devices(device_uid) ON DELETE CASCADE, + cpu_usage REAL, + memory_usage REAL, + memory_total_mb INTEGER, + disk_usage REAL, + disk_total_mb INTEGER, + network_rx_rate INTEGER, + network_tx_rate INTEGER, + running_procs INTEGER, + top_processes TEXT, + reported_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(device_uid) +); + +CREATE TABLE IF NOT EXISTS device_status_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + device_uid TEXT NOT NULL REFERENCES devices(device_uid) ON DELETE CASCADE, + cpu_usage REAL, + memory_usage REAL, + disk_usage REAL, + network_rx_rate INTEGER, + network_tx_rate INTEGER, + running_procs INTEGER, + reported_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS device_groups ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + description TEXT, + parent_id INTEGER REFERENCES device_groups(id), + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +-- Insert default group +INSERT OR IGNORE INTO device_groups (name, description) VALUES ('default', 'Default device group'); + +-- Indexes +CREATE INDEX IF NOT EXISTS idx_devices_status ON devices(status); +CREATE INDEX IF NOT EXISTS idx_device_status_uid ON device_status(device_uid); +CREATE INDEX IF NOT EXISTS idx_status_history_device_time ON device_status_history(device_uid, reported_at); +CREATE INDEX IF NOT EXISTS idx_status_history_time ON device_status_history(reported_at); diff --git a/migrations/002_assets.sql b/migrations/002_assets.sql new file mode 100644 index 0000000..0e132af --- /dev/null +++ b/migrations/002_assets.sql @@ -0,0 +1,42 @@ +-- 002_assets.sql: Asset management tables + +CREATE TABLE IF NOT EXISTS hardware_assets ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + device_uid TEXT NOT NULL REFERENCES devices(device_uid) ON DELETE CASCADE, + cpu_model TEXT, + cpu_cores INTEGER, + memory_total_mb INTEGER, + disk_model TEXT, + disk_total_mb INTEGER, + gpu_model TEXT, + motherboard TEXT, + serial_number TEXT, + reported_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(device_uid) +); + +CREATE TABLE IF NOT EXISTS software_assets ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + device_uid TEXT NOT NULL REFERENCES devices(device_uid) ON DELETE CASCADE, + name TEXT NOT NULL, + version TEXT, + publisher TEXT, + install_date TEXT, + install_path TEXT, + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(device_uid, name, version) +); + +CREATE TABLE IF NOT EXISTS asset_changes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + device_uid TEXT NOT NULL REFERENCES devices(device_uid) ON DELETE CASCADE, + change_type TEXT NOT NULL CHECK(change_type IN ('hardware', 'software_added', 'software_removed')), + change_detail TEXT NOT NULL, + detected_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +-- Indexes +CREATE INDEX IF NOT EXISTS idx_software_device ON software_assets(device_uid); +CREATE INDEX IF NOT EXISTS idx_asset_changes_time ON asset_changes(detected_at); +CREATE INDEX IF NOT EXISTS idx_asset_changes_device ON asset_changes(device_uid); diff --git a/migrations/003_usb.sql b/migrations/003_usb.sql new file mode 100644 index 0000000..98501a2 --- /dev/null +++ b/migrations/003_usb.sql @@ -0,0 +1,28 @@ +-- 003_usb.sql: USB control tables + +CREATE TABLE IF NOT EXISTS usb_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + device_uid TEXT NOT NULL REFERENCES devices(device_uid) ON DELETE CASCADE, + vendor_id TEXT, + product_id TEXT, + serial_number TEXT, + device_name TEXT, + event_type TEXT NOT NULL CHECK(event_type IN ('inserted', 'removed', 'blocked')), + event_time TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS usb_policies ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + policy_type TEXT NOT NULL CHECK(policy_type IN ('all_block', 'whitelist', 'blacklist')), + target_group TEXT, + rules TEXT NOT NULL, + enabled INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +-- Indexes +CREATE INDEX IF NOT EXISTS idx_usb_events_device_time ON usb_events(device_uid, event_time); +CREATE INDEX IF NOT EXISTS idx_usb_events_time ON usb_events(event_time); +CREATE INDEX IF NOT EXISTS idx_usb_policies_target ON usb_policies(target_group); diff --git a/migrations/004_alerts.sql b/migrations/004_alerts.sql new file mode 100644 index 0000000..e554b65 --- /dev/null +++ b/migrations/004_alerts.sql @@ -0,0 +1,46 @@ +-- 004_alerts.sql: Alert system tables + +CREATE TABLE IF NOT EXISTS alert_rules ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + rule_type TEXT NOT NULL CHECK(rule_type IN ('device_offline', 'cpu_high', 'memory_high', 'disk_high', 'usb_unauthorized', 'usb_unauth', 'asset_change')), + condition TEXT NOT NULL, + severity TEXT NOT NULL DEFAULT 'medium' CHECK(severity IN ('low', 'medium', 'high', 'critical')), + enabled INTEGER NOT NULL DEFAULT 1, + notify_email TEXT, + notify_webhook TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS alert_records ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + rule_id INTEGER REFERENCES alert_rules(id), + device_uid TEXT REFERENCES devices(device_uid) ON DELETE SET NULL, + alert_type TEXT NOT NULL, + severity TEXT NOT NULL DEFAULT 'medium' CHECK(severity IN ('low', 'medium', 'high', 'critical')), + detail TEXT NOT NULL, + handled INTEGER NOT NULL DEFAULT 0, + handled_by TEXT, + handled_at TEXT, + triggered_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +-- Admin audit log +CREATE TABLE IF NOT EXISTS admin_audit_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id), + action TEXT NOT NULL, + target_type TEXT, + target_id TEXT, + detail TEXT, + ip_address TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +-- Indexes +CREATE INDEX IF NOT EXISTS idx_alert_records_time ON alert_records(triggered_at); +CREATE INDEX IF NOT EXISTS idx_alert_records_device ON alert_records(device_uid, triggered_at); +CREATE INDEX IF NOT EXISTS idx_alert_records_unhandled ON alert_records(handled) WHERE handled = 0; +CREATE INDEX IF NOT EXISTS idx_audit_log_user_time ON admin_audit_log(user_id, created_at); +CREATE INDEX IF NOT EXISTS idx_audit_log_time ON admin_audit_log(created_at); diff --git a/migrations/005_plugins_web_filter.sql b/migrations/005_plugins_web_filter.sql new file mode 100644 index 0000000..71d4c35 --- /dev/null +++ b/migrations/005_plugins_web_filter.sql @@ -0,0 +1,23 @@ +-- 005_plugins_web_filter.sql: Web Filter plugin (上网拦截) + +CREATE TABLE IF NOT EXISTS web_filter_rules ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + rule_type TEXT NOT NULL CHECK(rule_type IN ('blacklist', 'whitelist', 'category')), + pattern TEXT NOT NULL, + target_type TEXT NOT NULL DEFAULT 'global' CHECK(target_type IN ('global', 'group', 'device')), + target_id TEXT, + enabled INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS web_access_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + device_uid TEXT NOT NULL REFERENCES devices(device_uid) ON DELETE CASCADE, + url TEXT NOT NULL, + action TEXT NOT NULL CHECK(action IN ('allowed', 'blocked')), + timestamp TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX IF NOT EXISTS idx_web_filter_rules_type ON web_filter_rules(rule_type, enabled); +CREATE INDEX IF NOT EXISTS idx_web_access_log_device_time ON web_access_log(device_uid, timestamp); +CREATE INDEX IF NOT EXISTS idx_web_access_log_time ON web_access_log(timestamp); diff --git a/migrations/006_plugins_usage_timer.sql b/migrations/006_plugins_usage_timer.sql new file mode 100644 index 0000000..6df1f60 --- /dev/null +++ b/migrations/006_plugins_usage_timer.sql @@ -0,0 +1,24 @@ +-- 006_plugins_usage_timer.sql: Usage Timer plugin (时长记录) + +CREATE TABLE IF NOT EXISTS usage_daily ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + device_uid TEXT NOT NULL REFERENCES devices(device_uid) ON DELETE CASCADE, + date TEXT NOT NULL, + total_active_minutes INTEGER NOT NULL DEFAULT 0, + total_idle_minutes INTEGER NOT NULL DEFAULT 0, + first_active_at TEXT, + last_active_at TEXT, + UNIQUE(device_uid, date) +); + +CREATE TABLE IF NOT EXISTS app_usage_daily ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + device_uid TEXT NOT NULL REFERENCES devices(device_uid) ON DELETE CASCADE, + date TEXT NOT NULL, + app_name TEXT NOT NULL, + usage_minutes INTEGER NOT NULL DEFAULT 0, + UNIQUE(device_uid, date, app_name) +); + +CREATE INDEX IF NOT EXISTS idx_usage_daily_date ON usage_daily(date); +CREATE INDEX IF NOT EXISTS idx_app_usage_daily_date ON app_usage_daily(date); diff --git a/migrations/007_plugins_software_blocker.sql b/migrations/007_plugins_software_blocker.sql new file mode 100644 index 0000000..c81d688 --- /dev/null +++ b/migrations/007_plugins_software_blocker.sql @@ -0,0 +1,24 @@ +-- 007_plugins_software_blocker.sql: Software Blocker plugin (软件禁止安装) + +CREATE TABLE IF NOT EXISTS software_blacklist ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name_pattern TEXT NOT NULL, + category TEXT CHECK(category IN ('game', 'social', 'vpn', 'mining', 'custom')), + action TEXT NOT NULL DEFAULT 'block' CHECK(action IN ('block', 'alert')), + target_type TEXT NOT NULL DEFAULT 'global' CHECK(target_type IN ('global', 'group', 'device')), + target_id TEXT, + enabled INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS software_violations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + device_uid TEXT NOT NULL REFERENCES devices(device_uid) ON DELETE CASCADE, + software_name TEXT NOT NULL, + action_taken TEXT NOT NULL CHECK(action_taken IN ('blocked_install', 'auto_uninstalled', 'alerted')), + timestamp TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX IF NOT EXISTS idx_software_blacklist_enabled ON software_blacklist(enabled); +CREATE INDEX IF NOT EXISTS idx_software_violations_device ON software_violations(device_uid, timestamp); +CREATE INDEX IF NOT EXISTS idx_software_violations_time ON software_violations(timestamp); diff --git a/migrations/008_plugins_popup_blocker.sql b/migrations/008_plugins_popup_blocker.sql new file mode 100644 index 0000000..cf4c3bb --- /dev/null +++ b/migrations/008_plugins_popup_blocker.sql @@ -0,0 +1,23 @@ +-- 008_plugins_popup_blocker.sql: Popup Blocker plugin (弹窗拦截) + +CREATE TABLE IF NOT EXISTS popup_filter_rules ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + rule_type TEXT NOT NULL CHECK(rule_type IN ('block', 'allow')), + window_title TEXT, + window_class TEXT, + process_name TEXT, + target_type TEXT NOT NULL DEFAULT 'global' CHECK(target_type IN ('global', 'group', 'device')), + target_id TEXT, + enabled INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS popup_block_stats ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + device_uid TEXT NOT NULL REFERENCES devices(device_uid) ON DELETE CASCADE, + blocked_count INTEGER NOT NULL DEFAULT 0, + date TEXT NOT NULL, + UNIQUE(device_uid, date) +); + +CREATE INDEX IF NOT EXISTS idx_popup_rules_enabled ON popup_filter_rules(rule_type, enabled); diff --git a/migrations/009_plugins_usb_file_audit.sql b/migrations/009_plugins_usb_file_audit.sql new file mode 100644 index 0000000..7931649 --- /dev/null +++ b/migrations/009_plugins_usb_file_audit.sql @@ -0,0 +1,16 @@ +-- 009_plugins_usb_file_audit.sql: USB File Audit plugin (U盘文件操作记录) + +CREATE TABLE IF NOT EXISTS usb_file_operations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + device_uid TEXT NOT NULL REFERENCES devices(device_uid) ON DELETE CASCADE, + usb_serial TEXT, + drive_letter TEXT, + operation TEXT NOT NULL CHECK(operation IN ('create', 'delete', 'rename', 'modify')), + file_path TEXT NOT NULL, + file_size INTEGER, + timestamp TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX IF NOT EXISTS idx_usb_file_ops_device ON usb_file_operations(device_uid, timestamp); +CREATE INDEX IF NOT EXISTS idx_usb_file_ops_time ON usb_file_operations(timestamp); +CREATE INDEX IF NOT EXISTS idx_usb_file_ops_usb ON usb_file_operations(usb_serial, timestamp); diff --git a/migrations/010_plugins_watermark.sql b/migrations/010_plugins_watermark.sql new file mode 100644 index 0000000..429fe5f --- /dev/null +++ b/migrations/010_plugins_watermark.sql @@ -0,0 +1,27 @@ +-- 010_plugins_watermark.sql: Screen Watermark plugin (水印管理) + +CREATE TABLE IF NOT EXISTS watermark_config ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + target_type TEXT NOT NULL DEFAULT 'global' CHECK(target_type IN ('global', 'group', 'device')), + target_id TEXT, + content TEXT NOT NULL DEFAULT '公司名称 | {username} | {date}', + font_size INTEGER NOT NULL DEFAULT 14, + opacity REAL NOT NULL DEFAULT 0.15, + color TEXT NOT NULL DEFAULT '#808080', + angle INTEGER NOT NULL DEFAULT -30, + enabled INTEGER NOT NULL DEFAULT 1, + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +-- Plugin enable/disable state per device group +CREATE TABLE IF NOT EXISTS plugin_state ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + plugin_name TEXT NOT NULL UNIQUE CHECK(plugin_name IN ( + 'web_filter', 'usage_timer', 'software_blocker', + 'popup_blocker', 'usb_file_audit', 'watermark' + )), + enabled INTEGER NOT NULL DEFAULT 0, + target_type TEXT NOT NULL DEFAULT 'global', + target_id TEXT, + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); diff --git a/migrations/011_token_security.sql b/migrations/011_token_security.sql new file mode 100644 index 0000000..05a44b0 --- /dev/null +++ b/migrations/011_token_security.sql @@ -0,0 +1,18 @@ +-- 011_token_security.sql: Token rotation and revocation tracking + +CREATE TABLE IF NOT EXISTS revoked_token_families ( + family TEXT NOT NULL PRIMARY KEY, + user_id INTEGER NOT NULL, + revoked_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS refresh_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + family TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + expires_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_revoked_families_user ON revoked_token_families(user_id); +CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id); diff --git a/plan.md b/plan.md new file mode 100644 index 0000000..5f888b5 --- /dev/null +++ b/plan.md @@ -0,0 +1,1646 @@ +# 电脑终端监控系统开发计划 + +## 一、项目概述 + +### 1.1 项目背景 +本项目旨在开发一个企业级电脑终端监控系统,用于监控企业内部Windows电脑终端的使用行为,保护企业信息安全,提升工作效率。系统采用客户端-服务器(C/S)架构,在员工电脑上部署客户端程序,在服务器端进行集中管理和监控。 + +### 1.2 系统目标 +- **实时屏幕监控**: 实时查看员工电脑屏幕,支持多屏同时监控 +- **上网行为监控**: 监控网站访问、应用程序使用、即时通讯等 +- **文件操作监控**: 监控文件的创建、修改、删除、外发等操作 +- **外设管理**: 管控USB设备、移动存储等外设的使用 +- **资产管理**: 自动采集软硬件资产信息 +- **远程控制**: 支持远程桌面控制,便于IT运维 + +### 1.3 系统特性 +- **隐蔽性强**: 客户端后台运行,用户无感知 +- **实时性高**: 屏幕监控延迟<1秒,行为监控实时上报 +- **资源占用低**: 客户端CPU占用<5%,内存占用<100MB +- **稳定性好**: 7x24小时稳定运行,支持断线重连 +- **安全性高**: 数据加密传输,防止信息泄露 + +## 二、系统架构设计 + +### 2.1 整体架构 +采用全栈Rust架构,技术栈统一,性能卓越: + +``` +┌─────────────────────────────────────────────────────────┐ +│ 管理端(Tauri桌面应用) │ +│ Tauri + Vue.js 3 + TypeScript │ +│ - 实时屏幕墙(WebSocket接收) │ +│ - 行为审计页面 │ +│ - 资产管理页面 │ +│ - 远程控制页面 │ +│ - 报表统计页面 │ +│ 体积: ~15MB | 内存: ~50MB | 启动: 0.3-0.6秒 │ +└─────────────────────────────────────────────────────────┘ + ↓ HTTPS/WSS +┌─────────────────────────────────────────────────────────┐ +│ 服务端(Rust后端) │ +│ Actix-web / Axum + Tokio + SQLx │ +│ - 高性能异步Web框架 │ +│ - WebSocket服务(屏幕流转发) │ +│ - TCP服务(客户端连接) │ +│ - MySQL + Redis连接池 │ +│ - RESTful API │ +│ 性能: 100k+ QPS | 内存: ~100MB │ +└─────────────────────────────────────────────────────────┘ + ↓ TCP长连接 + 自定义协议 +┌─────────────────────────────────────────────────────────┐ +│ 客户端(被监控端) │ +│ Rust客户端(Windows服务) │ +│ - 屏幕捕获模块(Windows API) │ +│ - 行为监控模块(Hook技术) │ +│ - 文件监控模块(ReadDirectoryChangesW) │ +│ - 网络监控模块(WinPcap) │ +│ - 外设管理模块(WMI) │ +│ - 资产采集模块 │ +│ 体积: ~3MB | 内存: ~30MB | CPU: <1% │ +└─────────────────────────────────────────────────────────┘ +``` + +**架构优势:** +- ✅ **技术栈统一**: 客户端、服务端、管理端全部使用Rust +- ✅ **代码复用**: 共享协议定义、数据结构、工具函数 +- ✅ **性能卓越**: Rust性能接近C++,内存安全无GC +- ✅ **体积小巧**: 管理端仅15MB,客户端仅3MB +- ✅ **开发高效**: Cargo统一构建,依赖管理简单 + +### 2.2 技术栈选型 + +#### 2.2.1 客户端技术栈(被监控端) - Rust +- **开发语言**: Rust (性能高、内存安全、无GC) +- **异步运行时**: Tokio 1.x (高性能异步IO) +- **屏幕捕获**: winapi + Windows GDI API +- **图像压缩**: image crate / jpeg-decoder +- **行为Hook**: retours库(Detours的Rust绑定) / minhook +- **网络通信**: tokio::net (异步TCP/UDP) +- **数据序列化**: serde + bincode / protobuf +- **加密传输**: rustls / native-tls (TLS 1.3) +- **本地存储**: sled / rocksdb (嵌入式数据库) +- **日志**: tracing + tracing-subscriber +- **错误处理**: anyhow / thiserror + +**核心依赖:** +```toml +[dependencies] +tokio = { version = "1", features = ["full"] } +serde = { version = "1", features = ["derive"] } +bincode = "1" +image = "0.24" +retours = "0.1" # Hook库 +winapi = { version = "0.3", features = ["winuser", "wingdi"] } +rustls = "0.21" +sled = "0.34" +tracing = "0.1" +anyhow = "1" +``` + +#### 2.2.2 服务端技术栈 - Rust +- **Web框架**: Actix-web 4.x (性能最强) 或 Axum 0.6 (更现代) +- **异步运行时**: Tokio 1.x +- **WebSocket**: actix-ws / tokio-tungstenite +- **数据库连接**: SQLx (编译时检查) 或 Diesel 2.x +- **数据库**: MySQL 8.0 / PostgreSQL +- **缓存**: Redis (redis crate) +- **连接池**: deadpool / mobc +- **文件存储**: 本地文件系统 / MinIO (对象存储) +- **日志**: tracing + tracing-subscriber +- **配置管理**: config crate +- **序列化**: serde + serde_json + +**核心依赖:** +```toml +[dependencies] +actix-web = "4" +actix-ws = "0.2" +tokio = { version = "1", features = ["full"] } +sqlx = { version = "0.7", features = ["mysql", "runtime-tokio"] } +redis = { version = "0.23", features = ["tokio-comp"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tracing = "0.1" +tracing-subscriber = "0.3" +config = "0.13" +deadpool = "0.9" +``` + +#### 2.2.3 管理端技术栈 - Tauri + Vue.js +- **桌面框架**: Tauri 2.x (Rust后端 + WebView前端) +- **前端框架**: Vue.js 3 + TypeScript +- **UI组件库**: Element Plus +- **图表库**: ECharts 5 +- **状态管理**: Pinia +- **路由**: Vue Router 4 +- **HTTP客户端**: Axios / reqwest (Rust) +- **实时通信**: WebSocket (原生 / tokio-tungstenite) +- **构建工具**: Vite + Tauri CLI +- **IPC通信**: @tauri-apps/api + +**Tauri优势:** +- ✅ 体积小: 最终应用仅15-20MB (vs Electron 150MB+) +- ✅ 内存低: 空窗口仅50MB (vs Electron 300MB+) +- ✅ 启动快: 0.3-0.6秒 (vs Electron 2秒+) +- ✅ 安全性: Rust后端,细粒度权限控制 +- ✅ 原生API: 直接调用系统API,性能更好 + +**核心依赖:** +```toml +[build-dependencies] +tauri-build = { version = "2", features = [] } + +[dependencies] +tauri = { version = "2", features = ["shell-open"] } +tauri-plugin-shell = "2" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tokio = { version = "1", features = ["full"] } +reqwest = { version = "0.11", features = ["json"] } +``` + +```json +// package.json +{ + "dependencies": { + "vue": "^3.3", + "vue-router": "^4.2", + "pinia": "^2.1", + "element-plus": "^2.4", + "echarts": "^5.4", + "axios": "^1.6", + "@tauri-apps/api": "^2.0" + }, + "devDependencies": { + "@tauri-apps/cli": "^2.0", + "vite": "^5.0", + "typescript": "^5.3" + } +} +``` + +### 2.3 通信协议设计 + +#### 2.3.1 客户端与服务端通信 +采用TCP长连接 + 自定义二进制协议: + +**协议格式:** +``` +┌──────────┬──────────┬──────────┬──────────────┐ +│ 魔数(4B) │ 版本(1B) │ 类型(1B) │ 数据长度(4B) │ +├──────────┴──────────┴──────────┴──────────────┤ +│ 数据内容(变长) │ +└────────────────────────────────────────────────┘ +``` + +**消息类型:** +- 0x01: 心跳消息 +- 0x02: 屏幕数据 +- 0x03: 行为日志 +- 0x04: 文件操作日志 +- 0x05: 网络行为日志 +- 0x06: 资产信息 +- 0x07: 告警消息 +- 0x10: 策略下发 +- 0x11: 远程控制指令 + +#### 2.3.2 管理端与服务端通信 +- HTTP/HTTPS: RESTful API +- WebSocket: 实时屏幕流、告警推送 + +## 三、核心功能模块设计 + +### 3.1 屏幕监控模块(核心功能) + +#### 3.1.1 功能描述 +- **实时屏幕查看**: 管理员可实时查看任意员工电脑屏幕 +- **多屏监控墙**: 支持16/25/36屏同时监控,形成"屏幕墙" +- **屏幕快照**: 定时抓取屏幕截图并存储 +- **屏幕录像**: 按需录制屏幕操作过程 +- **远程控制**: 支持远程桌面控制,可进行远程操作 + +#### 3.1.2 技术实现(Rust) + +**客户端实现(Rust):** +```rust +// src/screen_capture.rs +use anyhow::Result; +use image::{ImageBuffer, RgbImage}; +use std::time::Duration; +use tokio::sync::mpsc::Sender; +use winapi::um::wingdi::*; +use winapi::um::winuser::*; + +pub async fn start(tx: Sender, config: Config) -> Result<()> { + let mut last_frame: Option> = None; + let mut frame_count = 0u32; + + loop { + // 1. 捕获屏幕 + let (width, height, buffer) = capture_screen()?; + + // 2. 差分检测(降低带宽70%) + let frame_data = if let Some(ref last) = last_frame { + compute_diff(&last, &buffer, width, height) + } else { + buffer.clone() + }; + + // 3. 压缩为JPEG + let compressed = compress_image(&frame_data, width, height, config.quality)?; + + // 4. 发送到服务端 + tx.send(MonitorData::Screen { + frame_id: frame_count, + width, + height, + data: compressed, + }).await?; + + last_frame = Some(buffer); + frame_count += 1; + + // 5. 控制帧率 + tokio::time::sleep(Duration::from_millis(1000 / config.fps)).await; + } +} + +// 使用Windows GDI捕获屏幕 +fn capture_screen() -> Result<(u32, u32, Vec)> { + unsafe { + let width = GetSystemMetrics(SM_CXSCREEN); + let height = GetSystemMetrics(SM_CYSCREEN); + + let hdc_screen = GetDC(std::ptr::null_mut()); + let hdc_mem = CreateCompatibleDC(hdc_screen); + let hbitmap = CreateCompatibleBitmap(hdc_screen, width, height); + + SelectObject(hdc_mem, hbitmap as _); + BitBlt(hdc_mem, 0, 0, width, height, hdc_screen, 0, 0, SRCCOPY); + + // 获取位图数据 + let mut buffer = vec![0u8; (width * height * 3) as usize]; + let mut bmi = BITMAPINFO { + bmiHeader: BITMAPINFOHEADER { + biSize: std::mem::size_of::() as u32, + biWidth: width, + biHeight: -height, // 自上而下 + biPlanes: 1, + biBitCount: 24, + biCompression: BI_RGB, + ..std::mem::zeroed() + }, + ..std::mem::zeroed() + }; + + GetDIBits(hdc_mem, hbitmap, 0, height as u32, + buffer.as_mut_ptr() as *mut _, &mut bmi, DIB_RGB_COLORS); + + DeleteObject(hbitmap as _); + DeleteDC(hdc_mem); + ReleaseDC(std::ptr::null_mut(), hdc_screen); + + Ok((width as u32, height as u32, buffer)) + } +} + +// 图像压缩 +fn compress_image(data: &[u8], width: u32, height: u32, quality: u8) -> Result> { + let img: RgbImage = ImageBuffer::from_raw(width, height, data.to_vec()) + .ok_or_else(|| anyhow::anyhow!("Failed to create image buffer"))?; + + let mut compressed = Vec::new(); + let mut encoder = jpeg_encoder::Encoder::new(&mut compressed, quality); + encoder.encode(&img, width as u16, height as u16, jpeg_encoder::ColorType::Rgb)?; + + Ok(compressed) +} + +// 差分检测 +fn compute_diff(old: &[u8], new: &[u8], width: u32, height: u32) -> Vec { + let mut diff = Vec::with_capacity(new.len()); + + for (o, n) in old.iter().zip(new.iter()) { + // 简单的差分算法,实际可优化为块匹配 + if (o as i16 - *n as i16).abs() > 10 { + diff.push(*n); + } else { + diff.push(0); // 未变化区域 + } + } + + diff +} +``` + +**服务端实现(Rust + Actix-web):** +```rust +// src/server/screen_stream.rs +use actix::prelude::*; +use actix_web_actors::ws; +use std::collections::HashMap; + +pub struct ScreenStreamManager { + // 客户端ID -> 观看者列表 + viewers: HashMap>>, +} + +impl ScreenStreamManager { + pub fn new() -> Self { + Self { + viewers: HashMap::new(), + } + } + + // 处理屏幕数据 + pub async fn handle_screen_data(&mut self, client_id: String, data: Vec) { + if let Some(viewers) = self.viewers.get(&client_id) { + // 转发给所有观看者 + for viewer in viewers { + viewer.do_send(ws::Message::Binary(data.clone().into())); + } + } + } + + // 添加观看者 + pub fn add_viewer(&mut self, client_id: String, viewer: Addr) { + self.viewers + .entry(client_id) + .or_insert_with(Vec::new) + .push(viewer); + } +} + +// WebSocket处理器 +pub struct ScreenWebSocket { + manager: Addr, + client_id: String, +} + +impl StreamHandler> for ScreenWebSocket { + fn handle(&mut self, msg: Result, ctx: &mut Self::Context) { + match msg { + Ok(ws::Message::Ping(msg)) => ctx.pong(&msg), + Ok(ws::Message::Text(text)) => { + // 处理文本消息(如切换监控目标) + self.client_id = text.to_string(); + self.manager.do_send(AddViewer { + client_id: self.client_id.clone(), + viewer: ctx.address(), + }); + } + _ => (), + } + } +} +``` + +**管理端实现(Tauri + Vue):** +```vue + + + +``` + +#### 3.1.3 性能优化 +- **差分传输**: 只传输屏幕变化区域,降低带宽70% +- **动态帧率**: 根据网络状况自动调整帧率(1-10 FPS) +- **质量自适应**: 根据带宽自动调整图像质量 +- **硬件加速**: 使用GPU加速屏幕捕获和编码 + +### 3.2 上网行为监控模块 + +#### 3.2.1 功能描述 +- **网站访问监控**: 记录访问的网站URL、标题、时间、停留时长 +- **应用程序监控**: 记录使用的应用程序、窗口标题、使用时长 +- **即时通讯监控**: 监控QQ、微信、钉钉等聊天内容 +- **邮件监控**: 监控发送的邮件内容、附件 +- **搜索关键词**: 记录搜索引擎搜索的关键词 +- **流量统计**: 统计每个应用的网络流量 + +#### 3.2.2 技术实现 + +**客户端实现:** +```cpp +// 1. 网络流量监控 - 使用WinPcap/Npcap抓包 +pcap_t* fp = pcap_open_live(adapter, 65536, 1, 1000, errbuf); +while (true) { + struct pcap_pkthdr* header; + const u_char* pkt_data; + pcap_next_ex(fp, &header, &pkt_data); + + // 解析IP头、TCP/UDP头 + // 提取源IP、目的IP、端口、协议 + // 记录到日志 +} + +// 2. 浏览器监控 - Hook网络API +HookFunction("wininet.dll", "InternetConnectA", MyInternetConnect); +HookFunction("wininet.dll", "HttpOpenRequestA", MyHttpOpenRequest); + +// 3. 应用程序监控 - 轮询活动窗口 +while (true) { + HWND hwnd = GetForegroundWindow(); + GetWindowText(hwnd, title, 256); + GetWindowThreadProcessId(hwnd, &processId); + + // 记录窗口标题、进程名、时间 + LogActivity(processId, title); + Sleep(1000); +} + +// 4. 即时通讯监控 - Hook剪贴板、键盘输入 +SetWindowsHookEx(WH_KEYBOARD_LL, KeyboardProc, NULL, 0); +SetWindowsHookEx(WH_CALLWNDPROC, CallWndProc, NULL, threadId); +``` + +**数据结构:** +```sql +-- 网站访问记录表 +CREATE TABLE web_visit_log ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + device_id VARCHAR(100) NOT NULL, + url VARCHAR(500), + title VARCHAR(255), + visit_time TIMESTAMP, + stay_duration INT, + browser VARCHAR(50), + INDEX idx_device_time (device_id, visit_time) +); + +-- 应用程序使用记录表 +CREATE TABLE app_usage_log ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + device_id VARCHAR(100) NOT NULL, + process_name VARCHAR(100), + window_title VARCHAR(255), + start_time TIMESTAMP, + end_time TIMESTAMP, + duration INT, + INDEX idx_device_time (device_id, start_time) +); + +-- 聊天记录表 +CREATE TABLE chat_log ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + device_id VARCHAR(100) NOT NULL, + app_name VARCHAR(50), + chat_type VARCHAR(20), + content TEXT, + log_time TIMESTAMP, + INDEX idx_device_time (device_id, log_time) +); +``` + +### 3.3 文件操作监控模块 + +#### 3.3.1 功能描述 +- **文件操作记录**: 记录文件的创建、修改、删除、重命名、复制 +- **文件外发监控**: 监控通过QQ、邮件、网盘等方式外发文件 +- **敏感文件识别**: 自动识别包含敏感关键词的文件 +- **文件备份**: 重要文件自动备份到服务器 +- **U盘文件监控**: 监控U盘文件的拷入拷出 + +#### 3.3.2 技术实现 + +**客户端实现:** +```cpp +// 1. 文件系统监控 - 使用ReadDirectoryChangesW +HANDLE hDir = CreateFile(path, FILE_LIST_DIRECTORY, + FILE_SHARE_READ | FILE_SHARE_WRITE, NULL, + OPEN_EXISTING, FILE_FLAG_BACKUP_SEMANTICS, NULL); + +BYTE buffer[4096]; +DWORD bytesReturned; +while (true) { + ReadDirectoryChangesW(hDir, buffer, sizeof(buffer), TRUE, + FILE_NOTIFY_CHANGE_FILE_NAME | FILE_NOTIFY_CHANGE_DIR_NAME | + FILE_NOTIFY_CHANGE_ATTRIBUTES | FILE_NOTIFY_CHANGE_SIZE | + FILE_NOTIFY_CHANGE_LAST_WRITE, + &bytesReturned, NULL, NULL); + + FILE_NOTIFY_INFORMATION* pNotify = (FILE_NOTIFY_INFORMATION*)buffer; + while (true) { + // 解析文件操作类型、文件名 + LogFileOperation(pNotify->Action, pNotify->FileName); + + if (pNotify->NextEntryOffset == 0) break; + pNotify = (FILE_NOTIFY_INFORMATION*)((BYTE*)pNotify + pNotify->NextEntryOffset); + } +} + +// 2. 敏感文件检测 +bool IsSensitiveFile(string filePath) { + // 读取文件内容 + string content = ReadFileContent(filePath); + + // 检查敏感关键词 + for (auto keyword : sensitiveKeywords) { + if (content.find(keyword) != string::npos) { + return true; + } + } + return false; +} + +// 3. 文件外发拦截 +HookFunction("kernel32.dll", "CopyFileA", MyCopyFile); +HookFunction("shell32.dll", "ShellExecuteA", MyShellExecute); +``` + +**数据结构:** +```sql +-- 文件操作记录表 +CREATE TABLE file_operation_log ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + device_id VARCHAR(100) NOT NULL, + operation_type VARCHAR(20), + file_path VARCHAR(500), + file_name VARCHAR(255), + file_size BIGINT, + operation_time TIMESTAMP, + is_sensitive TINYINT, + INDEX idx_device_time (device_id, operation_time) +); + +-- 文件外发记录表 +CREATE TABLE file_send_log ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + device_id VARCHAR(100) NOT NULL, + file_path VARCHAR(500), + send_method VARCHAR(50), + send_target VARCHAR(255), + send_time TIMESTAMP, + is_blocked TINYINT, + INDEX idx_device_time (device_id, send_time) +); +``` + +### 3.4 外设管理模块 + +#### 3.4.1 功能描述 +- **USB设备管控**: 禁止/允许USB设备使用,设置只读/只写权限 +- **移动存储管理**: 监控U盘、移动硬盘的接入和文件操作 +- **光驱管理**: 禁用光驱刻录功能 +- **蓝牙管理**: 禁用蓝牙设备 +- **打印机管理**: 监控打印操作,控制打印权限 + +#### 3.4.2 技术实现 + +**客户端实现:** +```cpp +// 1. USB设备监控 - 使用WMI查询 +HRESULT hr = CoCreateInstance(CLSID_WbemLocator, NULL, + CLSCTX_INPROC_SERVER, IID_IWbemLocator, (LPVOID*)&pLoc); +pLoc->ConnectServer(L"ROOT\\CIMV2", NULL, NULL, 0, NULL, 0, 0, &pSvc); + +// 查询USB设备 +IEnumWbemClassObject* pEnumerator; +pSvc->ExecQuery(L"WQL", L"SELECT * FROM Win32_USBHub", + WBEM_FLAG_FORWARD_ONLY | WBEM_FLAG_RETURN_IMMEDIATELY, NULL, &pEnumerator); + +// 2. USB设备拦截 - 修改注册表 +// 禁用USB存储设备 +RegSetValueEx(HKEY_LOCAL_MACHINE, + L"SYSTEM\\CurrentControlSet\\Services\\USBSTOR", + L"Start", 0, REG_DWORD, (BYTE*)&value, sizeof(value)); + +// 3. 设备变化通知 +DEV_BROADCAST_DEVICEINTERFACE NotificationFilter; +NotificationFilter.dbcc_size = sizeof(DEV_BROADCAST_DEVICEINTERFACE); +NotificationFilter.dbcc_devicetype = DBT_DEVTYP_DEVICEINTERFACE; +NotificationFilter.dbcc_classguid = GUID_DEVINTERFACE_USB_DEVICE; + +HWND hWnd = CreateWindow(L"Static", NULL, 0, 0, 0, 0, 0, NULL, NULL, NULL, NULL); +RegisterDeviceNotification(hWnd, &NotificationFilter, DEVICE_NOTIFY_WINDOW_HANDLE); + +// 在窗口过程中处理WM_DEVICECHANGE消息 +``` + +**数据结构:** +```sql +-- USB设备接入记录表 +CREATE TABLE usb_device_log ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + device_id VARCHAR(100) NOT NULL, + usb_device_id VARCHAR(100), + usb_device_name VARCHAR(255), + vendor_id VARCHAR(50), + product_id VARCHAR(50), + action VARCHAR(20), + action_time TIMESTAMP, + INDEX idx_device_time (device_id, action_time) +); + +-- 外设策略表 +CREATE TABLE device_policy ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + policy_name VARCHAR(100), + device_type VARCHAR(50), + policy_type VARCHAR(20), + policy_value TEXT, + status TINYINT DEFAULT 1, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); +``` + +### 3.5 资产管理模块 + +#### 3.5.1 功能描述 +- **硬件资产**: CPU、内存、硬盘、网卡、显卡等硬件信息 +- **软件资产**: 已安装软件列表、版本、安装时间 +- **系统信息**: 操作系统版本、补丁列表、系统配置 +- **资产变更**: 监控硬件更换、软件安装卸载 +- **资产统计**: 生成资产清单和统计报表 + +#### 3.5.2 技术实现 + +**客户端实现:** +```cpp +// 1. 硬件信息采集 +void CollectHardwareInfo() { + // CPU信息 + SYSTEM_INFO sysInfo; + GetSystemInfo(&sysInfo); + + // 内存信息 + MEMORYSTATUSEX memInfo; + memInfo.dwLength = sizeof(memInfo); + GlobalMemoryStatusEx(&memInfo); + + // 硬盘信息 + GetDiskFreeSpaceEx(L"C:\\", &freeBytes, &totalBytes, &totalFreeBytes); + + // 网卡信息 + PIP_ADAPTER_INFO pAdapterInfo; + GetAdaptersInfo(pAdapterInfo, &ulOutBufLen); + + // 发送到服务端 + SendHardwareInfo(cpu, memory, disk, network); +} + +// 2. 软件信息采集 - 读取注册表 +void CollectSoftwareInfo() { + HKEY hKey; + RegOpenKeyEx(HKEY_LOCAL_MACHINE, + L"SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall", + 0, KEY_READ, &hKey); + + DWORD dwIndex = 0; + TCHAR szSubKey[MAX_PATH]; + while (RegEnumKey(hKey, dwIndex, szSubKey, MAX_PATH) == ERROR_SUCCESS) { + // 读取软件名称、版本、安装时间 + HKEY hSubKey; + RegOpenKeyEx(hKey, szSubKey, 0, KEY_READ, &hSubKey); + + QueryValue(hSubKey, L"DisplayName", softwareName); + QueryValue(hSubKey, L"DisplayVersion", version); + QueryValue(hSubKey, L"InstallDate", installDate); + + RegCloseKey(hSubKey); + dwIndex++; + } + RegCloseKey(hKey); +} +``` + +**数据结构:** +```sql +-- 硬件资产表 +CREATE TABLE hardware_asset ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + device_id VARCHAR(100) NOT NULL UNIQUE, + cpu_model VARCHAR(100), + cpu_cores INT, + memory_size BIGINT, + disk_size BIGINT, + disk_model VARCHAR(100), + network_card VARCHAR(100), + mac_address VARCHAR(50), + graphics_card VARCHAR(100), + motherboard VARCHAR(100), + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +); + +-- 软件资产表 +CREATE TABLE software_asset ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + device_id VARCHAR(100) NOT NULL, + software_name VARCHAR(255), + software_version VARCHAR(50), + publisher VARCHAR(100), + install_date DATE, + install_location VARCHAR(255), + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_device (device_id) +); + +-- 资产变更记录表 +CREATE TABLE asset_change_log ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + device_id VARCHAR(100) NOT NULL, + change_type VARCHAR(50), + old_value TEXT, + new_value TEXT, + change_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + INDEX idx_device_time (device_id, change_time) +); +``` + +### 3.6 远程控制模块 + +#### 3.6.1 功能描述 +- **远程桌面**: 远程查看和控制员工电脑桌面 +- **远程命令**: 远程执行命令、启动程序 +- **远程文件管理**: 远程浏览、上传、下载文件 +- **远程开关机**: 远程关机、重启、注销 +- **消息通知**: 向员工电脑发送通知消息 + +#### 3.6.2 技术实现 + +**客户端实现:** +```cpp +// 1. 远程桌面控制 - 接收鼠标键盘事件 +void OnRemoteControlData(ControlData* data) { + switch (data->type) { + case MOUSE_MOVE: + SetCursorPos(data->x, data->y); + break; + case MOUSE_CLICK: + mouse_event(MOUSEEVENTF_LEFTDOWN, 0, 0, 0, 0); + mouse_event(MOUSEEVENTF_LEFTUP, 0, 0, 0, 0); + break; + case KEY_PRESS: + keybd_event(data->vkCode, 0, 0, 0); + keybd_event(data->vkCode, 0, KEYEVENTF_KEYUP, 0); + break; + } +} + +// 2. 远程命令执行 +void ExecuteRemoteCommand(string command) { + STARTUPINFO si = {sizeof(si)}; + PROCESS_INFORMATION pi; + CreateProcess(NULL, (LPSTR)command.c_str(), NULL, NULL, + FALSE, 0, NULL, NULL, &si, &pi); +} + +// 3. 远程文件管理 +void HandleFileOperation(FileOperation* op) { + switch (op->type) { + case LIST_FILES: + ListFiles(op->path); + break; + case UPLOAD_FILE: + ReceiveFile(op->remotePath); + break; + case DOWNLOAD_FILE: + SendFile(op->localPath); + break; + case DELETE_FILE: + DeleteFile(op->path); + break; + } +} +``` + +### 3.7 告警与通知模块 + +#### 3.7.1 功能描述 +- **实时告警**: 检测到违规行为时立即告警 +- **告警规则**: 可配置告警触发条件和通知方式 +- **通知方式**: 支持弹窗、邮件、企业微信、钉钉等 +- **告警处理**: 记录告警处理过程和结果 + +#### 3.7.2 告警规则示例 +```sql +-- 告警规则表 +CREATE TABLE alarm_rule ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + rule_name VARCHAR(100) NOT NULL, + rule_type VARCHAR(50), + condition_json TEXT, + notify_methods VARCHAR(255), + notify_receivers VARCHAR(500), + severity VARCHAR(20), + status TINYINT DEFAULT 1, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- 告警记录表 +CREATE TABLE alarm_record ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + device_id VARCHAR(100), + rule_id BIGINT, + alarm_type VARCHAR(50), + alarm_level VARCHAR(20), + alarm_content VARCHAR(500), + alarm_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + status TINYINT DEFAULT 0, + handler VARCHAR(50), + handle_time TIMESTAMP, + handle_remark VARCHAR(255), + INDEX idx_device_time (device_id, alarm_time) +); +``` + +### 3.8 权限管理模块 + +#### 3.8.1 功能描述 +- **用户管理**: 管理员账号的增删改查 +- **角色管理**: 定义不同角色的权限 +- **权限控制**: 控制用户可访问的功能和数据 +- **操作审计**: 记录管理员的操作日志 + +#### 3.8.2 数据结构 +```sql +-- 用户表 +CREATE TABLE sys_user ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + username VARCHAR(50) NOT NULL UNIQUE, + password VARCHAR(255) NOT NULL, + real_name VARCHAR(50), + email VARCHAR(100), + phone VARCHAR(20), + role_id BIGINT, + status TINYINT DEFAULT 1, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- 角色表 +CREATE TABLE sys_role ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + role_name VARCHAR(50) NOT NULL, + role_code VARCHAR(50) UNIQUE, + permissions TEXT, + status TINYINT DEFAULT 1, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- 操作日志表 +CREATE TABLE operation_log ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + user_id BIGINT, + username VARCHAR(50), + operation_type VARCHAR(50), + operation_module VARCHAR(50), + operation_desc VARCHAR(255), + ip_address VARCHAR(50), + operation_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + INDEX idx_user_time (user_id, operation_time) +); +``` + +## 四、客户端详细设计(Rust实现) + +### 4.1 客户端架构 + +``` +┌─────────────────────────────────────────────────────────┐ +│ 客户端主程序(Rust) │ +├─────────────────────────────────────────────────────────┤ +│ 配置管理 │ 日志管理 │ 进程守护 │ 自动更新 │ +│ (config crate) │ (tracing) │ (windows-service) │ +└─────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────┐ +│ 功能模块层 │ +├──────────┬──────────┬──────────┬──────────┬──────────┤ +│ 屏幕捕获 │ 行为监控 │ 文件监控 │ 网络监控 │ 外设管理 │ +│(winapi) │(retours) │(notify) │(pcap) │(wmi) │ +└──────────┴──────────┴──────────┴──────────┴──────────┘ + ↓ +┌─────────────────────────────────────────────────────────┐ +│ 基础服务层 │ +├──────────┬──────────┬──────────┬──────────┬──────────┤ +│ 网络通信 │ 数据压缩 │ 加密传输 │ 本地存储 │ 序列化 │ +│(tokio) │(image) │(rustls) │(sled) │(serde) │ +└──────────┴──────────┴──────────┴──────────┴──────────┘ +``` + +### 4.2 客户端核心流程(Rust) + +```rust +// src/main.rs +use anyhow::Result; +use tokio::sync::mpsc; +use tracing::{info, error}; + +mod screen_capture; +mod behavior_monitor; +mod file_monitor; +mod network_monitor; +mod device_manager; +mod network; +mod config; + +#[tokio::main] +async fn main() -> Result<()> { + // 1. 初始化日志 + tracing_subscriber::fmt::init(); + info!("客户端启动中..."); + + // 2. 加载配置 + let config = config::load_config()?; + + // 3. 注册为Windows服务(隐蔽运行) + #[cfg(target_os = "windows")] + { + windows_service::install_service("MonitorService")?; + } + + // 4. 创建通信通道 + let (tx, rx) = mpsc::channel(1000); + + // 5. 启动各功能模块(异步任务) + let screen_handle = tokio::spawn(screen_capture::start(tx.clone(), config.clone())); + let behavior_handle = tokio::spawn(behavior_monitor::start(tx.clone(), config.clone())); + let file_handle = tokio::spawn(file_monitor::start(tx.clone(), config.clone())); + let network_handle = tokio::spawn(network_monitor::start(tx.clone(), config.clone())); + let device_handle = tokio::spawn(device_manager::start(tx.clone(), config.clone())); + + // 6. 启动网络客户端 + let mut client = network::Client::new(config.server_addr).await?; + + // 7. 主循环 + loop { + tokio::select! { + // 处理监控数据 + Some(data) = rx.recv() => { + client.send_data(data).await?; + } + + // 处理服务器指令 + cmd = client.recv_command() => { + handle_command(cmd?).await?; + } + + // 发送心跳 + _ = tokio::time::sleep(Duration::from_secs(30)) => { + client.send_heartbeat().await?; + } + } + } +} + +async fn handle_command(cmd: Command) -> Result<()> { + match cmd { + Command::UpdateConfig(new_config) => { + config::update_config(new_config)?; + } + Command::RemoteControl(action) => { + execute_remote_control(action).await?; + } + Command::StopMonitoring => { + info!("收到停止监控指令"); + } + } + Ok(()) +} +} +``` + +### 4.3 客户端隐蔽技术 + +```cpp +// 1. 进程隐藏 +void HideProcess() { + // 方法1: 修改PEB + typedef NTSTATUS(NTAPI* pNtSetInformationProcess)(HANDLE, ULONG, PVOID, ULONG); + pNtSetInformationProcess NtSetInformationProcess = + (pNtSetInformationProcess)GetProcAddress(GetModuleHandle(L"ntdll.dll"), "NtSetInformationProcess"); + + ULONG Hide = 1; + NtSetInformationProcess(GetCurrentProcess(), 0x1D, &Hide, sizeof(Hide)); + + // 方法2: 注册为系统服务 + SC_HANDLE hSCManager = OpenSCManager(NULL, NULL, SC_MANAGER_CREATE_SERVICE); + SC_HANDLE hService = CreateService(hSCManager, L"MonitorService", L"System Monitor Service", + SERVICE_ALL_ACCESS, SERVICE_WIN32_OWN_PROCESS, SERVICE_AUTO_START, SERVICE_ERROR_NORMAL, + L"C:\\Windows\\System32\\monitor.exe", NULL, NULL, NULL, NULL, NULL); +} + +// 2. 文件隐藏 +void HideFile(LPCWSTR filePath) { + SetFileAttributes(filePath, FILE_ATTRIBUTE_HIDDEN | FILE_ATTRIBUTE_SYSTEM); +} + +// 3. 注册表启动项隐藏 +void InstallToRegistry() { + HKEY hKey; + RegOpenKeyEx(HKEY_LOCAL_MACHINE, L"SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Run", + 0, KEY_WRITE, &hKey); + RegSetValueEx(hKey, L"SystemMonitor", 0, REG_SZ, (BYTE*)L"C:\\Windows\\System32\\monitor.exe", + wcslen(L"C:\\Windows\\System32\\monitor.exe") * sizeof(WCHAR)); + RegCloseKey(hKey); +} + +// 4. 防止被杀毒软件查杀 +void AvoidAntivirus() { + // 使用合法的数字签名 + // 避免使用敏感API + // 使用代码混淆 + // 定期更新特征码 +} +``` + +## 五、服务端详细设计(Rust实现) + +### 5.1 服务端架构 + +``` +┌─────────────────────────────────────────────────────────┐ +│ 管理端(Tauri桌面应用) │ +│ Tauri + Vue.js前端 │ +└─────────────────────────────────────────────────────────┘ + ↓ HTTPS/WSS +┌─────────────────────────────────────────────────────────┐ +│ Rust服务端 │ +│ Actix-web / Axum + Tokio │ +├─────────────────────────────────────────────────────────┤ +│ HTTP API: RESTful接口 │ +│ WebSocket: 实时屏幕流转发 │ +│ TCP Server: 客户端连接管理 │ +│ Actor System: 业务逻辑处理 │ +├─────────────────────────────────────────────────────────┤ +│ 数据访问层: SQLx / Diesel │ +│ 缓存层: Redis连接池 │ +│ 定时任务: tokio-cron-scheduler │ +└─────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────┐ +│ 数据存储层 │ +│ MySQL: 业务数据 │ +│ Redis: 缓存、会话 │ +│ 文件系统: 屏幕快照、录像 │ +└─────────────────────────────────────────────────────────┘ +``` + +### 5.2 核心服务实现(Rust) + +```rust +// src/main.rs +use actix_web::{middleware, web, App, HttpServer}; +use actix::Actor; +use sqlx::mysql::MySqlPoolOptions; + +mod api; +mod client_handler; +mod screen_stream; +mod models; +mod db; + +#[actix_web::main] +async fn main() -> std::io::Result<()> { + // 1. 初始化日志 + tracing_subscriber::fmt::init(); + + // 2. 连接数据库 + let db_pool = MySqlPoolOptions::new() + .max_connections(10) + .connect("mysql://user:pass@localhost/monitor_db") + .await + .expect("Failed to connect to database"); + + // 3. 连接Redis + let redis_client = redis::Client::open("redis://127.0.0.1/") + .expect("Failed to connect to Redis"); + + // 4. 启动客户端连接管理器(Actor) + let client_manager = client_handler::ClientManager::new(db_pool.clone()).start(); + + // 5. 启动屏幕流管理器(Actor) + let screen_manager = screen_stream::ScreenStreamManager::new().start(); + + // 6. 启动TCP服务(接收客户端连接) + tokio::spawn(client_handler::start_tcp_server( + "0.0.0.0:9999".to_string(), + client_manager.clone(), + )); + + // 7. 启动HTTP服务 + HttpServer::new(move || { + App::new() + .app_data(web::Data::new(db_pool.clone())) + .app_data(web::Data::new(redis_client.clone())) + .app_data(web::Data::new(client_manager.clone())) + .app_data(web::Data::new(screen_manager.clone())) + .wrap(middleware::Logger::default()) + .service(api::routes()) + }) + .bind("0.0.0.0:8080")? + .run() + .await +} + +// src/client_handler.rs +use actix::prelude::*; +use std::collections::HashMap; +use tokio::net::TcpListener; + +pub struct ClientManager { + sessions: HashMap>, + db_pool: sqlx::MySqlPool, +} + +impl Actor for ClientManager { + type Context = Context; +} + +// 处理客户端连接 +#[derive(Message)] +#[rtype(result = "()")] +pub struct ClientConnected { + pub client_id: String, + pub addr: Addr, +} + +impl Handler for ClientManager { + type Result = (); + + async fn handle(&mut self, msg: ClientConnected, _: &mut Self::Context) { + self.sessions.insert(msg.client_id.clone(), msg.addr); + + // 更新数据库中的在线状态 + sqlx::query!( + "UPDATE devices SET online_status = 1, last_heartbeat = NOW() WHERE device_id = ?", + msg.client_id + ) + .execute(&self.db_pool) + .await + .ok(); + } +} + +// TCP服务器 +pub async fn start_tcp_server(addr: String, manager: Addr) -> std::io::Result<()> { + let listener = TcpListener::bind(&addr).await?; + info!("TCP server listening on {}", addr); + + loop { + let (stream, addr) = listener.accept().await?; + let manager = manager.clone(); + + tokio::spawn(async move { + // 处理客户端连接 + ClientSession::new(stream, manager).await; + }); + } +} + +// src/api.rs +use actix_web::{web, HttpResponse, Result}; +use serde::{Deserialize, Serialize}; + +#[derive(Deserialize)] +pub struct LoginRequest { + username: String, + password: String, +} + +#[derive(Serialize)] +pub struct LoginResponse { + token: String, + user: UserInfo, +} + +// 用户登录 +pub async fn login( + req: web::Json, + db: web::Data, +) -> Result { + // 验证用户 + let user = sqlx::query_as!( + User, + "SELECT * FROM sys_user WHERE username = ? AND password = ?", + req.username, + req.password + ) + .fetch_optional(db.get_ref()) + .await?; + + match user { + Some(user) => { + // 生成JWT token + let token = generate_token(&user)?; + + Ok(HttpResponse::Ok().json(LoginResponse { + token, + user: UserInfo::from(user), + })) + } + None => Ok(HttpResponse::Unauthorized().finish()), + } +} + +// 获取设备列表 +pub async fn get_devices( + db: web::Data, +) -> Result { + let devices = sqlx::query_as!( + Device, + "SELECT * FROM devices ORDER BY created_at DESC" + ) + .fetch_all(db.get_ref()) + .await?; + + Ok(HttpResponse::Ok().json(devices)) +} + +pub fn routes() -> actix_web::Scope { + web::scope("/api") + .route("/login", web::post().to(login)) + .route("/devices", web::get().to(get_devices)) + // ... 其他路由 +} +``` +``` + +## 六、管理端界面设计 + +### 6.1 主要页面 + +#### 6.1.1 实时监控页面 +```vue + + + +``` + +#### 6.1.2 行为审计页面 +```vue + +``` + +## 七、开发计划 + +### 7.1 开发阶段划分(Rust + Tauri) + +#### 第一阶段: 基础框架搭建(1.5周) +- 搭建Rust项目框架(Cargo workspace) +- 设计并创建数据库表 +- 开发用户权限管理模块(Actix-web + SQLx) +- 搭建Tauri + Vue.js前端项目 +- 实现基础API接口 +- 配置CI/CD流水线 + +#### 第二阶段: 客户端开发(2.5周) +- 开发Rust客户端基础框架(Tokio异步运行时) +- 实现屏幕捕获功能(Windows API + image crate) +- 实现行为监控功能(retours Hook库) +- 实现文件监控功能(notify crate) +- 实现网络监控功能(pcap crate) +- 实现外设管理功能(WMI) +- 实现与服务端的TCP通信 + +#### 第三阶段: 服务端开发(2周) +- 实现客户端连接管理(TCP Server + Actor) +- 实现屏幕数据转发(WebSocket) +- 实现行为日志处理(SQLx + Redis) +- 实现告警规则引擎 +- 实现远程控制功能 + +#### 第四阶段: 管理端开发(1.5周) +- 实现实时监控页面(Tauri + Vue) +- 实现行为审计页面 +- 实现资产管理页面 +- 实现远程控制页面 +- 实现报表统计页面 + +#### 第五阶段: 测试与优化(1.5周) +- 功能测试 +- 性能测试(压力测试) +- 兼容性测试(Windows各版本) +- 安全测试(渗透测试) +- Bug修复 + +#### 第六阶段: 部署与上线(1周) +- 编写部署文档 +- 生产环境部署(Docker + Kubernetes) +- 用户培训 +- 系统上线 + +**总计: 10周(2.5个月)** + +### 7.2 开发团队配置(优化后) +- **项目经理**: 1人 +- **Rust开发工程师**: 3人(负责客户端+服务端开发) + - 1人专攻客户端(Windows API + Hook) + - 1人专攻服务端(Actix-web + Actor) + - 1人负责公共模块和协议 +- **前端开发工程师**: 1人(负责Tauri + Vue界面) +- **测试工程师**: 1人 +- **运维工程师**: 1人 + +**团队优势:** +- ✅ 技术栈统一,沟通成本低 +- ✅ 代码复用率高,开发效率高 +- ✅ Rust安全性强,减少Bug数量 +- ✅ 团队规模精简,管理成本低 + +### 7.3 技术风险与应对 + +#### 风险1: Rust学习曲线 +- **应对**: + - 提供Rust培训课程 + - 建立代码审查机制 + - 使用成熟的crates生态 + - 编写详细的开发文档 + +#### 风险2: 客户端被杀毒软件拦截 +- **应对**: + - 申请代码数字签名 + - 加入杀毒软件白名单 + - 使用合法的Windows API + - 避免使用敏感特征 + +#### 风险3: 屏幕监控性能问题 +- **应对**: + - 使用差分传输技术 + - 动态调整帧率和质量 + - 使用硬件加速(可选) + - 优化压缩算法 + +#### 风险4: 大规模客户端接入 +- **应对**: + - Tokio异步运行时(支持百万级并发) + - Actor模型处理业务逻辑 + - 数据库连接池优化 + - Redis缓存热点数据 + +#### 风险5: 跨平台兼容性 +- **应对**: + - 使用条件编译(#[cfg(target_os)]) + - 抽象平台相关代码 + - 多平台测试 + - 使用跨平台crates + +## 八、部署架构 + +### 8.1 生产环境部署 + +``` +┌─────────────────────────────────────────────────────────┐ +│ 负载均衡 │ +│ Nginx (HTTPS) │ +└─────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────┐ +│ 应用服务器 │ +│ Spring Boot应用 (多实例部署) │ +│ - HTTP服务: 8080 │ +│ - WebSocket服务: 8080 │ +│ - TCP服务: 9999 │ +└─────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────┐ +│ 数据库服务器 │ +│ MySQL 8.0 (主从复制) │ +│ Redis 7.0 (哨兵模式) │ +└─────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────┐ +│ 文件存储 │ +│ NFS / MinIO (屏幕快照、录像) │ +└─────────────────────────────────────────────────────────┘ +``` + +### 8.2 客户端部署 +- 制作客户端安装包(包含依赖库) +- 通过域控或手动方式分发安装 +- 客户端自动注册到服务端 +- 支持静默安装和自动更新 + +## 九、性能指标(Rust + Tauri优化版) + +### 9.1 客户端性能(Rust) +- **CPU占用**: < 1% (vs C++ 5%) +- **内存占用**: ~30MB (vs C++ 100MB) +- **磁盘占用**: ~3MB可执行文件 (vs C++ 10MB) +- **网络带宽**: < 300KB/s (差分传输) +- **启动时间**: < 100ms + +**性能优势:** +- ✅ Rust零成本抽象,性能接近C +- ✅ 无GC停顿,实时性更好 +- ✅ 内存占用低,资源消耗小 +- ✅ 编译优化,二进制体积小 + +### 9.2 服务端性能(Rust) +- **并发连接**: 支持10,000+客户端同时在线 +- **API响应时间**: < 50ms (vs Java 200ms) +- **屏幕流转发延迟**: < 200ms (vs Java 500ms) +- **数据库查询**: < 50ms +- **内存占用**: ~100MB (vs Java 500MB) +- **吞吐量**: 100k+ QPS (Actix-web) + +**性能优势:** +- ✅ Tokio异步运行时,高并发低延迟 +- ✅ Actor模型,无锁并发 +- ✅ 零拷贝技术,减少内存分配 +- ✅ 编译期优化,运行效率高 + +### 9.3 管理端性能(Tauri) +- **应用体积**: ~15MB (vs Electron 150MB) +- **内存占用**: ~50MB (vs Electron 300MB) +- **启动时间**: 0.3-0.6秒 (vs Electron 2秒+) +- **CPU占用**: < 1% (空闲状态) + +**性能优势:** +- ✅ 使用系统WebView,无需打包浏览器 +- ✅ Rust后端,原生性能 +- ✅ 体积小,下载快 +- ✅ 启动快,用户体验好 + +### 9.4 系统可用性 +- **系统可用性**: > 99.9% (Rust内存安全) +- **数据保留时长**: 90天 +- **故障恢复时间**: < 10分钟 +- **平均无故障时间(MTBF)**: > 10,000小时 + +### 9.5 性能对比总结 + +| 指标 | C++ + Java方案 | Rust + Tauri方案 | 提升 | +|------|---------------|-----------------|------| +| 客户端CPU | 5% | 1% | ⬇️ 80% | +| 客户端内存 | 100MB | 30MB | ⬇️ 70% | +| 服务端内存 | 500MB | 100MB | ⬇️ 80% | +| API响应 | 200ms | 50ms | ⚡ 4倍 | +| 管理端体积 | 150MB | 15MB | ⬇️ 90% | +| 启动时间 | 2秒 | 0.5秒 | ⚡ 4倍 | +| 并发连接 | 1,000 | 10,000 | ⬆️ 10倍 | + +## 十、安全设计 + +### 10.1 客户端安全 +- 代码混淆和加密 +- 反调试和反逆向 +- 数字签名验证 +- 定期自检和更新 + +### 10.2 通信安全 +- TLS 1.3加密传输 +- 客户端证书认证 +- 数据完整性校验 +- 防重放攻击 + +### 10.3 数据安全 +- 敏感数据加密存储 +- 数据库访问控制 +- 定期数据备份 +- 数据脱敏展示 + +### 10.4 访问控制 +- 基于角色的权限控制 +- 操作审计日志 +- 登录失败锁定 +- 会话超时控制 + +## 十一、项目交付物 + +### 11.1 软件交付物 +- 客户端安装包(Windows) +- 服务端部署包 +- 数据库初始化脚本 +- 配置文件模板 + +### 11.2 文档交付物 +- 系统架构设计文档 +- 数据库设计文档 +- API接口文档 +- 客户端开发文档 +- 部署运维手册 +- 用户操作手册 + +### 11.3 其他交付物 +- 源代码(Git仓库) +- 测试报告 +- 性能测试报告 +- 安全测试报告 + +## 十二、总结 + +本系统采用C/S架构,专注于企业内部Windows电脑终端的实时监控和管理。核心功能包括: +1. **实时屏幕监控**: 支持多屏同时监控,延迟<1秒 +2. **上网行为监控**: 全面的网络行为审计 +3. **文件操作监控**: 防止敏感数据泄露 +4. **外设管理**: 管控USB等外设使用 +5. **资产管理**: 自动采集软硬件资产 +6. **远程控制**: 支持远程桌面和命令执行 + +系统设计注重实用性、稳定性和隐蔽性,能够满足企业对终端安全管理的实际需求。通过分阶段开发,确保项目按时高质量交付。 diff --git a/test_route.rs b/test_route.rs new file mode 100644 index 0000000..fc4d593 --- /dev/null +++ b/test_route.rs @@ -0,0 +1,27 @@ +use axum::{Router, routing::get,}; + +async fn hello() -> &'static str' { + "Hello" +} + +async fn hello_id() -> &'static str' { + "Hello ID" +} + +#[tokio::main] +async fn main() { + let read = Router::new() + .route("/test", get(hello)) + .route("/test/{id}", get(hello_id)); + + let write = Router::new() + .route("/test/{id}", get(hello_id)); + + let app = Router::new() + .merge(read) + .merge(write); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:9999").await.unwrap(); + axum::serve(listener, app).await; + println!("Server running on 9999"); +} diff --git a/web/auto-imports.d.ts b/web/auto-imports.d.ts new file mode 100644 index 0000000..1d89ee8 --- /dev/null +++ b/web/auto-imports.d.ts @@ -0,0 +1,9 @@ +/* eslint-disable */ +/* prettier-ignore */ +// @ts-nocheck +// noinspection JSUnusedGlobalSymbols +// Generated by unplugin-auto-import +export {} +declare global { + +} diff --git a/web/components.d.ts b/web/components.d.ts new file mode 100644 index 0000000..5fc9519 --- /dev/null +++ b/web/components.d.ts @@ -0,0 +1,53 @@ +/* eslint-disable */ +/* prettier-ignore */ +// @ts-nocheck +// Generated by unplugin-vue-components +// Read more: https://github.com/vuejs/core/pull/3399 +export {} + +declare module 'vue' { + export interface GlobalComponents { + ElAside: typeof import('element-plus/es')['ElAside'] + ElBadge: typeof import('element-plus/es')['ElBadge'] + ElButton: typeof import('element-plus/es')['ElButton'] + ElCard: typeof import('element-plus/es')['ElCard'] + ElCol: typeof import('element-plus/es')['ElCol'] + ElColorPicker: typeof import('element-plus/es')['ElColorPicker'] + ElContainer: typeof import('element-plus/es')['ElContainer'] + ElDescriptions: typeof import('element-plus/es')['ElDescriptions'] + ElDescriptionsItem: typeof import('element-plus/es')['ElDescriptionsItem'] + ElDialog: typeof import('element-plus/es')['ElDialog'] + ElDropdown: typeof import('element-plus/es')['ElDropdown'] + ElDropdownItem: typeof import('element-plus/es')['ElDropdownItem'] + ElDropdownMenu: typeof import('element-plus/es')['ElDropdownMenu'] + ElEmpty: typeof import('element-plus/es')['ElEmpty'] + ElForm: typeof import('element-plus/es')['ElForm'] + ElFormItem: typeof import('element-plus/es')['ElFormItem'] + ElHeader: typeof import('element-plus/es')['ElHeader'] + ElIcon: typeof import('element-plus/es')['ElIcon'] + ElInput: typeof import('element-plus/es')['ElInput'] + ElInputNumber: typeof import('element-plus/es')['ElInputNumber'] + ElMain: typeof import('element-plus/es')['ElMain'] + ElMenu: typeof import('element-plus/es')['ElMenu'] + ElMenuItem: typeof import('element-plus/es')['ElMenuItem'] + ElOption: typeof import('element-plus/es')['ElOption'] + ElPageHeader: typeof import('element-plus/es')['ElPageHeader'] + ElPagination: typeof import('element-plus/es')['ElPagination'] + ElProgress: typeof import('element-plus/es')['ElProgress'] + ElRow: typeof import('element-plus/es')['ElRow'] + ElSelect: typeof import('element-plus/es')['ElSelect'] + ElSlider: typeof import('element-plus/es')['ElSlider'] + ElSubMenu: typeof import('element-plus/es')['ElSubMenu'] + ElSwitch: typeof import('element-plus/es')['ElSwitch'] + ElTable: typeof import('element-plus/es')['ElTable'] + ElTableColumn: typeof import('element-plus/es')['ElTableColumn'] + ElTabPane: typeof import('element-plus/es')['ElTabPane'] + ElTabs: typeof import('element-plus/es')['ElTabs'] + ElTag: typeof import('element-plus/es')['ElTag'] + RouterLink: typeof import('vue-router')['RouterLink'] + RouterView: typeof import('vue-router')['RouterView'] + } + export interface ComponentCustomProperties { + vLoading: typeof import('element-plus/es')['ElLoadingDirective'] + } +} diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..96023d5 --- /dev/null +++ b/web/index.html @@ -0,0 +1,13 @@ + + + + + + CSM - 企业终端管理系统 + + + +
+ + + diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 0000000..453030e --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,2759 @@ +{ + "name": "csm-web", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "csm-web", + "version": "0.1.0", + "dependencies": { + "@vueuse/core": "^10.7.2", + "axios": "^1.6.7", + "dayjs": "^1.11.10", + "echarts": "^5.5.0", + "element-plus": "^2.5.6", + "pinia": "^2.1.7", + "vue": "^3.4.21", + "vue-router": "^4.2.5" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.0.3", + "typescript": "^5.3.3", + "unplugin-auto-import": "^0.17.5", + "unplugin-vue-components": "^0.26.0", + "vite": "^5.0.12", + "vue-tsc": "^3.2.6" + } + }, + "node_modules/@antfu/utils": { + "version": "0.7.10", + "resolved": "https://registry.npmjs.org/@antfu/utils/-/utils-0.7.10.tgz", + "integrity": "sha512-+562v9k4aI80m1+VuMHehNJWLOFjBnXn3tdOitzD0il5b7smkSBal4+a3oKiQTbrwMmN/TBUMDvbdoWDehgOww==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@ctrl/tinycolor": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@ctrl/tinycolor/-/tinycolor-4.2.0.tgz", + "integrity": "sha512-kzyuwOAQnXJNLS9PSyrk0CWk35nWJW/zl/6KvnTBMFK65gm7U1/Z5BqjxeapjZCIhQcM/DsrEmcbRwDyXyXK4A==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/@element-plus/icons-vue": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/@element-plus/icons-vue/-/icons-vue-2.3.2.tgz", + "integrity": "sha512-OzIuTaIfC8QXEPmJvB4Y4kw34rSXdCJzxcD1kFStBvr8bK6X1zQAYDo0CNMjojnfTqRQCJ0I7prlErcoRiET2A==", + "license": "MIT", + "peerDependencies": { + "vue": "^3.2.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "license": "MIT" + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@popperjs/core": { + "name": "@sxzz/popperjs-es", + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@sxzz/popperjs-es/-/popperjs-es-2.11.8.tgz", + "integrity": "sha512-wOwESXvvED3S8xBmcPWHs2dUuzrE4XiZeFu7e1hROIJkm02a49N120pmOXxY33sBb6hArItm5W5tcg1cBtV+HQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@rollup/pluginutils": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", + "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", + "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz", + "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz", + "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz", + "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz", + "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz", + "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz", + "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz", + "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz", + "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz", + "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz", + "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz", + "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz", + "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz", + "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz", + "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz", + "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz", + "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz", + "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz", + "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz", + "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz", + "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz", + "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz", + "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz", + "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz", + "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/lodash": { + "version": "4.17.24", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz", + "integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==", + "license": "MIT" + }, + "node_modules/@types/lodash-es": { + "version": "4.17.12", + "resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz", + "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", + "license": "MIT", + "dependencies": { + "@types/lodash": "*" + } + }, + "node_modules/@types/web-bluetooth": { + "version": "0.0.20", + "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz", + "integrity": "sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==", + "license": "MIT" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@volar/language-core": { + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.28.tgz", + "integrity": "sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/source-map": "2.4.28" + } + }, + "node_modules/@volar/source-map": { + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.28.tgz", + "integrity": "sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@volar/typescript": { + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.28.tgz", + "integrity": "sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.28", + "path-browserify": "^1.0.1", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.32.tgz", + "integrity": "sha512-4x74Tbtqnda8s/NSD6e1Dr5p1c8HdMU5RWSjMSUzb8RTcUQqevDCxVAitcLBKT+ie3o0Dl9crc/S/opJM7qBGQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.2", + "@vue/shared": "3.5.32", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.32.tgz", + "integrity": "sha512-ybHAu70NtiEI1fvAUz3oXZqkUYEe5J98GjMDpTGl5iHb0T15wQYLR4wE3h9xfuTNA+Cm2f4czfe8B4s+CCH57Q==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.32", + "@vue/shared": "3.5.32" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.32.tgz", + "integrity": "sha512-8UYUYo71cP/0YHMO814TRZlPuUUw3oifHuMR7Wp9SNoRSrxRQnhMLNlCeaODNn6kNTJsjFoQ/kqIj4qGvya4Xg==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.2", + "@vue/compiler-core": "3.5.32", + "@vue/compiler-dom": "3.5.32", + "@vue/compiler-ssr": "3.5.32", + "@vue/shared": "3.5.32", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.8", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.32.tgz", + "integrity": "sha512-Gp4gTs22T3DgRotZ8aA/6m2jMR+GMztvBXUBEUOYOcST+giyGWJ4WvFd7QLHBkzTxkfOt8IELKNdpzITLbA2rw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.32", + "@vue/shared": "3.5.32" + } + }, + "node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "license": "MIT" + }, + "node_modules/@vue/language-core": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-3.2.6.tgz", + "integrity": "sha512-xYYYX3/aVup576tP/23sEUpgiEnujrENaoNRbaozC1/MA9I6EGFQRJb4xrt/MmUCAGlxTKL2RmT8JLTPqagCkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.28", + "@vue/compiler-dom": "^3.5.0", + "@vue/shared": "^3.5.0", + "alien-signals": "^3.0.0", + "muggle-string": "^0.4.1", + "path-browserify": "^1.0.1", + "picomatch": "^4.0.2" + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.32.tgz", + "integrity": "sha512-/ORasxSGvZ6MN5gc+uE364SxFdJ0+WqVG0CENXaGW58TOCdrAW76WWaplDtECeS1qphvtBZtR+3/o1g1zL4xPQ==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.32" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.32.tgz", + "integrity": "sha512-pDrXCejn4UpFDFmMd27AcJEbHaLemaE5o4pbb7sLk79SRIhc6/t34BQA7SGNgYtbMnvbF/HHOftYBgFJtUoJUQ==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.32", + "@vue/shared": "3.5.32" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.32.tgz", + "integrity": "sha512-1CDVv7tv/IV13V8Nip1k/aaObVbWqRlVCVezTwx3K07p7Vxossp5JU1dcPNhJk3w347gonIUT9jQOGutyJrSVQ==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.32", + "@vue/runtime-core": "3.5.32", + "@vue/shared": "3.5.32", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.32.tgz", + "integrity": "sha512-IOjm2+JQwRFS7W28HNuJeXQle9KdZbODFY7hFGVtnnghF51ta20EWAZJHX+zLGtsHhaU6uC9BGPV52KVpYryMQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.32", + "@vue/shared": "3.5.32" + }, + "peerDependencies": { + "vue": "3.5.32" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.32.tgz", + "integrity": "sha512-ksNyrmRQzWJJ8n3cRDuSF7zNNontuJg1YHnmWRJd2AMu8Ij2bqwiiri2lH5rHtYPZjj4STkNcgcmiQqlOjiYGg==", + "license": "MIT" + }, + "node_modules/@vueuse/core": { + "version": "10.11.1", + "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-10.11.1.tgz", + "integrity": "sha512-guoy26JQktXPcz+0n3GukWIy/JDNKti9v6VEMu6kV2sYBsWuGiTU8OWdg+ADfUbHg3/3DlqySDe7JmdHrktiww==", + "license": "MIT", + "dependencies": { + "@types/web-bluetooth": "^0.0.20", + "@vueuse/metadata": "10.11.1", + "@vueuse/shared": "10.11.1", + "vue-demi": ">=0.14.8" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/metadata": { + "version": "10.11.1", + "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-10.11.1.tgz", + "integrity": "sha512-IGa5FXd003Ug1qAZmyE8wF3sJ81xGLSqTqtQ6jaVfkeZ4i5kS2mwQF61yhVqojRnenVew5PldLyRgvdl4YYuSw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared": { + "version": "10.11.1", + "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-10.11.1.tgz", + "integrity": "sha512-LHpC8711VFZlDaYUXEBbFBCQ7GS3dVU9mjOhhMhXP6txTV4EhYQg/KGnQuvt/sPAtoUKq7VVUnL6mVtFoL42sA==", + "license": "MIT", + "dependencies": { + "vue-demi": ">=0.14.8" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/alien-signals": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-3.1.2.tgz", + "integrity": "sha512-d9dYqZTS90WLiU0I5c6DHj/HcKkF8ZyGN3G5x8wSbslulz70KOxaqCT0hQCo9KOyhVqzqGojvNdJXoTumZOtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/async-validator": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/async-validator/-/async-validator-4.2.5.tgz", + "integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==", + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.14.0.tgz", + "integrity": "sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/brace-expansion": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", + "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/confbox": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/dayjs": { + "version": "1.11.20", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.20.tgz", + "integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/echarts": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/echarts/-/echarts-5.6.0.tgz", + "integrity": "sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "2.3.0", + "zrender": "5.6.1" + } + }, + "node_modules/element-plus": { + "version": "2.13.6", + "resolved": "https://registry.npmjs.org/element-plus/-/element-plus-2.13.6.tgz", + "integrity": "sha512-XHgwXr8Fjz6i+6BaqFhAbae/dJbG7bBAAlHrY3pWL7dpj+JcqcOyKYt4Oy5KP86FQwS1k4uIZDjCx2FyUR5lDg==", + "license": "MIT", + "dependencies": { + "@ctrl/tinycolor": "^4.2.0", + "@element-plus/icons-vue": "^2.3.2", + "@floating-ui/dom": "^1.0.1", + "@popperjs/core": "npm:@sxzz/popperjs-es@^2.11.7", + "@types/lodash": "^4.17.20", + "@types/lodash-es": "^4.17.12", + "@vueuse/core": "12.0.0", + "async-validator": "^4.2.5", + "dayjs": "^1.11.19", + "lodash": "^4.17.23", + "lodash-es": "^4.17.23", + "lodash-unified": "^1.0.3", + "memoize-one": "^6.0.0", + "normalize-wheel-es": "^1.2.0", + "vue-component-type-helpers": "^3.2.4" + }, + "peerDependencies": { + "vue": "^3.3.0" + } + }, + "node_modules/element-plus/node_modules/@vueuse/core": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-12.0.0.tgz", + "integrity": "sha512-C12RukhXiJCbx4MGhjmd/gH52TjJsc3G0E0kQj/kb19H3Nt6n1CA4DRWuTdWWcaFRdlTe0npWDS942mvacvNBw==", + "license": "MIT", + "dependencies": { + "@types/web-bluetooth": "^0.0.20", + "@vueuse/metadata": "12.0.0", + "@vueuse/shared": "12.0.0", + "vue": "^3.5.13" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/element-plus/node_modules/@vueuse/metadata": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-12.0.0.tgz", + "integrity": "sha512-Yzimd1D3sjxTDOlF05HekU5aSGdKjxhuhRFHA7gDWLn57PRbBIh+SF5NmjhJ0WRgF3my7T8LBucyxdFJjIfRJQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/element-plus/node_modules/@vueuse/shared": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-12.0.0.tgz", + "integrity": "sha512-3i6qtcq2PIio5i/vVYidkkcgvmTjCqrf26u+Fd4LhnbBmIT6FN8y6q/GJERp8lfcB9zVEfjdV0Br0443qZuJpw==", + "license": "MIT", + "dependencies": { + "vue": "^3.5.13" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/exsolve": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", + "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/local-pkg": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-0.5.1.tgz", + "integrity": "sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mlly": "^1.7.3", + "pkg-types": "^1.2.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/lodash-es": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", + "license": "MIT" + }, + "node_modules/lodash-unified": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/lodash-unified/-/lodash-unified-1.0.3.tgz", + "integrity": "sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==", + "license": "MIT", + "peerDependencies": { + "@types/lodash-es": "*", + "lodash": "*", + "lodash-es": "*" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/memoize-one": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz", + "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==", + "license": "MIT" + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/mlly": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", + "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.16.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.3" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/muggle-string": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", + "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/normalize-wheel-es": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/normalize-wheel-es/-/normalize-wheel-es-1.2.0.tgz", + "integrity": "sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==", + "license": "BSD-3-Clause" + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pinia": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/pinia/-/pinia-2.3.1.tgz", + "integrity": "sha512-khUlZSwt9xXCaTbbxFYBKDc/bWAGWJjOgvxETwkTN7KRm66EeT1ZdZj6i2ceh9sP2Pzqsbc704r2yngBrxBVug==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.3", + "vue-demi": "^0.14.10" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "typescript": ">=4.4.4", + "vue": "^2.7.0 || ^3.5.11" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/pkg-types": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/quansync": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz", + "integrity": "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/antfu" + }, + { + "type": "individual", + "url": "https://github.com/sponsors/sxzz" + } + ], + "license": "MIT" + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/readdirp/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/resolve": { + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", + "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.1", + "@rollup/rollup-android-arm64": "4.60.1", + "@rollup/rollup-darwin-arm64": "4.60.1", + "@rollup/rollup-darwin-x64": "4.60.1", + "@rollup/rollup-freebsd-arm64": "4.60.1", + "@rollup/rollup-freebsd-x64": "4.60.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", + "@rollup/rollup-linux-arm-musleabihf": "4.60.1", + "@rollup/rollup-linux-arm64-gnu": "4.60.1", + "@rollup/rollup-linux-arm64-musl": "4.60.1", + "@rollup/rollup-linux-loong64-gnu": "4.60.1", + "@rollup/rollup-linux-loong64-musl": "4.60.1", + "@rollup/rollup-linux-ppc64-gnu": "4.60.1", + "@rollup/rollup-linux-ppc64-musl": "4.60.1", + "@rollup/rollup-linux-riscv64-gnu": "4.60.1", + "@rollup/rollup-linux-riscv64-musl": "4.60.1", + "@rollup/rollup-linux-s390x-gnu": "4.60.1", + "@rollup/rollup-linux-x64-gnu": "4.60.1", + "@rollup/rollup-linux-x64-musl": "4.60.1", + "@rollup/rollup-openbsd-x64": "4.60.1", + "@rollup/rollup-openharmony-arm64": "4.60.1", + "@rollup/rollup-win32-arm64-msvc": "4.60.1", + "@rollup/rollup-win32-ia32-msvc": "4.60.1", + "@rollup/rollup-win32-x64-gnu": "4.60.1", + "@rollup/rollup-win32-x64-msvc": "4.60.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/scule": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/scule/-/scule-1.3.0.tgz", + "integrity": "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==", + "dev": true, + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/strip-literal": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-2.1.1.tgz", + "integrity": "sha512-631UJ6O00eNGfMiWG78ck80dfBab8X6IVFB51jZK5Icd7XAs60Z5y7QdSd/wGIklnWvRbUNloVzhOKKmutxQ6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/tslib": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/ufo": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz", + "integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/unimport": { + "version": "3.14.6", + "resolved": "https://registry.npmjs.org/unimport/-/unimport-3.14.6.tgz", + "integrity": "sha512-CYvbDaTT04Rh8bmD8jz3WPmHYZRG/NnvYVzwD6V1YAlvvKROlAeNDUBhkBGzNav2RKaeuXvlWYaa1V4Lfi/O0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.1.4", + "acorn": "^8.14.0", + "escape-string-regexp": "^5.0.0", + "estree-walker": "^3.0.3", + "fast-glob": "^3.3.3", + "local-pkg": "^1.0.0", + "magic-string": "^0.30.17", + "mlly": "^1.7.4", + "pathe": "^2.0.1", + "picomatch": "^4.0.2", + "pkg-types": "^1.3.0", + "scule": "^1.3.0", + "strip-literal": "^2.1.1", + "unplugin": "^1.16.1" + } + }, + "node_modules/unimport/node_modules/confbox": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", + "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unimport/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/unimport/node_modules/local-pkg": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-1.1.2.tgz", + "integrity": "sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mlly": "^1.7.4", + "pkg-types": "^2.3.0", + "quansync": "^0.2.11" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/unimport/node_modules/local-pkg/node_modules/pkg-types": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz", + "integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==", + "dev": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.2.2", + "exsolve": "^1.0.7", + "pathe": "^2.0.3" + } + }, + "node_modules/unplugin": { + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-1.16.1.tgz", + "integrity": "sha512-4/u/j4FrCKdi17jaxuJA0jClGxB1AvU2hw/IuayPc4ay1XGaJs/rbb4v5WKwAjNifjmXK9PIFyuPiaK8azyR9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.14.0", + "webpack-virtual-modules": "^0.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/unplugin-auto-import": { + "version": "0.17.8", + "resolved": "https://registry.npmjs.org/unplugin-auto-import/-/unplugin-auto-import-0.17.8.tgz", + "integrity": "sha512-CHryj6HzJ+n4ASjzwHruD8arhbdl+UXvhuAIlHDs15Y/IMecG3wrf7FVg4pVH/DIysbq/n0phIjNHAjl7TG7Iw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@antfu/utils": "^0.7.10", + "@rollup/pluginutils": "^5.1.0", + "fast-glob": "^3.3.2", + "local-pkg": "^0.5.0", + "magic-string": "^0.30.10", + "minimatch": "^9.0.4", + "unimport": "^3.7.2", + "unplugin": "^1.11.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@nuxt/kit": "^3.2.2", + "@vueuse/core": "*" + }, + "peerDependenciesMeta": { + "@nuxt/kit": { + "optional": true + }, + "@vueuse/core": { + "optional": true + } + } + }, + "node_modules/unplugin-vue-components": { + "version": "0.26.0", + "resolved": "https://registry.npmjs.org/unplugin-vue-components/-/unplugin-vue-components-0.26.0.tgz", + "integrity": "sha512-s7IdPDlnOvPamjunVxw8kNgKNK8A5KM1YpK5j/p97jEKTjlPNrA0nZBiSfAKKlK1gWZuyWXlKL5dk3EDw874LQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@antfu/utils": "^0.7.6", + "@rollup/pluginutils": "^5.0.4", + "chokidar": "^3.5.3", + "debug": "^4.3.4", + "fast-glob": "^3.3.1", + "local-pkg": "^0.4.3", + "magic-string": "^0.30.3", + "minimatch": "^9.0.3", + "resolve": "^1.22.4", + "unplugin": "^1.4.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@babel/parser": "^7.15.8", + "@nuxt/kit": "^3.2.2", + "vue": "2 || 3" + }, + "peerDependenciesMeta": { + "@babel/parser": { + "optional": true + }, + "@nuxt/kit": { + "optional": true + } + } + }, + "node_modules/unplugin-vue-components/node_modules/local-pkg": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-0.4.3.tgz", + "integrity": "sha512-SFppqq5p42fe2qcZQqqEOiVRXl+WCP1MdT6k7BDEW1j++sp5fIY+/fdRQitvKgB5BrBcmrs5m/L0v2FrU5MY1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vscode-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vue": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.32.tgz", + "integrity": "sha512-vM4z4Q9tTafVfMAK7IVzmxg34rSzTFMyIe0UUEijUCkn9+23lj0WRfA83dg7eQZIUlgOSGrkViIaCfqSAUXsMw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.32", + "@vue/compiler-sfc": "3.5.32", + "@vue/runtime-dom": "3.5.32", + "@vue/server-renderer": "3.5.32", + "@vue/shared": "3.5.32" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-component-type-helpers": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/vue-component-type-helpers/-/vue-component-type-helpers-3.2.6.tgz", + "integrity": "sha512-O02tnvIfOQVmnvoWwuSydwRoHjZVt8UEBR+2p4rT35p8GAy5VTlWP8o5qXfJR/GWCN0nVZoYWsVUvx2jwgdBmQ==", + "license": "MIT" + }, + "node_modules/vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/vue-router": { + "version": "4.6.4", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz", + "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/vue-tsc": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-3.2.6.tgz", + "integrity": "sha512-gYW/kWI0XrwGzd0PKc7tVB/qpdeAkIZLNZb10/InizkQjHjnT8weZ/vBarZoj4kHKbUTZT/bAVgoOr8x4NsQ/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/typescript": "2.4.28", + "@vue/language-core": "3.2.6" + }, + "bin": { + "vue-tsc": "bin/vue-tsc.js" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + } + }, + "node_modules/webpack-virtual-modules": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz", + "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/zrender": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/zrender/-/zrender-5.6.1.tgz", + "integrity": "sha512-OFXkDJKcrlx5su2XbzJvj/34Q3m6PvyCZkVPHGYpcCJ52ek4U/ymZyfuV1nKE23AyBJ51E/6Yr0mhZ7xGTO4ag==", + "license": "BSD-3-Clause", + "dependencies": { + "tslib": "2.3.0" + } + } + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..4c09a79 --- /dev/null +++ b/web/package.json @@ -0,0 +1,30 @@ +{ + "name": "csm-web", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vue-tsc && vite build", + "preview": "vite preview", + "type-check": "vue-tsc --noEmit" + }, + "dependencies": { + "@vueuse/core": "^10.7.2", + "axios": "^1.6.7", + "dayjs": "^1.11.10", + "echarts": "^5.5.0", + "element-plus": "^2.5.6", + "pinia": "^2.1.7", + "vue": "^3.4.21", + "vue-router": "^4.2.5" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.0.3", + "typescript": "^5.3.3", + "unplugin-auto-import": "^0.17.5", + "unplugin-vue-components": "^0.26.0", + "vite": "^5.0.12", + "vue-tsc": "^3.2.6" + } +} diff --git a/web/src/App.vue b/web/src/App.vue new file mode 100644 index 0000000..11d5421 --- /dev/null +++ b/web/src/App.vue @@ -0,0 +1,8 @@ + + + diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts new file mode 100644 index 0000000..77bdfa5 --- /dev/null +++ b/web/src/lib/api.ts @@ -0,0 +1,126 @@ +/** + * Shared API client with authentication and error handling + */ + +const API_BASE = import.meta.env.VITE_API_BASE || '' + +export interface ApiResult { + success: boolean + data?: T + error?: string +} + +export class ApiError extends Error { + constructor( + public status: number, + public code: string, + message: string, + ) { + super(message) + this.name = 'ApiError' + } +} + +function getToken(): string | null { + const token = localStorage.getItem('token') + if (!token || token.trim() === '') return null + return token +} + +function clearAuth() { + localStorage.removeItem('token') + localStorage.removeItem('refresh_token') + window.location.href = '/login' +} + +async function request( + path: string, + options: RequestInit = {}, +): Promise { + const token = getToken() + const headers = new Headers(options.headers || {}) + + if (token) { + headers.set('Authorization', `Bearer ${token}`) + } + + if (options.body && typeof options.body === 'string') { + headers.set('Content-Type', 'application/json') + } + + const response = await fetch(`${API_BASE}${path}`, { + ...options, + headers, + }) + + // Handle 401 - token expired or invalid + if (response.status === 401) { + clearAuth() + throw new ApiError(401, 'UNAUTHORIZED', 'Session expired') + } + + // Handle 403 - insufficient permissions + if (response.status === 403) { + throw new ApiError(403, 'FORBIDDEN', 'Insufficient permissions') + } + + // Handle non-JSON responses (502, 503, etc.) + const contentType = response.headers.get('content-type') + if (!contentType || !contentType.includes('application/json')) { + throw new ApiError(response.status, 'NON_JSON_RESPONSE', `Server returned ${response.status}`) + } + + const result: ApiResult = await response.json() + + if (!result.success) { + throw new ApiError(response.status, 'API_ERROR', result.error || 'Unknown error') + } + + return result.data as T +} + +export const api = { + get(path: string): Promise { + return request(path) + }, + + post(path: string, body?: unknown): Promise { + return request(path, { + method: 'POST', + body: body ? JSON.stringify(body) : undefined, + }) + }, + + put(path: string, body?: unknown): Promise { + return request(path, { + method: 'PUT', + body: body ? JSON.stringify(body) : undefined, + }) + }, + + delete(path: string): Promise { + return request(path, { method: 'DELETE' }) + }, + + /** Login doesn't use the auth header */ + async login(username: string, password: string): Promise<{ access_token: string; refresh_token: string; user: { id: number; username: string; role: string } }> { + const response = await fetch(`${API_BASE}/api/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, password }), + }) + + const result = await response.json() + if (!result.success) { + throw new ApiError(response.status, 'LOGIN_FAILED', result.error || 'Login failed') + } + + localStorage.setItem('token', result.data.access_token) + localStorage.setItem('refresh_token', result.data.refresh_token) + return result.data + }, + + logout() { + clearAuth() + }, +} diff --git a/web/src/main.ts b/web/src/main.ts new file mode 100644 index 0000000..e5876d1 --- /dev/null +++ b/web/src/main.ts @@ -0,0 +1,12 @@ +import { createApp } from 'vue' +import { createPinia } from 'pinia' +import 'element-plus/dist/index.css' +import App from './App.vue' +import router from './router' + +const app = createApp(App) + +app.use(createPinia()) +app.use(router) + +app.mount('#app') diff --git a/web/src/router/index.ts b/web/src/router/index.ts new file mode 100644 index 0000000..846827d --- /dev/null +++ b/web/src/router/index.ts @@ -0,0 +1,63 @@ +import { createRouter, createWebHistory } from 'vue-router' +import AppLayout from '../views/Layout.vue' + +const router = createRouter({ + history: createWebHistory(), + routes: [ + { path: '/login', name: 'Login', component: () => import('../views/Login.vue') }, + { + path: '/', + component: AppLayout, + redirect: '/dashboard', + children: [ + { path: 'dashboard', name: 'Dashboard', component: () => import('../views/Dashboard.vue') }, + { path: 'devices', name: 'Devices', component: () => import('../views/Devices.vue') }, + { path: 'devices/:uid', name: 'DeviceDetail', component: () => import('../views/DeviceDetail.vue') }, + { path: 'assets', name: 'Assets', component: () => import('../views/Assets.vue') }, + { path: 'usb', name: 'UsbPolicy', component: () => import('../views/UsbPolicy.vue') }, + { path: 'alerts', name: 'Alerts', component: () => import('../views/Alerts.vue') }, + { path: 'settings', name: 'Settings', component: () => import('../views/Settings.vue') }, + // Phase 2: Plugin pages + { path: 'plugins/web-filter', name: 'WebFilter', component: () => import('../views/plugins/WebFilter.vue') }, + { path: 'plugins/usage-timer', name: 'UsageTimer', component: () => import('../views/plugins/UsageTimer.vue') }, + { path: 'plugins/software-blocker', name: 'SoftwareBlocker', component: () => import('../views/plugins/SoftwareBlocker.vue') }, + { path: 'plugins/popup-blocker', name: 'PopupBlocker', component: () => import('../views/plugins/PopupBlocker.vue') }, + { path: 'plugins/usb-file-audit', name: 'UsbFileAudit', component: () => import('../views/plugins/UsbFileAudit.vue') }, + { path: 'plugins/watermark', name: 'Watermark', component: () => import('../views/plugins/Watermark.vue') }, + ], + }, + ], +}) + +/** Check if a JWT token is structurally valid and not expired */ +function isTokenValid(token: string): boolean { + if (!token || token.trim() === '') return false + try { + const parts = token.split('.') + if (parts.length !== 3) return false + const payload = JSON.parse(atob(parts[1])) + if (!payload.exp) return false + // Reject if token expires within 30 seconds + return payload.exp * 1000 > Date.now() + 30_000 + } catch { + return false + } +} + +router.beforeEach((to, _from, next) => { + if (to.path === '/login') { + next() + return + } + + const token = localStorage.getItem('token') + if (!token || !isTokenValid(token)) { + localStorage.removeItem('token') + localStorage.removeItem('refresh_token') + next('/login') + } else { + next() + } +}) + +export default router diff --git a/web/src/stores/devices.ts b/web/src/stores/devices.ts new file mode 100644 index 0000000..f03e045 --- /dev/null +++ b/web/src/stores/devices.ts @@ -0,0 +1,87 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' +import axios from 'axios' + +const api = axios.create({ + baseURL: '/api', + headers: { + 'Content-Type': 'application/json', + }, +}) + +// Add auth token to requests +api.interceptors.request.use((config) => { + const token = localStorage.getItem('token') + if (token) { + config.headers.Authorization = `Bearer ${token}` + } + return config +}) + +export interface Device { + id: number + device_uid: string + hostname: string + ip_address: string + mac_address: string | null + os_version: string | null + client_version: string | null + status: 'online' | 'offline' + last_heartbeat: string | null + registered_at: string + group_name: string +} + +export interface DeviceStatusDetail { + cpu_usage: number + memory_usage: number + memory_total: number + disk_usage: number + disk_total: number + running_procs: number + top_processes: Array<{ name: string; pid: number; cpu_usage: number; memory_mb: number }> +} + +export const useDeviceStore = defineStore('devices', () => { + const devices = ref([]) + const loading = ref(false) + const total = ref(0) + + async function fetchDevices(params?: Record) { + loading.value = true + try { + const { data } = await api.get('/devices', { params }) + if (data.success) { + devices.value = data.data.devices + total.value = data.data.total ?? devices.value.length + } + } finally { + loading.value = false + } + } + + async function fetchDeviceStatus(uid: string): Promise { + const { data } = await api.get(`/devices/${uid}/status`) + return data.success ? data.data : null + } + + async function fetchDeviceHistory(uid: string, params?: Record) { + const { data } = await api.get(`/devices/${uid}/history`, { params }) + return data.success ? data.data : null + } + + async function removeDevice(uid: string) { + await api.delete(`/devices/${uid}`) + devices.value = devices.value.filter((d) => d.device_uid !== uid) + } + + return { + devices, + loading, + total, + fetchDevices, + fetchDeviceStatus, + fetchDeviceHistory, + removeDevice, + } +}) diff --git a/web/src/views/Alerts.vue b/web/src/views/Alerts.vue new file mode 100644 index 0000000..aedf780 --- /dev/null +++ b/web/src/views/Alerts.vue @@ -0,0 +1,216 @@ + + + + + diff --git a/web/src/views/Assets.vue b/web/src/views/Assets.vue new file mode 100644 index 0000000..3fba996 --- /dev/null +++ b/web/src/views/Assets.vue @@ -0,0 +1,123 @@ + + + + + diff --git a/web/src/views/Dashboard.vue b/web/src/views/Dashboard.vue new file mode 100644 index 0000000..9332477 --- /dev/null +++ b/web/src/views/Dashboard.vue @@ -0,0 +1,264 @@ + + + + + diff --git a/web/src/views/DeviceDetail.vue b/web/src/views/DeviceDetail.vue new file mode 100644 index 0000000..3b33ea6 --- /dev/null +++ b/web/src/views/DeviceDetail.vue @@ -0,0 +1,131 @@ + + + + + diff --git a/web/src/views/Devices.vue b/web/src/views/Devices.vue new file mode 100644 index 0000000..f9e6a7b --- /dev/null +++ b/web/src/views/Devices.vue @@ -0,0 +1,108 @@ + + + + + diff --git a/web/src/views/Layout.vue b/web/src/views/Layout.vue new file mode 100644 index 0000000..eca8609 --- /dev/null +++ b/web/src/views/Layout.vue @@ -0,0 +1,189 @@ + + + + + diff --git a/web/src/views/Login.vue b/web/src/views/Login.vue new file mode 100644 index 0000000..603e424 --- /dev/null +++ b/web/src/views/Login.vue @@ -0,0 +1,119 @@ + + + + + diff --git a/web/src/views/Settings.vue b/web/src/views/Settings.vue new file mode 100644 index 0000000..9bbd382 --- /dev/null +++ b/web/src/views/Settings.vue @@ -0,0 +1,115 @@ + + + + + diff --git a/web/src/views/UsbPolicy.vue b/web/src/views/UsbPolicy.vue new file mode 100644 index 0000000..499239b --- /dev/null +++ b/web/src/views/UsbPolicy.vue @@ -0,0 +1,192 @@ + + + + + diff --git a/web/src/views/plugins/PopupBlocker.vue b/web/src/views/plugins/PopupBlocker.vue new file mode 100644 index 0000000..e45244f --- /dev/null +++ b/web/src/views/plugins/PopupBlocker.vue @@ -0,0 +1,56 @@ + + + diff --git a/web/src/views/plugins/SoftwareBlocker.vue b/web/src/views/plugins/SoftwareBlocker.vue new file mode 100644 index 0000000..05db44f --- /dev/null +++ b/web/src/views/plugins/SoftwareBlocker.vue @@ -0,0 +1,71 @@ + + + diff --git a/web/src/views/plugins/UsageTimer.vue b/web/src/views/plugins/UsageTimer.vue new file mode 100644 index 0000000..f787e27 --- /dev/null +++ b/web/src/views/plugins/UsageTimer.vue @@ -0,0 +1,74 @@ + + + diff --git a/web/src/views/plugins/UsbFileAudit.vue b/web/src/views/plugins/UsbFileAudit.vue new file mode 100644 index 0000000..203b2e2 --- /dev/null +++ b/web/src/views/plugins/UsbFileAudit.vue @@ -0,0 +1,53 @@ + + + diff --git a/web/src/views/plugins/Watermark.vue b/web/src/views/plugins/Watermark.vue new file mode 100644 index 0000000..f34cd96 --- /dev/null +++ b/web/src/views/plugins/Watermark.vue @@ -0,0 +1,112 @@ + + + diff --git a/web/src/views/plugins/WebFilter.vue b/web/src/views/plugins/WebFilter.vue new file mode 100644 index 0000000..2d2b1f8 --- /dev/null +++ b/web/src/views/plugins/WebFilter.vue @@ -0,0 +1,105 @@ + + + + + diff --git a/web/src/vite-env.d.ts b/web/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/web/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..4e5ea1b --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "preserve", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + } + }, + "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/web/tsconfig.node.json b/web/tsconfig.node.json new file mode 100644 index 0000000..42872c5 --- /dev/null +++ b/web/tsconfig.node.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/web/vite.config.ts b/web/vite.config.ts new file mode 100644 index 0000000..dfb25a0 --- /dev/null +++ b/web/vite.config.ts @@ -0,0 +1,62 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' +import AutoImport from 'unplugin-auto-import/vite' +import Components from 'unplugin-vue-components/vite' +import { ElementPlusResolver } from 'unplugin-vue-components/resolvers' +import { resolve } from 'path' + +export default defineConfig({ + plugins: [ + vue(), + AutoImport({ + resolvers: [ElementPlusResolver()], + }), + Components({ + resolvers: [ElementPlusResolver()], + }), + ], + resolve: { + alias: { + '@': resolve(__dirname, 'src'), + }, + }, + optimizeDeps: { + include: [ + 'element-plus', + '@element-plus/icons-vue', + 'echarts', + ], + }, + server: { + port: 3000, + proxy: { + '/api': { + target: 'http://localhost:8080', + changeOrigin: true, + }, + '/ws': { + target: 'ws://localhost:8080', + ws: true, + }, + '/health': { + target: 'http://localhost:8080', + changeOrigin: true, + }, + }, + }, + build: { + outDir: 'dist', + assetsDir: 'assets', + sourcemap: false, + chunkSizeWarningLimit: 500, + rollupOptions: { + output: { + manualChunks: { + 'element-plus': ['element-plus'], + 'echarts': ['echarts'], + 'vendor': ['vue', 'vue-router', 'pinia'], + }, + }, + }, + }, +})