commit d3bb9f45a6e806f32bf87cf087b4b34a6aa9bb3f Author: Eeveid <448859157@qq.com> Date: Mon May 25 01:13:03 2026 +0800 实现 LightOps 运维面板基础功能 diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..5d34b4f --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,9 @@ +[http] +check-revoke = false +multiplexing = false + +[net] +git-fetch-with-cli = true + +[registries.crates-io] +protocol = "sparse" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b0987b2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +target/ +web/dist/ +node_modules/ +.git/ +.git-codex/ +.npm-cache/ +.vfox/ +.cargo-home/ +.vfox-home/ +.env +*.db +*.sqlite +*.log diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..5b6b68a --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,3349 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[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 = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures", + "password-hash", +] + +[[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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "axum" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" +dependencies = [ + "async-trait", + "axum-core", + "axum-macros", + "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", + "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 = "axum-macros" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57d123550fa8d071b7255cb0cc04dc302baa6c8c4a79f55701552684d8399bce" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[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 = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +dependencies = [ + "serde_core", +] + +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + +[[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 = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[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.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[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 = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[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-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.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + +[[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 = "dashmap" +version = "6.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + +[[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 = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" +dependencies = [ + "serde", +] + +[[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 = "filedescriptor" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +dependencies = [ + "libc", + "thiserror 1.0.69", + "winapi", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[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 = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-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-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-io", + "futures-macro", + "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.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[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.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[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 = "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", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "bytes", + "http", + "http-body", + "hyper", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "ioctl-rs" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7970510895cee30b3e9128319f2cefd4bde883a39f38baa279567ba3a7eb97d" +dependencies = [ + "libc", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" +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 = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[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.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" +dependencies = [ + "bitflags 2.11.1", + "libc", + "plain", + "redox_syscall 0.7.5", +] + +[[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 = "lightops-agent" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "base64", + "chrono", + "clap", + "dashmap", + "futures-util", + "lightops-common", + "portable-pty", + "serde", + "serde_json", + "sysinfo", + "tokio", + "tokio-tungstenite", + "toml", + "tracing", + "tracing-subscriber", + "url", + "uuid", +] + +[[package]] +name = "lightops-cli" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", +] + +[[package]] +name = "lightops-common" +version = "0.1.0" +dependencies = [ + "chrono", + "serde", + "serde_json", + "uuid", +] + +[[package]] +name = "lightops-server" +version = "0.1.0" +dependencies = [ + "anyhow", + "argon2", + "async-trait", + "axum", + "base64", + "chrono", + "clap", + "dashmap", + "futures-util", + "jsonwebtoken", + "lightops-common", + "rand", + "serde", + "serde_json", + "sha2", + "sqlx", + "thiserror 1.0.69", + "tokio", + "toml", + "tower-http", + "tracing", + "tracing-subscriber", + "uuid", +] + +[[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 = "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 = "memoffset" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aa361d4faea93603064a027415f07bd8e1d5c88c9fbf68bf56a285428fd79ce" +dependencies = [ + "autocfg", +] + +[[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 = "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 = "nix" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f346ff70e7dbfd675fe90590b92d59ef2de15a8779ae305ebcbfd3f0caf59be4" +dependencies = [ + "autocfg", + "bitflags 1.3.2", + "cfg-if", + "libc", + "memoffset", + "pin-utils", +] + +[[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", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[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 = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[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 = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core", + "subtle", +] + +[[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 = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[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.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "portable-pty" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "806ee80c2a03dbe1a9fb9534f8d19e4c0546b790cde8fd1fea9d6390644cb0be" +dependencies = [ + "anyhow", + "bitflags 1.3.2", + "downcast-rs", + "filedescriptor", + "lazy_static", + "libc", + "log", + "nix", + "serial", + "shared_library", + "shell-words", + "winapi", + "winreg", +] + +[[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 = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[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", +] + +[[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 = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +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 2.11.1", +] + +[[package]] +name = "redox_syscall" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4666a1a60d8412eab19d94f6d13dcc9cea0a5ef4fdf6a5db306537413c661b1b" +dependencies = [ + "bitflags 2.11.1", +] + +[[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 = "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", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "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 = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[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.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +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 = "serial" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1237a96570fc377c13baa1b88c7589ab66edced652e43ffb17088f003db3e86" +dependencies = [ + "serial-core", + "serial-unix", + "serial-windows", +] + +[[package]] +name = "serial-core" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f46209b345401737ae2125fe5b19a77acce90cd53e1658cda928e4fe9a64581" +dependencies = [ + "libc", +] + +[[package]] +name = "serial-unix" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f03fbca4c9d866e24a459cbca71283f545a37f8e3e002ad8c70593871453cab7" +dependencies = [ + "ioctl-rs", + "libc", + "serial-core", + "termios", +] + +[[package]] +name = "serial-windows" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15c6d3b776267a75d31bbdfd5d36c0ca051251caafc285827052bc53bcdc8162" +dependencies = [ + "libc", + "serial-core", +] + +[[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 = "shared_library" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a9e7e0f2bfae24d8a5b5a66c5b257a83c7412304311512a0c054cd5e619da11" +dependencies = [ + "lazy_static", + "libc", +] + +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + +[[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", +] + +[[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", + "chrono", + "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", + "rustls", + "serde", + "serde_json", + "sha2", + "smallvec", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tracing", + "url", + "uuid", + "webpki-roots 0.26.11", +] + +[[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 2.11.1", + "byteorder", + "bytes", + "chrono", + "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", + "rsa", + "serde", + "sha1", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 2.0.18", + "tracing", + "uuid", + "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 2.11.1", + "byteorder", + "chrono", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-util", + "hex", + "hkdf", + "hmac", + "home", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "rand", + "serde", + "serde_json", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 2.0.18", + "tracing", + "uuid", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" +dependencies = [ + "atoi", + "chrono", + "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", + "uuid", +] + +[[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 = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[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" + +[[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", +] + +[[package]] +name = "termios" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5d9cf598a6d7ce700a4e6a9199da127e6819a61e64b68609683cc9a01b5683a" +dependencies = [ + "libc", +] + +[[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.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +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.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[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", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tungstenite", + "webpki-roots 0.26.11", +] + +[[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.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.11.1", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "http-range-header", + "httpdate", + "mime", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[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-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", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[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", + "rustls", + "rustls-pki-types", + "sha1", + "thiserror 1.0.69", + "utf-8", +] + +[[package]] +name = "typenum" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" + +[[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 = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +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 = "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.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[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 0.51.0", +] + +[[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.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" +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 2.11.1", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[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.7", +] + +[[package]] +name = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +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 = "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-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.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "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-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[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.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", + "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_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_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_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_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[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_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_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_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 = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "winreg" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" +dependencies = [ + "winapi", +] + +[[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" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[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 2.11.1", + "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.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +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..b029302 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,44 @@ +[workspace] +members = [ + "crates/lightops-common", + "crates/lightops-server", + "crates/lightops-agent", + "crates/lightops-cli", +] +resolver = "2" + +[workspace.package] +edition = "2021" +license = "MIT" +version = "0.1.0" +authors = ["LightOps"] + +[workspace.dependencies] +anyhow = "1" +argon2 = "0.5" +async-trait = "0.1" +axum = { version = "0.7", features = ["ws", "macros"] } +base64 = "0.22" +chrono = { version = "0.4", features = ["serde", "clock"] } +clap = { version = "4", features = ["derive", "env"] } +dashmap = "6" +futures-util = "0.3" +jsonwebtoken = "9" +portable-pty = "0.8" +rand = "0.8" +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sha2 = "0.10" +sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "sqlite", "chrono", "uuid", "migrate"] } +sysinfo = "0.30" +thiserror = "1" +tokio = { version = "1", features = ["full"] } +tokio-tungstenite = { version = "0.24", features = ["rustls-tls-webpki-roots"] } +toml = "0.8" +tower-http = { version = "0.6", features = ["cors", "fs", "trace"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +url = "2" +uuid = { version = "1", features = ["v4", "serde"] } + diff --git a/README.md b/README.md new file mode 100644 index 0000000..62a82cf --- /dev/null +++ b/README.md @@ -0,0 +1,153 @@ +# LightOps + +LightOps 是一个基于 Rust 的极轻量探针式运维面板。它只需要部署一个主控端 Server,每台被管理 Linux 主机只安装一个轻量 Agent。Agent 主动通过 WebSocket 连接 Server,因此被管理节点不需要暴露 Web 面板,也不需要本地数据库。 + +## 架构 + +```text +浏览器 Web 界面 + -> Axum Server REST/WS + -> SQLite + 内存连接注册表 + -> Agent WebSocket + -> 本机能力:文件、PTY 终端、systemd、Nginx、Docker、日志、应用发现 +``` + +核心设计: + +- Server:使用 Axum、Tokio、SQLx SQLite,负责 JWT 认证、注册 Token、任务调度、审计日志和静态 Web 托管。 +- Agent:Rust 守护进程,不提供 Web UI,不使用本地数据库,支持断线重连、30 秒心跳/指标上报和白名单任务执行。 +- 协议:共享协议类型放在 `lightops-common`,通过 serde JSON 序列化。 +- 前端:Svelte + Vite + TypeScript,使用轻量 CSS,终端使用 xterm.js。 + +## 目录结构 + +```text +lightops/ +├── crates/ +│ ├── lightops-server/ +│ ├── lightops-agent/ +│ ├── lightops-common/ +│ └── lightops-cli/ +├── web/ +├── scripts/ +├── deploy/ +├── migrations/ +├── docs/ +└── README.md +``` + +## 已实现的 MVP + +- 管理员初始化和登录,密码使用 Argon2 哈希,接口使用 JWT 鉴权。 +- 权限支持模块级和动作级授权;已有模块级权限继续兼容,动作级权限可细分到文件读取/写入/危险操作、Docker 查看/变更/危险操作、Nginx 查看/修改/危险操作等。 +- 一次性 Agent 注册 Token。 +- Agent WebSocket 注册、长期 Secret、断线重连、协议级 Ping/Pong、静默超时检测、重复连接替换保护、心跳和指标上报。 +- 主机列表和最新 CPU/内存/磁盘指标。 +- 通用 `task.request` / `task.response` 任务下发和超时控制。 +- 文件列表、读取、写入、新建目录、删除、递归删除、重命名、chmod,以及大文件分片上传/下载。 +- 浏览器 xterm.js 到 Server WebSocket,再到 Agent PTY 的远程终端链路。 +- systemd 服务列表、启动、停止和重启。 +- Nginx 状态、站点列表、新建静态/SPA/反代/负载均衡/PHP-FastCGI 站点、配置在线编辑、启用、禁用、测试和重载,带备份列表与一键恢复;反代模板支持 WebSocket、gzip、静态资源缓存、强制 HTTPS 和上传大小限制。 +- 免费 HTTPS 证书管理:通过 certbot 申请/续期证书,展示证书到期时间,并支持启用自动续期。优先使用系统 `certbot.timer`,缺失时创建 `lightops-certbot-renew.timer`。证书 30 天内到期会联动告警中心。 +- Docker 状态、容器/镜像/数据卷列表、拉取镜像、从镜像创建容器、容器详情/资源占用、容器 exec 终端和基础生命周期管理,当前通过 `DockerCliProvider` 实现。 +- Docker Compose 项目识别、启动、停止、重启、日志查看和 YAML 部署,基于容器 `com.docker.compose.project` 标签聚合,不强依赖 compose 文件路径。 +- 软件商店:支持 `store/catalog/*.toml` 配置化应用目录,默认提供 Alist、Uptime Kuma、Gitea、Redis、Nginx 示例站;应用模板可声明安装参数字段,并在 Compose 模板中使用 `{{field.xxx}}` 替换,敏感字段会在安装记录和审计日志中脱敏;基于 Docker Compose 一键部署到指定 Agent,安装前会检测 Docker/Compose、端口占用、项目名冲突和目录风险;安装后支持启动、停止、重启、查看日志、拉取镜像更新和卸载 Compose 项目,默认不删除数据目录,并会刷新应用管理缓存。 +- 任务历史页面,可查看任务动作、状态、参数摘要、结果和错误。 +- 任务中心增强:提供任务统计、任务详情、自动刷新、失败重试和运行中任务取消;支持任务事件日志,记录任务创建、下发、Agent 执行、stdout/stderr 摘要、失败原因和完成状态;普通用户只能查看自己授权主机的任务。 +- 应用管理:应用发现、应用列表、应用详情、systemd/Docker 应用启停重启、应用日志、手动和自动应用健康检查、文件级应用备份、Nginx 站点应用、APT 允许清单识别和自定义应用纳管。 +- 单机详情补强:支持查看 Agent 实时系统快照,包括高占用进程、监听端口、磁盘分区和网络接口。 +- 告警中心支持 CPU、内存、磁盘、SSL 到期和应用健康规则,支持按主机、状态、级别筛选;支持规则静默、恢复通知、Webhook 测试发送和通知投递历史。 +- 主控端 SQLite 备份/恢复:支持在线创建一致性备份、下载备份、删除备份和从备份恢复;恢复后服务会退出并由 systemd 重启。 +- 主控端在线更新:设置页支持检查 Git 远程分支更新,并可一键触发服务器本机更新脚本,自动拉取代码、构建前端和 Rust 二进制、替换程序并重启 systemd 服务。 +- 审计日志 API 和页面。 + +## 开发 + +前置要求: + +- Rust 稳定版工具链。 +- Node.js 20 或更高版本。 +- Agent 运行能力优先面向 Linux 主机。 + +构建前端: + +```bash +cd web +npm install +npm run build +``` + +运行 Server: + +```bash +cp config/server.toml.example config/server.toml +cargo run -p lightops-server -- --config config/server.toml +``` + +本地运行 Agent: + +```bash +cargo run -p lightops-agent -- --server http://127.0.0.1:8080 --token +``` + +如果希望页面生成的一键安装命令能自动下载 Agent 二进制文件,请把构建好的 Agent 放到 Server 静态目录,例如: + +```text +web/dist/downloads/lightops-agent-linux-x86_64 +``` + +主机列表和单机详情会展示 Agent 版本状态,落后版本可复制升级命令。安装/升级脚本支持 `--sha256` 校验下载的 Agent 二进制。 + +安装 Agent: + +```bash +curl -fsSL https://panel.example.com/install-agent.sh | sh -s -- --server https://panel.example.com --token +``` + +升级 Agent: + +```bash +curl -fsSL https://panel.example.com/upgrade-agent.sh | sh -s -- --server https://panel.example.com +``` + +卸载 Agent: + +```bash +curl -fsSL https://panel.example.com/uninstall-agent.sh | sh +``` + +在线更新要求 Server 运行目录是 Git 仓库,例如 `/opt/lightops`,并在设置页保持: + +```text +仓库目录:/opt/lightops +更新分支:main +更新脚本:scripts/update-from-git.sh +``` + +当你从 Windows、macOS 或 Linux 任意客户端向远程仓库 push 新代码后,在设置页点击“检查更新”,确认有远程提交后点击“立即更新并重启”。提交端不需要安装触发脚本,也不参与部署;服务器会执行 `scripts/update-from-git.sh`,更新日志默认写入 `/var/log/lightops/update.log`。 + +完全清理 Agent 配置: + +```bash +curl -fsSL https://panel.example.com/uninstall-agent.sh | sh -s -- --purge +``` + +打开初始化页面: + +```text +http://127.0.0.1:8080/init +``` + +## 生产部署注意事项 + +- 生产环境建议将 Server 放在 Caddy、Nginx 或其他 TLS 反向代理后,只暴露 HTTPS/WSS。 +- 反向代理需要关闭过短的 WebSocket 空闲超时,建议不低于 120 秒。 +- Agent 连接具备 30 秒心跳、30 秒 Server Ping、约 100 秒静默超时和退避重连;如果链路中间设备强制断开,Agent 会自动重连。 +- 请将 `jwt_secret` 替换为足够长的随机值。 +- SQLite 数据库需要放在持久化存储上,并定期备份。 +- 主控备份默认保存在 SQLite 数据库同级目录的 `backups/` 子目录;恢复前会额外生成 `pre-restore-*.db` 回滚副本。 +- Agent 通常需要 root 权限,才能管理 systemd、Nginx、Docker 和完整文件系统。 +- SSL 证书功能依赖被控主机已安装 `certbot` 和对应 Nginx 插件;自动续期会定期执行 `certbot renew`,证书变更后尝试重载 Nginx。 +- Webhook 告警通知依赖 Server 运行环境存在 `curl` 命令。 +- Docker 控制权限风险很高,基本等价于 root 权限,请只在可信主机上部署 Agent。 +- 生产环境不要在无 TLS 的情况下暴露 Agent WebSocket 接口。 diff --git a/config/server.toml.example b/config/server.toml.example new file mode 100644 index 0000000..9ca3b57 --- /dev/null +++ b/config/server.toml.example @@ -0,0 +1,7 @@ +bind = "0.0.0.0:8080" +database_url = "sqlite://lightops.db?mode=rwc" +jwt_secret = "请替换为足够长的随机密钥" +public_url = "https://panel.example.com" +static_dir = "web/dist" +registration_token_ttl_minutes = 30 +task_timeout_secs = 20 diff --git a/crates/lightops-agent/Cargo.toml b/crates/lightops-agent/Cargo.toml new file mode 100644 index 0000000..c785dab --- /dev/null +++ b/crates/lightops-agent/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "lightops-agent" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +anyhow.workspace = true +async-trait.workspace = true +base64.workspace = true +chrono.workspace = true +clap.workspace = true +dashmap.workspace = true +futures-util.workspace = true +lightops-common = { path = "../lightops-common" } +portable-pty.workspace = true +serde.workspace = true +serde_json.workspace = true +sysinfo.workspace = true +tokio.workspace = true +tokio-tungstenite.workspace = true +toml.workspace = true +tracing.workspace = true +tracing-subscriber.workspace = true +url.workspace = true +uuid.workspace = true diff --git a/crates/lightops-agent/src/actions.rs b/crates/lightops-agent/src/actions.rs new file mode 100644 index 0000000..bec5fc6 --- /dev/null +++ b/crates/lightops-agent/src/actions.rs @@ -0,0 +1,1944 @@ +use anyhow::{anyhow, bail, Context, Result}; +use base64::{engine::general_purpose, Engine}; +use chrono::{DateTime, NaiveDateTime, Utc}; +use lightops_common::protocol::{DockerContainer, DockerImage, FileEntry, NginxSite, ServiceInfo}; +use serde_json::{json, Value}; +use std::{ + fs, + io::{Read, Seek, SeekFrom, Write}, + path::{Path, PathBuf}, +}; +use tokio::process::Command; +use tokio::time::{timeout, Duration}; + +const MAX_TEXT_FILE: u64 = 2 * 1024 * 1024; +const NGINX_AVAILABLE: &str = "/etc/nginx/sites-available"; +const NGINX_ENABLED: &str = "/etc/nginx/sites-enabled"; + +pub async fn handle(action: &str, params: Value) -> Result { + if action.starts_with("app.") { + return crate::app::handle(action, params).await; + } + match action { + "system.snapshot" => system_snapshot().await, + "file.roots" => file_roots(), + "file.list" => file_list(path_param(¶ms, "path")?), + "file.search" => file_search(params), + "file.read" => file_read(path_param(¶ms, "path")?), + "file.write" => file_write( + path_param(¶ms, "path")?, + string_param(¶ms, "content")?, + ), + "file.mkdir" => file_mkdir(path_param(¶ms, "path")?), + "file.delete" => file_delete( + path_param(¶ms, "path")?, + params + .get("recursive") + .and_then(Value::as_bool) + .unwrap_or(false), + ), + "file.rename" => file_rename(path_param(¶ms, "from")?, path_param(¶ms, "to")?), + "file.upload.chunk" => file_upload_chunk(params), + "file.download.chunk" => file_download_chunk(params), + "file.chmod" => file_chmod(params), + "log.tail_file" | "log.read_file" => log_tail(params).await, + "service.list" => service_list().await, + "service.status" => service_status(string_param(¶ms, "name")?).await, + "service.start" => service_action("start", string_param(¶ms, "name")?).await, + "service.stop" => service_action("stop", string_param(¶ms, "name")?).await, + "service.restart" => service_action("restart", string_param(¶ms, "name")?).await, + "service.enable" => service_action("enable", string_param(¶ms, "name")?).await, + "service.disable" => service_action("disable", string_param(¶ms, "name")?).await, + "nginx.status" => nginx_status().await, + "nginx.sites" => nginx_sites(), + "nginx.site.get" => nginx_site_get(string_param(¶ms, "name")?), + "nginx.site.backups" => nginx_site_backups(string_param(¶ms, "name")?), + "nginx.site.restore_backup" => { + nginx_site_restore_backup( + string_param(¶ms, "name")?, + string_param(¶ms, "backup")?, + ) + .await + } + "nginx.site.create" => nginx_site_create(params).await, + "nginx.site.update" => { + nginx_site_update( + string_param(¶ms, "name")?, + string_param(¶ms, "content")?, + ) + .await + } + "nginx.site.enable" => nginx_site_enable(string_param(¶ms, "name")?).await, + "nginx.site.disable" => nginx_site_disable(string_param(¶ms, "name")?), + "nginx.test" => nginx_test().await, + "nginx.reload" => nginx_reload().await, + "nginx.ssl.status" => nginx_ssl_status().await, + "nginx.ssl.issue" => nginx_ssl_issue(params).await, + "nginx.ssl.renew" => nginx_ssl_renew().await, + "nginx.ssl.auto_renew" => nginx_ssl_auto_renew().await, + "docker.status" => docker_status().await, + "docker.containers" => docker_containers().await, + "docker.container.inspect" => docker_inspect(string_param(¶ms, "id")?).await, + "docker.container.stats" => docker_stats(string_param(¶ms, "id")?).await, + "docker.container.run" => docker_run(params).await, + "docker.container.start" => docker_simple("start", string_param(¶ms, "id")?).await, + "docker.container.stop" => docker_simple("stop", string_param(¶ms, "id")?).await, + "docker.container.restart" => docker_simple("restart", string_param(¶ms, "id")?).await, + "docker.container.delete" => docker_simple("rm", string_param(¶ms, "id")?).await, + "docker.container.logs" => { + docker_logs( + string_param(¶ms, "id")?, + params.get("tail").and_then(Value::as_u64).unwrap_or(200), + ) + .await + } + "docker.images" => docker_images().await, + "docker.image.pull" => docker_pull(string_param(¶ms, "image")?).await, + "docker.image.delete" => docker_rmi(string_param(¶ms, "id")?).await, + "docker.volumes" => docker_volumes().await, + "docker.volume.delete" => docker_volume_delete(string_param(¶ms, "name")?).await, + "docker.compose.projects" => docker_compose_projects().await, + "docker.compose.start" => { + docker_compose_action("start", string_param(¶ms, "project")?).await + } + "docker.compose.stop" => { + docker_compose_action("stop", string_param(¶ms, "project")?).await + } + "docker.compose.restart" => { + docker_compose_action("restart", string_param(¶ms, "project")?).await + } + "docker.compose.logs" => { + docker_compose_logs( + string_param(¶ms, "project")?, + params.get("tail").and_then(Value::as_u64).unwrap_or(200), + ) + .await + } + "docker.compose.preflight" => docker_compose_preflight(params).await, + "docker.compose.deploy" => docker_compose_deploy(params).await, + "docker.compose.update" => docker_compose_update(params).await, + "docker.compose.down" => docker_compose_down(params).await, + _ => bail!("不支持的操作"), + } +} + +#[allow(dead_code)] +pub trait DockerProvider { + async fn list_containers(&self) -> Result>; + async fn start_container(&self, id: &str) -> Result<()>; + async fn stop_container(&self, id: &str) -> Result<()>; + async fn restart_container(&self, id: &str) -> Result<()>; + async fn remove_container(&self, id: &str) -> Result<()>; +} + +#[allow(dead_code)] +pub struct DockerCliProvider; + +impl DockerProvider for DockerCliProvider { + async fn list_containers(&self) -> Result> { + let value = docker_containers().await?; + Ok(serde_json::from_value(value["containers"].clone())?) + } + + async fn start_container(&self, id: &str) -> Result<()> { + docker_simple("start", id.to_string()).await.map(|_| ()) + } + + async fn stop_container(&self, id: &str) -> Result<()> { + docker_simple("stop", id.to_string()).await.map(|_| ()) + } + + async fn restart_container(&self, id: &str) -> Result<()> { + docker_simple("restart", id.to_string()).await.map(|_| ()) + } + + async fn remove_container(&self, id: &str) -> Result<()> { + docker_simple("rm", id.to_string()).await.map(|_| ()) + } +} + +async fn system_snapshot() -> Result { + let processes = command_json_lines( + Command::new("ps").args(["-eo", "pid,ppid,user,comm,%cpu,%mem", "--sort=-%cpu"]), + 8, + parse_process_line, + 30, + ) + .await + .unwrap_or_default(); + let ports = command_lines(Command::new("ss").args(["-H", "-tulpen"]), 8) + .await + .or_else(|_| command_lines(Command::new("netstat").args(["-tulpen"]), 8).await) + .unwrap_or_default() + .into_iter() + .take(80) + .map(|line| json!({ "raw": line })) + .collect::>(); + let disks = command_json_lines(Command::new("df").args(["-hP"]), 8, parse_disk_line, 50) + .await + .unwrap_or_default(); + let networks = command_lines(Command::new("ip").args(["-brief", "addr"]), 8) + .await + .unwrap_or_default() + .into_iter() + .map(|line| json!({ "raw": line })) + .collect::>(); + Ok(json!({ + "processes": processes, + "ports": ports, + "disks": disks, + "networks": networks + })) +} + +async fn command_lines(cmd: &mut Command, timeout_secs: u64) -> Result> { + let output = run_command(cmd, timeout_secs).await?; + let text = output_text(output)?; + Ok(text + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .map(ToString::to_string) + .collect()) +} + +async fn command_json_lines( + cmd: &mut Command, + timeout_secs: u64, + parser: F, + limit: usize, +) -> Result> +where + F: Fn(&str) -> Option, +{ + Ok(command_lines(cmd, timeout_secs) + .await? + .into_iter() + .skip(1) + .filter_map(|line| parser(&line)) + .take(limit) + .collect()) +} + +fn parse_process_line(line: &str) -> Option { + let parts = line.split_whitespace().collect::>(); + if parts.len() < 6 { + return None; + } + Some(json!({ + "pid": parts[0], + "ppid": parts[1], + "user": parts[2], + "name": parts[3], + "cpu": parts[4], + "memory": parts[5] + })) +} + +fn parse_disk_line(line: &str) -> Option { + let parts = line.split_whitespace().collect::>(); + if parts.len() < 6 { + return None; + } + Some(json!({ + "filesystem": parts[0], + "size": parts[1], + "used": parts[2], + "available": parts[3], + "usage": parts[4], + "mount": parts[5] + })) +} + +fn string_param(params: &Value, name: &str) -> Result { + params + .get(name) + .and_then(Value::as_str) + .map(ToString::to_string) + .ok_or_else(|| anyhow!("缺少参数:{name}")) +} + +fn path_param(params: &Value, name: &str) -> Result { + let value = string_param(params, name)?; + if value.contains('\0') { + bail!("路径无效"); + } + Ok(PathBuf::from(value)) +} + +fn file_roots() -> Result { + let mut roots = Vec::new(); + #[cfg(windows)] + { + for letter in b'A'..=b'Z' { + let path = format!("{}:\\", letter as char); + if Path::new(&path).exists() { + roots.push(json!({ "name": path, "path": path })); + } + } + } + #[cfg(not(windows))] + { + roots.push(json!({ "name": "/", "path": "/" })); + } + Ok(json!({ "roots": roots })) +} + +fn file_list(path: PathBuf) -> Result { + let entries = fs::read_dir(&path) + .with_context(|| format!("read directory {}", path.display()))? + .filter_map(|entry| { + let entry = entry.ok()?; + let meta = entry.metadata().ok()?; + Some(FileEntry { + name: entry.file_name().to_string_lossy().to_string(), + path: entry.path().to_string_lossy().to_string(), + is_dir: meta.is_dir(), + size: meta.len(), + modified: meta.modified().ok().and_then(|t| { + t.duration_since(std::time::UNIX_EPOCH) + .ok() + .map(|d| d.as_secs().to_string()) + }), + readonly: meta.permissions().readonly(), + }) + }) + .collect::>(); + Ok(json!({ "path": path, "entries": entries })) +} + +fn file_search(params: Value) -> Result { + let root = path_param(¶ms, "path")?; + let keyword = string_param(¶ms, "keyword")?; + let keyword = keyword.trim().to_lowercase(); + if keyword.is_empty() + || keyword.len() > 120 + || keyword.contains('\0') + || keyword.contains('/') + || keyword.contains('\\') + { + bail!("搜索关键词无效"); + } + let max_depth = params + .get("max_depth") + .and_then(Value::as_u64) + .unwrap_or(5) + .min(10) as usize; + let limit = params + .get("limit") + .and_then(Value::as_u64) + .unwrap_or(100) + .min(500) as usize; + let mut results = Vec::new(); + search_dir(&root, &keyword, 0, max_depth, limit, &mut results)?; + Ok(json!({ "path": root, "keyword": keyword, "entries": results })) +} + +fn search_dir( + path: &Path, + keyword: &str, + depth: usize, + max_depth: usize, + limit: usize, + results: &mut Vec, +) -> Result<()> { + if depth > max_depth || results.len() >= limit { + return Ok(()); + } + let Ok(entries) = fs::read_dir(path) else { + return Ok(()); + }; + for entry in entries.flatten() { + if results.len() >= limit { + break; + } + let name = entry.file_name().to_string_lossy().to_string(); + let entry_path = entry.path(); + let meta = entry.metadata().ok(); + let is_dir = meta.as_ref().is_some_and(|value| value.is_dir()); + if name.to_lowercase().contains(keyword) { + results.push(json!({ + "name": name, + "path": entry_path, + "is_dir": is_dir, + "size": meta.as_ref().map(|value| value.len()).unwrap_or(0) + })); + } + if is_dir { + let _ = search_dir(&entry_path, keyword, depth + 1, max_depth, limit, results); + } + } + Ok(()) +} + +fn file_read(path: PathBuf) -> Result { + let meta = fs::metadata(&path)?; + if meta.len() > MAX_TEXT_FILE { + bail!("文件过大,无法按文本读取"); + } + Ok(json!({ "path": path, "content": fs::read_to_string(path)? })) +} + +fn file_write(path: PathBuf, content: String) -> Result { + if content.len() as u64 > MAX_TEXT_FILE { + bail!("写入内容过大"); + } + fs::write(&path, content)?; + Ok(json!({ "ok": true, "path": path })) +} + +fn file_mkdir(path: PathBuf) -> Result { + fs::create_dir_all(&path)?; + Ok(json!({ "ok": true, "path": path })) +} + +fn file_delete(path: PathBuf, recursive: bool) -> Result { + let meta = fs::metadata(&path)?; + if meta.is_dir() { + if recursive { + fs::remove_dir_all(&path)?; + } else { + fs::remove_dir(&path)?; + } + } else { + fs::remove_file(&path)?; + } + Ok(json!({ "ok": true })) +} + +fn file_rename(from: PathBuf, to: PathBuf) -> Result { + fs::rename(&from, &to)?; + Ok(json!({ "ok": true })) +} + +fn file_upload_chunk(params: Value) -> Result { + let path = path_param(¶ms, "path")?; + let offset = params.get("offset").and_then(Value::as_u64).unwrap_or(0); + let data = string_param(¶ms, "data")?; + let bytes = general_purpose::STANDARD + .decode(data) + .context("分片数据不是有效 base64")?; + if bytes.len() > 1024 * 1024 { + bail!("单个分片不能超过 1MB"); + } + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + let mut file = fs::OpenOptions::new() + .create(true) + .write(true) + .truncate(offset == 0) + .open(&path)?; + file.seek(SeekFrom::Start(offset))?; + file.write_all(&bytes)?; + Ok(json!({ "ok": true, "path": path, "written": bytes.len(), "offset": offset })) +} + +fn file_download_chunk(params: Value) -> Result { + let path = path_param(¶ms, "path")?; + let offset = params.get("offset").and_then(Value::as_u64).unwrap_or(0); + let size = params + .get("size") + .and_then(Value::as_u64) + .unwrap_or(512 * 1024) + .min(1024 * 1024) as usize; + let meta = fs::metadata(&path)?; + if !meta.is_file() { + bail!("只能下载普通文件"); + } + let mut file = fs::File::open(&path)?; + file.seek(SeekFrom::Start(offset))?; + let mut buf = vec![0u8; size]; + let read = file.read(&mut buf)?; + buf.truncate(read); + Ok(json!({ + "path": path, + "offset": offset, + "size": meta.len(), + "read": read, + "eof": offset + read as u64 >= meta.len(), + "data": general_purpose::STANDARD.encode(buf) + })) +} + +fn file_chmod(params: Value) -> Result { + let path = path_param(¶ms, "path")?; + let mode = string_param(¶ms, "mode")?; + if mode.len() > 4 || !mode.chars().all(|c| matches!(c, '0'..='7')) { + bail!("权限模式无效,例如 755"); + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let value = u32::from_str_radix(&mode, 8)?; + let mut permissions = fs::metadata(&path)?.permissions(); + permissions.set_mode(value); + fs::set_permissions(&path, permissions)?; + Ok(json!({ "ok": true, "path": path, "mode": mode })) + } + #[cfg(not(unix))] + { + let _ = path; + bail!("当前系统不支持 chmod") + } +} + +async fn log_tail(params: Value) -> Result { + let path = path_param(¶ms, "path")?; + let lines = params + .get("lines") + .and_then(Value::as_u64) + .unwrap_or(200) + .min(2000); + let output = run_command( + Command::new("tail") + .arg("-n") + .arg(lines.to_string()) + .arg(path), + 8, + ) + .await?; + command_value(output) +} + +async fn service_list() -> Result { + let output = run_command( + Command::new("systemctl").args([ + "list-units", + "--type=service", + "--all", + "--no-pager", + "--plain", + ]), + 10, + ) + .await?; + let text = output_text(output)?; + let services = text + .lines() + .filter(|line| line.ends_with(".service") || line.contains(".service ")) + .filter_map(parse_service_line) + .collect::>(); + Ok(json!({ "services": services })) +} + +fn parse_service_line(line: &str) -> Option { + let parts = line.split_whitespace().collect::>(); + if parts.len() < 4 || !parts[0].contains(".service") { + return None; + } + Some(ServiceInfo { + name: parts[0].to_string(), + load: parts.get(1).unwrap_or(&"").to_string(), + active: parts.get(2).unwrap_or(&"").to_string(), + sub: parts.get(3).unwrap_or(&"").to_string(), + description: parts.get(4..).unwrap_or(&[]).join(" "), + enabled: None, + }) +} + +async fn service_status(name: String) -> Result { + validate_unit(&name)?; + command_value( + run_command( + Command::new("systemctl").args(["status", "--no-pager", &name]), + 10, + ) + .await?, + ) +} + +async fn service_action(action: &str, name: String) -> Result { + validate_unit(&name)?; + command_value(run_command(Command::new("systemctl").args([action, &name]), 30).await?) +} + +fn validate_unit(name: &str) -> Result<()> { + if name.len() > 200 + || !name + .chars() + .all(|c| c.is_ascii_alphanumeric() || ".@_-".contains(c)) + { + bail!("服务名称无效"); + } + Ok(()) +} + +async fn nginx_status() -> Result { + let output = run_command(Command::new("nginx").arg("-v"), 8).await; + match output { + Ok(output) => { + Ok(json!({ "installed": output.status.success(), "version": stderr_text(&output) })) + } + Err(_) => Ok(json!({ "installed": false, "version": null })), + } +} + +fn nginx_sites() -> Result { + let enabled = fs::read_dir(NGINX_ENABLED) + .map(|it| { + it.filter_map(|e| e.ok().map(|e| e.file_name().to_string_lossy().to_string())) + .collect::>() + }) + .unwrap_or_default(); + let sites = fs::read_dir(NGINX_AVAILABLE)? + .filter_map(|entry| { + let entry = entry.ok()?; + let name = entry.file_name().to_string_lossy().to_string(); + Some(NginxSite { + enabled: enabled.contains(&name), + path: entry.path().to_string_lossy().to_string(), + name, + }) + }) + .collect::>(); + Ok(json!({ "sites": sites })) +} + +fn nginx_site_get(name: String) -> Result { + validate_site_name(&name)?; + let path = Path::new(NGINX_AVAILABLE).join(&name); + Ok(json!({ "name": name, "content": fs::read_to_string(path)? })) +} + +async fn nginx_site_create(params: Value) -> Result { + let name = string_param(¶ms, "name")?; + validate_site_name(&name)?; + let server_name = params + .get("server_name") + .and_then(Value::as_str) + .unwrap_or(&name); + let mode = params + .get("mode") + .and_then(Value::as_str) + .unwrap_or("static"); + let content = match mode { + "proxy" => { + let upstream = params + .get("upstream") + .and_then(Value::as_str) + .unwrap_or("http://127.0.0.1:3000"); + nginx_proxy_config(server_name, upstream, ¶ms) + } + "spa" => { + let root = params + .get("root") + .and_then(Value::as_str) + .unwrap_or("/var/www/html"); + nginx_spa_config(server_name, root) + } + "load_balance" => nginx_load_balance_config(server_name, ¶ms)?, + "php" => { + let root = params + .get("root") + .and_then(Value::as_str) + .unwrap_or("/var/www/html"); + let fastcgi_pass = params + .get("fastcgi_pass") + .and_then(Value::as_str) + .unwrap_or("unix:/run/php/php8.2-fpm.sock"); + nginx_php_config(server_name, root, fastcgi_pass) + } + _ => { + let root = params + .get("root") + .and_then(Value::as_str) + .unwrap_or("/var/www/html"); + nginx_static_config(server_name, root) + } + }; + nginx_site_update(name.clone(), content).await?; + Ok(json!({ "ok": true, "name": name })) +} + +fn nginx_site_backups(name: String) -> Result { + validate_site_name(&name)?; + let prefix = format!("{name}.bak."); + let backups = fs::read_dir(NGINX_AVAILABLE) + .map(|entries| { + entries + .filter_map(|entry| { + let entry = entry.ok()?; + let file_name = entry.file_name().to_string_lossy().to_string(); + if !file_name.starts_with(&prefix) { + return None; + } + let meta = entry.metadata().ok()?; + Some(json!({ + "name": file_name, + "path": entry.path().to_string_lossy().to_string(), + "size": meta.len(), + "modified": meta.modified().ok().and_then(|time| time.duration_since(std::time::UNIX_EPOCH).ok()).map(|duration| duration.as_secs().to_string()) + })) + }) + .collect::>() + }) + .unwrap_or_default(); + Ok(json!({ "name": name, "backups": backups })) +} + +async fn nginx_site_restore_backup(name: String, backup: String) -> Result { + validate_site_name(&name)?; + validate_site_name(&backup)?; + if !backup.starts_with(&format!("{name}.bak.")) { + bail!("备份文件不属于该站点"); + } + let backup_path = Path::new(NGINX_AVAILABLE).join(&backup); + if !backup_path.exists() { + bail!("备份文件不存在"); + } + let target = Path::new(NGINX_AVAILABLE).join(&name); + if target.exists() { + let rollback = target.with_extension(format!("bak.{}", chrono_like_timestamp())); + fs::copy(&target, rollback)?; + } + fs::copy(&backup_path, &target)?; + match nginx_test().await { + Ok(_) => Ok(json!({ "ok": true, "name": name, "backup": backup })), + Err(err) => bail!("恢复后 Nginx 配置测试失败:{err}"), + } +} + +async fn nginx_site_update(name: String, content: String) -> Result { + validate_site_name(&name)?; + let path = Path::new(NGINX_AVAILABLE).join(&name); + let mut backup_path = None; + if path.exists() { + let backup = path.with_extension(format!("bak.{}", chrono_like_timestamp())); + fs::copy(&path, &backup)?; + backup_path = Some(backup); + } + fs::write(&path, content)?; + match nginx_test().await { + Ok(_) => Ok(json!({ "ok": true })), + Err(err) => { + if let Some(backup) = backup_path { + let _ = fs::copy(backup, &path); + } else { + let _ = fs::remove_file(&path); + } + bail!("Nginx 配置测试失败,已尝试回滚:{err}") + } + } +} + +async fn nginx_site_enable(name: String) -> Result { + validate_site_name(&name)?; + let src = Path::new(NGINX_AVAILABLE).join(&name); + let dst = Path::new(NGINX_ENABLED).join(&name); + if !dst.exists() { + #[cfg(unix)] + std::os::unix::fs::symlink(&src, &dst)?; + #[cfg(windows)] + fs::copy(&src, &dst)?; + } + nginx_test().await?; + Ok(json!({ "ok": true })) +} + +fn nginx_site_disable(name: String) -> Result { + validate_site_name(&name)?; + let dst = Path::new(NGINX_ENABLED).join(&name); + if dst.exists() { + fs::remove_file(dst)?; + } + Ok(json!({ "ok": true })) +} + +async fn nginx_test() -> Result { + command_value(run_command(Command::new("nginx").arg("-t"), 15).await?) +} + +async fn nginx_reload() -> Result { + nginx_test().await?; + command_value(run_command(Command::new("systemctl").args(["reload", "nginx"]), 20).await?) +} + +async fn nginx_ssl_status() -> Result { + let certbot = run_command(Command::new("certbot").arg("--version"), 8).await; + let installed = certbot + .as_ref() + .map(|output| output.status.success()) + .unwrap_or(false); + let version = certbot + .as_ref() + .map(stderr_text) + .filter(|text| !text.trim().is_empty()) + .or_else(|| certbot.as_ref().map(stdout_text)) + .unwrap_or_default(); + let certbot_timer = run_command( + Command::new("systemctl").args(["is-enabled", "certbot.timer"]), + 8, + ) + .await; + let lightops_timer = run_command( + Command::new("systemctl").args(["is-enabled", "lightops-certbot-renew.timer"]), + 8, + ) + .await; + let auto_renew_enabled = certbot_timer + .as_ref() + .map(|output| output.status.success()) + .unwrap_or(false) + || lightops_timer + .as_ref() + .map(|output| output.status.success()) + .unwrap_or(false); + let auto_renew_provider = if certbot_timer + .as_ref() + .map(|output| output.status.success()) + .unwrap_or(false) + { + "certbot.timer" + } else if lightops_timer + .as_ref() + .map(|output| output.status.success()) + .unwrap_or(false) + { + "lightops-certbot-renew.timer" + } else { + "none" + }; + let certs = list_letsencrypt_certs().await; + Ok(json!({ + "installed": installed, + "version": version.trim(), + "auto_renew_enabled": auto_renew_enabled, + "auto_renew_provider": auto_renew_provider, + "certs": certs + })) +} + +async fn nginx_ssl_issue(params: Value) -> Result { + ensure_certbot_installed().await?; + let domains = params + .get("domains") + .and_then(Value::as_array) + .ok_or_else(|| anyhow!("缺少域名列表"))? + .iter() + .filter_map(Value::as_str) + .map(str::trim) + .filter(|domain| !domain.is_empty()) + .map(ToString::to_string) + .collect::>(); + if domains.is_empty() { + bail!("至少需要一个域名"); + } + for domain in &domains { + validate_domain(domain)?; + } + let email = params.get("email").and_then(Value::as_str).map(str::trim); + if let Some(email) = email { + validate_email(email)?; + } + let mut command = Command::new("certbot"); + command + .args(["--nginx", "--non-interactive", "--agree-tos", "--redirect"]) + .arg("--keep-until-expiring"); + if let Some(email) = email.filter(|value| !value.is_empty()) { + command.args(["-m", email]); + } else { + command.arg("--register-unsafely-without-email"); + } + for domain in &domains { + command.args(["-d", domain]); + } + let result = command_value(run_command(&mut command, 120).await?)?; + let _ = nginx_reload().await; + Ok(result) +} + +async fn nginx_ssl_renew() -> Result { + ensure_certbot_installed().await?; + let result = command_value(run_command(Command::new("certbot").arg("renew"), 120).await?)?; + let _ = nginx_reload().await; + Ok(result) +} + +async fn nginx_ssl_auto_renew() -> Result { + ensure_certbot_installed().await?; + let systemctl = run_command( + Command::new("systemctl").args(["enable", "--now", "certbot.timer"]), + 20, + ) + .await; + match systemctl { + Ok(output) if output.status.success() => command_value(output), + _ => { + install_lightops_certbot_timer()?; + command_value(run_command(Command::new("systemctl").arg("daemon-reload"), 20).await?)?; + command_value( + run_command( + Command::new("systemctl").args([ + "enable", + "--now", + "lightops-certbot-renew.timer", + ]), + 20, + ) + .await?, + ) + } + } +} + +async fn ensure_certbot_installed() -> Result<()> { + let output = run_command(Command::new("certbot").arg("--version"), 8).await; + match output { + Ok(output) if output.status.success() => Ok(()), + _ => bail!("未检测到 certbot,请先在 Agent 主机安装 certbot 和 Nginx 插件"), + } +} + +fn install_lightops_certbot_timer() -> Result<()> { + let service = r#"[Unit] +Description=LightOps Certbot 自动续期 +Wants=network-online.target +After=network-online.target + +[Service] +Type=oneshot +ExecStart=/usr/bin/env certbot renew --quiet --deploy-hook "systemctl reload nginx" +"#; + let timer = r#"[Unit] +Description=LightOps 每日检查 Certbot 证书续期 + +[Timer] +OnCalendar=*-*-* 03:35:00 +RandomizedDelaySec=1800 +Persistent=true + +[Install] +WantedBy=timers.target +"#; + fs::write( + "/etc/systemd/system/lightops-certbot-renew.service", + service, + )?; + fs::write("/etc/systemd/system/lightops-certbot-renew.timer", timer)?; + Ok(()) +} + +async fn list_letsencrypt_certs() -> Vec { + let root = Path::new("/etc/letsencrypt/live"); + let Ok(entries) = fs::read_dir(root) else { + return Vec::new(); + }; + let mut certs = Vec::new(); + for entry in entries { + if let Some(cert) = async { + let entry = entry.ok()?; + let name = entry.file_name().to_string_lossy().to_string(); + if name.starts_with('.') { + return None; + } + let fullchain = entry.path().join("fullchain.pem"); + let (expires_at, days_remaining, status) = cert_expiry(&fullchain).await; + Some(json!({ + "name": name, + "path": entry.path().to_string_lossy().to_string(), + "fullchain": fullchain.to_string_lossy().to_string(), + "privkey": entry.path().join("privkey.pem").to_string_lossy().to_string(), + "expires_at": expires_at, + "days_remaining": days_remaining, + "status": status + })) + } + .await + { + certs.push(cert); + } + } + certs +} + +async fn cert_expiry(path: &Path) -> (Option, Option, String) { + let output = run_command( + Command::new("openssl") + .args(["x509", "-enddate", "-noout", "-in"]) + .arg(path), + 8, + ) + .await; + let Ok(output) = output else { + return (None, None, "unknown".into()); + }; + if !output.status.success() { + return (None, None, "unknown".into()); + } + let text = stdout_text(&output); + let raw = text.trim().strip_prefix("notAfter=").unwrap_or(text.trim()); + let Ok(naive) = NaiveDateTime::parse_from_str(raw, "%b %e %H:%M:%S %Y GMT") else { + return (Some(raw.to_string()), None, "unknown".into()); + }; + let expires = DateTime::::from_naive_utc_and_offset(naive, Utc); + let days = (expires - Utc::now()).num_days(); + let status = if days < 0 { + "expired" + } else if days <= 7 { + "critical" + } else if days <= 30 { + "warning" + } else { + "valid" + }; + (Some(expires.to_rfc3339()), Some(days), status.into()) +} + +fn nginx_static_config(server_name: &str, root: &str) -> String { + format!( + "server {{\n listen 80;\n server_name {server_name};\n\n root {root};\n index index.html index.htm;\n\n location / {{\n try_files $uri $uri/ =404;\n }}\n}}\n" + ) +} + +fn nginx_spa_config(server_name: &str, root: &str) -> String { + format!( + "server {{\n listen 80;\n server_name {server_name};\n\n root {root};\n index index.html;\n\n location / {{\n try_files $uri $uri/ /index.html;\n }}\n\n location ~* \\.(?:css|js|jpg|jpeg|gif|png|ico|svg|webp|woff2?)$ {{\n try_files $uri =404;\n expires 7d;\n add_header Cache-Control \"public, max-age=604800\";\n }}\n}}\n" + ) +} + +fn nginx_php_config(server_name: &str, root: &str, fastcgi_pass: &str) -> String { + format!( + "server {{\n listen 80;\n server_name {server_name};\n\n root {root};\n index index.php index.html index.htm;\n\n location / {{\n try_files $uri $uri/ /index.php?$query_string;\n }}\n\n location ~ \\.php$ {{\n include snippets/fastcgi-php.conf;\n fastcgi_pass {fastcgi_pass};\n }}\n\n location ~ /\\.ht {{\n deny all;\n }}\n}}\n" + ) +} + +fn nginx_load_balance_config(server_name: &str, params: &Value) -> Result { + let upstream_name = format!("lightops_{}", sanitize_upstream_name(server_name)); + let upstreams = params + .get("upstreams") + .and_then(Value::as_array) + .ok_or_else(|| anyhow!("缺少上游列表"))? + .iter() + .filter_map(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .collect::>(); + if upstreams.is_empty() { + bail!("至少需要一个上游地址"); + } + let mut upstream_block = format!("upstream {upstream_name} {{\n"); + for upstream in upstreams { + validate_nginx_upstream(upstream)?; + upstream_block.push_str(&format!(" server {upstream};\n")); + } + upstream_block.push_str(" keepalive 32;\n}\n\n"); + let mut proxy_params = params.clone(); + if let Some(obj) = proxy_params.as_object_mut() { + obj.insert("websocket".into(), json!(true)); + obj.insert("gzip".into(), json!(true)); + } + Ok(format!( + "{upstream_block}{}", + nginx_proxy_config( + server_name, + &format!("http://{upstream_name}"), + &proxy_params + ) + )) +} + +fn nginx_proxy_config(server_name: &str, upstream: &str, params: &Value) -> String { + let websocket = params + .get("websocket") + .and_then(Value::as_bool) + .unwrap_or(true); + let gzip = params.get("gzip").and_then(Value::as_bool).unwrap_or(true); + let cache_static = params + .get("cache_static") + .and_then(Value::as_bool) + .unwrap_or(false); + let force_https = params + .get("force_https") + .and_then(Value::as_bool) + .unwrap_or(false); + let client_max_body_size = params + .get("client_max_body_size") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("64m"); + let client_max_body_size = sanitize_nginx_size(client_max_body_size); + let mut extra = String::new(); + if force_https { + extra.push_str( + " if ($scheme = http) {\n return 301 https://$host$request_uri;\n }\n\n", + ); + } + if gzip { + extra.push_str(" gzip on;\n gzip_types text/plain text/css application/json application/javascript application/xml image/svg+xml;\n\n"); + } + let websocket_headers = if websocket { + " proxy_http_version 1.1;\n proxy_set_header Upgrade $http_upgrade;\n proxy_set_header Connection \"upgrade\";\n" + } else { + "" + }; + let static_cache = if cache_static { + "\n location ~* \\.(?:css|js|jpg|jpeg|gif|png|ico|svg|webp|woff2?)$ {\n proxy_pass {upstream};\n expires 7d;\n add_header Cache-Control \"public, max-age=604800\";\n }\n" + } else { + "" + }; + format!( + "server {{\n listen 80;\n server_name {server_name};\n client_max_body_size {client_max_body_size};\n\n{extra} location / {{\n proxy_pass {upstream};\n proxy_set_header Host $host;\n proxy_set_header X-Real-IP $remote_addr;\n proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n proxy_set_header X-Forwarded-Proto $scheme;\n{websocket_headers} proxy_read_timeout 300s;\n proxy_send_timeout 300s;\n }}\n{static_cache}}}\n" + ) + .replace("{upstream}", upstream) +} + +fn sanitize_upstream_name(server_name: &str) -> String { + let name = server_name + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() { + c.to_ascii_lowercase() + } else { + '_' + } + }) + .collect::(); + name.trim_matches('_').chars().take(80).collect() +} + +fn validate_nginx_upstream(value: &str) -> Result<()> { + if value.len() > 200 + || value.contains('\0') + || value.contains(';') + || value.contains('{') + || value.contains('}') + || value.contains('$') + || value.contains('`') + || value.contains(' ') + { + bail!("上游地址无效:{value}"); + } + Ok(()) +} + +fn sanitize_nginx_size(value: &str) -> String { + if value.len() <= 16 + && value + .chars() + .all(|c| c.is_ascii_digit() || matches!(c, 'k' | 'K' | 'm' | 'M' | 'g' | 'G')) + { + value.to_string() + } else { + "64m".into() + } +} + +fn validate_site_name(name: &str) -> Result<()> { + if name.len() > 180 + || name.contains('/') + || name.contains('\\') + || name.contains("..") + || name.contains('\0') + { + bail!("站点名称无效"); + } + Ok(()) +} + +fn validate_domain(domain: &str) -> Result<()> { + if domain.len() > 253 + || domain.starts_with('-') + || domain.ends_with('-') + || domain.contains("..") + || !domain + .chars() + .all(|c| c.is_ascii_alphanumeric() || ".-".contains(c)) + { + bail!("域名无效:{domain}"); + } + Ok(()) +} + +fn validate_email(email: &str) -> Result<()> { + if email.len() > 254 || email.contains('\0') || !email.contains('@') { + bail!("邮箱无效"); + } + Ok(()) +} + +fn chrono_like_timestamp() -> String { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() + .to_string() +} + +async fn docker_status() -> Result { + let output = run_command( + Command::new("docker") + .arg("version") + .arg("--format") + .arg("{{json .Server.Version}}"), + 8, + ) + .await; + match output { + Ok(output) => Ok( + json!({ "installed": output.status.success(), "version": stdout_text(&output).trim() }), + ), + Err(_) => Ok(json!({ "installed": false, "version": null })), + } +} + +async fn docker_containers() -> Result { + let output = run_command( + Command::new("docker").args(["ps", "-a", "--format", "{{json .}}"]), + 12, + ) + .await?; + let text = output_text(output)?; + let containers = text + .lines() + .filter_map(|line| serde_json::from_str::(line).ok()) + .map(|v| DockerContainer { + id: v + .get("ID") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + image: v + .get("Image") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + command: v + .get("Command") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + status: v + .get("Status") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + names: v + .get("Names") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + ports: v + .get("Ports") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + }) + .collect::>(); + Ok(json!({ "containers": containers })) +} + +async fn docker_inspect(id: String) -> Result { + validate_docker_id(&id)?; + let output = run_command(Command::new("docker").args(["inspect", &id]), 20).await?; + let text = output_text(output)?; + let parsed: Value = serde_json::from_str(&text).context("Docker inspect 输出不是有效 JSON")?; + let detail = parsed + .as_array() + .and_then(|items| items.first()) + .cloned() + .unwrap_or(parsed); + Ok(json!({ "id": id, "detail": detail })) +} + +async fn docker_stats(id: String) -> Result { + validate_docker_id(&id)?; + let output = run_command( + Command::new("docker").args(["stats", "--no-stream", "--format", "{{json .}}", &id]), + 20, + ) + .await?; + let text = output_text(output)?; + let stats = text + .lines() + .find_map(|line| serde_json::from_str::(line).ok()) + .unwrap_or_else(|| json!({})); + Ok(json!({ "id": id, "stats": stats })) +} + +async fn docker_images() -> Result { + let output = run_command( + Command::new("docker").args(["images", "--format", "{{json .}}"]), + 12, + ) + .await?; + let text = output_text(output)?; + let images = text + .lines() + .filter_map(|line| serde_json::from_str::(line).ok()) + .map(|v| DockerImage { + repository: v + .get("Repository") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + tag: v + .get("Tag") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + id: v + .get("ID") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + size: v + .get("Size") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + }) + .collect::>(); + Ok(json!({ "images": images })) +} + +async fn docker_volumes() -> Result { + let output = run_command( + Command::new("docker").args(["volume", "ls", "--format", "{{json .}}"]), + 12, + ) + .await?; + let text = output_text(output)?; + let volumes = text + .lines() + .filter_map(|line| serde_json::from_str::(line).ok()) + .collect::>(); + Ok(json!({ "volumes": volumes })) +} + +async fn docker_volume_delete(name: String) -> Result { + validate_docker_volume_name(&name)?; + command_value(run_command(Command::new("docker").args(["volume", "rm", &name]), 60).await?) +} + +async fn docker_simple(action: &str, id: String) -> Result { + validate_docker_id(&id)?; + command_value(run_command(Command::new("docker").args([action, &id]), 30).await?) +} + +async fn docker_logs(id: String, tail: u64) -> Result { + validate_docker_id(&id)?; + let tail = tail.min(2000).to_string(); + command_value( + run_command( + Command::new("docker").args(["logs", "--tail", &tail, &id]), + 30, + ) + .await?, + ) +} + +async fn docker_rmi(id: String) -> Result { + validate_docker_id(&id)?; + command_value(run_command(Command::new("docker").args(["rmi", &id]), 60).await?) +} + +async fn docker_pull(image: String) -> Result { + validate_docker_image(&image)?; + command_value(run_command(Command::new("docker").args(["pull", &image]), 600).await?) +} + +async fn docker_run(params: Value) -> Result { + let image = string_param(¶ms, "image")?; + validate_docker_image(&image)?; + let mut cmd = Command::new("docker"); + cmd.arg("run"); + if params + .get("detach") + .and_then(Value::as_bool) + .unwrap_or(true) + { + cmd.arg("-d"); + } + if let Some(name) = params.get("name").and_then(Value::as_str).map(str::trim) { + if !name.is_empty() { + validate_container_name(name)?; + cmd.args(["--name", name]); + } + } + if let Some(restart) = params.get("restart").and_then(Value::as_str) { + validate_restart_policy(restart)?; + if restart != "no" { + cmd.args(["--restart", restart]); + } + } + if let Some(items) = params.get("ports").and_then(Value::as_array) { + for item in items { + let host = item + .get("host") + .and_then(Value::as_u64) + .ok_or_else(|| anyhow!("端口映射缺少宿主机端口"))?; + let container = item + .get("container") + .and_then(Value::as_u64) + .ok_or_else(|| anyhow!("端口映射缺少容器端口"))?; + validate_port(host)?; + validate_port(container)?; + let protocol = item + .get("protocol") + .and_then(Value::as_str) + .unwrap_or("tcp"); + if !matches!(protocol, "tcp" | "udp") { + bail!("端口协议只支持 tcp/udp"); + } + cmd.args(["-p", &format!("{host}:{container}/{protocol}")]); + } + } + if let Some(items) = params.get("volumes").and_then(Value::as_array) { + for item in items { + let host = item + .get("host") + .and_then(Value::as_str) + .ok_or_else(|| anyhow!("卷映射缺少宿主机路径"))?; + let container = item + .get("container") + .and_then(Value::as_str) + .ok_or_else(|| anyhow!("卷映射缺少容器路径"))?; + validate_volume_path(host)?; + validate_volume_path(container)?; + let suffix = if item + .get("readonly") + .and_then(Value::as_bool) + .unwrap_or(false) + { + ":ro" + } else { + "" + }; + cmd.args(["-v", &format!("{host}:{container}{suffix}")]); + } + } + if let Some(env) = params.get("env").and_then(Value::as_object) { + for (key, value) in env { + validate_env_key(key)?; + let value = value.as_str().unwrap_or_default(); + if value.contains('\0') || value.len() > 2048 { + bail!("环境变量值无效"); + } + cmd.args(["-e", &format!("{key}={value}")]); + } + } + cmd.arg(&image); + if let Some(command) = params.get("command").and_then(Value::as_str).map(str::trim) { + if !command.is_empty() { + validate_command_args(command)?; + for part in command.split_whitespace() { + cmd.arg(part); + } + } + } + command_value(run_command(&mut cmd, 120).await?) +} + +async fn docker_compose_projects() -> Result { + let output = run_command( + Command::new("docker").args(["ps", "-a", "--format", "{{json .}}"]), + 12, + ) + .await?; + let text = output_text(output)?; + let mut projects = serde_json::Map::new(); + for line in text.lines() { + let Ok(value) = serde_json::from_str::(line) else { + continue; + }; + let labels = value + .get("Labels") + .and_then(Value::as_str) + .unwrap_or_default(); + let Some(project) = label_value(labels, "com.docker.compose.project") else { + continue; + }; + let service = label_value(labels, "com.docker.compose.service").unwrap_or_default(); + let status = value + .get("Status") + .and_then(Value::as_str) + .unwrap_or_default(); + let entry = projects.entry(project.clone()).or_insert_with(|| { + json!({ + "name": project, + "services": [], + "containers": [], + "running": 0, + "stopped": 0, + "status": "Stopped" + }) + }); + if let Some(obj) = entry.as_object_mut() { + push_unique_json_string(obj, "services", &service); + if let Some(id) = value.get("ID").and_then(Value::as_str) { + push_unique_json_string(obj, "containers", id); + } + let key = if status.starts_with("Up") { + "running" + } else { + "stopped" + }; + let next = obj.get(key).and_then(Value::as_u64).unwrap_or(0) + 1; + obj.insert(key.into(), json!(next)); + obj.insert( + "status".into(), + json!(if status.starts_with("Up") { + "Running" + } else { + "Stopped" + }), + ); + } + } + Ok(json!({ "projects": projects.into_iter().map(|(_, value)| value).collect::>() })) +} + +async fn docker_compose_action(action: &str, project: String) -> Result { + validate_compose_project(&project)?; + let ids = compose_container_ids(&project).await?; + if ids.is_empty() { + bail!("未找到 Compose 项目容器"); + } + let mut cmd = Command::new("docker"); + cmd.arg(action); + for id in &ids { + cmd.arg(id); + } + command_value(run_command(&mut cmd, 60).await?) +} + +async fn docker_compose_logs(project: String, tail: u64) -> Result { + validate_compose_project(&project)?; + let ids = compose_container_ids(&project).await?; + let tail = tail.min(2000).to_string(); + let mut content = String::new(); + for id in ids { + let output = run_command( + Command::new("docker").args(["logs", "--tail", &tail, &id]), + 30, + ) + .await?; + content.push_str(&format!("===== {id} =====\n")); + content.push_str(&stdout_text(&output)); + content.push_str(&stderr_text(&output)); + if !content.ends_with('\n') { + content.push('\n'); + } + } + Ok(json!({ "ok": true, "stdout": content, "stderr": "" })) +} + +async fn docker_compose_preflight(params: Value) -> Result { + let project = string_param(¶ms, "project")?; + validate_compose_project(&project)?; + let work_dir = path_param(¶ms, "work_dir")?; + let data_dir = path_param(¶ms, "data_dir")?; + validate_absolute_path(&work_dir)?; + validate_absolute_path(&data_dir)?; + + let docker = run_command(Command::new("docker").arg("info"), 15).await?; + if !docker.status.success() { + bail!("Docker 不可用:{}", stderr_text(&docker)); + } + let compose = run_command(Command::new("docker").args(["compose", "version"]), 15).await?; + if !compose.status.success() { + bail!("Docker Compose 不可用:{}", stderr_text(&compose)); + } + let ids = compose_container_ids(&project).await?; + if !ids.is_empty() { + bail!("Compose 项目名已存在:{project}"); + } + if work_dir.join("compose.yaml").exists() { + bail!("工作目录已存在 compose.yaml,为避免覆盖请更换项目名或工作目录"); + } + + let mut warnings = Vec::new(); + if dir_has_entries(&work_dir)? { + warnings.push(format!("工作目录 {} 非空", work_dir.display())); + } + if dir_has_entries(&data_dir)? { + warnings.push(format!( + "数据目录 {} 非空,安装会复用该目录", + data_dir.display() + )); + } + if let Some(ports) = params.get("ports").and_then(Value::as_array) { + for port in ports { + let port = port.as_u64().ok_or_else(|| anyhow!("端口必须是数字"))?; + validate_port(port)?; + if port_in_use(port as u16).await? { + bail!("端口已被占用:{port}"); + } + } + } + + Ok(json!({ + "ok": true, + "checks": { + "docker": true, + "compose": true, + "project_available": true, + "ports_available": true, + "work_dir_safe": true + }, + "warnings": warnings + })) +} + +async fn docker_compose_deploy(params: Value) -> Result { + let project = string_param(¶ms, "project")?; + validate_compose_project(&project)?; + let work_dir = path_param(¶ms, "work_dir")?; + if !work_dir.is_absolute() { + bail!("工作目录必须是绝对路径"); + } + let content = string_param(¶ms, "content")?; + if content.is_empty() || content.len() > 256 * 1024 { + bail!("Compose 内容为空或超过 256KB"); + } + fs::create_dir_all(&work_dir)?; + let compose_path = work_dir.join("compose.yaml"); + let backup_path = work_dir.join(format!( + ".compose.yaml.lightops-bak-{}", + chrono_like_timestamp() + )); + let had_old = compose_path.exists(); + if had_old { + fs::copy(&compose_path, &backup_path)?; + } + fs::write(&compose_path, content)?; + let result = run_command( + Command::new("docker") + .arg("compose") + .args(["-p", &project]) + .arg("-f") + .arg(&compose_path) + .args(["up", "-d"]), + 180, + ) + .await; + match result { + Ok(output) if output.status.success() => Ok(json!({ + "ok": true, + "stdout": stdout_text(&output), + "stderr": stderr_text(&output), + "work_dir": work_dir, + "compose_file": compose_path + })), + Ok(output) => { + if had_old { + let _ = fs::copy(&backup_path, &compose_path); + } else { + let _ = fs::remove_file(&compose_path); + } + bail!("{}", stderr_text(&output)) + } + Err(err) => { + if had_old { + let _ = fs::copy(&backup_path, &compose_path); + } else { + let _ = fs::remove_file(&compose_path); + } + Err(err) + } + } +} + +fn validate_absolute_path(path: &Path) -> Result<()> { + if path.is_absolute() + && path.to_string_lossy().len() <= 300 + && !path.to_string_lossy().contains('\0') + { + Ok(()) + } else { + bail!("路径必须是安全的绝对路径") + } +} + +fn dir_has_entries(path: &Path) -> Result { + if !path.exists() { + return Ok(false); + } + if !path.is_dir() { + bail!("路径不是目录:{}", path.display()); + } + Ok(fs::read_dir(path)?.next().is_some()) +} + +async fn port_in_use(port: u16) -> Result { + let output = run_command(Command::new("ss").args(["-H", "-tuln"]), 8).await; + let text = match output { + Ok(output) if output.status.success() => stdout_text(&output), + _ => { + let output = run_command(Command::new("netstat").args(["-tuln"]), 8).await?; + if !output.status.success() { + return Ok(false); + } + stdout_text(&output) + } + }; + Ok(text.lines().any(|line| line_has_port(line, port))) +} + +fn line_has_port(line: &str, port: u16) -> bool { + let suffix = format!(":{port}"); + line.split_whitespace() + .any(|part| part.ends_with(&suffix) || part.contains(&format!("{suffix} "))) +} + +async fn docker_compose_update(params: Value) -> Result { + let (project, compose_path) = compose_file_params(¶ms)?; + let pull = run_command( + Command::new("docker") + .arg("compose") + .args(["-p", &project]) + .arg("-f") + .arg(&compose_path) + .arg("pull"), + 300, + ) + .await?; + if !pull.status.success() { + bail!("{}", stderr_text(&pull)); + } + let up = run_command( + Command::new("docker") + .arg("compose") + .args(["-p", &project]) + .arg("-f") + .arg(&compose_path) + .args(["up", "-d"]), + 180, + ) + .await?; + command_value_with_extra( + up, + json!({ + "pull_stdout": stdout_text(&pull), + "pull_stderr": stderr_text(&pull), + "compose_file": compose_path + }), + ) +} + +async fn docker_compose_down(params: Value) -> Result { + let (project, compose_path) = compose_file_params(¶ms)?; + command_value( + run_command( + Command::new("docker") + .arg("compose") + .args(["-p", &project]) + .arg("-f") + .arg(&compose_path) + .arg("down"), + 120, + ) + .await?, + ) +} + +fn compose_file_params(params: &Value) -> Result<(String, PathBuf)> { + let project = string_param(params, "project")?; + validate_compose_project(&project)?; + let work_dir = path_param(params, "work_dir")?; + if !work_dir.is_absolute() { + bail!("工作目录必须是绝对路径"); + } + let compose_path = work_dir.join("compose.yaml"); + if !compose_path.exists() { + bail!("Compose 文件不存在"); + } + Ok((project, compose_path)) +} + +async fn compose_container_ids(project: &str) -> Result> { + let label = format!("label=com.docker.compose.project={project}"); + let output = run_command( + Command::new("docker").args(["ps", "-aq", "--filter", &label]), + 12, + ) + .await?; + output_text(output).map(|text| { + text.lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .map(ToString::to_string) + .collect() + }) +} + +fn label_value(labels: &str, key: &str) -> Option { + labels.split(',').find_map(|item| { + item.trim() + .strip_prefix(&format!("{key}=")) + .map(ToString::to_string) + }) +} + +fn push_unique_json_string(obj: &mut serde_json::Map, key: &str, value: &str) { + if value.is_empty() { + return; + } + let values = obj.entry(key.into()).or_insert_with(|| json!([])); + if let Some(array) = values.as_array_mut() { + if !array.iter().any(|item| item.as_str() == Some(value)) { + array.push(json!(value)); + } + } +} + +fn validate_docker_id(id: &str) -> Result<()> { + if id.len() > 200 + || !id + .chars() + .all(|c| c.is_ascii_alphanumeric() || ".:/@_-".contains(c)) + { + bail!("Docker 标识无效"); + } + Ok(()) +} + +fn validate_docker_image(image: &str) -> Result<()> { + if image.is_empty() + || image.len() > 255 + || image.contains('\0') + || image.starts_with('-') + || image.contains("://") + || !image + .chars() + .all(|c| c.is_ascii_alphanumeric() || ".:/@_-".contains(c)) + { + bail!("Docker 镜像名称无效"); + } + Ok(()) +} + +fn validate_container_name(name: &str) -> Result<()> { + if name.len() > 128 + || !name + .chars() + .all(|c| c.is_ascii_alphanumeric() || "._-".contains(c)) + { + bail!("容器名称无效"); + } + Ok(()) +} + +fn validate_restart_policy(policy: &str) -> Result<()> { + if matches!(policy, "no" | "always" | "unless-stopped" | "on-failure") { + Ok(()) + } else { + bail!("重启策略无效") + } +} + +fn validate_port(port: u64) -> Result<()> { + if (1..=65535).contains(&port) { + Ok(()) + } else { + bail!("端口无效") + } +} + +fn validate_volume_path(path: &str) -> Result<()> { + if path.is_empty() + || path.len() > 300 + || path.contains('\0') + || path.contains('\n') + || path.contains('\r') + { + bail!("卷路径无效"); + } + Ok(()) +} + +fn validate_docker_volume_name(name: &str) -> Result<()> { + if name.is_empty() + || name.len() > 255 + || name.starts_with('-') + || !name + .chars() + .all(|c| c.is_ascii_alphanumeric() || "._-".contains(c)) + { + bail!("Docker volume 名称无效"); + } + Ok(()) +} + +fn validate_env_key(key: &str) -> Result<()> { + if key.is_empty() + || key.len() > 80 + || !key + .chars() + .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_') + { + bail!("环境变量名无效,建议使用大写字母、数字和下划线"); + } + Ok(()) +} + +fn validate_command_args(command: &str) -> Result<()> { + if command.len() > 500 + || command.contains('\0') + || command.contains("&&") + || command.contains("||") + || command.contains(';') + || command.contains('`') + || command.contains("$(") + { + bail!("容器命令不安全"); + } + Ok(()) +} + +fn validate_compose_project(project: &str) -> Result<()> { + if project.is_empty() + || project.len() > 128 + || !project + .chars() + .all(|c| c.is_ascii_alphanumeric() || "._-".contains(c)) + { + bail!("Compose 项目名称无效"); + } + Ok(()) +} + +async fn run_command(cmd: &mut Command, timeout_secs: u64) -> Result { + timeout(Duration::from_secs(timeout_secs), cmd.output()) + .await + .map_err(|_| anyhow!("命令执行超时"))? + .map_err(Into::into) +} + +fn command_value(output: std::process::Output) -> Result { + let success = output.status.success(); + let stdout = stdout_text(&output); + let stderr = stderr_text(&output); + if !success { + bail!("{}", if stderr.is_empty() { stdout } else { stderr }); + } + Ok(json!({ "ok": true, "stdout": stdout, "stderr": stderr })) +} + +fn command_value_with_extra(output: std::process::Output, extra: Value) -> Result { + let mut value = command_value(output)?; + if let (Some(value), Some(extra)) = (value.as_object_mut(), extra.as_object()) { + for (key, item) in extra { + value.insert(key.clone(), item.clone()); + } + } + Ok(value) +} + +fn output_text(output: std::process::Output) -> Result { + if output.status.success() { + Ok(String::from_utf8_lossy(&output.stdout).to_string()) + } else { + bail!("{}", String::from_utf8_lossy(&output.stderr)); + } +} + +fn stdout_text(output: &std::process::Output) -> String { + String::from_utf8_lossy(&output.stdout).to_string() +} + +fn stderr_text(output: &std::process::Output) -> String { + String::from_utf8_lossy(&output.stderr).to_string() +} diff --git a/crates/lightops-agent/src/app.rs b/crates/lightops-agent/src/app.rs new file mode 100644 index 0000000..77b29d3 --- /dev/null +++ b/crates/lightops-agent/src/app.rs @@ -0,0 +1,1788 @@ +use anyhow::{anyhow, bail, Context, Result}; +use async_trait::async_trait; +use chrono::Utc; +use lightops_common::protocol::{ + Application, ApplicationDetail, ApplicationProviderType, ApplicationRelation, + ApplicationStatus, ApplicationType, +}; +use serde_json::{json, Value}; +use std::{ + collections::HashSet, + fs, + path::{Path, PathBuf}, + time::Instant, +}; +use tokio::{net::TcpStream, process::Command, time::Duration}; +use uuid::Uuid; + +const NGINX_AVAILABLE: &str = "/etc/nginx/sites-available"; +const NGINX_ENABLED: &str = "/etc/nginx/sites-enabled"; +const NGINX_CONF_D: &str = "/etc/nginx/conf.d"; + +#[async_trait] +#[allow(dead_code)] +pub trait ApplicationProvider { + async fn detect(&self) -> Result; + async fn list_apps(&self) -> Result>; + async fn get_app(&self, id: &str) -> Result; + async fn start(&self, id: &str) -> Result<()>; + async fn stop(&self, id: &str) -> Result<()>; + async fn restart(&self, id: &str) -> Result<()>; + async fn reload(&self, id: &str) -> Result<()>; + async fn logs(&self, id: &str, lines: usize) -> Result>; + async fn update(&self, id: &str) -> Result<()>; + async fn uninstall(&self, id: &str) -> Result<()>; +} + +pub async fn handle(action: &str, params: Value) -> Result { + match action { + "app.discover" | "app.list" => discover().await, + "app.get" => app_detail(params).await, + "app.start" => provider_action(params, ProviderVerb::Start).await, + "app.stop" => provider_action(params, ProviderVerb::Stop).await, + "app.restart" => provider_action(params, ProviderVerb::Restart).await, + "app.reload" => provider_action(params, ProviderVerb::Reload).await, + "app.logs" => app_logs(params).await, + "app.health" => app_health(params).await, + "app.update" => bail!("暂不支持操作:应用更新"), + "app.uninstall" => bail!("暂不支持操作:应用卸载需要先实现备份能力"), + "app.backup" => app_backup(params).await, + "app.restore" => bail!("暂不支持操作:应用恢复"), + "app.manage_custom" => manage_custom(params, false).await, + "app.create_systemd_service" => manage_custom(params, true).await, + "app.unmanage" => Ok(json!({ "ok": true })), + _ => bail!("不支持的操作"), + } +} + +async fn discover() -> Result { + let agent_id = agent_id(); + let mut apps = Vec::new(); + let mut relations = Vec::new(); + + let providers: Vec> = vec![ + Box::new(SystemdAppProvider::new(agent_id.clone())), + Box::new(DockerComposeAppProvider::new(agent_id.clone())), + Box::new(DockerAppProvider::new(agent_id.clone())), + Box::new(NginxSiteAppProvider::new(agent_id.clone())), + Box::new(PackageAppProvider::new(agent_id.clone())), + Box::new(LightOpsManagedAppProvider::new(agent_id.clone())), + ]; + + for provider in providers { + if provider.detect().await.unwrap_or(false) { + match provider.list_apps().await { + Ok(found) => merge_apps(&mut apps, found), + Err(err) => tracing::debug!(?err, "应用 Provider 执行失败"), + } + } + } + + enrich_with_ports(&mut apps).await; + build_relations(&apps, &mut relations); + + Ok(json!({ "applications": apps, "relations": relations })) +} + +fn merge_apps(apps: &mut Vec, found: Vec) { + for mut app in found { + if let Some(existing) = apps.iter_mut().find(|existing| same_app(existing, &app)) { + if existing.version.is_none() { + existing.version = app.version.take(); + } + if existing.install_path.is_none() { + existing.install_path = app.install_path.take(); + } + if existing.work_dir.is_none() { + existing.work_dir = app.work_dir.take(); + } + merge_vec(&mut existing.config_paths, app.config_paths); + merge_vec(&mut existing.log_paths, app.log_paths); + merge_vec(&mut existing.data_paths, app.data_paths); + merge_vec(&mut existing.ports, app.ports); + merge_vec(&mut existing.domains, app.domains); + existing.package_name = existing.package_name.take().or(app.package_name.take()); + existing.service_name = existing.service_name.take().or(app.service_name.take()); + existing.container_id = existing.container_id.take().or(app.container_id.take()); + existing.nginx_site = existing.nginx_site.take().or(app.nginx_site.take()); + existing.metadata = merge_metadata(existing.metadata.clone(), app.metadata); + } else { + apps.push(app); + } + } +} + +fn same_app(left: &Application, right: &Application) -> bool { + left.id == right.id + || left.name == right.name + || (left.service_name.is_some() && left.service_name == right.service_name) + || (left.container_id.is_some() && left.container_id == right.container_id) + || (left.nginx_site.is_some() && left.nginx_site == right.nginx_site) +} + +fn merge_vec(target: &mut Vec, incoming: Vec) { + let mut seen = target.iter().cloned().collect::>(); + for item in incoming { + if seen.insert(item.clone()) { + target.push(item); + } + } +} + +fn merge_metadata(left: Value, right: Value) -> Value { + let mut merged = left.as_object().cloned().unwrap_or_default(); + if let Some(map) = right.as_object() { + for (key, value) in map { + merged.insert(key.clone(), value.clone()); + } + } + Value::Object(merged) +} + +async fn app_detail(params: Value) -> Result { + let app = app_param(¶ms)?; + let detail = ApplicationDetail { + application: app, + relations: Vec::new(), + recent_actions: Vec::new(), + runtime_info: json!({}), + available_actions: vec![ + "start".into(), + "stop".into(), + "restart".into(), + "logs".into(), + "health".into(), + "backup".into(), + ], + risk_level: "normal".into(), + provider_specific_info: json!({}), + }; + Ok(json!(detail)) +} + +enum ProviderVerb { + Start, + Stop, + Restart, + Reload, +} + +async fn provider_action(params: Value, verb: ProviderVerb) -> Result { + let app = app_param(¶ms)?; + if app.is_system { + bail!("系统应用默认只读"); + } + match app.provider { + ApplicationProviderType::Systemd | ApplicationProviderType::LightOpsManaged => { + let service = app + .service_name + .ok_or_else(|| anyhow!("缺少 service_name"))?; + let provider = SystemdAppProvider::new(app.agent_id); + match verb { + ProviderVerb::Start => provider.start(&service).await?, + ProviderVerb::Stop => provider.stop(&service).await?, + ProviderVerb::Restart => provider.restart(&service).await?, + ProviderVerb::Reload => provider.reload(&service).await?, + } + } + ApplicationProviderType::Docker => { + let container = app + .container_id + .ok_or_else(|| anyhow!("缺少 container_id"))?; + let provider = DockerAppProvider::new(app.agent_id); + match verb { + ProviderVerb::Start => provider.start(&container).await?, + ProviderVerb::Stop => provider.stop(&container).await?, + ProviderVerb::Restart => provider.restart(&container).await?, + ProviderVerb::Reload => bail!("Docker 容器不支持重载操作"), + } + } + ApplicationProviderType::DockerCompose => { + let project = app + .compose_project + .ok_or_else(|| anyhow!("缺少 compose_project"))?; + let provider = DockerComposeAppProvider::new(app.agent_id); + match verb { + ProviderVerb::Start => provider.start(&project).await?, + ProviderVerb::Stop => provider.stop(&project).await?, + ProviderVerb::Restart => provider.restart(&project).await?, + ProviderVerb::Reload => bail!("Docker Compose 项目不支持重载操作"), + } + } + ApplicationProviderType::NginxSite => { + if matches!(verb, ProviderVerb::Reload) { + command_checked(Command::new("systemctl").args(["reload", "nginx"])).await?; + } else { + bail!("Nginx 站点不支持此操作"); + } + } + _ => bail!("此应用来源不支持该操作"), + } + Ok(json!({ "ok": true })) +} + +async fn app_logs(params: Value) -> Result { + let app = app_param(¶ms)?; + let lines = params + .get("params") + .and_then(|p| p.get("lines")) + .and_then(Value::as_u64) + .unwrap_or(200) + .min(2000) as usize; + let logs = match app.provider { + ApplicationProviderType::Docker => { + let id = app + .container_id + .ok_or_else(|| anyhow!("缺少 container_id"))?; + DockerAppProvider::new(app.agent_id) + .logs(&id, lines) + .await? + } + ApplicationProviderType::DockerCompose => { + let project = app + .compose_project + .ok_or_else(|| anyhow!("缺少 compose_project"))?; + DockerComposeAppProvider::new(app.agent_id) + .logs(&project, lines) + .await? + } + ApplicationProviderType::Systemd | ApplicationProviderType::LightOpsManaged => { + let service = app + .service_name + .ok_or_else(|| anyhow!("缺少 service_name"))?; + SystemdAppProvider::new(app.agent_id) + .logs(&service, lines) + .await? + } + ApplicationProviderType::NginxSite => { + let mut out = Vec::new(); + for path in app.log_paths.iter().take(4) { + out.extend(tail_file(path, lines / 2).await.unwrap_or_default()); + } + out + } + _ => { + let mut out = Vec::new(); + for path in app.log_paths.iter().take(4) { + out.extend(tail_file(path, lines / 2).await.unwrap_or_default()); + } + out + } + }; + Ok(json!({ "lines": logs, "content": logs.join("\n") })) +} + +async fn app_health(params: Value) -> Result { + let app = app_param(¶ms)?; + let options = params.get("params").cloned().unwrap_or_else(|| json!({})); + let timeout_secs = options + .get("timeout_secs") + .and_then(Value::as_u64) + .unwrap_or(5) + .clamp(1, 30); + let timeout_duration = Duration::from_secs(timeout_secs); + let kind = options + .get("kind") + .and_then(Value::as_str) + .unwrap_or_default() + .to_lowercase(); + + if kind == "http" + || options.get("url").and_then(Value::as_str).is_some() + || (!kind.eq("tcp") && !app.domains.is_empty()) + { + return http_health(&app, &options, timeout_secs).await; + } + + tcp_health(&app, &options, timeout_duration).await +} + +async fn app_backup(params: Value) -> Result { + let app = app_param(¶ms)?; + if app.is_system { + bail!("系统应用默认不允许备份"); + } + let paths = backup_paths(&app); + if paths.is_empty() { + bail!("当前应用没有可备份路径"); + } + let backup_dir = Path::new("/var/backups/lightops/apps"); + fs::create_dir_all(backup_dir).context("创建应用备份目录失败")?; + let timestamp = Utc::now().format("%Y%m%d%H%M%S").to_string(); + let archive_name = format!("{}-{timestamp}.tar.gz", safe_app_name(&app.name)?); + let archive_path = backup_dir.join(archive_name); + + let mut cmd = Command::new("tar"); + cmd.arg("-czf").arg(&archive_path).arg("-C").arg("/"); + for path in &paths { + let relative = path + .strip_prefix("/") + .ok_or_else(|| anyhow!("备份路径必须是绝对路径"))?; + cmd.arg(relative); + } + command_checked(&mut cmd).await?; + let size = fs::metadata(&archive_path) + .map(|meta| meta.len()) + .unwrap_or(0); + Ok(json!({ + "ok": true, + "archive_path": archive_path.to_string_lossy(), + "size": size, + "paths": paths, + })) +} + +fn backup_paths(app: &Application) -> Vec { + let mut out = Vec::new(); + let mut seen = HashSet::new(); + for value in app + .install_path + .iter() + .chain(app.work_dir.iter()) + .chain(app.config_paths.iter()) + .chain(app.data_paths.iter()) + { + let path = Path::new(value); + if !path.is_absolute() || !path.exists() || value == "/" || value.starts_with("/proc/") { + continue; + } + if seen.insert(value.clone()) { + out.push(value.clone()); + } + } + out.truncate(64); + out +} + +async fn http_health(app: &Application, options: &Value, timeout_secs: u64) -> Result { + let target = options + .get("url") + .and_then(Value::as_str) + .map(ToString::to_string) + .or_else(|| app.domains.first().map(|domain| format!("http://{domain}"))) + .or_else(|| { + app.ports + .first() + .map(|port| format!("http://127.0.0.1:{port}")) + }) + .ok_or_else(|| anyhow!("缺少 HTTP 健康检查目标"))?; + validate_url_target(&target)?; + + let mut cmd = Command::new("curl"); + cmd.arg("-I") + .arg("-L") + .arg("-sS") + .arg("-o") + .arg("/dev/null") + .arg("-w") + .arg("%{http_code}") + .arg("--max-time") + .arg(timeout_secs.to_string()) + .arg(&target); + + let start = Instant::now(); + let output = + tokio::time::timeout(Duration::from_secs(timeout_secs + 2), cmd.output()).await??; + let latency_ms = start.elapsed().as_millis() as u64; + let status_text = String::from_utf8_lossy(&output.stdout).trim().to_string(); + let status_code = status_text.parse::().ok(); + let ok = output.status.success() && status_code.is_some_and(|code| (200..500).contains(&code)); + let error = if ok { + None + } else { + Some(String::from_utf8_lossy(&output.stderr).trim().to_string()) + .filter(|text| !text.is_empty()) + .or_else(|| Some("HTTP 健康检查失败".to_string())) + }; + + Ok(json!({ + "ok": ok, + "kind": "http", + "target": target, + "latency_ms": latency_ms, + "status_code": status_code, + "error": error, + })) +} + +async fn tcp_health( + app: &Application, + options: &Value, + timeout_duration: Duration, +) -> Result { + let host = options + .get("host") + .and_then(Value::as_str) + .unwrap_or("127.0.0.1") + .to_string(); + validate_host_target(&host)?; + let port = options + .get("port") + .and_then(Value::as_u64) + .and_then(|port| u16::try_from(port).ok()) + .or_else(|| app.ports.first().copied()) + .ok_or_else(|| anyhow!("缺少 TCP 健康检查端口"))?; + + let target = format!("{host}:{port}"); + let start = Instant::now(); + let result = + tokio::time::timeout(timeout_duration, TcpStream::connect((&host[..], port))).await; + let latency_ms = start.elapsed().as_millis() as u64; + let (ok, error) = match result { + Ok(Ok(_stream)) => (true, None), + Ok(Err(err)) => (false, Some(err.to_string())), + Err(_) => (false, Some("TCP 健康检查超时".to_string())), + }; + + Ok(json!({ + "ok": ok, + "kind": "tcp", + "target": target, + "latency_ms": latency_ms, + "status_code": null, + "error": error, + })) +} + +async fn manage_custom(params: Value, create_service: bool) -> Result { + let name = string_param(¶ms, "name")?; + let safe_name = safe_app_name(&name)?; + let work_dir = string_param(¶ms, "work_dir")?; + let run_user = params + .get("run_user") + .and_then(Value::as_str) + .unwrap_or("root") + .to_string(); + if !Path::new(&work_dir).is_dir() { + bail!("工作目录不存在"); + } + ensure_user_exists(&run_user).await?; + let ports = params + .get("ports") + .and_then(Value::as_array) + .map(|items| { + items + .iter() + .filter_map(|v| v.as_u64().map(|n| n as u16)) + .collect::>() + }) + .unwrap_or_default(); + let log_paths = params + .get("log_paths") + .and_then(Value::as_array) + .map(|items| { + items + .iter() + .filter_map(|v| v.as_str().map(ToString::to_string)) + .collect::>() + }) + .unwrap_or_default(); + + let mut service_name = None; + let mut generated_service_path = None; + if create_service + || params + .get("create_systemd") + .and_then(Value::as_bool) + .unwrap_or(false) + { + let start_command = string_param(¶ms, "start_command")?; + validate_start_command(&start_command)?; + let unit = format!("lightops-{safe_name}.service"); + let path = Path::new("/etc/systemd/system").join(&unit); + if path.exists() { + bail!("服务已存在"); + } + let env = params + .get("env") + .and_then(Value::as_object) + .cloned() + .unwrap_or_default(); + let env_lines = env + .iter() + .map(|(key, value)| { + Ok(format!( + "Environment=\"{}={}\"", + sanitize_env_key(key)?, + value.as_str().unwrap_or_default().replace('"', "\\\"") + )) + }) + .collect::>>()? + .join("\n"); + let content = format!( + "[Unit]\nDescription=LightOps App {name}\nAfter=network.target\n\n[Service]\nType=simple\nWorkingDirectory={work_dir}\nExecStart={start_command}\nRestart=always\nUser={run_user}\n{env_lines}\n\n[Install]\nWantedBy=multi-user.target\n" + ); + fs::write(&path, content)?; + let result = async { + command_checked(Command::new("systemctl").arg("daemon-reload")).await?; + if params + .get("enable") + .and_then(Value::as_bool) + .unwrap_or(true) + { + command_checked(Command::new("systemctl").args(["enable", &unit])).await?; + } + if params.get("start").and_then(Value::as_bool).unwrap_or(true) { + command_checked(Command::new("systemctl").args(["start", &unit])).await?; + } + Ok::<_, anyhow::Error>(()) + } + .await; + if let Err(err) = result { + let _ = fs::remove_file(&path); + let _ = command_checked(Command::new("systemctl").arg("daemon-reload")).await; + return Err(err); + } + service_name = Some(unit); + generated_service_path = Some(path.to_string_lossy().to_string()); + } + + let app = Application { + id: format!("lightops:{}", safe_name), + agent_id: agent_id(), + name: safe_name.clone(), + display_name: name, + description: params + .get("description") + .and_then(Value::as_str) + .map(ToString::to_string), + app_type: ApplicationType::Custom, + provider: ApplicationProviderType::LightOpsManaged, + status: if params.get("start").and_then(Value::as_bool).unwrap_or(true) { + ApplicationStatus::Running + } else { + ApplicationStatus::Stopped + }, + version: None, + install_path: Some(work_dir.clone()), + work_dir: Some(work_dir), + config_paths: generated_service_path.iter().cloned().collect(), + log_paths, + data_paths: Vec::new(), + ports, + domains: Vec::new(), + service_name, + container_id: None, + compose_project: None, + package_name: None, + nginx_site: None, + run_user: Some(run_user), + is_system: false, + is_managed: true, + is_lightops_managed: true, + metadata: json!({ "source": "lightops", "generated_service": generated_service_path }), + created_at: None, + updated_at: None, + }; + Ok(json!({ "application": app })) +} + +fn app_param(params: &Value) -> Result { + serde_json::from_value( + params + .get("application") + .cloned() + .ok_or_else(|| anyhow!("缺少应用数据"))?, + ) + .context("解析应用数据失败") +} + +struct SystemdAppProvider { + agent_id: String, +} + +impl SystemdAppProvider { + fn new(agent_id: String) -> Self { + Self { agent_id } + } +} + +#[async_trait] +impl ApplicationProvider for SystemdAppProvider { + async fn detect(&self) -> Result { + Ok(Path::new("/bin/systemctl").exists() || Path::new("/usr/bin/systemctl").exists()) + } + + async fn list_apps(&self) -> Result> { + let output = command_output(Command::new("systemctl").args([ + "list-units", + "--type=service", + "--all", + "--no-pager", + "--plain", + ])) + .await?; + let mut apps = Vec::new(); + for line in output.lines() { + if let Some(app) = parse_systemd_line(&self.agent_id, line) { + apps.push(app); + } + } + Ok(apps) + } + + async fn get_app(&self, id: &str) -> Result { + let app = self + .list_apps() + .await? + .into_iter() + .find(|app| app.id == id || app.service_name.as_deref() == Some(id)) + .ok_or_else(|| anyhow!("应用不存在"))?; + Ok(detail(app)) + } + + async fn start(&self, id: &str) -> Result<()> { + validate_unit(id)?; + command_checked(Command::new("systemctl").args(["start", id])).await + } + + async fn stop(&self, id: &str) -> Result<()> { + validate_unit(id)?; + command_checked(Command::new("systemctl").args(["stop", id])).await + } + + async fn restart(&self, id: &str) -> Result<()> { + validate_unit(id)?; + command_checked(Command::new("systemctl").args(["restart", id])).await + } + + async fn reload(&self, id: &str) -> Result<()> { + validate_unit(id)?; + command_checked(Command::new("systemctl").args(["reload", id])).await + } + + async fn logs(&self, id: &str, lines: usize) -> Result> { + validate_unit(id)?; + let line_count = lines.min(2000).to_string(); + let output = command_output( + Command::new("journalctl") + .arg("-u") + .arg(id) + .arg("-n") + .arg(line_count) + .arg("--no-pager"), + ) + .await?; + Ok(output.lines().map(ToString::to_string).collect()) + } + + async fn update(&self, _id: &str) -> Result<()> { + bail!("不支持的操作") + } + + async fn uninstall(&self, _id: &str) -> Result<()> { + bail!("不支持的操作") + } +} + +fn parse_systemd_line(agent_id: &str, line: &str) -> Option { + let parts = line.split_whitespace().collect::>(); + if parts.len() < 4 || !parts[0].ends_with(".service") { + return None; + } + let service = parts[0].to_string(); + let base = service.trim_end_matches(".service").to_string(); + let description = parts.get(4..).unwrap_or(&[]).join(" "); + if !is_manageable_service(&base, &service, &description) { + return None; + } + let is_system = is_core_system_name(&base); + Some(Application { + id: format!("systemd:{service}"), + agent_id: agent_id.to_string(), + name: base.clone(), + display_name: human_name(&base), + description: (!description.is_empty()).then_some(description), + app_type: infer_app_type(&base), + provider: ApplicationProviderType::Systemd, + status: match parts.get(2).copied().unwrap_or_default() { + "active" => ApplicationStatus::Running, + "failed" => ApplicationStatus::Failed, + _ => ApplicationStatus::Stopped, + }, + version: None, + install_path: None, + work_dir: None, + config_paths: common_config_paths(&base), + log_paths: Vec::new(), + data_paths: common_data_paths(&base), + ports: common_ports(&base), + domains: Vec::new(), + service_name: Some(service), + container_id: None, + compose_project: None, + package_name: package_for_service(&base), + nginx_site: None, + run_user: None, + is_system, + is_managed: true, + is_lightops_managed: base.starts_with("lightops-"), + metadata: json!({ "source": "systemd", "load": parts.get(1).copied().unwrap_or_default(), "sub": parts.get(3).copied().unwrap_or_default() }), + created_at: None, + updated_at: None, + }) +} + +struct DockerAppProvider { + agent_id: String, +} + +impl DockerAppProvider { + fn new(agent_id: String) -> Self { + Self { agent_id } + } +} + +#[async_trait] +impl ApplicationProvider for DockerAppProvider { + async fn detect(&self) -> Result { + Ok(command_status(Command::new("docker").arg("version")) + .await + .unwrap_or(false)) + } + + async fn list_apps(&self) -> Result> { + let output = + command_output(Command::new("docker").args(["ps", "-a", "--format", "{{json .}}"])) + .await?; + Ok(output + .lines() + .filter_map(|line| parse_docker_app(&self.agent_id, line)) + .collect()) + } + + async fn get_app(&self, id: &str) -> Result { + let app = self + .list_apps() + .await? + .into_iter() + .find(|app| app.id == id || app.container_id.as_deref() == Some(id)) + .ok_or_else(|| anyhow!("应用不存在"))?; + Ok(detail(app)) + } + + async fn start(&self, id: &str) -> Result<()> { + validate_docker_id(id)?; + command_checked(Command::new("docker").args(["start", id])).await + } + + async fn stop(&self, id: &str) -> Result<()> { + validate_docker_id(id)?; + command_checked(Command::new("docker").args(["stop", id])).await + } + + async fn restart(&self, id: &str) -> Result<()> { + validate_docker_id(id)?; + command_checked(Command::new("docker").args(["restart", id])).await + } + + async fn reload(&self, _id: &str) -> Result<()> { + bail!("不支持的操作") + } + + async fn logs(&self, id: &str, lines: usize) -> Result> { + validate_docker_id(id)?; + let line_count = lines.min(2000).to_string(); + let output = command_output( + Command::new("docker") + .arg("logs") + .arg("--tail") + .arg(line_count) + .arg(id), + ) + .await?; + Ok(output.lines().map(ToString::to_string).collect()) + } + + async fn update(&self, _id: &str) -> Result<()> { + bail!("不支持的操作") + } + + async fn uninstall(&self, _id: &str) -> Result<()> { + bail!("不支持的操作") + } +} + +struct DockerComposeAppProvider { + agent_id: String, +} + +impl DockerComposeAppProvider { + fn new(agent_id: String) -> Self { + Self { agent_id } + } +} + +#[async_trait] +impl ApplicationProvider for DockerComposeAppProvider { + async fn detect(&self) -> Result { + Ok(command_status(Command::new("docker").arg("version")) + .await + .unwrap_or(false)) + } + + async fn list_apps(&self) -> Result> { + let output = + command_output(Command::new("docker").args(["ps", "-a", "--format", "{{json .}}"])) + .await?; + Ok(compose_project_apps(&self.agent_id, &output)) + } + + async fn get_app(&self, id: &str) -> Result { + let app = self + .list_apps() + .await? + .into_iter() + .find(|app| app.id == id || app.compose_project.as_deref() == Some(id)) + .ok_or_else(|| anyhow!("应用不存在"))?; + Ok(detail(app)) + } + + async fn start(&self, id: &str) -> Result<()> { + docker_compose_container_action(id, "start").await + } + + async fn stop(&self, id: &str) -> Result<()> { + docker_compose_container_action(id, "stop").await + } + + async fn restart(&self, id: &str) -> Result<()> { + docker_compose_container_action(id, "restart").await + } + + async fn reload(&self, _id: &str) -> Result<()> { + bail!("不支持的操作") + } + + async fn logs(&self, id: &str, lines: usize) -> Result> { + validate_compose_project(id)?; + let ids = compose_container_ids(id).await?; + let mut out = Vec::new(); + let line_count = lines.min(2000).to_string(); + for container_id in ids { + out.push(format!("===== {container_id} =====")); + let output = command_output(Command::new("docker").args([ + "logs", + "--tail", + &line_count, + &container_id, + ])) + .await + .unwrap_or_else(|err| err.to_string()); + out.extend(output.lines().map(ToString::to_string)); + } + Ok(out) + } + + async fn update(&self, _id: &str) -> Result<()> { + bail!("不支持的操作") + } + + async fn uninstall(&self, _id: &str) -> Result<()> { + bail!("不支持的操作") + } +} + +fn compose_project_apps(agent_id: &str, output: &str) -> Vec { + let mut projects = std::collections::BTreeMap::::new(); + for line in output.lines() { + let Ok(value) = serde_json::from_str::(line) else { + continue; + }; + let labels = value + .get("Labels") + .and_then(Value::as_str) + .unwrap_or_default(); + let Some(project) = docker_label_value(labels, "com.docker.compose.project") else { + continue; + }; + let service = docker_label_value(labels, "com.docker.compose.service").unwrap_or_default(); + let status = value + .get("Status") + .and_then(Value::as_str) + .unwrap_or_default(); + let ports = parse_port_numbers( + value + .get("Ports") + .and_then(Value::as_str) + .unwrap_or_default(), + ); + let entry = projects.entry(project.clone()).or_default(); + entry.running |= status.starts_with("Up"); + if !service.is_empty() && !entry.services.contains(&service) { + entry.services.push(service); + } + if let Some(id) = value.get("ID").and_then(Value::as_str) { + entry.container_ids.push(id.to_string()); + } + for port in ports { + if !entry.ports.contains(&port) { + entry.ports.push(port); + } + } + } + projects + .into_iter() + .map(|(project, acc)| Application { + id: format!("compose:{project}"), + agent_id: agent_id.to_string(), + name: project.clone(), + display_name: project.clone(), + description: Some(format!( + "Docker Compose 项目,服务:{}", + if acc.services.is_empty() { + "-".into() + } else { + acc.services.join(", ") + } + )), + app_type: ApplicationType::ComposeProject, + provider: ApplicationProviderType::DockerCompose, + status: if acc.running { + ApplicationStatus::Running + } else { + ApplicationStatus::Stopped + }, + version: None, + install_path: None, + work_dir: None, + config_paths: Vec::new(), + log_paths: Vec::new(), + data_paths: Vec::new(), + ports: acc.ports, + domains: Vec::new(), + service_name: None, + container_id: None, + compose_project: Some(project.clone()), + package_name: None, + nginx_site: None, + run_user: None, + is_system: false, + is_managed: true, + is_lightops_managed: false, + metadata: json!({ "source": "docker-compose", "services": acc.services, "containers": acc.container_ids }), + created_at: None, + updated_at: None, + }) + .collect() +} + +#[derive(Default)] +struct ComposeAccumulator { + running: bool, + services: Vec, + container_ids: Vec, + ports: Vec, +} + +async fn docker_compose_container_action(project: &str, action: &str) -> Result<()> { + validate_compose_project(project)?; + let ids = compose_container_ids(project).await?; + if ids.is_empty() { + bail!("未找到 Compose 项目容器"); + } + let mut cmd = Command::new("docker"); + cmd.arg(action); + for id in ids { + cmd.arg(id); + } + command_checked(&mut cmd).await +} + +async fn compose_container_ids(project: &str) -> Result> { + let label = format!("label=com.docker.compose.project={project}"); + let output = + command_output(Command::new("docker").args(["ps", "-aq", "--filter", &label])).await?; + Ok(output + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .map(ToString::to_string) + .collect()) +} + +fn docker_label_value(labels: &str, key: &str) -> Option { + labels.split(',').find_map(|item| { + item.trim() + .strip_prefix(&format!("{key}=")) + .map(ToString::to_string) + }) +} + +fn parse_docker_app(agent_id: &str, line: &str) -> Option { + let value = serde_json::from_str::(line).ok()?; + let id = value.get("ID").and_then(Value::as_str)?.to_string(); + let name = value + .get("Names") + .and_then(Value::as_str) + .unwrap_or(&id) + .trim_start_matches('/') + .to_string(); + let status_text = value + .get("Status") + .and_then(Value::as_str) + .unwrap_or_default(); + let labels = value + .get("Labels") + .and_then(Value::as_str) + .unwrap_or_default(); + let compose_project = labels.split(',').find_map(|item| { + item.strip_prefix("com.docker.compose.project=") + .map(ToString::to_string) + }); + Some(Application { + id: format!("docker:{id}"), + agent_id: agent_id.to_string(), + name: name.clone(), + display_name: name, + description: value + .get("Image") + .and_then(Value::as_str) + .map(ToString::to_string), + app_type: ApplicationType::Container, + provider: ApplicationProviderType::Docker, + status: if status_text.starts_with("Up") { + ApplicationStatus::Running + } else { + ApplicationStatus::Stopped + }, + version: None, + install_path: None, + work_dir: None, + config_paths: Vec::new(), + log_paths: Vec::new(), + data_paths: Vec::new(), + ports: parse_port_numbers( + value + .get("Ports") + .and_then(Value::as_str) + .unwrap_or_default(), + ), + domains: Vec::new(), + service_name: None, + container_id: Some(id), + compose_project, + package_name: None, + nginx_site: None, + run_user: None, + is_system: false, + is_managed: true, + is_lightops_managed: false, + metadata: value, + created_at: None, + updated_at: None, + }) +} + +struct NginxSiteAppProvider { + agent_id: String, +} + +impl NginxSiteAppProvider { + fn new(agent_id: String) -> Self { + Self { agent_id } + } +} + +#[async_trait] +impl ApplicationProvider for NginxSiteAppProvider { + async fn detect(&self) -> Result { + Ok(Path::new(NGINX_AVAILABLE).exists() || Path::new(NGINX_CONF_D).exists()) + } + + async fn list_apps(&self) -> Result> { + let mut sites = Vec::new(); + for dir in [NGINX_AVAILABLE, NGINX_CONF_D] { + if let Ok(entries) = fs::read_dir(dir) { + for entry in entries.flatten() { + if entry.path().is_file() { + sites.push(site_app(&self.agent_id, entry.path())); + } + } + } + } + Ok(sites) + } + + async fn get_app(&self, id: &str) -> Result { + let app = self + .list_apps() + .await? + .into_iter() + .find(|app| app.id == id || app.nginx_site.as_deref() == Some(id)) + .ok_or_else(|| anyhow!("应用不存在"))?; + Ok(detail(app)) + } + + async fn start(&self, _id: &str) -> Result<()> { + bail!("不支持的操作") + } + + async fn stop(&self, _id: &str) -> Result<()> { + bail!("不支持的操作") + } + + async fn restart(&self, _id: &str) -> Result<()> { + bail!("不支持的操作") + } + + async fn reload(&self, _id: &str) -> Result<()> { + command_checked(Command::new("systemctl").args(["reload", "nginx"])).await + } + + async fn logs(&self, id: &str, lines: usize) -> Result> { + let app = self.get_app(id).await?.application; + let mut out = Vec::new(); + for path in app.log_paths { + out.extend(tail_file(&path, lines / 2).await.unwrap_or_default()); + } + Ok(out) + } + + async fn update(&self, _id: &str) -> Result<()> { + bail!("不支持的操作") + } + + async fn uninstall(&self, _id: &str) -> Result<()> { + bail!("不支持的操作") + } +} + +fn site_app(agent_id: &str, path: PathBuf) -> Application { + let name = path + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| "nginx-site".into()); + let content = fs::read_to_string(&path).unwrap_or_default(); + let domains = parse_nginx_domains(&content); + let ports = parse_nginx_listen_ports(&content); + let enabled = Path::new(NGINX_ENABLED).join(&name).exists() || path.starts_with(NGINX_CONF_D); + Application { + id: format!("nginx:{name}"), + agent_id: agent_id.to_string(), + name: name.clone(), + display_name: if domains.is_empty() { + name.clone() + } else { + domains.join(", ") + }, + description: Some("Nginx site".into()), + app_type: if content.contains("proxy_pass") { + ApplicationType::ReverseProxy + } else { + ApplicationType::StaticSite + }, + provider: ApplicationProviderType::NginxSite, + status: if enabled { + ApplicationStatus::Enabled + } else { + ApplicationStatus::Disabled + }, + version: None, + install_path: None, + work_dir: parse_nginx_root(&content), + config_paths: vec![path.to_string_lossy().to_string()], + log_paths: parse_nginx_logs(&content), + data_paths: Vec::new(), + ports, + domains, + service_name: Some("nginx.service".into()), + container_id: None, + compose_project: None, + package_name: Some("nginx".into()), + nginx_site: Some(name), + run_user: None, + is_system: false, + is_managed: true, + is_lightops_managed: false, + metadata: json!({ "enabled": enabled, "source": "nginx" }), + created_at: None, + updated_at: None, + } +} + +struct PackageAppProvider { + agent_id: String, +} + +impl PackageAppProvider { + fn new(agent_id: String) -> Self { + Self { agent_id } + } +} + +#[async_trait] +impl ApplicationProvider for PackageAppProvider { + async fn detect(&self) -> Result { + Ok(Path::new("/usr/bin/dpkg-query").exists()) + } + + async fn list_apps(&self) -> Result> { + let output = + command_output(Command::new("dpkg-query").args(["-W", "-f=${Package} ${Version}\n"])) + .await?; + let allowed = allowed_packages(); + Ok(output + .lines() + .filter_map(|line| { + let mut parts = line.split_whitespace(); + let package = parts.next()?.to_string(); + if !allowed.contains(package.as_str()) { + return None; + } + let version = parts.next().map(ToString::to_string); + Some(Application { + id: format!("apt:{package}"), + agent_id: self.agent_id.clone(), + name: package.clone(), + display_name: human_name(&package), + description: Some("APT package with operational value".into()), + app_type: infer_app_type(&package), + provider: ApplicationProviderType::Apt, + status: ApplicationStatus::Unknown, + version, + install_path: None, + work_dir: None, + config_paths: common_config_paths(&package), + log_paths: Vec::new(), + data_paths: common_data_paths(&package), + ports: common_ports(&package), + domains: Vec::new(), + service_name: service_for_package(&package), + container_id: None, + compose_project: None, + package_name: Some(package), + nginx_site: None, + run_user: None, + is_system: false, + is_managed: true, + is_lightops_managed: false, + metadata: json!({ "source": "apt" }), + created_at: None, + updated_at: None, + }) + }) + .collect()) + } + + async fn get_app(&self, id: &str) -> Result { + let app = self + .list_apps() + .await? + .into_iter() + .find(|app| app.id == id || app.package_name.as_deref() == Some(id)) + .ok_or_else(|| anyhow!("应用不存在"))?; + Ok(detail(app)) + } + + async fn start(&self, _id: &str) -> Result<()> { + bail!("不支持的操作") + } + + async fn stop(&self, _id: &str) -> Result<()> { + bail!("不支持的操作") + } + + async fn restart(&self, _id: &str) -> Result<()> { + bail!("不支持的操作") + } + + async fn reload(&self, _id: &str) -> Result<()> { + bail!("不支持的操作") + } + + async fn logs(&self, _id: &str, _lines: usize) -> Result> { + bail!("不支持的操作") + } + + async fn update(&self, _id: &str) -> Result<()> { + bail!("不支持的操作") + } + + async fn uninstall(&self, _id: &str) -> Result<()> { + bail!("不支持的操作") + } +} + +struct LightOpsManagedAppProvider { + agent_id: String, +} + +impl LightOpsManagedAppProvider { + fn new(agent_id: String) -> Self { + Self { agent_id } + } +} + +#[async_trait] +impl ApplicationProvider for LightOpsManagedAppProvider { + async fn detect(&self) -> Result { + Ok(true) + } + + async fn list_apps(&self) -> Result> { + SystemdAppProvider::new(self.agent_id.clone()) + .list_apps() + .await + .map(|apps| { + apps.into_iter() + .filter(|app| app.is_lightops_managed) + .collect() + }) + } + + async fn get_app(&self, id: &str) -> Result { + SystemdAppProvider::new(self.agent_id.clone()) + .get_app(id) + .await + } + + async fn start(&self, id: &str) -> Result<()> { + SystemdAppProvider::new(self.agent_id.clone()) + .start(id) + .await + } + + async fn stop(&self, id: &str) -> Result<()> { + SystemdAppProvider::new(self.agent_id.clone()) + .stop(id) + .await + } + + async fn restart(&self, id: &str) -> Result<()> { + SystemdAppProvider::new(self.agent_id.clone()) + .restart(id) + .await + } + + async fn reload(&self, id: &str) -> Result<()> { + SystemdAppProvider::new(self.agent_id.clone()) + .reload(id) + .await + } + + async fn logs(&self, id: &str, lines: usize) -> Result> { + SystemdAppProvider::new(self.agent_id.clone()) + .logs(id, lines) + .await + } + + async fn update(&self, _id: &str) -> Result<()> { + bail!("不支持的操作") + } + + async fn uninstall(&self, _id: &str) -> Result<()> { + bail!("不支持的操作") + } +} + +fn detail(application: Application) -> ApplicationDetail { + ApplicationDetail { + application, + relations: Vec::new(), + recent_actions: Vec::new(), + runtime_info: json!({}), + available_actions: vec![ + "start".into(), + "stop".into(), + "restart".into(), + "logs".into(), + "health".into(), + "backup".into(), + ], + risk_level: "normal".into(), + provider_specific_info: json!({}), + } +} + +fn build_relations(apps: &[Application], relations: &mut Vec) { + for nginx in apps + .iter() + .filter(|app| app.provider == ApplicationProviderType::NginxSite) + { + for port in &nginx.ports { + for target in apps.iter().filter(|app| { + app.provider != ApplicationProviderType::NginxSite && app.ports.contains(port) + }) { + relations.push(ApplicationRelation { + id: Uuid::new_v4().to_string(), + agent_id: nginx.agent_id.clone(), + app_id: nginx.id.clone(), + relation_type: "proxy_to_port".into(), + target_id: Some(target.id.clone()), + target_name: Some(target.display_name.clone()), + metadata: json!({ "port": port }), + }); + } + } + } +} + +async fn enrich_with_ports(apps: &mut [Application]) { + let Ok(output) = command_output(Command::new("ss").args(["-tulpen"])).await else { + return; + }; + for line in output.lines() { + let Some(port) = line + .rsplit(':') + .find_map(|part| part.split_whitespace().next()?.parse::().ok()) + else { + continue; + }; + for app in apps.iter_mut() { + if line.to_lowercase().contains(&app.name.to_lowercase()) && !app.ports.contains(&port) + { + app.ports.push(port); + } + } + } +} + +async fn tail_file(path: &str, lines: usize) -> Result> { + let line_count = lines.min(2000).to_string(); + let output = command_output(Command::new("tail").arg("-n").arg(line_count).arg(path)).await?; + Ok(output.lines().map(ToString::to_string).collect()) +} + +async fn command_output(cmd: &mut Command) -> Result { + let output = tokio::time::timeout(Duration::from_secs(8), cmd.output()).await??; + if !output.status.success() { + bail!("{}", String::from_utf8_lossy(&output.stderr)); + } + Ok(String::from_utf8_lossy(&output.stdout).to_string()) +} + +async fn command_checked(cmd: &mut Command) -> Result<()> { + command_output(cmd).await.map(|_| ()) +} + +async fn command_status(cmd: &mut Command) -> Result { + let output = tokio::time::timeout(Duration::from_secs(5), cmd.output()).await??; + Ok(output.status.success()) +} + +async fn ensure_user_exists(user: &str) -> Result<()> { + if user.len() > 64 + || !user + .chars() + .all(|c| c.is_ascii_alphanumeric() || "_-".contains(c)) + { + bail!("运行用户无效"); + } + command_checked(Command::new("id").arg("-u").arg(user)).await +} + +fn string_param(params: &Value, name: &str) -> Result { + params + .get(name) + .and_then(Value::as_str) + .map(ToString::to_string) + .ok_or_else(|| anyhow!("缺少参数:{name}")) +} + +fn safe_app_name(name: &str) -> Result { + let safe = name + .to_lowercase() + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == '-' || c == '_' { + c + } else { + '-' + } + }) + .collect::() + .trim_matches('-') + .to_string(); + if safe.is_empty() || safe.len() > 64 { + bail!("应用名称无效"); + } + Ok(safe) +} + +fn validate_start_command(command: &str) -> Result<()> { + if command.len() > 500 + || command.contains('\0') + || command.contains("&&") + || command.contains("||") + || command.contains(';') + || command.contains('`') + || command.contains("$(") + { + bail!("启动命令不安全"); + } + Ok(()) +} + +fn validate_url_target(url: &str) -> Result<()> { + if url.len() > 500 + || url.contains('\0') + || url.chars().any(char::is_whitespace) + || !(url.starts_with("http://") || url.starts_with("https://")) + { + bail!("健康检查 URL 无效"); + } + Ok(()) +} + +fn validate_host_target(host: &str) -> Result<()> { + if host.is_empty() + || host.len() > 253 + || host.contains('\0') + || host.chars().any(char::is_whitespace) + || host.starts_with('-') + { + bail!("健康检查主机无效"); + } + Ok(()) +} + +fn sanitize_env_key(key: &str) -> Result<&str> { + if key.is_empty() + || key.len() > 80 + || !key + .chars() + .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_') + { + bail!("环境变量名无效"); + } + Ok(key) +} + +fn validate_unit(name: &str) -> Result<()> { + if name.len() > 200 + || !name + .chars() + .all(|c| c.is_ascii_alphanumeric() || ".@_-".contains(c)) + { + bail!("服务名称无效"); + } + Ok(()) +} + +fn validate_docker_id(id: &str) -> Result<()> { + if id.len() > 200 + || !id + .chars() + .all(|c| c.is_ascii_alphanumeric() || ".:/@_-".contains(c)) + { + bail!("Docker 标识无效"); + } + Ok(()) +} + +fn validate_compose_project(project: &str) -> Result<()> { + if project.is_empty() + || project.len() > 128 + || !project + .chars() + .all(|c| c.is_ascii_alphanumeric() || "._-".contains(c)) + { + bail!("Compose 项目名称无效"); + } + Ok(()) +} + +fn is_manageable_service(base: &str, service: &str, description: &str) -> bool { + base.starts_with("lightops-") + || allowed_packages().contains(base) + || allowed_packages().contains(service.trim_end_matches(".service")) + || description.to_lowercase().contains("docker") + || description.to_lowercase().contains("nginx") + || description.to_lowercase().contains("redis") +} + +fn is_core_system_name(name: &str) -> bool { + let blocked = [ + "systemd", + "dbus", + "udev", + "apt", + "cron", + "ssh", + "getty", + "networking", + "rsyslog", + "polkit", + ]; + blocked + .iter() + .any(|prefix| name == *prefix || name.starts_with(&format!("{prefix}-"))) +} + +fn allowed_packages() -> HashSet<&'static str> { + [ + "nginx", + "apache2", + "caddy", + "redis-server", + "mysql-server", + "mariadb-server", + "postgresql", + "docker", + "docker-ce", + "containerd", + "fail2ban", + "supervisor", + "frps", + "frpc", + "gitea", + "alist", + "minio", + "prometheus", + "grafana", + ] + .into_iter() + .collect() +} + +fn infer_app_type(name: &str) -> ApplicationType { + match name { + "redis-server" | "mysql-server" | "mariadb-server" | "postgresql" => { + ApplicationType::Database + } + "nginx" | "apache2" | "caddy" => ApplicationType::WebApp, + "docker" | "docker-ce" | "containerd" => ApplicationType::Runtime, + _ => ApplicationType::Service, + } +} + +fn human_name(name: &str) -> String { + name.split(['-', '_']) + .filter(|part| !part.is_empty()) + .map(|part| { + let mut chars = part.chars(); + match chars.next() { + Some(first) => first.to_uppercase().collect::() + chars.as_str(), + None => String::new(), + } + }) + .collect::>() + .join(" ") +} + +fn package_for_service(base: &str) -> Option { + allowed_packages().contains(base).then(|| base.to_string()) +} + +fn service_for_package(package: &str) -> Option { + Some(match package { + "docker-ce" => "docker.service".to_string(), + "redis-server" => "redis-server.service".to_string(), + _ => format!("{package}.service"), + }) +} + +fn common_config_paths(name: &str) -> Vec { + match name { + "nginx" => vec!["/etc/nginx/nginx.conf".into()], + "redis-server" => vec!["/etc/redis/redis.conf".into()], + "postgresql" => vec!["/etc/postgresql".into()], + "mysql-server" | "mariadb-server" => vec!["/etc/mysql".into()], + _ => Vec::new(), + } +} + +fn common_data_paths(name: &str) -> Vec { + match name { + "redis-server" => vec!["/var/lib/redis".into()], + "postgresql" => vec!["/var/lib/postgresql".into()], + "mysql-server" | "mariadb-server" => vec!["/var/lib/mysql".into()], + _ => Vec::new(), + } +} + +fn common_ports(name: &str) -> Vec { + match name { + "nginx" | "apache2" | "caddy" => vec![80, 443], + "redis-server" => vec![6379], + "mysql-server" | "mariadb-server" => vec![3306], + "postgresql" => vec![5432], + "prometheus" => vec![9090], + "grafana" => vec![3000], + _ => Vec::new(), + } +} + +fn parse_port_numbers(input: &str) -> Vec { + let mut ports = Vec::new(); + for token in input.split([',', ' ', '-', '>']) { + let cleaned = token + .trim() + .trim_end_matches("/tcp") + .trim_end_matches("/udp"); + if let Some(port) = cleaned + .rsplit(':') + .next() + .and_then(|v| v.parse::().ok()) + .or_else(|| cleaned.parse::().ok()) + { + if !ports.contains(&port) { + ports.push(port); + } + } + } + ports +} + +fn parse_nginx_domains(content: &str) -> Vec { + content + .lines() + .filter_map(|line| line.trim().strip_prefix("server_name")) + .flat_map(|line| { + line.trim() + .trim_end_matches(';') + .split_whitespace() + .map(ToString::to_string) + .collect::>() + }) + .filter(|domain| domain != "_") + .collect() +} + +fn parse_nginx_listen_ports(content: &str) -> Vec { + content + .lines() + .filter_map(|line| line.trim().strip_prefix("listen")) + .filter_map(|line| { + line.split_whitespace() + .next()? + .trim_end_matches(';') + .parse::() + .ok() + }) + .collect() +} + +fn parse_nginx_root(content: &str) -> Option { + content.lines().find_map(|line| { + line.trim() + .strip_prefix("root") + .map(|v| v.trim().trim_end_matches(';').to_string()) + }) +} + +fn parse_nginx_logs(content: &str) -> Vec { + let mut paths = content + .lines() + .filter_map(|line| { + let line = line.trim(); + if line.starts_with("access_log") || line.starts_with("error_log") { + line.split_whitespace() + .nth(1) + .map(|v| v.trim_end_matches(';').to_string()) + } else { + None + } + }) + .collect::>(); + if paths.is_empty() { + paths.push("/var/log/nginx/access.log".into()); + paths.push("/var/log/nginx/error.log".into()); + } + paths +} + +fn agent_id() -> String { + std::env::var("LIGHTOPS_AGENT_ID").unwrap_or_else(|_| "local".into()) +} diff --git a/crates/lightops-agent/src/config.rs b/crates/lightops-agent/src/config.rs new file mode 100644 index 0000000..e6ced19 --- /dev/null +++ b/crates/lightops-agent/src/config.rs @@ -0,0 +1,59 @@ +use anyhow::Result; +use serde::{Deserialize, Serialize}; +use std::{fs, path::Path}; +use url::Url; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AgentConfig { + pub server_url: String, + pub agent_id: Option, + pub token: Option, + pub secret: Option, + pub name: Option, + pub heartbeat_interval: Option, + #[serde(skip)] + pub config_path: Option, +} + +impl Default for AgentConfig { + fn default() -> Self { + Self { + server_url: "http://127.0.0.1:8080".to_string(), + agent_id: None, + token: None, + secret: None, + name: None, + heartbeat_interval: Some(30), + config_path: None, + } + } +} + +impl AgentConfig { + pub fn load_optional(path: &str) -> Result { + if Path::new(path).exists() { + Ok(toml::from_str(&fs::read_to_string(path)?)?) + } else { + Ok(Self::default()) + } + } + + pub fn save(&self, path: &str) -> Result<()> { + if let Some(parent) = Path::new(path).parent() { + fs::create_dir_all(parent)?; + } + fs::write(path, toml::to_string_pretty(self)?)?; + Ok(()) + } + + pub fn ws_url(&self) -> Result { + let mut url = Url::parse(&self.server_url)?; + url.set_scheme(match url.scheme() { + "https" => "wss", + _ => "ws", + }) + .ok(); + url.set_path("/api/agent/ws"); + Ok(url.to_string()) + } +} diff --git a/crates/lightops-agent/src/main.rs b/crates/lightops-agent/src/main.rs new file mode 100644 index 0000000..b4340ff --- /dev/null +++ b/crates/lightops-agent/src/main.rs @@ -0,0 +1,343 @@ +mod actions; +mod app; +mod config; +mod system_info; +mod terminal; + +use anyhow::{Context, Result}; +use clap::Parser; +use config::AgentConfig; +use dashmap::DashMap; +use futures_util::{SinkExt, StreamExt}; +use lightops_common::protocol::{AgentCapabilities, AgentMessage, ServerMessage}; +use std::{ + sync::Arc, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; +use tokio::sync::mpsc; +use tokio_tungstenite::{connect_async, tungstenite::Message}; +use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; + +const CONNECT_TIMEOUT_SECS: u64 = 15; +const HANDSHAKE_TIMEOUT_SECS: u64 = 15; +const READ_GRACE_SECS: u64 = 100; +const MAX_RECONNECT_BACKOFF_SECS: u64 = 60; + +#[derive(Debug, Parser)] +struct Args { + #[arg(long)] + server: Option, + #[arg(long)] + token: Option, + #[arg(long)] + config: Option, + #[arg(long)] + name: Option, +} + +#[tokio::main] +async fn main() -> Result<()> { + tracing_subscriber::registry() + .with( + tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()), + ) + .with(tracing_subscriber::fmt::layer()) + .init(); + + let args = Args::parse(); + let config_path = args.config.clone().unwrap_or_else(|| default_config_path()); + let mut cfg = AgentConfig::load_optional(&config_path)?; + if let Some(server) = args.server { + cfg.server_url = server; + } + if let Some(token) = args.token { + cfg.token = Some(token); + } + if let Some(name) = args.name { + cfg.name = Some(name); + } + cfg.config_path = Some(config_path); + run_forever(cfg).await +} + +fn default_config_path() -> String { + #[cfg(windows)] + { + "agent.toml".to_string() + } + #[cfg(not(windows))] + { + "/etc/lightops/agent.toml".to_string() + } +} + +async fn run_forever(mut cfg: AgentConfig) -> Result<()> { + let mut backoff = 1u64; + loop { + match run_once(cfg.clone()).await { + Ok(updated) => { + cfg = updated; + tracing::warn!("Agent 连接已断开,准备重连"); + tokio::time::sleep(reconnect_delay(1)).await; + backoff = 1; + } + Err(err) => { + tracing::warn!(?err, backoff, "Agent 连接失败,等待后重试"); + tokio::time::sleep(reconnect_delay(backoff)).await; + backoff = (backoff * 2).min(MAX_RECONNECT_BACKOFF_SECS); + } + } + } +} + +async fn run_once(mut cfg: AgentConfig) -> Result { + let ws_url = cfg.ws_url()?; + tracing::info!("正在连接主控端 {}", ws_url); + let (ws, _) = tokio::time::timeout( + Duration::from_secs(CONNECT_TIMEOUT_SECS), + connect_async(ws_url), + ) + .await + .context("连接主控端超时")? + .context("连接主控端 WebSocket 失败")?; + let (mut write, mut read) = ws.split(); + let (tx, mut rx) = mpsc::unbounded_channel::(); + let streams = Arc::new(DashMap::new()); + + let hello = AgentMessage::AgentHello { + agent_id: cfg.agent_id.clone(), + token: cfg.token.clone(), + secret: cfg.secret.clone(), + hostname: hostname(), + os: std::env::consts::OS.to_string(), + arch: std::env::consts::ARCH.to_string(), + version: env!("CARGO_PKG_VERSION").to_string(), + capabilities: AgentCapabilities::default(), + }; + write + .send(Message::Text(serde_json::to_string(&hello)?)) + .await?; + + let Some(Ok(Message::Text(first))) = + tokio::time::timeout(Duration::from_secs(HANDSHAKE_TIMEOUT_SECS), read.next()) + .await + .context("等待主控端握手响应超时")? + else { + anyhow::bail!("Server 在接受 Agent 前关闭了连接"); + }; + match serde_json::from_str::(&first)? { + ServerMessage::AgentAccepted { agent_id, secret } => { + cfg.agent_id = Some(agent_id); + if let Some(id) = cfg.agent_id.as_deref() { + std::env::set_var("LIGHTOPS_AGENT_ID", id); + } + if let Some(secret) = secret { + cfg.secret = Some(secret); + cfg.token = None; + } + if let Some(path) = cfg.config_path.as_deref() { + cfg.save(path)?; + } + } + ServerMessage::ErrorMessage { message, .. } => anyhow::bail!(message), + _ => anyhow::bail!("Server 首条消息不符合预期"), + } + + let writer = tokio::spawn(async move { + while let Some(msg) = rx.recv().await { + let Ok(text) = serde_json::to_string(&msg) else { + continue; + }; + if write.send(Message::Text(text)).await.is_err() { + break; + } + } + }); + + let heartbeat_tx = tx.clone(); + let heartbeat_id = cfg.agent_id.clone().unwrap_or_default(); + let heartbeat_interval = cfg.heartbeat_interval.unwrap_or(30).max(10); + let heartbeat = tokio::spawn(async move { + loop { + let metrics = system_info::collect_metrics(); + let _ = heartbeat_tx.send(AgentMessage::AgentHeartbeat { + agent_id: heartbeat_id.clone(), + metrics: Some(metrics), + }); + tokio::time::sleep(Duration::from_secs(heartbeat_interval)).await; + } + }); + + loop { + let msg = tokio::time::timeout(Duration::from_secs(READ_GRACE_SECS), read.next()).await; + match msg { + Ok(Some(Ok(Message::Text(text)))) => { + let server_msg = serde_json::from_str::(&text)?; + handle_server_message(server_msg, tx.clone(), streams.clone(), &cfg).await; + } + Ok(Some(Ok(Message::Close(_)))) | Ok(None) => break, + Ok(Some(Ok(_))) => {} + Ok(Some(Err(err))) => return Err(err).context("读取主控端消息失败"), + Err(_) => anyhow::bail!("主控端连接静默超时"), + } + } + + heartbeat.abort(); + writer.abort(); + Ok(cfg) +} + +fn reconnect_delay(base_secs: u64) -> Duration { + let jitter_ms = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| (d.subsec_millis() % 1000) as u64) + .unwrap_or(0); + Duration::from_millis(base_secs.saturating_mul(1000).saturating_add(jitter_ms)) +} + +async fn handle_server_message( + msg: ServerMessage, + tx: mpsc::UnboundedSender, + streams: Arc>, + cfg: &AgentConfig, +) { + match msg { + ServerMessage::ServerPing { timestamp } => { + let agent_id = cfg.agent_id.clone().unwrap_or_default(); + let _ = tx.send(AgentMessage::AgentPong { + agent_id, + timestamp, + }); + } + ServerMessage::TaskRequest { + task_id, + action, + params, + } => { + tokio::spawn(async move { + let _ = tx.send(AgentMessage::TaskEvent { + task_id: task_id.clone(), + level: "info".into(), + message: format!("开始执行 {action}"), + data: serde_json::json!({ "action": action }), + }); + let result = actions::handle(&action, params).await; + let response = match result { + Ok(data) => { + emit_task_output_events(&tx, &task_id, &data); + let _ = tx.send(AgentMessage::TaskEvent { + task_id: task_id.clone(), + level: "info".into(), + message: "任务执行完成".into(), + data: serde_json::json!({ "success": true }), + }); + AgentMessage::TaskResponse { + task_id, + success: true, + data, + error: None, + } + } + Err(err) => { + let error = err.to_string(); + let _ = tx.send(AgentMessage::TaskEvent { + task_id: task_id.clone(), + level: "error".into(), + message: "任务执行失败".into(), + data: serde_json::json!({ "error": error }), + }); + AgentMessage::TaskResponse { + task_id, + success: false, + data: serde_json::json!({}), + error: Some(error), + } + } + }; + let _ = tx.send(response); + }); + } + ServerMessage::StreamOpen { + stream_id, + kind, + meta, + } => { + if kind == "terminal" || kind == "docker.exec" { + let result = if kind == "docker.exec" { + terminal::open_docker_exec(stream_id.clone(), tx.clone(), meta) + } else { + terminal::open(stream_id.clone(), tx.clone(), meta) + }; + match result { + Ok(handle) => { + streams.insert(stream_id, handle); + } + Err(err) => { + let _ = tx.send(AgentMessage::StreamClose { + stream_id, + reason: Some(err.to_string()), + }); + } + } + } + } + ServerMessage::StreamData { + stream_id, + data, + binary, + } => { + if let Some(handle) = streams.get(&stream_id) { + let _ = handle.write(data, binary); + } + } + ServerMessage::StreamClose { stream_id, .. } => { + streams.remove(&stream_id); + } + ServerMessage::AgentAccepted { .. } => {} + ServerMessage::ErrorMessage { code, message } => { + tracing::warn!(%code, %message, "主控端返回连接错误"); + } + } +} + +fn emit_task_output_events( + tx: &mpsc::UnboundedSender, + task_id: &str, + data: &serde_json::Value, +) { + for key in ["stdout", "stderr", "pull_stdout", "pull_stderr"] { + let Some(value) = data.get(key).and_then(serde_json::Value::as_str) else { + continue; + }; + let text = value.trim(); + if text.is_empty() { + continue; + } + let level = if key.contains("stderr") { + "warn" + } else { + "info" + }; + let _ = tx.send(AgentMessage::TaskEvent { + task_id: task_id.to_string(), + level: level.into(), + message: key.to_string(), + data: serde_json::json!({ "output": truncate_event_text(text) }), + }); + } +} + +fn truncate_event_text(text: &str) -> String { + const MAX_EVENT_TEXT: usize = 16 * 1024; + if text.len() <= MAX_EVENT_TEXT { + text.to_string() + } else { + format!("{}...(输出过长,已截断)", &text[..MAX_EVENT_TEXT]) + } +} + +fn hostname() -> String { + std::env::var("HOSTNAME") + .or_else(|_| std::env::var("COMPUTERNAME")) + .unwrap_or_else(|_| "lightops-node".to_string()) +} diff --git a/crates/lightops-agent/src/system_info.rs b/crates/lightops-agent/src/system_info.rs new file mode 100644 index 0000000..3d22075 --- /dev/null +++ b/crates/lightops-agent/src/system_info.rs @@ -0,0 +1,44 @@ +use lightops_common::protocol::{NetworkInfo, SystemMetrics}; +use std::collections::HashMap; +use sysinfo::{Disks, Networks, System}; + +pub fn collect_metrics() -> SystemMetrics { + let mut system = System::new_all(); + system.refresh_all(); + let cpu_usage = if system.cpus().is_empty() { + 0.0 + } else { + system + .cpus() + .iter() + .map(|c| c.cpu_usage() as f64) + .sum::() + / system.cpus().len() as f64 + }; + let disks = Disks::new_with_refreshed_list(); + let disk_total = disks.iter().map(|d| d.total_space()).sum(); + let disk_available: u64 = disks.iter().map(|d| d.available_space()).sum(); + let networks = Networks::new_with_refreshed_list() + .iter() + .map(|(name, data)| { + ( + name.to_string(), + NetworkInfo { + received: data.total_received(), + transmitted: data.total_transmitted(), + }, + ) + }) + .collect::>(); + let load = System::load_average(); + SystemMetrics { + cpu_usage, + memory_total: system.total_memory(), + memory_used: system.used_memory(), + disk_total, + disk_used: disk_total.saturating_sub(disk_available), + load_avg: load.one, + uptime: System::uptime(), + networks, + } +} diff --git a/crates/lightops-agent/src/terminal.rs b/crates/lightops-agent/src/terminal.rs new file mode 100644 index 0000000..828d197 --- /dev/null +++ b/crates/lightops-agent/src/terminal.rs @@ -0,0 +1,122 @@ +use anyhow::Result; +use base64::Engine; +use lightops_common::protocol::AgentMessage; +use portable_pty::{native_pty_system, CommandBuilder, PtySize}; +use serde_json::Value; +use std::{ + io::{Read, Write}, + sync::{Arc, Mutex}, + thread, +}; +use tokio::sync::mpsc; + +pub struct TerminalHandle { + writer: Arc>>, +} + +impl TerminalHandle { + pub fn write(&self, data: String, binary: bool) -> Result<()> { + let bytes = if binary { + base64::engine::general_purpose::STANDARD.decode(data)? + } else { + data.into_bytes() + }; + self.writer.lock().expect("pty writer").write_all(&bytes)?; + Ok(()) + } +} + +pub fn open( + stream_id: String, + tx: mpsc::UnboundedSender, + meta: Value, +) -> Result { + let cols = meta.get("cols").and_then(Value::as_u64).unwrap_or(100) as u16; + let rows = meta.get("rows").and_then(Value::as_u64).unwrap_or(30) as u16; + let pty_system = native_pty_system(); + let pair = pty_system.openpty(PtySize { + rows, + cols, + pixel_width: 0, + pixel_height: 0, + })?; + let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string()); + let mut cmd = CommandBuilder::new(shell); + open_with_command(stream_id, tx, pair, &mut cmd) +} + +pub fn open_docker_exec( + stream_id: String, + tx: mpsc::UnboundedSender, + meta: Value, +) -> Result { + let cols = meta.get("cols").and_then(Value::as_u64).unwrap_or(100) as u16; + let rows = meta.get("rows").and_then(Value::as_u64).unwrap_or(30) as u16; + let container_id = meta + .get("container_id") + .and_then(Value::as_str) + .ok_or_else(|| anyhow::anyhow!("缺少容器 ID"))?; + validate_docker_id(container_id)?; + let shell = meta.get("shell").and_then(Value::as_str).unwrap_or("sh"); + let shell = if shell == "bash" { "bash" } else { "sh" }; + let pty_system = native_pty_system(); + let pair = pty_system.openpty(PtySize { + rows, + cols, + pixel_width: 0, + pixel_height: 0, + })?; + let mut cmd = CommandBuilder::new("docker"); + cmd.arg("exec"); + cmd.arg("-it"); + cmd.arg(container_id); + cmd.arg(shell); + open_with_command(stream_id, tx, pair, &mut cmd) +} + +fn open_with_command( + stream_id: String, + tx: mpsc::UnboundedSender, + pair: portable_pty::PtyPair, + cmd: &mut CommandBuilder, +) -> Result { + cmd.env("TERM", "xterm-256color"); + let mut child = pair.slave.spawn_command(cmd)?; + let mut reader = pair.master.try_clone_reader()?; + let writer = Arc::new(Mutex::new(pair.master.take_writer()?)); + let close_id = stream_id.clone(); + thread::spawn(move || { + let mut buf = [0u8; 8192]; + loop { + match reader.read(&mut buf) { + Ok(0) => break, + Ok(n) => { + let data = String::from_utf8_lossy(&buf[..n]).to_string(); + let _ = tx.send(AgentMessage::StreamData { + stream_id: stream_id.clone(), + data, + binary: false, + }); + } + Err(_) => break, + } + } + let _ = child.kill(); + let _ = tx.send(AgentMessage::StreamClose { + stream_id: close_id, + reason: Some("终端已关闭".into()), + }); + }); + Ok(TerminalHandle { writer }) +} + +fn validate_docker_id(id: &str) -> Result<()> { + if id.len() > 200 + || !id + .chars() + .all(|c| c.is_ascii_alphanumeric() || ".:/@_-".contains(c)) + { + anyhow::bail!("Docker 容器标识无效"); + } + Ok(()) +} diff --git a/crates/lightops-cli/Cargo.toml b/crates/lightops-cli/Cargo.toml new file mode 100644 index 0000000..0c9d2b9 --- /dev/null +++ b/crates/lightops-cli/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "lightops-cli" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +anyhow.workspace = true +clap.workspace = true + diff --git a/crates/lightops-cli/src/main.rs b/crates/lightops-cli/src/main.rs new file mode 100644 index 0000000..09a0bc4 --- /dev/null +++ b/crates/lightops-cli/src/main.rs @@ -0,0 +1,14 @@ +use anyhow::Result; +use clap::Parser; + +#[derive(Debug, Parser)] +struct Args { + #[arg(long)] + version: bool, +} + +fn main() -> Result<()> { + let _args = Args::parse(); + println!("lightops-cli {}", env!("CARGO_PKG_VERSION")); + Ok(()) +} diff --git a/crates/lightops-common/Cargo.toml b/crates/lightops-common/Cargo.toml new file mode 100644 index 0000000..91ebc36 --- /dev/null +++ b/crates/lightops-common/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "lightops-common" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +serde.workspace = true +serde_json.workspace = true +uuid.workspace = true +chrono.workspace = true + diff --git a/crates/lightops-common/src/api.rs b/crates/lightops-common/src/api.rs new file mode 100644 index 0000000..bb03cae --- /dev/null +++ b/crates/lightops-common/src/api.rs @@ -0,0 +1,48 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize)] +pub struct ApiResponse +where + T: Serialize, +{ + pub success: bool, + pub data: Option, + pub error: Option, +} + +impl ApiResponse +where + T: Serialize, +{ + pub fn ok(data: T) -> Self { + Self { + success: true, + data: Some(data), + error: None, + } + } +} + +impl ApiResponse<()> { + pub fn empty() -> Self { + Self { + success: true, + data: Some(()), + error: None, + } + } + + pub fn err(message: impl Into) -> Self { + Self { + success: false, + data: None, + error: Some(message.into()), + } + } +} + +#[derive(Debug, Deserialize)] +pub struct PageQuery { + pub limit: Option, + pub offset: Option, +} diff --git a/crates/lightops-common/src/lib.rs b/crates/lightops-common/src/lib.rs new file mode 100644 index 0000000..027d4fe --- /dev/null +++ b/crates/lightops-common/src/lib.rs @@ -0,0 +1,2 @@ +pub mod api; +pub mod protocol; diff --git a/crates/lightops-common/src/protocol.rs b/crates/lightops-common/src/protocol.rs new file mode 100644 index 0000000..e38581e --- /dev/null +++ b/crates/lightops-common/src/protocol.rs @@ -0,0 +1,280 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::HashMap; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AgentCapabilities { + pub file: bool, + pub terminal: bool, + pub systemd: bool, + pub nginx: bool, + pub docker: bool, + pub logs: bool, +} + +impl Default for AgentCapabilities { + fn default() -> Self { + Self { + file: true, + terminal: true, + systemd: true, + nginx: true, + docker: true, + logs: true, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SystemMetrics { + pub cpu_usage: f64, + pub memory_total: u64, + pub memory_used: u64, + pub disk_total: u64, + pub disk_used: u64, + pub load_avg: f64, + pub uptime: u64, + pub networks: HashMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NetworkInfo { + pub received: u64, + pub transmitted: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type")] +pub enum AgentMessage { + #[serde(rename = "agent.hello")] + AgentHello { + agent_id: Option, + token: Option, + secret: Option, + hostname: String, + os: String, + arch: String, + version: String, + capabilities: AgentCapabilities, + }, + #[serde(rename = "agent.heartbeat")] + AgentHeartbeat { + agent_id: String, + metrics: Option, + }, + #[serde(rename = "agent.pong")] + AgentPong { agent_id: String, timestamp: i64 }, + #[serde(rename = "task.response")] + TaskResponse { + task_id: String, + success: bool, + data: Value, + error: Option, + }, + #[serde(rename = "task.event")] + TaskEvent { + task_id: String, + level: String, + message: String, + data: Value, + }, + #[serde(rename = "stream.open")] + StreamOpen { + stream_id: String, + kind: String, + meta: Value, + }, + #[serde(rename = "stream.data")] + StreamData { + stream_id: String, + data: String, + binary: bool, + }, + #[serde(rename = "stream.close")] + StreamClose { + stream_id: String, + reason: Option, + }, + #[serde(rename = "error")] + ErrorMessage { code: String, message: String }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type")] +pub enum ServerMessage { + #[serde(rename = "agent.accepted")] + AgentAccepted { + agent_id: String, + secret: Option, + }, + #[serde(rename = "task.request")] + TaskRequest { + task_id: String, + action: String, + params: Value, + }, + #[serde(rename = "server.ping")] + ServerPing { timestamp: i64 }, + #[serde(rename = "stream.open")] + StreamOpen { + stream_id: String, + kind: String, + meta: Value, + }, + #[serde(rename = "stream.data")] + StreamData { + stream_id: String, + data: String, + binary: bool, + }, + #[serde(rename = "stream.close")] + StreamClose { + stream_id: String, + reason: Option, + }, + #[serde(rename = "error")] + ErrorMessage { code: String, message: String }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FileEntry { + pub name: String, + pub path: String, + pub is_dir: bool, + pub size: u64, + pub modified: Option, + pub readonly: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ServiceInfo { + pub name: String, + pub load: String, + pub active: String, + pub sub: String, + pub description: String, + pub enabled: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DockerContainer { + pub id: String, + pub image: String, + pub command: String, + pub status: String, + pub names: String, + pub ports: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DockerImage { + pub repository: String, + pub tag: String, + pub id: String, + pub size: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NginxSite { + pub name: String, + pub enabled: bool, + pub path: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub enum ApplicationType { + WebApp, + Service, + Database, + Runtime, + Tool, + Container, + ComposeProject, + StaticSite, + ReverseProxy, + Custom, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub enum ApplicationProviderType { + Systemd, + Docker, + DockerCompose, + Apt, + Dnf, + Pacman, + Snap, + Flatpak, + Binary, + PM2, + Supervisor, + NginxSite, + LightOpsManaged, + Custom, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub enum ApplicationStatus { + Running, + Stopped, + Failed, + Enabled, + Disabled, + Installing, + Updating, + Unknown, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Application { + pub id: String, + pub agent_id: String, + pub name: String, + pub display_name: String, + pub description: Option, + pub app_type: ApplicationType, + pub provider: ApplicationProviderType, + pub status: ApplicationStatus, + pub version: Option, + pub install_path: Option, + pub work_dir: Option, + pub config_paths: Vec, + pub log_paths: Vec, + pub data_paths: Vec, + pub ports: Vec, + pub domains: Vec, + pub service_name: Option, + pub container_id: Option, + pub compose_project: Option, + pub package_name: Option, + pub nginx_site: Option, + pub run_user: Option, + pub is_system: bool, + pub is_managed: bool, + pub is_lightops_managed: bool, + pub metadata: Value, + pub created_at: Option, + pub updated_at: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ApplicationRelation { + pub id: String, + pub agent_id: String, + pub app_id: String, + pub relation_type: String, + pub target_id: Option, + pub target_name: Option, + pub metadata: Value, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ApplicationDetail { + pub application: Application, + pub relations: Vec, + pub recent_actions: Vec, + pub runtime_info: Value, + pub available_actions: Vec, + pub risk_level: String, + pub provider_specific_info: Value, +} diff --git a/crates/lightops-server/Cargo.toml b/crates/lightops-server/Cargo.toml new file mode 100644 index 0000000..796ea55 --- /dev/null +++ b/crates/lightops-server/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "lightops-server" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +anyhow.workspace = true +argon2.workspace = true +async-trait.workspace = true +axum.workspace = true +base64.workspace = true +chrono.workspace = true +clap.workspace = true +dashmap.workspace = true +futures-util.workspace = true +jsonwebtoken.workspace = true +lightops-common = { path = "../lightops-common" } +rand.workspace = true +serde.workspace = true +serde_json.workspace = true +sha2.workspace = true +sqlx.workspace = true +thiserror.workspace = true +tokio.workspace = true +toml.workspace = true +tower-http.workspace = true +tracing.workspace = true +tracing-subscriber.workspace = true +uuid.workspace = true diff --git a/crates/lightops-server/src/apps.rs b/crates/lightops-server/src/apps.rs new file mode 100644 index 0000000..ee6709d --- /dev/null +++ b/crates/lightops-server/src/apps.rs @@ -0,0 +1,1194 @@ +use crate::{ + auth::AuthUser, + error::AppError, + state::AppState, + task::{self, request_agent_task}, +}; +use axum::{ + extract::{Path, Query, State}, + routing::{get, post}, + Json, Router, +}; +use chrono::Utc; +use lightops_common::{ + api::ApiResponse, + protocol::{Application, ApplicationRelation}, +}; +use serde::Deserialize; +use serde_json::{json, Value}; +use sqlx::Row; +use uuid::Uuid; + +pub fn router() -> Router { + Router::new() + .route("/apps", get(list_all_apps)) + .route("/apps/overview", get(apps_overview)) + .route("/agents/:id/apps", get(list_agent_apps)) + .route("/agents/:id/apps/discover", post(discover_apps)) + .route("/agents/:id/apps/manage-custom", post(manage_custom_app)) + .route( + "/agents/:id/apps/create-systemd-service", + post(create_systemd_service), + ) + .route( + "/agents/:id/apps/:app_id", + get(get_app).delete(uninstall_app), + ) + .route("/agents/:id/apps/:app_id/start", post(start_app)) + .route("/agents/:id/apps/:app_id/stop", post(stop_app)) + .route("/agents/:id/apps/:app_id/restart", post(restart_app)) + .route("/agents/:id/apps/:app_id/reload", post(reload_app)) + .route("/agents/:id/apps/:app_id/health", post(app_health)) + .route("/agents/:id/apps/:app_id/logs", get(app_logs)) + .route("/agents/:id/apps/:app_id/update", post(update_app)) + .route("/agents/:id/apps/:app_id/backup", post(backup_app)) + .route("/agents/:id/apps/:app_id/restore", post(restore_app)) + .route("/agents/:id/apps/:app_id/unmanage", post(unmanage_app)) + .route("/agents/:id/apps/:app_id/relations", get(app_relations)) +} + +pub(crate) async fn run_scheduled_health_checks(state: &AppState) -> Result<(), AppError> { + if !setting_bool(state, "apps.health_check_enabled", true).await { + return Ok(()); + } + let interval_seconds = setting_i64(state, "apps.health_check_interval_seconds", 300) + .await + .max(60); + let batch_size = setting_i64(state, "apps.health_check_batch_size", 20) + .await + .clamp(1, 100); + let timeout_seconds = setting_i64(state, "apps.health_check_timeout_seconds", 5) + .await + .clamp(1, 30); + let cutoff = (Utc::now() - chrono::Duration::seconds(interval_seconds)).to_rfc3339(); + let rows = sqlx::query( + "SELECT app.* FROM applications app + INNER JOIN agents ag ON ag.id = app.agent_id AND ag.status = 'online' + WHERE app.status IN ('Running', 'Enabled') + AND (app.ports_json != '[]' OR app.domains_json != '[]') + AND NOT EXISTS ( + SELECT 1 FROM application_actions aa + WHERE aa.agent_id = app.agent_id + AND aa.app_id = app.id + AND aa.action = 'app.health' + AND aa.created_at > ? + ) + ORDER BY app.updated_at DESC + LIMIT ?", + ) + .bind(cutoff) + .bind(batch_size) + .fetch_all(&state.pool) + .await?; + + for row in rows { + let app = row_to_app_json(row); + let Some(agent_id) = app + .get("agent_id") + .and_then(Value::as_str) + .map(ToString::to_string) + else { + continue; + }; + let Some(app_id) = app + .get("id") + .and_then(Value::as_str) + .map(ToString::to_string) + else { + continue; + }; + let params = json!({ + "id": app_id, + "application": app, + "params": { "timeout_secs": timeout_seconds } + }); + let reply = + match request_agent_task(state, &agent_id, None, "app.health", params.clone()).await { + Ok(reply) => reply, + Err(err) => { + let error_text = err.to_string(); + record_action( + state, + &agent_id, + Some(&app_id), + None, + "app.health", + "failed", + Some(¶ms), + None, + Some(&error_text), + ) + .await?; + sync_app_health_alert( + state, + &agent_id, + &app_id, + params.get("application").unwrap_or(&Value::Null), + false, + None, + Some(&error_text), + ) + .await?; + continue; + } + }; + record_action( + state, + &agent_id, + Some(&app_id), + None, + "app.health", + if reply.success { "success" } else { "failed" }, + Some(¶ms), + Some(&reply.data), + reply.error.as_deref(), + ) + .await?; + sync_app_health_alert( + state, + &agent_id, + &app_id, + params.get("application").unwrap_or(&Value::Null), + reply.success + && reply + .data + .get("ok") + .and_then(Value::as_bool) + .unwrap_or(false), + Some(&reply.data), + reply.error.as_deref(), + ) + .await?; + } + Ok(()) +} + +#[derive(Debug, Deserialize)] +struct AppQuery { + agent_id: Option, + status: Option, + app_type: Option, + provider: Option, + q: Option, + limit: Option, + offset: Option, +} + +#[derive(Debug, Deserialize)] +struct LogsQuery { + lines: Option, +} + +async fn list_all_apps( + user: AuthUser, + State(state): State, + Query(q): Query, +) -> Result>, AppError> { + require_app_permission(&user)?; + let rows = if user.role == "admin" { + sqlx::query("SELECT * FROM applications ORDER BY updated_at DESC LIMIT ? OFFSET ?") + .bind(q.limit.unwrap_or(500).min(1000)) + .bind(q.offset.unwrap_or(0)) + .fetch_all(&state.pool) + .await? + } else { + sqlx::query( + "SELECT app.* FROM applications app + INNER JOIN agent_access aa ON aa.agent_id = app.agent_id + WHERE aa.user_id = ? ORDER BY app.updated_at DESC LIMIT ? OFFSET ?", + ) + .bind(user.id) + .bind(q.limit.unwrap_or(500).min(1000)) + .bind(q.offset.unwrap_or(0)) + .fetch_all(&state.pool) + .await? + }; + let apps = rows + .into_iter() + .map(row_to_app_json) + .filter(|app| filter_app(app, &q)) + .collect::>(); + let apps = attach_latest_health(&state, apps).await?; + Ok(Json(ApiResponse::ok(json!(apps)))) +} + +async fn apps_overview( + user: AuthUser, + State(state): State, +) -> Result>, AppError> { + require_app_permission(&user)?; + let (total, running): (i64, i64) = if user.role == "admin" { + let total = sqlx::query_scalar("SELECT COUNT(*) FROM applications") + .fetch_one(&state.pool) + .await?; + let running: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM applications WHERE status = 'Running'") + .fetch_one(&state.pool) + .await?; + (total, running) + } else { + let total = sqlx::query_scalar( + "SELECT COUNT(*) FROM applications app INNER JOIN agent_access aa ON aa.agent_id = app.agent_id WHERE aa.user_id = ?", + ) + .bind(user.id) + .fetch_one(&state.pool) + .await?; + let running = sqlx::query_scalar( + "SELECT COUNT(*) FROM applications app INNER JOIN agent_access aa ON aa.agent_id = app.agent_id WHERE aa.user_id = ? AND app.status = 'Running'", + ) + .bind(user.id) + .fetch_one(&state.pool) + .await?; + (total, running) + }; + let by_provider_rows = if user.role == "admin" { + sqlx::query("SELECT provider, COUNT(*) AS count FROM applications GROUP BY provider") + .fetch_all(&state.pool) + .await? + } else { + sqlx::query( + "SELECT app.provider, COUNT(*) AS count FROM applications app + INNER JOIN agent_access aa ON aa.agent_id = app.agent_id + WHERE aa.user_id = ? GROUP BY app.provider", + ) + .bind(user.id) + .fetch_all(&state.pool) + .await? + }; + let by_provider = by_provider_rows + .into_iter() + .map(|r| json!({ "provider": r.get::("provider"), "count": r.get::("count") })) + .collect::>(); + Ok(Json(ApiResponse::ok(json!({ + "total": total, + "running": running, + "stopped": total.saturating_sub(running), + "by_provider": by_provider, + })))) +} + +async fn list_agent_apps( + user: AuthUser, + State(state): State, + Path(id): Path, + Query(q): Query, +) -> Result>, AppError> { + require_app_permission(&user)?; + require_app_agent_access(&user, &state, &id).await?; + let rows = + sqlx::query("SELECT * FROM applications WHERE agent_id = ? ORDER BY updated_at DESC") + .bind(&id) + .fetch_all(&state.pool) + .await?; + let apps = rows + .into_iter() + .map(row_to_app_json) + .filter(|app| filter_app(app, &q)) + .collect::>(); + let apps = attach_latest_health(&state, apps).await?; + Ok(Json(ApiResponse::ok(json!(apps)))) +} + +async fn discover_apps( + user: AuthUser, + State(state): State, + Path(id): Path, +) -> Result>, AppError> { + require_app_permission(&user)?; + require_app_agent_access(&user, &state, &id).await?; + let reply = request_agent_task(&state, &id, Some(user.id), "app.discover", json!({})).await?; + if !reply.success { + return Err(AppError::BadRequest( + reply.error.unwrap_or_else(|| "应用发现失败".into()), + )); + } + persist_discovery(&state, &id, &reply.data).await?; + task::audit( + &state, + Some(user.id), + Some(&id), + "app.discover", + None, + None, + true, + None, + ) + .await; + Ok(Json(ApiResponse::ok(reply.data))) +} + +async fn get_app( + user: AuthUser, + State(state): State, + Path((id, app_id)): Path<(String, String)>, +) -> Result>, AppError> { + require_app_permission(&user)?; + require_app_agent_access(&user, &state, &id).await?; + let app = load_app_json(&state, &id, &app_id).await?; + let relations = load_relations_json(&state, &id, &app_id).await?; + let actions = load_recent_actions(&state, &id, &app_id).await?; + let detail = json!({ + "application": app, + "relations": relations, + "recent_actions": actions, + "runtime_info": { + "latest_health": load_latest_health(&state, &id, &app_id).await?, + }, + "available_actions": available_actions(&app), + "risk_level": if app.get("is_system").and_then(Value::as_bool).unwrap_or(false) { "high" } else { "normal" }, + "provider_specific_info": app.get("metadata").cloned().unwrap_or_else(|| json!({})), + }); + Ok(Json(ApiResponse::ok(detail))) +} + +async fn app_relations( + user: AuthUser, + State(state): State, + Path((id, app_id)): Path<(String, String)>, +) -> Result>, AppError> { + require_app_permission(&user)?; + require_app_agent_access(&user, &state, &id).await?; + Ok(Json(ApiResponse::ok(json!( + load_relations_json(&state, &id, &app_id).await? + )))) +} + +async fn start_app( + user: AuthUser, + State(state): State, + Path((id, app_id)): Path<(String, String)>, +) -> Result>, AppError> { + run_app_action(&user, &state, &id, &app_id, "app.start", true, json!({})).await +} + +async fn stop_app( + user: AuthUser, + State(state): State, + Path((id, app_id)): Path<(String, String)>, +) -> Result>, AppError> { + run_app_action(&user, &state, &id, &app_id, "app.stop", true, json!({})).await +} + +async fn restart_app( + user: AuthUser, + State(state): State, + Path((id, app_id)): Path<(String, String)>, +) -> Result>, AppError> { + run_app_action(&user, &state, &id, &app_id, "app.restart", true, json!({})).await +} + +async fn reload_app( + user: AuthUser, + State(state): State, + Path((id, app_id)): Path<(String, String)>, +) -> Result>, AppError> { + run_app_action(&user, &state, &id, &app_id, "app.reload", true, json!({})).await +} + +async fn app_health( + user: AuthUser, + State(state): State, + Path((id, app_id)): Path<(String, String)>, + Json(params): Json, +) -> Result>, AppError> { + run_app_action(&user, &state, &id, &app_id, "app.health", false, params).await +} + +async fn update_app( + user: AuthUser, + State(state): State, + Path((id, app_id)): Path<(String, String)>, +) -> Result>, AppError> { + run_app_action(&user, &state, &id, &app_id, "app.update", true, json!({})).await +} + +async fn backup_app( + user: AuthUser, + State(state): State, + Path((id, app_id)): Path<(String, String)>, +) -> Result>, AppError> { + run_app_action(&user, &state, &id, &app_id, "app.backup", false, json!({})).await +} + +async fn restore_app( + user: AuthUser, + State(state): State, + Path((id, app_id)): Path<(String, String)>, + Json(params): Json, +) -> Result>, AppError> { + run_app_action(&user, &state, &id, &app_id, "app.restore", true, params).await +} + +async fn uninstall_app( + user: AuthUser, + State(state): State, + Path((id, app_id)): Path<(String, String)>, +) -> Result>, AppError> { + run_app_action( + &user, + &state, + &id, + &app_id, + "app.uninstall", + true, + json!({}), + ) + .await +} + +async fn unmanage_app( + user: AuthUser, + State(state): State, + Path((id, app_id)): Path<(String, String)>, +) -> Result>, AppError> { + let result = + run_app_action(&user, &state, &id, &app_id, "app.unmanage", true, json!({})).await?; + sqlx::query("UPDATE applications SET is_managed = 0, is_lightops_managed = 0, updated_at = ? WHERE agent_id = ? AND id = ?") + .bind(Utc::now().to_rfc3339()) + .bind(&id) + .bind(&app_id) + .execute(&state.pool) + .await?; + Ok(result) +} + +async fn app_logs( + user: AuthUser, + State(state): State, + Path((id, app_id)): Path<(String, String)>, + Query(q): Query, +) -> Result>, AppError> { + run_app_action( + &user, + &state, + &id, + &app_id, + "app.logs", + false, + json!({ "lines": q.lines.unwrap_or(200).min(2000) }), + ) + .await +} + +async fn manage_custom_app( + user: AuthUser, + State(state): State, + Path(id): Path, + Json(params): Json, +) -> Result>, AppError> { + require_app_permission(&user)?; + require_app_agent_access(&user, &state, &id).await?; + let reply = request_agent_task( + &state, + &id, + Some(user.id), + "app.manage_custom", + params.clone(), + ) + .await?; + record_action( + &state, + &id, + None, + Some(user.id), + "app.manage_custom", + if reply.success { "success" } else { "failed" }, + Some(¶ms), + Some(&reply.data), + reply.error.as_deref(), + ) + .await?; + task::audit( + &state, + Some(user.id), + Some(&id), + "app.manage_custom", + params.get("name").and_then(Value::as_str), + Some(summarize(¶ms)), + reply.success, + reply.error.as_deref(), + ) + .await; + if !reply.success { + return Err(AppError::BadRequest( + reply.error.unwrap_or_else(|| "纳管应用失败".into()), + )); + } + if let Some(app) = reply.data.get("application") { + persist_app_value(&state, &id, app).await?; + } + Ok(Json(ApiResponse::ok(reply.data))) +} + +async fn create_systemd_service( + user: AuthUser, + State(state): State, + Path(id): Path, + Json(params): Json, +) -> Result>, AppError> { + require_app_permission(&user)?; + require_app_agent_access(&user, &state, &id).await?; + let reply = request_agent_task( + &state, + &id, + Some(user.id), + "app.create_systemd_service", + params.clone(), + ) + .await?; + record_action( + &state, + &id, + None, + Some(user.id), + "app.create_systemd_service", + if reply.success { "success" } else { "failed" }, + Some(¶ms), + Some(&reply.data), + reply.error.as_deref(), + ) + .await?; + task::audit( + &state, + Some(user.id), + Some(&id), + "app.create_systemd_service", + params.get("name").and_then(Value::as_str), + Some(summarize(¶ms)), + reply.success, + reply.error.as_deref(), + ) + .await; + if !reply.success { + return Err(AppError::BadRequest( + reply + .error + .unwrap_or_else(|| "创建 systemd 服务失败".into()), + )); + } + if let Some(app) = reply.data.get("application") { + persist_app_value(&state, &id, app).await?; + } + Ok(Json(ApiResponse::ok(reply.data))) +} + +async fn run_app_action( + user: &AuthUser, + state: &AppState, + agent_id: &str, + app_id: &str, + action: &str, + dangerous: bool, + extra_params: Value, +) -> Result>, AppError> { + require_app_permission(user)?; + require_app_agent_access(user, state, agent_id).await?; + let app = load_app_json(state, agent_id, app_id).await?; + if dangerous + && app + .get("is_system") + .and_then(Value::as_bool) + .unwrap_or(false) + { + task::audit( + state, + Some(user.id), + Some(agent_id), + action, + Some(app_id), + Some("已阻止系统应用操作".into()), + false, + Some("系统应用默认只读"), + ) + .await; + return Err(AppError::Forbidden); + } + let params = json!({ "id": app_id, "application": app, "params": extra_params }); + let reply = request_agent_task(state, agent_id, Some(user.id), action, params.clone()).await?; + record_action( + state, + agent_id, + Some(app_id), + Some(user.id), + action, + if reply.success { "success" } else { "failed" }, + Some(¶ms), + Some(&reply.data), + reply.error.as_deref(), + ) + .await?; + if action == "app.health" { + sync_app_health_alert( + state, + agent_id, + app_id, + params.get("application").unwrap_or(&Value::Null), + reply.success + && reply + .data + .get("ok") + .and_then(Value::as_bool) + .unwrap_or(false), + Some(&reply.data), + reply.error.as_deref(), + ) + .await?; + } + task::audit( + state, + Some(user.id), + Some(agent_id), + action, + Some(app_id), + Some(summarize(¶ms)), + reply.success, + reply.error.as_deref(), + ) + .await; + if !reply.success { + return Err(AppError::BadRequest( + reply.error.unwrap_or_else(|| "应用操作失败".into()), + )); + } + if matches!(action, "app.start" | "app.stop" | "app.restart") { + let _ = refresh_one_status(state, agent_id, app_id).await; + } + Ok(Json(ApiResponse::ok(reply.data))) +} + +fn require_app_permission(user: &AuthUser) -> Result<(), AppError> { + if user.can("apps") { + Ok(()) + } else { + Err(AppError::Forbidden) + } +} + +async fn require_app_agent_access( + user: &AuthUser, + state: &AppState, + agent_id: &str, +) -> Result<(), AppError> { + if user.role == "admin" { + return Ok(()); + } + let allowed: Option = + sqlx::query_scalar("SELECT 1 FROM agent_access WHERE user_id = ? AND agent_id = ?") + .bind(user.id) + .bind(agent_id) + .fetch_optional(&state.pool) + .await?; + if allowed.is_some() { + Ok(()) + } else { + Err(AppError::Forbidden) + } +} + +async fn refresh_one_status( + state: &AppState, + agent_id: &str, + app_id: &str, +) -> Result<(), AppError> { + let reply = request_agent_task(state, agent_id, None, "app.discover", json!({})).await?; + if reply.success { + persist_discovery(state, agent_id, &reply.data).await?; + } else { + tracing::debug!( + agent_id, + app_id, + error = reply.error.as_deref().unwrap_or("未知错误"), + "应用操作后刷新状态失败" + ); + } + Ok(()) +} + +pub(crate) async fn persist_discovery( + state: &AppState, + agent_id: &str, + data: &Value, +) -> Result<(), AppError> { + sqlx::query("DELETE FROM applications WHERE agent_id = ? AND is_lightops_managed = 0") + .bind(agent_id) + .execute(&state.pool) + .await?; + sqlx::query("DELETE FROM application_relations WHERE agent_id = ?") + .bind(agent_id) + .execute(&state.pool) + .await?; + for app in data + .get("applications") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default() + { + persist_app_value(state, agent_id, &app).await?; + } + for relation in data + .get("relations") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default() + { + persist_relation_value(state, agent_id, &relation).await?; + } + Ok(()) +} + +async fn persist_app_value( + state: &AppState, + agent_id: &str, + value: &Value, +) -> Result<(), AppError> { + let app: Application = serde_json::from_value(value.clone()) + .map_err(|err| AppError::BadRequest(format!("应用数据无效:{err}")))?; + let now = Utc::now().to_rfc3339(); + sqlx::query( + "INSERT INTO applications ( + id, agent_id, name, display_name, description, app_type, provider, status, version, install_path, work_dir, + config_paths_json, log_paths_json, data_paths_json, ports_json, domains_json, service_name, container_id, + compose_project, package_name, nginx_site, run_user, is_system, is_managed, is_lightops_managed, metadata_json, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + agent_id=excluded.agent_id, name=excluded.name, display_name=excluded.display_name, description=excluded.description, + app_type=excluded.app_type, provider=excluded.provider, status=excluded.status, version=excluded.version, + install_path=excluded.install_path, work_dir=excluded.work_dir, config_paths_json=excluded.config_paths_json, + log_paths_json=excluded.log_paths_json, data_paths_json=excluded.data_paths_json, ports_json=excluded.ports_json, + domains_json=excluded.domains_json, service_name=excluded.service_name, container_id=excluded.container_id, + compose_project=excluded.compose_project, package_name=excluded.package_name, nginx_site=excluded.nginx_site, + run_user=excluded.run_user, is_system=excluded.is_system, is_managed=excluded.is_managed, + is_lightops_managed=excluded.is_lightops_managed, metadata_json=excluded.metadata_json, updated_at=excluded.updated_at", + ) + .bind(app.id) + .bind(agent_id) + .bind(app.name) + .bind(app.display_name) + .bind(app.description) + .bind(enum_name(&app.app_type)) + .bind(enum_name(&app.provider)) + .bind(enum_name(&app.status)) + .bind(app.version) + .bind(app.install_path) + .bind(app.work_dir) + .bind(json!(app.config_paths).to_string()) + .bind(json!(app.log_paths).to_string()) + .bind(json!(app.data_paths).to_string()) + .bind(json!(app.ports).to_string()) + .bind(json!(app.domains).to_string()) + .bind(app.service_name) + .bind(app.container_id) + .bind(app.compose_project) + .bind(app.package_name) + .bind(app.nginx_site) + .bind(app.run_user) + .bind(if app.is_system { 1 } else { 0 }) + .bind(if app.is_managed { 1 } else { 0 }) + .bind(if app.is_lightops_managed { 1 } else { 0 }) + .bind(app.metadata.to_string()) + .bind(app.created_at.unwrap_or_else(|| now.clone())) + .bind(app.updated_at.unwrap_or(now)) + .execute(&state.pool) + .await?; + Ok(()) +} + +async fn persist_relation_value( + state: &AppState, + agent_id: &str, + value: &Value, +) -> Result<(), AppError> { + let rel: ApplicationRelation = serde_json::from_value(value.clone()) + .map_err(|err| AppError::BadRequest(format!("应用关系数据无效:{err}")))?; + sqlx::query("INSERT OR REPLACE INTO application_relations (id, agent_id, app_id, relation_type, target_id, target_name, metadata_json, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)") + .bind(rel.id) + .bind(agent_id) + .bind(rel.app_id) + .bind(rel.relation_type) + .bind(rel.target_id) + .bind(rel.target_name) + .bind(rel.metadata.to_string()) + .bind(Utc::now().to_rfc3339()) + .execute(&state.pool) + .await?; + Ok(()) +} + +async fn load_app_json(state: &AppState, agent_id: &str, app_id: &str) -> Result { + let row = sqlx::query("SELECT * FROM applications WHERE agent_id = ? AND id = ?") + .bind(agent_id) + .bind(app_id) + .fetch_optional(&state.pool) + .await? + .ok_or(AppError::NotFound)?; + Ok(row_to_app_json(row)) +} + +async fn load_relations_json( + state: &AppState, + agent_id: &str, + app_id: &str, +) -> Result, AppError> { + Ok(sqlx::query("SELECT * FROM application_relations WHERE agent_id = ? AND app_id = ? ORDER BY created_at DESC") + .bind(agent_id) + .bind(app_id) + .fetch_all(&state.pool) + .await? + .into_iter() + .map(|r| json!({ + "id": r.get::("id"), + "agent_id": r.get::("agent_id"), + "app_id": r.get::("app_id"), + "relation_type": r.get::("relation_type"), + "target_id": r.get::, _>("target_id"), + "target_name": r.get::, _>("target_name"), + "metadata": parse_json_obj(r.get::("metadata_json")), + })) + .collect()) +} + +async fn load_recent_actions( + state: &AppState, + agent_id: &str, + app_id: &str, +) -> Result, AppError> { + Ok(sqlx::query("SELECT * FROM application_actions WHERE agent_id = ? AND app_id = ? ORDER BY created_at DESC LIMIT 20") + .bind(agent_id) + .bind(app_id) + .fetch_all(&state.pool) + .await? + .into_iter() + .map(|r| json!({ + "id": r.get::("id"), + "action": r.get::("action"), + "status": r.get::("status"), + "error": r.get::, _>("error"), + "created_at": r.get::("created_at"), + "finished_at": r.get::, _>("finished_at"), + })) + .collect()) +} + +async fn load_latest_health( + state: &AppState, + agent_id: &str, + app_id: &str, +) -> Result, AppError> { + let row = sqlx::query( + "SELECT status, result_json, error, created_at FROM application_actions + WHERE agent_id = ? AND app_id = ? AND action = 'app.health' + ORDER BY created_at DESC LIMIT 1", + ) + .bind(agent_id) + .bind(app_id) + .fetch_optional(&state.pool) + .await?; + Ok(row.map(|r| { + json!({ + "status": r.get::("status"), + "result": r.get::, _>("result_json").and_then(|raw| serde_json::from_str::(&raw).ok()), + "error": r.get::, _>("error"), + "created_at": r.get::("created_at"), + }) + })) +} + +async fn attach_latest_health(state: &AppState, apps: Vec) -> Result, AppError> { + let mut out = Vec::with_capacity(apps.len()); + for mut app in apps { + let agent_id = app + .get("agent_id") + .and_then(Value::as_str) + .map(ToString::to_string); + let app_id = app + .get("id") + .and_then(Value::as_str) + .map(ToString::to_string); + if let (Some(agent_id), Some(app_id)) = (agent_id, app_id) { + let latest_health = load_latest_health(state, &agent_id, &app_id).await?; + if let Some(obj) = app.as_object_mut() { + obj.insert( + "latest_health".to_string(), + latest_health.unwrap_or(Value::Null), + ); + } + } + out.push(app); + } + Ok(out) +} + +async fn sync_app_health_alert( + state: &AppState, + agent_id: &str, + app_id: &str, + app: &Value, + ok: bool, + result: Option<&Value>, + error: Option<&str>, +) -> Result<(), AppError> { + if !setting_bool(state, "alerts.enabled", true).await + || !setting_bool(state, "apps.health_alert_enabled", true).await + { + return Ok(()); + } + let now = Utc::now().to_rfc3339(); + let rule_id = format!("builtin-app-health:{agent_id}:{app_id}"); + let app_name = app + .get("display_name") + .and_then(Value::as_str) + .or_else(|| app.get("name").and_then(Value::as_str)) + .unwrap_or(app_id); + sqlx::query( + "INSERT OR IGNORE INTO alert_rules(id, name, metric, operator, threshold, severity, agent_id, enabled, created_at, updated_at) + VALUES(?, ?, 'app_health', '<', 1, 'critical', ?, 1, ?, ?)", + ) + .bind(&rule_id) + .bind(format!("应用健康异常:{app_name}")) + .bind(agent_id) + .bind(&now) + .bind(&now) + .execute(&state.pool) + .await?; + + if ok { + sqlx::query( + "UPDATE alert_events SET status = 'resolved', resolved_at = ?, last_seen_at = ? + WHERE rule_id = ? AND agent_id = ? AND status = 'open'", + ) + .bind(&now) + .bind(&now) + .bind(&rule_id) + .bind(agent_id) + .execute(&state.pool) + .await?; + return Ok(()); + } + + let rule_row = sqlx::query("SELECT enabled, silence_until FROM alert_rules WHERE id = ?") + .bind(&rule_id) + .fetch_optional(&state.pool) + .await?; + if let Some(row) = rule_row { + let enabled: i64 = row.get("enabled"); + let silence_until: Option = row.try_get("silence_until").ok().flatten(); + if enabled != 1 + || silence_until + .as_deref() + .is_some_and(|value| value > now.as_str()) + { + return Ok(()); + } + } + + let target = result + .and_then(|value| value.get("target")) + .and_then(Value::as_str) + .unwrap_or("-"); + let detail = result + .and_then(|value| value.get("error")) + .and_then(Value::as_str) + .or(error) + .unwrap_or("健康检查失败"); + let message = format!("应用健康异常:{app_name},目标:{target},原因:{detail}"); + let existing = sqlx::query_scalar::<_, String>( + "SELECT id FROM alert_events WHERE rule_id = ? AND agent_id = ? AND status = 'open' LIMIT 1", + ) + .bind(&rule_id) + .bind(agent_id) + .fetch_optional(&state.pool) + .await?; + + if let Some(event_id) = existing { + sqlx::query( + "UPDATE alert_events SET value = 0, threshold = 1, severity = 'critical', message = ?, last_seen_at = ? WHERE id = ?", + ) + .bind(message) + .bind(&now) + .bind(event_id) + .execute(&state.pool) + .await?; + } else { + sqlx::query( + "INSERT INTO alert_events(id, rule_id, agent_id, metric, value, threshold, severity, status, message, first_seen_at, last_seen_at) + VALUES(?, ?, ?, 'app_health', 0, 1, 'critical', 'open', ?, ?, ?)", + ) + .bind(Uuid::new_v4().to_string()) + .bind(&rule_id) + .bind(agent_id) + .bind(message) + .bind(&now) + .bind(&now) + .execute(&state.pool) + .await?; + } + Ok(()) +} + +async fn record_action( + state: &AppState, + agent_id: &str, + app_id: Option<&str>, + user_id: Option, + action: &str, + status: &str, + params: Option<&Value>, + result: Option<&Value>, + error: Option<&str>, +) -> Result<(), AppError> { + let now = Utc::now().to_rfc3339(); + sqlx::query("INSERT INTO application_actions (id, agent_id, app_id, user_id, action, status, params_json, result_json, error, created_at, finished_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)") + .bind(Uuid::new_v4().to_string()) + .bind(agent_id) + .bind(app_id) + .bind(user_id.map(|id| id.to_string())) + .bind(action) + .bind(status) + .bind(params.map(Value::to_string)) + .bind(result.map(Value::to_string)) + .bind(error) + .bind(&now) + .bind(&now) + .execute(&state.pool) + .await?; + Ok(()) +} + +fn row_to_app_json(row: sqlx::sqlite::SqliteRow) -> Value { + json!({ + "id": row.get::("id"), + "agent_id": row.get::("agent_id"), + "name": row.get::("name"), + "display_name": row.get::("display_name"), + "description": row.get::, _>("description"), + "app_type": row.get::("app_type"), + "provider": row.get::("provider"), + "status": row.get::("status"), + "version": row.get::, _>("version"), + "install_path": row.get::, _>("install_path"), + "work_dir": row.get::, _>("work_dir"), + "config_paths": parse_json_arr(row.get::("config_paths_json")), + "log_paths": parse_json_arr(row.get::("log_paths_json")), + "data_paths": parse_json_arr(row.get::("data_paths_json")), + "ports": parse_json_arr(row.get::("ports_json")), + "domains": parse_json_arr(row.get::("domains_json")), + "service_name": row.get::, _>("service_name"), + "container_id": row.get::, _>("container_id"), + "compose_project": row.get::, _>("compose_project"), + "package_name": row.get::, _>("package_name"), + "nginx_site": row.get::, _>("nginx_site"), + "run_user": row.get::, _>("run_user"), + "is_system": row.get::("is_system") == 1, + "is_managed": row.get::("is_managed") == 1, + "is_lightops_managed": row.get::("is_lightops_managed") == 1, + "metadata": parse_json_obj(row.get::("metadata_json")), + "created_at": row.get::("created_at"), + "updated_at": row.get::("updated_at"), + }) +} + +fn filter_app(app: &Value, q: &AppQuery) -> bool { + if q.agent_id + .as_ref() + .is_some_and(|v| app.get("agent_id").and_then(Value::as_str) != Some(v)) + { + return false; + } + if q.status + .as_ref() + .is_some_and(|v| app.get("status").and_then(Value::as_str) != Some(v)) + { + return false; + } + if q.app_type + .as_ref() + .is_some_and(|v| app.get("app_type").and_then(Value::as_str) != Some(v)) + { + return false; + } + if q.provider + .as_ref() + .is_some_and(|v| app.get("provider").and_then(Value::as_str) != Some(v)) + { + return false; + } + if let Some(search) = &q.q { + let needle = search.to_lowercase(); + let haystack = format!( + "{} {}", + app.get("name").and_then(Value::as_str).unwrap_or_default(), + app.get("display_name") + .and_then(Value::as_str) + .unwrap_or_default() + ) + .to_lowercase(); + return haystack.contains(&needle); + } + true +} + +fn available_actions(app: &Value) -> Vec<&'static str> { + if app + .get("is_system") + .and_then(Value::as_bool) + .unwrap_or(false) + { + return vec!["logs", "health"]; + } + match app + .get("provider") + .and_then(Value::as_str) + .unwrap_or_default() + { + "Systemd" | "Docker" | "LightOpsManaged" => { + vec!["start", "stop", "restart", "logs", "health", "backup"] + } + "NginxSite" => vec!["reload", "logs", "health", "backup"], + _ => vec!["logs", "health", "backup"], + } +} + +fn parse_json_arr(value: String) -> Value { + serde_json::from_str(&value).unwrap_or_else(|_| json!([])) +} + +fn parse_json_obj(value: String) -> Value { + serde_json::from_str(&value).unwrap_or_else(|_| json!({})) +} + +fn enum_name(value: &T) -> String { + serde_json::to_value(value) + .ok() + .and_then(|v| v.as_str().map(ToString::to_string)) + .unwrap_or_else(|| "Unknown".to_string()) +} + +fn summarize(value: &Value) -> String { + let mut s = value.to_string(); + if s.len() > 500 { + s.truncate(500); + } + s +} + +async fn setting_i64(state: &AppState, key: &str, default: i64) -> i64 { + sqlx::query_scalar::<_, String>("SELECT value FROM settings WHERE key = ?") + .bind(key) + .fetch_optional(&state.pool) + .await + .ok() + .flatten() + .and_then(|value| value.parse::().ok()) + .unwrap_or(default) +} + +async fn setting_bool(state: &AppState, key: &str, default: bool) -> bool { + sqlx::query_scalar::<_, String>("SELECT value FROM settings WHERE key = ?") + .bind(key) + .fetch_optional(&state.pool) + .await + .ok() + .flatten() + .map(|value| value != "false") + .unwrap_or(default) +} diff --git a/crates/lightops-server/src/auth.rs b/crates/lightops-server/src/auth.rs new file mode 100644 index 0000000..5c40611 --- /dev/null +++ b/crates/lightops-server/src/auth.rs @@ -0,0 +1,146 @@ +use crate::{error::AppError, state::AppState}; +use argon2::{ + password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString}, + Argon2, +}; +use async_trait::async_trait; +use axum::{ + extract::FromRequestParts, + http::{header::AUTHORIZATION, request::Parts}, +}; +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; +use chrono::{Duration, Utc}; +use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation}; +use rand::{rngs::OsRng, RngCore}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +#[derive(Debug, Clone)] +pub struct AuthUser { + pub id: i64, + pub username: String, + pub role: String, + pub permissions: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +struct Claims { + sub: i64, + username: String, + role: String, + exp: usize, +} + +#[async_trait] +impl FromRequestParts for AuthUser { + type Rejection = AppError; + + async fn from_request_parts( + parts: &mut Parts, + state: &AppState, + ) -> Result { + let header_token = parts + .headers + .get(AUTHORIZATION) + .and_then(|v| v.to_str().ok()) + .and_then(|h| h.strip_prefix("Bearer ")) + .map(ToString::to_string); + let query_token = parts.uri.query().and_then(|query| { + query.split('&').find_map(|part| { + let (k, v) = part.split_once('=')?; + (k == "token").then(|| v.to_string()) + }) + }); + let token = header_token.or(query_token).ok_or(AppError::Unauthorized)?; + let data = decode::( + &token, + &DecodingKey::from_secret(state.cfg.jwt_secret.as_bytes()), + &Validation::default(), + ) + .map_err(|_| AppError::Unauthorized)?; + let permissions = load_permissions(state, data.claims.sub, &data.claims.role).await?; + Ok(AuthUser { + id: data.claims.sub, + username: data.claims.username, + role: data.claims.role, + permissions, + }) + } +} + +async fn load_permissions( + state: &AppState, + user_id: i64, + role: &str, +) -> Result, AppError> { + if role == "admin" { + return Ok(vec!["*".into()]); + } + let rows = sqlx::query_scalar::<_, String>( + "SELECT permission FROM user_permissions WHERE user_id = ? ORDER BY permission", + ) + .bind(user_id) + .fetch_all(&state.pool) + .await + .map_err(|_| AppError::Unauthorized)?; + Ok(rows) +} + +impl AuthUser { + pub fn can(&self, permission: &str) -> bool { + self.role == "admin" + || self.permissions.iter().any(|item| { + item == "*" + || item == permission + || permission + .strip_prefix(&format!("{}.", item.trim_end_matches('*'))) + .is_some() + || permission + .strip_prefix(item.trim_end_matches('*')) + .is_some_and(|_| item.ends_with('*')) + }) + } +} + +pub fn hash_password(password: &str) -> anyhow::Result { + let salt = SaltString::generate(&mut OsRng); + Ok(Argon2::default() + .hash_password(password.as_bytes(), &salt) + .map_err(|err| anyhow::anyhow!("密码哈希失败: {err}"))? + .to_string()) +} + +pub fn verify_password(password: &str, hash: &str) -> bool { + let Ok(parsed) = PasswordHash::new(hash) else { + return false; + }; + Argon2::default() + .verify_password(password.as_bytes(), &parsed) + .is_ok() +} + +pub fn make_jwt(user: &AuthUser, secret: &str) -> anyhow::Result { + let claims = Claims { + sub: user.id, + username: user.username.clone(), + role: user.role.clone(), + exp: (Utc::now() + Duration::hours(12)).timestamp() as usize, + }; + Ok(encode( + &Header::default(), + &claims, + &EncodingKey::from_secret(secret.as_bytes()), + )?) +} + +pub fn random_token() -> String { + let mut bytes = [0u8; 32]; + OsRng.fill_bytes(&mut bytes); + URL_SAFE_NO_PAD.encode(bytes) +} + +pub fn token_hash(token: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(token.as_bytes()); + URL_SAFE_NO_PAD.encode(hasher.finalize()) +} diff --git a/crates/lightops-server/src/config.rs b/crates/lightops-server/src/config.rs new file mode 100644 index 0000000..97fb849 --- /dev/null +++ b/crates/lightops-server/src/config.rs @@ -0,0 +1,38 @@ +use anyhow::Result; +use serde::Deserialize; +use std::{fs, path::Path}; + +#[derive(Debug, Clone, Deserialize)] +pub struct ServerConfig { + pub bind: String, + pub database_url: String, + pub jwt_secret: String, + pub public_url: String, + pub static_dir: String, + pub registration_token_ttl_minutes: i64, + pub task_timeout_secs: u64, +} + +impl Default for ServerConfig { + fn default() -> Self { + Self { + bind: "0.0.0.0:8080".to_string(), + database_url: "sqlite://lightops.db?mode=rwc".to_string(), + jwt_secret: "change-me-in-production".to_string(), + public_url: "http://127.0.0.1:8080".to_string(), + static_dir: "web/dist".to_string(), + registration_token_ttl_minutes: 30, + task_timeout_secs: 20, + } + } +} + +impl ServerConfig { + pub fn load(path: &str) -> Result { + if Path::new(path).exists() { + Ok(toml::from_str(&fs::read_to_string(path)?)?) + } else { + Ok(Self::default()) + } + } +} diff --git a/crates/lightops-server/src/db.rs b/crates/lightops-server/src/db.rs new file mode 100644 index 0000000..f98eb1c --- /dev/null +++ b/crates/lightops-server/src/db.rs @@ -0,0 +1,7 @@ +use anyhow::Result; +use sqlx::SqlitePool; + +pub async fn migrate(pool: &SqlitePool) -> Result<()> { + sqlx::migrate!("../../migrations").run(pool).await?; + Ok(()) +} diff --git a/crates/lightops-server/src/error.rs b/crates/lightops-server/src/error.rs new file mode 100644 index 0000000..cec8cf7 --- /dev/null +++ b/crates/lightops-server/src/error.rs @@ -0,0 +1,51 @@ +use axum::{http::StatusCode, response::IntoResponse, Json}; +use lightops_common::api::ApiResponse; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum AppError { + #[error("未登录或登录已失效")] + Unauthorized, + #[error("无权执行此操作")] + Forbidden, + #[error("资源不存在")] + NotFound, + #[error("请求参数错误:{0}")] + BadRequest(String), + #[error("Agent 不在线")] + AgentOffline, + #[error("操作超时")] + Timeout, + #[error("服务器内部错误")] + Internal, +} + +impl IntoResponse for AppError { + fn into_response(self) -> axum::response::Response { + let status = match self { + AppError::Unauthorized => StatusCode::UNAUTHORIZED, + AppError::Forbidden => StatusCode::FORBIDDEN, + AppError::NotFound => StatusCode::NOT_FOUND, + AppError::BadRequest(_) => StatusCode::BAD_REQUEST, + AppError::AgentOffline => StatusCode::SERVICE_UNAVAILABLE, + AppError::Timeout => StatusCode::GATEWAY_TIMEOUT, + AppError::Internal => StatusCode::INTERNAL_SERVER_ERROR, + }; + let message = self.to_string(); + (status, Json(ApiResponse::err(message))).into_response() + } +} + +impl From for AppError { + fn from(err: sqlx::Error) -> Self { + tracing::error!(?err, "database error"); + AppError::Internal + } +} + +impl From for AppError { + fn from(err: anyhow::Error) -> Self { + tracing::error!(?err, "application error"); + AppError::Internal + } +} diff --git a/crates/lightops-server/src/handlers.rs b/crates/lightops-server/src/handlers.rs new file mode 100644 index 0000000..45bbcf1 --- /dev/null +++ b/crates/lightops-server/src/handlers.rs @@ -0,0 +1,4048 @@ +use crate::{ + auth::{self, AuthUser}, + error::AppError, + state::{AgentHandle, AppState, TaskReply}, + task, +}; +use axum::{ + extract::{ + ws::{Message, WebSocket, WebSocketUpgrade}, + ConnectInfo, Path, Query, State, + }, + http::{header, HeaderMap, HeaderValue, StatusCode}, + response::IntoResponse, + routing::{delete, get, post, put}, + Json, Router, +}; +use base64::Engine; +use chrono::{Duration, Utc}; +use futures_util::{SinkExt, StreamExt}; +use lightops_common::{ + api::{ApiResponse, PageQuery}, + protocol::{AgentMessage, ServerMessage}, +}; +use serde::Deserialize; +use serde_json::{json, Value}; +use sqlx::Row; +use std::{ + fs, + net::SocketAddr, + path::{Path as FsPath, PathBuf}, + process::Stdio, +}; +use tokio::{process::Command, sync::mpsc, time::Duration as TokioDuration}; +use uuid::Uuid; + +const AGENT_PING_INTERVAL_SECS: u64 = 30; +const AGENT_READ_TIMEOUT_SECS: u64 = 100; + +pub fn router() -> Router { + Router::new() + .route("/auth/init", post(auth_init)) + .route("/auth/login", post(auth_login)) + .route("/auth/logout", post(auth_logout)) + .route("/auth/me", get(auth_me)) + .route("/users", get(list_users).post(create_user)) + .route("/users/:user_id", put(update_user).delete(delete_user)) + .route("/users/:user_id/password", post(reset_user_password)) + .route("/agent-tokens", post(create_agent_token)) + .route("/agents", get(list_agents)) + .route("/agents/:id", get(get_agent).delete(delete_agent)) + .route("/agents/:id/metrics", get(get_agent_metrics)) + .route("/agent/ws", get(agent_ws)) + .route("/agents/:id/files/roots", get(file_roots)) + .route("/agents/:id/files", get(file_list)) + .route("/agents/:id/files/search", post(file_search)) + .route("/agents/:id/files/read", post(file_read)) + .route("/agents/:id/files/write", post(file_write)) + .route("/agents/:id/files/mkdir", post(file_mkdir)) + .route("/agents/:id/files/delete", post(file_delete)) + .route("/agents/:id/files/rename", post(file_rename)) + .route("/agents/:id/files/upload", post(file_upload)) + .route("/agents/:id/files/upload-chunk", post(file_upload_chunk)) + .route("/agents/:id/files/download", get(file_download)) + .route("/agents/:id/files/download-chunk", get(file_download_chunk)) + .route("/agents/:id/files/chmod", post(file_chmod)) + .route("/agents/:id/terminal", get(terminal_ws)) + .route("/agents/:id/logs/tail", post(log_tail)) + .route("/agents/:id/logs/stream", get(log_stream_ws)) + .route("/agents/:id/services", get(service_list)) + .route("/agents/:id/services/:name/start", post(service_start)) + .route("/agents/:id/services/:name/stop", post(service_stop)) + .route("/agents/:id/services/:name/restart", post(service_restart)) + .route("/agents/:id/services/:name/enable", post(service_enable)) + .route("/agents/:id/services/:name/disable", post(service_disable)) + .route("/agents/:id/nginx/status", get(nginx_status)) + .route( + "/agents/:id/nginx/sites", + get(nginx_sites).post(nginx_create_site), + ) + .route( + "/agents/:id/nginx/sites/:name", + get(nginx_get_site).put(nginx_put_site), + ) + .route( + "/agents/:id/nginx/sites/:name/backups", + get(nginx_site_backups), + ) + .route( + "/agents/:id/nginx/sites/:name/backups/:backup/restore", + post(nginx_site_restore_backup), + ) + .route( + "/agents/:id/nginx/sites/:name/enable", + post(nginx_enable_site), + ) + .route( + "/agents/:id/nginx/sites/:name/disable", + post(nginx_disable_site), + ) + .route("/agents/:id/nginx/test", post(nginx_test)) + .route("/agents/:id/nginx/reload", post(nginx_reload)) + .route("/agents/:id/nginx/ssl/status", get(nginx_ssl_status)) + .route("/agents/:id/nginx/ssl/issue", post(nginx_ssl_issue)) + .route("/agents/:id/nginx/ssl/renew", post(nginx_ssl_renew)) + .route( + "/agents/:id/nginx/ssl/auto-renew", + post(nginx_ssl_auto_renew), + ) + .route("/agents/:id/docker/status", get(docker_status)) + .route("/agents/:id/docker/containers", get(docker_containers)) + .route( + "/agents/:id/docker/containers/:container_id/inspect", + get(docker_container_inspect), + ) + .route( + "/agents/:id/docker/containers/:container_id/stats", + get(docker_container_stats), + ) + .route( + "/agents/:id/docker/containers/run", + post(docker_container_run), + ) + .route( + "/agents/:id/docker/containers/:container_id/start", + post(docker_container_start), + ) + .route( + "/agents/:id/docker/containers/:container_id/stop", + post(docker_container_stop), + ) + .route( + "/agents/:id/docker/containers/:container_id/restart", + post(docker_container_restart), + ) + .route( + "/agents/:id/docker/containers/:container_id", + delete(docker_container_delete), + ) + .route( + "/agents/:id/docker/containers/:container_id/logs", + get(docker_container_logs), + ) + .route( + "/agents/:id/docker/containers/:container_id/exec", + get(docker_container_exec_ws), + ) + .route("/agents/:id/docker/images", get(docker_images)) + .route("/agents/:id/docker/images/pull", post(docker_image_pull)) + .route( + "/agents/:id/docker/images/:image_id", + delete(docker_image_delete), + ) + .route("/agents/:id/docker/volumes", get(docker_volumes)) + .route( + "/agents/:id/docker/volumes/:volume_name", + delete(docker_volume_delete), + ) + .route( + "/agents/:id/docker/compose/projects", + get(docker_compose_projects), + ) + .route( + "/agents/:id/docker/compose/projects/:project/start", + post(docker_compose_start), + ) + .route( + "/agents/:id/docker/compose/projects/:project/stop", + post(docker_compose_stop), + ) + .route( + "/agents/:id/docker/compose/projects/:project/restart", + post(docker_compose_restart), + ) + .route( + "/agents/:id/docker/compose/projects/:project/logs", + get(docker_compose_logs), + ) + .route( + "/agents/:id/docker/compose/deploy", + post(docker_compose_deploy), + ) + .route("/agents/:id/system/snapshot", get(system_snapshot)) + .route("/tasks", get(tasks)) + .route("/tasks/summary", get(tasks_summary)) + .route("/tasks/:task_id", get(task_detail)) + .route("/tasks/:task_id/events", get(task_events)) + .route("/tasks/:task_id/retry", post(task_retry)) + .route("/tasks/:task_id/cancel", post(task_cancel)) + .route("/agents/:id/tasks", get(agent_tasks)) + .route( + "/system/backups", + get(system_backups).post(create_system_backup), + ) + .route("/system/update/check", get(system_update_check)) + .route("/system/update/run", post(system_update_run)) + .route( + "/system/backups/:name/download", + get(download_system_backup), + ) + .route("/system/backups/:name/restore", post(restore_system_backup)) + .route("/system/backups/:name", delete(delete_system_backup)) + .route("/settings", get(get_settings).put(update_settings)) + .route("/notifications/webhook/test", post(test_webhook)) + .route("/notification-deliveries", get(notification_deliveries)) + .route("/permissions", get(permission_catalog)) + .route("/alert-rules", get(alert_rules).post(create_alert_rule)) + .route( + "/alert-rules/:rule_id", + put(update_alert_rule).delete(delete_alert_rule), + ) + .route("/alert-events", get(alert_events)) + .route("/alert-events/:event_id/ack", post(ack_alert_event)) + .route("/audit-logs", get(audit_logs)) + .merge(crate::apps::router()) + .merge(crate::store::router()) +} + +#[derive(Debug, Deserialize)] +struct InitReq { + username: String, + password: String, +} + +async fn auth_init( + State(state): State, + Json(req): Json, +) -> Result>, AppError> { + let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM users") + .fetch_one(&state.pool) + .await?; + if count > 0 { + return Err(AppError::BadRequest("管理员已初始化".into())); + } + if req.username.len() < 3 || req.password.len() < 8 { + return Err(AppError::BadRequest("用户名或密码过短".into())); + } + let hash = auth::hash_password(&req.password)?; + sqlx::query("INSERT INTO users (username, password_hash, role) VALUES (?, ?, 'admin')") + .bind(&req.username) + .bind(hash) + .execute(&state.pool) + .await?; + let user_id: i64 = sqlx::query_scalar("SELECT id FROM users WHERE username = ?") + .bind(&req.username) + .fetch_one(&state.pool) + .await?; + let user = AuthUser { + id: user_id, + username: req.username, + role: "admin".to_string(), + permissions: vec!["*".into()], + }; + let token = auth::make_jwt(&user, &state.cfg.jwt_secret)?; + task::audit( + &state, + Some(user.id), + None, + "auth.init", + None, + None, + true, + None, + ) + .await; + Ok(Json(ApiResponse::ok( + json!({ "token": token, "user": user_json(&user) }), + ))) +} + +#[derive(Debug, Deserialize)] +struct LoginReq { + username: String, + password: String, +} + +async fn auth_login( + State(state): State, + Json(req): Json, +) -> Result>, AppError> { + let row = sqlx::query("SELECT id, username, password_hash, role FROM users WHERE username = ?") + .bind(&req.username) + .fetch_optional(&state.pool) + .await? + .ok_or(AppError::Unauthorized)?; + let hash: String = row.get("password_hash"); + if !auth::verify_password(&req.password, &hash) { + task::audit( + &state, + None, + None, + "auth.login", + Some(&req.username), + None, + false, + Some("账号或密码错误"), + ) + .await; + return Err(AppError::Unauthorized); + } + let user_id: i64 = row.get("id"); + let role: String = row.get("role"); + let permissions = if role == "admin" { + vec!["*".into()] + } else { + sqlx::query_scalar::<_, String>( + "SELECT permission FROM user_permissions WHERE user_id = ? ORDER BY permission", + ) + .bind(user_id) + .fetch_all(&state.pool) + .await? + }; + let user = AuthUser { + id: user_id, + username: row.get("username"), + role, + permissions, + }; + let token = auth::make_jwt(&user, &state.cfg.jwt_secret)?; + task::audit( + &state, + Some(user.id), + None, + "auth.login", + None, + None, + true, + None, + ) + .await; + Ok(Json(ApiResponse::ok( + json!({ "token": token, "user": user_json(&user) }), + ))) +} + +async fn auth_logout(user: AuthUser) -> Json> { + let _ = user; + Json(ApiResponse::empty()) +} + +async fn auth_me(user: AuthUser) -> Json> { + Json(ApiResponse::ok(user_json(&user))) +} + +fn user_json(user: &AuthUser) -> Value { + json!({ "id": user.id, "username": user.username, "role": user.role, "permissions": user.permissions }) +} + +#[derive(Debug, Deserialize)] +struct UserReq { + username: String, + password: Option, + role: Option, + permissions: Option>, + agent_ids: Option>, +} + +#[derive(Debug, Deserialize)] +struct PasswordReq { + password: String, +} + +async fn list_users( + user: AuthUser, + State(state): State, +) -> Result>, AppError> { + require_admin(&user)?; + let rows = + sqlx::query("SELECT id, username, role, created_at, updated_at FROM users ORDER BY id") + .fetch_all(&state.pool) + .await?; + let mut users = Vec::new(); + for row in rows { + let user_id: i64 = row.get("id"); + let permissions = sqlx::query_scalar::<_, String>( + "SELECT permission FROM user_permissions WHERE user_id = ? ORDER BY permission", + ) + .bind(user_id) + .fetch_all(&state.pool) + .await?; + let agent_ids = sqlx::query_scalar::<_, String>( + "SELECT agent_id FROM agent_access WHERE user_id = ? ORDER BY agent_id", + ) + .bind(user_id) + .fetch_all(&state.pool) + .await?; + users.push(json!({ + "id": user_id, + "username": row.get::("username"), + "role": row.get::("role"), + "permissions": permissions, + "agent_ids": agent_ids, + "created_at": row.get::("created_at"), + "updated_at": row.get::("updated_at") + })); + } + Ok(Json(ApiResponse::ok(json!(users)))) +} + +async fn create_user( + user: AuthUser, + State(state): State, + Json(req): Json, +) -> Result>, AppError> { + require_admin(&user)?; + if req.username.len() < 3 { + return Err(AppError::BadRequest("用户名至少 3 个字符".into())); + } + let password = req + .password + .as_deref() + .ok_or_else(|| AppError::BadRequest("创建用户必须设置密码".into()))?; + if password.len() < 8 { + return Err(AppError::BadRequest("密码至少 8 个字符".into())); + } + let role = normalize_role(req.role.as_deref()); + let now = Utc::now().to_rfc3339(); + let result = sqlx::query( + "INSERT INTO users (username, password_hash, role, created_at, updated_at) VALUES (?, ?, ?, ?, ?)", + ) + .bind(&req.username) + .bind(auth::hash_password(password)?) + .bind(role) + .bind(&now) + .bind(&now) + .execute(&state.pool) + .await?; + let user_id = result.last_insert_rowid(); + sync_user_grants( + &state, + user_id, + role, + req.permissions.unwrap_or_default(), + req.agent_ids.unwrap_or_default(), + ) + .await?; + task::audit( + &state, + Some(user.id), + None, + "user.create", + Some(&req.username), + None, + true, + None, + ) + .await; + Ok(Json(ApiResponse::ok(json!({ "id": user_id })))) +} + +async fn update_user( + user: AuthUser, + State(state): State, + Path(user_id): Path, + Json(req): Json, +) -> Result>, AppError> { + require_admin(&user)?; + if user_id == user.id && req.role.as_deref().is_some_and(|role| role != "admin") { + return Err(AppError::BadRequest("不能降级当前登录管理员".into())); + } + let role = normalize_role(req.role.as_deref()); + sqlx::query("UPDATE users SET username = ?, role = ?, updated_at = ? WHERE id = ?") + .bind(&req.username) + .bind(role) + .bind(Utc::now().to_rfc3339()) + .bind(user_id) + .execute(&state.pool) + .await?; + sync_user_grants( + &state, + user_id, + role, + req.permissions.unwrap_or_default(), + req.agent_ids.unwrap_or_default(), + ) + .await?; + task::audit( + &state, + Some(user.id), + None, + "user.update", + Some(&req.username), + None, + true, + None, + ) + .await; + Ok(Json(ApiResponse::ok(json!({ "id": user_id })))) +} + +async fn reset_user_password( + user: AuthUser, + State(state): State, + Path(user_id): Path, + Json(req): Json, +) -> Result>, AppError> { + require_admin(&user)?; + if req.password.len() < 8 { + return Err(AppError::BadRequest("密码至少 8 个字符".into())); + } + sqlx::query("UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ?") + .bind(auth::hash_password(&req.password)?) + .bind(Utc::now().to_rfc3339()) + .bind(user_id) + .execute(&state.pool) + .await?; + task::audit( + &state, + Some(user.id), + None, + "user.password_reset", + Some(&user_id.to_string()), + None, + true, + None, + ) + .await; + Ok(Json(ApiResponse::empty())) +} + +async fn delete_user( + user: AuthUser, + State(state): State, + Path(user_id): Path, +) -> Result>, AppError> { + require_admin(&user)?; + if user_id == user.id { + return Err(AppError::BadRequest("不能删除当前登录用户".into())); + } + sqlx::query("DELETE FROM users WHERE id = ?") + .bind(user_id) + .execute(&state.pool) + .await?; + task::audit( + &state, + Some(user.id), + None, + "user.delete", + Some(&user_id.to_string()), + None, + true, + None, + ) + .await; + Ok(Json(ApiResponse::empty())) +} + +async fn sync_user_grants( + state: &AppState, + user_id: i64, + role: &str, + permissions: Vec, + agent_ids: Vec, +) -> Result<(), AppError> { + sqlx::query("DELETE FROM user_permissions WHERE user_id = ?") + .bind(user_id) + .execute(&state.pool) + .await?; + sqlx::query("DELETE FROM agent_access WHERE user_id = ?") + .bind(user_id) + .execute(&state.pool) + .await?; + if role != "admin" { + for permission in permissions + .into_iter() + .filter(|value| !value.trim().is_empty()) + { + sqlx::query( + "INSERT OR IGNORE INTO user_permissions (user_id, permission) VALUES (?, ?)", + ) + .bind(user_id) + .bind(permission) + .execute(&state.pool) + .await?; + } + for agent_id in agent_ids + .into_iter() + .filter(|value| !value.trim().is_empty()) + { + sqlx::query("INSERT OR IGNORE INTO agent_access (user_id, agent_id) VALUES (?, ?)") + .bind(user_id) + .bind(agent_id) + .execute(&state.pool) + .await?; + } + } + Ok(()) +} + +fn normalize_role(role: Option<&str>) -> &'static str { + match role { + Some("admin") => "admin", + _ => "operator", + } +} + +#[derive(Debug, Deserialize)] +struct TokenReq { + name: Option, + ttl_minutes: Option, +} + +async fn create_agent_token( + user: AuthUser, + State(state): State, + Json(req): Json, +) -> Result>, AppError> { + require_admin(&user)?; + let token = auth::random_token(); + let token_hash = auth::token_hash(&token); + let ttl = req + .ttl_minutes + .unwrap_or(state.cfg.registration_token_ttl_minutes); + let expires_at = (Utc::now() + Duration::minutes(ttl)).to_rfc3339(); + let id = Uuid::new_v4().to_string(); + sqlx::query( + "INSERT INTO registration_tokens (id, token_hash, name, expires_at) VALUES (?, ?, ?, ?)", + ) + .bind(&id) + .bind(token_hash) + .bind(&req.name) + .bind(&expires_at) + .execute(&state.pool) + .await?; + let command = format!( + "curl -fsSL {}/install-agent.sh | sh -s -- --server {} --token {}", + state.cfg.public_url, state.cfg.public_url, token + ); + task::audit( + &state, + Some(user.id), + None, + "agent_token.create", + req.name.as_deref(), + None, + true, + None, + ) + .await; + Ok(Json(ApiResponse::ok( + json!({ "id": id, "token": token, "expires_at": expires_at, "install_command": command }), + ))) +} + +async fn list_agents( + user: AuthUser, + State(state): State, +) -> Result>, AppError> { + let rows = if user.role == "admin" { + sqlx::query( + "SELECT id, name, hostname, os, arch, version, ip, status, last_seen_at, created_at, updated_at FROM agents ORDER BY created_at DESC", + ) + .fetch_all(&state.pool) + .await? + } else { + sqlx::query( + "SELECT a.id, a.name, a.hostname, a.os, a.arch, a.version, a.ip, a.status, a.last_seen_at, a.created_at, a.updated_at + FROM agents a INNER JOIN agent_access aa ON aa.agent_id = a.id + WHERE aa.user_id = ? ORDER BY a.created_at DESC", + ) + .bind(user.id) + .fetch_all(&state.pool) + .await? + }; + let agents: Vec = rows + .into_iter() + .map(|r| { + json!({ + "id": r.get::("id"), + "name": r.get::("name"), + "hostname": r.get::("hostname"), + "os": r.get::("os"), + "arch": r.get::("arch"), + "version": r.get::("version"), + "latest_version": env!("CARGO_PKG_VERSION"), + "version_status": agent_version_status(&r.get::("version")), + "upgrade_command": format!("curl -fsSL {}/upgrade-agent.sh | sh -s -- --server {}", state.cfg.public_url, state.cfg.public_url), + "ip": r.get::, _>("ip"), + "status": r.get::("status"), + "last_seen_at": r.get::, _>("last_seen_at"), + "created_at": r.get::("created_at"), + "updated_at": r.get::("updated_at"), + }) + }) + .collect(); + Ok(Json(ApiResponse::ok(json!(agents)))) +} + +async fn get_agent( + user: AuthUser, + State(state): State, + Path(id): Path, +) -> Result>, AppError> { + require_agent_access(&user, &state, &id).await?; + let row = sqlx::query("SELECT * FROM agents WHERE id = ?") + .bind(&id) + .fetch_optional(&state.pool) + .await? + .ok_or(AppError::NotFound)?; + Ok(Json(ApiResponse::ok(json!({ + "id": row.get::("id"), + "name": row.get::("name"), + "hostname": row.get::("hostname"), + "os": row.get::("os"), + "arch": row.get::("arch"), + "version": row.get::("version"), + "latest_version": env!("CARGO_PKG_VERSION"), + "version_status": agent_version_status(&row.get::("version")), + "upgrade_command": format!("curl -fsSL {}/upgrade-agent.sh | sh -s -- --server {}", state.cfg.public_url, state.cfg.public_url), + "ip": row.get::, _>("ip"), + "status": row.get::("status"), + "capabilities": serde_json::from_str::(&row.get::("capabilities_json")).unwrap_or(json!({})), + "last_seen_at": row.get::, _>("last_seen_at"), + })))) +} + +fn agent_version_status(version: &str) -> &'static str { + if version == env!("CARGO_PKG_VERSION") { + "current" + } else { + "outdated" + } +} + +async fn delete_agent( + user: AuthUser, + State(state): State, + Path(id): Path, +) -> Result>, AppError> { + require_admin(&user)?; + state.agents.write().await.remove(&id); + sqlx::query("DELETE FROM agents WHERE id = ?") + .bind(&id) + .execute(&state.pool) + .await?; + task::audit( + &state, + Some(user.id), + Some(&id), + "agent.delete", + Some(&id), + None, + true, + None, + ) + .await; + Ok(Json(ApiResponse::empty())) +} + +async fn get_agent_metrics( + user: AuthUser, + State(state): State, + Path(id): Path, + Query(q): Query, +) -> Result>, AppError> { + require_agent_access(&user, &state, &id).await?; + let rows = sqlx::query( + "SELECT * FROM agent_metrics WHERE agent_id = ? ORDER BY created_at DESC LIMIT ? OFFSET ?", + ) + .bind(&id) + .bind(q.limit.unwrap_or(60).min(500)) + .bind(q.offset.unwrap_or(0)) + .fetch_all(&state.pool) + .await?; + let data: Vec = rows + .into_iter() + .map(|r| { + json!({ + "id": r.get::("id"), + "agent_id": r.get::("agent_id"), + "cpu_usage": r.get::("cpu_usage"), + "memory_total": r.get::("memory_total"), + "memory_used": r.get::("memory_used"), + "disk_total": r.get::("disk_total"), + "disk_used": r.get::("disk_used"), + "load_avg": r.get::("load_avg"), + "uptime": r.get::("uptime"), + "network_rx": r.get::("network_rx"), + "network_tx": r.get::("network_tx"), + "created_at": r.get::("created_at"), + }) + }) + .collect(); + Ok(Json(ApiResponse::ok(json!(data)))) +} + +async fn agent_ws( + ws: WebSocketUpgrade, + State(state): State, + ConnectInfo(addr): ConnectInfo, +) -> impl IntoResponse { + ws.on_upgrade(move |socket| handle_agent_socket(socket, state, addr)) +} + +async fn handle_agent_socket(socket: WebSocket, state: AppState, addr: SocketAddr) { + let (mut sender, mut receiver) = socket.split(); + let Some(Ok(Message::Text(first))) = receiver.next().await else { + return; + }; + let Ok(AgentMessage::AgentHello { + agent_id, + token, + secret, + hostname, + os, + arch, + version, + capabilities, + }) = serde_json::from_str::(&first) + else { + let _ = sender + .send(Message::Text( + r#"{"type":"error","code":"bad_hello","message":"Agent 握手消息无效"}"#.into(), + )) + .await; + return; + }; + + let accepted = match accept_agent( + &state, + agent_id, + token, + secret, + &hostname, + &os, + &arch, + &version, + &capabilities, + addr, + ) + .await + { + Ok(v) => v, + Err(err) => { + tracing::warn!(?err, "Agent 鉴权失败"); + let _ = sender + .send(Message::Text( + r#"{"type":"error","code":"auth_failed","message":"Agent 鉴权失败"}"#.into(), + )) + .await; + return; + } + }; + + let (agent_id, issued_secret) = accepted; + let connection_id = Uuid::new_v4().to_string(); + let (tx, mut rx) = mpsc::unbounded_channel::(); + if let Some(old) = state.agents.write().await.insert( + agent_id.clone(), + AgentHandle { + connection_id: connection_id.clone(), + tx, + }, + ) { + let _ = old.tx.send(ServerMessage::ErrorMessage { + code: "connection_replaced".into(), + message: "Agent 已建立新连接,当前旧连接将被替换".into(), + }); + } + let accepted_msg = ServerMessage::AgentAccepted { + agent_id: agent_id.clone(), + secret: issued_secret, + }; + if sender + .send(Message::Text( + serde_json::to_string(&accepted_msg).unwrap_or_default(), + )) + .await + .is_err() + { + return; + } + + let write_task = tokio::spawn(async move { + let mut ping = tokio::time::interval(TokioDuration::from_secs(AGENT_PING_INTERVAL_SECS)); + loop { + tokio::select! { + maybe_msg = rx.recv() => { + let Some(msg) = maybe_msg else { + break; + }; + let should_close = matches!( + &msg, + ServerMessage::ErrorMessage { code, .. } if code == "connection_replaced" + ); + let Ok(text) = serde_json::to_string(&msg) else { + continue; + }; + if sender.send(Message::Text(text)).await.is_err() { + break; + } + if should_close { + let _ = sender.send(Message::Close(None)).await; + break; + } + } + _ = ping.tick() => { + let msg = ServerMessage::ServerPing { + timestamp: Utc::now().timestamp(), + }; + let Ok(text) = serde_json::to_string(&msg) else { + continue; + }; + if sender.send(Message::Text(text)).await.is_err() { + break; + } + } + } + } + }); + + loop { + let msg = tokio::time::timeout( + TokioDuration::from_secs(AGENT_READ_TIMEOUT_SECS), + receiver.next(), + ) + .await; + match msg { + Ok(Some(Ok(Message::Text(text)))) => { + handle_agent_message(&state, &agent_id, &text).await + } + Ok(Some(Ok(Message::Close(_)))) | Ok(None) => break, + Ok(Some(Ok(_))) => {} + Ok(Some(Err(err))) => { + tracing::warn!(agent_id = %agent_id, ?err, "Agent WebSocket 读取失败"); + break; + } + Err(_) => { + tracing::warn!(agent_id = %agent_id, "Agent 连接静默超时,主动断开"); + break; + } + } + } + + write_task.abort(); + let should_mark_offline = { + let mut agents = state.agents.write().await; + match agents.get(&agent_id) { + Some(handle) if handle.connection_id == connection_id => { + agents.remove(&agent_id); + true + } + _ => false, + } + }; + if should_mark_offline { + task::fail_agent_pending_tasks(&state, &agent_id, "Agent 连接已断开").await; + let _ = sqlx::query("UPDATE agents SET status = 'offline', updated_at = ? WHERE id = ?") + .bind(Utc::now().to_rfc3339()) + .bind(&agent_id) + .execute(&state.pool) + .await; + } +} + +#[allow(clippy::too_many_arguments)] +async fn accept_agent( + state: &AppState, + agent_id: Option, + token: Option, + secret: Option, + hostname: &str, + os: &str, + arch: &str, + version: &str, + capabilities: &lightops_common::protocol::AgentCapabilities, + addr: SocketAddr, +) -> Result<(String, Option), AppError> { + if let (Some(id), Some(secret)) = (agent_id.clone(), secret) { + let row = sqlx::query("SELECT secret_hash FROM agents WHERE id = ?") + .bind(&id) + .fetch_optional(&state.pool) + .await? + .ok_or(AppError::Unauthorized)?; + if auth::token_hash(&secret) != row.get::("secret_hash") { + return Err(AppError::Unauthorized); + } + update_agent( + state, + &id, + hostname, + os, + arch, + version, + capabilities, + addr, + None, + ) + .await?; + return Ok((id, None)); + } + + let token = token.ok_or(AppError::Unauthorized)?; + let token_hash = auth::token_hash(&token); + let row = sqlx::query( + "SELECT id, expires_at FROM registration_tokens WHERE token_hash = ? AND used_at IS NULL", + ) + .bind(&token_hash) + .fetch_optional(&state.pool) + .await? + .ok_or(AppError::Unauthorized)?; + let expires_at: String = row.get("expires_at"); + if expires_at <= Utc::now().to_rfc3339() { + return Err(AppError::Unauthorized); + } + let id = agent_id.unwrap_or_else(|| Uuid::new_v4().to_string()); + let new_secret = auth::random_token(); + let secret_hash = auth::token_hash(&new_secret); + update_agent( + state, + &id, + hostname, + os, + arch, + version, + capabilities, + addr, + Some(&secret_hash), + ) + .await?; + sqlx::query("UPDATE registration_tokens SET used_at = ? WHERE id = ?") + .bind(Utc::now().to_rfc3339()) + .bind(row.get::("id")) + .execute(&state.pool) + .await?; + task::audit( + state, + None, + Some(&id), + "agent.register", + Some(hostname), + None, + true, + None, + ) + .await; + Ok((id, Some(new_secret))) +} + +async fn update_agent( + state: &AppState, + id: &str, + hostname: &str, + os: &str, + arch: &str, + version: &str, + capabilities: &lightops_common::protocol::AgentCapabilities, + addr: SocketAddr, + secret_hash: Option<&str>, +) -> Result<(), AppError> { + let caps = serde_json::to_string(capabilities).unwrap_or_else(|_| "{}".into()); + sqlx::query( + "INSERT INTO agents (id, name, hostname, os, arch, version, ip, status, secret_hash, capabilities_json, last_seen_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, 'online', COALESCE(?, ''), ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET hostname=excluded.hostname, os=excluded.os, arch=excluded.arch, version=excluded.version, + ip=excluded.ip, status='online', capabilities_json=excluded.capabilities_json, last_seen_at=excluded.last_seen_at, + updated_at=excluded.updated_at, secret_hash=CASE WHEN excluded.secret_hash = '' THEN agents.secret_hash ELSE excluded.secret_hash END", + ) + .bind(id) + .bind(hostname) + .bind(hostname) + .bind(os) + .bind(arch) + .bind(version) + .bind(addr.ip().to_string()) + .bind(secret_hash) + .bind(caps) + .bind(Utc::now().to_rfc3339()) + .bind(Utc::now().to_rfc3339()) + .execute(&state.pool) + .await?; + Ok(()) +} + +async fn handle_agent_message(state: &AppState, agent_id: &str, text: &str) { + let Ok(msg) = serde_json::from_str::(text) else { + tracing::warn!("Agent 消息无效"); + return; + }; + match msg { + AgentMessage::AgentHeartbeat { metrics, .. } => { + let now = Utc::now().to_rfc3339(); + let _ = sqlx::query("UPDATE agents SET status = 'online', last_seen_at = ?, updated_at = ? WHERE id = ?") + .bind(&now) + .bind(&now) + .bind(agent_id) + .execute(&state.pool) + .await; + if let Some(m) = metrics { + let network_rx: i64 = m.networks.values().map(|n| n.received as i64).sum(); + let network_tx: i64 = m.networks.values().map(|n| n.transmitted as i64).sum(); + let memory_usage = percent_f64(m.memory_used as f64, m.memory_total as f64); + let disk_usage = percent_f64(m.disk_used as f64, m.disk_total as f64); + let _ = sqlx::query( + "INSERT INTO agent_metrics (agent_id, cpu_usage, memory_total, memory_used, disk_total, disk_used, load_avg, uptime, network_rx, network_tx) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ) + .bind(agent_id) + .bind(m.cpu_usage) + .bind(m.memory_total as i64) + .bind(m.memory_used as i64) + .bind(m.disk_total as i64) + .bind(m.disk_used as i64) + .bind(m.load_avg) + .bind(m.uptime as i64) + .bind(network_rx) + .bind(network_tx) + .execute(&state.pool) + .await; + evaluate_alerts(state, agent_id, m.cpu_usage, memory_usage, disk_usage).await; + } + } + AgentMessage::AgentPong { .. } => { + let now = Utc::now().to_rfc3339(); + let _ = sqlx::query( + "UPDATE agents SET status = 'online', last_seen_at = ?, updated_at = ? WHERE id = ?", + ) + .bind(&now) + .bind(&now) + .bind(agent_id) + .execute(&state.pool) + .await; + } + AgentMessage::TaskResponse { + task_id, + success, + data, + error, + } => { + state.pending_agents.remove(&task_id); + if let Some((_, tx)) = state.pending.remove(&task_id) { + let _ = tx.send(TaskReply { + success, + data, + error, + }); + } + } + AgentMessage::TaskEvent { + task_id, + level, + message, + data, + } => { + let _ = task::append_task_event(state, &task_id, &level, &message, Some(&data)).await; + } + AgentMessage::StreamData { ref stream_id, .. } + | AgentMessage::StreamClose { ref stream_id, .. } + | AgentMessage::StreamOpen { ref stream_id, .. } => { + if let Some(tx) = state.streams.get(stream_id) { + let _ = tx.send(msg); + } + } + AgentMessage::AgentHello { .. } | AgentMessage::ErrorMessage { .. } => {} + } +} + +#[derive(Debug, Deserialize)] +struct PathReq { + path: String, +} + +async fn run_task( + user: &AuthUser, + state: &AppState, + agent_id: &str, + action: &str, + params: Value, + audit_target: Option, +) -> Result>, AppError> { + require_permission(user, permission_for_action(action))?; + require_agent_access(user, state, agent_id).await?; + ensure_feature_enabled(state, action).await?; + if is_dangerous_action(action) { + if let (Some(expected), Some(actual)) = ( + audit_target.as_deref(), + params.get("confirm_target").and_then(Value::as_str), + ) { + if expected != actual { + return Err(AppError::BadRequest("危险操作确认目标不匹配".into())); + } + } + } + let reply = + task::request_agent_task(state, agent_id, Some(user.id), action, params.clone()).await?; + let dangerous = is_dangerous_action(action); + if dangerous { + task::audit( + state, + Some(user.id), + Some(agent_id), + action, + audit_target.as_deref(), + Some(summarize(¶ms)), + reply.success, + reply.error.as_deref(), + ) + .await; + } + if reply.success { + Ok(Json(ApiResponse::ok(reply.data))) + } else { + Err(AppError::BadRequest( + reply.error.unwrap_or_else(|| "任务执行失败".into()), + )) + } +} + +fn require_permission(user: &AuthUser, permission: &str) -> Result<(), AppError> { + if user.can(permission) { + Ok(()) + } else { + Err(AppError::Forbidden) + } +} + +async fn require_agent_access( + user: &AuthUser, + state: &AppState, + agent_id: &str, +) -> Result<(), AppError> { + if user.role == "admin" { + return Ok(()); + } + let allowed: Option = + sqlx::query_scalar("SELECT 1 FROM agent_access WHERE user_id = ? AND agent_id = ?") + .bind(user.id) + .bind(agent_id) + .fetch_optional(&state.pool) + .await?; + if allowed.is_some() { + Ok(()) + } else { + Err(AppError::Forbidden) + } +} + +async fn user_agent_ids(state: &AppState, user_id: i64) -> Result, AppError> { + Ok( + sqlx::query_scalar::<_, String>("SELECT agent_id FROM agent_access WHERE user_id = ?") + .bind(user_id) + .fetch_all(&state.pool) + .await?, + ) +} + +fn permission_for_action(action: &str) -> &'static str { + match action { + "file.roots" | "file.list" | "file.search" | "file.read" | "file.download.chunk" => { + "file.read" + } + "file.delete" => "file.danger", + action if action.starts_with("file.") => "file.write", + "terminal.open" => "terminal.open", + action if action.starts_with("log.") => "logs.read", + "service.list" | "service.status" => "service.read", + "service.stop" | "service.disable" => "service.danger", + action if action.starts_with("service.") => "service.write", + "nginx.status" | "nginx.sites" | "nginx.site.get" | "nginx.site.backups" + | "nginx.ssl.status" => "nginx.read", + "nginx.site.disable" | "nginx.site.restore_backup" => "nginx.danger", + action if action.starts_with("nginx.") => "nginx.write", + "docker.status" + | "docker.containers" + | "docker.container.inspect" + | "docker.container.stats" + | "docker.container.logs" + | "docker.images" + | "docker.volumes" + | "docker.compose.projects" + | "docker.compose.logs" => "docker.read", + "docker.container.stop" + | "docker.container.delete" + | "docker.image.delete" + | "docker.volume.delete" + | "docker.compose.stop" => "docker.danger", + action if action.starts_with("docker.") => "docker.write", + "app.list" | "app.get" | "app.logs" | "app.health" | "app.discover" => "apps.read", + "app.stop" | "app.uninstall" => "apps.danger", + action if action.starts_with("app.") => "apps.write", + "system.snapshot" => "agents", + _ => "tasks", + } +} + +async fn ensure_feature_enabled(state: &AppState, action: &str) -> Result<(), AppError> { + if action.starts_with("terminal") + && !setting_bool(state, "security.terminal_enabled", true).await + { + return Err(AppError::Forbidden); + } + if matches!( + action, + "file.write" | "file.upload.chunk" | "file.delete" | "file.rename" | "file.chmod" + ) && !setting_bool(state, "security.file_write_enabled", true).await + { + return Err(AppError::Forbidden); + } + Ok(()) +} + +async fn setting_bool(state: &AppState, key: &str, default: bool) -> bool { + sqlx::query_scalar::<_, String>("SELECT value FROM settings WHERE key = ?") + .bind(key) + .fetch_optional(&state.pool) + .await + .ok() + .flatten() + .and_then(|value| value.parse::().ok()) + .unwrap_or(default) +} + +fn summarize(value: &Value) -> String { + let sanitized = sanitize_for_audit(value); + let mut s = sanitized.to_string(); + if s.len() > 500 { + s.truncate(500); + } + s +} + +fn sanitize_for_audit(value: &Value) -> Value { + match value { + Value::Object(map) => { + let mut out = serde_json::Map::new(); + for (key, value) in map { + let lower = key.to_ascii_lowercase(); + if lower.contains("secret") + || lower.contains("token") + || lower.contains("password") + || lower == "env" + { + out.insert(key.clone(), json!("[已隐藏]")); + } else { + out.insert(key.clone(), sanitize_for_audit(value)); + } + } + Value::Object(out) + } + Value::Array(items) => Value::Array(items.iter().map(sanitize_for_audit).collect()), + _ => value.clone(), + } +} + +fn is_dangerous_action(action: &str) -> bool { + matches!( + action, + "file.write" + | "file.delete" + | "file.rename" + | "file.upload.chunk" + | "file.chmod" + | "service.start" + | "service.stop" + | "service.restart" + | "service.enable" + | "service.disable" + | "nginx.site.create" + | "nginx.site.update" + | "nginx.site.restore_backup" + | "nginx.site.enable" + | "nginx.site.disable" + | "nginx.reload" + | "nginx.ssl.issue" + | "nginx.ssl.renew" + | "nginx.ssl.auto_renew" + | "docker.container.stop" + | "docker.container.restart" + | "docker.container.delete" + | "docker.container.run" + | "docker.image.pull" + | "docker.image.delete" + | "docker.volume.delete" + | "docker.compose.start" + | "docker.compose.stop" + | "docker.compose.restart" + | "docker.compose.deploy" + ) +} + +fn is_safe_docker_identifier(value: &str) -> bool { + !value.is_empty() + && value.len() <= 200 + && value + .chars() + .all(|c| c.is_ascii_alphanumeric() || ".:/@_-".contains(c)) +} + +fn require_admin(user: &AuthUser) -> Result<(), AppError> { + if user.role == "admin" { + Ok(()) + } else { + Err(AppError::Forbidden) + } +} + +#[derive(Debug, Deserialize)] +struct FileListQuery { + path: Option, +} + +#[derive(Debug, Deserialize)] +struct FileSearchReq { + path: String, + keyword: String, + max_depth: Option, + limit: Option, +} + +async fn file_list( + user: AuthUser, + State(state): State, + Path(id): Path, + Query(q): Query, +) -> Result>, AppError> { + run_task( + &user, + &state, + &id, + "file.list", + json!({ "path": q.path.unwrap_or_else(|| "/".into()) }), + None, + ) + .await +} + +async fn file_roots( + user: AuthUser, + State(state): State, + Path(id): Path, +) -> Result>, AppError> { + run_task(&user, &state, &id, "file.roots", json!({}), None).await +} + +async fn file_search( + user: AuthUser, + State(state): State, + Path(id): Path, + Json(req): Json, +) -> Result>, AppError> { + run_task( + &user, + &state, + &id, + "file.search", + json!({ + "path": req.path, + "keyword": req.keyword, + "max_depth": req.max_depth.unwrap_or(5), + "limit": req.limit.unwrap_or(100) + }), + None, + ) + .await +} + +async fn file_read( + user: AuthUser, + State(state): State, + Path(id): Path, + Json(req): Json, +) -> Result>, AppError> { + run_task( + &user, + &state, + &id, + "file.read", + json!({ "path": req.path }), + None, + ) + .await +} + +#[derive(Debug, Deserialize)] +struct FileWriteReq { + path: String, + content: String, +} + +#[derive(Debug, Deserialize)] +struct FileDeleteReq { + path: String, + recursive: Option, +} + +#[derive(Debug, Deserialize)] +struct FileChunkQuery { + path: String, + offset: Option, + size: Option, +} + +#[derive(Debug, Deserialize)] +struct FileUploadChunkReq { + path: String, + offset: u64, + data: String, +} + +#[derive(Debug, Deserialize)] +struct FileChmodReq { + path: String, + mode: String, +} + +async fn file_write( + user: AuthUser, + State(state): State, + Path(id): Path, + Json(req): Json, +) -> Result>, AppError> { + run_task( + &user, + &state, + &id, + "file.write", + json!({ "path": req.path, "content": req.content }), + Some(req.path), + ) + .await +} + +async fn file_mkdir( + user: AuthUser, + State(state): State, + Path(id): Path, + Json(req): Json, +) -> Result>, AppError> { + run_task( + &user, + &state, + &id, + "file.mkdir", + json!({ "path": req.path }), + Some(req.path), + ) + .await +} + +async fn file_delete( + user: AuthUser, + State(state): State, + Path(id): Path, + Json(req): Json, +) -> Result>, AppError> { + run_task( + &user, + &state, + &id, + "file.delete", + json!({ "path": req.path, "recursive": req.recursive.unwrap_or(false) }), + Some(req.path), + ) + .await +} + +#[derive(Debug, Deserialize)] +struct RenameReq { + from: String, + to: String, +} +async fn file_rename( + user: AuthUser, + State(state): State, + Path(id): Path, + Json(req): Json, +) -> Result>, AppError> { + let target = format!("{} -> {}", req.from, req.to); + run_task( + &user, + &state, + &id, + "file.rename", + json!({ "from": req.from, "to": req.to }), + Some(target), + ) + .await +} + +async fn file_upload( + user: AuthUser, + State(state): State, + Path(id): Path, + Json(req): Json, +) -> Result>, AppError> { + run_task( + &user, + &state, + &id, + "file.write", + json!({ "path": req.path, "content": req.content }), + Some(req.path), + ) + .await +} + +async fn file_upload_chunk( + user: AuthUser, + State(state): State, + Path(id): Path, + Json(req): Json, +) -> Result>, AppError> { + let target = req.path.clone(); + run_task( + &user, + &state, + &id, + "file.upload.chunk", + json!({ "path": req.path, "offset": req.offset, "data": req.data }), + Some(target), + ) + .await +} + +async fn file_download( + user: AuthUser, + State(state): State, + Path(id): Path, + Query(q): Query, +) -> Result>, AppError> { + run_task( + &user, + &state, + &id, + "file.read", + json!({ "path": q.path.unwrap_or_default() }), + None, + ) + .await +} + +async fn file_download_chunk( + user: AuthUser, + State(state): State, + Path(id): Path, + Query(q): Query, +) -> Result>, AppError> { + run_task( + &user, + &state, + &id, + "file.download.chunk", + json!({ "path": q.path, "offset": q.offset.unwrap_or(0), "size": q.size.unwrap_or(512 * 1024) }), + None, + ) + .await +} + +async fn file_chmod( + user: AuthUser, + State(state): State, + Path(id): Path, + Json(req): Json, +) -> Result>, AppError> { + let target = req.path.clone(); + run_task( + &user, + &state, + &id, + "file.chmod", + json!({ "path": req.path, "mode": req.mode }), + Some(target), + ) + .await +} + +async fn terminal_ws( + user: AuthUser, + ws: WebSocketUpgrade, + State(state): State, + Path(id): Path, +) -> impl IntoResponse { + if !user.can("terminal") + || require_agent_access(&user, &state, &id).await.is_err() + || !setting_bool(&state, "security.terminal_enabled", true).await + { + return StatusCode::FORBIDDEN.into_response(); + } + ws.on_upgrade(move |socket| async move { + task::audit( + &state, + Some(user.id), + Some(&id), + "terminal.open", + None, + None, + true, + None, + ) + .await; + proxy_stream_socket( + socket, + state, + id, + "terminal", + json!({ "cols": 100, "rows": 30 }), + ) + .await; + }) + .into_response() +} + +async fn docker_container_exec_ws( + user: AuthUser, + ws: WebSocketUpgrade, + State(state): State, + Path((id, container_id)): Path<(String, String)>, +) -> impl IntoResponse { + if !user.can("docker") + || require_agent_access(&user, &state, &id).await.is_err() + || !setting_bool(&state, "security.terminal_enabled", true).await + || !is_safe_docker_identifier(&container_id) + { + return StatusCode::FORBIDDEN.into_response(); + } + ws.on_upgrade(move |socket| async move { + task::audit( + &state, + Some(user.id), + Some(&id), + "docker.container.exec", + Some(&container_id), + None, + true, + None, + ) + .await; + proxy_stream_socket( + socket, + state, + id, + "docker.exec", + json!({ "cols": 100, "rows": 30, "container_id": container_id, "shell": "sh" }), + ) + .await; + }) + .into_response() +} + +async fn log_stream_ws( + user: AuthUser, + ws: WebSocketUpgrade, + State(state): State, + Path(id): Path, +) -> impl IntoResponse { + if !user.can("logs") || require_agent_access(&user, &state, &id).await.is_err() { + return StatusCode::FORBIDDEN.into_response(); + } + ws.on_upgrade(move |socket| async move { + proxy_stream_socket( + socket, + state, + id, + "log.tail", + json!({ "cols": 100, "rows": 30 }), + ) + .await; + }) + .into_response() +} + +async fn proxy_stream_socket( + mut socket: WebSocket, + state: AppState, + agent_id: String, + kind: &str, + meta: Value, +) { + let stream_id = Uuid::new_v4().to_string(); + let handle = { + let agents = state.agents.read().await; + agents.get(&agent_id).cloned() + }; + let Some(handle) = handle else { + let _ = socket.send(Message::Text("Agent 不在线".into())).await; + return; + }; + let _ = handle.tx.send(ServerMessage::StreamOpen { + stream_id: stream_id.clone(), + kind: kind.to_string(), + meta, + }); + + let (tx, mut rx) = mpsc::unbounded_channel(); + state.streams.insert(stream_id.clone(), tx); + loop { + tokio::select! { + browser_msg = socket.next() => { + let Some(Ok(msg)) = browser_msg else { break; }; + match msg { + Message::Text(data) => { + let _ = handle.tx.send(ServerMessage::StreamData { stream_id: stream_id.clone(), data, binary: false }); + } + Message::Binary(bytes) => { + let data = base64::engine::general_purpose::STANDARD.encode(bytes); + let _ = handle.tx.send(ServerMessage::StreamData { stream_id: stream_id.clone(), data, binary: true }); + } + Message::Close(_) => break, + _ => {} + } + } + agent_msg = rx.recv() => { + let Some(agent_msg) = agent_msg else { break; }; + match agent_msg { + AgentMessage::StreamData { data, binary, .. } => { + let send_result = if binary { + match base64::engine::general_purpose::STANDARD.decode(data) { + Ok(bytes) => socket.send(Message::Binary(bytes)).await, + Err(_) => continue, + } + } else { + socket.send(Message::Text(data)).await + }; + if send_result.is_err() { + break; + } + } + AgentMessage::StreamClose { reason, .. } => { + let _ = socket.send(Message::Text(reason.unwrap_or_else(|| "closed".into()))).await; + break; + } + _ => {} + } + } + } + } + state.streams.remove(&stream_id); + let _ = handle.tx.send(ServerMessage::StreamClose { + stream_id, + reason: Some("浏览器已关闭连接".into()), + }); +} + +async fn log_tail( + user: AuthUser, + State(state): State, + Path(id): Path, + Json(req): Json, +) -> Result>, AppError> { + run_task(&user, &state, &id, "log.tail_file", req, None).await +} + +async fn service_list( + user: AuthUser, + State(state): State, + Path(id): Path, +) -> Result>, AppError> { + run_task(&user, &state, &id, "service.list", json!({}), None).await +} + +async fn service_start( + user: AuthUser, + State(state): State, + Path((id, name)): Path<(String, String)>, +) -> Result>, AppError> { + run_task( + &user, + &state, + &id, + "service.start", + json!({ "name": name }), + Some(name), + ) + .await +} + +async fn service_stop( + user: AuthUser, + State(state): State, + Path((id, name)): Path<(String, String)>, +) -> Result>, AppError> { + run_task( + &user, + &state, + &id, + "service.stop", + json!({ "name": name }), + Some(name), + ) + .await +} + +async fn service_restart( + user: AuthUser, + State(state): State, + Path((id, name)): Path<(String, String)>, +) -> Result>, AppError> { + run_task( + &user, + &state, + &id, + "service.restart", + json!({ "name": name }), + Some(name), + ) + .await +} + +async fn service_enable( + user: AuthUser, + State(state): State, + Path((id, name)): Path<(String, String)>, +) -> Result>, AppError> { + run_task( + &user, + &state, + &id, + "service.enable", + json!({ "name": name }), + Some(name), + ) + .await +} + +async fn service_disable( + user: AuthUser, + State(state): State, + Path((id, name)): Path<(String, String)>, +) -> Result>, AppError> { + run_task( + &user, + &state, + &id, + "service.disable", + json!({ "name": name }), + Some(name), + ) + .await +} + +async fn nginx_status( + user: AuthUser, + State(state): State, + Path(id): Path, +) -> Result>, AppError> { + run_task(&user, &state, &id, "nginx.status", json!({}), None).await +} +async fn nginx_sites( + user: AuthUser, + State(state): State, + Path(id): Path, +) -> Result>, AppError> { + run_task(&user, &state, &id, "nginx.sites", json!({}), None).await +} +async fn nginx_create_site( + user: AuthUser, + State(state): State, + Path(id): Path, + Json(req): Json, +) -> Result>, AppError> { + run_task( + &user, + &state, + &id, + "nginx.site.create", + req.clone(), + Some( + req.get("name") + .and_then(Value::as_str) + .unwrap_or("site") + .into(), + ), + ) + .await +} +async fn nginx_get_site( + user: AuthUser, + State(state): State, + Path((id, name)): Path<(String, String)>, +) -> Result>, AppError> { + run_task( + &user, + &state, + &id, + "nginx.site.get", + json!({ "name": name }), + None, + ) + .await +} +async fn nginx_put_site( + user: AuthUser, + State(state): State, + Path((id, name)): Path<(String, String)>, + Json(req): Json, +) -> Result>, AppError> { + run_task( + &user, + &state, + &id, + "nginx.site.update", + json!({ "name": name, "content": req.get("content").cloned().unwrap_or(json!("")) }), + Some(name), + ) + .await +} + +async fn nginx_site_backups( + user: AuthUser, + State(state): State, + Path((id, name)): Path<(String, String)>, +) -> Result>, AppError> { + run_task( + &user, + &state, + &id, + "nginx.site.backups", + json!({ "name": name }), + None, + ) + .await +} + +async fn nginx_site_restore_backup( + user: AuthUser, + State(state): State, + Path((id, name, backup)): Path<(String, String, String)>, +) -> Result>, AppError> { + run_task( + &user, + &state, + &id, + "nginx.site.restore_backup", + json!({ "name": name, "backup": backup }), + Some(name), + ) + .await +} + +async fn nginx_enable_site( + user: AuthUser, + State(state): State, + Path((id, name)): Path<(String, String)>, +) -> Result>, AppError> { + run_task( + &user, + &state, + &id, + "nginx.site.enable", + json!({ "name": name }), + Some(name), + ) + .await +} +async fn nginx_disable_site( + user: AuthUser, + State(state): State, + Path((id, name)): Path<(String, String)>, +) -> Result>, AppError> { + run_task( + &user, + &state, + &id, + "nginx.site.disable", + json!({ "name": name }), + Some(name), + ) + .await +} +async fn nginx_test( + user: AuthUser, + State(state): State, + Path(id): Path, +) -> Result>, AppError> { + run_task(&user, &state, &id, "nginx.test", json!({}), None).await +} +async fn nginx_reload( + user: AuthUser, + State(state): State, + Path(id): Path, +) -> Result>, AppError> { + run_task( + &user, + &state, + &id, + "nginx.reload", + json!({}), + Some("nginx".into()), + ) + .await +} + +async fn nginx_ssl_status( + user: AuthUser, + State(state): State, + Path(id): Path, +) -> Result>, AppError> { + let response = run_task(&user, &state, &id, "nginx.ssl.status", json!({}), None).await?; + if let Some(data) = response.0.data.clone() { + sync_ssl_expiry_alert(&state, &id, &data).await; + } + Ok(response) +} + +async fn nginx_ssl_issue( + user: AuthUser, + State(state): State, + Path(id): Path, + Json(req): Json, +) -> Result>, AppError> { + run_task( + &user, + &state, + &id, + "nginx.ssl.issue", + req.clone(), + req.get("domains") + .map(|domains| domains.to_string()) + .or_else(|| Some("ssl".into())), + ) + .await +} + +async fn nginx_ssl_renew( + user: AuthUser, + State(state): State, + Path(id): Path, +) -> Result>, AppError> { + run_task( + &user, + &state, + &id, + "nginx.ssl.renew", + json!({}), + Some("certbot renew".into()), + ) + .await +} + +async fn nginx_ssl_auto_renew( + user: AuthUser, + State(state): State, + Path(id): Path, +) -> Result>, AppError> { + run_task( + &user, + &state, + &id, + "nginx.ssl.auto_renew", + json!({}), + Some("certbot.timer".into()), + ) + .await +} + +async fn docker_status( + user: AuthUser, + State(state): State, + Path(id): Path, +) -> Result>, AppError> { + run_task(&user, &state, &id, "docker.status", json!({}), None).await +} +async fn docker_containers( + user: AuthUser, + State(state): State, + Path(id): Path, +) -> Result>, AppError> { + run_task(&user, &state, &id, "docker.containers", json!({}), None).await +} + +async fn docker_container_inspect( + user: AuthUser, + State(state): State, + Path((id, container_id)): Path<(String, String)>, +) -> Result>, AppError> { + run_task( + &user, + &state, + &id, + "docker.container.inspect", + json!({ "id": container_id }), + None, + ) + .await +} + +async fn docker_container_stats( + user: AuthUser, + State(state): State, + Path((id, container_id)): Path<(String, String)>, +) -> Result>, AppError> { + run_task( + &user, + &state, + &id, + "docker.container.stats", + json!({ "id": container_id }), + None, + ) + .await +} + +async fn docker_container_run( + user: AuthUser, + State(state): State, + Path(id): Path, + Json(params): Json, +) -> Result>, AppError> { + let target = params + .get("name") + .and_then(Value::as_str) + .or_else(|| params.get("image").and_then(Value::as_str)) + .map(ToString::to_string); + run_task(&user, &state, &id, "docker.container.run", params, target).await +} + +async fn docker_container_start( + user: AuthUser, + State(state): State, + Path((id, container_id)): Path<(String, String)>, +) -> Result>, AppError> { + run_task( + &user, + &state, + &id, + "docker.container.start", + json!({ "id": container_id }), + Some(container_id), + ) + .await +} +async fn docker_container_stop( + user: AuthUser, + State(state): State, + Path((id, container_id)): Path<(String, String)>, +) -> Result>, AppError> { + run_task( + &user, + &state, + &id, + "docker.container.stop", + json!({ "id": container_id }), + Some(container_id), + ) + .await +} +async fn docker_container_restart( + user: AuthUser, + State(state): State, + Path((id, container_id)): Path<(String, String)>, +) -> Result>, AppError> { + run_task( + &user, + &state, + &id, + "docker.container.restart", + json!({ "id": container_id }), + Some(container_id), + ) + .await +} +async fn docker_container_delete( + user: AuthUser, + State(state): State, + Path((id, container_id)): Path<(String, String)>, +) -> Result>, AppError> { + run_task( + &user, + &state, + &id, + "docker.container.delete", + json!({ "id": container_id }), + Some(container_id), + ) + .await +} +async fn docker_container_logs( + user: AuthUser, + State(state): State, + Path((id, container_id)): Path<(String, String)>, +) -> Result>, AppError> { + run_task( + &user, + &state, + &id, + "docker.container.logs", + json!({ "id": container_id, "tail": 200 }), + None, + ) + .await +} +async fn docker_images( + user: AuthUser, + State(state): State, + Path(id): Path, +) -> Result>, AppError> { + run_task(&user, &state, &id, "docker.images", json!({}), None).await +} + +async fn docker_image_pull( + user: AuthUser, + State(state): State, + Path(id): Path, + Json(params): Json, +) -> Result>, AppError> { + let image = params + .get("image") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(); + run_task( + &user, + &state, + &id, + "docker.image.pull", + json!({ "image": image.clone() }), + Some(image), + ) + .await +} + +async fn docker_image_delete( + user: AuthUser, + State(state): State, + Path((id, image_id)): Path<(String, String)>, +) -> Result>, AppError> { + run_task( + &user, + &state, + &id, + "docker.image.delete", + json!({ "id": image_id }), + Some(image_id), + ) + .await +} + +async fn docker_volumes( + user: AuthUser, + State(state): State, + Path(id): Path, +) -> Result>, AppError> { + run_task(&user, &state, &id, "docker.volumes", json!({}), None).await +} + +async fn docker_volume_delete( + user: AuthUser, + State(state): State, + Path((id, volume_name)): Path<(String, String)>, +) -> Result>, AppError> { + run_task( + &user, + &state, + &id, + "docker.volume.delete", + json!({ "name": volume_name }), + Some(volume_name), + ) + .await +} + +async fn docker_compose_projects( + user: AuthUser, + State(state): State, + Path(id): Path, +) -> Result>, AppError> { + run_task( + &user, + &state, + &id, + "docker.compose.projects", + json!({}), + None, + ) + .await +} + +async fn docker_compose_start( + user: AuthUser, + State(state): State, + Path((id, project)): Path<(String, String)>, +) -> Result>, AppError> { + run_task( + &user, + &state, + &id, + "docker.compose.start", + json!({ "project": project.clone() }), + Some(project), + ) + .await +} + +async fn docker_compose_stop( + user: AuthUser, + State(state): State, + Path((id, project)): Path<(String, String)>, +) -> Result>, AppError> { + run_task( + &user, + &state, + &id, + "docker.compose.stop", + json!({ "project": project.clone() }), + Some(project), + ) + .await +} + +async fn docker_compose_restart( + user: AuthUser, + State(state): State, + Path((id, project)): Path<(String, String)>, +) -> Result>, AppError> { + run_task( + &user, + &state, + &id, + "docker.compose.restart", + json!({ "project": project.clone() }), + Some(project), + ) + .await +} + +async fn docker_compose_logs( + user: AuthUser, + State(state): State, + Path((id, project)): Path<(String, String)>, +) -> Result>, AppError> { + run_task( + &user, + &state, + &id, + "docker.compose.logs", + json!({ "project": project, "tail": 200 }), + None, + ) + .await +} + +async fn docker_compose_deploy( + user: AuthUser, + State(state): State, + Path(id): Path, + Json(params): Json, +) -> Result>, AppError> { + let target = params + .get("project") + .and_then(Value::as_str) + .map(ToString::to_string); + run_task(&user, &state, &id, "docker.compose.deploy", params, target).await +} + +async fn system_snapshot( + user: AuthUser, + State(state): State, + Path(id): Path, +) -> Result>, AppError> { + run_task(&user, &state, &id, "system.snapshot", json!({}), None).await +} + +#[derive(Debug, Deserialize)] +struct TaskListQuery { + limit: Option, + offset: Option, + status: Option, + action: Option, + agent_id: Option, +} + +async fn tasks( + user: AuthUser, + State(state): State, + Query(q): Query, +) -> Result>, AppError> { + require_permission(&user, "tasks")?; + let allowed_agents = if user.role == "admin" { + None + } else { + Some(user_agent_ids(&state, user.id).await?) + }; + list_tasks(&state, q, None, allowed_agents).await +} + +async fn agent_tasks( + user: AuthUser, + State(state): State, + Path(id): Path, + Query(q): Query, +) -> Result>, AppError> { + require_permission(&user, "tasks")?; + require_agent_access(&user, &state, &id).await?; + list_tasks(&state, q, Some(id), None).await +} + +async fn task_detail( + user: AuthUser, + State(state): State, + Path(task_id): Path, +) -> Result>, AppError> { + require_permission(&user, "tasks")?; + let row = sqlx::query("SELECT * FROM tasks WHERE id = ?") + .bind(&task_id) + .fetch_optional(&state.pool) + .await? + .ok_or(AppError::NotFound)?; + let agent_id: String = row.get("agent_id"); + require_agent_access(&user, &state, &agent_id).await?; + Ok(Json(ApiResponse::ok(task_row_json(row)))) +} + +async fn task_events( + user: AuthUser, + State(state): State, + Path(task_id): Path, +) -> Result>, AppError> { + require_permission(&user, "tasks")?; + let agent_id = sqlx::query_scalar::<_, String>("SELECT agent_id FROM tasks WHERE id = ?") + .bind(&task_id) + .fetch_optional(&state.pool) + .await? + .ok_or(AppError::NotFound)?; + require_agent_access(&user, &state, &agent_id).await?; + let rows = sqlx::query("SELECT * FROM task_events WHERE task_id = ? ORDER BY id ASC") + .bind(&task_id) + .fetch_all(&state.pool) + .await?; + let data = rows + .into_iter() + .map(|row| { + json!({ + "id": row.get::("id"), + "task_id": row.get::("task_id"), + "level": row.get::("level"), + "message": row.get::("message"), + "data": row.get::, _>("data_json").and_then(|value| serde_json::from_str::(&value).ok()), + "created_at": row.get::("created_at") + }) + }) + .collect::>(); + Ok(Json(ApiResponse::ok(json!(data)))) +} + +async fn tasks_summary( + user: AuthUser, + State(state): State, + Query(q): Query, +) -> Result>, AppError> { + require_permission(&user, "tasks")?; + if let Some(agent_id) = &q.agent_id { + require_agent_access(&user, &state, agent_id).await?; + } + let allowed_agents = if user.role == "admin" { + None + } else { + Some(user_agent_ids(&state, user.id).await?) + }; + let rows = + sqlx::query("SELECT agent_id, action, status FROM tasks ORDER BY created_at DESC LIMIT ?") + .bind(q.limit.unwrap_or(1000).min(5000)) + .fetch_all(&state.pool) + .await?; + let mut total = 0; + let mut running = 0; + let mut success = 0; + let mut failed = 0; + let mut timeout = 0; + let mut cancelled = 0; + for row in rows { + let agent_id: String = row.get("agent_id"); + let action: String = row.get("action"); + if q.agent_id.as_ref().is_some_and(|value| value != &agent_id) { + continue; + } + if allowed_agents + .as_ref() + .is_some_and(|agents| !agents.iter().any(|item| item == &agent_id)) + { + continue; + } + if q.action + .as_ref() + .is_some_and(|value| !action.contains(value)) + { + continue; + } + total += 1; + match row.get::("status").as_str() { + "running" | "pending" => running += 1, + "success" => success += 1, + "failed" => failed += 1, + "timeout" => timeout += 1, + "cancelled" => cancelled += 1, + _ => {} + } + } + Ok(Json(ApiResponse::ok(json!({ + "total": total, + "running": running, + "success": success, + "failed": failed, + "timeout": timeout, + "cancelled": cancelled + })))) +} + +async fn task_retry( + user: AuthUser, + State(state): State, + Path(task_id): Path, +) -> Result>, AppError> { + let row = sqlx::query("SELECT agent_id, action, params_json FROM tasks WHERE id = ?") + .bind(&task_id) + .fetch_optional(&state.pool) + .await? + .ok_or(AppError::NotFound)?; + let agent_id: String = row.get("agent_id"); + let action: String = row.get("action"); + if !is_retryable_action(&action) { + return Err(AppError::BadRequest("该任务不适合直接重试".into())); + } + let params_json: String = row.get("params_json"); + let params: Value = serde_json::from_str(¶ms_json) + .map_err(|_| AppError::BadRequest("历史任务参数无法解析".into()))?; + let target = params + .get("confirm_target") + .and_then(Value::as_str) + .or_else(|| params.get("name").and_then(Value::as_str)) + .or_else(|| params.get("id").and_then(Value::as_str)) + .or_else(|| params.get("path").and_then(Value::as_str)) + .map(ToString::to_string) + .or_else(|| Some(format!("重试任务 {task_id}"))); + let result = run_task(&user, &state, &agent_id, &action, params.clone(), target).await; + let retry_error = result.as_ref().err().map(ToString::to_string); + task::audit( + &state, + Some(user.id), + Some(&agent_id), + "task.retry", + Some(&task_id), + Some(summarize(&json!({ "action": action, "params": params }))), + result.is_ok(), + retry_error.as_deref(), + ) + .await; + result +} + +fn is_retryable_action(action: &str) -> bool { + !matches!( + action, + "file.upload.chunk" | "file.download.chunk" | "terminal.open" | "log.tail" + ) +} + +fn percent_f64(used: f64, total: f64) -> f64 { + if total <= 0.0 { + 0.0 + } else { + (used / total * 100.0).clamp(0.0, 100.0) + } +} + +async fn evaluate_alerts( + state: &AppState, + agent_id: &str, + cpu_usage: f64, + memory_usage: f64, + disk_usage: f64, +) { + if !setting_bool(state, "alerts.enabled", true).await { + return; + } + let rows = match sqlx::query( + "SELECT * FROM alert_rules WHERE enabled = 1 AND (agent_id IS NULL OR agent_id = ?)", + ) + .bind(agent_id) + .fetch_all(&state.pool) + .await + { + Ok(rows) => rows, + Err(err) => { + tracing::warn!(?err, "读取告警规则失败"); + return; + } + }; + for row in rows { + let rule_id: String = row.get("id"); + let metric: String = row.get("metric"); + let threshold: f64 = row.get("threshold"); + let operator: String = row.get("operator"); + let severity: String = row.get("severity"); + let silence_until: Option = row.try_get("silence_until").ok().flatten(); + if silence_until + .as_deref() + .is_some_and(|value| value > Utc::now().to_rfc3339().as_str()) + { + continue; + } + let value = match metric.as_str() { + "cpu_usage" => cpu_usage, + "memory_usage" => memory_usage, + "disk_usage" => disk_usage, + _ => continue, + }; + let triggered = match operator.as_str() { + ">" => value > threshold, + "<" => value < threshold, + "<=" => value <= threshold, + _ => value >= threshold, + }; + if triggered { + open_or_update_alert_event( + state, agent_id, &rule_id, &metric, value, threshold, &severity, + ) + .await; + } else { + resolve_alert_event(state, agent_id, &rule_id).await; + } + } +} + +async fn open_or_update_alert_event( + state: &AppState, + agent_id: &str, + rule_id: &str, + metric: &str, + value: f64, + threshold: f64, + severity: &str, +) { + let now = Utc::now().to_rfc3339(); + let message = if metric == "ssl_days_remaining" { + format!( + "{} 当前 {:.0} 天,阈值 {:.0} 天", + metric_label(metric), + value, + threshold + ) + } else { + format!( + "{} 当前 {:.1}%,阈值 {:.1}%", + metric_label(metric), + value, + threshold + ) + }; + let existing = sqlx::query_scalar::<_, String>( + "SELECT id FROM alert_events WHERE rule_id = ? AND agent_id = ? AND status = 'open' LIMIT 1", + ) + .bind(rule_id) + .bind(agent_id) + .fetch_optional(&state.pool) + .await + .ok() + .flatten(); + if let Some(id) = existing { + let _ = sqlx::query( + "UPDATE alert_events SET value = ?, threshold = ?, severity = ?, message = ?, last_seen_at = ? WHERE id = ?", + ) + .bind(value) + .bind(threshold) + .bind(severity) + .bind(message) + .bind(now) + .bind(id) + .execute(&state.pool) + .await; + } else { + let event_id = Uuid::new_v4().to_string(); + let _ = sqlx::query( + "INSERT INTO alert_events(id, rule_id, agent_id, metric, value, threshold, severity, status, message, first_seen_at, last_seen_at) + VALUES(?, ?, ?, ?, ?, ?, ?, 'open', ?, ?, ?)", + ) + .bind(&event_id) + .bind(rule_id) + .bind(agent_id) + .bind(metric) + .bind(value) + .bind(threshold) + .bind(severity) + .bind(message) + .bind(&now) + .bind(&now) + .execute(&state.pool) + .await; + send_alert_webhook( + state, + "triggered", + &event_id, + agent_id, + rule_id, + metric, + value, + threshold, + severity, + &message, + &now, + ) + .await; + } +} + +#[allow(clippy::too_many_arguments)] +async fn send_alert_webhook( + state: &AppState, + event_type: &str, + event_id: &str, + agent_id: &str, + rule_id: &str, + metric: &str, + value: f64, + threshold: f64, + severity: &str, + message: &str, + created_at: &str, +) { + if !setting_bool(state, "notifications.webhook_enabled", false).await { + return; + } + let Some(url) = sqlx::query_scalar::<_, String>( + "SELECT value FROM settings WHERE key = 'notifications.webhook_url'", + ) + .fetch_optional(&state.pool) + .await + .ok() + .flatten() else { + return; + }; + let url = url.trim(); + if !is_safe_webhook_url(url) { + let _ = insert_notification_delivery( + state, + event_id, + "webhook", + "failed", + Some("Webhook URL 无效"), + ) + .await; + return; + } + let payload = json!({ + "source": "LightOps", + "event_type": event_type, + "event_id": event_id, + "agent_id": agent_id, + "rule_id": rule_id, + "metric": metric, + "value": value, + "threshold": threshold, + "severity": severity, + "message": message, + "created_at": created_at + }) + .to_string(); + let output = Command::new("curl") + .args([ + "-fsS", + "--max-time", + "10", + "-H", + "Content-Type: application/json", + "-d", + &payload, + url, + ]) + .output() + .await; + match output { + Ok(output) if output.status.success() => { + let status = if event_type == "resolved" { + "recovery_sent" + } else { + "sent" + }; + let _ = insert_notification_delivery(state, event_id, "webhook", status, None).await; + } + Ok(output) => { + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + let error = if stderr.trim().is_empty() { + String::from_utf8_lossy(&output.stdout).to_string() + } else { + stderr + }; + let _ = + insert_notification_delivery(state, event_id, "webhook", "failed", Some(&error)) + .await; + } + Err(err) => { + let _ = insert_notification_delivery( + state, + event_id, + "webhook", + "failed", + Some(&format!("执行 curl 失败:{err}")), + ) + .await; + } + } +} + +fn is_safe_webhook_url(url: &str) -> bool { + (url.starts_with("https://") || url.starts_with("http://")) + && url.len() <= 1000 + && !url.contains('\0') + && !url.contains('\n') + && !url.contains('\r') +} + +async fn insert_notification_delivery( + state: &AppState, + event_id: &str, + channel: &str, + status: &str, + error: Option<&str>, +) -> Result<(), sqlx::Error> { + sqlx::query( + "INSERT INTO notification_deliveries(id, event_id, channel, status, error, created_at) + VALUES(?, ?, ?, ?, ?, ?)", + ) + .bind(Uuid::new_v4().to_string()) + .bind(event_id) + .bind(channel) + .bind(status) + .bind(error.map(|value| value.chars().take(500).collect::())) + .bind(Utc::now().to_rfc3339()) + .execute(&state.pool) + .await?; + Ok(()) +} + +async fn resolve_alert_event(state: &AppState, agent_id: &str, rule_id: &str) { + let now = Utc::now().to_rfc3339(); + let existing = sqlx::query( + "SELECT * FROM alert_events WHERE rule_id = ? AND agent_id = ? AND status = 'open' LIMIT 1", + ) + .bind(rule_id) + .bind(agent_id) + .fetch_optional(&state.pool) + .await + .ok() + .flatten(); + let _ = sqlx::query( + "UPDATE alert_events SET status = 'resolved', resolved_at = ?, last_seen_at = ? WHERE rule_id = ? AND agent_id = ? AND status = 'open'", + ) + .bind(&now) + .bind(&now) + .bind(rule_id) + .bind(agent_id) + .execute(&state.pool) + .await; + if !setting_bool(state, "notifications.recovery_enabled", true).await { + return; + } + let notify_recovery = + sqlx::query_scalar::<_, i64>("SELECT notify_recovery FROM alert_rules WHERE id = ?") + .bind(rule_id) + .fetch_optional(&state.pool) + .await + .ok() + .flatten() + .unwrap_or(1) + == 1; + if !notify_recovery { + return; + } + if let Some(row) = existing { + let event_id: String = row.get("id"); + let metric: String = row.get("metric"); + let value: f64 = row.get("value"); + let threshold: f64 = row.get("threshold"); + let severity: String = row.get("severity"); + let message = format!( + "{} 已恢复:{}", + metric_label(&metric), + row.get::("message") + ); + send_alert_webhook( + state, "resolved", &event_id, agent_id, rule_id, &metric, value, threshold, &severity, + &message, &now, + ) + .await; + } +} + +fn metric_label(metric: &str) -> &'static str { + match metric { + "cpu_usage" => "CPU 使用率", + "memory_usage" => "内存使用率", + "disk_usage" => "磁盘使用率", + "ssl_days_remaining" => "SSL 证书剩余天数", + "app_health" => "应用健康", + _ => "指标", + } +} + +async fn sync_ssl_expiry_alert(state: &AppState, agent_id: &str, data: &Value) { + let threshold = 30.0; + let rule_id = "builtin-ssl-expiring"; + let now = Utc::now().to_rfc3339(); + let _ = sqlx::query( + "INSERT OR IGNORE INTO alert_rules(id, name, metric, operator, threshold, severity, enabled, created_at, updated_at) + VALUES(?, 'SSL 证书即将到期', 'ssl_days_remaining', '<=', ?, 'warning', 1, ?, ?)", + ) + .bind(rule_id) + .bind(threshold) + .bind(&now) + .bind(&now) + .execute(&state.pool) + .await; + + let mut min_days: Option = None; + if let Some(certs) = data.get("certs").and_then(Value::as_array) { + for cert in certs { + let Some(days) = cert.get("days_remaining").and_then(Value::as_f64) else { + continue; + }; + min_days = Some(min_days.map_or(days, |current| current.min(days))); + } + } + + if let Some(days) = min_days { + if days <= threshold { + open_or_update_alert_event( + state, + agent_id, + rule_id, + "ssl_days_remaining", + days, + threshold, + if days <= 7.0 { "critical" } else { "warning" }, + ) + .await; + } else { + resolve_alert_event(state, agent_id, rule_id).await; + } + } +} + +async fn task_cancel( + user: AuthUser, + State(state): State, + Path(task_id): Path, +) -> Result>, AppError> { + require_permission(&user, "tasks")?; + let row = sqlx::query("SELECT agent_id, action, status FROM tasks WHERE id = ?") + .bind(&task_id) + .fetch_optional(&state.pool) + .await? + .ok_or(AppError::NotFound)?; + let status: String = row.get("status"); + if !matches!(status.as_str(), "pending" | "running") { + return Err(AppError::BadRequest("只能取消等待中或运行中的任务".into())); + } + if let Some((_, tx)) = state.pending.remove(&task_id) { + let _ = tx.send(TaskReply { + success: false, + data: json!({}), + error: Some("任务已被用户取消".into()), + }); + } + state.pending_agents.remove(&task_id); + task::mark_task( + &state, + &task_id, + "cancelled", + None, + Some("任务已被用户取消"), + ) + .await?; + let agent_id: String = row.get("agent_id"); + let action: String = row.get("action"); + task::audit( + &state, + Some(user.id), + Some(&agent_id), + "task.cancel", + Some(&task_id), + Some(summarize(&json!({ "action": action }))), + true, + None, + ) + .await; + Ok(Json(ApiResponse::ok(json!({ "cancelled": true })))) +} + +async fn system_backups( + user: AuthUser, + State(state): State, +) -> Result>, AppError> { + require_permission(&user, "settings")?; + let dir = backup_dir(&state)?; + fs::create_dir_all(&dir).map_err(|_| AppError::Internal)?; + let mut items = Vec::new(); + let entries = fs::read_dir(&dir).map_err(|_| AppError::Internal)?; + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|value| value.to_str()) != Some("db") { + continue; + } + let Ok(meta) = entry.metadata() else { + continue; + }; + items.push(json!({ + "name": entry.file_name().to_string_lossy().to_string(), + "size": meta.len(), + "created_at": meta.created().ok().and_then(|time| time.duration_since(std::time::UNIX_EPOCH).ok()).map(|duration| duration.as_secs().to_string()), + "modified_at": meta.modified().ok().and_then(|time| time.duration_since(std::time::UNIX_EPOCH).ok()).map(|duration| duration.as_secs().to_string()) + })); + } + items.sort_by(|a, b| { + b.get("name") + .and_then(Value::as_str) + .cmp(&a.get("name").and_then(Value::as_str)) + }); + Ok(Json(ApiResponse::ok(json!(items)))) +} + +async fn create_system_backup( + user: AuthUser, + State(state): State, +) -> Result>, AppError> { + require_permission(&user, "settings.write")?; + let dir = backup_dir(&state)?; + fs::create_dir_all(&dir).map_err(|_| AppError::Internal)?; + let name = format!("lightops-{}.db", Utc::now().format("%Y%m%d%H%M%S")); + let path = dir.join(&name); + let path_text = path.to_string_lossy().replace('\'', "''"); + sqlx::query(&format!("VACUUM INTO '{}'", path_text)) + .execute(&state.pool) + .await?; + task::audit( + &state, + Some(user.id), + None, + "system.backup.create", + Some(&name), + None, + true, + None, + ) + .await; + Ok(Json(ApiResponse::ok(json!({ + "name": name, + "size": fs::metadata(&path).map(|meta| meta.len()).unwrap_or(0) + })))) +} + +async fn download_system_backup( + user: AuthUser, + State(state): State, + Path(name): Path, +) -> Result { + require_permission(&user, "settings")?; + let path = safe_backup_path(&state, &name)?; + let bytes = fs::read(&path).map_err(|_| AppError::NotFound)?; + let mut headers = HeaderMap::new(); + headers.insert( + header::CONTENT_TYPE, + HeaderValue::from_static("application/octet-stream"), + ); + headers.insert( + header::CONTENT_DISPOSITION, + HeaderValue::from_str(&format!("attachment; filename=\"{}\"", name)) + .map_err(|_| AppError::BadRequest("备份名称无效".into()))?, + ); + Ok((headers, bytes)) +} + +async fn restore_system_backup( + user: AuthUser, + State(state): State, + Path(name): Path, +) -> Result>, AppError> { + require_permission(&user, "settings.write")?; + let backup = safe_backup_path(&state, &name)?; + validate_sqlite_backup(&backup).await?; + let db_path = sqlite_database_path(&state.cfg.database_url)?; + let restore_copy = db_path.with_extension(format!( + "pre-restore-{}.db", + Utc::now().format("%Y%m%d%H%M%S") + )); + task::audit( + &state, + Some(user.id), + None, + "system.backup.restore", + Some(&name), + None, + true, + None, + ) + .await; + state.pool.close().await; + fs::copy(&db_path, &restore_copy).map_err(|_| AppError::Internal)?; + fs::copy(&backup, &db_path).map_err(|_| AppError::Internal)?; + tokio::spawn(async { + tokio::time::sleep(TokioDuration::from_millis(500)).await; + std::process::exit(0); + }); + Ok(Json(ApiResponse::ok(json!({ + "restored": true, + "message": "备份已恢复,服务即将退出并由 systemd 重启", + "pre_restore_backup": restore_copy.to_string_lossy() + })))) +} + +async fn delete_system_backup( + user: AuthUser, + State(state): State, + Path(name): Path, +) -> Result>, AppError> { + require_permission(&user, "settings.write")?; + let path = safe_backup_path(&state, &name)?; + fs::remove_file(&path).map_err(|_| AppError::NotFound)?; + task::audit( + &state, + Some(user.id), + None, + "system.backup.delete", + Some(&name), + None, + true, + None, + ) + .await; + Ok(Json(ApiResponse::empty())) +} + +async fn system_update_check( + user: AuthUser, + State(state): State, +) -> Result>, AppError> { + require_permission(&user, "settings")?; + let repo_dir = update_repo_dir(&state).await?; + let branch_setting = setting_string(&state, "updates.branch", "main").await; + let current_branch = git_output(&repo_dir, &["rev-parse", "--abbrev-ref", "HEAD"], 8) + .await + .unwrap_or_else(|_| branch_setting.clone()); + let branch = if current_branch == "HEAD" { + branch_setting + } else { + current_branch.trim().to_string() + }; + let current_commit = git_output(&repo_dir, &["rev-parse", "HEAD"], 8).await?; + let current_subject = git_output(&repo_dir, &["log", "-1", "--pretty=%s"], 8) + .await + .unwrap_or_default(); + let remote_url = git_output(&repo_dir, &["remote", "get-url", "origin"], 8).await?; + let dirty = !git_output(&repo_dir, &["status", "--porcelain"], 8) + .await + .unwrap_or_default() + .trim() + .is_empty(); + let fetch_result = git_output(&repo_dir, &["fetch", "--quiet", "origin", &branch], 60).await; + let fetch_error = fetch_result.as_ref().err().map(ToString::to_string); + let remote_ref = format!("origin/{branch}"); + let remote_commit = git_output(&repo_dir, &["rev-parse", &remote_ref], 8) + .await + .unwrap_or_default(); + let behind = if remote_commit.trim().is_empty() { + 0 + } else { + git_output( + &repo_dir, + &["rev-list", "--count", &format!("HEAD..{remote_ref}")], + 8, + ) + .await + .ok() + .and_then(|value| value.trim().parse::().ok()) + .unwrap_or(0) + }; + Ok(Json(ApiResponse::ok(json!({ + "repo_dir": repo_dir.to_string_lossy(), + "branch": branch, + "remote_url": redact_remote_url(remote_url.trim()), + "current_commit": current_commit.trim(), + "current_subject": current_subject.trim(), + "remote_commit": remote_commit.trim(), + "behind": behind, + "dirty": dirty, + "update_available": behind > 0, + "fetch_ok": fetch_error.is_none(), + "fetch_error": fetch_error, + })))) +} + +async fn system_update_run( + user: AuthUser, + State(state): State, + Json(req): Json, +) -> Result>, AppError> { + require_permission(&user, "settings.write")?; + let expected = "system-update"; + if req.get("confirm_target").and_then(Value::as_str) != Some(expected) { + return Err(AppError::BadRequest("系统更新确认目标不匹配".into())); + } + let repo_dir = update_repo_dir(&state).await?; + let branch = setting_string(&state, "updates.branch", "main").await; + let script_path = + setting_string(&state, "updates.script_path", "scripts/update-from-git.sh").await; + if script_path.contains('\0') { + return Err(AppError::BadRequest("更新脚本路径无效".into())); + } + let script = if FsPath::new(&script_path).is_absolute() { + PathBuf::from(&script_path) + } else { + repo_dir.join(&script_path) + }; + if !script.is_file() { + return Err(AppError::BadRequest(format!( + "更新脚本不存在:{}", + script.to_string_lossy() + ))); + } + let unit = format!("lightops-update-{}", Utc::now().format("%Y%m%d%H%M%S")); + let args = vec![ + "--unit".to_string(), + unit.clone(), + "bash".to_string(), + script.to_string_lossy().to_string(), + "--repo-dir".to_string(), + repo_dir.to_string_lossy().to_string(), + "--branch".to_string(), + branch.clone(), + ]; + let systemd_run = Command::new("systemd-run").args(&args).output().await; + let (started_by, stdout, stderr) = match systemd_run { + Ok(output) if output.status.success() => ( + "systemd-run", + String::from_utf8_lossy(&output.stdout).to_string(), + String::from_utf8_lossy(&output.stderr).to_string(), + ), + _ => { + let mut child = Command::new("nohup"); + child + .arg("bash") + .arg(&script) + .arg("--repo-dir") + .arg(&repo_dir) + .arg("--branch") + .arg(&branch) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + child + .spawn() + .map_err(|err| AppError::BadRequest(format!("启动更新脚本失败:{err}")))?; + ( + "nohup", + String::new(), + String::from("未使用 systemd-run,已通过 nohup 后台启动"), + ) + } + }; + task::audit( + &state, + Some(user.id), + None, + "system.update.run", + Some(expected), + Some(summarize(&json!({ + "repo_dir": repo_dir.to_string_lossy(), + "branch": branch, + "script": script.to_string_lossy() + }))), + true, + None, + ) + .await; + Ok(Json(ApiResponse::ok(json!({ + "started": true, + "started_by": started_by, + "unit": unit, + "stdout": stdout, + "stderr": stderr, + "log_path": "/var/log/lightops/update.log" + })))) +} + +fn backup_dir(state: &AppState) -> Result { + let db_path = sqlite_database_path(&state.cfg.database_url)?; + Ok(db_path + .parent() + .unwrap_or_else(|| FsPath::new(".")) + .join("backups")) +} + +async fn update_repo_dir(state: &AppState) -> Result { + let raw = setting_string(state, "updates.repo_dir", ".").await; + if raw.contains('\0') { + return Err(AppError::BadRequest("仓库目录无效".into())); + } + let path = PathBuf::from(raw); + let path = if path.is_absolute() { + path + } else { + std::env::current_dir() + .map_err(|_| AppError::Internal)? + .join(path) + }; + let path = path + .canonicalize() + .map_err(|_| AppError::BadRequest("仓库目录不存在".into()))?; + if !path.join(".git").exists() { + return Err(AppError::BadRequest("仓库目录不是 Git 仓库".into())); + } + Ok(path) +} + +async fn git_output( + repo_dir: &FsPath, + args: &[&str], + timeout_secs: u64, +) -> Result { + let output = tokio::time::timeout( + TokioDuration::from_secs(timeout_secs), + Command::new("git") + .args(args) + .current_dir(repo_dir) + .output(), + ) + .await + .map_err(|_| AppError::Timeout)? + .map_err(|err| AppError::BadRequest(format!("执行 git 失败:{err}")))?; + if output.status.success() { + Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) + } else { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + Err(AppError::BadRequest(if stderr.is_empty() { + "git 命令执行失败".into() + } else { + stderr + })) + } +} + +async fn setting_string(state: &AppState, key: &str, default: &str) -> String { + sqlx::query_scalar::<_, String>("SELECT value FROM settings WHERE key = ?") + .bind(key) + .fetch_optional(&state.pool) + .await + .ok() + .flatten() + .unwrap_or_else(|| default.to_string()) +} + +fn redact_remote_url(url: &str) -> String { + if let Some((scheme, rest)) = url.split_once("://") { + if let Some((_, host)) = rest.rsplit_once('@') { + return format!("{scheme}://***@{host}"); + } + } + url.to_string() +} + +fn safe_backup_path(state: &AppState, name: &str) -> Result { + if name.contains('/') + || name.contains('\\') + || name.contains("..") + || name.contains('\0') + || !name.ends_with(".db") + { + return Err(AppError::BadRequest("备份名称无效".into())); + } + Ok(backup_dir(state)?.join(name)) +} + +fn sqlite_database_path(database_url: &str) -> Result { + let without_scheme = database_url + .strip_prefix("sqlite://") + .or_else(|| database_url.strip_prefix("sqlite:")) + .ok_or_else(|| AppError::BadRequest("当前只支持 SQLite 数据库备份".into()))?; + let raw = without_scheme.split('?').next().unwrap_or(without_scheme); + if raw.is_empty() || raw == ":memory:" { + return Err(AppError::BadRequest("内存数据库不支持备份恢复".into())); + } + let path = if raw.starts_with('/') { + PathBuf::from(raw) + } else { + PathBuf::from(raw.trim_start_matches('/')) + }; + Ok(path) +} + +async fn validate_sqlite_backup(path: &FsPath) -> Result<(), AppError> { + let url = format!("sqlite://{}", path.to_string_lossy().replace('\\', "/")); + let pool = sqlx::SqlitePool::connect(&url).await?; + let result: String = sqlx::query_scalar("PRAGMA integrity_check") + .fetch_one(&pool) + .await?; + pool.close().await; + if result == "ok" { + Ok(()) + } else { + Err(AppError::BadRequest("备份文件完整性校验失败".into())) + } +} + +async fn get_settings( + user: AuthUser, + State(state): State, +) -> Result>, AppError> { + require_permission(&user, "settings")?; + let rows = sqlx::query("SELECT key, value FROM settings ORDER BY key") + .fetch_all(&state.pool) + .await?; + let mut map = serde_json::Map::new(); + for row in rows { + map.insert( + row.get::("key"), + json!(row.get::("value")), + ); + } + Ok(Json(ApiResponse::ok(Value::Object(map)))) +} + +async fn update_settings( + user: AuthUser, + State(state): State, + Json(req): Json, +) -> Result>, AppError> { + require_permission(&user, "settings.write")?; + let Some(map) = req.as_object() else { + return Err(AppError::BadRequest("设置内容必须是对象".into())); + }; + for (key, value) in map { + if matches!(key.as_str(), "confirm_target" | "confirm") { + continue; + } + if !is_allowed_setting_key(key) { + return Err(AppError::BadRequest(format!("不允许修改设置项:{key}"))); + } + let text = value + .as_str() + .map(ToString::to_string) + .unwrap_or_else(|| value.to_string()); + sqlx::query("INSERT INTO settings(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value") + .bind(key) + .bind(text) + .execute(&state.pool) + .await?; + } + task::audit( + &state, + Some(user.id), + None, + "settings.update", + None, + Some(summarize(&req)), + true, + None, + ) + .await; + get_settings(user, State(state)).await +} + +async fn test_webhook( + user: AuthUser, + State(state): State, + Json(req): Json, +) -> Result>, AppError> { + require_permission(&user, "settings.write")?; + let url = if let Some(url) = req + .get("url") + .and_then(Value::as_str) + .or_else(|| req.get("webhook_url").and_then(Value::as_str)) + { + url.to_string() + } else { + sqlx::query_scalar::<_, String>( + "SELECT value FROM settings WHERE key = 'notifications.webhook_url'", + ) + .fetch_optional(&state.pool) + .await? + .unwrap_or_default() + }; + let url = url.trim().to_string(); + if !is_safe_webhook_url(&url) { + return Err(AppError::BadRequest("Webhook URL 无效".into())); + } + let message = req + .get("message") + .and_then(Value::as_str) + .unwrap_or("LightOps Webhook 测试"); + let payload = json!({ + "source": "LightOps", + "type": "webhook_test", + "message": message, + "created_at": Utc::now().to_rfc3339() + }) + .to_string(); + let output = Command::new("curl") + .args([ + "-fsS", + "--max-time", + "10", + "-H", + "Content-Type: application/json", + "-d", + &payload, + &url, + ]) + .output() + .await + .map_err(|err| AppError::BadRequest(format!("执行 curl 失败:{err}")))?; + let success = output.status.success(); + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + task::audit( + &state, + Some(user.id), + None, + "notification.webhook.test", + Some("webhook"), + Some(summarize(&json!({ "url": url }))), + success, + if success { None } else { Some(&stderr) }, + ) + .await; + if !success { + return Err(AppError::BadRequest(if stderr.trim().is_empty() { + stdout + } else { + stderr + })); + } + Ok(Json(ApiResponse::ok( + json!({ "ok": true, "stdout": stdout, "stderr": stderr }), + ))) +} + +async fn notification_deliveries( + user: AuthUser, + State(state): State, + Query(q): Query, +) -> Result>, AppError> { + require_permission(&user, "alerts")?; + let rows = sqlx::query( + "SELECT d.*, e.agent_id, e.message + FROM notification_deliveries d + LEFT JOIN alert_events e ON e.id = d.event_id + ORDER BY d.created_at DESC + LIMIT ? OFFSET ?", + ) + .bind(q.limit.unwrap_or(100).min(500)) + .bind(q.offset.unwrap_or(0)) + .fetch_all(&state.pool) + .await?; + let data = rows + .into_iter() + .map(|row| { + json!({ + "id": row.get::("id"), + "event_id": row.get::("event_id"), + "agent_id": row.get::, _>("agent_id"), + "message": row.get::, _>("message"), + "channel": row.get::("channel"), + "status": row.get::("status"), + "error": row.get::, _>("error"), + "created_at": row.get::("created_at") + }) + }) + .collect::>(); + Ok(Json(ApiResponse::ok(json!(data)))) +} + +fn is_allowed_setting_key(key: &str) -> bool { + matches!( + key, + "security.terminal_enabled" + | "security.file_write_enabled" + | "security.require_danger_confirm" + | "agent.offline_after_seconds" + | "metrics.retention_days" + | "apps.health_check_enabled" + | "apps.health_alert_enabled" + | "apps.health_check_interval_seconds" + | "apps.health_check_batch_size" + | "apps.health_check_timeout_seconds" + | "updates.repo_dir" + | "updates.branch" + | "updates.script_path" + | "alerts.enabled" + | "notifications.webhook_enabled" + | "notifications.webhook_url" + | "notifications.recovery_enabled" + ) +} + +async fn permission_catalog(user: AuthUser) -> Result>, AppError> { + require_admin(&user)?; + Ok(Json(ApiResponse::ok(json!([ + { "key": "*", "label": "全部权限" }, + { "key": "agents", "label": "主机查看" }, + { "key": "tasks", "label": "任务查看与重试" }, + { "key": "file", "label": "文件管理" }, + { "key": "file.read", "label": "文件读取" }, + { "key": "file.write", "label": "文件写入" }, + { "key": "file.danger", "label": "文件危险操作" }, + { "key": "terminal", "label": "终端" }, + { "key": "terminal.open", "label": "打开终端" }, + { "key": "logs", "label": "日志查看" }, + { "key": "logs.read", "label": "日志读取" }, + { "key": "service", "label": "服务管理" }, + { "key": "service.read", "label": "服务查看" }, + { "key": "service.write", "label": "服务启停重启" }, + { "key": "service.danger", "label": "服务危险操作" }, + { "key": "nginx", "label": "Nginx 管理" }, + { "key": "nginx.read", "label": "Nginx 查看" }, + { "key": "nginx.write", "label": "Nginx 配置修改" }, + { "key": "nginx.danger", "label": "Nginx 危险操作" }, + { "key": "docker", "label": "Docker 管理" }, + { "key": "docker.read", "label": "Docker 查看" }, + { "key": "docker.write", "label": "Docker 变更操作" }, + { "key": "docker.danger", "label": "Docker 危险操作" }, + { "key": "apps", "label": "应用管理" }, + { "key": "apps.read", "label": "应用查看" }, + { "key": "apps.write", "label": "应用变更操作" }, + { "key": "apps.danger", "label": "应用危险操作" }, + { "key": "alerts", "label": "告警查看" }, + { "key": "alerts.write", "label": "告警规则管理" }, + { "key": "settings", "label": "设置查看" }, + { "key": "settings.write", "label": "设置修改" } + ])))) +} + +#[derive(Debug, Deserialize)] +struct AlertQuery { + status: Option, + agent_id: Option, + severity: Option, + limit: Option, +} + +async fn alert_rules( + user: AuthUser, + State(state): State, +) -> Result>, AppError> { + require_permission(&user, "alerts")?; + let rows = sqlx::query("SELECT * FROM alert_rules ORDER BY created_at DESC") + .fetch_all(&state.pool) + .await?; + let data = rows + .into_iter() + .map(|r| { + json!({ + "id": r.get::("id"), + "name": r.get::("name"), + "metric": r.get::("metric"), + "operator": r.get::("operator"), + "threshold": r.get::("threshold"), + "duration_seconds": r.get::("duration_seconds"), + "severity": r.get::("severity"), + "agent_id": r.get::, _>("agent_id"), + "enabled": r.get::("enabled") == 1, + "silence_until": r.try_get::, _>("silence_until").ok().flatten(), + "notify_recovery": r.try_get::("notify_recovery").unwrap_or(1) == 1, + "created_at": r.get::("created_at"), + "updated_at": r.get::("updated_at") + }) + }) + .collect::>(); + Ok(Json(ApiResponse::ok(json!(data)))) +} + +async fn create_alert_rule( + user: AuthUser, + State(state): State, + Json(req): Json, +) -> Result>, AppError> { + require_permission(&user, "alerts.write")?; + let id = Uuid::new_v4().to_string(); + upsert_alert_rule(&state, &id, &req).await?; + task::audit( + &state, + Some(user.id), + None, + "alert_rule.create", + Some(&id), + Some(summarize(&req)), + true, + None, + ) + .await; + Ok(Json(ApiResponse::ok(json!({ "id": id })))) +} + +async fn update_alert_rule( + user: AuthUser, + State(state): State, + Path(rule_id): Path, + Json(req): Json, +) -> Result>, AppError> { + require_permission(&user, "alerts.write")?; + upsert_alert_rule(&state, &rule_id, &req).await?; + task::audit( + &state, + Some(user.id), + None, + "alert_rule.update", + Some(&rule_id), + Some(summarize(&req)), + true, + None, + ) + .await; + Ok(Json(ApiResponse::ok(json!({ "id": rule_id })))) +} + +async fn delete_alert_rule( + user: AuthUser, + State(state): State, + Path(rule_id): Path, +) -> Result>, AppError> { + require_permission(&user, "alerts.write")?; + sqlx::query("DELETE FROM alert_rules WHERE id = ?") + .bind(&rule_id) + .execute(&state.pool) + .await?; + task::audit( + &state, + Some(user.id), + None, + "alert_rule.delete", + Some(&rule_id), + None, + true, + None, + ) + .await; + Ok(Json(ApiResponse::empty())) +} + +async fn alert_events( + user: AuthUser, + State(state): State, + Query(q): Query, +) -> Result>, AppError> { + require_permission(&user, "alerts")?; + let rows = sqlx::query("SELECT * FROM alert_events ORDER BY last_seen_at DESC LIMIT ?") + .bind(q.limit.unwrap_or(100).min(500)) + .fetch_all(&state.pool) + .await?; + let data = rows + .into_iter() + .map(|r| { + json!({ + "id": r.get::("id"), + "rule_id": r.get::("rule_id"), + "agent_id": r.get::("agent_id"), + "metric": r.get::("metric"), + "value": r.get::("value"), + "threshold": r.get::("threshold"), + "severity": r.get::("severity"), + "status": r.get::("status"), + "message": r.get::("message"), + "first_seen_at": r.get::("first_seen_at"), + "last_seen_at": r.get::("last_seen_at"), + "resolved_at": r.get::, _>("resolved_at"), + }) + }) + .filter(|event| { + q.status + .as_ref() + .is_none_or(|status| event.get("status").and_then(Value::as_str) == Some(status)) + }) + .filter(|event| { + q.agent_id.as_ref().is_none_or(|agent_id| { + event.get("agent_id").and_then(Value::as_str) == Some(agent_id) + }) + }) + .filter(|event| { + q.severity.as_ref().is_none_or(|severity| { + event.get("severity").and_then(Value::as_str) == Some(severity) + }) + }) + .collect::>(); + Ok(Json(ApiResponse::ok(json!(data)))) +} + +async fn ack_alert_event( + user: AuthUser, + State(state): State, + Path(event_id): Path, +) -> Result>, AppError> { + require_permission(&user, "alerts.write")?; + let now = Utc::now().to_rfc3339(); + sqlx::query("UPDATE alert_events SET status = 'ack', resolved_at = ? WHERE id = ?") + .bind(now) + .bind(&event_id) + .execute(&state.pool) + .await?; + task::audit( + &state, + Some(user.id), + None, + "alert_event.ack", + Some(&event_id), + None, + true, + None, + ) + .await; + Ok(Json(ApiResponse::ok(json!({ "ack": true })))) +} + +async fn upsert_alert_rule(state: &AppState, id: &str, req: &Value) -> Result<(), AppError> { + let name = req + .get("name") + .and_then(Value::as_str) + .ok_or_else(|| AppError::BadRequest("缺少告警规则名称".into()))?; + let metric = req + .get("metric") + .and_then(Value::as_str) + .ok_or_else(|| AppError::BadRequest("缺少指标".into()))?; + if !matches!( + metric, + "cpu_usage" | "memory_usage" | "disk_usage" | "ssl_days_remaining" | "app_health" + ) { + return Err(AppError::BadRequest( + "暂只支持 CPU、内存、磁盘、SSL 到期告警".into(), + )); + } + let threshold = req + .get("threshold") + .and_then(Value::as_f64) + .ok_or_else(|| AppError::BadRequest("缺少阈值".into()))?; + let operator = req.get("operator").and_then(Value::as_str).unwrap_or(">="); + let severity = req + .get("severity") + .and_then(Value::as_str) + .unwrap_or("warning"); + let agent_id = req.get("agent_id").and_then(Value::as_str); + let enabled = req.get("enabled").and_then(Value::as_bool).unwrap_or(true); + let notify_recovery = req + .get("notify_recovery") + .and_then(Value::as_bool) + .unwrap_or(true); + let silence_until = req + .get("silence_until") + .and_then(Value::as_str) + .filter(|value| !value.trim().is_empty()); + let now = Utc::now().to_rfc3339(); + sqlx::query( + "INSERT INTO alert_rules(id, name, metric, operator, threshold, duration_seconds, severity, agent_id, enabled, silence_until, notify_recovery, created_at, updated_at) + VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET name=excluded.name, metric=excluded.metric, operator=excluded.operator, + threshold=excluded.threshold, duration_seconds=excluded.duration_seconds, severity=excluded.severity, + agent_id=excluded.agent_id, enabled=excluded.enabled, silence_until=excluded.silence_until, + notify_recovery=excluded.notify_recovery, updated_at=excluded.updated_at", + ) + .bind(id) + .bind(name) + .bind(metric) + .bind(operator) + .bind(threshold) + .bind(req.get("duration_seconds").and_then(Value::as_i64).unwrap_or(0)) + .bind(severity) + .bind(agent_id) + .bind(if enabled { 1 } else { 0 }) + .bind(silence_until) + .bind(if notify_recovery { 1 } else { 0 }) + .bind(&now) + .bind(&now) + .execute(&state.pool) + .await?; + Ok(()) +} + +async fn list_tasks( + state: &AppState, + q: TaskListQuery, + forced_agent_id: Option, + allowed_agents: Option>, +) -> Result>, AppError> { + let rows = sqlx::query("SELECT * FROM tasks ORDER BY created_at DESC LIMIT ? OFFSET ?") + .bind(q.limit.unwrap_or(100).min(500)) + .bind(q.offset.unwrap_or(0)) + .fetch_all(&state.pool) + .await?; + let agent_filter = forced_agent_id.or(q.agent_id); + let data = rows + .into_iter() + .map(task_row_json) + .filter(|task| { + agent_filter.as_ref().is_none_or(|agent_id| { + task.get("agent_id").and_then(Value::as_str) == Some(agent_id) + }) + }) + .filter(|task| { + allowed_agents.as_ref().is_none_or(|agents| { + task.get("agent_id") + .and_then(Value::as_str) + .is_some_and(|agent_id| agents.iter().any(|item| item == agent_id)) + }) + }) + .filter(|task| { + q.status + .as_ref() + .is_none_or(|status| task.get("status").and_then(Value::as_str) == Some(status)) + }) + .filter(|task| { + q.action.as_ref().is_none_or(|action| { + task.get("action") + .and_then(Value::as_str) + .unwrap_or_default() + .contains(action) + }) + }) + .collect::>(); + Ok(Json(ApiResponse::ok(json!(data)))) +} + +fn task_row_json(r: sqlx::sqlite::SqliteRow) -> Value { + json!({ + "id": r.get::("id"), + "agent_id": r.get::("agent_id"), + "user_id": r.get::, _>("user_id"), + "action": r.get::("action"), + "params_json": r.get::("params_json"), + "status": r.get::("status"), + "result_json": r.get::, _>("result_json"), + "error": r.get::, _>("error"), + "created_at": r.get::("created_at"), + "started_at": r.get::, _>("started_at"), + "finished_at": r.get::, _>("finished_at"), + }) +} + +async fn audit_logs( + _user: AuthUser, + State(state): State, + Query(q): Query, +) -> Result>, AppError> { + let rows = sqlx::query("SELECT * FROM audit_logs ORDER BY created_at DESC LIMIT ? OFFSET ?") + .bind(q.limit.unwrap_or(100).min(500)) + .bind(q.offset.unwrap_or(0)) + .fetch_all(&state.pool) + .await?; + let data: Vec = rows + .into_iter() + .map(|r| { + json!({ + "id": r.get::("id"), + "user_id": r.get::, _>("user_id"), + "agent_id": r.get::, _>("agent_id"), + "action": r.get::("action"), + "target": r.get::, _>("target"), + "params_summary": r.get::, _>("params_summary"), + "success": r.get::("success") == 1, + "error": r.get::, _>("error"), + "created_at": r.get::("created_at"), + }) + }) + .collect(); + Ok(Json(ApiResponse::ok(json!(data)))) +} diff --git a/crates/lightops-server/src/main.rs b/crates/lightops-server/src/main.rs new file mode 100644 index 0000000..631e859 --- /dev/null +++ b/crates/lightops-server/src/main.rs @@ -0,0 +1,121 @@ +mod apps; +mod auth; +mod config; +mod db; +mod error; +mod handlers; +mod maintenance; +mod state; +mod store; +mod task; + +use anyhow::Result; +use axum::{http::header, response::IntoResponse, routing::get, Router}; +use clap::Parser; +use config::ServerConfig; +use sqlx::sqlite::SqlitePoolOptions; +use std::net::SocketAddr; +use tokio::net::TcpListener; +use tower_http::{ + cors::CorsLayer, + services::{ServeDir, ServeFile}, + trace::TraceLayer, +}; +use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; + +#[derive(Debug, Parser)] +struct Args { + #[arg(long, default_value = "config/server.toml")] + config: String, +} + +#[tokio::main] +async fn main() -> Result<()> { + tracing_subscriber::registry() + .with( + tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()), + ) + .with(tracing_subscriber::fmt::layer()) + .init(); + + let args = Args::parse(); + let cfg = ServerConfig::load(&args.config)?; + let pool = SqlitePoolOptions::new() + .max_connections(8) + .connect(&cfg.database_url) + .await?; + db::migrate(&pool).await?; + + let state = state::AppState::new(pool, cfg.clone()); + maintenance::spawn(state.clone()); + let app = api_router(state.clone()) + .route("/install-agent.sh", get(install_agent_script)) + .route("/upgrade-agent.sh", get(upgrade_agent_script)) + .route("/uninstall-agent.sh", get(uninstall_agent_script)) + .fallback_service( + ServeDir::new(&cfg.static_dir) + .append_index_html_on_directories(true) + .not_found_service(ServeFile::new(format!("{}/index.html", cfg.static_dir))), + ) + .layer(CorsLayer::permissive()) + .layer(TraceLayer::new_for_http()); + + let addr: SocketAddr = cfg.bind.parse()?; + let listener = TcpListener::bind(addr).await?; + tracing::info!("LightOps 服务已监听 {}", addr); + axum::serve( + listener, + app.into_make_service_with_connect_info::(), + ) + .with_graceful_shutdown(shutdown_signal()) + .await?; + Ok(()) +} + +fn api_router(state: state::AppState) -> Router { + Router::new() + .nest("/api", handlers::router()) + .with_state(state) +} + +async fn install_agent_script() -> impl IntoResponse { + ( + [(header::CONTENT_TYPE, "text/x-shellscript; charset=utf-8")], + include_str!("../../../scripts/install-agent.sh"), + ) +} + +async fn upgrade_agent_script() -> impl IntoResponse { + ( + [(header::CONTENT_TYPE, "text/x-shellscript; charset=utf-8")], + include_str!("../../../scripts/upgrade-agent.sh"), + ) +} + +async fn uninstall_agent_script() -> impl IntoResponse { + ( + [(header::CONTENT_TYPE, "text/x-shellscript; charset=utf-8")], + include_str!("../../../scripts/uninstall-agent.sh"), + ) +} + +async fn shutdown_signal() { + let ctrl_c = async { + let _ = tokio::signal::ctrl_c().await; + }; + + #[cfg(unix)] + let terminate = async { + let mut signal = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) + .expect("install signal handler"); + signal.recv().await; + }; + + #[cfg(not(unix))] + let terminate = std::future::pending::<()>(); + + tokio::select! { + _ = ctrl_c => {}, + _ = terminate => {}, + } +} diff --git a/crates/lightops-server/src/maintenance.rs b/crates/lightops-server/src/maintenance.rs new file mode 100644 index 0000000..72da4ed --- /dev/null +++ b/crates/lightops-server/src/maintenance.rs @@ -0,0 +1,70 @@ +use crate::state::AppState; +use chrono::{Duration, Utc}; +use sqlx::Row; + +pub fn spawn(state: AppState) { + tokio::spawn(async move { + let mut interval = tokio::time::interval(std::time::Duration::from_secs(60)); + loop { + interval.tick().await; + if let Err(err) = run_once(&state).await { + tracing::warn!(?err, "后台维护任务执行失败"); + } + } + }); +} + +async fn run_once(state: &AppState) -> anyhow::Result<()> { + mark_stale_agents_offline(state).await?; + prune_old_metrics(state).await?; + crate::apps::run_scheduled_health_checks(state).await?; + Ok(()) +} + +async fn mark_stale_agents_offline(state: &AppState) -> anyhow::Result<()> { + let offline_after = setting_i64(state, "agent.offline_after_seconds", 120) + .await + .max(30); + let cutoff = (Utc::now() - Duration::seconds(offline_after)).to_rfc3339(); + let rows = sqlx::query( + "SELECT id FROM agents WHERE status = 'online' AND last_seen_at IS NOT NULL AND last_seen_at < ?", + ) + .bind(&cutoff) + .fetch_all(&state.pool) + .await?; + + for row in rows { + let agent_id: String = row.get("id"); + state.agents.write().await.remove(&agent_id); + crate::task::fail_agent_pending_tasks(state, &agent_id, "Agent 心跳超时").await; + sqlx::query("UPDATE agents SET status = 'offline', updated_at = ? WHERE id = ?") + .bind(Utc::now().to_rfc3339()) + .bind(&agent_id) + .execute(&state.pool) + .await?; + } + Ok(()) +} + +async fn prune_old_metrics(state: &AppState) -> anyhow::Result<()> { + let retention_days = setting_i64(state, "metrics.retention_days", 30) + .await + .max(1); + let modifier = format!("-{retention_days} days"); + sqlx::query("DELETE FROM agent_metrics WHERE created_at < datetime('now', ?)") + .bind(modifier) + .execute(&state.pool) + .await?; + Ok(()) +} + +async fn setting_i64(state: &AppState, key: &str, default: i64) -> i64 { + sqlx::query_scalar::<_, String>("SELECT value FROM settings WHERE key = ?") + .bind(key) + .fetch_optional(&state.pool) + .await + .ok() + .flatten() + .and_then(|value| value.parse::().ok()) + .unwrap_or(default) +} diff --git a/crates/lightops-server/src/state.rs b/crates/lightops-server/src/state.rs new file mode 100644 index 0000000..e995ee7 --- /dev/null +++ b/crates/lightops-server/src/state.rs @@ -0,0 +1,43 @@ +use crate::config::ServerConfig; +use dashmap::DashMap; +use lightops_common::protocol::{AgentMessage, ServerMessage}; +use serde_json::Value; +use sqlx::SqlitePool; +use std::{collections::HashMap, sync::Arc}; +use tokio::sync::{mpsc, oneshot, RwLock}; + +#[derive(Clone)] +pub struct AppState { + pub pool: SqlitePool, + pub cfg: Arc, + pub agents: Arc>>, + pub pending: Arc>>, + pub pending_agents: Arc>, + pub streams: Arc>>, +} + +#[derive(Clone)] +pub struct AgentHandle { + pub connection_id: String, + pub tx: mpsc::UnboundedSender, +} + +#[derive(Debug)] +pub struct TaskReply { + pub success: bool, + pub data: Value, + pub error: Option, +} + +impl AppState { + pub fn new(pool: SqlitePool, cfg: ServerConfig) -> Self { + Self { + pool, + cfg: Arc::new(cfg), + agents: Arc::new(RwLock::new(HashMap::new())), + pending: Arc::new(DashMap::new()), + pending_agents: Arc::new(DashMap::new()), + streams: Arc::new(DashMap::new()), + } + } +} diff --git a/crates/lightops-server/src/store.rs b/crates/lightops-server/src/store.rs new file mode 100644 index 0000000..afb2cc4 --- /dev/null +++ b/crates/lightops-server/src/store.rs @@ -0,0 +1,1225 @@ +use crate::{ + auth::AuthUser, + error::AppError, + state::AppState, + task::{ + self, request_agent_task_with_timeout, request_agent_task_with_timeout_and_stored_params, + }, +}; +use axum::{ + extract::{Path, Query, State}, + routing::{delete, get, post}, + Json, Router, +}; +use chrono::Utc; +use lightops_common::api::ApiResponse; +use serde::Deserialize; +use serde_json::{json, Value}; +use sqlx::Row; +use std::{fs, path::Path}; +use uuid::Uuid; + +const STORE_INSTALL_TIMEOUT_SECS: u64 = 600; +const STORE_CATALOG_DIR: &str = "store/catalog"; + +pub fn router() -> Router { + Router::new() + .route("/app-store", get(list_store_apps)) + .route("/app-store/installations", get(list_installations)) + .route( + "/app-store/installations/:install_id/start", + post(start_installation), + ) + .route( + "/app-store/installations/:install_id/stop", + post(stop_installation), + ) + .route( + "/app-store/installations/:install_id/restart", + post(restart_installation), + ) + .route( + "/app-store/installations/:install_id/update", + post(update_installation), + ) + .route( + "/app-store/installations/:install_id/logs", + get(logs_installation), + ) + .route( + "/app-store/installations/:install_id", + delete(uninstall_installation), + ) + .route("/app-store/:slug", get(get_store_app)) + .route( + "/agents/:id/app-store/:slug/install", + post(install_store_app), + ) +} + +#[derive(Debug, Deserialize)] +struct StoreQuery { + q: Option, + category: Option, +} + +#[derive(Debug, Deserialize)] +struct InstallQuery { + agent_id: Option, +} + +#[derive(Debug, Deserialize)] +struct LogsQuery { + tail: Option, +} + +#[derive(Debug, Clone)] +struct StoreApp { + slug: String, + name: String, + description: String, + category: String, + image: String, + default_port: u16, + container_port: u16, + extra_ports: Vec, + data_dir_suffix: String, + tags: Vec, + compose_template: Option, + fields: Vec, +} + +#[derive(Debug, Clone)] +struct StoreInstall { + id: String, + agent_id: String, + name: String, + project: String, + status: String, + params: Value, +} + +#[derive(Debug, Deserialize)] +struct StoreCatalogFile { + apps: Vec, +} + +#[derive(Debug, Deserialize)] +struct StoreAppConfig { + slug: String, + name: String, + description: String, + category: String, + image: String, + default_port: u16, + container_port: u16, + extra_ports: Option>, + data_dir_suffix: Option, + tags: Option>, + compose_template: Option, + fields: Option>, +} + +#[derive(Debug, Clone)] +struct StoreInstallField { + key: String, + label: String, + field_type: String, + required: bool, + default: Option, + placeholder: Option, + help: Option, + sensitive: bool, + options: Vec, + min: Option, + max: Option, +} + +#[derive(Debug, Deserialize)] +struct StoreInstallFieldConfig { + key: String, + label: String, + #[serde(rename = "type")] + field_type: String, + required: Option, + default: Option, + placeholder: Option, + help: Option, + sensitive: Option, + options: Option>, + min: Option, + max: Option, +} + +async fn list_store_apps( + user: AuthUser, + Query(q): Query, +) -> Result>, AppError> { + require_store_permission(&user)?; + let apps = catalog() + .into_iter() + .filter(|app| { + q.category + .as_ref() + .is_none_or(|category| app.category == category) + }) + .filter(|app| { + q.q.as_ref().is_none_or(|keyword| { + let text = format!( + "{} {} {} {}", + app.slug, + app.name, + app.description, + app.tags.join(" ") + ) + .to_lowercase(); + text.contains(&keyword.to_lowercase()) + }) + }) + .map(store_app_json) + .collect::>(); + Ok(Json(ApiResponse::ok(json!(apps)))) +} + +async fn get_store_app( + user: AuthUser, + Path(slug): Path, +) -> Result>, AppError> { + require_store_permission(&user)?; + let app = find_store_app(&slug)?; + Ok(Json(ApiResponse::ok(store_app_json(app)))) +} + +async fn list_installations( + user: AuthUser, + State(state): State, + Query(q): Query, +) -> Result>, AppError> { + require_store_permission(&user)?; + let rows = if user.role == "admin" { + if let Some(agent_id) = q.agent_id { + sqlx::query( + "SELECT * FROM app_store_installs WHERE agent_id = ? ORDER BY updated_at DESC", + ) + .bind(agent_id) + .fetch_all(&state.pool) + .await? + } else { + sqlx::query("SELECT * FROM app_store_installs ORDER BY updated_at DESC") + .fetch_all(&state.pool) + .await? + } + } else { + sqlx::query( + "SELECT i.* FROM app_store_installs i + INNER JOIN agent_access aa ON aa.agent_id = i.agent_id + WHERE aa.user_id = ? ORDER BY i.updated_at DESC", + ) + .bind(user.id) + .fetch_all(&state.pool) + .await? + }; + let data = rows.into_iter().map(install_row_json).collect::>(); + Ok(Json(ApiResponse::ok(json!(data)))) +} + +async fn install_store_app( + user: AuthUser, + State(state): State, + Path((agent_id, slug)): Path<(String, String)>, + Json(req): Json, +) -> Result>, AppError> { + require_store_permission(&user)?; + require_agent_access(&user, &state, &agent_id).await?; + let app = find_store_app(&slug)?; + let project = req + .get("project") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(&app.slug); + validate_compose_project(project)?; + let port = req + .get("port") + .and_then(Value::as_u64) + .unwrap_or(app.default_port as u64); + validate_port(port)?; + let data_dir = req + .get("data_dir") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string) + .unwrap_or_else(|| format!("/opt/lightops-store/{project}/{}", app.data_dir_suffix)); + validate_abs_path(&data_dir)?; + let work_dir = req + .get("work_dir") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string) + .unwrap_or_else(|| format!("/opt/lightops-store/{project}")); + validate_abs_path(&work_dir)?; + let field_values = validate_store_fields(&app, req.get("fields"))?; + let mut ports = vec![port]; + ports.extend(app.extra_ports.iter().map(|value| *value as u64)); + ports.extend(field_port_values(&app, &field_values)); + let preflight_params = json!({ + "project": project, + "work_dir": work_dir, + "data_dir": data_dir, + "ports": ports + }); + let preflight = request_agent_task_with_timeout( + &state, + &agent_id, + Some(user.id), + "docker.compose.preflight", + preflight_params.clone(), + 60, + ) + .await?; + if !preflight.success { + let error = preflight.error.unwrap_or_else(|| "安装前检测失败".into()); + task::audit( + &state, + Some(user.id), + Some(&agent_id), + "app_store.preflight", + Some(&app.name), + Some(summarize_for_audit(&preflight_params)), + false, + Some(&error), + ) + .await; + return Err(AppError::BadRequest(error)); + } + task::audit( + &state, + Some(user.id), + Some(&agent_id), + "app_store.preflight", + Some(&app.name), + Some(summarize_for_audit(&preflight_params)), + true, + None, + ) + .await; + let compose = render_compose(&app, project, port as u16, &data_dir, &field_values); + let install_id = Uuid::new_v4().to_string(); + let now = Utc::now().to_rfc3339(); + let params = json!({ + "project": project, + "port": port, + "data_dir": data_dir, + "work_dir": work_dir, + "fields": mask_store_fields(&app, &field_values) + }); + sqlx::query( + "INSERT INTO app_store_installs(id, agent_id, slug, name, project, status, params_json, created_at, updated_at) + VALUES(?, ?, ?, ?, ?, 'installing', ?, ?, ?)", + ) + .bind(&install_id) + .bind(&agent_id) + .bind(&app.slug) + .bind(&app.name) + .bind(project) + .bind(params.to_string()) + .bind(&now) + .bind(&now) + .execute(&state.pool) + .await?; + + let deploy_params = json!({ + "project": project, + "work_dir": work_dir, + "content": compose + }); + let reply = request_agent_task_with_timeout_and_stored_params( + &state, + &agent_id, + Some(user.id), + "docker.compose.deploy", + deploy_params, + json!({ + "project": project, + "work_dir": work_dir, + "content": "[Compose 内容已隐藏,避免泄露安装参数]" + }), + STORE_INSTALL_TIMEOUT_SECS, + ) + .await; + + match reply { + Ok(reply) if reply.success => { + let now = Utc::now().to_rfc3339(); + sqlx::query( + "UPDATE app_store_installs SET status = 'installed', result_json = ?, updated_at = ? WHERE id = ?", + ) + .bind(reply.data.to_string()) + .bind(now) + .bind(&install_id) + .execute(&state.pool) + .await?; + task::audit( + &state, + Some(user.id), + Some(&agent_id), + "app_store.install", + Some(&app.name), + Some(summarize_for_audit(¶ms)), + true, + None, + ) + .await; + refresh_app_cache_after_install(&state, &agent_id).await; + Ok(Json(ApiResponse::ok(json!({ + "id": install_id, + "status": "installed", + "app": store_app_json(app), + "access": { + "port": port, + "hint": format!("请使用目标主机 IP 或已配置域名访问端口 {port}") + }, + "result": reply.data + })))) + } + Ok(reply) => { + let error = reply.error.unwrap_or_else(|| "安装失败".into()); + mark_install_failed(&state, &install_id, &error).await?; + task::audit( + &state, + Some(user.id), + Some(&agent_id), + "app_store.install", + Some(&app.name), + Some(summarize_for_audit(¶ms)), + false, + Some(&error), + ) + .await; + Err(AppError::BadRequest(error)) + } + Err(err) => { + let error = err.to_string(); + mark_install_failed(&state, &install_id, &error).await?; + task::audit( + &state, + Some(user.id), + Some(&agent_id), + "app_store.install", + Some(&app.name), + Some(summarize_for_audit(¶ms)), + false, + Some(&error), + ) + .await; + Err(err) + } + } +} + +async fn start_installation( + user: AuthUser, + State(state): State, + Path(install_id): Path, +) -> Result>, AppError> { + run_installation_action( + user, + state, + install_id, + "app_store.start", + "docker.compose.start", + "installed", + STORE_INSTALL_TIMEOUT_SECS, + false, + ) + .await +} + +async fn stop_installation( + user: AuthUser, + State(state): State, + Path(install_id): Path, +) -> Result>, AppError> { + run_installation_action( + user, + state, + install_id, + "app_store.stop", + "docker.compose.stop", + "stopped", + STORE_INSTALL_TIMEOUT_SECS, + false, + ) + .await +} + +async fn restart_installation( + user: AuthUser, + State(state): State, + Path(install_id): Path, +) -> Result>, AppError> { + run_installation_action( + user, + state, + install_id, + "app_store.restart", + "docker.compose.restart", + "installed", + STORE_INSTALL_TIMEOUT_SECS, + false, + ) + .await +} + +async fn update_installation( + user: AuthUser, + State(state): State, + Path(install_id): Path, +) -> Result>, AppError> { + run_installation_action( + user, + state, + install_id, + "app_store.update", + "docker.compose.update", + "installed", + STORE_INSTALL_TIMEOUT_SECS, + true, + ) + .await +} + +async fn uninstall_installation( + user: AuthUser, + State(state): State, + Path(install_id): Path, +) -> Result>, AppError> { + run_installation_action( + user, + state, + install_id, + "app_store.uninstall", + "docker.compose.down", + "uninstalled", + STORE_INSTALL_TIMEOUT_SECS, + true, + ) + .await +} + +async fn logs_installation( + user: AuthUser, + State(state): State, + Path(install_id): Path, + Query(q): Query, +) -> Result>, AppError> { + require_store_permission(&user)?; + let install = load_install(&state, &install_id).await?; + require_agent_access(&user, &state, &install.agent_id).await?; + let params = json!({ + "project": install.project, + "tail": q.tail.unwrap_or(300).min(2000) + }); + let reply = request_agent_task_with_timeout( + &state, + &install.agent_id, + Some(user.id), + "docker.compose.logs", + params.clone(), + 120, + ) + .await?; + task::audit( + &state, + Some(user.id), + Some(&install.agent_id), + "app_store.logs", + Some(&install.name), + Some(summarize_for_audit(¶ms)), + reply.success, + reply.error.as_deref(), + ) + .await; + if reply.success { + Ok(Json(ApiResponse::ok(reply.data))) + } else { + Err(AppError::BadRequest( + reply.error.unwrap_or_else(|| "读取日志失败".into()), + )) + } +} + +async fn run_installation_action( + user: AuthUser, + state: AppState, + install_id: String, + audit_action: &'static str, + agent_action: &'static str, + success_status: &'static str, + timeout_secs: u64, + needs_work_dir: bool, +) -> Result>, AppError> { + require_store_permission(&user)?; + let install = load_install(&state, &install_id).await?; + require_agent_access(&user, &state, &install.agent_id).await?; + if install.status == "uninstalled" && agent_action != "docker.compose.start" { + return Err(AppError::BadRequest("应用已经卸载".into())); + } + let mut params = json!({ "project": install.project }); + if needs_work_dir { + let work_dir = install + .params + .get("work_dir") + .and_then(Value::as_str) + .ok_or_else(|| AppError::BadRequest("安装记录缺少工作目录".into()))?; + validate_abs_path(work_dir)?; + params["work_dir"] = json!(work_dir); + } + let reply = request_agent_task_with_timeout( + &state, + &install.agent_id, + Some(user.id), + agent_action, + params.clone(), + timeout_secs, + ) + .await; + match reply { + Ok(reply) if reply.success => { + sqlx::query( + "UPDATE app_store_installs SET status = ?, result_json = ?, error = NULL, updated_at = ? WHERE id = ?", + ) + .bind(success_status) + .bind(reply.data.to_string()) + .bind(Utc::now().to_rfc3339()) + .bind(&install.id) + .execute(&state.pool) + .await?; + task::audit( + &state, + Some(user.id), + Some(&install.agent_id), + audit_action, + Some(&install.name), + Some(summarize_for_audit(¶ms)), + true, + None, + ) + .await; + refresh_app_cache_after_install(&state, &install.agent_id).await; + Ok(Json(ApiResponse::ok(json!({ + "id": install.id, + "status": success_status, + "result": reply.data + })))) + } + Ok(reply) => { + let error = reply.error.unwrap_or_else(|| "软件商店操作失败".into()); + update_install_error(&state, &install.id, &error).await?; + task::audit( + &state, + Some(user.id), + Some(&install.agent_id), + audit_action, + Some(&install.name), + Some(summarize_for_audit(¶ms)), + false, + Some(&error), + ) + .await; + Err(AppError::BadRequest(error)) + } + Err(err) => { + let error = err.to_string(); + update_install_error(&state, &install.id, &error).await?; + task::audit( + &state, + Some(user.id), + Some(&install.agent_id), + audit_action, + Some(&install.name), + Some(summarize_for_audit(¶ms)), + false, + Some(&error), + ) + .await; + Err(err) + } + } +} + +async fn mark_install_failed( + state: &AppState, + install_id: &str, + error: &str, +) -> Result<(), AppError> { + sqlx::query( + "UPDATE app_store_installs SET status = 'failed', error = ?, updated_at = ? WHERE id = ?", + ) + .bind(error.chars().take(1000).collect::()) + .bind(Utc::now().to_rfc3339()) + .bind(install_id) + .execute(&state.pool) + .await?; + Ok(()) +} + +async fn update_install_error( + state: &AppState, + install_id: &str, + error: &str, +) -> Result<(), AppError> { + sqlx::query("UPDATE app_store_installs SET error = ?, updated_at = ? WHERE id = ?") + .bind(error.chars().take(1000).collect::()) + .bind(Utc::now().to_rfc3339()) + .bind(install_id) + .execute(&state.pool) + .await?; + Ok(()) +} + +async fn load_install(state: &AppState, install_id: &str) -> Result { + let row = sqlx::query("SELECT * FROM app_store_installs WHERE id = ?") + .bind(install_id) + .fetch_optional(&state.pool) + .await? + .ok_or(AppError::NotFound)?; + let params_json = row.get::("params_json"); + Ok(StoreInstall { + id: row.get("id"), + agent_id: row.get("agent_id"), + name: row.get("name"), + project: row.get("project"), + status: row.get("status"), + params: serde_json::from_str(¶ms_json).unwrap_or_else(|_| json!({})), + }) +} + +async fn refresh_app_cache_after_install(state: &AppState, agent_id: &str) { + let reply = + request_agent_task_with_timeout(state, agent_id, None, "app.discover", json!({}), 120) + .await; + match reply { + Ok(reply) if reply.success => { + if let Err(err) = crate::apps::persist_discovery(state, agent_id, &reply.data).await { + tracing::warn!(?err, agent_id, "软件商店安装后刷新应用缓存失败"); + } + } + Ok(reply) => { + tracing::warn!( + agent_id, + error = ?reply.error, + "软件商店安装后应用发现失败" + ); + } + Err(err) => { + tracing::warn!(?err, agent_id, "软件商店安装后应用发现任务失败"); + } + } +} + +fn render_compose( + app: &StoreApp, + project: &str, + port: u16, + data_dir: &str, + fields: &serde_json::Map, +) -> String { + if let Some(template) = &app.compose_template { + let mut content = template + .replace("{{project}}", project) + .replace("{{image}}", &app.image) + .replace("{{port}}", &port.to_string()) + .replace("{{container_port}}", &app.container_port.to_string()) + .replace("{{data_dir}}", data_dir); + for (key, value) in fields { + content = content.replace(&format!("{{{{field.{key}}}}}"), &field_value_string(value)); + } + return content; + } + match app.slug.as_str() { + "redis" => format!( + "services:\n redis:\n image: {}\n container_name: {project}\n ports:\n - \"{port}:6379\"\n volumes:\n - \"{data_dir}:/data\"\n command: redis-server --appendonly yes\n restart: unless-stopped\n", + app.image + ), + "nginx" => format!( + "services:\n web:\n image: {}\n container_name: {project}\n ports:\n - \"{port}:80\"\n volumes:\n - \"{data_dir}:/usr/share/nginx/html\"\n restart: unless-stopped\n", + app.image + ), + "gitea" => format!( + "services:\n gitea:\n image: {}\n container_name: {project}\n ports:\n - \"{port}:3000\"\n - \"2222:22\"\n volumes:\n - \"{data_dir}:/data\"\n environment:\n - USER_UID=1000\n - USER_GID=1000\n restart: unless-stopped\n", + app.image + ), + _ => format!( + "services:\n app:\n image: {}\n container_name: {project}\n ports:\n - \"{port}:{}\"\n volumes:\n - \"{data_dir}:/data\"\n restart: unless-stopped\n", + app.image, app.container_port + ), + } +} + +fn catalog() -> Vec { + let configured = load_catalog_from_dir(Path::new(STORE_CATALOG_DIR)); + if !configured.is_empty() { + return configured; + } + default_catalog() +} + +fn load_catalog_from_dir(dir: &Path) -> Vec { + let Ok(entries) = fs::read_dir(dir) else { + return Vec::new(); + }; + let mut apps = Vec::new(); + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|value| value.to_str()) != Some("toml") { + continue; + } + let Ok(content) = fs::read_to_string(&path) else { + tracing::warn!(path = %path.display(), "读取软件商店目录失败"); + continue; + }; + match toml::from_str::(&content) { + Ok(file) => { + for item in file.apps { + match item.try_into() { + Ok(app) => apps.push(app), + Err(err) => { + tracing::warn!(path = %path.display(), error = %err, "软件商店应用配置无效") + } + } + } + } + Err(err) => { + tracing::warn!(path = %path.display(), error = ?err, "解析软件商店目录失败") + } + } + } + apps.sort_by(|left, right| left.name.cmp(&right.name)); + apps +} + +impl TryFrom for StoreApp { + type Error = &'static str; + + fn try_from(value: StoreAppConfig) -> Result { + if value.slug.trim().is_empty() + || value.name.trim().is_empty() + || value.image.trim().is_empty() + || value.default_port == 0 + || value.container_port == 0 + { + return Err("必填字段为空"); + } + Ok(Self { + slug: value.slug, + name: value.name, + description: value.description, + category: value.category, + image: value.image, + default_port: value.default_port, + container_port: value.container_port, + extra_ports: value.extra_ports.unwrap_or_default(), + data_dir_suffix: value.data_dir_suffix.unwrap_or_else(|| "data".into()), + tags: value.tags.unwrap_or_default(), + compose_template: value.compose_template, + fields: value + .fields + .unwrap_or_default() + .into_iter() + .map(StoreInstallField::try_from) + .collect::, _>>()?, + }) + } +} + +impl TryFrom for StoreInstallField { + type Error = &'static str; + + fn try_from(value: StoreInstallFieldConfig) -> Result { + if !is_safe_field_key(&value.key) || value.label.trim().is_empty() { + return Err("字段 key 或 label 无效"); + } + let field_type = value.field_type.trim().to_string(); + if !matches!( + field_type.as_str(), + "text" | "password" | "number" | "port" | "bool" | "select" + ) { + return Err("字段类型无效"); + } + if field_type == "select" && value.options.as_ref().is_none_or(Vec::is_empty) { + return Err("下拉字段必须配置 options"); + } + Ok(Self { + key: value.key, + label: value.label, + field_type, + required: value.required.unwrap_or(false), + default: value.default, + placeholder: value.placeholder, + help: value.help, + sensitive: value.sensitive.unwrap_or(false) || value.field_type == "password", + options: value.options.unwrap_or_default(), + min: value.min, + max: value.max, + }) + } +} + +fn validate_store_fields( + app: &StoreApp, + raw: Option<&Value>, +) -> Result, AppError> { + let source = raw.and_then(Value::as_object); + let mut values = serde_json::Map::new(); + for field in &app.fields { + let raw_value = source + .and_then(|items| items.get(&field.key)) + .cloned() + .or_else(|| field.default.clone()); + let Some(raw_value) = raw_value else { + if field.required { + return Err(AppError::BadRequest(format!( + "缺少安装参数:{}", + field.label + ))); + } + continue; + }; + let value = validate_store_field_value(field, raw_value)?; + if is_empty_field_value(&value) && field.required { + return Err(AppError::BadRequest(format!( + "缺少安装参数:{}", + field.label + ))); + } + values.insert(field.key.clone(), value); + } + Ok(values) +} + +fn validate_store_field_value(field: &StoreInstallField, value: Value) -> Result { + match field.field_type.as_str() { + "text" | "password" => { + let text = value + .as_str() + .map(str::trim) + .unwrap_or_default() + .to_string(); + let max_len = field.max.unwrap_or(if field.sensitive { 256 } else { 300 }) as usize; + if text.len() > max_len + || text.contains('\0') + || text.contains('\n') + || text.contains('\r') + || text.contains('`') + || text.contains("$(") + { + return Err(AppError::BadRequest(format!( + "安装参数不安全:{}", + field.label + ))); + } + Ok(json!(text)) + } + "number" | "port" => { + let number = value + .as_i64() + .or_else(|| { + value + .as_str() + .and_then(|item| item.trim().parse::().ok()) + }) + .ok_or_else(|| { + AppError::BadRequest(format!("安装参数必须是数字:{}", field.label)) + })?; + if let Some(min) = field.min { + if number < min { + return Err(AppError::BadRequest(format!( + "安装参数过小:{}", + field.label + ))); + } + } + if let Some(max) = field.max { + if number > max { + return Err(AppError::BadRequest(format!( + "安装参数过大:{}", + field.label + ))); + } + } + if field.field_type == "port" { + validate_port(number as u64)?; + } + Ok(json!(number)) + } + "bool" => { + let boolean = value.as_bool().or_else(|| { + value.as_str().and_then(|item| match item.trim() { + "true" | "1" | "yes" | "on" => Some(true), + "false" | "0" | "no" | "off" => Some(false), + _ => None, + }) + }); + boolean.map(|value| json!(value)).ok_or_else(|| { + AppError::BadRequest(format!("安装参数必须是布尔值:{}", field.label)) + }) + } + "select" => { + let text = value.as_str().map(str::trim).unwrap_or_default(); + if !field.options.iter().any(|item| item == text) { + return Err(AppError::BadRequest(format!( + "安装参数不在允许范围内:{}", + field.label + ))); + } + Ok(json!(text)) + } + _ => Err(AppError::BadRequest("不支持的安装参数类型".into())), + } +} + +fn field_port_values(app: &StoreApp, values: &serde_json::Map) -> Vec { + app.fields + .iter() + .filter(|field| field.field_type == "port") + .filter_map(|field| values.get(&field.key).and_then(Value::as_i64)) + .filter(|value| (1..=65535).contains(value)) + .map(|value| value as u64) + .collect() +} + +fn mask_store_fields(app: &StoreApp, values: &serde_json::Map) -> Value { + let mut masked = serde_json::Map::new(); + for field in &app.fields { + if let Some(value) = values.get(&field.key) { + masked.insert( + field.key.clone(), + if field.sensitive { + json!("******") + } else { + value.clone() + }, + ); + } + } + Value::Object(masked) +} + +fn field_value_string(value: &Value) -> String { + match value { + Value::String(value) => value.clone(), + Value::Bool(value) => value.to_string(), + Value::Number(value) => value.to_string(), + _ => String::new(), + } +} + +fn is_empty_field_value(value: &Value) -> bool { + value.as_str().is_some_and(str::is_empty) +} + +fn is_safe_field_key(key: &str) -> bool { + !key.is_empty() && key.len() <= 64 && key.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') +} + +fn store_field_json(field: StoreInstallField) -> Value { + json!({ + "key": field.key, + "label": field.label, + "type": field.field_type, + "required": field.required, + "default": field.default, + "placeholder": field.placeholder, + "help": field.help, + "sensitive": field.sensitive, + "options": field.options, + "min": field.min, + "max": field.max + }) +} + +fn default_catalog() -> Vec { + vec![ + StoreApp { + slug: "alist".into(), + name: "Alist".into(), + description: "轻量网盘目录程序,适合统一挂载和分享文件。".into(), + category: "storage".into(), + image: "xhofe/alist:latest".into(), + default_port: 5244, + container_port: 5244, + extra_ports: Vec::new(), + data_dir_suffix: "data".into(), + tags: vec!["网盘".into(), "文件".into(), "Docker".into()], + compose_template: None, + fields: Vec::new(), + }, + StoreApp { + slug: "uptime-kuma".into(), + name: "Uptime Kuma".into(), + description: "开源服务可用性监控和状态页。".into(), + category: "monitoring".into(), + image: "louislam/uptime-kuma:1".into(), + default_port: 3001, + container_port: 3001, + extra_ports: Vec::new(), + data_dir_suffix: "data".into(), + tags: vec!["监控".into(), "状态页".into(), "Docker".into()], + compose_template: None, + fields: Vec::new(), + }, + StoreApp { + slug: "gitea".into(), + name: "Gitea".into(), + description: "轻量 Git 代码托管服务。".into(), + category: "dev".into(), + image: "gitea/gitea:1.22".into(), + default_port: 3000, + container_port: 3000, + extra_ports: vec![2222], + data_dir_suffix: "data".into(), + tags: vec!["Git".into(), "代码仓库".into(), "Docker".into()], + compose_template: None, + fields: Vec::new(), + }, + StoreApp { + slug: "redis".into(), + name: "Redis".into(), + description: "内存键值数据库,启用 AOF 持久化。".into(), + category: "database".into(), + image: "redis:7-alpine".into(), + default_port: 6379, + container_port: 6379, + extra_ports: Vec::new(), + data_dir_suffix: "data".into(), + tags: vec!["数据库".into(), "缓存".into(), "Docker".into()], + compose_template: None, + fields: Vec::new(), + }, + StoreApp { + slug: "nginx".into(), + name: "Nginx 示例站".into(), + description: "快速启动一个 Nginx 静态站容器。".into(), + category: "web".into(), + image: "nginx:alpine".into(), + default_port: 8080, + container_port: 80, + extra_ports: Vec::new(), + data_dir_suffix: "html".into(), + tags: vec!["Web".into(), "静态站".into(), "Docker".into()], + compose_template: None, + fields: Vec::new(), + }, + ] +} + +fn find_store_app(slug: &str) -> Result { + catalog() + .into_iter() + .find(|app| app.slug == slug) + .ok_or(AppError::NotFound) +} + +fn store_app_json(app: StoreApp) -> Value { + json!({ + "slug": app.slug, + "name": app.name, + "description": app.description, + "category": app.category, + "image": app.image, + "default_port": app.default_port, + "container_port": app.container_port, + "extra_ports": app.extra_ports, + "data_dir_suffix": app.data_dir_suffix, + "fields": app.fields.into_iter().map(store_field_json).collect::>(), + "tags": app.tags + }) +} + +fn install_row_json(row: sqlx::sqlite::SqliteRow) -> Value { + json!({ + "id": row.get::("id"), + "agent_id": row.get::("agent_id"), + "slug": row.get::("slug"), + "name": row.get::("name"), + "project": row.get::("project"), + "status": row.get::("status"), + "params": serde_json::from_str::(&row.get::("params_json")).unwrap_or_else(|_| json!({})), + "result": row.get::, _>("result_json").and_then(|value| serde_json::from_str::(&value).ok()), + "error": row.get::, _>("error"), + "created_at": row.get::("created_at"), + "updated_at": row.get::("updated_at") + }) +} + +fn require_store_permission(user: &AuthUser) -> Result<(), AppError> { + if user.can("apps") { + Ok(()) + } else { + Err(AppError::Forbidden) + } +} + +async fn require_agent_access( + user: &AuthUser, + state: &AppState, + agent_id: &str, +) -> Result<(), AppError> { + if user.role == "admin" { + return Ok(()); + } + let allowed: Option = + sqlx::query_scalar("SELECT 1 FROM agent_access WHERE user_id = ? AND agent_id = ?") + .bind(user.id) + .bind(agent_id) + .fetch_optional(&state.pool) + .await?; + if allowed.is_some() { + Ok(()) + } else { + Err(AppError::Forbidden) + } +} + +fn validate_compose_project(project: &str) -> Result<(), AppError> { + if project.is_empty() + || project.len() > 80 + || !project + .chars() + .all(|c| c.is_ascii_alphanumeric() || "._-".contains(c)) + { + return Err(AppError::BadRequest( + "项目名只能包含字母、数字、点、短横线和下划线".into(), + )); + } + Ok(()) +} + +fn validate_port(port: u64) -> Result<(), AppError> { + if (1..=65535).contains(&port) { + Ok(()) + } else { + Err(AppError::BadRequest("端口无效".into())) + } +} + +fn validate_abs_path(path: &str) -> Result<(), AppError> { + if path.starts_with('/') + && path.len() <= 300 + && !path.contains('\0') + && !path.contains('\n') + && !path.contains('\r') + && !path.contains('"') + { + Ok(()) + } else { + Err(AppError::BadRequest("路径必须是安全的绝对路径".into())) + } +} + +fn summarize_for_audit(value: &Value) -> String { + value.to_string().chars().take(500).collect() +} diff --git a/crates/lightops-server/src/task.rs b/crates/lightops-server/src/task.rs new file mode 100644 index 0000000..0fed224 --- /dev/null +++ b/crates/lightops-server/src/task.rs @@ -0,0 +1,226 @@ +use crate::{ + error::AppError, + state::{AppState, TaskReply}, +}; +use chrono::Utc; +use lightops_common::protocol::ServerMessage; +use serde_json::Value; +use tokio::sync::oneshot; +use uuid::Uuid; + +pub async fn request_agent_task( + state: &AppState, + agent_id: &str, + user_id: Option, + action: &str, + params: Value, +) -> Result { + request_agent_task_with_timeout( + state, + agent_id, + user_id, + action, + params, + state.cfg.task_timeout_secs, + ) + .await +} + +pub async fn request_agent_task_with_timeout( + state: &AppState, + agent_id: &str, + user_id: Option, + action: &str, + params: Value, + timeout_secs: u64, +) -> Result { + request_agent_task_with_timeout_and_stored_params( + state, + agent_id, + user_id, + action, + params.clone(), + params, + timeout_secs, + ) + .await +} + +pub async fn request_agent_task_with_timeout_and_stored_params( + state: &AppState, + agent_id: &str, + user_id: Option, + action: &str, + params: Value, + stored_params: Value, + timeout_secs: u64, +) -> Result { + let task_id = Uuid::new_v4().to_string(); + sqlx::query( + "INSERT INTO tasks (id, agent_id, user_id, action, params_json, status, started_at) VALUES (?, ?, ?, ?, ?, 'running', ?)", + ) + .bind(&task_id) + .bind(agent_id) + .bind(user_id) + .bind(action) + .bind(stored_params.to_string()) + .bind(Utc::now().to_rfc3339()) + .execute(&state.pool) + .await?; + append_task_event( + state, + &task_id, + "info", + "任务已创建", + Some(&serde_json::json!({ "action": action })), + ) + .await?; + + let handle = { + let agents = state.agents.read().await; + agents.get(agent_id).cloned() + } + .ok_or(AppError::AgentOffline)?; + + let (tx, rx) = oneshot::channel(); + state.pending.insert(task_id.clone(), tx); + state + .pending_agents + .insert(task_id.clone(), agent_id.to_string()); + let msg = ServerMessage::TaskRequest { + task_id: task_id.clone(), + action: action.to_string(), + params, + }; + if handle.tx.send(msg).is_err() { + state.pending.remove(&task_id); + state.pending_agents.remove(&task_id); + mark_task(state, &task_id, "failed", None, Some("Agent 已断开连接")).await?; + return Err(AppError::AgentOffline); + } + append_task_event(state, &task_id, "info", "任务已下发到 Agent", None).await?; + + let reply = match tokio::time::timeout(std::time::Duration::from_secs(timeout_secs), rx).await { + Ok(Ok(reply)) => reply, + Ok(Err(_)) => { + state.pending_agents.remove(&task_id); + mark_task(state, &task_id, "failed", None, Some("Agent 已断开连接")).await?; + return Err(AppError::AgentOffline); + } + Err(_) => { + state.pending.remove(&task_id); + state.pending_agents.remove(&task_id); + mark_task(state, &task_id, "timeout", None, Some("任务超时")).await?; + return Err(AppError::Timeout); + } + }; + + let status = if reply.success { "success" } else { "failed" }; + mark_task( + state, + &task_id, + status, + Some(&reply.data), + reply.error.as_deref(), + ) + .await?; + Ok(reply) +} + +pub async fn append_task_event( + state: &AppState, + task_id: &str, + level: &str, + message: &str, + data: Option<&Value>, +) -> Result<(), AppError> { + sqlx::query( + "INSERT INTO task_events(task_id, level, message, data_json, created_at) VALUES(?, ?, ?, ?, ?)", + ) + .bind(task_id) + .bind(level) + .bind(message.chars().take(500).collect::()) + .bind(data.map(|value| value.to_string())) + .bind(Utc::now().to_rfc3339()) + .execute(&state.pool) + .await?; + Ok(()) +} + +pub async fn fail_agent_pending_tasks(state: &AppState, agent_id: &str, reason: &str) { + let task_ids: Vec = state + .pending_agents + .iter() + .filter(|item| item.value() == agent_id) + .map(|item| item.key().clone()) + .collect(); + + for task_id in task_ids { + state.pending_agents.remove(&task_id); + if let Some((_, tx)) = state.pending.remove(&task_id) { + let _ = tx.send(TaskReply { + success: false, + data: serde_json::json!({}), + error: Some(reason.to_string()), + }); + } + let _ = mark_task(state, &task_id, "failed", None, Some(reason)).await; + } +} + +pub async fn mark_task( + state: &AppState, + task_id: &str, + status: &str, + result: Option<&Value>, + error: Option<&str>, +) -> Result<(), AppError> { + sqlx::query( + "UPDATE tasks SET status = ?, result_json = ?, error = ?, finished_at = ? WHERE id = ?", + ) + .bind(status) + .bind(result.map(|v| v.to_string())) + .bind(error) + .bind(Utc::now().to_rfc3339()) + .bind(task_id) + .execute(&state.pool) + .await?; + let level = if status == "success" { "info" } else { "error" }; + let message = match status { + "success" => "任务成功完成", + "timeout" => "任务执行超时", + "cancelled" => "任务已取消", + _ => "任务执行失败", + }; + let data = serde_json::json!({ + "status": status, + "error": error, + "result": result + }); + append_task_event(state, task_id, level, message, Some(&data)).await?; + Ok(()) +} + +pub async fn audit( + state: &AppState, + user_id: Option, + agent_id: Option<&str>, + action: &str, + target: Option<&str>, + params_summary: Option, + success: bool, + error: Option<&str>, +) { + let _ = sqlx::query( + "INSERT INTO audit_logs (user_id, agent_id, action, target, params_summary, success, error) VALUES (?, ?, ?, ?, ?, ?, ?)", + ) + .bind(user_id) + .bind(agent_id) + .bind(action) + .bind(target) + .bind(params_summary) + .bind(if success { 1 } else { 0 }) + .bind(error) + .execute(&state.pool) + .await; +} diff --git a/deploy/lightops-agent.service b/deploy/lightops-agent.service new file mode 100644 index 0000000..6e72c6f --- /dev/null +++ b/deploy/lightops-agent.service @@ -0,0 +1,15 @@ +[Unit] +Description=LightOps Agent +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +ExecStart=/usr/local/bin/lightops-agent --config /etc/lightops/agent.toml +Restart=always +RestartSec=5 +User=root + +[Install] +WantedBy=multi-user.target + diff --git a/deploy/lightops-server.service b/deploy/lightops-server.service new file mode 100644 index 0000000..776b434 --- /dev/null +++ b/deploy/lightops-server.service @@ -0,0 +1,16 @@ +[Unit] +Description=LightOps Server +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +WorkingDirectory=/opt/lightops +ExecStart=/opt/lightops/lightops-server --config /etc/lightops/server.toml +Restart=always +RestartSec=5 +User=root + +[Install] +WantedBy=multi-user.target + diff --git a/docs/application-management.md b/docs/application-management.md new file mode 100644 index 0000000..56639e8 --- /dev/null +++ b/docs/application-management.md @@ -0,0 +1,79 @@ +# 应用管理 + +LightOps 应用管理是在现有 Agent 任务架构上的增量模块。它不会替代进程、服务、Docker、Nginx、文件或日志模块,而是把用户真正需要运维的运行资产统一归一为 `Application` 模型,并与已有模块建立跳转和关联。 + +## 模型 + +`lightops-common` 定义: + +- `Application` +- `ApplicationType` +- `ApplicationProviderType` +- `ApplicationStatus` +- `ApplicationRelation` +- `ApplicationDetail` + +Server 使用 SQLite 保存应用发现结果: + +- `applications` +- `application_relations` +- `application_actions` + +这些表是缓存和操作历史,不是第二套控制面。机器上的真实操作仍然只通过 Agent 任务执行。 + +## Server 流程 + +- `GET /api/apps` 返回所有 Agent 的应用缓存。 +- `GET /api/agents/:id/apps` 返回某个 Agent 的应用缓存。 +- `POST /api/agents/:id/apps/discover` 向 Agent 下发 `app.discover`,并写入返回的应用和关系。 +- 启动、停止、重启、日志等应用操作会通过任务系统下发给 Agent。 +- 危险操作写入 `audit_logs` 和 `application_actions`。 +- `is_system = true` 的应用默认只读。 + +## Agent 流程 + +Agent 通过现有动作白名单暴露 `app.*` 动作,并聚合多个 Provider 的结果: + +- `SystemdAppProvider` +- `DockerAppProvider` +- `NginxSiteAppProvider` +- `PackageAppProvider` +- `LightOpsManagedAppProvider` + +MVP 的发现策略会主动过滤系统包和系统服务,不会把所有系统包、系统依赖或基础服务都展示为应用。它只识别具有明确运维价值的服务型应用。 + +## MVP 动作 + +已实现: + +- `app.discover` +- `app.list` +- `app.get` +- `app.start` +- `app.stop` +- `app.restart` +- `app.reload`,当前支持 Nginx/systemd 的可用场景 +- `app.logs` +- `app.manage_custom` +- `app.create_systemd_service` +- `app.unmanage` + +暂缓实现: + +- 完整卸载 +- 完整备份/恢复 +- PM2 +- Supervisor +- 完整 Docker Compose 生命周期 +- 复杂应用关系图谱 +- 批量更新 + +## 安全策略 + +- Agent 使用 `tokio::process::Command` 并通过参数数组传参。 +- 不暴露通用 shell 执行。 +- 系统应用默认只读。 +- 自定义 systemd 服务名会被安全过滤。 +- `start_command` 会拒绝常见 shell 拼接字符。 +- 命令执行使用短超时,避免任务长期卡住。 + diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..711f05f --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,61 @@ +# LightOps 架构 + +LightOps 是一个极轻量探针式运维面板。一个 Server 负责 Web UI、SQLite 数据、认证、审计日志、任务调度和 Agent 连接注册;每台被管理主机只运行一个 Rust Agent,不提供 Web UI,也不保存本地复杂状态。 + +## 控制面 + +- Server 向浏览器提供 REST API,并向 Agent 提供一个 WebSocket 入口。 +- 浏览器请求通过 Bearer JWT 鉴权。浏览器 WebSocket 端点也支持通过 `?token=` 携带同一个 Token。 +- SQLite 是默认数据库,不依赖 Redis、MySQL、Elasticsearch 或外部队列。 +- Agent 连接保存在内存中,结构为 `agent_id -> sender`。 +- 任务响应通过 `task_id` 关联,并受超时机制约束。 +- 每个 Agent 连接都有独立 `connection_id`。同一个 Agent 重连时,新连接会替换旧连接;旧连接退出时不会把新连接误标为离线。 +- 危险操作写入 `audit_logs`。 + +## 数据面 + +- Agent 主动连接 `wss://server/api/agent/ws`。 +- 首次连接使用一次性注册 Token。 +- Server 返回长期 Secret,Agent 将其保存到 `/etc/lightops/agent.toml`。 +- 后续连接使用 `agent_id + secret` 鉴权。 +- 默认每 30 秒发送心跳和指标。 +- Server 默认每 30 秒发送协议级 `server.ping`,Agent 收到后立即返回 `agent.pong`。 +- Server 和 Agent 都会检测约 100 秒的连接静默时间,超时后主动断开并触发 Agent 退避重连。 +- Agent 连接失败使用指数退避并增加毫秒级抖动,避免大量节点同时重连冲击 Server。 +- Agent 断开后,Server 会立即失败该 Agent 未完成的 pending 任务,避免用户等待任务超时。 +- Server 不主动连接被管理节点,被管理节点无需开放入站端口。 + +## 协议 + +所有 Agent 消息都是带 `type` 字段的 JSON: + +- `agent.hello` +- `agent.heartbeat` +- `agent.pong` +- `server.ping` +- `agent.accepted` +- `task.request` +- `task.response` +- `stream.open` +- `stream.data` +- `stream.close` +- `error` + +## 安全模型 + +- 生产环境 Server 应只通过 HTTPS/WSS 暴露。 +- 注册 Token 一次性使用并带过期时间。 +- Agent Secret 和注册 Token 在 Server 端只保存哈希。 +- 密码使用 Argon2 保存。 +- Agent 只执行白名单动作。 +- 不提供通用 shell 执行 API。 +- 终端打开、文件删除、服务停止、Nginx 重载和 Docker 破坏性操作都会审计。 +- 文本文件读写默认限制大小。 +- Docker CLI 访问意味着 Docker Socket/root 等价风险,请只在可信主机上部署 Agent。 + +## MVP 边界 + +- Docker Provider 抽象为 `DockerProvider`,MVP 使用 Docker CLI 实现。 +- Nginx 目录布局优先支持 Debian/Ubuntu 的 `/etc/nginx/sites-available` 和 `/etc/nginx/sites-enabled`。 +- 文件管理是懒加载、非递归扫描。 +- 日志按需读取或订阅,不做持久化日志索引。 diff --git a/migrations/0001_init.sql b/migrations/0001_init.sql new file mode 100644 index 0000000..05fafb8 --- /dev/null +++ b/migrations/0001_init.sql @@ -0,0 +1,89 @@ +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'admin', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS agents ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + hostname TEXT NOT NULL, + os TEXT NOT NULL, + arch TEXT NOT NULL, + version TEXT NOT NULL, + ip TEXT, + status TEXT NOT NULL DEFAULT 'offline', + secret_hash TEXT NOT NULL, + capabilities_json TEXT NOT NULL DEFAULT '{}', + last_seen_at TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS agent_metrics ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + agent_id TEXT NOT NULL, + cpu_usage REAL NOT NULL, + memory_total INTEGER NOT NULL, + memory_used INTEGER NOT NULL, + disk_total INTEGER NOT NULL, + disk_used INTEGER NOT NULL, + load_avg REAL NOT NULL, + uptime INTEGER NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY(agent_id) REFERENCES agents(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_agent_metrics_agent_created ON agent_metrics(agent_id, created_at DESC); + +CREATE TABLE IF NOT EXISTS registration_tokens ( + id TEXT PRIMARY KEY, + token_hash TEXT NOT NULL UNIQUE, + name TEXT, + expires_at TEXT NOT NULL, + used_at TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS tasks ( + id TEXT PRIMARY KEY, + agent_id TEXT NOT NULL, + user_id INTEGER, + action TEXT NOT NULL, + params_json TEXT NOT NULL, + status TEXT NOT NULL, + result_json TEXT, + error TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + started_at TEXT, + finished_at TEXT, + FOREIGN KEY(agent_id) REFERENCES agents(id) ON DELETE CASCADE, + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE SET NULL +); + +CREATE INDEX IF NOT EXISTS idx_tasks_agent_created ON tasks(agent_id, created_at DESC); + +CREATE TABLE IF NOT EXISTS audit_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER, + agent_id TEXT, + action TEXT NOT NULL, + target TEXT, + params_summary TEXT, + success INTEGER NOT NULL, + error TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE SET NULL, + FOREIGN KEY(agent_id) REFERENCES agents(id) ON DELETE SET NULL +); + +CREATE INDEX IF NOT EXISTS idx_audit_logs_created ON audit_logs(created_at DESC); + +CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +); + diff --git a/migrations/0002_applications.sql b/migrations/0002_applications.sql new file mode 100644 index 0000000..0ea6e18 --- /dev/null +++ b/migrations/0002_applications.sql @@ -0,0 +1,70 @@ +CREATE TABLE IF NOT EXISTS applications ( + id TEXT PRIMARY KEY, + agent_id TEXT NOT NULL, + name TEXT NOT NULL, + display_name TEXT NOT NULL, + description TEXT, + app_type TEXT NOT NULL, + provider TEXT NOT NULL, + status TEXT NOT NULL, + version TEXT, + install_path TEXT, + work_dir TEXT, + config_paths_json TEXT NOT NULL DEFAULT '[]', + log_paths_json TEXT NOT NULL DEFAULT '[]', + data_paths_json TEXT NOT NULL DEFAULT '[]', + ports_json TEXT NOT NULL DEFAULT '[]', + domains_json TEXT NOT NULL DEFAULT '[]', + service_name TEXT, + container_id TEXT, + compose_project TEXT, + package_name TEXT, + nginx_site TEXT, + run_user TEXT, + is_system INTEGER NOT NULL DEFAULT 0, + is_managed INTEGER NOT NULL DEFAULT 0, + is_lightops_managed INTEGER NOT NULL DEFAULT 0, + metadata_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY(agent_id) REFERENCES agents(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_applications_agent ON applications(agent_id); +CREATE INDEX IF NOT EXISTS idx_applications_provider ON applications(provider); +CREATE INDEX IF NOT EXISTS idx_applications_status ON applications(status); + +CREATE TABLE IF NOT EXISTS application_relations ( + id TEXT PRIMARY KEY, + agent_id TEXT NOT NULL, + app_id TEXT NOT NULL, + relation_type TEXT NOT NULL, + target_id TEXT, + target_name TEXT, + metadata_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL, + FOREIGN KEY(agent_id) REFERENCES agents(id) ON DELETE CASCADE, + FOREIGN KEY(app_id) REFERENCES applications(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_application_relations_app ON application_relations(app_id); + +CREATE TABLE IF NOT EXISTS application_actions ( + id TEXT PRIMARY KEY, + agent_id TEXT NOT NULL, + app_id TEXT, + user_id TEXT, + action TEXT NOT NULL, + status TEXT NOT NULL, + params_json TEXT, + result_json TEXT, + error TEXT, + created_at TEXT NOT NULL, + finished_at TEXT, + FOREIGN KEY(agent_id) REFERENCES agents(id) ON DELETE CASCADE, + FOREIGN KEY(app_id) REFERENCES applications(id) ON DELETE SET NULL +); + +CREATE INDEX IF NOT EXISTS idx_application_actions_app_created ON application_actions(app_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_application_actions_agent_created ON application_actions(agent_id, created_at DESC); + diff --git a/migrations/0003_agent_network_metrics.sql b/migrations/0003_agent_network_metrics.sql new file mode 100644 index 0000000..5199c72 --- /dev/null +++ b/migrations/0003_agent_network_metrics.sql @@ -0,0 +1,2 @@ +ALTER TABLE agent_metrics ADD COLUMN network_rx INTEGER NOT NULL DEFAULT 0; +ALTER TABLE agent_metrics ADD COLUMN network_tx INTEGER NOT NULL DEFAULT 0; diff --git a/migrations/0004_production_hardening.sql b/migrations/0004_production_hardening.sql new file mode 100644 index 0000000..8b93623 --- /dev/null +++ b/migrations/0004_production_hardening.sql @@ -0,0 +1,68 @@ +CREATE TABLE IF NOT EXISTS user_permissions ( + user_id INTEGER NOT NULL, + permission TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY(user_id, permission), + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS agent_access ( + user_id INTEGER NOT NULL, + agent_id TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY(user_id, agent_id), + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY(agent_id) REFERENCES agents(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS alert_rules ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + metric TEXT NOT NULL, + operator TEXT NOT NULL DEFAULT '>=', + threshold REAL NOT NULL, + duration_seconds INTEGER NOT NULL DEFAULT 0, + severity TEXT NOT NULL DEFAULT 'warning', + agent_id TEXT, + enabled INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY(agent_id) REFERENCES agents(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_alert_rules_enabled ON alert_rules(enabled); +CREATE INDEX IF NOT EXISTS idx_alert_rules_agent ON alert_rules(agent_id); + +CREATE TABLE IF NOT EXISTS alert_events ( + id TEXT PRIMARY KEY, + rule_id TEXT NOT NULL, + agent_id TEXT NOT NULL, + metric TEXT NOT NULL, + value REAL NOT NULL, + threshold REAL NOT NULL, + severity TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'open', + message TEXT NOT NULL, + first_seen_at TEXT NOT NULL, + last_seen_at TEXT NOT NULL, + resolved_at TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY(rule_id) REFERENCES alert_rules(id) ON DELETE CASCADE, + FOREIGN KEY(agent_id) REFERENCES agents(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_alert_events_status ON alert_events(status, last_seen_at DESC); +CREATE INDEX IF NOT EXISTS idx_alert_events_agent ON alert_events(agent_id, last_seen_at DESC); + +INSERT OR IGNORE INTO settings(key, value) VALUES +('security.terminal_enabled', 'true'), +('security.file_write_enabled', 'true'), +('security.require_danger_confirm', 'true'), +('agent.offline_after_seconds', '120'), +('metrics.retention_days', '30'), +('alerts.enabled', 'true'); + +INSERT OR IGNORE INTO alert_rules(id, name, metric, operator, threshold, severity) VALUES +('builtin-cpu-high', 'CPU 使用率过高', 'cpu_usage', '>=', 90, 'warning'), +('builtin-memory-high', '内存使用率过高', 'memory_usage', '>=', 90, 'warning'), +('builtin-disk-high', '磁盘使用率过高', 'disk_usage', '>=', 92, 'critical'); diff --git a/migrations/0005_notifications.sql b/migrations/0005_notifications.sql new file mode 100644 index 0000000..a741c67 --- /dev/null +++ b/migrations/0005_notifications.sql @@ -0,0 +1,29 @@ +CREATE TABLE IF NOT EXISTS notification_channels ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + channel_type TEXT NOT NULL, + config_json TEXT NOT NULL DEFAULT '{}', + enabled INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS notification_deliveries ( + id TEXT PRIMARY KEY, + event_id TEXT NOT NULL, + channel TEXT NOT NULL, + status TEXT NOT NULL, + error TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY(event_id) REFERENCES alert_events(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_notification_deliveries_event ON notification_deliveries(event_id); +CREATE INDEX IF NOT EXISTS idx_notification_deliveries_status ON notification_deliveries(status, created_at DESC); + +INSERT OR IGNORE INTO settings(key, value) VALUES +('notifications.webhook_enabled', 'false'), +('notifications.webhook_url', ''); + +INSERT OR IGNORE INTO alert_rules(id, name, metric, operator, threshold, severity) VALUES +('builtin-ssl-expiring', 'SSL 证书即将到期', 'ssl_days_remaining', '<=', 30, 'warning'); diff --git a/migrations/0006_app_store.sql b/migrations/0006_app_store.sql new file mode 100644 index 0000000..012fd90 --- /dev/null +++ b/migrations/0006_app_store.sql @@ -0,0 +1,17 @@ +CREATE TABLE IF NOT EXISTS app_store_installs ( + id TEXT PRIMARY KEY, + agent_id TEXT NOT NULL, + slug TEXT NOT NULL, + name TEXT NOT NULL, + project TEXT NOT NULL, + status TEXT NOT NULL, + params_json TEXT NOT NULL DEFAULT '{}', + result_json TEXT, + error TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY(agent_id) REFERENCES agents(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_app_store_installs_agent ON app_store_installs(agent_id, updated_at DESC); +CREATE INDEX IF NOT EXISTS idx_app_store_installs_slug ON app_store_installs(slug, updated_at DESC); diff --git a/migrations/0007_alert_notification_details.sql b/migrations/0007_alert_notification_details.sql new file mode 100644 index 0000000..39248d5 --- /dev/null +++ b/migrations/0007_alert_notification_details.sql @@ -0,0 +1,5 @@ +ALTER TABLE alert_rules ADD COLUMN silence_until TEXT; +ALTER TABLE alert_rules ADD COLUMN notify_recovery INTEGER NOT NULL DEFAULT 1; + +INSERT OR IGNORE INTO settings(key, value) VALUES +('notifications.recovery_enabled', 'true'); diff --git a/migrations/0008_task_events.sql b/migrations/0008_task_events.sql new file mode 100644 index 0000000..ad5d857 --- /dev/null +++ b/migrations/0008_task_events.sql @@ -0,0 +1,11 @@ +CREATE TABLE IF NOT EXISTS task_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + task_id TEXT NOT NULL, + level TEXT NOT NULL, + message TEXT NOT NULL, + data_json TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY(task_id) REFERENCES tasks(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_task_events_task_created ON task_events(task_id, created_at ASC); diff --git a/migrations/0009_app_health_settings.sql b/migrations/0009_app_health_settings.sql new file mode 100644 index 0000000..1b988f5 --- /dev/null +++ b/migrations/0009_app_health_settings.sql @@ -0,0 +1,9 @@ +INSERT OR IGNORE INTO settings(key, value) VALUES +('apps.health_check_enabled', 'true'), +('apps.health_alert_enabled', 'true'), +('apps.health_check_interval_seconds', '300'), +('apps.health_check_batch_size', '20'), +('apps.health_check_timeout_seconds', '5'), +('updates.repo_dir', '.'), +('updates.branch', 'main'), +('updates.script_path', 'scripts/update-from-git.sh'); diff --git a/scripts/install-agent.sh b/scripts/install-agent.sh new file mode 100755 index 0000000..f8c851f --- /dev/null +++ b/scripts/install-agent.sh @@ -0,0 +1,108 @@ +#!/usr/bin/env sh +set -eu + +SERVER="" +TOKEN="" +BIN_URL="" +VERSION="" +KEEP_CONFIG="0" +SHA256="" + +while [ "$#" -gt 0 ]; do + case "$1" in + --server) SERVER="$2"; shift 2 ;; + --token) TOKEN="$2"; shift 2 ;; + --bin-url) BIN_URL="$2"; shift 2 ;; + --version) VERSION="$2"; shift 2 ;; + --sha256) SHA256="$2"; shift 2 ;; + --keep-config) KEEP_CONFIG="1"; shift 1 ;; + *) echo "未知参数:$1" >&2; exit 1 ;; + esac +done + +if [ -z "$SERVER" ] || [ -z "$TOKEN" ]; then + echo "用法:install-agent.sh --server https://panel.example.com --token TOKEN [--bin-url URL]" >&2 + exit 1 +fi + +if [ "$(id -u)" -ne 0 ]; then + echo "请使用 root 用户运行" >&2 + exit 1 +fi + +install -d -m 0755 /etc/lightops /usr/local/bin + +if command -v systemctl >/dev/null 2>&1; then + systemctl stop lightops-agent 2>/dev/null || true +fi + +if [ -z "$BIN_URL" ]; then + OS="$(uname -s | tr '[:upper:]' '[:lower:]')" + ARCH="$(uname -m)" + SUFFIX="" + if [ -n "$VERSION" ]; then + SUFFIX="-$VERSION" + fi + BIN_URL="$SERVER/downloads/lightops-agent-$OS-$ARCH$SUFFIX" +fi + +if [ -n "$BIN_URL" ]; then + TMP_BIN="$(mktemp)" + echo "正在下载 Agent:$BIN_URL" + curl -fL --retry 3 --connect-timeout 10 "$BIN_URL" -o "$TMP_BIN" + if [ -n "$SHA256" ]; then + echo "$SHA256 $TMP_BIN" | sha256sum -c - + fi + chmod +x "$TMP_BIN" + if /usr/local/bin/lightops-agent --version >/dev/null 2>&1; then + cp /usr/local/bin/lightops-agent "/usr/local/bin/lightops-agent.bak.$(date +%Y%m%d%H%M%S)" || true + fi + mv "$TMP_BIN" /usr/local/bin/lightops-agent +elif [ ! -x /usr/local/bin/lightops-agent ]; then + echo "未在 /usr/local/bin/lightops-agent 找到 lightops-agent 二进制文件" + echo "请先复制二进制文件,或传入 --bin-url https://.../lightops-agent" + exit 1 +fi + +if [ "$KEEP_CONFIG" != "1" ] || [ ! -f /etc/lightops/agent.toml ]; then + cat >/etc/lightops/agent.toml </etc/systemd/system/lightops-agent.service <<'EOF' +[Unit] +Description=LightOps Agent +After=network-online.target +Wants=network-online.target +StartLimitIntervalSec=300 +StartLimitBurst=10 + +[Service] +Type=simple +ExecStart=/usr/local/bin/lightops-agent --config /etc/lightops/agent.toml +Restart=always +RestartSec=5 +KillSignal=SIGINT +TimeoutStopSec=20 +User=root +NoNewPrivileges=false + +[Install] +WantedBy=multi-user.target +EOF + +systemctl daemon-reload +systemctl enable --now lightops-agent +sleep 2 +if ! systemctl is-active --quiet lightops-agent; then + echo "Agent 启动失败,最近日志如下:" >&2 + journalctl -u lightops-agent -n 80 --no-pager >&2 || true + exit 1 +fi + +echo "LightOps Agent 已安装并运行" +systemctl status lightops-agent --no-pager diff --git a/scripts/install-server.sh b/scripts/install-server.sh new file mode 100755 index 0000000..e092448 --- /dev/null +++ b/scripts/install-server.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env sh +set -eu + +if [ "$(id -u)" -ne 0 ]; then + echo "请使用 root 用户运行" >&2 + exit 1 +fi + +install -d /opt/lightops /etc/lightops +chmod 0755 /opt/lightops + +if [ ! -x /opt/lightops/lightops-server ]; then + echo "运行此脚本前,请先将 lightops-server 放到 /opt/lightops/lightops-server" >&2 + exit 1 +fi + +if [ ! -f /etc/lightops/server.toml ]; then + cat >/etc/lightops/server.toml <<'EOF' +bind = "0.0.0.0:8080" +database_url = "sqlite:///opt/lightops/lightops.db?mode=rwc" +jwt_secret = "请替换为足够长的随机密钥" +public_url = "https://panel.example.com" +static_dir = "/opt/lightops/web" +registration_token_ttl_minutes = 30 +task_timeout_secs = 20 +EOF +fi + +cat >/etc/systemd/system/lightops-server.service <<'EOF' +[Unit] +Description=LightOps Server +After=network-online.target +Wants=network-online.target +StartLimitIntervalSec=300 +StartLimitBurst=10 + +[Service] +Type=simple +WorkingDirectory=/opt/lightops +ExecStart=/opt/lightops/lightops-server --config /etc/lightops/server.toml +Restart=always +RestartSec=5 +KillSignal=SIGINT +TimeoutStopSec=20 +User=root + +[Install] +WantedBy=multi-user.target +EOF + +systemctl daemon-reload +systemctl enable --now lightops-server +sleep 2 +if ! systemctl is-active --quiet lightops-server; then + echo "Server 启动失败,最近日志如下:" >&2 + journalctl -u lightops-server -n 80 --no-pager >&2 || true + exit 1 +fi +echo "LightOps Server 已安装并运行" diff --git a/scripts/run-local-smoke.ps1 b/scripts/run-local-smoke.ps1 new file mode 100644 index 0000000..feac07e --- /dev/null +++ b/scripts/run-local-smoke.ps1 @@ -0,0 +1,100 @@ +param( + [int]$Port = 18083 +) + +$ErrorActionPreference = "Stop" +$Root = Resolve-Path (Join-Path $PSScriptRoot "..") +$RunDir = Join-Path $Root "target\run" +New-Item -ItemType Directory -Force -Path $RunDir | Out-Null + +$ExistingListener = Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction SilentlyContinue | Select-Object -First 1 +if ($ExistingListener) { + $Process = Get-Process -Id $ExistingListener.OwningProcess -ErrorAction SilentlyContinue + $Name = if ($Process) { $Process.ProcessName } else { "未知进程" } + throw "端口 $Port 已被 PID $($ExistingListener.OwningProcess) ($Name) 占用。请换一个端口,例如 -Port $($Port + 1),或先停止该进程。" +} + +$Config = Join-Path $RunDir "server-$Port.toml" +$Db = (Join-Path $RunDir "lightops-$Port.db").Replace("\", "/") + +@" +bind = "127.0.0.1:$Port" +database_url = "sqlite://${Db}?mode=rwc" +jwt_secret = "local-dev-lightops-secret-please-change" +public_url = "http://127.0.0.1:$Port" +static_dir = "web/dist" +registration_token_ttl_minutes = 30 +task_timeout_secs = 20 +"@ | Set-Content -Encoding UTF8 -Path $Config + +$ServerOut = Join-Path $RunDir "server-$Port.out" +$ServerErr = Join-Path $RunDir "server-$Port.err" +$AgentOut = Join-Path $RunDir "agent-$Port.out" +$AgentErr = Join-Path $RunDir "agent-$Port.err" + +$Server = Start-Process ` + -FilePath (Join-Path $Root "target\debug\lightops-server.exe") ` + -ArgumentList @("--config", $Config) ` + -WorkingDirectory $Root ` + -PassThru ` + -WindowStyle Hidden ` + -RedirectStandardOutput $ServerOut ` + -RedirectStandardError $ServerErr + +Set-Content -Path (Join-Path $RunDir "server-$Port.pid") -Value $Server.Id +Start-Sleep -Seconds 2 + +if ($Server.HasExited) { + throw "Server 提前退出,退出码 $($Server.ExitCode)。请查看 $ServerOut 和 $ServerErr" +} + +$BaseUrl = "http://127.0.0.1:$Port" +$Page = Invoke-WebRequest -UseBasicParsing "$BaseUrl/" -TimeoutSec 10 + +$InitBody = @{ username = "admin"; password = "LightOps@123456" } | ConvertTo-Json +$Init = Invoke-RestMethod -Method Post -Uri "$BaseUrl/api/auth/init" -ContentType "application/json" -Body $InitBody -TimeoutSec 10 +$Jwt = $Init.data.token +$Headers = @{ Authorization = "Bearer $Jwt" } + +$TokenBody = @{ name = "本机测试节点"; ttl_minutes = 30 } | ConvertTo-Json +$AgentTokenResp = Invoke-RestMethod -Method Post -Uri "$BaseUrl/api/agent-tokens" -Headers $Headers -ContentType "application/json" -Body $TokenBody -TimeoutSec 10 +$AgentToken = $AgentTokenResp.data.token + +$AgentConfig = Join-Path $RunDir "agent-$Port.toml" +$Agent = Start-Process ` + -FilePath (Join-Path $Root "target\debug\lightops-agent.exe") ` + -ArgumentList @("--server", $BaseUrl, "--token", $AgentToken, "--config", $AgentConfig, "--name", "本机测试节点") ` + -WorkingDirectory $Root ` + -PassThru ` + -WindowStyle Hidden ` + -RedirectStandardOutput $AgentOut ` + -RedirectStandardError $AgentErr + +Set-Content -Path (Join-Path $RunDir "agent-$Port.pid") -Value $Agent.Id +Start-Sleep -Seconds 5 + +if ($Agent.HasExited) { + throw "Agent 提前退出,退出码 $($Agent.ExitCode)。请查看 $AgentOut 和 $AgentErr" +} + +$Agents = Invoke-RestMethod -Method Get -Uri "$BaseUrl/api/agents" -Headers $Headers -TimeoutSec 10 +$AgentId = $Agents.data[0].id +$Metrics = Invoke-RestMethod -Method Get -Uri "$BaseUrl/api/agents/$AgentId/metrics" -Headers $Headers -TimeoutSec 10 +$Files = Invoke-RestMethod -Method Get -Uri "$BaseUrl/api/agents/$AgentId/files?path=." -Headers $Headers -TimeoutSec 10 + +[pscustomobject]@{ + url = $BaseUrl + server_pid = $Server.Id + agent_pid = $Agent.Id + page_status = $Page.StatusCode + init_success = $Init.success + token_created = $AgentTokenResp.success + agent_count = @($Agents.data).Count + agent_id = $AgentId + agent_status = $Agents.data[0].status + metrics_count = @($Metrics.data).Count + file_api_success = $Files.success + file_count = @($Files.data.entries).Count + username = "admin" + password = "LightOps@123456" +} | ConvertTo-Json -Depth 5 diff --git a/scripts/rust-gnu-linker.cmd b/scripts/rust-gnu-linker.cmd new file mode 100644 index 0000000..b27f240 --- /dev/null +++ b/scripts/rust-gnu-linker.cmd @@ -0,0 +1,3 @@ +@echo off +python "%~dp0rust_gnu_linker.py" %* +exit /b %errorlevel% diff --git a/scripts/rust_gnu_linker.py b/scripts/rust_gnu_linker.py new file mode 100644 index 0000000..cbf0c9d --- /dev/null +++ b/scripts/rust_gnu_linker.py @@ -0,0 +1,44 @@ +import os +import shlex +import subprocess +import sys + + +def expand_args(args): + expanded = [] + for arg in args: + if arg.startswith("@") and os.path.exists(arg[1:]): + with open(arg[1:], "r", encoding="utf-8") as file: + content = file.read() + expanded.extend(shlex.split(content, posix=False)) + else: + expanded.append(arg) + return expanded + + +def output_path(args): + for index, arg in enumerate(args): + if arg == "-o" and index + 1 < len(args): + return args[index + 1].strip('"') + return None + + +def main(): + args = sys.argv[1:] + expanded = expand_args(args) + output = output_path(expanded) + + result = subprocess.run(["x86_64-w64-mingw32-gcc.exe", *args]) + if result.returncode != 0: + return result.returncode + + if output and os.path.exists(output): + subprocess.run(["strip.exe", "--strip-debug", output], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + if output.lower().endswith(".exe"): + subprocess.run(["strip.exe", output], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/uninstall-agent.sh b/scripts/uninstall-agent.sh new file mode 100755 index 0000000..6924e8c --- /dev/null +++ b/scripts/uninstall-agent.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env sh +set -eu + +if [ "$(id -u)" -ne 0 ]; then + echo "请使用 root 用户运行" >&2 + exit 1 +fi + +PURGE="0" +while [ "$#" -gt 0 ]; do + case "$1" in + --purge) PURGE="1"; shift 1 ;; + *) echo "未知参数:$1" >&2; exit 1 ;; + esac +done + +systemctl disable --now lightops-agent 2>/dev/null || true +rm -f /etc/systemd/system/lightops-agent.service +systemctl daemon-reload +rm -f /usr/local/bin/lightops-agent +rm -f /usr/local/bin/lightops-agent.bak.* +if [ "$PURGE" = "1" ]; then + rm -rf /etc/lightops + echo "LightOps Agent 已卸载,配置已清理" +else + echo "LightOps Agent 已卸载,配置保留在 /etc/lightops。需要完全清理请追加 --purge" +fi diff --git a/scripts/update-from-git.sh b/scripts/update-from-git.sh new file mode 100755 index 0000000..3d15c2e --- /dev/null +++ b/scripts/update-from-git.sh @@ -0,0 +1,131 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +REPO_DIR="/opt/lightops" +BRANCH="main" +SERVER_SERVICE="lightops-server" +AGENT_SERVICE="lightops-agent" +LOG_FILE="/var/log/lightops/update.log" +LOCK_FILE="/tmp/lightops-update.lock" + +while [[ $# -gt 0 ]]; do + case "$1" in + --repo-dir) + REPO_DIR="${2:?missing --repo-dir value}" + shift 2 + ;; + --branch) + BRANCH="${2:?missing --branch value}" + shift 2 + ;; + --server-service) + SERVER_SERVICE="${2:?missing --server-service value}" + shift 2 + ;; + --agent-service) + AGENT_SERVICE="${2:?missing --agent-service value}" + shift 2 + ;; + --log-file) + LOG_FILE="${2:?missing --log-file value}" + shift 2 + ;; + *) + echo "未知参数:$1" >&2 + exit 2 + ;; + esac +done + +mkdir -p "$(dirname "$LOG_FILE")" +exec >>"$LOG_FILE" 2>&1 +exec 9>"$LOCK_FILE" +if ! flock -n 9; then + echo "[$(date -Is)] 已有更新任务正在执行" + exit 1 +fi + +if [[ "$(id -u)" -eq 0 ]]; then + SUDO=() +else + SUDO=(sudo -n) +fi + +echo "[$(date -Is)] 开始更新 LightOps" +echo "仓库目录:$REPO_DIR" +echo "目标分支:$BRANCH" + +cd "$REPO_DIR" +if [[ ! -d .git ]]; then + echo "当前目录不是 Git 仓库" + exit 1 +fi + +if [[ -n "$(git status --porcelain)" ]]; then + echo "仓库存在未提交改动,为避免覆盖本地修改,已停止更新" + git status --short + exit 1 +fi + +git fetch origin "$BRANCH" +CURRENT_COMMIT="$(git rev-parse HEAD)" +REMOTE_COMMIT="$(git rev-parse "origin/$BRANCH")" +echo "当前提交:$CURRENT_COMMIT" +echo "远程提交:$REMOTE_COMMIT" + +if [[ "$CURRENT_COMMIT" == "$REMOTE_COMMIT" ]]; then + echo "已经是最新版本" + exit 0 +fi + +git checkout "$BRANCH" +git pull --ff-only origin "$BRANCH" + +echo "[$(date -Is)] 构建前端" +cd "$REPO_DIR/web" +if [[ -f package-lock.json ]]; then + npm ci +else + npm install +fi +npm run build + +echo "[$(date -Is)] 构建 Rust 二进制" +cd "$REPO_DIR" +cargo build --release -p lightops-server -p lightops-agent + +STAMP="$(date +%Y%m%d%H%M%S)" +BACKUP_DIR="$REPO_DIR/target/update-backups/$STAMP" +mkdir -p "$BACKUP_DIR" + +backup_if_exists() { + local path="$1" + if [[ -f "$path" ]]; then + cp -a "$path" "$BACKUP_DIR/$(basename "$path")" + fi +} + +backup_if_exists "/usr/local/bin/lightops-server" +backup_if_exists "/usr/local/bin/lightops-agent" +backup_if_exists "$REPO_DIR/lightops-server" +backup_if_exists "$REPO_DIR/lightops-agent" + +echo "[$(date -Is)] 替换二进制" +"${SUDO[@]}" install -m 755 "$REPO_DIR/target/release/lightops-server" /usr/local/bin/lightops-server +"${SUDO[@]}" install -m 755 "$REPO_DIR/target/release/lightops-agent" /usr/local/bin/lightops-agent + +# 兼容旧 service 文件:ExecStart=/opt/lightops/lightops-server +install -m 755 "$REPO_DIR/target/release/lightops-server" "$REPO_DIR/lightops-server" +install -m 755 "$REPO_DIR/target/release/lightops-agent" "$REPO_DIR/lightops-agent" + +echo "[$(date -Is)] 重启服务" +if systemctl list-unit-files "$AGENT_SERVICE.service" >/dev/null 2>&1; then + "${SUDO[@]}" systemctl restart "$AGENT_SERVICE" || true +fi +if systemctl list-unit-files "$SERVER_SERVICE.service" >/dev/null 2>&1; then + "${SUDO[@]}" systemctl restart "$SERVER_SERVICE" +else + echo "未找到 $SERVER_SERVICE.service,请手动重启 Server" +fi + +echo "[$(date -Is)] 更新完成" diff --git a/scripts/upgrade-agent.sh b/scripts/upgrade-agent.sh new file mode 100755 index 0000000..21d35b0 --- /dev/null +++ b/scripts/upgrade-agent.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env sh +set -eu + +SERVER="" +BIN_URL="" +VERSION="" +SHA256="" + +while [ "$#" -gt 0 ]; do + case "$1" in + --server) SERVER="$2"; shift 2 ;; + --bin-url) BIN_URL="$2"; shift 2 ;; + --version) VERSION="$2"; shift 2 ;; + --sha256) SHA256="$2"; shift 2 ;; + *) echo "未知参数:$1" >&2; exit 1 ;; + esac +done + +if [ "$(id -u)" -ne 0 ]; then + echo "请使用 root 用户运行" >&2 + exit 1 +fi + +if [ -z "$BIN_URL" ]; then + if [ -z "$SERVER" ]; then + if [ -f /etc/lightops/agent.toml ]; then + SERVER="$(awk -F= '/server_url/ { gsub(/[ "]/, "", $2); print $2; exit }' /etc/lightops/agent.toml)" + fi + fi + if [ -z "$SERVER" ]; then + echo "缺少 --server 或 --bin-url" >&2 + exit 1 + fi + OS="$(uname -s | tr '[:upper:]' '[:lower:]')" + ARCH="$(uname -m)" + SUFFIX="" + if [ -n "$VERSION" ]; then + SUFFIX="-$VERSION" + fi + BIN_URL="$SERVER/downloads/lightops-agent-$OS-$ARCH$SUFFIX" +fi + +TMP_BIN="$(mktemp)" +echo "正在下载 Agent:$BIN_URL" +curl -fL --retry 3 --connect-timeout 10 "$BIN_URL" -o "$TMP_BIN" +if [ -n "$SHA256" ]; then + echo "$SHA256 $TMP_BIN" | sha256sum -c - +fi +chmod +x "$TMP_BIN" +"$TMP_BIN" --version >/dev/null + +systemctl stop lightops-agent 2>/dev/null || true +if [ -x /usr/local/bin/lightops-agent ]; then + BACKUP="/usr/local/bin/lightops-agent.bak.$(date +%Y%m%d%H%M%S)" + cp /usr/local/bin/lightops-agent "$BACKUP" +else + BACKUP="" +fi +mv "$TMP_BIN" /usr/local/bin/lightops-agent + +if ! systemctl start lightops-agent; then + echo "新版本启动失败,正在回滚" >&2 + if [ -n "$BACKUP" ]; then + cp "$BACKUP" /usr/local/bin/lightops-agent + systemctl start lightops-agent || true + fi + exit 1 +fi + +sleep 2 +if ! systemctl is-active --quiet lightops-agent; then + echo "新版本未保持运行,正在回滚" >&2 + if [ -n "$BACKUP" ]; then + cp "$BACKUP" /usr/local/bin/lightops-agent + systemctl restart lightops-agent || true + fi + journalctl -u lightops-agent -n 80 --no-pager >&2 || true + exit 1 +fi + +echo "LightOps Agent 已升级" diff --git a/store/catalog/default.toml b/store/catalog/default.toml new file mode 100644 index 0000000..0641668 --- /dev/null +++ b/store/catalog/default.toml @@ -0,0 +1,151 @@ +[[apps]] +slug = "alist" +name = "Alist" +description = "轻量网盘目录程序,适合统一挂载和分享文件。" +category = "storage" +image = "xhofe/alist:latest" +default_port = 5244 +container_port = 5244 +data_dir_suffix = "data" +tags = ["网盘", "文件", "Docker"] +compose_template = """ +services: + app: + image: {{image}} + container_name: {{project}} + ports: + - "{{port}}:{{container_port}}" + volumes: + - "{{data_dir}}:/opt/alist/data" + restart: unless-stopped +""" + +[[apps]] +slug = "uptime-kuma" +name = "Uptime Kuma" +description = "开源服务可用性监控和状态页。" +category = "monitoring" +image = "louislam/uptime-kuma:1" +default_port = 3001 +container_port = 3001 +data_dir_suffix = "data" +tags = ["监控", "状态页", "Docker"] +compose_template = """ +services: + app: + image: {{image}} + container_name: {{project}} + ports: + - "{{port}}:{{container_port}}" + volumes: + - "{{data_dir}}:/app/data" + restart: unless-stopped +""" + +[[apps]] +slug = "gitea" +name = "Gitea" +description = "轻量 Git 代码托管服务。" +category = "dev" +image = "gitea/gitea:1.22" +default_port = 3000 +container_port = 3000 +data_dir_suffix = "data" +tags = ["Git", "代码仓库", "Docker"] +compose_template = """ +services: + gitea: + image: {{image}} + container_name: {{project}} + ports: + - "{{port}}:3000" + - "{{field.ssh_port}}:22" + volumes: + - "{{data_dir}}:/data" + environment: + - USER_UID={{field.user_uid}} + - USER_GID={{field.user_gid}} + restart: unless-stopped +""" + +[[apps.fields]] +key = "ssh_port" +label = "SSH 端口" +type = "port" +required = true +default = 2222 +help = "Gitea Git SSH 克隆端口,必须未被占用。" + +[[apps.fields]] +key = "user_uid" +label = "运行 UID" +type = "number" +required = true +default = 1000 +min = 1 +max = 65535 + +[[apps.fields]] +key = "user_gid" +label = "运行 GID" +type = "number" +required = true +default = 1000 +min = 1 +max = 65535 + +[[apps]] +slug = "redis" +name = "Redis" +description = "内存键值数据库,启用 AOF 持久化。" +category = "database" +image = "redis:7-alpine" +default_port = 6379 +container_port = 6379 +data_dir_suffix = "data" +tags = ["数据库", "缓存", "Docker"] +compose_template = """ +services: + redis: + image: {{image}} + container_name: {{project}} + ports: + - "{{port}}:6379" + volumes: + - "{{data_dir}}:/data" + command: redis-server --appendonly yes + restart: unless-stopped +""" + +[[apps]] +slug = "nginx" +name = "Nginx 示例站" +description = "快速启动一个 Nginx 静态站容器。" +category = "web" +image = "nginx:alpine" +default_port = 8080 +container_port = 80 +data_dir_suffix = "html" +tags = ["Web", "静态站", "Docker"] +compose_template = """ +services: + web: + image: {{image}} + container_name: {{project}} + ports: + - "{{port}}:80" + volumes: + - "{{data_dir}}:/usr/share/nginx/html" + environment: + - LIGHTOPS_SITE_NAME={{field.site_name}} + restart: unless-stopped +""" + +[[apps.fields]] +key = "site_name" +label = "站点名称" +type = "text" +required = false +default = "LightOps 示例站" +placeholder = "例如:我的网站" +max = 80 diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..92771b9 --- /dev/null +++ b/web/index.html @@ -0,0 +1,13 @@ + + + + + + LightOps + + +
+ + + + diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 0000000..86d1aec --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,1257 @@ +{ + "name": "lightops-web", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "lightops-web", + "version": "0.1.0", + "dependencies": { + "@sveltejs/vite-plugin-svelte": "^3.1.2", + "@xterm/xterm": "^5.5.0", + "svelte": "^4.2.19", + "typescript": "^5.5.4", + "vite": "^5.4.2" + }, + "devDependencies": { + "@tsconfig/svelte": "^5.0.4" + } + }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "license": "Apache-2.0", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmmirror.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmmirror.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz", + "integrity": "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz", + "integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz", + "integrity": "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz", + "integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz", + "integrity": "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz", + "integrity": "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz", + "integrity": "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz", + "integrity": "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz", + "integrity": "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz", + "integrity": "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz", + "integrity": "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz", + "integrity": "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz", + "integrity": "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz", + "integrity": "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz", + "integrity": "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz", + "integrity": "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz", + "integrity": "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz", + "integrity": "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.4.tgz", + "integrity": "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz", + "integrity": "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz", + "integrity": "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz", + "integrity": "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz", + "integrity": "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz", + "integrity": "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.4.tgz", + "integrity": "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@sveltejs/vite-plugin-svelte": { + "version": "3.1.2", + "resolved": "https://registry.npmmirror.com/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-3.1.2.tgz", + "integrity": "sha512-Txsm1tJvtiYeLUVRNqxZGKR/mI+CzuIQuc2gn+YCs9rMTowpNZ2Nqt53JdL8KF9bLhAf2ruR/dr9eZCwdTriRA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@sveltejs/vite-plugin-svelte-inspector": "^2.1.0", + "debug": "^4.3.4", + "deepmerge": "^4.3.1", + "kleur": "^4.1.5", + "magic-string": "^0.30.10", + "svelte-hmr": "^0.16.0", + "vitefu": "^0.2.5" + }, + "engines": { + "node": "^18.0.0 || >=20" + }, + "peerDependencies": { + "svelte": "^4.0.0 || ^5.0.0-next.0", + "vite": "^5.0.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte-inspector": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-2.1.0.tgz", + "integrity": "sha512-9QX28IymvBlSCqsCll5t0kQVxipsfhFFL+L2t3nTWfXnddYwxBuAEtTtlaVQpRz9c37BhJjltSeY4AJSC03SSg==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.0.0 || >=20" + }, + "peerDependencies": { + "@sveltejs/vite-plugin-svelte": "^3.0.0", + "svelte": "^4.0.0 || ^5.0.0-next.0", + "vite": "^5.0.0" + } + }, + "node_modules/@tsconfig/svelte": { + "version": "5.0.8", + "resolved": "https://registry.npmmirror.com/@tsconfig/svelte/-/svelte-5.0.8.tgz", + "integrity": "sha512-UkNnw1/oFEfecR8ypyHIQuWYdkPvHiwcQ78sh+ymIiYoF+uc5H1UBetbjyqT+vgGJ3qQN6nhucJviX6HesWtKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "license": "MIT" + }, + "node_modules/@xterm/xterm": { + "version": "5.5.0", + "resolved": "https://registry.npmmirror.com/@xterm/xterm/-/xterm-5.5.0.tgz", + "integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==", + "license": "MIT" + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmmirror.com/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/aria-query": { + "version": "5.3.2", + "resolved": "https://registry.npmmirror.com/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/code-red": { + "version": "1.0.4", + "resolved": "https://registry.npmmirror.com/code-red/-/code-red-1.0.4.tgz", + "integrity": "sha512-7qJWqItLA8/VPVlKJlFXU+NBlo/qyfs39aJcuMT/2ere32ZqvF5OSxgdM5xOfJJ7O429gg2HM47y8v9P+9wrNw==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15", + "@types/estree": "^1.0.1", + "acorn": "^8.10.0", + "estree-walker": "^3.0.3", + "periscopic": "^3.1.0" + } + }, + "node_modules/css-tree": { + "version": "2.3.1", + "resolved": "https://registry.npmmirror.com/css-tree/-/css-tree-2.3.1.tgz", + "integrity": "sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==", + "license": "MIT", + "dependencies": { + "mdn-data": "2.0.30", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmmirror.com/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "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/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/is-reference": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/is-reference/-/is-reference-3.0.3.tgz", + "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.6" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmmirror.com/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/locate-character": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/locate-character/-/locate-character-3.0.0.tgz", + "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmmirror.com/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/mdn-data": { + "version": "2.0.30", + "resolved": "https://registry.npmmirror.com/mdn-data/-/mdn-data-2.0.30.tgz", + "integrity": "sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==", + "license": "CC0-1.0" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "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/periscopic": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/periscopic/-/periscopic-3.1.0.tgz", + "integrity": "sha512-vKiQ8RRtkl9P+r/+oefh25C3fhybptkHKCZSPlcXiJux2tJF55GnEj3BVn4A5gKfq9NWWXXrxkHBwVPUfH0opw==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^3.0.0", + "is-reference": "^3.0.0" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "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.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.60.4", + "resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.60.4.tgz", + "integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==", + "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.4", + "@rollup/rollup-android-arm64": "4.60.4", + "@rollup/rollup-darwin-arm64": "4.60.4", + "@rollup/rollup-darwin-x64": "4.60.4", + "@rollup/rollup-freebsd-arm64": "4.60.4", + "@rollup/rollup-freebsd-x64": "4.60.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", + "@rollup/rollup-linux-arm-musleabihf": "4.60.4", + "@rollup/rollup-linux-arm64-gnu": "4.60.4", + "@rollup/rollup-linux-arm64-musl": "4.60.4", + "@rollup/rollup-linux-loong64-gnu": "4.60.4", + "@rollup/rollup-linux-loong64-musl": "4.60.4", + "@rollup/rollup-linux-ppc64-gnu": "4.60.4", + "@rollup/rollup-linux-ppc64-musl": "4.60.4", + "@rollup/rollup-linux-riscv64-gnu": "4.60.4", + "@rollup/rollup-linux-riscv64-musl": "4.60.4", + "@rollup/rollup-linux-s390x-gnu": "4.60.4", + "@rollup/rollup-linux-x64-gnu": "4.60.4", + "@rollup/rollup-linux-x64-musl": "4.60.4", + "@rollup/rollup-openbsd-x64": "4.60.4", + "@rollup/rollup-openharmony-arm64": "4.60.4", + "@rollup/rollup-win32-arm64-msvc": "4.60.4", + "@rollup/rollup-win32-ia32-msvc": "4.60.4", + "@rollup/rollup-win32-x64-gnu": "4.60.4", + "@rollup/rollup-win32-x64-msvc": "4.60.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/rollup/node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/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/svelte": { + "version": "4.2.20", + "resolved": "https://registry.npmmirror.com/svelte/-/svelte-4.2.20.tgz", + "integrity": "sha512-eeEgGc2DtiUil5ANdtd8vPwt9AgaMdnuUFnPft9F5oMvU/FHu5IHFic+p1dR/UOB7XU2mX2yHW+NcTch4DCh5Q==", + "license": "MIT", + "peer": true, + "dependencies": { + "@ampproject/remapping": "^2.2.1", + "@jridgewell/sourcemap-codec": "^1.4.15", + "@jridgewell/trace-mapping": "^0.3.18", + "@types/estree": "^1.0.1", + "acorn": "^8.9.0", + "aria-query": "^5.3.0", + "axobject-query": "^4.0.0", + "code-red": "^1.0.3", + "css-tree": "^2.3.1", + "estree-walker": "^3.0.3", + "is-reference": "^3.0.1", + "locate-character": "^3.0.0", + "magic-string": "^0.30.4", + "periscopic": "^3.1.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/svelte-hmr": { + "version": "0.16.0", + "resolved": "https://registry.npmmirror.com/svelte-hmr/-/svelte-hmr-0.16.0.tgz", + "integrity": "sha512-Gyc7cOS3VJzLlfj7wKS0ZnzDVdv3Pn2IuVeJPk9m2skfhcu5bq3wtIZyQGggr7/Iim5rH5cncyQft/kRLupcnA==", + "license": "ISC", + "engines": { + "node": "^12.20 || ^14.13.1 || >= 16" + }, + "peerDependencies": { + "svelte": "^3.19.0 || ^4.0.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmmirror.com/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "license": "MIT", + "peer": true, + "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/vitefu": { + "version": "0.2.5", + "resolved": "https://registry.npmmirror.com/vitefu/-/vitefu-0.2.5.tgz", + "integrity": "sha512-SgHtMLoqaeeGnd2evZ849ZbACbnwQCIwRH57t18FxcXoZop0uQu0uzlIhJBlF/eWVzuce0sHeqPcDo+evVcg8Q==", + "license": "MIT", + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + } + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..c00b2d7 --- /dev/null +++ b/web/package.json @@ -0,0 +1,21 @@ +{ + "name": "lightops-web", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite --host 0.0.0.0", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "@sveltejs/vite-plugin-svelte": "^3.1.2", + "@xterm/xterm": "^5.5.0", + "svelte": "^4.2.19", + "typescript": "^5.5.4", + "vite": "^5.4.2" + }, + "devDependencies": { + "@tsconfig/svelte": "^5.0.4" + } +} diff --git a/web/public/install-agent.sh b/web/public/install-agent.sh new file mode 100644 index 0000000..8ee77ce --- /dev/null +++ b/web/public/install-agent.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env sh +set -eu + +SERVER="" +TOKEN="" +BIN_URL="" + +while [ "$#" -gt 0 ]; do + case "$1" in + --server) SERVER="$2"; shift 2 ;; + --token) TOKEN="$2"; shift 2 ;; + --bin-url) BIN_URL="$2"; shift 2 ;; + *) echo "未知参数:$1" >&2; exit 1 ;; + esac +done + +if [ -z "$SERVER" ] || [ -z "$TOKEN" ]; then + echo "用法:install-agent.sh --server https://panel.example.com --token TOKEN [--bin-url URL]" >&2 + exit 1 +fi + +if [ "$(id -u)" -ne 0 ]; then + echo "请使用 root 用户运行" >&2 + exit 1 +fi + +install -d /etc/lightops /usr/local/bin + +if [ -z "$BIN_URL" ]; then + OS="$(uname -s | tr '[:upper:]' '[:lower:]')" + ARCH="$(uname -m)" + BIN_URL="$SERVER/downloads/lightops-agent-$OS-$ARCH" +fi + +if [ -n "$BIN_URL" ]; then + curl -fsSL "$BIN_URL" -o /usr/local/bin/lightops-agent + chmod +x /usr/local/bin/lightops-agent +elif [ ! -x /usr/local/bin/lightops-agent ]; then + echo "未在 /usr/local/bin/lightops-agent 找到 lightops-agent 二进制文件" + echo "请先复制二进制文件,或传入 --bin-url https://.../lightops-agent" + exit 1 +fi + +cat >/etc/lightops/agent.toml </etc/systemd/system/lightops-agent.service <<'EOF' +[Unit] +Description=LightOps Agent +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +ExecStart=/usr/local/bin/lightops-agent --config /etc/lightops/agent.toml +Restart=always +RestartSec=5 +User=root + +[Install] +WantedBy=multi-user.target +EOF + +systemctl daemon-reload +systemctl enable --now lightops-agent diff --git a/web/src/App.svelte b/web/src/App.svelte new file mode 100644 index 0000000..7a84421 --- /dev/null +++ b/web/src/App.svelte @@ -0,0 +1,161 @@ + + +{#if loading} +
加载中...
+{:else if !me && (path === '/login' || path === '/init')} +
+
+

LightOps

+

{path === '/init' ? '初始化管理员' : '登录主控端'}

+ + e.key === 'Enter' && login(path === '/init')} /> + {#if error}

{error}

{/if} + + +
+
+{:else} +
+ +
+
+ {me?.username} + +
+ {#if path.startsWith('/nodes/') && path.endsWith('/files')} + + {:else if path.startsWith('/nodes/') && path.endsWith('/terminal')} + + {:else if path.startsWith('/nodes/') && path.endsWith('/logs')} + + {:else if path.startsWith('/nodes/') && path.endsWith('/services')} + + {:else if path.startsWith('/nodes/') && path.endsWith('/nginx')} + + {:else if path.startsWith('/nodes/') && path.endsWith('/docker')} + + {:else if path.startsWith('/nodes/') && path.endsWith('/tasks')} + + {:else if path.startsWith('/nodes/') && path.endsWith('/apps/manage')} + + {:else if path.startsWith('/nodes/') && path.includes('/apps/') && nodeAppId !== 'manage'} + + {:else if path.startsWith('/nodes/') && path.endsWith('/apps')} + + {:else if path.startsWith('/nodes/')} + + {:else if path === '/apps'} + + {:else if path === '/store'} + + {:else if storeSlug} + + {:else if path === '/nodes'} + + {:else if path === '/audit'} + + {:else if path === '/tasks'} + + {:else if path === '/alerts'} + + {:else if path === '/users'} + + {:else if path === '/settings'} + + {:else} + + {/if} +
+
+{/if} diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts new file mode 100644 index 0000000..f49f2bb --- /dev/null +++ b/web/src/lib/api.ts @@ -0,0 +1,46 @@ +export type ApiResponse = { + success: boolean; + data: T | null; + error: string | null; +}; + +const TOKEN_KEY = 'lightops_token'; + +export function getToken() { + return localStorage.getItem(TOKEN_KEY); +} + +export function setToken(token: string | null) { + if (token) localStorage.setItem(TOKEN_KEY, token); + else localStorage.removeItem(TOKEN_KEY); +} + +export async function api(path: string, options: RequestInit = {}): Promise { + const headers = new Headers(options.headers); + headers.set('content-type', 'application/json'); + const token = getToken(); + if (token) headers.set('authorization', `Bearer ${token}`); + const res = await fetch(path, { ...options, headers }); + const body = (await res.json()) as ApiResponse; + if (!res.ok || !body.success) { + throw new Error(body.error || `HTTP ${res.status}`); + } + return body.data as T; +} + +export async function post(path: string, body: unknown): Promise { + return api(path, { method: 'POST', body: JSON.stringify(body) }); +} + +export async function del(path: string): Promise { + return api(path, { method: 'DELETE' }); +} + +export function wsUrl(path: string) { + const token = getToken(); + const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; + const url = new URL(path, `${proto}//${location.host}`); + if (token) url.searchParams.set('token', token); + return url.toString(); +} + diff --git a/web/src/lib/confirm.ts b/web/src/lib/confirm.ts new file mode 100644 index 0000000..90afd92 --- /dev/null +++ b/web/src/lib/confirm.ts @@ -0,0 +1,28 @@ +export type ConfirmPayload = { + confirmed_at: string; + target: string; + level: 'normal' | 'high'; +}; + +export function requireConfirm(message: string) { + return window.confirm(message); +} + +export function requireDangerConfirm(action: string, target: string, level: 'normal' | 'high' = 'high') { + const cleanTarget = target.trim(); + if (!cleanTarget) return null; + const typed = window.prompt(`高风险操作:${action}\n请输入目标名称以确认:${cleanTarget}`); + if (typed !== cleanTarget) { + window.alert('确认内容不匹配,操作已取消。'); + return null; + } + return { + confirmed_at: new Date().toISOString(), + target: cleanTarget, + level + } satisfies ConfirmPayload; +} + +export function withConfirm>(body: T, confirm: ConfirmPayload | null) { + return confirm ? { ...body, confirm_target: confirm.target, confirm } : body; +} diff --git a/web/src/main.ts b/web/src/main.ts new file mode 100644 index 0000000..72fa86e --- /dev/null +++ b/web/src/main.ts @@ -0,0 +1,7 @@ +import App from './App.svelte'; +import './styles.css'; + +new App({ + target: document.getElementById('app')! +}); + diff --git a/web/src/pages/Alerts.svelte b/web/src/pages/Alerts.svelte new file mode 100644 index 0000000..e232723 --- /dev/null +++ b/web/src/pages/Alerts.svelte @@ -0,0 +1,244 @@ + + +
+
+
+

监控告警

+

告警中心

+

基于 Agent 心跳指标生成告警事件,默认支持 CPU、内存、磁盘阈值。

+
+ +
+ + {#if error}

{error}

{/if} + +
+ + + +
+ +

告警事件

+
+ {#each events as event} +
+ {event.message} + {event.severity} + {statusLabel(event.status)} + {event.agent_id} + {event.last_seen_at} + {#if event.status === 'open'}{/if} +
+ {/each} +
+ +

新增规则

+
+
+ + + + + + + + +
+ +
+ +

规则列表

+
+ {#each rules as item} +
+ {item.name} + {metricLabel(item.metric)} {item.operator || '>='} {item.threshold}{metricUnit(item.metric)} + {item.severity} + {item.enabled ? '启用' : '停用'}{item.silence_until ? ` · 静默到 ${item.silence_until}` : ''} + + + {#if item.silence_until}{/if} + +
+ {/each} +
+ +

通知投递历史

+
+ {#each deliveries as item} +
+ {deliveryStatus(item.status)} + {item.channel} + {item.agent_id || '-'} + {item.message || item.event_id} + {item.error || item.created_at} +
+ {/each} + {#if deliveries.length === 0} +

暂无通知投递记录。

+ {/if} +
+
diff --git a/web/src/pages/AppDetail.svelte b/web/src/pages/AppDetail.svelte new file mode 100644 index 0000000..9ee5370 --- /dev/null +++ b/web/src/pages/AppDetail.svelte @@ -0,0 +1,387 @@ + + +{#if detail} +
+
+
+

{providerLabel(detail.application.provider)} / {typeLabel(detail.application.app_type)}

+

{detail.application.display_name}

+

{detail.application.description || detail.application.name}

+
+
+ {#if detail.available_actions?.includes('start')}{/if} + {#if detail.available_actions?.includes('restart')}{/if} + {#if detail.available_actions?.includes('stop')}{/if} + + {#if detail.available_actions?.includes('health')} + + {/if} + {#if detail.available_actions?.includes('backup')} + + {/if} + +
+
+ {#if error}

{error}

{/if} + +
+
状态{statusLabel(detail.application.status)}
+
版本{detail.application.version || '-'}
+
运行用户{detail.application.run_user || '-'}
+
风险{riskLabel(detail.risk_level)}
+
+ +
+
+
+

健康检查

+

优先检测域名 HTTP,其次检测本机监听端口 TCP,用于快速判断应用是否可访问。

+
+
+ + + + + +
+
+ {#if healthResult} +
+ {healthResult.ok ? '可用' : '异常'} + {healthResult.kind?.toUpperCase()} {healthResult.target} + 耗时 {healthResult.latency_ms ?? '-'} ms + {#if healthResult.status_code}HTTP {healthResult.status_code}{/if} + {#if healthResult.error}{healthResult.error}{/if} +
+ {/if} +
+ + {#if backupResult} +
+

备份结果

+

备份文件:{backupResult.archive_path}

+

大小:{backupResult.size || 0} B

+

包含路径:{(backupResult.paths || []).join(', ') || '-'}

+ +
+ {/if} + +
+
+

路径信息

+

安装目录:{detail.application.install_path || '-'}

+

工作目录:{detail.application.work_dir || '-'}

+

配置:{(detail.application.config_paths || []).join(', ') || '-'}

+

日志:{(detail.application.log_paths || []).join(', ') || '-'}

+

数据:{(detail.application.data_paths || []).join(', ') || '-'}

+ {#if detail.application.install_path} + + {/if} + {#if detail.application.work_dir} + + {/if} + {#each detail.application.config_paths || [] as configPath} + + {/each} + {#each detail.application.log_paths || [] as logPath} + + {/each} +
+
+

关联资源

+

服务:{detail.application.service_name || '-'}

+

容器:{detail.application.container_id || '-'}

+

Compose:{detail.application.compose_project || '-'}

+

Nginx:{detail.application.nginx_site || '-'}

+

端口:{(detail.application.ports || []).join(', ') || '-'}

+

域名:{(detail.application.domains || []).join(', ') || '-'}

+
+ {#if detail.application.service_name}{/if} + {#if detail.application.container_id || detail.application.compose_project}{/if} + {#if detail.application.nginx_site}{/if} + +
+ {#if (detail.application.domains || []).length > 0} +
+ {#each detail.application.domains as domain} + 访问 {domain} + {/each} +
+ {/if} +
+
+ + {#if proxyVisible} +
+

创建应用反代站点

+

用于把域名反向代理到当前应用监听端口。保存前会执行 nginx -t,失败会回滚。

+
+ + + + + +
+
+ + +
+
+ {/if} + +
+

最近操作

+
+ {#each detail.recent_actions || [] as item} +
{actionLabel(item.action)}{item.status === 'success' ? '成功' : item.status === 'failed' ? '失败' : item.status}{item.error || '-'}{item.created_at}
+ {/each} +
+
+ +
{logs}
+
+{/if} diff --git a/web/src/pages/AppManage.svelte b/web/src/pages/AppManage.svelte new file mode 100644 index 0000000..4bc5675 --- /dev/null +++ b/web/src/pages/AppManage.svelte @@ -0,0 +1,72 @@ + + +
+

LightOps 纳管

+

纳管应用

+
+ + + + + + + + + + +
+ {#if error}

{error}

{/if} + {#if result}

{result}

{/if} + +
diff --git a/web/src/pages/AppStore.svelte b/web/src/pages/AppStore.svelte new file mode 100644 index 0000000..fffb03d --- /dev/null +++ b/web/src/pages/AppStore.svelte @@ -0,0 +1,313 @@ + + +
+
+
+

一键部署

+

软件商店

+

基于 Docker Compose 的轻量软件商店。应用安装动作会下发到指定 Agent 执行,不在 Agent 保存复杂数据库。

+
+ +
+ + {#if error}

{error}

{/if} + {#if message}

{message}

{/if} + +
+ + e.key === 'Enter' && loadApps()} /> + + +
+ +
+ {#each apps as app} +
+
+ {categoryLabel(app.category)} +

{app.name}

+

{app.description}

+

镜像:{app.image}

+
+ {#each app.tags || [] as tag} + {tag} + {/each} +
+
+
+ + + + {#each app.fields || [] as field} + {#if field.type === 'bool'} + + {:else if field.type === 'select'} + + {:else} + + {/if} + {/each} +
+
+ + +
+
+ {/each} +
+ +

安装记录

+
+ {#each installs as item} +
+ {item.name} + {statusLabel(item.status)} + {item.project} + {item.agent_id} + {item.error || '-'} +
+ + + + + + + +
+
+ {/each} + {#if installs.length === 0} +

暂无安装记录。

+ {/if} +
+ + {#if logText} +

安装应用日志

+

{logTitle}

+
{logText}
+ {/if} +
diff --git a/web/src/pages/Apps.svelte b/web/src/pages/Apps.svelte new file mode 100644 index 0000000..3f7ef1e --- /dev/null +++ b/web/src/pages/Apps.svelte @@ -0,0 +1,173 @@ + + +
+
+
+

{id ? '当前主机应用' : '全局应用视图'}

+

应用管理

+
+
+ + +
+
+ +
+ {#if !id} + + {/if} + e.key === 'Enter' && load()} /> + + + +
+ + {#if error}

{error}

{/if} + +
+
+ 应用状态健康类型来源端口域名主机 +
+ {#each apps as app} + + {/each} +
+
diff --git a/web/src/pages/Audit.svelte b/web/src/pages/Audit.svelte new file mode 100644 index 0000000..cd962f4 --- /dev/null +++ b/web/src/pages/Audit.svelte @@ -0,0 +1,149 @@ + + +
+
+
+

操作日志

+

日志

+

记录登录、Agent 注册、终端、文件、服务、Nginx、Docker、应用等关键操作。

+
+ +
+ +
+ +
+ + {#if error}

{error}

{/if} + +
+ {#each filteredLogs as log} +
+
+ {log.created_at} + {logLevel(log)} +
+
+
+ {actionLabel(log.action)} + {log.success ? '成功' : '失败'} +
+
+ 用户 {log.user_id || '-'} + 主机 {log.agent_id || '-'} + 目标 {log.target || '-'} + 动作 {log.action} +
+ {#if log.params_summary} +
{log.params_summary}
+ {/if} + {#if log.error} +

{log.error}

+ {/if} +
+
+ {/each} +
+ + {#if !loading && filteredLogs.length === 0} +
+

没有日志

+

暂无匹配的操作记录。

+
+ {/if} +
diff --git a/web/src/pages/Dashboard.svelte b/web/src/pages/Dashboard.svelte new file mode 100644 index 0000000..25c79f4 --- /dev/null +++ b/web/src/pages/Dashboard.svelte @@ -0,0 +1,249 @@ + + +
+
+
+

探针总览

+

{agents.length} 台主机

+

这里是全站唯一主入口。点击某台服务器后,再进入文件、终端、服务、Nginx、Docker、应用等运维功能。

+
+
+ + +
+
+ + {#if error}

{error}

{/if} + +
+
{agents.length}主机总数
+
{onlineCount}在线主机
+
{offlineCount}离线主机
+
{latestSeen || '-'}最近心跳
+
+ +
+ +
+ + + + +
+
+ + {#if tokenResult} +
+

Agent 安装命令

+

在被管理主机上执行下面命令,Agent 会主动连接主控端。

+ +
+ {/if} + +
+ {#each filteredAgents as agent} + {@const metric = latestMetric(agent)} + {@const state = healthState(agent)} + + {/each} +
+ + {#if !loading && agents.length > 0 && filteredAgents.length === 0} +
+

没有匹配的主机

+

调整搜索关键字或健康状态筛选。

+
+ {/if} + + {#if !loading && agents.length === 0} +
+

还没有主机

+

先生成一次性 Token,在服务器上安装 Agent 后,这里会出现探针卡片。

+ +
+ {/if} +
diff --git a/web/src/pages/Docker.svelte b/web/src/pages/Docker.svelte new file mode 100644 index 0000000..1a429cf --- /dev/null +++ b/web/src/pages/Docker.svelte @@ -0,0 +1,559 @@ + + +
+

Docker

+

安装状态:{status.installed ? '已安装' : '未安装'} {status.version || ''}

+ {#if error}

{error}

{/if} +

运行容器

+
+
+ + + + + + +
+ + +
+ +

部署 Compose

+
+
+ + +
+ + +
+ +

Docker Compose 项目

+
+ {#each composeProjects as project} +
+ {project.name} + {project.status === 'Running' ? '运行中' : '已停止'} + 服务:{(project.services || []).join(', ') || '-'} + 容器:{(project.containers || []).length} + + + + + +
+ {/each} + {#if composeProjects.length === 0} +

未发现 Docker Compose 项目。

+ {/if} +
+

容器

+
+ {#each containers as c} +
+ {c.names}{c.image}{containerStatus(c.status)}{c.ports || '-'} + + + + + + + + +
+ {/each} +
+ {#if execContainer} +

容器终端

+
+
+
+ {execContainer.names || execContainer.id} +

通过 docker exec -it 进入容器,只连接当前容器,不开放任意宿主机命令。

+
+ +
+
+
+ {/if} + {#if selectedContainer} +

容器详情

+
+
+
+ {selectedContainer} +

资源占用来自 docker stats --no-stream,配置来自 docker inspect。

+
+ +
+
+
{statValue('CPUPerc')}CPU
+
{statValue('MemUsage')}内存
+
{statValue('NetIO')}网络
+
{statValue('BlockIO')}磁盘 IO
+
+
+ {#each detailPairs(containerDetail) as pair} +
{pair[0]}{pair[1]}
+ {/each} +
+

挂载目录

+ {#if mountList(containerDetail).length === 0} +

无挂载目录。

+ {:else} +
+ {#each mountList(containerDetail) as mount} + {mount} + {/each} +
+ {/if} +
+ {/if} + {#if proxyVisible} +

一键创建 Nginx 反代

+
+

目标:{proxyTarget}。会创建站点、启用站点,并执行 nginx -t 后重载。

+
+ + + + + +
+
+ + +
+
+ {/if} + {#if logs} +

容器日志

+
{logs}
+ {/if} +

镜像

+
+ + +
+
+ {#each images as img} +
+ {img.repository}:{img.tag}{img.id}{img.size} + +
+ {/each} +
+ +

数据卷

+

删除数据卷会删除持久化数据。只建议删除确认不再被容器使用的卷。

+
+ {#each volumes as volume} +
+ {volume.Name || volume.name} + 驱动:{volume.Driver || '-'} + 挂载点:{volume.Mountpoint || '-'} + +
+ {/each} + {#if volumes.length === 0} +

未发现 Docker 数据卷。

+ {/if} +
+
diff --git a/web/src/pages/Files.svelte b/web/src/pages/Files.svelte new file mode 100644 index 0000000..d9e250b --- /dev/null +++ b/web/src/pages/Files.svelte @@ -0,0 +1,360 @@ + + +
+
+
+
+

远程文件系统

+

文件管理

+

通过 Agent 浏览当前主机文件,能访问的范围取决于 Agent 运行用户权限。

+
+ +
+ +
+ {#each roots as root} + + {/each} +
+ +
+ + e.key === 'Enter' && load()} /> + +
+ +
+ e.key === 'Enter' && searchFiles()} /> + + +
+ + {#if searchResults.length > 0} +
+

搜索结果

+
+ {#each searchResults as result} +
+ + {result.is_dir ? '-' : size(result.size)} + - + - + {#if !result.is_dir}{/if} +
+ {/each} +
+
+ {/if} + +
+ + + +
+ + {#if error}

{error}

{/if} + {#if transfer}

{transfer}

{/if} + +
+
+ 名称大小修改时间权限操作 +
+ {#each entries as entry} +
+ + {entry.is_dir ? '-' : size(entry.size)} + {entry.modified || '-'} + {entry.readonly ? '只读' : '可写'} + + {#if !entry.is_dir} + + {/if} + + + + +
+ {/each} +
+
+ +
+

{editPath || '选择左侧文本文件'}

+ +
+ + +
+
+
diff --git a/web/src/pages/Logs.svelte b/web/src/pages/Logs.svelte new file mode 100644 index 0000000..526ca88 --- /dev/null +++ b/web/src/pages/Logs.svelte @@ -0,0 +1,35 @@ + + +
+

日志查看

+
+ + + +
+ {#if error}

{error}

{/if} +
{output}
+
diff --git a/web/src/pages/Nginx.svelte b/web/src/pages/Nginx.svelte new file mode 100644 index 0000000..aa7cd77 --- /dev/null +++ b/web/src/pages/Nginx.svelte @@ -0,0 +1,430 @@ + + +
+
+
+
+

网站与反向代理

+

Nginx 管理

+

安装状态:{status.installed ? '已安装' : '未安装'} {status.version || ''}

+
+
+ + + +
+
+ + {#if error}

{error}

{/if} + {#if output}
{output}
{/if} + +
+

新建站点

+
+ + + + {#if createMode === 'proxy'} + + + {:else if createMode === 'load_balance'} + + + {/if} + {#if modeNeedsRoot(createMode)} + + {/if} + {#if createMode === 'php'} + + {/if} +
+ {#if modeNeedsProxyOptions(createMode)} +
+ + + + +
+ {/if} + +
+ +

站点列表

+
+
+ 站点状态配置路径操作 +
+ {#each sites as site} +
+ + {site.enabled ? '已启用' : '已禁用'} + {site.path} + {selected === site.name ? '编辑中' : '点击编辑'} + + + + + +
+ {/each} +
+
+ +
+

{selected ? `编辑配置:${selected}` : '选择站点配置'}

+ +
+ + + +
+ + {#if selected} +

配置备份

+ {#if backups.length === 0} +

暂无备份。每次保存站点配置前会自动创建备份。

+ {:else} +
+ {#each backups as backup} +
+ {backup.name} + {backupTime(backup.modified)} + {backup.size || 0} B + +
+ {/each} +
+ {/if} + {/if} + +

HTTPS 证书

+

+ Certbot:{sslStatus.installed ? '已安装' : '未安装'} {sslStatus.version || ''} + 自动续期:{sslStatus.auto_renew_enabled ? `已启用(${autoRenewProviderLabel(sslStatus.auto_renew_provider)})` : '未启用'} +

+
+ + + + + +
+
+ {#if (sslStatus.certs || []).length === 0} +

暂未发现 Let’s Encrypt 证书。

+ {:else} + {#each sslStatus.certs || [] as cert} +
+
+ {cert.name} + {certStatusLabel(cert.status)} +
+

到期时间:{formatExpiresAt(cert.expires_at)}

+

剩余天数:{cert.days_remaining ?? '未知'}

+

{cert.fullchain}

+
+ {/each} + {/if} +
+
+
diff --git a/web/src/pages/NodeDetail.svelte b/web/src/pages/NodeDetail.svelte new file mode 100644 index 0000000..fd0e118 --- /dev/null +++ b/web/src/pages/NodeDetail.svelte @@ -0,0 +1,195 @@ + + +
+
+
+

单机运维

+

{agent?.hostname || id}

+

{agent?.ip || '-'} · {agent?.os} / {agent?.arch} / {agent?.status === 'online' ? '在线' : '离线'} · Agent {agent?.version || '-'}{agent?.version_status === 'outdated' ? `(最新 ${agent?.latest_version})` : ''}

+
+
+ {#if agent?.version_status === 'outdated'}{/if} + +
+
+
+
{(m.cpu_usage || 0).toFixed?.(1) || 0}%CPU
+
{Math.round((m.memory_used || 0) / 1024 / 1024)} MB内存使用
+
{Math.round((m.disk_used || 0) / 1024 / 1024 / 1024)} GB磁盘使用
+
{m.load_avg || 0}负载
+
+

监控趋势

+
+
+
CPU{(m.cpu_usage || 0).toFixed?.(1) || 0}%
+ +
+
+
内存{percent(m.memory_used, m.memory_total).toFixed(1)}% · {size(m.memory_used)}
+ +
+
+
磁盘{percent(m.disk_used, m.disk_total).toFixed(1)}% · {size(m.disk_used)}
+ +
+
+
网络下行 {speed(latestRx)} / 上行 {speed(latestTx)}
+ + + + +
+
+

运维操作

+
+ + + + + + + + +
+ +
+
+

系统快照

+

查看进程、监听端口、磁盘分区和网络接口,便于不用 SSH 也能快速定位问题。

+
+ +
+ {#if snapshotError}

{snapshotError}

{/if} +
+
+

高占用进程

+ {#each (snapshot.processes || []).slice(0, 8) as item} +

{item.name} PID {item.pid} · CPU {item.cpu}% · 内存 {item.memory}% · {item.user}

+ {/each} + {#if !snapshot.processes?.length}

暂无进程数据。

{/if} +
+
+

监听端口

+ {#each (snapshot.ports || []).slice(0, 8) as item} +

{item.raw}

+ {/each} + {#if !snapshot.ports?.length}

暂无端口数据。

{/if} +
+
+

磁盘分区

+ {#each snapshot.disks || [] as item} +

{item.mount} {item.used}/{item.size} · {item.usage} · {item.filesystem}

+ {/each} + {#if !snapshot.disks?.length}

暂无磁盘数据。

{/if} +
+
+

网络接口

+ {#each snapshot.networks || [] as item} +

{item.raw}

+ {/each} + {#if !snapshot.networks?.length}

暂无网络接口数据。

{/if} +
+
+
diff --git a/web/src/pages/Nodes.svelte b/web/src/pages/Nodes.svelte new file mode 100644 index 0000000..ee94b51 --- /dev/null +++ b/web/src/pages/Nodes.svelte @@ -0,0 +1,25 @@ + + +
+

主机列表

+
+ {#each agents as a} + + {/each} +
+
diff --git a/web/src/pages/Services.svelte b/web/src/pages/Services.svelte new file mode 100644 index 0000000..990c992 --- /dev/null +++ b/web/src/pages/Services.svelte @@ -0,0 +1,49 @@ + + +
+

服务管理

+
+ {#each services as s} +
+ {s.name}{serviceStatus(s.active)}/{serviceStatus(s.sub)}{s.description} + + + + + +
+ {/each} +
+
diff --git a/web/src/pages/Settings.svelte b/web/src/pages/Settings.svelte new file mode 100644 index 0000000..c640b06 --- /dev/null +++ b/web/src/pages/Settings.svelte @@ -0,0 +1,332 @@ + + +
+
+
+

生产配置

+

系统设置

+

这里控制高风险功能开关、Agent 判定策略和监控保留策略。生产环境建议只给可信用户开启终端和写文件权限。

+
+ +
+ + {#if error}

{error}

{/if} + {#if message}

{message}

{/if} + +
+ + + + + + + + +
+ +
+ + + + + + +
+
+ +
+ +
+
+
+

系统更新

+

从服务器上的 Git 仓库检查远程分支。Windows、macOS、Linux 都只负责提交代码;更新动作由面板触发,在部署服务器上拉取、构建、替换二进制并重启服务。

+
+
+ + +
+
+ {#if updateInfo} +
+
仓库目录{updateInfo.repo_dir}
+
远程仓库{updateInfo.remote_url}
+
分支{updateInfo.branch}
+
当前提交{shortCommit(updateInfo.current_commit)} · {updateInfo.current_subject || '-'}
+
远程提交{shortCommit(updateInfo.remote_commit)}
+
状态{updateInfo.dirty ? '本地有未提交改动,请先处理' : updateInfo.update_available ? `落后 ${updateInfo.behind} 个提交` : '已是最新'}
+ {#if updateInfo.fetch_error}
拉取错误{updateInfo.fetch_error}
{/if} +
+ {:else} +

尚未检查更新。

+ {/if} +
+ + + +
+
+ +
+
+
+

主控备份

+

在线使用 SQLite VACUUM INTO 创建一致性备份。恢复会替换主控数据库并让服务退出,生产环境由 systemd 自动重启。

+
+ +
+
+ {#each backups as backup} +
+ {backup.name} + {size(backup.size)} + {time(backup.modified_at || backup.created_at)} + + + +
+ {/each} + {#if backups.length === 0} +

暂无备份。

+ {/if} +
+
+ +
+

权限目录

+

权限已由后端支持,后续用户管理页会基于这些权限给普通用户授权。

+
+ {#each permissions as permission} + {permission.key} · {permission.label} + {/each} +
+
+
diff --git a/web/src/pages/StoreAppDetail.svelte b/web/src/pages/StoreAppDetail.svelte new file mode 100644 index 0000000..9e9914b --- /dev/null +++ b/web/src/pages/StoreAppDetail.svelte @@ -0,0 +1,71 @@ + + +
+
+
+

软件商店详情

+

{app?.name || slug}

+

{app?.description || '正在加载应用说明...'}

+
+ +
+ + {#if error}

{error}

{/if} + + {#if app} +
+
分类{app.category}
+
镜像{app.image}
+
默认端口{app.default_port}
+
容器端口{app.container_port}
+
+ +
+

安装参数

+ {#if app.fields?.length} +
+ {#each app.fields as field} +
+ {field.label} + {field.type} + {field.required ? '必填' : '可选'} + {field.sensitive ? '敏感字段,会脱敏保存' : field.help || '-'} +
+ {/each} +
+ {:else} +

该应用无需额外安装参数。

+ {/if} +
+ +
+

运维说明

+

安装前会自动检测 Docker、Docker Compose、端口占用、项目名冲突和目录风险。

+

安装后可以在软件商店安装记录中启动、停止、重启、查看日志、更新和卸载;也可以在应用管理中继续查看关联 Docker、Nginx、文件和日志。

+
+ {/if} +
diff --git a/web/src/pages/Tasks.svelte b/web/src/pages/Tasks.svelte new file mode 100644 index 0000000..e8ac645 --- /dev/null +++ b/web/src/pages/Tasks.svelte @@ -0,0 +1,233 @@ + + +
+
+
+

任务执行记录

+

任务历史

+

记录 Server 下发给 Agent 的任务状态、参数摘要、结果和错误,便于排查运维操作。

+
+
+ + +
+
+ +
+
{summary.total || 0}全部
+
{summary.running || 0}运行中
+
{summary.success || 0}成功
+
{(summary.failed || 0) + (summary.timeout || 0)}失败/超时
+
+ +
+ + e.key === 'Enter' && load()} /> + +
+ + {#if error}

{error}

{/if} + +
+ {#each tasks as task} +
+ {task.action} + {statusLabel(task.status)} + 主机 {task.agent_id} + {task.created_at} + {task.error || '-'} + + {#if canRetry(task)} + + {/if} + {#if canCancel(task)} + + {/if} +
+ {#if expanded[task.id]} +
+

任务 ID:{task.id}

+

开始:{task.started_at || '-'},结束:{task.finished_at || '-'}

+

参数

+
{prettyJson(task.params_json)}
+

结果

+
{prettyJson(task.result_json)}
+

错误

+
{task.error || '-'}
+

实时日志

+
+ {#each events[task.id] || [] as event} +
+ {event.created_at} + {event.level} + {event.message} + {#if event.data}
{prettyEventData(event.data)}
{/if} +
+ {/each} + {#if !(events[task.id] || []).length} +

暂无任务事件。

+ {/if} +
+
+ {:else} +
{short(task.params_json)}
+ {#if task.result_json} +
{short(task.result_json)}
+ {/if} + {/if} + {/each} +
+ + {#if !loading && tasks.length === 0} +
+

暂无任务

+

还没有匹配的任务记录。

+
+ {/if} +
diff --git a/web/src/pages/Terminal.svelte b/web/src/pages/Terminal.svelte new file mode 100644 index 0000000..54662fd --- /dev/null +++ b/web/src/pages/Terminal.svelte @@ -0,0 +1,25 @@ + + +
+

远程终端

+
+
diff --git a/web/src/pages/Users.svelte b/web/src/pages/Users.svelte new file mode 100644 index 0000000..8f6e99c --- /dev/null +++ b/web/src/pages/Users.svelte @@ -0,0 +1,191 @@ + + +
+
+
+

访问控制

+

用户管理

+

创建普通运维用户,按功能权限和主机范围授权。管理员默认拥有全部权限。

+
+ +
+ + {#if error}

{error}

{/if} + {#if message}

{message}

{/if} + +
+
+

用户列表

+
+ {#each users as user} +
+ {user.username} + {user.role === 'admin' ? '管理员' : '运维用户'} + 权限 {user.permissions?.length || (user.role === 'admin' ? '全部' : 0)} + + + +
+ {/each} +
+
+ + {#if selected} +
+

{selected.id ? '编辑用户' : '新建用户'}

+
+ + + {#if !selected.id} + + {/if} +
+ + {#if selected.role !== 'admin'} +

功能权限

+
+ {#each permissions as permission} + + {/each} +
+ +

可访问主机

+
+ {#each agents as agent} + + {/each} +
+ {/if} + +
+ + +
+
+ {/if} +
+
diff --git a/web/src/styles.css b/web/src/styles.css new file mode 100644 index 0000000..f904e44 --- /dev/null +++ b/web/src/styles.css @@ -0,0 +1,1131 @@ +:root { + color: #14201d; + background: #eef5ed; + font-family: "Aptos", "Segoe UI", sans-serif; +} + +body { + margin: 0; + min-height: 100vh; + background: + radial-gradient(circle at 10% 10%, rgba(66, 139, 111, 0.18), transparent 30rem), + linear-gradient(135deg, #f8faf4 0%, #dcebe1 100%); +} + +button, input, textarea { + font: inherit; +} + +button { + border: 0; + border-radius: 12px; + background: #183c34; + color: white; + padding: 0.7rem 1rem; + cursor: pointer; +} + +button.ghost { + background: transparent; + color: #183c34; +} + +button.danger { + background: #9c2f22; +} + +button:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +input, textarea { + width: 100%; + box-sizing: border-box; + border: 1px solid rgba(20, 32, 29, 0.14); + border-radius: 12px; + padding: 0.8rem; + background: rgba(255, 255, 255, 0.72); + color: #14201d; +} + +select { + border: 1px solid rgba(20, 32, 29, 0.14); + border-radius: 12px; + padding: 0.8rem; + background: rgba(255, 255, 255, 0.72); + color: #14201d; +} + +textarea { + resize: vertical; + font-family: "JetBrains Mono", "Consolas", monospace; +} + +.center, .auth-shell { + min-height: 100vh; + display: grid; + place-items: center; +} + +.auth-card, .panel, .hero-card { + border: 1px solid rgba(20, 32, 29, 0.1); + border-radius: 24px; + background: rgba(255, 255, 255, 0.72); + box-shadow: 0 24px 70px rgba(34, 67, 55, 0.12); + backdrop-filter: blur(18px); +} + +.auth-card { + width: min(420px, calc(100vw - 2rem)); + padding: 2rem; + display: grid; + gap: 1rem; +} + +.eyebrow { + color: #5c746d; + text-transform: uppercase; + letter-spacing: 0.18em; + font-size: 0.75rem; +} + +.error { + color: #9c2f22; +} + +.app-shell { + display: grid; + grid-template-columns: 220px 1fr; + min-height: 100vh; +} + +aside { + padding: 1.2rem; + display: grid; + align-content: start; + gap: 0.7rem; + background: rgba(10, 26, 22, 0.88); +} + +aside button { + text-align: left; + background: transparent; +} + +aside button.active { + background: #d6f27d; + color: #10231e; +} + +.brand { + color: #d6f27d; + font-weight: 800; + font-size: 1.35rem; + padding: 0.8rem; + cursor: pointer; +} + +.main { + padding: 1rem; +} + +header { + height: 52px; + display: flex; + justify-content: flex-end; + align-items: center; + gap: 1rem; +} + +.panel, .hero-card { + padding: 1.25rem; +} + +.grid-hero { + display: grid; + grid-template-columns: 1fr 1.2fr; + gap: 1rem; +} + +.overview-shell { + display: grid; + gap: 1rem; +} + +.probe-hero { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 1rem; + border: 1px solid rgba(20, 32, 29, 0.1); + border-radius: 28px; + padding: 1.4rem; + color: #d6f27d; + background: + radial-gradient(circle at 85% 10%, rgba(214, 242, 125, 0.22), transparent 22rem), + linear-gradient(135deg, #10231e 0%, #1d4b40 100%); + box-shadow: 0 24px 70px rgba(34, 67, 55, 0.16); +} + +.probe-hero h1 { + margin: 0; + font-size: clamp(2.4rem, 8vw, 6rem); + line-height: 1; +} + +.probe-hero .muted { + max-width: 680px; + color: #d8e6df; +} + +.probe-hero button.ghost { + color: #d6f27d; + border: 1px solid rgba(214, 242, 125, 0.32); +} + +.probe-stats { + display: grid; + grid-template-columns: repeat(6, 1fr); + gap: 1rem; +} + +.stat-card { + border-radius: 20px; + padding: 1rem; + background: rgba(255, 255, 255, 0.72); + border: 1px solid rgba(20, 32, 29, 0.1); +} + +.stat-card b { + display: block; + font-size: 1.9rem; +} + +.stat-card.compact b { + font-size: 1rem; + line-height: 1.45; + word-break: break-word; +} + +.stat-card span { + color: #5c746d; +} + +.stat-card.warn b { + color: #9c2f22; +} + +.probe-toolbar { + display: grid; + grid-template-columns: 1fr auto; + gap: 1rem; + align-items: center; +} + +.segmented { + display: flex; + gap: 0.35rem; + padding: 0.35rem; + border-radius: 16px; + background: rgba(16, 35, 30, 0.08); +} + +.segmented button { + background: transparent; + color: #183c34; + padding: 0.55rem 0.8rem; +} + +.segmented button.active { + background: #183c34; + color: white; +} + +.node-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); + gap: 1rem; +} + +.node-card { + display: grid; + gap: 1rem; + text-align: left; + color: #14201d; + background: rgba(255, 255, 255, 0.72); + border: 1px solid rgba(20, 32, 29, 0.1); + box-shadow: 0 18px 44px rgba(34, 67, 55, 0.1); +} + +.node-card:hover { + transform: translateY(-2px); +} + +.node-title { + display: grid; + grid-template-columns: 18px 1fr auto; + align-items: center; + gap: 0.7rem; +} + +.node-title small { + display: block; + color: #5c746d; + margin-top: 0.2rem; +} + +.node-title em { + font-style: normal; + color: #5c746d; +} + +.warn-text { + color: #a16207 !important; +} + +.node-status-line { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.node-status-line span { + border-radius: 999px; + padding: 0.28rem 0.55rem; + color: #183c34; + background: rgba(214, 242, 125, 0.34); + font-size: 0.86rem; +} + +.probe-rings { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 0.8rem; +} + +.ring-item { + display: grid; + justify-items: center; + gap: 0.35rem; + min-width: 0; +} + +.ring { + --value: 0; + width: 76px; + height: 76px; + display: grid; + place-items: center; + border-radius: 999px; + background: + radial-gradient(circle closest-side, rgba(255, 255, 255, 0.92) 67%, transparent 69%), + conic-gradient(#15a36e calc(var(--value) * 1%), rgba(16, 35, 30, 0.1) 0); + box-shadow: inset 0 0 0 1px rgba(20, 32, 29, 0.04); +} + +.ring b { + font-size: 1rem; +} + +.ring-item span { + font-weight: 700; +} + +.ring-item small { + color: #5c746d; + font-size: 0.75rem; + text-align: center; + white-space: nowrap; +} + +.node-meta { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + color: #5c746d; + font-size: 0.85rem; +} + +.node-meta span { + border-radius: 999px; + padding: 0.3rem 0.55rem; + background: rgba(16, 35, 30, 0.06); +} + +.empty-state { + text-align: center; +} + +.hero-card h1 { + font-size: clamp(2rem, 6vw, 5rem); + margin: 0; +} + +.table { + display: grid; + gap: 0.5rem; +} + +.row { + display: grid; + grid-template-columns: 24px 1.2fr 1fr 1fr 1fr; + align-items: center; + gap: 0.7rem; + padding: 0.75rem; + border-radius: 14px; + background: rgba(255, 255, 255, 0.58); + color: #14201d; + text-align: left; +} + +.row button { + padding: 0.45rem 0.65rem; +} + +.task-row { + grid-template-columns: 1.1fr 0.55fr 1fr 1fr 1fr auto auto auto; +} + +.docker-row { + grid-template-columns: 1.1fr 1fr 1fr 1.2fr repeat(8, auto); +} + +.volume-row { + grid-template-columns: 1fr 0.7fr 1.6fr auto; +} + +.backup-row { + grid-template-columns: 1.4fr 0.6fr 1fr auto auto auto; +} + +.nginx-backup-row { + grid-template-columns: 1.4fr 1fr 0.5fr auto; +} + +.wide { + grid-column: 1 / -1; +} + +.task-detail { + border-radius: 16px; + padding: 0.9rem; + background: rgba(255, 255, 255, 0.58); +} + +.button-link { + display: inline-flex; + align-items: center; + border-radius: 12px; + padding: 0.65rem 0.9rem; + color: white; + background: #183c34; + text-decoration: none; +} + +.alert-row { + grid-template-columns: 1.5fr 0.7fr 0.7fr 1fr 1fr auto; +} + +.user-row { + grid-template-columns: 1fr 0.7fr 0.7fr auto auto auto; +} + +.settings-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + gap: 1rem; + margin: 1rem 0; +} + +.compact-settings { + grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); +} + +.setting-card { + display: grid; + grid-template-columns: 1fr auto; + gap: 0.5rem; + align-items: center; + border-radius: 18px; + padding: 1rem; + background: rgba(255, 255, 255, 0.62); +} + +.setting-card small { + grid-column: 1 / -1; + color: #5c746d; +} + +.permission-list { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.permission-list span { + border-radius: 999px; + padding: 0.35rem 0.65rem; + background: rgba(16, 35, 30, 0.08); +} + +.selectable-list button { + background: rgba(16, 35, 30, 0.08); + color: #183c34; +} + +.selectable-list button.active { + background: #183c34; + color: white; +} + +.success { + color: #137a55; +} + +.dot { + width: 10px; + height: 10px; + border-radius: 999px; + background: #9ca3af; +} + +.dot.online { + background: #15a36e; + box-shadow: 0 0 0 6px rgba(21, 163, 110, 0.12); +} + +.muted { + color: #5c746d; +} + +.metric-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 1rem; + margin: 1rem 0; +} + +.metric-grid div { + background: #10231e; + color: #d6f27d; + padding: 1rem; + border-radius: 18px; +} + +.metric-grid b { + display: block; + font-size: 1.6rem; +} + +.metric-grid span { + color: #d8e6df; +} + +.trend-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1rem; + margin: 1rem 0; +} + +.trend-card { + border-radius: 20px; + padding: 1rem; + background: rgba(255, 255, 255, 0.62); +} + +.trend-card div { + display: flex; + justify-content: space-between; + gap: 1rem; + margin-bottom: 0.7rem; + color: #183c34; +} + +.trend-card span { + color: #5c746d; +} + +.trend-card svg { + width: 100%; + height: 120px; + border-radius: 14px; + background: linear-gradient(180deg, rgba(214, 242, 125, 0.22), rgba(255, 255, 255, 0.2)); +} + +.trend-card polyline { + fill: none; + stroke: #183c34; + stroke-width: 2.4; + stroke-linecap: round; + stroke-linejoin: round; +} + +.trend-card polyline.rx { + stroke: #137a55; +} + +.trend-card polyline.tx { + stroke: #d98b1f; +} + +.detail-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + gap: 0.8rem; + margin: 1rem 0; +} + +.detail-grid div { + display: grid; + gap: 0.25rem; + border-radius: 14px; + padding: 0.8rem; + background: rgba(16, 35, 30, 0.06); + word-break: break-all; +} + +.detail-grid span { + color: #5c746d; + font-size: 0.82rem; +} + +.actions, .inline-form { + display: flex; + flex-wrap: wrap; + gap: 0.7rem; +} + +.title-row { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 1rem; +} + +.filter-bar { + display: grid; + grid-template-columns: 180px 1fr 160px 180px auto; + gap: 0.7rem; + margin: 1rem 0; +} + +.store-filter { + grid-template-columns: 220px 1fr 160px auto; +} + +.store-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 1rem; + margin: 1rem 0; +} + +.store-card { + display: grid; + gap: 1rem; + align-content: space-between; + border-radius: 22px; + padding: 1rem; + background: rgba(255, 255, 255, 0.64); + box-shadow: 0 18px 55px rgba(16, 35, 30, 0.08); +} + +.store-card h2 { + margin: 0.45rem 0; +} + +.store-pill { + display: inline-flex; + border-radius: 999px; + padding: 0.28rem 0.6rem; + color: #183c34; + background: rgba(214, 242, 125, 0.55); + font-size: 0.78rem; + font-weight: 700; +} + +.store-form { + grid-template-columns: 1fr 1fr; + margin: 0; +} + +.store-install-row { + grid-template-columns: 1fr 0.7fr 0.9fr 1fr 1.2fr 2.8fr; +} + +.store-actions { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 0.35rem; +} + +.app-table { + display: grid; + gap: 0.5rem; +} + +.app-row { + display: grid; + grid-template-columns: 1.3fr 0.7fr 0.9fr 0.9fr 0.7fr 1fr 1fr; + align-items: center; + gap: 0.7rem; + padding: 0.75rem; + border-radius: 14px; + background: rgba(255, 255, 255, 0.58); + color: #14201d; + text-align: left; +} + +.apps-row { + grid-template-columns: 1.3fr 0.65fr 0.75fr 0.9fr 0.9fr 0.7fr 1fr 1fr; +} + +.app-head { + font-weight: 700; + background: rgba(16, 35, 30, 0.08); +} + +.good { + color: #137a55; +} + +.bad { + color: #9c2f22; +} + +.detail-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 1rem; + margin: 1rem 0; +} + +.info-card { + border-radius: 18px; + padding: 1rem; + background: #10231e; + color: #d6f27d; +} + +.info-card span { + display: block; + color: #d8e6df; +} + +.inner { + box-shadow: none; +} + +.form-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 1rem; + margin: 1rem 0; +} + +.form-grid label { + display: grid; + gap: 0.4rem; +} + +.form-grid label small { + color: var(--muted); + font-size: 0.78rem; +} + +.form-grid .check { + grid-template-columns: auto 1fr; + align-items: center; +} + +.form-grid .check input { + width: auto; +} + +.inline-actions { + align-items: center; + justify-content: flex-end; +} + +.inline-actions .check { + display: inline-flex; + align-items: center; + gap: 0.4rem; + color: var(--muted); +} + +.compact-metrics { + margin-bottom: 1rem; +} + +.sub-title-row { + margin-top: 1.5rem; +} + +.detail-card { + border: 1px solid var(--border); + border-radius: 18px; + padding: 1rem; + background: rgba(255, 255, 255, 0.72); + min-height: 160px; +} + +.detail-card h3 { + margin: 0 0 0.75rem; +} + +.detail-card p { + margin: 0.35rem 0; +} + +.mono-line { + font-family: ui-monospace, SFMono-Regular, Consolas, monospace; + overflow-wrap: anywhere; +} + +.task-event-stream { + display: grid; + gap: 0.5rem; +} + +.task-event-line { + border-left: 3px solid #70bda3; + border-radius: 12px; + background: rgba(255, 255, 255, 0.65); + padding: 0.65rem 0.75rem; +} + +.task-event-line span { + color: var(--muted); + font-size: 0.78rem; +} + +.task-event-line b { + margin: 0 0.5rem; +} + +.task-event-line pre { + margin: 0.45rem 0 0; + white-space: pre-wrap; +} + +.split { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1rem; +} + +.file-shell { + display: grid; + grid-template-columns: minmax(520px, 1.2fr) minmax(360px, 0.8fr); + gap: 1rem; +} + +.file-browser, .file-editor { + min-width: 0; +} + +.root-list, .path-bar { + display: flex; + flex-wrap: wrap; + gap: 0.55rem; + margin: 1rem 0; +} + +.root-list button { + background: rgba(16, 35, 30, 0.08); + color: #183c34; + padding: 0.55rem 0.8rem; +} + +.root-list button.active { + background: #183c34; + color: white; +} + +.path-bar { + display: grid; + grid-template-columns: auto 1fr auto; +} + +.upload-button { + position: relative; + overflow: hidden; + border-radius: 12px; + background: #183c34; + color: white; + padding: 0.7rem 1rem; + cursor: pointer; +} + +.upload-button input { + position: absolute; + inset: 0; + opacity: 0; + cursor: pointer; +} + +.file-table { + display: grid; + gap: 0.45rem; + margin-top: 1rem; +} + +.file-row { + display: grid; + grid-template-columns: minmax(180px, 1fr) 90px 130px 70px minmax(210px, auto); + gap: 0.6rem; + align-items: center; + padding: 0.65rem; + border-radius: 14px; + background: rgba(255, 255, 255, 0.58); +} + +.file-head { + font-weight: 700; + color: #5c746d; + background: rgba(16, 35, 30, 0.08); +} + +.file-name { + display: flex; + align-items: center; + gap: 0.55rem; + padding: 0; + background: transparent; + color: #14201d; + text-align: left; +} + +.file-name b { + border-radius: 999px; + padding: 0.22rem 0.48rem; + color: #183c34; + background: rgba(214, 242, 125, 0.42); + font-size: 0.8rem; +} + +.file-name span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.file-actions { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 0.35rem; +} + +.file-actions button { + padding: 0.45rem 0.6rem; +} + +.nginx-shell { + display: grid; + grid-template-columns: minmax(560px, 1fr) minmax(420px, 0.9fr); + gap: 1rem; +} + +.nginx-create { + margin: 1rem 0; + border-radius: 18px; + padding: 1rem; + background: rgba(16, 35, 30, 0.06); +} + +.nginx-editor { + min-width: 0; +} + +.compact-log { + min-height: auto; + max-height: 180px; + margin: 1rem 0; +} + +.cert-list { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); + gap: 0.45rem; + margin-top: 0.8rem; +} + +.cert-card { + border: 1px solid rgba(30, 64, 55, 0.12); + border-radius: 16px; + padding: 0.75rem; + background: rgba(255, 255, 255, 0.72); +} + +.cert-card div { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.6rem; +} + +.cert-card p { + margin: 0.35rem 0 0; + overflow-wrap: anywhere; +} + +.cert-pill { + border-radius: 999px; + padding: 0.22rem 0.5rem; + font-size: 0.86rem; +} + +.cert-pill.good { + color: #183c34; + background: rgba(214, 242, 125, 0.42); +} + +.cert-pill.warn { + color: #6d4a00; + background: rgba(255, 202, 87, 0.32); +} + +.cert-pill.danger { + color: #7a1e19; + background: rgba(255, 114, 94, 0.22); +} + +.cert-pill.muted { + color: #52615d; + background: rgba(82, 97, 93, 0.12); +} + +.terminal { + height: calc(100vh - 180px); + min-height: 420px; + border-radius: 16px; + overflow: hidden; + background: #08110f; + padding: 0.7rem; +} + +.log-output { + min-height: 420px; + overflow: auto; + white-space: pre-wrap; + border-radius: 16px; + background: #08110f; + color: #d8e6df; + padding: 1rem; +} + +.log-toolbar { + margin: 1rem 0; +} + +.audit-log-stream { + display: grid; + gap: 0.75rem; +} + +.audit-log-line { + display: grid; + grid-template-columns: 220px 1fr; + gap: 1rem; + border-radius: 18px; + padding: 0.95rem; + background: rgba(255, 255, 255, 0.64); + border: 1px solid rgba(20, 32, 29, 0.1); + border-left: 5px solid #15a36e; +} + +.audit-log-line.risk-line { + border-left-color: #a16207; +} + +.audit-log-line.error-line { + border-left-color: #9c2f22; +} + +.audit-log-time { + display: grid; + align-content: start; + gap: 0.45rem; + color: #5c746d; + font-size: 0.88rem; +} + +.audit-log-time b { + width: fit-content; + border-radius: 999px; + padding: 0.25rem 0.55rem; + color: #183c34; + background: rgba(214, 242, 125, 0.42); +} + +.audit-log-body { + display: grid; + gap: 0.55rem; + min-width: 0; +} + +.audit-log-main { + display: flex; + justify-content: space-between; + gap: 1rem; + align-items: center; +} + +.audit-log-main em { + font-style: normal; + color: #5c746d; +} + +.audit-log-meta { + display: flex; + flex-wrap: wrap; + gap: 0.45rem; +} + +.audit-log-meta span { + border-radius: 999px; + padding: 0.28rem 0.55rem; + color: #5c746d; + background: rgba(16, 35, 30, 0.06); + font-size: 0.86rem; +} + +.audit-log-body pre { + margin: 0; + overflow: auto; + border-radius: 12px; + padding: 0.75rem; + color: #d8e6df; + background: #08110f; + font-size: 0.86rem; +} + +@media (max-width: 820px) { + .app-shell, .grid-hero, .split, .probe-stats, .probe-toolbar, .file-shell, .nginx-shell { + grid-template-columns: 1fr; + } + .probe-hero { + display: grid; + } + aside { + grid-auto-flow: column; + overflow-x: auto; + } + .metric-grid { + grid-template-columns: 1fr 1fr; + } + .filter-bar, .app-row, .detail-grid, .form-grid, .file-row, .path-bar { + grid-template-columns: 1fr; + } + .file-actions { + justify-content: flex-start; + } + .audit-log-line { + grid-template-columns: 1fr; + } +} diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..4a5ba67 --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "@tsconfig/svelte/tsconfig.json", + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "module": "ESNext", + "resolveJsonModule": true, + "allowSyntheticDefaultImports": true, + "moduleResolution": "bundler", + "strict": true + }, + "include": ["src/**/*.d.ts", "src/**/*.ts", "src/**/*.svelte"], + "references": [{ "path": "./tsconfig.node.json" }] +} + diff --git a/web/tsconfig.node.json b/web/tsconfig.node.json new file mode 100644 index 0000000..e428d50 --- /dev/null +++ b/web/tsconfig.node.json @@ -0,0 +1,11 @@ +{ + "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..5132da2 --- /dev/null +++ b/web/vite.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from 'vite'; +import { svelte, vitePreprocess } from '@sveltejs/vite-plugin-svelte'; + +export default defineConfig({ + plugins: [svelte({ preprocess: vitePreprocess() })], + build: { + chunkSizeWarningLimit: 800 + }, + server: { + proxy: { + '/api': 'http://127.0.0.1:8080' + } + } +});