commit d39f04033c68ef5702c0cc3532018cdfcd30a5b0 Author: judy <68886854+judy-gotv@users.noreply.github.com> Date: Wed Sep 30 16:11:51 2026 +0800 Add files via upload diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..6c3f0b1 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,4348 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "actix-codec" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c13df95297bcf9014dc89162b0cc69431e192e34e3b419612fc124cfcd45dbf" +dependencies = [ + "bitflags", + "bytes", + "futures-core", + "futures-sink", + "memchr", + "pin-project-lite", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "actix-files" +version = "0.6.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8c4f30e3272d7c345f88ae0aac3848507ef5ba871f9cc2a41c8085a0f0523b" +dependencies = [ + "actix-http", + "actix-service", + "actix-utils", + "actix-web", + "bitflags", + "bytes", + "derive_more", + "futures-core", + "http-range", + "log", + "mime", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "v_htmlescape", +] + +[[package]] +name = "actix-http" +version = "3.18.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2ab7d72c0bb26f10df574ea560177b4de2f27ee8f421b2c1e9b75628bd9db23" +dependencies = [ + "actix-codec", + "actix-service", + "actix-utils", + "base64 0.22.1", + "bitflags", + "brotli", + "bytes", + "bytestring", + "derive_more", + "encoding_rs", + "flate2", + "foldhash 0.2.0", + "futures-core", + "h2", + "http 0.2.12", + "httparse", + "httpdate", + "itoa", + "language-tags", + "local-channel", + "mime", + "percent-encoding", + "pin-project-lite", + "rand 0.10.3", + "sha1 0.11.0", + "smallvec", + "tokio", + "tokio-util", + "tracing", + "zstd", +] + +[[package]] +name = "actix-macros" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "367f814ad4afbac74f07df5001214da65f65e185c90ef56c4dd8df23f8695b9b" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "actix-router" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14f8c75c51892f18d9c46150c5ac7beb81c95f78c8b83a634d49f4ca32551fe7" +dependencies = [ + "bytestring", + "cfg-if", + "http 0.2.12", + "regex", + "regex-lite", + "serde", + "tracing", +] + +[[package]] +name = "actix-rt" +version = "2.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5f794807f82bbd36430c12cd600c73bbab0f52fdde4f0ed49978df113f4807f" +dependencies = [ + "futures-core", + "tokio", +] + +[[package]] +name = "actix-server" +version = "2.9.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8db2cf6f034e227c0c79825fbf11f3cad48569109515f62542b84d8abedb9376" +dependencies = [ + "actix-rt", + "actix-service", + "futures-core", + "futures-util", + "mio", + "socket2", + "tokio", + "tracing", +] + +[[package]] +name = "actix-service" +version = "2.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e46f36bf0e5af44bdc4bdb36fbbd421aa98c79a9bce724e1edeb3894e10dc7f" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "actix-utils" +version = "3.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0128396dd7313f697ad05b21b1a7be7d4cbb81888704f55996e4a27db196bb4d" +dependencies = [ + "local-waker", + "pin-project-lite", +] + +[[package]] +name = "actix-web" +version = "4.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbacab3593b6b4f7be815076fc52d60a83c873426824675417e2abdd229e2e36" +dependencies = [ + "actix-codec", + "actix-http", + "actix-macros", + "actix-router", + "actix-rt", + "actix-server", + "actix-service", + "actix-utils", + "actix-web-codegen", + "bytes", + "bytestring", + "cfg-if", + "cookie", + "derive_more", + "encoding_rs", + "foldhash 0.2.0", + "futures-core", + "futures-util", + "impl-more", + "itoa", + "language-tags", + "log", + "mime", + "once_cell", + "pin-project-lite", + "regex", + "regex-lite", + "serde", + "serde_json", + "serde_urlencoded", + "smallvec", + "socket2", + "time", + "tracing", + "url", +] + +[[package]] +name = "actix-web-codegen" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b96b09c4878563f8ab4a5fd0c59f9f0d6e0e9f60eb9b748526a0b9604fd89c50" +dependencies = [ + "actix-router", + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common 0.1.7", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" +dependencies = [ + "alloc-no-stdlib", +] + +[[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.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +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.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures 0.2.17", + "password-hash", +] + +[[package]] +name = "async-compression" +version = "0.4.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3789ca2cd1c4e8ce71fe9bd6312566014e20ff3016fc87bc71c59ff93b5fa61" +dependencies = [ + "compression-codecs", + "compression-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[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 = "aws-lc-rs" +version = "1.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" +dependencies = [ + "serde_core", +] + +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest 0.10.7", +] + +[[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 = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "brotli" +version = "8.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bstr" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f" +dependencies = [ + "memchr", + "serde_core", +] + +[[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.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "bytestring" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86566c496f2f47d9b8147a4c8b02ffdb69c919fe0c2b2e7195d22cbba0e635c9" +dependencies = [ + "bytes", +] + +[[package]] +name = "cc" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f360145194ee8e21db5ee7f3fcd4fe52210864c75c985dae33218202c8bbe040" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core 0.10.1", +] + +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20 0.9.1", + "cipher", + "poly1305", + "zeroize", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "chrono-tz" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93698b29de5e97ad0ae26447b344c482a7284c737d9ddc5f9e52b74a336671bb" +dependencies = [ + "chrono", + "chrono-tz-build", + "phf", +] + +[[package]] +name = "chrono-tz-build" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c088aee841df9c3041febbb73934cfc39708749bf96dc827e3359cd39ef11b1" +dependencies = [ + "parse-zoneinfo", + "phf", + "phf_codegen", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common 0.1.7", + "inout", + "zeroize", +] + +[[package]] +name = "clap" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "clap_lex" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "compression-codecs" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f018b36ac50598f7bfb0aa6094281e253e6b3cbc507a69652d2fb963a326d8d" +dependencies = [ + "compression-core", + "flate2", + "memchr", +] + +[[package]] +name = "compression-core" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e8ccc4ea9f6acc32d102c0f6d471d11d913ad15f20c04de743374861fa1d414" + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "cookie" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e859cd57d0710d9e06c381b550c06e76992472a8c6d527aecd2fc673dcc231fb" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core_detect" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +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 = "crc32fast" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03e8bd762f7479489c70ed6c768ddca99d7296857de437a68dcb2a94365b3fae" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "csv" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938" +dependencies = [ + "csv-core", + "itoa", + "ryu", + "serde_core", +] + +[[package]] +name = "csv-core" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782" +dependencies = [ + "memchr", +] + +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid 0.9.6", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", + "unicode-xid", +] + +[[package]] +name = "deunicode" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abd57806937c9cc163efc8ea3910e00a62e2aeb0b8119f1793a978088f8f6b04" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "const-oid 0.9.6", + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid 0.10.2", + "crypto-common 0.2.2", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "either" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" +dependencies = [ + "serde", +] + +[[package]] +name = "email-encoding" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "420b9da095f052ea597503e39073b5b3c522f7db933fbac202d91d24492693fd" +dependencies = [ + "base64 0.23.1", + "memchr", +] + +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" + +[[package]] +name = "encoding_rs" +version = "0.8.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e985e0451871ad22fb8d2b6b076e2028a502a0d3950998c2c5c0a4f9b5d9679" +dependencies = [ + "cfg-if", + "core_detect", + "multiversion_no_op", + "rustversion", + "scopeguard", + "simdutf8", +] + +[[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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "find-msvc-tools" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide", + "zlib-rs", +] + +[[package]] +name = "flume" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +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.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +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.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasm-bindgen", +] + +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + +[[package]] +name = "globset" +version = "0.4.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07c34a9410465b45bd9787443bc7370f37735bad04b0f0cd57ff1a3186c98988" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "globwalk" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf760ebf69878d9fd8f110c89703d90ce35095324d1f1edcb595c63945ee757" +dependencies = [ + "bitflags", + "ignore", + "walkdir", +] + +[[package]] +name = "h2" +version = "0.3.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http 0.2.12", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.1.5", +] + +[[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 0.10.7", +] + +[[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 = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http 1.5.0", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http 1.5.0", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "http-range" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21dec9db110f5f872ed9699c3ecf50cf16f423502706ba5c72462e28d3157573" + +[[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 = "humansize" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb51c9a029ddc91b07a787f1d86b53ccfa49b0e86688c946ebe8d3555685dd7" +dependencies = [ + "libm", +] + +[[package]] +name = "hybrid-array" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" +dependencies = [ + "typenum", +] + +[[package]] +name = "hyper" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http 1.5.0", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" +dependencies = [ + "http 1.5.0", + "hyper", + "hyper-util", + "rustls", + "rustls-native-certs", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" +dependencies = [ + "base64 0.23.1", + "bytes", + "futures-channel", + "futures-util", + "http 1.5.0", + "http-body", + "httparse", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[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.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[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 = "ignore" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b69833ed729dc5aa7d19541d96d6cf8e9137194207a04916d658e43168402f" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata", + "same-file", + "walkdir", + "winapi-util", +] + +[[package]] +name = "impl-more" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30c0cddce6b7505483307994f60d97c4b156020e1504d0cf1f05a21b1b325e64" + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + +[[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 = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "language-tags" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4345964bb142484797b161f473a503a434de77149dd8c7427788c6e13379388" + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] + +[[package]] +name = "lettre" +version = "0.11.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2c646bd5cc763b1087b15493e29a64be6147ba8f19342004fa52048ee596eae" +dependencies = [ + "async-trait", + "base64 0.23.1", + "email-encoding", + "email_address", + "fastrand", + "futures-io", + "futures-util", + "httpdate", + "idna", + "mime", + "nom", + "percent-encoding", + "quoted_printable", + "rustls", + "socket2", + "tokio", + "tokio-rustls", + "url", + "webpki-roots 1.0.9", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[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.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d" +dependencies = [ + "bitflags", + "libc", + "plain", + "redox_syscall 0.9.4", +] + +[[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 = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "local-channel" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6cbc85e69b8df4b8bb8b89ec634e7189099cea8927a276b7384ce5488e53ec8" +dependencies = [ + "futures-core", + "futures-sink", + "local-waker", +] + +[[package]] +name = "local-waker" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d873d7c67ce09b42110d801813efbc9364414e356be9935700d368351657487" + +[[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.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "lru-slab" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest 0.10.7", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "multiversion_no_op" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "ntapi" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" +dependencies = [ + "winapi", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.8", + "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.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "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 = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[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 = "parse-zoneinfo" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f2a05b18d44e2957b88f96ba460715e295bc1d7510468a2f3d3b44535d26c24" +dependencies = [ + "regex", +] + +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + +[[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 = "pest" +version = "2.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45d3aca230fad2e6f6317ca0a72724338c4960cb97168a85cdee66df4a9a21a8" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "284b60557f2c4a2e72ad3f2d34d42685a2fa4a6a61d0d2a10c0ae2a5e916c2cf" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d9d1f08a115309ee99268cf85e5228e0e56aa9caf8841ec12866b6be07c3109" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pest_meta" +version = "2.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed93ba1a9ffcca32130a5188701c81c0c49cf00d4b7c5007d5148951d743adcb" +dependencies = [ + "pest", +] + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_shared", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared", + "rand 0.8.8", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +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 = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quinn" +version = "0.11.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9746dbde176634f4f2f1faf2404e30a31b2bc1e9cafb5329c95d8177a18c9fc" +dependencies = [ + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.3", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "quoted_printable" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "478e0585659a122aa407eb7e3c0e1fa51b1d8a870038bd29f0cf4a8551eea972" + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +dependencies = [ + "chacha20 0.10.2", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + +[[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", +] + +[[package]] +name = "redox_syscall" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "737970939a87c6fa31e7acad13307bccbb017a073b695b6089a2c484f929e20e" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-lite" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "http 1.5.0", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[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 0.9.6", + "digest 0.10.7", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[package]] +name = "rust_proxy_admin" +version = "0.1.0" +dependencies = [ + "actix-files", + "actix-web", + "aes-gcm", + "anyhow", + "argon2", + "base64 0.22.1", + "chacha20poly1305", + "chrono", + "clap", + "csv", + "futures-util", + "hex", + "hmac", + "lettre", + "rand 0.9.5", + "regex", + "reqwest", + "rustls", + "rustls-native-certs", + "serde", + "serde_json", + "serde_yaml", + "sha2", + "sqlx", + "sysinfo", + "tera", + "thiserror", + "tokio", + "tokio-rustls", + "tokio-tungstenite", + "tokio-util", + "toml", + "tracing", + "tracing-subscriber", + "url", + "urlencoding", + "uuid", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustls" +version = "0.23.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +dependencies = [ + "aws-lc-rs", + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[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 = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha1" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "digest 0.11.3", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[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 0.10.7", + "rand_core 0.6.4", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "siphasher" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "slug" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "882a80f72ee45de3cc9a5afeb2da0331d58df69e4e7d8eeb5d3c7784ae67e724" +dependencies = [ + "deunicode", + "wasm-bindgen", +] + +[[package]] +name = "smallvec" +version = "1.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" +dependencies = [ + "serde", +] + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" +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 0.22.1", + "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", + "tokio", + "tokio-stream", + "tracing", + "url", + "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 2.0.119", +] + +[[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 2.0.119", + "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 0.22.1", + "bitflags", + "byteorder", + "bytes", + "chrono", + "crc", + "digest 0.10.7", + "dotenvy", + "either", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "generic-array", + "hex", + "hkdf", + "hmac", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "percent-encoding", + "rand 0.8.8", + "rsa", + "serde", + "sha1 0.10.7", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror", + "tracing", + "whoami", +] + +[[package]] +name = "sqlx-postgres" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" +dependencies = [ + "atoi", + "base64 0.22.1", + "bitflags", + "byteorder", + "chrono", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-util", + "hex", + "hkdf", + "hmac", + "home", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "rand 0.8.8", + "serde", + "serde_json", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror", + "tracing", + "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", + "tracing", + "url", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + +[[package]] +name = "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.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "sysinfo" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c33cd241af0f2e9e3b5c32163b873b29956890b5342e6745b917ce9d490f4af" +dependencies = [ + "core-foundation-sys", + "libc", + "memchr", + "ntapi", + "rayon", + "windows", +] + +[[package]] +name = "tera" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8004bca281f2d32df3bacd59bc67b312cb4c70cea46cbd79dbe8ac5ed206722" +dependencies = [ + "chrono", + "chrono-tz", + "globwalk", + "humansize", + "lazy_static", + "percent-encoding", + "pest", + "pest_derive", + "rand 0.8.8", + "regex", + "serde", + "serde_json", + "slug", + "unicode-segmentation", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a9daff607c6d2bf6c16fd681ccb7eecc83e4e2cdc1ca067ffaadfca5de7f084" +dependencies = [ + "futures-util", + "log", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tungstenite", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "libc", + "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", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "async-compression", + "bitflags", + "bytes", + "futures-core", + "futures-util", + "http 1.5.0", + "http-body", + "http-body-util", + "pin-project-lite", + "tokio", + "tokio-util", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[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 2.0.119", +] + +[[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 = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "tungstenite" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4793cb5e56680ecbb1d843515b23b6de9a75eb04b66643e256a396d43be33c13" +dependencies = [ + "bytes", + "data-encoding", + "http 1.5.0", + "httparse", + "log", + "rand 0.9.5", + "rustls", + "rustls-pki-types", + "sha1 0.10.7", + "thiserror", + "utf-8", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + +[[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.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[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-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[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 = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + +[[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.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "v_htmlescape" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e8257fbc510f0a46eb602c10215901938b5c2a7d5e70fc11483b1d3c9b5b18c" + +[[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 = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" +dependencies = [ + "js-sys", + "tokio", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.6", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.9", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +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-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[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.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12342cb4d8e3b046f3d80effd474a7a02447231330ef77d71daa6fbc40681143" +dependencies = [ + "windows-core 0.57.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2ed2439a290666cd67ecce2b0ffaad89c2a56b976b736e6ece670297897832d" +dependencies = [ + "windows-implement 0.57.0", + "windows-interface 0.57.0", + "windows-result 0.1.2", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement 0.60.2", + "windows-interface 0.59.3", + "windows-link", + "windows-result 0.4.1", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9107ddc059d5b6fbfbffdfa7a7fe3e22a226def0b2608f72e9d552763d3e1ad7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[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 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29bee4b38ea3cde66011baa44dba677c432a78593e202392d1e9070cf2a7fca7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[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 2.0.119", +] + +[[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.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-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 = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.59" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6df92bf3d9227be3d53173901ddbffac2babc27ae50f397776ffd6dc33f800cb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.59" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac4f328cf2f05d084e496c3e9c3f33ed0a183656a16e1fcec4d464d8373aec82" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[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.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64d80649ab6db9d9f6f9c80a40becd948eda4714a0a5ac8c4d157a32231c7882" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.1.0+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ef0a8027ec3ee71300ab3bcbcd0393f434aa72b91ca6d635a39941deae8eea0" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..a7df34e --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,49 @@ +[package] +name = "rust_proxy_admin" +version = "0.1.0" +edition = "2021" + +[dependencies] +actix-web = "4" +actix-files = "0.6" +tokio = { version = "1", features = ["full"] } +tokio-util = "0.7" +sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "sqlite", "chrono", "migrate"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +serde_yaml = "0.9" +argon2 = "0.5" +chrono = { version = "0.4", features = ["serde"] } +toml = "0.8" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +tera = "1" +rand = "0.9" +base64 = "0.22" +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls-native-roots", "gzip", "socks"] } +url = "2" +clap = { version = "4", features = ["derive"] } +thiserror = "2" +anyhow = "1" +csv = "1" +sysinfo = "0.32" +regex = "1" +hmac = "0.12" +sha2 = "0.10" +hex = "0.4" +lettre = { version = "0.11", default-features = false, features = ["tokio1-rustls-tls", "smtp-transport", "builder"] } +urlencoding = "2" +tokio-tungstenite = { version = "0.26", features = ["rustls-tls-native-roots"] } +tokio-rustls = "0.26" +rustls = "0.23" +rustls-native-certs = "0.8" +aes-gcm = "0.10" +chacha20poly1305 = "0.10" +uuid = { version = "1.26.1", features = ["v4"] } +futures-util = { version = "0.3", features = ["sink"] } + +[profile.release] +lto = true +codegen-units = 1 +opt-level = 3 +strip = true diff --git a/README.md b/README.md new file mode 100644 index 0000000..5ea5062 --- /dev/null +++ b/README.md @@ -0,0 +1,34 @@ +# RustProxyAdmin v7 + +多节点代理/端口转发/隧道管理面板(Rust + actix-web + SQLite)。 + +## 构建 + +```bash +# 需要 Rust 1.88+ +cargo build --release +``` + +## 运行 + +```bash +# 复制并修改配置 +cp config.toml config.local.toml # 按需修改 [server]/[auth] 等 +./target/release/rust_proxy_admin +# 浏览器访问 http://127.0.0.1:8080/admin +``` + +常用环境变量覆盖(`RPA__` 前缀 + `__` 分隔层级): + +- `RPA__SERVER__BIND=127.0.0.1:8080` +- `RPA__DB__URL=sqlite://data/rust_proxy_admin.db` +- `RPA__SERVER__SECRET_KEY=...`(渠道密钥 AES-256-GCM 加密密钥,不设则明文存储) +- `RPA__AUTH__USERNAME` / `RPA__AUTH__PASSWORD`(初始管理员) + +## 其他命令 + +```bash +./target/release/rust_proxy_admin reset-password --username admin --password '新密码' +``` + +完整功能说明见需求文档 `RustProxyAdmin______v7.md`(P0–P13)。 diff --git a/config.toml b/config.toml new file mode 100644 index 0000000..c8f86e4 --- /dev/null +++ b/config.toml @@ -0,0 +1,61 @@ +[server] +bind = "0.0.0.0:8080" +trusted_proxies = ["127.0.0.1"] +session_hours = 24 +cookie_secure = false +public_base_url = "http://127.0.0.1:8080" + +[db] +url = "sqlite://data/data.db" + +[auth] +init_username = "admin" +init_password = "ChangeMe123!" +login_max_fail = 5 +login_lock_minutes = 15 + +[time] +timezone_offset_hours = 8 + +[log] +level = "info" +access_log_enabled = true +access_log_sample_rate = 1.0 +access_log_retention_days = 14 +audit_log_retention_days = 180 +subscription_log_retention_days = 30 + +[stats] +aggregate_interval_secs = 60 +hourly_retention_days = 60 +daily_retention_days = 730 + +[nodes] +heartbeat_interval_secs = 10 +heartbeat_timeout_secs = 30 +config_pull_interval_secs = 15 +report_interval_secs = 10 + +[subscription] +rate_limit_per_min = 30 +default_expire_preset = "30d" + +[import] +fetch_timeout_secs = 15 +max_body_mb = 5 +user_agent = "RustProxyAdmin/1.0" +parse_cache_minutes = 10 + +[templates] +max_size_kb = 256 +regex_max_len = 200 + +[lifecycle] +tick_secs = 60 +expired_sub_cleanup_days = 0 + +[alerts] +dispatch_interval_secs = 10 +max_attempts = 5 +node_check_interval_secs = 15 +event_retention_days = 60 diff --git a/migrations/001_init.sql b/migrations/001_init.sql new file mode 100644 index 0000000..af8c3b7 --- /dev/null +++ b/migrations/001_init.sql @@ -0,0 +1,353 @@ +PRAGMA foreign_keys = ON; + +-- ============ 用户 / 会话 / 设置 ============ +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', -- admin | viewer + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, -- 32 字节随机 base64url + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + csrf_token TEXT NOT NULL, + ip_addr TEXT, + user_agent TEXT, + expires_at TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +); + +-- ============ 服务器节点 ============ +CREATE TABLE IF NOT EXISTS nodes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + is_local INTEGER NOT NULL DEFAULT 0, -- 1 = Master 本机 + addr TEXT, + public_host TEXT, -- 对外域名/IP,用于生成订阅 + api_token TEXT NOT NULL, + enabled INTEGER NOT NULL DEFAULT 1, + meta TEXT, -- JSON:地区、运营商、备注 + version TEXT, + last_heartbeat_at TEXT, + last_load TEXT, -- JSON:{cpu,mem,conns} + online_state TEXT NOT NULL DEFAULT 'unknown', -- unknown | online | offline(离线告警用) + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +-- ============ 分组 / 标签 / 输出模板 ============ +CREATE TABLE IF NOT EXISTS entry_groups ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + color TEXT NOT NULL DEFAULT '#3b82f6', + description TEXT, + sort_order INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS tags ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + color TEXT NOT NULL DEFAULT '#64748b', + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS sub_templates ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + description TEXT, + format TEXT NOT NULL, -- clash | singbox + content TEXT NOT NULL, -- Clash=YAML,Sing-box=JSON,含 @ 标记 + is_builtin INTEGER NOT NULL DEFAULT 0, -- 内置模板不可删除/修改,可复制 + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +-- ============ 导入记录(必须先于条目表创建) ============ +CREATE TABLE IF NOT EXISTS import_records ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, + source_url TEXT, + source_type TEXT NOT NULL, -- clash | singbox | base64 | uri_list | paste + target_node_id INTEGER REFERENCES nodes(id) ON DELETE SET NULL, + parsed_count INTEGER NOT NULL DEFAULT 0, + imported_count INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +-- ============ 三类条目 ============ +CREATE TABLE IF NOT EXISTS proxy_rules ( -- 落地代理 + id INTEGER PRIMARY KEY AUTOINCREMENT, + node_id INTEGER NOT NULL REFERENCES nodes(id) ON DELETE CASCADE, + name TEXT NOT NULL, + listen_addr TEXT, -- 本地监听,可空(纯导入条目无需监听) + upstream_type TEXT NOT NULL, -- http | https | socks5 | ss | vmess | vless | trojan | hysteria2 | tuic ... + upstream_addr TEXT NOT NULL, -- host:port + auth_user TEXT, + auth_pass TEXT, + export_host TEXT, -- 订阅导出地址;空则用 upstream 的 host + export_port INTEGER, + extra TEXT, -- JSON:原始节点完整配置(Clash 风格字典) + source_import_id INTEGER REFERENCES import_records(id) ON DELETE SET NULL, + group_id INTEGER REFERENCES entry_groups(id) ON DELETE SET NULL, + expires_at TEXT, -- UTC;NULL = 不过期 + enabled INTEGER NOT NULL DEFAULT 1, + disabled_reason TEXT, -- manual | expired | quota + remark TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS port_forwards ( -- 端口转发 + id INTEGER PRIMARY KEY AUTOINCREMENT, + node_id INTEGER NOT NULL REFERENCES nodes(id) ON DELETE CASCADE, + name TEXT NOT NULL, + listen_ip TEXT NOT NULL DEFAULT '0.0.0.0', + listen_port INTEGER NOT NULL, + target_ip TEXT NOT NULL, + target_port INTEGER NOT NULL, + protocol TEXT NOT NULL DEFAULT 'tcp', -- tcp | udp | both + export_host TEXT, + export_port INTEGER, + extra TEXT, + source_import_id INTEGER REFERENCES import_records(id) ON DELETE SET NULL, + group_id INTEGER REFERENCES entry_groups(id) ON DELETE SET NULL, + expires_at TEXT, + enabled INTEGER NOT NULL DEFAULT 1, + disabled_reason TEXT, + remark TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(node_id, listen_ip, listen_port, protocol) +); + +CREATE TABLE IF NOT EXISTS tunnels ( -- 中转隧道 + id INTEGER PRIMARY KEY AUTOINCREMENT, + node_id INTEGER NOT NULL REFERENCES nodes(id) ON DELETE CASCADE, + name TEXT NOT NULL, + tunnel_type TEXT NOT NULL, -- tcp | ws | wss | reverse + local_addr TEXT NOT NULL, + remote_addr TEXT NOT NULL, + token TEXT, + export_host TEXT, + export_port INTEGER, + extra TEXT, + source_import_id INTEGER REFERENCES import_records(id) ON DELETE SET NULL, + group_id INTEGER REFERENCES entry_groups(id) ON DELETE SET NULL, + expires_at TEXT, + enabled INTEGER NOT NULL DEFAULT 1, + disabled_reason TEXT, + remark TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +-- ============ 多态关联表(删除条目时由应用层同事务清理) ============ +CREATE TABLE IF NOT EXISTS entry_tags ( + entry_type TEXT NOT NULL, -- proxy | forward | tunnel + entry_id INTEGER NOT NULL, + tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE, + PRIMARY KEY (entry_type, entry_id, tag_id) +); + +CREATE TABLE IF NOT EXISTS entry_quotas ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + entry_type TEXT NOT NULL, + entry_id INTEGER NOT NULL, + quota_bytes INTEGER NOT NULL, -- 0 = 无限制(等同于删除配额) + used_bytes INTEGER NOT NULL DEFAULT 0, + period TEXT NOT NULL DEFAULT 'total', -- total | monthly + reset_day INTEGER NOT NULL DEFAULT 1, -- monthly 时每月几号重置,1~28 + period_start TEXT NOT NULL DEFAULT (datetime('now')), + warn_level INTEGER NOT NULL DEFAULT 0, -- 本周期已发出的最高预警百分比 + status TEXT NOT NULL DEFAULT 'ok', -- ok | exceeded + exceeded_at TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE (entry_type, entry_id) +); + +-- ============ 日志 / 统计 / 监控 ============ +CREATE TABLE IF NOT EXISTS audit_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + actor_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, + actor_name TEXT, -- 系统动作为 'system' + action TEXT NOT NULL, + resource_type TEXT, + resource_id INTEGER, + node_id INTEGER, + detail TEXT, -- JSON + ip_addr TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS access_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + node_id INTEGER NOT NULL, + rule_type TEXT NOT NULL, -- proxy | forward | tunnel + rule_id INTEGER NOT NULL, + client_ip TEXT, + started_at TEXT NOT NULL, + ended_at TEXT, + bytes_up INTEGER NOT NULL DEFAULT 0, + bytes_down INTEGER NOT NULL DEFAULT 0, + status TEXT +); + +CREATE TABLE IF NOT EXISTS stats_hourly ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + stat_time TEXT NOT NULL, -- 2026-09-30T14 + node_id INTEGER NOT NULL, + rule_type TEXT NOT NULL, + rule_id INTEGER NOT NULL, + total_connections INTEGER NOT NULL DEFAULT 0, + total_bytes_up INTEGER NOT NULL DEFAULT 0, + total_bytes_down INTEGER NOT NULL DEFAULT 0, + UNIQUE(stat_time, node_id, rule_type, rule_id) +); + +CREATE TABLE IF NOT EXISTS stats_daily ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + stat_date TEXT NOT NULL, -- 2026-09-30 + node_id INTEGER NOT NULL, + rule_type TEXT NOT NULL, + rule_id INTEGER NOT NULL, + total_connections INTEGER NOT NULL DEFAULT 0, + total_bytes_up INTEGER NOT NULL DEFAULT 0, + total_bytes_down INTEGER NOT NULL DEFAULT 0, + UNIQUE(stat_date, node_id, rule_type, rule_id) +); + +CREATE TABLE IF NOT EXISTS metrics_snapshots ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + node_id INTEGER NOT NULL REFERENCES nodes(id) ON DELETE CASCADE, + collected_at TEXT NOT NULL, + cpu REAL, mem REAL, conns INTEGER, net_up_bps INTEGER, net_down_bps INTEGER +); + +-- ============ 订阅 ============ +CREATE TABLE IF NOT EXISTS subscriptions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, + token TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + description TEXT, + scope TEXT NOT NULL DEFAULT 'custom', -- single | custom | all | rule + default_format TEXT NOT NULL DEFAULT 'clash', -- clash | singbox | base64 | json + template_id INTEGER REFERENCES sub_templates(id) ON DELETE SET NULL, + enabled INTEGER NOT NULL DEFAULT 1, + disabled_reason TEXT, -- manual | expired + expires_at TEXT, -- NULL = 永久;UTC + max_ips INTEGER, -- 24h 内不同来源 IP 数上限,NULL = 不限 + access_count INTEGER NOT NULL DEFAULT 0, + last_access_at TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS subscription_entries ( -- 手工勾选的条目(single / custom,及 rule 的例外项) + id INTEGER PRIMARY KEY AUTOINCREMENT, + subscription_id INTEGER NOT NULL REFERENCES subscriptions(id) ON DELETE CASCADE, + entry_type TEXT NOT NULL, + entry_id INTEGER NOT NULL, + sort_order INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(subscription_id, entry_type, entry_id) +); + +CREATE TABLE IF NOT EXISTS subscription_rules ( -- 动态规则 + id INTEGER PRIMARY KEY AUTOINCREMENT, + subscription_id INTEGER NOT NULL REFERENCES subscriptions(id) ON DELETE CASCADE, + kind TEXT NOT NULL, -- group | tag | node | type + value TEXT NOT NULL, -- group/tag 存 id;node 存 node_id;type 存 proxy|forward|tunnel + mode TEXT NOT NULL DEFAULT 'include', -- include | exclude + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS subscription_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + subscription_id INTEGER NOT NULL REFERENCES subscriptions(id) ON DELETE CASCADE, + client_ip TEXT, + user_agent TEXT, + format TEXT, + result TEXT NOT NULL, -- ok | expired | disabled | ip_limit + accessed_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +-- ============ 告警 ============ +CREATE TABLE IF NOT EXISTS alert_channels ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + kind TEXT NOT NULL, -- webhook | telegram | email + config TEXT NOT NULL, -- JSON,见第 14 章 + 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 alert_rules ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + event_type TEXT NOT NULL, -- 见 14.3 事件清单 + params TEXT NOT NULL DEFAULT '{}', -- JSON 阈值参数 + channel_ids TEXT NOT NULL DEFAULT '[]', -- JSON 数组 + cooldown_secs INTEGER NOT NULL DEFAULT 3600, + 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 alert_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + rule_id INTEGER REFERENCES alert_rules(id) ON DELETE SET NULL, + event_type TEXT NOT NULL, + dedup_key TEXT NOT NULL, + severity TEXT NOT NULL DEFAULT 'warning', -- info | warning | critical + title TEXT NOT NULL, + body TEXT NOT NULL, + resource_type TEXT, + resource_id INTEGER, + status TEXT NOT NULL DEFAULT 'pending', -- pending | sent | failed + attempts INTEGER NOT NULL DEFAULT 0, + next_retry_at TEXT, + last_error TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + sent_at TEXT +); + +-- ============ 索引 ============ +CREATE INDEX IF NOT EXISTS idx_sessions_exp ON sessions(expires_at); +CREATE INDEX IF NOT EXISTS idx_proxy_node ON proxy_rules(node_id); +CREATE INDEX IF NOT EXISTS idx_proxy_import ON proxy_rules(source_import_id); +CREATE INDEX IF NOT EXISTS idx_proxy_group ON proxy_rules(group_id); +CREATE INDEX IF NOT EXISTS idx_proxy_exp ON proxy_rules(expires_at) WHERE expires_at IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_forward_node ON port_forwards(node_id); +CREATE INDEX IF NOT EXISTS idx_forward_import ON port_forwards(source_import_id); +CREATE INDEX IF NOT EXISTS idx_forward_group ON port_forwards(group_id); +CREATE INDEX IF NOT EXISTS idx_forward_exp ON port_forwards(expires_at) WHERE expires_at IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_tunnel_node ON tunnels(node_id); +CREATE INDEX IF NOT EXISTS idx_tunnel_import ON tunnels(source_import_id); +CREATE INDEX IF NOT EXISTS idx_tunnel_group ON tunnels(group_id); +CREATE INDEX IF NOT EXISTS idx_tunnel_exp ON tunnels(expires_at) WHERE expires_at IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_entry_tags_tag ON entry_tags(tag_id); +CREATE INDEX IF NOT EXISTS idx_audit_created ON audit_logs(created_at); +CREATE INDEX IF NOT EXISTS idx_audit_action ON audit_logs(action); +CREATE INDEX IF NOT EXISTS idx_access_rule ON access_logs(rule_type, rule_id, started_at); +CREATE INDEX IF NOT EXISTS idx_access_node_time ON access_logs(node_id, started_at); +CREATE INDEX IF NOT EXISTS idx_stats_h_time ON stats_hourly(stat_time); +CREATE INDEX IF NOT EXISTS idx_stats_d_date ON stats_daily(stat_date); +CREATE INDEX IF NOT EXISTS idx_metrics_node ON metrics_snapshots(node_id, collected_at); +CREATE INDEX IF NOT EXISTS idx_sub_token ON subscriptions(token); +CREATE INDEX IF NOT EXISTS idx_sub_entries_sub ON subscription_entries(subscription_id); +CREATE INDEX IF NOT EXISTS idx_sub_rules_sub ON subscription_rules(subscription_id); +CREATE INDEX IF NOT EXISTS idx_sub_logs_sub ON subscription_logs(subscription_id, accessed_at); +CREATE INDEX IF NOT EXISTS idx_alert_ev_status ON alert_events(status, next_retry_at); +CREATE INDEX IF NOT EXISTS idx_alert_ev_dedup ON alert_events(rule_id, dedup_key, created_at); diff --git a/migrations/002_node_status.sql b/migrations/002_node_status.sql new file mode 100644 index 0000000..c74c028 --- /dev/null +++ b/migrations/002_node_status.sql @@ -0,0 +1,10 @@ +-- P10:Worker 上报的条目运行状态(Master 侧存储,供监控页展示与 runtime_error 告警用) +CREATE TABLE IF NOT EXISTS node_entry_status ( + node_id INTEGER NOT NULL REFERENCES nodes(id) ON DELETE CASCADE, + entry_type TEXT NOT NULL, -- proxy | forward | tunnel + entry_id INTEGER NOT NULL, + status TEXT NOT NULL DEFAULT 'unknown', -- running | idle | error:<原因> + conns INTEGER NOT NULL DEFAULT 0, + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (node_id, entry_type, entry_id) +); diff --git a/src/audit.rs b/src/audit.rs new file mode 100644 index 0000000..f3407d7 --- /dev/null +++ b/src/audit.rs @@ -0,0 +1,55 @@ +use crate::util::now_str; +use sqlx::SqlitePool; + +/// 写审计日志 +#[allow(clippy::too_many_arguments)] +pub async fn audit( + pool: &SqlitePool, + actor_user_id: Option, + actor_name: &str, + action: &str, + resource_type: Option<&str>, + resource_id: Option, + node_id: Option, + detail: serde_json::Value, + ip_addr: &str, +) { + let _ = sqlx::query( + "INSERT INTO audit_logs + (actor_user_id, actor_name, action, resource_type, resource_id, node_id, detail, ip_addr, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + ) + .bind(actor_user_id) + .bind(actor_name) + .bind(action) + .bind(resource_type) + .bind(resource_id) + .bind(node_id) + .bind(detail.to_string()) + .bind(if ip_addr.is_empty() { None } else { Some(ip_addr) }) + .bind(now_str()) + .execute(pool) + .await; +} + +/// 系统动作的审计(actor_name='system') +pub async fn audit_system( + pool: &SqlitePool, + action: &str, + resource_type: Option<&str>, + resource_id: Option, + detail: serde_json::Value, +) { + audit( + pool, + None, + "system", + action, + resource_type, + resource_id, + None, + detail, + "", + ) + .await; +} diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..f5fe0aa --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,34 @@ +use crate::db; +use crate::util::hash_password; +use sqlx::SqlitePool; + +/// rust_proxy_admin reset-password --username admin --password 'NewPass123!' +pub async fn reset_password(db_url: &str, username: &str, password: &str) -> anyhow::Result<()> { + if password.len() < 8 { + anyhow::bail!("新密码至少 8 位"); + } + let pool: SqlitePool = db::connect(db_url).await?; + db::migrate(&pool).await?; + let exists: Option<(i64,)> = + sqlx::query_as("SELECT id FROM users WHERE username = ?") + .bind(username) + .fetch_optional(&pool) + .await?; + let Some((uid,)) = exists else { + anyhow::bail!("用户 {username} 不存在"); + }; + let mut tx = pool.begin().await?; + sqlx::query("UPDATE users SET password_hash = ?, updated_at = datetime('now') WHERE id = ?") + .bind(hash_password(password)?) + .bind(uid) + .execute(&mut *tx) + .await?; + // 旧会话全部失效 + sqlx::query("DELETE FROM sessions WHERE user_id = ?") + .bind(uid) + .execute(&mut *tx) + .await?; + tx.commit().await?; + println!("用户 {username} 的密码已重置,旧会话已全部失效"); + Ok(()) +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..f86073c --- /dev/null +++ b/src/config.rs @@ -0,0 +1,346 @@ +use serde::Deserialize; + +fn d_bind() -> String { + "0.0.0.0:8080".into() +} +fn d_db() -> String { + "sqlite://data/data.db".into() +} +fn d_true() -> bool { + true +} +fn d_session_hours() -> i64 { + 24 +} +fn d_tz() -> i64 { + 8 +} +fn d_rate() -> i64 { + 30 +} + +#[derive(Debug, Clone, Deserialize)] +pub struct Config { + #[serde(default)] + pub server: ServerCfg, + #[serde(default)] + pub db: DbCfg, + #[serde(default)] + pub auth: AuthCfg, + #[serde(default)] + pub time: TimeCfg, + #[serde(default)] + pub log: LogCfg, + #[serde(default)] + pub stats: StatsCfg, + #[serde(default)] + pub nodes: NodesCfg, + #[serde(default)] + pub subscription: SubCfg, + #[serde(default)] + pub import: ImportCfg, + #[serde(default)] + pub templates: TplCfg, + #[serde(default)] + pub lifecycle: LifecycleCfg, + #[serde(default)] + pub alerts: AlertsCfg, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct ServerCfg { + #[serde(default = "d_bind")] + pub bind: String, + #[serde(default)] + pub trusted_proxies: Vec, + #[serde(default = "d_session_hours")] + pub session_hours: i64, + #[serde(default)] + pub cookie_secure: bool, + #[serde(default)] + pub public_base_url: String, + /// P13:告警渠道密钥 AES-256-GCM 加密密钥(未配置则渠道密钥明文存储) + #[serde(default)] + pub secret_key: Option, +} +impl Default for ServerCfg { + fn default() -> Self { + Self { + bind: d_bind(), + trusted_proxies: vec!["127.0.0.1".into()], + session_hours: 24, + cookie_secure: false, + public_base_url: "http://127.0.0.1:8080".into(), + secret_key: None, + } + } +} + +#[derive(Debug, Clone, Deserialize)] +pub struct DbCfg { + #[serde(default = "d_db")] + pub url: String, +} +impl Default for DbCfg { + fn default() -> Self { + Self { url: d_db() } + } +} + +#[derive(Debug, Clone, Deserialize)] +pub struct AuthCfg { + #[serde(default)] + pub init_username: String, + #[serde(default)] + pub init_password: String, + #[serde(default = "d_rate")] + pub login_max_fail: i64, + #[serde(default)] + pub login_lock_minutes: i64, +} +impl Default for AuthCfg { + fn default() -> Self { + Self { + init_username: "admin".into(), + init_password: "ChangeMe123!".into(), + login_max_fail: 5, + login_lock_minutes: 15, + } + } +} + +#[derive(Debug, Clone, Deserialize)] +pub struct TimeCfg { + #[serde(default = "d_tz")] + pub timezone_offset_hours: i64, +} +impl Default for TimeCfg { + fn default() -> Self { + Self { + timezone_offset_hours: 8, + } + } +} + +#[derive(Debug, Clone, Deserialize)] +pub struct LogCfg { + #[serde(default)] + pub level: String, + #[serde(default = "d_true")] + pub access_log_enabled: bool, + #[serde(default)] + pub access_log_sample_rate: f64, + #[serde(default)] + pub access_log_retention_days: i64, + #[serde(default)] + pub audit_log_retention_days: i64, + #[serde(default)] + pub subscription_log_retention_days: i64, +} +impl Default for LogCfg { + fn default() -> Self { + Self { + level: "info".into(), + access_log_enabled: true, + access_log_sample_rate: 1.0, + access_log_retention_days: 14, + audit_log_retention_days: 180, + subscription_log_retention_days: 30, + } + } +} + +#[derive(Debug, Clone, Deserialize)] +pub struct StatsCfg { + #[serde(default)] + pub aggregate_interval_secs: i64, + #[serde(default)] + pub hourly_retention_days: i64, + #[serde(default)] + pub daily_retention_days: i64, +} +impl Default for StatsCfg { + fn default() -> Self { + Self { + aggregate_interval_secs: 60, + hourly_retention_days: 60, + daily_retention_days: 730, + } + } +} + +#[derive(Debug, Clone, Deserialize)] +pub struct NodesCfg { + #[serde(default)] + pub heartbeat_interval_secs: i64, + #[serde(default)] + pub heartbeat_timeout_secs: i64, + #[serde(default)] + pub config_pull_interval_secs: i64, + #[serde(default)] + pub report_interval_secs: i64, +} +impl Default for NodesCfg { + fn default() -> Self { + Self { + heartbeat_interval_secs: 10, + heartbeat_timeout_secs: 30, + config_pull_interval_secs: 15, + report_interval_secs: 10, + } + } +} + +#[derive(Debug, Clone, Deserialize)] +pub struct SubCfg { + #[serde(default = "d_rate")] + pub rate_limit_per_min: i64, + #[serde(default)] + pub default_expire_preset: String, +} +impl Default for SubCfg { + fn default() -> Self { + Self { + rate_limit_per_min: 30, + default_expire_preset: "30d".into(), + } + } +} + +#[derive(Debug, Clone, Deserialize)] +pub struct ImportCfg { + #[serde(default)] + pub fetch_timeout_secs: i64, + #[serde(default)] + pub max_body_mb: i64, + #[serde(default)] + pub user_agent: String, + #[serde(default)] + pub parse_cache_minutes: i64, +} +impl Default for ImportCfg { + fn default() -> Self { + Self { + fetch_timeout_secs: 15, + max_body_mb: 5, + user_agent: "RustProxyAdmin/1.0".into(), + parse_cache_minutes: 10, + } + } +} + +#[derive(Debug, Clone, Deserialize)] +pub struct TplCfg { + #[serde(default)] + pub max_size_kb: i64, + #[serde(default)] + pub regex_max_len: i64, +} +impl Default for TplCfg { + fn default() -> Self { + Self { + max_size_kb: 256, + regex_max_len: 200, + } + } +} + +#[derive(Debug, Clone, Deserialize)] +pub struct LifecycleCfg { + #[serde(default)] + pub tick_secs: i64, + #[serde(default)] + pub expired_sub_cleanup_days: i64, +} +impl Default for LifecycleCfg { + fn default() -> Self { + Self { + tick_secs: 60, + expired_sub_cleanup_days: 0, + } + } +} + +#[derive(Debug, Clone, Deserialize)] +pub struct AlertsCfg { + #[serde(default)] + pub dispatch_interval_secs: i64, + #[serde(default)] + pub max_attempts: i64, + #[serde(default)] + pub node_check_interval_secs: i64, + #[serde(default)] + pub event_retention_days: i64, +} +impl Default for AlertsCfg { + fn default() -> Self { + Self { + dispatch_interval_secs: 10, + max_attempts: 5, + node_check_interval_secs: 15, + event_retention_days: 60, + } + } +} + +impl Config { + /// 从 config.toml 读取;环境变量 RPA__SECTION__KEY 可覆盖。 + pub fn load(path: &str) -> anyhow::Result { + let mut cfg: Config = match std::fs::read_to_string(path) { + Ok(s) => toml::from_str(&s)?, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Config::default(), + Err(e) => return Err(e.into()), + }; + // 环境变量覆盖(仅覆盖常用项) + macro_rules! ov { + ($env:expr, $set:expr) => { + if let Ok(v) = std::env::var($env) { + $set(v); + } + }; + } + ov!("RPA__SERVER__BIND", |v: String| cfg.server.bind = v); + ov!("RPA__SERVER__PUBLIC_BASE_URL", |v: String| { + cfg.server.public_base_url = v + }); + ov!("RPA__SERVER__COOKIE_SECURE", |v: String| { + cfg.server.cookie_secure = v == "1" || v.eq_ignore_ascii_case("true") + }); + ov!("RPA__SERVER__SECRET_KEY", |v: String| { + cfg.server.secret_key = if v.trim().is_empty() { None } else { Some(v) } + }); + ov!("RPA__DB__URL", |v: String| cfg.db.url = v); + ov!("RPA__AUTH__INIT_USERNAME", |v: String| { + cfg.auth.init_username = v + }); + ov!("RPA__AUTH__INIT_PASSWORD", |v: String| { + cfg.auth.init_password = v + }); + ov!("RPA__TIME__TIMEZONE_OFFSET_HOURS", |v: String| { + if let Ok(n) = v.parse() { + cfg.time.timezone_offset_hours = n + } + }); + ov!("RPA__LOG__LEVEL", |v: String| cfg.log.level = v); + Ok(cfg) + } +} + +impl Default for Config { + fn default() -> Self { + Self { + server: ServerCfg::default(), + db: DbCfg::default(), + auth: AuthCfg::default(), + time: TimeCfg::default(), + log: LogCfg::default(), + stats: StatsCfg::default(), + nodes: NodesCfg::default(), + subscription: SubCfg::default(), + import: ImportCfg::default(), + templates: TplCfg::default(), + lifecycle: LifecycleCfg::default(), + alerts: AlertsCfg::default(), + } + } +} diff --git a/src/db.rs b/src/db.rs new file mode 100644 index 0000000..32e9145 --- /dev/null +++ b/src/db.rs @@ -0,0 +1,24 @@ +use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions, SqliteSynchronous}; +use sqlx::SqlitePool; +use std::str::FromStr; +use std::time::Duration; + +pub async fn connect(db_url: &str) -> anyhow::Result { + // sqlite://data/data.db 中的相对路径相对于工作目录 + let opts = SqliteConnectOptions::from_str(db_url)? + .create_if_missing(true) + .foreign_keys(true) + .journal_mode(SqliteJournalMode::Wal) + .synchronous(SqliteSynchronous::Normal) + .busy_timeout(Duration::from_secs(5)); + let pool = SqlitePoolOptions::new() + .max_connections(8) + .connect_with(opts) + .await?; + Ok(pool) +} + +pub async fn migrate(pool: &SqlitePool) -> anyhow::Result<()> { + sqlx::migrate!("./migrations").run(pool).await?; + Ok(()) +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..9d104cd --- /dev/null +++ b/src/error.rs @@ -0,0 +1,81 @@ +use actix_web::{HttpResponse, ResponseError, http::StatusCode}; +use serde::Serialize; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum AppError { + #[error("{0}")] + Bad(String), + #[error("{0}")] + Unauthorized(String), + #[error("{0}")] + Forbidden(String), + #[error("{0}")] + NotFound(String), + #[error("内部错误: {0}")] + Internal(String), + #[error(transparent)] + Db(#[from] sqlx::Error), + #[error(transparent)] + Any(#[from] anyhow::Error), +} + +impl AppError { + pub fn bad(m: impl Into) -> Self { + Self::Bad(m.into()) + } + pub fn unauthorized(m: impl Into) -> Self { + Self::Unauthorized(m.into()) + } + pub fn forbidden(m: impl Into) -> Self { + Self::Forbidden(m.into()) + } + pub fn not_found(m: impl Into) -> Self { + Self::NotFound(m.into()) + } + pub fn internal(m: impl Into) -> Self { + Self::Internal(m.into()) + } +} + +#[derive(Serialize)] +struct ErrBody { + ok: bool, + data: Option<()>, + error: String, +} + +impl ResponseError for AppError { + fn status_code(&self) -> StatusCode { + match self { + AppError::Bad(_) => StatusCode::BAD_REQUEST, + AppError::Unauthorized(_) => StatusCode::UNAUTHORIZED, + AppError::Forbidden(_) => StatusCode::FORBIDDEN, + AppError::NotFound(_) => StatusCode::NOT_FOUND, + AppError::Internal(_) | AppError::Db(_) | AppError::Any(_) => { + StatusCode::INTERNAL_SERVER_ERROR + } + } + } + + fn error_response(&self) -> HttpResponse { + let msg = match self { + AppError::Db(e) => { + tracing::error!(?e, "db error"); + "数据库错误".to_string() + } + AppError::Any(e) => { + tracing::error!(?e, "internal error"); + format!("内部错误: {e}") + } + other => other.to_string(), + }; + HttpResponse::build(self.status_code()).json(ErrBody { + ok: false, + data: None, + error: msg, + }) + } +} + +pub type AppResult = Result; diff --git a/src/handlers/alerts.rs b/src/handlers/alerts.rs new file mode 100644 index 0000000..a396282 --- /dev/null +++ b/src/handlers/alerts.rs @@ -0,0 +1,714 @@ +//! P12 —— 告警中心 API +//! +//! - GET / POST /api/alerts/channels(渠道列表密钥脱敏 / 新建) +//! - PUT / DELETE /api/alerts/channels/{id}(修改 / 删除;被规则引用时自动从规则中移除) +//! - POST /api/alerts/channels/{id}/test(同步发送测试消息) +//! - GET / POST /api/alerts/rules(规则列表 / 新建) +//! - PUT / DELETE /api/alerts/rules/{id}(修改 / 删除) +//! - POST /api/alerts/rules/{id}/toggle(启停) +//! - GET /api/alerts/events(事件历史,status/event_type/from/to + 分页) +//! - POST /api/alerts/events/{id}/retry(手动重发失败事件) +//! - POST /api/alerts/events/cleanup(清理 N 天前事件 {"days":30}) + +use actix_web::{web, HttpRequest, HttpResponse}; +use serde::Deserialize; + +use crate::audit::audit; +use crate::error::{AppError, AppResult}; +use crate::middleware::auth_user; +use crate::models::{ApiResp, Page}; +use crate::services::alert::crypto; +use crate::services::alert::{AlertChannel, AlertDraft, AlertEvent, AlertRule}; +use crate::state::AppState; +use crate::util::client_ip; + +pub fn routes(cfg: &mut web::ServiceConfig) { + cfg.service( + web::scope("/api/alerts") + .route("/channels", web::get().to(list_channels)) + .route("/channels", web::post().to(create_channel)) + .route("/channels/{id}", web::put().to(update_channel)) + .route("/channels/{id}", web::delete().to(delete_channel)) + .route("/channels/{id}/test", web::post().to(test_channel)) + .route("/rules", web::get().to(list_rules)) + .route("/rules", web::post().to(create_rule)) + .route("/rules/{id}", web::put().to(update_rule)) + .route("/rules/{id}", web::delete().to(delete_rule)) + .route("/rules/{id}/toggle", web::post().to(toggle_rule)) + .route("/events", web::get().to(list_events)) + .route("/events/{id}/retry", web::post().to(retry_event)) + .route("/events/cleanup", web::post().to(cleanup_events)), + ); +} + +// ============ 渠道 ============ + +fn channel_to_json( + ch: &AlertChannel, + secret_key: Option<&str>, + is_viewer: bool, +) -> serde_json::Value { + let raw: serde_json::Value = serde_json::from_str(&ch.config).unwrap_or(serde_json::Value::Null); + let mut v = crypto::decrypt_config(&raw, secret_key); + if is_viewer { + // P13:viewer 连字段本身都不可见,直接删除 + crypto::strip_secrets(&mut v); + } else { + // admin:脱敏为 ****** + crypto::mask_config(&mut v); + } + serde_json::json!({ + "id": ch.id, + "name": ch.name, + "kind": ch.kind, + "config": v, + "enabled": ch.enabled, + "created_at": ch.created_at, + "updated_at": ch.updated_at, + }) +} + +/// 前端回传 ****** 表示该字段不修改 → 用旧值替换 +///(旧值可能是 enc:v1: 密文,原样保留即可,加密函数会跳过已加密的值) +fn merge_secret(old: &str, new_v: &mut serde_json::Value, keys: &[&str]) { + let old_v: serde_json::Value = serde_json::from_str(old).unwrap_or_default(); + if let (Some(new_obj), Some(old_obj)) = (new_v.as_object_mut(), old_v.as_object()) { + for k in keys { + if new_obj.get(*k).and_then(|v| v.as_str()) == Some("******") { + if let Some(old_val) = old_obj.get(*k) { + new_obj.insert(k.to_string(), old_val.clone()); + } else { + new_obj.remove(*k); + } + } + } + } +} + +pub async fn list_channels( + state: web::Data, + req: HttpRequest, +) -> AppResult { + let user = auth_user(&req)?; + let is_viewer = !user.is_admin(); + let sk = state.cfg.server.secret_key.as_deref(); + let rows: Vec = + sqlx::query_as("SELECT * FROM alert_channels ORDER BY id") + .fetch_all(&state.pool) + .await?; + Ok(HttpResponse::Ok().json(ApiResp::ok( + rows.iter() + .map(|ch| channel_to_json(ch, sk, is_viewer)) + .collect::>(), + ))) +} + +#[derive(Deserialize)] +pub struct ChannelBody { + pub name: String, + pub kind: String, + pub config: serde_json::Value, + #[serde(default = "one")] + pub enabled: i64, +} + +fn one() -> i64 { + 1 +} + +fn validate_channel(kind: &str, cfg: &serde_json::Value) -> Result<(), AppError> { + match kind { + "telegram" => { + if cfg.get("bot_token").and_then(|v| v.as_str()).filter(|s| !s.is_empty()).is_none() { + return Err(AppError::bad("telegram 需要 bot_token")); + } + if cfg.get("chat_id").and_then(|v| v.as_str()).filter(|s| !s.is_empty()).is_none() { + return Err(AppError::bad("telegram 需要 chat_id")); + } + } + "webhook" => { + if cfg.get("url").and_then(|v| v.as_str()).filter(|s| !s.is_empty()).is_none() { + return Err(AppError::bad("webhook 需要 url")); + } + } + "email" => { + for k in ["host", "from"] { + if cfg.get(k).and_then(|v| v.as_str()).filter(|s| !s.is_empty()).is_none() { + return Err(AppError::bad(format!("email 需要 {k}"))); + } + } + let has_to = cfg + .get("to") + .and_then(|v| v.as_array()) + .map(|a| !a.is_empty()) + .unwrap_or(false); + if !has_to { + return Err(AppError::bad("email 需要至少一个收件人 to")); + } + } + _ => return Err(AppError::bad("未知渠道类型(telegram / webhook / email)")), + } + Ok(()) +} + +pub async fn create_channel( + state: web::Data, + req: HttpRequest, + body: web::Json, +) -> AppResult { + let user = auth_user(&req)?; + let name = body.name.trim(); + if name.is_empty() { + return Err(AppError::bad("名称不能为空")); + } + validate_channel(&body.kind, &body.config)?; + // P13:写库前加密密钥字段(未配置 secret_key 则明文存储) + let mut cfg = body.config.clone(); + crypto::encrypt_config(&mut cfg, state.cfg.server.secret_key.as_deref()); + let id: i64 = sqlx::query_scalar( + "INSERT INTO alert_channels (name, kind, config, enabled) VALUES (?, ?, ?, ?) + RETURNING id", + ) + .bind(name) + .bind(&body.kind) + .bind(cfg.to_string()) + .bind(body.enabled) + .fetch_one(&state.pool) + .await + .map_err(|e| { + if e.to_string().contains("UNIQUE") { + AppError::bad("渠道名称已存在") + } else { + e.into() + } + })?; + audit( + &state.pool, + Some(user.id), + &user.username, + "alert_channel_create", + Some("alert_channels"), + Some(id), + None, + serde_json::json!({"name": name, "kind": body.kind}), + &client_ip(&req, &state.cfg.server.trusted_proxies), + ) + .await; + Ok(HttpResponse::Ok().json(ApiResp::ok(serde_json::json!({ "id": id })))) +} + +pub async fn update_channel( + state: web::Data, + req: HttpRequest, + path: web::Path, + body: web::Json, +) -> AppResult { + let user = auth_user(&req)?; + let id = path.into_inner(); + let old: Option = + sqlx::query_as("SELECT * FROM alert_channels WHERE id = ?") + .bind(id) + .fetch_optional(&state.pool) + .await?; + let old = old.ok_or_else(|| AppError::not_found("渠道不存在"))?; + let mut cfg = body.config.clone(); + let keys: &[&str] = match old.kind.as_str() { + "telegram" => &["bot_token"], + "webhook" => &["secret"], + "email" => &["password"], + _ => &[], + }; + merge_secret(&old.config, &mut cfg, keys); + validate_channel(&old.kind, &cfg)?; + // P13:写库前加密密钥字段(已是 enc:v1: 的旧值会被跳过) + crypto::encrypt_config(&mut cfg, state.cfg.server.secret_key.as_deref()); + let name = body.name.trim(); + if name.is_empty() { + return Err(AppError::bad("名称不能为空")); + } + sqlx::query( + "UPDATE alert_channels SET name = ?, config = ?, enabled = ?, updated_at = datetime('now') + WHERE id = ?", + ) + .bind(name) + .bind(cfg.to_string()) + .bind(body.enabled) + .bind(id) + .execute(&state.pool) + .await + .map_err(|e| { + if e.to_string().contains("UNIQUE") { + AppError::bad("渠道名称已存在") + } else { + e.into() + } + })?; + audit( + &state.pool, + Some(user.id), + &user.username, + "alert_channel_update", + Some("alert_channels"), + Some(id), + None, + serde_json::json!({"name": name}), + &client_ip(&req, &state.cfg.server.trusted_proxies), + ) + .await; + Ok(HttpResponse::Ok().json(ApiResp::ok(serde_json::json!({ "id": id })))) +} + +pub async fn delete_channel( + state: web::Data, + req: HttpRequest, + path: web::Path, +) -> AppResult { + let user = auth_user(&req)?; + let id = path.into_inner(); + let mut tx = state.pool.begin().await?; + let n = sqlx::query("DELETE FROM alert_channels WHERE id = ?") + .bind(id) + .execute(&mut *tx) + .await? + .rows_affected(); + if n == 0 { + return Err(AppError::not_found("渠道不存在")); + } + // 被规则引用时自动从规则中移除 + let rules: Vec<(i64, String)> = + sqlx::query_as("SELECT id, channel_ids FROM alert_rules") + .fetch_all(&mut *tx) + .await?; + for (rid, ids_json) in rules { + let mut ids: Vec = serde_json::from_str(&ids_json).unwrap_or_default(); + if ids.iter().any(|x| *x == id) { + ids.retain(|x| *x != id); + sqlx::query( + "UPDATE alert_rules SET channel_ids = ?, updated_at = datetime('now') WHERE id = ?", + ) + .bind(serde_json::to_string(&ids).unwrap_or_default()) + .bind(rid) + .execute(&mut *tx) + .await?; + } + } + tx.commit().await?; + audit( + &state.pool, + Some(user.id), + &user.username, + "alert_channel_delete", + Some("alert_channels"), + Some(id), + None, + serde_json::json!({}), + &client_ip(&req, &state.cfg.server.trusted_proxies), + ) + .await; + Ok(HttpResponse::Ok().json(ApiResp::ok(serde_json::json!({ "id": id })))) +} + +pub async fn test_channel( + state: web::Data, + req: HttpRequest, + path: web::Path, +) -> AppResult { + let _user = auth_user(&req)?; + let id = path.into_inner(); + let ch: Option = sqlx::query_as("SELECT * FROM alert_channels WHERE id = ?") + .bind(id) + .fetch_optional(&state.pool) + .await?; + let ch = ch.ok_or_else(|| AppError::not_found("渠道不存在"))?; + let base = &state.cfg.server.public_base_url; + let draft = AlertDraft::test_channel(&ch.name, &ch.kind, base); + // 同步发送,不经过规则与事件表,错误原样返回 + match crate::services::alert::dispatcher::send_draft_to_channel( + &state.pool, + &state.http, + id, + &draft, + state.cfg.server.secret_key.as_deref(), + ) + .await + { + Ok(()) => Ok(HttpResponse::Ok().json(ApiResp::ok( + serde_json::json!({ "sent": true }), + ))), + Err(e) => Err(AppError::bad(format!("测试发送失败:{e}"))), + } +} + +// ============ 规则 ============ + +/// 事件类型中文名 + 默认参数(前端新建下拉用) +pub fn event_types() -> Vec { + vec![ + ("node_offline", "节点离线", r#"{"offline_secs":90}"#), + ("node_online", "节点恢复", "{}"), + ("sub_expiring", "订阅即将到期", r#"{"days_before":[7,3,1]}"#), + ("sub_expired", "订阅已过期", "{}"), + ("entry_expiring", "条目即将到期", r#"{"days_before":[7,3,1]}"#), + ("entry_expired", "条目已到期", "{}"), + ("quota_warn", "配额预警", r#"{"percent":[80,95]}"#), + ("quota_exceeded", "配额用尽", "{}"), + ("runtime_error", "条目运行异常", "{}"), + ("login_fail_burst", "登录失败激增", r#"{"threshold":5}"#), + ("sub_ip_limit", "订阅触发 IP 限制", "{}"), + ] + .into_iter() + .map(|(t, zh, params)| serde_json::json!({"event_type": t, "name": zh, "default_params": params})) + .collect() +} + +pub async fn list_rules( + state: web::Data, + req: HttpRequest, +) -> AppResult { + let _user = auth_user(&req)?; + let rows: Vec = + sqlx::query_as("SELECT * FROM alert_rules ORDER BY id") + .fetch_all(&state.pool) + .await?; + let items: Vec = rows + .iter() + .map(|r| { + let channels: Vec = serde_json::from_str(&r.channel_ids).unwrap_or_default(); + serde_json::json!({ + "id": r.id, + "name": r.name, + "event_type": r.event_type, + "params": serde_json::from_str::(&r.params).unwrap_or_default(), + "channel_ids": channels, + "cooldown_secs": r.cooldown_secs, + "enabled": r.enabled, + "created_at": r.created_at, + "updated_at": r.updated_at, + }) + }) + .collect(); + Ok(HttpResponse::Ok().json(ApiResp::ok(serde_json::json!({ + "items": items, + "event_types": event_types(), + })))) +} + +#[derive(Deserialize)] +pub struct RuleBody { + pub name: String, + pub event_type: String, + #[serde(default)] + pub params: serde_json::Value, + #[serde(default)] + pub channel_ids: Vec, + #[serde(default = "default_cooldown")] + pub cooldown_secs: i64, + #[serde(default = "one")] + pub enabled: i64, +} + +fn default_cooldown() -> i64 { + 3600 +} + +pub async fn create_rule( + state: web::Data, + req: HttpRequest, + body: web::Json, +) -> AppResult { + let user = auth_user(&req)?; + let name = body.name.trim(); + if name.is_empty() { + return Err(AppError::bad("名称不能为空")); + } + if !event_types() + .iter() + .any(|e| e["event_type"] == body.event_type) + { + return Err(AppError::bad("未知事件类型")); + } + let id: i64 = sqlx::query_scalar( + "INSERT INTO alert_rules (name, event_type, params, channel_ids, cooldown_secs, enabled) + VALUES (?, ?, ?, ?, ?, ?) RETURNING id", + ) + .bind(name) + .bind(&body.event_type) + .bind(body.params.to_string()) + .bind(serde_json::to_string(&body.channel_ids).unwrap_or_default()) + .bind(body.cooldown_secs.max(0)) + .bind(body.enabled) + .fetch_one(&state.pool) + .await?; + audit( + &state.pool, + Some(user.id), + &user.username, + "alert_rule_create", + Some("alert_rules"), + Some(id), + None, + serde_json::json!({"name": name, "event_type": body.event_type}), + &client_ip(&req, &state.cfg.server.trusted_proxies), + ) + .await; + Ok(HttpResponse::Ok().json(ApiResp::ok(serde_json::json!({ "id": id })))) +} + +pub async fn update_rule( + state: web::Data, + req: HttpRequest, + path: web::Path, + body: web::Json, +) -> AppResult { + let user = auth_user(&req)?; + let id = path.into_inner(); + let name = body.name.trim(); + if name.is_empty() { + return Err(AppError::bad("名称不能为空")); + } + let n = sqlx::query( + "UPDATE alert_rules SET name = ?, params = ?, channel_ids = ?, cooldown_secs = ?, + enabled = ?, updated_at = datetime('now') WHERE id = ?", + ) + .bind(name) + .bind(body.params.to_string()) + .bind(serde_json::to_string(&body.channel_ids).unwrap_or_default()) + .bind(body.cooldown_secs.max(0)) + .bind(body.enabled) + .bind(id) + .execute(&state.pool) + .await? + .rows_affected(); + if n == 0 { + return Err(AppError::not_found("规则不存在")); + } + audit( + &state.pool, + Some(user.id), + &user.username, + "alert_rule_update", + Some("alert_rules"), + Some(id), + None, + serde_json::json!({"name": name}), + &client_ip(&req, &state.cfg.server.trusted_proxies), + ) + .await; + Ok(HttpResponse::Ok().json(ApiResp::ok(serde_json::json!({ "id": id })))) +} + +pub async fn delete_rule( + state: web::Data, + req: HttpRequest, + path: web::Path, +) -> AppResult { + let user = auth_user(&req)?; + let id = path.into_inner(); + let n = sqlx::query("DELETE FROM alert_rules WHERE id = ?") + .bind(id) + .execute(&state.pool) + .await? + .rows_affected(); + if n == 0 { + return Err(AppError::not_found("规则不存在")); + } + audit( + &state.pool, + Some(user.id), + &user.username, + "alert_rule_delete", + Some("alert_rules"), + Some(id), + None, + serde_json::json!({}), + &client_ip(&req, &state.cfg.server.trusted_proxies), + ) + .await; + Ok(HttpResponse::Ok().json(ApiResp::ok(serde_json::json!({ "id": id })))) +} + +pub async fn toggle_rule( + state: web::Data, + req: HttpRequest, + path: web::Path, +) -> AppResult { + let user = auth_user(&req)?; + let id = path.into_inner(); + let cur: Option = sqlx::query_scalar("SELECT enabled FROM alert_rules WHERE id = ?") + .bind(id) + .fetch_optional(&state.pool) + .await? + .flatten(); + let cur = cur.ok_or_else(|| AppError::not_found("规则不存在"))?; + let next = if cur == 1 { 0 } else { 1 }; + sqlx::query("UPDATE alert_rules SET enabled = ?, updated_at = datetime('now') WHERE id = ?") + .bind(next) + .bind(id) + .execute(&state.pool) + .await?; + audit( + &state.pool, + Some(user.id), + &user.username, + "alert_rule_toggle", + Some("alert_rules"), + Some(id), + None, + serde_json::json!({"enabled": next}), + &client_ip(&req, &state.cfg.server.trusted_proxies), + ) + .await; + Ok(HttpResponse::Ok().json(ApiResp::ok( + serde_json::json!({ "enabled": next }), + ))) +} + +// ============ 事件历史 ============ + +#[derive(Deserialize)] +pub struct EventQuery { + #[serde(default = "default_page")] + pub page: i64, + #[serde(default = "default_page_size")] + pub page_size: i64, + pub status: Option, + pub event_type: Option, + pub from: Option, + pub to: Option, +} + +fn default_page() -> i64 { + 1 +} +fn default_page_size() -> i64 { + 20 +} + +pub async fn list_events( + state: web::Data, + req: HttpRequest, + q: web::Query, +) -> AppResult { + let _user = auth_user(&req)?; + let page = q.page.max(1); + let ps = q.page_size.clamp(1, 100); + let mut cond = String::from("WHERE 1=1"); + let mut bind_vals: Vec = Vec::new(); + if let Some(s) = &q.status { + if ["pending", "sent", "failed"].contains(&s.as_str()) { + cond.push_str(" AND status = ?"); + bind_vals.push(s.clone()); + } + } + if let Some(t) = &q.event_type { + if !t.is_empty() { + cond.push_str(" AND event_type = ?"); + bind_vals.push(t.clone()); + } + } + if let Some(f) = &q.from { + if !f.is_empty() { + cond.push_str(" AND created_at >= ?"); + bind_vals.push(f.clone()); + } + } + if let Some(t) = &q.to { + if !t.is_empty() { + cond.push_str(" AND created_at <= ?"); + bind_vals.push(t.clone()); + } + } + let total_sql = format!("SELECT COUNT(*) FROM alert_events {cond}"); + let list_sql = format!("SELECT * FROM alert_events {cond} ORDER BY id DESC LIMIT ? OFFSET ?"); + let mut total_q = sqlx::query_scalar::<_, i64>(&total_sql); + let mut list_q = sqlx::query_as::<_, AlertEvent>(&list_sql); + for v in &bind_vals { + total_q = total_q.bind(v); + list_q = list_q.bind(v); + } + let total = total_q.fetch_one(&state.pool).await?; + let items = list_q + .bind(ps) + .bind((page - 1) * ps) + .fetch_all(&state.pool) + .await?; + Ok(HttpResponse::Ok().json(ApiResp::ok(Page { + items, + total, + page, + page_size: ps, + }))) +} + +pub async fn retry_event( + state: web::Data, + req: HttpRequest, + path: web::Path, +) -> AppResult { + let user = auth_user(&req)?; + let id = path.into_inner(); + let n = sqlx::query( + "UPDATE alert_events SET status = 'pending', attempts = 0, + next_retry_at = NULL, last_error = NULL + WHERE id = ? AND status = 'failed'", + ) + .bind(id) + .execute(&state.pool) + .await? + .rows_affected(); + if n == 0 { + return Err(AppError::not_found("失败事件不存在(仅失败事件可重试)")); + } + audit( + &state.pool, + Some(user.id), + &user.username, + "alert_event_retry", + Some("alert_events"), + Some(id), + None, + serde_json::json!({}), + &client_ip(&req, &state.cfg.server.trusted_proxies), + ) + .await; + Ok(HttpResponse::Ok().json(ApiResp::ok(serde_json::json!({ "id": id })))) +} + +#[derive(Deserialize)] +pub struct CleanupBody { + #[serde(default = "default_days")] + pub days: i64, +} + +fn default_days() -> i64 { + 30 +} + +pub async fn cleanup_events( + state: web::Data, + req: HttpRequest, + body: web::Json, +) -> AppResult { + let user = auth_user(&req)?; + let days = body.days.clamp(1, 3650); + let n = sqlx::query("DELETE FROM alert_events WHERE created_at <= datetime('now', ?)") + .bind(format!("-{days} days")) + .execute(&state.pool) + .await? + .rows_affected(); + audit( + &state.pool, + Some(user.id), + &user.username, + "alert_events_cleanup", + Some("alert_events"), + None, + None, + serde_json::json!({"days": days, "deleted": n}), + &client_ip(&req, &state.cfg.server.trusted_proxies), + ) + .await; + Ok(HttpResponse::Ok().json(ApiResp::ok( + serde_json::json!({ "deleted": n }), + ))) +} diff --git a/src/handlers/auth.rs b/src/handlers/auth.rs new file mode 100644 index 0000000..d5d2d80 --- /dev/null +++ b/src/handlers/auth.rs @@ -0,0 +1,305 @@ +use crate::audit::audit; +use crate::error::{AppError, AppResult}; +use crate::middleware::auth_user; +use crate::models::{ApiResp, ChangePasswordReq, ChangeUsernameReq, LoginReq, User}; +use crate::state::AppState; +use crate::util::{client_ip, hash_password, now_str, random_token, verify_password, TIME_FMT}; +use actix_web::{web, HttpRequest, HttpResponse}; +use chrono::{Duration, Utc}; + +pub fn routes(cfg: &mut web::ServiceConfig) { + cfg.route("/login", web::post().to(login)) + .route("/logout", web::post().to(logout)) + .route("/logout_all", web::post().to(logout_all)) + .route("/me", web::get().to(me)) + .route("/change_password", web::post().to(change_password)) + .route("/change_username", web::post().to(change_username)); +} + +fn session_cookie(sid: &str, secure: bool, hours: i64) -> String { + let mut c = format!( + "sid={sid}; Path=/; HttpOnly; SameSite=Strict; Max-Age={}", + hours * 3600 + ); + if secure { + c.push_str("; Secure"); + } + c +} + +/// POST /api/auth/login +async fn login( + state: web::Data, + req: HttpRequest, + body: web::Json, +) -> AppResult { + let ip = client_ip(&req, &state.cfg.server.trusted_proxies); + let (max_fail, lock_minutes) = ( + state.cfg.auth.login_max_fail, + state.cfg.auth.login_lock_minutes, + ); + + // 锁定检查 + { + let guard = state.login_guard.lock().await; + if let Err(msg) = guard.check(&ip, max_fail) { + return Err(AppError::forbidden(msg)); + } + } + + let user: Option = sqlx::query_as("SELECT * FROM users WHERE username = ?") + .bind(body.username.trim()) + .fetch_optional(&state.pool) + .await?; + + let ok = user + .as_ref() + .map(|u| verify_password(&body.password, &u.password_hash)) + .unwrap_or(false); + + if !ok { + { + let mut guard = state.login_guard.lock().await; + guard.record_fail(&ip, max_fail, lock_minutes); + } + audit( + &state.pool, + None, + &body.username, + "login_fail", + Some("users"), + None, + None, + serde_json::json!({"username": body.username}), + &ip, + ) + .await; + // 达到阈值时触发告警事件 + let n = { + let guard = state.login_guard.lock().await; + guard.fails.get(&ip).map(|(n, _)| *n).unwrap_or(0) + }; + if n as i64 >= max_fail { + let base = state.cfg.server.public_base_url.clone(); + let _ = crate::services::alert::emit( + &state.pool, + crate::services::alert::AlertDraft::login_fail_burst(&ip, n as i64, lock_minutes, &base), + ) + .await; + } + // 故意不区分用户名/密码错误 + return Err(AppError::unauthorized("用户名或密码错误")); + } + let user = user.unwrap(); + { + let mut guard = state.login_guard.lock().await; + guard.record_ok(&ip); + } + + let sid = random_token(32); + let csrf = random_token(32); + let exp = (Utc::now() + Duration::hours(state.cfg.server.session_hours)) + .format(TIME_FMT) + .to_string(); + let ua = req + .headers() + .get("user-agent") + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_string(); + sqlx::query( + "INSERT INTO sessions (id, user_id, csrf_token, ip_addr, user_agent, expires_at, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?)", + ) + .bind(&sid) + .bind(user.id) + .bind(&csrf) + .bind(&ip) + .bind(&ua) + .bind(&exp) + .bind(now_str()) + .execute(&state.pool) + .await?; + + audit( + &state.pool, + Some(user.id), + &user.username, + "login", + Some("users"), + Some(user.id), + None, + serde_json::json!({}), + &ip, + ) + .await; + + // 是否仍是默认密码 → 前端提示 + let is_default = verify_password("ChangeMe123!", &user.password_hash); + Ok(HttpResponse::Ok() + .insert_header(( + "Set-Cookie", + session_cookie(&sid, state.cfg.server.cookie_secure, state.cfg.server.session_hours), + )) + .json(ApiResp::ok(serde_json::json!({ + "id": user.id, + "username": user.username, + "role": user.role, + "csrf_token": csrf, + "is_default_password": is_default, + })))) +} + +/// POST /api/auth/logout +async fn logout(state: web::Data, req: HttpRequest) -> AppResult { + let user = auth_user(&req)?; + sqlx::query("DELETE FROM sessions WHERE id = ?") + .bind(&user.session_id) + .execute(&state.pool) + .await?; + audit( + &state.pool, + Some(user.id), + &user.username, + "logout", + None, + None, + None, + serde_json::json!({}), + &client_ip(&req, &state.cfg.server.trusted_proxies), + ) + .await; + Ok(HttpResponse::Ok() + .insert_header(("Set-Cookie", "sid=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0")) + .json(ApiResp::ok(serde_json::json!({})))) +} + +/// POST /api/auth/logout_all +async fn logout_all(state: web::Data, req: HttpRequest) -> AppResult { + let user = auth_user(&req)?; + sqlx::query("DELETE FROM sessions WHERE user_id = ?") + .bind(user.id) + .execute(&state.pool) + .await?; + audit( + &state.pool, + Some(user.id), + &user.username, + "logout_all", + None, + None, + None, + serde_json::json!({}), + &client_ip(&req, &state.cfg.server.trusted_proxies), + ) + .await; + Ok(HttpResponse::Ok() + .insert_header(("Set-Cookie", "sid=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0")) + .json(ApiResp::ok(serde_json::json!({})))) +} + +/// GET /api/auth/me +async fn me(state: web::Data, req: HttpRequest) -> AppResult { + let user = auth_user(&req)?; + let u: User = sqlx::query_as("SELECT * FROM users WHERE id = ?") + .bind(user.id) + .fetch_one(&state.pool) + .await?; + let is_default = verify_password("ChangeMe123!", &u.password_hash); + Ok(HttpResponse::Ok().json(ApiResp::ok(serde_json::json!({ + "id": u.id, + "username": u.username, + "role": u.role, + "csrf_token": user.csrf_token, + "is_default_password": is_default, + })))) +} + +/// POST /api/auth/change_password +async fn change_password( + state: web::Data, + req: HttpRequest, + body: web::Json, +) -> AppResult { + let user = auth_user(&req)?; + if body.new_password.len() < 8 { + return Err(AppError::bad("新密码至少 8 位")); + } + let u: User = sqlx::query_as("SELECT * FROM users WHERE id = ?") + .bind(user.id) + .fetch_one(&state.pool) + .await?; + if !verify_password(&body.old_password, &u.password_hash) { + return Err(AppError::bad("旧密码不正确")); + } + sqlx::query("UPDATE users SET password_hash = ?, updated_at = datetime('now') WHERE id = ?") + .bind(hash_password(&body.new_password).map_err(|e| AppError::internal(e.to_string()))?) + .bind(user.id) + .execute(&state.pool) + .await?; + // 其他会话失效 + sqlx::query("DELETE FROM sessions WHERE user_id = ? AND id != ?") + .bind(user.id) + .bind(&user.session_id) + .execute(&state.pool) + .await?; + audit( + &state.pool, + Some(user.id), + &user.username, + "change_password", + Some("users"), + Some(user.id), + None, + serde_json::json!({}), + &client_ip(&req, &state.cfg.server.trusted_proxies), + ) + .await; + Ok(HttpResponse::Ok().json(ApiResp::ok(serde_json::json!({})))) +} + +/// POST /api/auth/change_username +async fn change_username( + state: web::Data, + req: HttpRequest, + body: web::Json, +) -> AppResult { + let user = auth_user(&req)?; + let new_name = body.new_username.trim(); + if new_name.is_empty() || new_name.len() > 64 { + return Err(AppError::bad("用户名不合法")); + } + let u: User = sqlx::query_as("SELECT * FROM users WHERE id = ?") + .bind(user.id) + .fetch_one(&state.pool) + .await?; + if !verify_password(&body.password, &u.password_hash) { + return Err(AppError::bad("密码不正确")); + } + let dup: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM users WHERE username = ? AND id != ?") + .bind(new_name) + .bind(user.id) + .fetch_one(&state.pool) + .await?; + if dup > 0 { + return Err(AppError::bad("用户名已存在")); + } + sqlx::query("UPDATE users SET username = ?, updated_at = datetime('now') WHERE id = ?") + .bind(new_name) + .bind(user.id) + .execute(&state.pool) + .await?; + audit( + &state.pool, + Some(user.id), + new_name, + "change_username", + Some("users"), + Some(user.id), + None, + serde_json::json!({"old": user.username, "new": new_name}), + &client_ip(&req, &state.cfg.server.trusted_proxies), + ) + .await; + Ok(HttpResponse::Ok().json(ApiResp::ok(serde_json::json!({})))) +} diff --git a/src/handlers/entries_common.rs b/src/handlers/entries_common.rs new file mode 100644 index 0000000..9926dbf --- /dev/null +++ b/src/handlers/entries_common.rs @@ -0,0 +1,787 @@ +use crate::audit::audit; +use crate::error::{AppError, AppResult}; +use crate::middleware::auth_user; +use crate::models::{ApiResp, EntryRef, ListQuery, Page}; +use crate::state::AppState; +use crate::util::{calc_expires, client_ip, random_token, table_of}; +use actix_web::{web, HttpRequest, HttpResponse}; +use serde::Deserialize; + +// ============ 条目删除:统一清理多态关联(10.5) ============ +pub async fn delete_entries_tx( + tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>, + entry_type: &str, + ids: &[i64], +) -> AppResult { + let table = table_of(entry_type)?; + let mut total = 0; + for chunk in ids.chunks(500) { + let ph = vec!["?"; chunk.len()].join(","); + + for cleanup in [ + format!("DELETE FROM subscription_entries WHERE entry_type = ? AND entry_id IN ({ph})"), + format!("DELETE FROM entry_tags WHERE entry_type = ? AND entry_id IN ({ph})"), + format!("DELETE FROM entry_quotas WHERE entry_type = ? AND entry_id IN ({ph})"), + ] { + let mut q = sqlx::query(&cleanup).bind(entry_type); + for id in chunk { + q = q.bind(id); + } + q.execute(&mut **tx).await?; + } + + let sql = format!("DELETE FROM {table} WHERE id IN ({ph})"); + let mut q = sqlx::query(&sql); + for id in chunk { + q = q.bind(id); + } + total += q.execute(&mut **tx).await?.rows_affected(); + } + Ok(total) +} + +/// 条目被多少个订阅引用(删除前提示用) +pub async fn count_sub_refs( + pool: &sqlx::SqlitePool, + entry_type: &str, + entry_id: i64, +) -> i64 { + sqlx::query_scalar( + "SELECT COUNT(DISTINCT subscription_id) FROM subscription_entries + WHERE entry_type = ? AND entry_id = ?", + ) + .bind(entry_type) + .bind(entry_id) + .fetch_one(pool) + .await + .unwrap_or(0) +} + +// ============ 列表查询 ============ +pub async fn list_entries( + pool: &sqlx::SqlitePool, + entry_type: &str, + q: &ListQuery, +) -> AppResult> { + let table = table_of(entry_type)?; + let page = q.page.unwrap_or(1).max(1); + let page_size = q.page_size.unwrap_or(20).clamp(1, 200); + + let mut conds: Vec = vec!["1=1".into()]; + // 搜索字段按类型不同 + let search_cols: &[&str] = match entry_type { + "proxy" => &["name", "upstream_addr", "listen_addr", "remark"], + "forward" => &["name", "target_ip", "listen_ip", "remark"], + _ => &["name", "local_addr", "remote_addr", "remark"], + }; + if let Some(s) = q.q.as_deref().filter(|s| !s.trim().is_empty()) { + let like = format!("%{}%", s.trim().replace('%', "").replace('_', "")); + let or = search_cols + .iter() + .map(|c| format!("{table}.{c} LIKE ?")) + .collect::>() + .join(" OR "); + conds.push(format!("({or})")); + let _ = like; + } + if let Some(nid) = q.node_id { + conds.push(format!("{table}.node_id = {nid}")); + } + if let Some(en) = q.enabled { + conds.push(format!("{table}.enabled = {en}")); + } + if let Some(sid) = q.source_import_id { + conds.push(format!("{table}.source_import_id = {sid}")); + } + if let Some(gid) = q.group_id { + conds.push(format!("{table}.group_id = {gid}")); + } + if q.no_group == Some(1) { + conds.push(format!("{table}.group_id IS NULL")); + } + if let Some(dr) = q.disabled_reason.as_deref().filter(|s| !s.is_empty()) { + conds.push(format!( + "{table}.disabled_reason = '{}'", + dr.replace('\'', "") + )); + } + if let Some(days) = q.expiring_days { + conds.push(format!( + "{table}.expires_at IS NOT NULL AND {table}.expires_at <= datetime('now','+{days} days') AND {table}.enabled = 1" + )); + } + if let Some(tag_ids) = q.tag_id.as_deref().filter(|s| !s.trim().is_empty()) { + let ids: Vec = tag_ids + .split(',') + .filter_map(|s| s.trim().parse().ok()) + .collect(); + if !ids.is_empty() { + let ph = ids.iter().map(|i| i.to_string()).collect::>().join(","); + conds.push(format!( + "EXISTS (SELECT 1 FROM entry_tags et WHERE et.entry_type = '{entry_type}' + AND et.entry_id = {table}.id AND et.tag_id IN ({ph}))" + )); + } + } + let where_sql = conds.join(" AND "); + + // q 搜索的 LIKE 参数 + let like_param: Option = q.q.as_deref().filter(|s| !s.trim().is_empty()).map(|s| { + format!("%{}%", s.trim().replace('%', "").replace('_', "")) + }); + let n_like = search_cols.len(); + + let count_sql = format!("SELECT COUNT(*) FROM {table} WHERE {where_sql}"); + let mut cq = sqlx::query_scalar::<_, i64>(&count_sql); + if let Some(lp) = &like_param { + for _ in 0..n_like { + cq = cq.bind(lp); + } + } + let total: i64 = cq.fetch_one(pool).await?; + + let offset = (page - 1) * page_size; + let list_sql = format!("SELECT * FROM {table} WHERE {where_sql} ORDER BY id DESC LIMIT {page_size} OFFSET {offset}"); + let mut lq = sqlx::query(&list_sql); + if let Some(lp) = &like_param { + for _ in 0..n_like { + lq = lq.bind(lp); + } + } + let rows = lq.fetch_all(pool).await?; + + let mut items = Vec::with_capacity(rows.len()); + for r in rows { + items.push(enrich_entry(pool, entry_type, &r).await?); + } + Ok(Page { + items, + total, + page, + page_size, + }) +} + +fn row_to_json(row: &sqlx::sqlite::SqliteRow) -> serde_json::Value { + use sqlx::{Column, Row, TypeInfo}; + let mut map = serde_json::Map::new(); + for col in row.columns() { + let name = col.name(); + let v = match col.type_info().name() { + "INTEGER" | "INT" => row + .try_get::(name) + .map(serde_json::Value::from) + .unwrap_or(serde_json::Value::Null), + "REAL" | "FLOAT" | "DOUBLE" => row + .try_get::(name) + .map(|f| serde_json::json!(f)) + .unwrap_or(serde_json::Value::Null), + _ => row + .try_get::(name) + .map(serde_json::Value::from) + .unwrap_or(serde_json::Value::Null), + }; + map.insert(name.to_string(), v); + } + serde_json::Value::Object(map) +} + +/// 列表/详情行附加 group / tags / quota / node_name +pub async fn enrich_entry( + pool: &sqlx::SqlitePool, + entry_type: &str, + row: &sqlx::sqlite::SqliteRow, +) -> AppResult { + use sqlx::Row; + let mut v = row_to_json(row); + let id: i64 = row.try_get("id").unwrap_or(0); + let node_id: i64 = row.try_get("node_id").unwrap_or(0); + let group_id: Option = row.try_get("group_id").ok(); + + let node_name: Option = + sqlx::query_scalar("SELECT name FROM nodes WHERE id = ?") + .bind(node_id) + .fetch_optional(pool) + .await?; + v["node_name"] = node_name.map(serde_json::Value::from).unwrap_or(serde_json::Value::Null); + + if let Some(gid) = group_id { + let g: Option<(i64, String, String)> = + sqlx::query_as("SELECT id, name, color FROM entry_groups WHERE id = ?") + .bind(gid) + .fetch_optional(pool) + .await?; + v["group"] = g + .map(|(id, name, color)| serde_json::json!({"id": id, "name": name, "color": color})) + .unwrap_or(serde_json::Value::Null); + } else { + v["group"] = serde_json::Value::Null; + } + + let tags: Vec<(i64, String, String)> = sqlx::query_as( + "SELECT t.id, t.name, t.color FROM tags t + JOIN entry_tags et ON et.tag_id = t.id + WHERE et.entry_type = ? AND et.entry_id = ? ORDER BY t.name", + ) + .bind(entry_type) + .bind(id) + .fetch_all(pool) + .await?; + v["tags"] = tags + .into_iter() + .map(|(id, name, color)| serde_json::json!({"id": id, "name": name, "color": color})) + .collect(); + + let quota: Option = sqlx::query_as::<_, crate::models::QuotaRow>( + "SELECT * FROM entry_quotas WHERE entry_type = ? AND entry_id = ?", + ) + .bind(entry_type) + .bind(id) + .fetch_optional(pool) + .await? + .map(|q| serde_json::to_value(q).unwrap_or(serde_json::Value::Null)); + v["quota"] = quota.unwrap_or(serde_json::Value::Null); + Ok(v) +} + +pub async fn get_entry( + pool: &sqlx::SqlitePool, + entry_type: &str, + id: i64, +) -> AppResult { + let table = table_of(entry_type)?; + let row = sqlx::query(&format!("SELECT * FROM {table} WHERE id = ?")) + .bind(id) + .fetch_optional(pool) + .await? + .ok_or_else(|| AppError::not_found("条目不存在"))?; + let mut v = enrich_entry(pool, entry_type, &row).await?; + v["sub_refs"] = serde_json::json!(count_sub_refs(pool, entry_type, id).await); + Ok(v) +} + +// ============ 标签设置 ============ +pub async fn set_entry_tags( + tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>, + entry_type: &str, + entry_id: i64, + tag_ids: &[i64], +) -> AppResult<()> { + sqlx::query("DELETE FROM entry_tags WHERE entry_type = ? AND entry_id = ?") + .bind(entry_type) + .bind(entry_id) + .execute(&mut **tx) + .await?; + for tid in tag_ids { + sqlx::query( + "INSERT OR IGNORE INTO entry_tags (entry_type, entry_id, tag_id) VALUES (?, ?, ?)", + ) + .bind(entry_type) + .bind(entry_id) + .bind(tid) + .execute(&mut **tx) + .await?; + } + Ok(()) +} + + +/// 修改 expires_at 到未来(或永久)且当前 disabled_reason='expired' → 自动恢复(§13.2) +pub async fn recover_expired_if_future( + tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>, + entry_type: &str, + entry_id: i64, + expires: Option<&str>, +) -> AppResult<()> { + let table = table_of(entry_type)?; + sqlx::query(&format!( + "UPDATE {table} SET enabled = 1, disabled_reason = NULL, updated_at = datetime('now') + WHERE id = ? AND disabled_reason = 'expired' AND (? IS NULL OR ? > datetime('now'))" + )) + .bind(entry_id) + .bind(expires) + .bind(expires) + .execute(&mut **tx) + .await?; + Ok(()) +} + +// ============ 配额是否可计量 ============ + +pub async fn quota_measurable( + pool: &sqlx::SqlitePool, + entry_type: &str, + entry_id: i64, +) -> AppResult { + let table = table_of(entry_type)?; + match entry_type { + "proxy" => { + let la: Option = + sqlx::query_scalar(&format!("SELECT listen_addr FROM {table} WHERE id = ?")) + .bind(entry_id) + .fetch_optional(pool) + .await? + .unwrap_or(None); + Ok(la.as_deref().map(|s| !s.trim().is_empty()).unwrap_or(false)) + } + "forward" | "tunnel" => Ok(true), + _ => Ok(false), + } +} + +/// 设置配额;quota_bytes=0 表示取消。返回是否触发了自动恢复。 +pub async fn upsert_quota( + pool: &sqlx::SqlitePool, + entry_type: &str, + entry_id: i64, + quota_bytes: i64, + period: &str, + reset_day: Option, + actor: Option<&crate::middleware::AuthUser>, + ip: &str, +) -> AppResult { + if !quota_measurable(pool, entry_type, entry_id).await? { + return Err(AppError::bad("该条目流量不经过本系统,无法设置配额")); + } + if quota_bytes < 0 || quota_bytes > 1024_i64.pow(5) { + return Err(AppError::bad("配额必须在 0 ~ 1PB 之间")); + } + if !matches!(period, "total" | "monthly") { + return Err(AppError::bad("period 只能是 total 或 monthly")); + } + let rd = reset_day.unwrap_or(1).clamp(1, 28); + let table = table_of(entry_type)?; + let mut tx = pool.begin().await?; + let mut recovered = false; + + if quota_bytes == 0 { + sqlx::query("DELETE FROM entry_quotas WHERE entry_type = ? AND entry_id = ?") + .bind(entry_type) + .bind(entry_id) + .execute(&mut *tx) + .await?; + } else { + sqlx::query( + "INSERT INTO entry_quotas + (entry_type, entry_id, quota_bytes, period, reset_day, period_start) + VALUES (?, ?, ?, ?, ?, datetime('now')) + ON CONFLICT(entry_type, entry_id) DO UPDATE SET + quota_bytes = excluded.quota_bytes, period = excluded.period, + reset_day = excluded.reset_day, updated_at = datetime('now')", + ) + .bind(entry_type) + .bind(entry_id) + .bind(quota_bytes) + .bind(period) + .bind(rd) + .execute(&mut *tx) + .await?; + + // 调大配额 → 若当前超限且已用量小于新配额,恢复条目 + let q: Option = sqlx::query_as( + "SELECT * FROM entry_quotas WHERE entry_type = ? AND entry_id = ?", + ) + .bind(entry_type) + .bind(entry_id) + .fetch_optional(&mut *tx) + .await?; + if let Some(q) = q { + if q.status == "exceeded" && q.used_bytes < quota_bytes { + sqlx::query( + "UPDATE entry_quotas SET status = 'ok', exceeded_at = NULL, warn_level = 0, + updated_at = datetime('now') WHERE id = ?", + ) + .bind(q.id) + .execute(&mut *tx) + .await?; + let n = sqlx::query(&format!( + "UPDATE {table} SET enabled = 1, disabled_reason = NULL, + updated_at = datetime('now') + WHERE id = ? AND disabled_reason = 'quota'" + )) + .bind(entry_id) + .execute(&mut *tx) + .await? + .rows_affected(); + recovered = n > 0; + } + } + } + // 条目 updated_at 刷新,Worker 感知配置变化 + sqlx::query(&format!( + "UPDATE {table} SET updated_at = datetime('now') WHERE id = ?" + )) + .bind(entry_id) + .execute(&mut *tx) + .await?; + tx.commit().await?; + + if let Some(a) = actor { + audit( + pool, + Some(a.id), + &a.username, + "set_quota", + Some(entry_type), + Some(entry_id), + None, + serde_json::json!({"quota_bytes": quota_bytes, "period": period, "recovered": recovered}), + ip, + ) + .await; + } + Ok(recovered) +} + +// ============ 跨类型批量操作 ============ +#[derive(Deserialize)] +pub struct BatchItems { + pub items: Vec, +} + +#[derive(Deserialize)] +pub struct BatchSetGroup { + pub items: Vec, + pub group_id: Option, +} + +#[derive(Deserialize)] +pub struct BatchTags { + pub items: Vec, + pub tag_ids: Vec, +} + +#[derive(Deserialize)] +pub struct BatchSetExpiry { + pub items: Vec, + pub expire_preset: Option, + pub expires_at: Option, +} + +#[derive(Deserialize)] +pub struct BatchSetQuota { + pub items: Vec, + pub quota_bytes: i64, + pub period: String, + pub reset_day: Option, +} + +pub fn entries_routes(cfg: &mut web::ServiceConfig) { + super::quotas::entry_quota_routes(cfg); + cfg.route("/batch_set_group", web::post().to(batch_set_group)) + .route("/batch_add_tags", web::post().to(batch_add_tags)) + .route("/batch_remove_tags", web::post().to(batch_remove_tags)) + .route("/batch_set_expiry", web::post().to(batch_set_expiry)) + .route("/batch_set_quota", web::post().to(batch_set_quota)) + .route("/{etype}/{id}/quick_subscription", web::post().to(quick_subscription)); +} + +fn group_items(items: &[EntryRef]) -> AppResult>> { + let mut m: std::collections::HashMap> = Default::default(); + for it in items { + if !matches!(it.entry_type.as_str(), "proxy" | "forward" | "tunnel") { + return Err(AppError::bad("未知条目类型")); + } + m.entry(it.entry_type.clone()).or_default().push(it.entry_id); + } + Ok(m) +} + +async fn batch_set_group( + state: web::Data, + req: HttpRequest, + body: web::Json, +) -> AppResult { + let user = auth_user(&req)?; + let ip = client_ip(&req, &state.cfg.server.trusted_proxies); + let grouped = group_items(&body.items)?; + let mut tx = state.pool.begin().await?; + let mut n = 0; + for (t, ids) in &grouped { + let table = table_of(t)?; + for chunk in ids.chunks(500) { + let ph = vec!["?"; chunk.len()].join(","); + let sql = format!( + "UPDATE {table} SET group_id = ?, updated_at = datetime('now') WHERE id IN ({ph})" + ); + let mut q = sqlx::query(&sql).bind(body.group_id); + for id in chunk { + q = q.bind(id); + } + n += q.execute(&mut *tx).await?.rows_affected(); + } + } + tx.commit().await?; + audit( + &state.pool, + Some(user.id), + &user.username, + "batch_set_group", + Some("entries"), + None, + None, + serde_json::json!({"count": n, "group_id": body.group_id}), + &ip, + ) + .await; + crate::services::runtime::on_entries_changed(&state).await; + Ok(HttpResponse::Ok().json(ApiResp::ok(serde_json::json!({"updated": n})))) +} + +async fn batch_add_tags( + state: web::Data, + req: HttpRequest, + body: web::Json, +) -> AppResult { + let user = auth_user(&req)?; + let ip = client_ip(&req, &state.cfg.server.trusted_proxies); + let mut tx = state.pool.begin().await?; + let mut n = 0i64; + for it in &body.items { + if !matches!(it.entry_type.as_str(), "proxy" | "forward" | "tunnel") { + return Err(AppError::bad("未知条目类型")); + } + for tid in &body.tag_ids { + let r = sqlx::query( + "INSERT OR IGNORE INTO entry_tags (entry_type, entry_id, tag_id) VALUES (?, ?, ?)", + ) + .bind(&it.entry_type) + .bind(it.entry_id) + .bind(tid) + .execute(&mut *tx) + .await?; + n += r.rows_affected() as i64; + } + let table = table_of(&it.entry_type)?; + sqlx::query(&format!("UPDATE {table} SET updated_at = datetime('now') WHERE id = ?")) + .bind(it.entry_id) + .execute(&mut *tx) + .await?; + } + tx.commit().await?; + audit( + &state.pool, + Some(user.id), + &user.username, + "batch_add_tags", + Some("entries"), + None, + None, + serde_json::json!({"added": n, "tag_ids": body.tag_ids}), + &ip, + ) + .await; + crate::services::runtime::on_entries_changed(&state).await; + Ok(HttpResponse::Ok().json(ApiResp::ok(serde_json::json!({"added": n})))) +} + +async fn batch_remove_tags( + state: web::Data, + req: HttpRequest, + body: web::Json, +) -> AppResult { + let user = auth_user(&req)?; + let ip = client_ip(&req, &state.cfg.server.trusted_proxies); + let mut tx = state.pool.begin().await?; + let mut n = 0i64; + for it in &body.items { + if !matches!(it.entry_type.as_str(), "proxy" | "forward" | "tunnel") { + return Err(AppError::bad("未知条目类型")); + } + for tid in &body.tag_ids { + let r = sqlx::query( + "DELETE FROM entry_tags WHERE entry_type = ? AND entry_id = ? AND tag_id = ?", + ) + .bind(&it.entry_type) + .bind(it.entry_id) + .bind(tid) + .execute(&mut *tx) + .await?; + n += r.rows_affected() as i64; + } + let table = table_of(&it.entry_type)?; + sqlx::query(&format!("UPDATE {table} SET updated_at = datetime('now') WHERE id = ?")) + .bind(it.entry_id) + .execute(&mut *tx) + .await?; + } + tx.commit().await?; + audit( + &state.pool, + Some(user.id), + &user.username, + "batch_remove_tags", + Some("entries"), + None, + None, + serde_json::json!({"removed": n, "tag_ids": body.tag_ids}), + &ip, + ) + .await; + crate::services::runtime::on_entries_changed(&state).await; + Ok(HttpResponse::Ok().json(ApiResp::ok(serde_json::json!({"removed": n})))) +} + +async fn batch_set_expiry( + state: web::Data, + req: HttpRequest, + body: web::Json, +) -> AppResult { + let user = auth_user(&req)?; + let ip = client_ip(&req, &state.cfg.server.trusted_proxies); + let preset = body.expire_preset.as_deref().unwrap_or("permanent"); + let expires = calc_expires(preset, body.expires_at.as_deref())?; + let grouped = group_items(&body.items)?; + let mut tx = state.pool.begin().await?; + let mut n = 0u64; + for (t, ids) in &grouped { + let table = table_of(t)?; + for chunk in ids.chunks(500) { + let ph = vec!["?"; chunk.len()].join(","); + // 到期延到未来 → 自动恢复因 expired 停用的 + let sql = format!( + "UPDATE {table} SET expires_at = ?, updated_at = datetime('now'), + enabled = CASE WHEN disabled_reason = 'expired' AND (? IS NULL OR ? > datetime('now')) + THEN 1 ELSE enabled END, + disabled_reason = CASE WHEN disabled_reason = 'expired' AND (? IS NULL OR ? > datetime('now')) + THEN NULL ELSE disabled_reason END + WHERE id IN ({ph})" + ); + let mut q = sqlx::query(&sql).bind(&expires) + .bind(&expires) + .bind(&expires) + .bind(&expires) + .bind(&expires); + for id in chunk { + q = q.bind(id); + } + n += q.execute(&mut *tx).await?.rows_affected(); + } + } + tx.commit().await?; + audit( + &state.pool, + Some(user.id), + &user.username, + "batch_set_expiry", + Some("entries"), + None, + None, + serde_json::json!({"count": n, "expires_at": expires}), + &ip, + ) + .await; + crate::services::runtime::on_entries_changed(&state).await; + Ok(HttpResponse::Ok().json(ApiResp::ok(serde_json::json!({"updated": n})))) +} + +async fn batch_set_quota( + state: web::Data, + req: HttpRequest, + body: web::Json, +) -> AppResult { + let user = auth_user(&req)?; + let ip = client_ip(&req, &state.cfg.server.trusted_proxies); + let mut ok = 0; + let mut skipped: Vec = vec![]; + for it in &body.items { + match upsert_quota( + &state.pool, + &it.entry_type, + it.entry_id, + body.quota_bytes, + &body.period, + body.reset_day, + None, + &ip, + ) + .await + { + Ok(_) => ok += 1, + Err(e) => skipped.push(serde_json::json!({ + "entry_type": it.entry_type, "entry_id": it.entry_id, "reason": e.to_string() + })), + } + } + audit( + &state.pool, + Some(user.id), + &user.username, + "batch_set_quota", + Some("entries"), + None, + None, + serde_json::json!({"ok": ok, "skipped": skipped.len(), "quota_bytes": body.quota_bytes}), + &ip, + ) + .await; + crate::services::runtime::on_entries_changed(&state).await; + Ok(HttpResponse::Ok().json(ApiResp::ok(serde_json::json!({"ok": ok, "skipped": skipped})))) +} + +/// POST /api/entries/{type}/{id}/quick_subscription — 一键为单个条目生成订阅 +async fn quick_subscription( + state: web::Data, + req: HttpRequest, + path: web::Path<(String, i64)>, + body: web::Json, +) -> AppResult { + let user = auth_user(&req)?; + let ip = client_ip(&req, &state.cfg.server.trusted_proxies); + let (etype, eid) = path.into_inner(); + table_of(&etype)?; + // 条目存在性 + get_entry(&state.pool, &etype, eid).await?; + + let preset = body.get("expire_preset").and_then(|v| v.as_str()).unwrap_or("permanent"); + let expires = calc_expires(preset, body.get("expires_at").and_then(|v| v.as_str()))?; + let name: String = body + .get("name") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .unwrap_or_else(|| format!("单条目订阅-{eid}")); + + let token = random_token(32); + let mut tx = state.pool.begin().await?; + let sub_id: i64 = sqlx::query_scalar( + "INSERT INTO subscriptions + (user_id, token, name, scope, default_format, expires_at, enabled) + VALUES (?, ?, ?, 'single', 'clash', ?, 1) RETURNING id", + ) + .bind(user.id) + .bind(&token) + .bind(&name) + .bind(&expires) + .fetch_one(&mut *tx) + .await?; + sqlx::query( + "INSERT INTO subscription_entries (subscription_id, entry_type, entry_id, sort_order) + VALUES (?, ?, ?, 0)", + ) + .bind(sub_id) + .bind(&etype) + .bind(eid) + .execute(&mut *tx) + .await?; + tx.commit().await?; + + audit( + &state.pool, + Some(user.id), + &user.username, + "quick_subscription", + Some("subscriptions"), + Some(sub_id), + None, + serde_json::json!({"entry_type": etype, "entry_id": eid}), + &ip, + ) + .await; + + let base = state.cfg.server.public_base_url.trim_end_matches('/'); + Ok(HttpResponse::Ok().json(ApiResp::ok(serde_json::json!({ + "id": sub_id, + "token": token, + "url": format!("{base}/sub/{token}"), + })))) +} diff --git a/src/handlers/forward.rs b/src/handlers/forward.rs new file mode 100644 index 0000000..961493a --- /dev/null +++ b/src/handlers/forward.rs @@ -0,0 +1,518 @@ +use crate::audit::audit; +use crate::error::{AppError, AppResult}; +use crate::handlers::entries_common::{delete_entries_tx, get_entry, list_entries, set_entry_tags}; +use crate::middleware::auth_user; +use crate::models::{ApiResp, ListQuery}; +use crate::state::AppState; +use crate::util::{calc_expires, client_ip}; +use actix_web::{web, HttpRequest, HttpResponse}; +use serde::Deserialize; + +const ET: &str = "forward"; + +pub fn routes(cfg: &mut web::ServiceConfig) { + cfg.route("", web::get().to(list)) + .route("", web::post().to(create)) + .route("/{id}", web::get().to(detail)) + .route("/{id}", web::put().to(update)) + .route("/{id}", web::delete().to(delete)) + .route("/{id}/toggle", web::post().to(toggle)) + .route("/batch_delete", web::post().to(batch_delete)) + .route("/batch_toggle", web::post().to(batch_toggle)) + .route("/batch_move", web::post().to(batch_move)); +} + +#[derive(Deserialize)] +pub struct ForwardCreate { + pub node_id: i64, + pub name: String, + pub protocol: Option, + pub listen_ip: Option, + pub listen_port: i64, + pub target_ip: String, + pub target_port: i64, + pub remark: Option, + pub export_host: Option, + pub export_port: Option, + pub extra: Option, + pub group_id: Option, + pub enabled: Option, + pub expire_preset: Option, + pub expires_at: Option, + pub tag_ids: Option>, +} + +#[derive(Deserialize)] +pub struct ForwardUpdate { + pub node_id: Option, + pub name: Option, + pub protocol: Option, + pub listen_ip: Option>, + pub listen_port: Option, + pub target_ip: Option, + pub target_port: Option, + pub remark: Option>, + pub export_host: Option>, + pub export_port: Option>, + pub extra: Option>, + pub group_id: Option>, + pub expire_preset: Option, + pub expires_at: Option>, + pub tag_ids: Option>, +} + +#[derive(Deserialize)] +pub struct ToggleReq { + pub enabled: bool, +} +#[derive(Deserialize)] +pub struct IdsReq { + pub ids: Vec, +} +#[derive(Deserialize)] +pub struct BatchToggleReq { + pub ids: Vec, + pub enabled: bool, +} +#[derive(Deserialize)] +pub struct BatchMoveReq { + pub ids: Vec, + pub node_id: i64, +} + +fn validate_port(p: i64, what: &str) -> AppResult<()> { + if !(1..=65535).contains(&p) { + return Err(AppError::bad(format!("{what} 必须在 1-65535 之间"))); + } + Ok(()) +} + +fn validate_name(name: &str) -> AppResult<()> { + let t = name.trim(); + if t.is_empty() || t.chars().count() > 100 { + return Err(AppError::bad("名称不能为空且不超过 100 字符")); + } + Ok(()) +} + +fn validate_ip(ip: &str, what: &str) -> AppResult<()> { + let t = ip.trim(); + if t.is_empty() || t.len() > 64 { + return Err(AppError::bad(format!("{what} 格式不正确"))); + } + Ok(()) +} + +async fn node_exists(pool: &sqlx::SqlitePool, node_id: i64) -> AppResult<()> { + let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM nodes WHERE id = ?") + .bind(node_id) + .fetch_one(pool) + .await?; + if n == 0 { + return Err(AppError::bad("节点不存在")); + } + Ok(()) +} + +fn protos_overlap(a: &str, b: &str) -> bool { + a == b || a == "both" || b == "both" +} + +/// 同节点 + 同监听 IP + 同端口 + 协议重叠 → 冲突 +async fn check_port_conflict( + pool: &sqlx::SqlitePool, + node_id: i64, + listen_ip: &str, + listen_port: i64, + proto: &str, + exclude_id: Option, +) -> AppResult<()> { + let rows: Vec<(i64, String, String)> = sqlx::query_as( + "SELECT id, protocol, listen_ip FROM port_forwards + WHERE node_id = ? AND listen_port = ? AND enabled = 1", + ) + .bind(node_id) + .bind(listen_port) + .fetch_all(pool) + .await?; + for (id, p, lip) in rows { + if Some(id) == exclude_id { + continue; + } + // IP 重叠:完全相同,或任一方是通配地址(0.0.0.0 / ::) + let wild = |s: &str| s == "0.0.0.0" || s == "::"; + if lip != listen_ip && !wild(&lip) && !wild(listen_ip) { + continue; + } + if protos_overlap(&p, proto) { + return Err(AppError::bad(format!( + "端口冲突:同节点上已存在启用的转发条目 #{id} 占用 {lip}:{listen_port}" + ))); + } + } + Ok(()) +} + +async fn list( + state: web::Data, + query: web::Query, +) -> AppResult { + let page = list_entries(&state.pool, ET, &query).await?; + Ok(HttpResponse::Ok().json(ApiResp::ok(page))) +} + +async fn create( + state: web::Data, + req: HttpRequest, + body: web::Json, +) -> AppResult { + let user = auth_user(&req)?; + let ip = client_ip(&req, &state.cfg.server.trusted_proxies); + let b = body.into_inner(); + validate_name(&b.name)?; + validate_port(b.listen_port, "监听端口")?; + validate_port(b.target_port, "目标端口")?; + validate_ip(&b.target_ip, "目标 IP")?; + let proto = b.protocol.unwrap_or_else(|| "tcp".into()); + if !matches!(proto.as_str(), "tcp" | "udp" | "both") { + return Err(AppError::bad("protocol 只能是 tcp/udp/both")); + } + let lip = b.listen_ip.unwrap_or_else(|| "0.0.0.0".into()); + validate_ip(&lip, "监听 IP")?; + node_exists(&state.pool, b.node_id).await?; + check_port_conflict(&state.pool, b.node_id, &lip, b.listen_port, &proto, None).await?; + let expires = calc_expires( + b.expire_preset.as_deref().unwrap_or("permanent"), + b.expires_at.as_deref(), + )?; + + let mut tx = state.pool.begin().await?; + let id: i64 = sqlx::query_scalar( + "INSERT INTO port_forwards + (node_id, name, protocol, listen_ip, listen_port, target_ip, target_port, + export_host, export_port, extra, remark, group_id, enabled, expires_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id", + ) + .bind(b.node_id) + .bind(b.name.trim()) + .bind(&proto) + .bind(&lip) + .bind(b.listen_port) + .bind(b.target_ip.trim()) + .bind(b.target_port) + .bind(b.export_host.as_deref().map(str::trim).filter(|s| !s.is_empty())) + .bind(b.export_port) + .bind(b.extra.as_deref().map(str::trim).filter(|s| !s.is_empty())) + .bind(b.remark.as_deref().map(str::trim).filter(|s| !s.is_empty())) + .bind(b.group_id) + .bind(b.enabled.unwrap_or(true) as i64) + .bind(&expires) + .fetch_one(&mut *tx) + .await?; + if let Some(tags) = b.tag_ids.as_deref() { + set_entry_tags(&mut tx, ET, id, tags).await?; + } + tx.commit().await?; + + audit( + &state.pool, + Some(user.id), + &user.username, + "create_forward", + Some(ET), + Some(id), + None, + serde_json::json!({"name": b.name}), + &ip, + ) + .await; + crate::services::runtime::on_entries_changed(&state).await; + Ok(HttpResponse::Ok().json(ApiResp::ok(get_entry(&state.pool, ET, id).await?))) +} + +async fn detail(state: web::Data, path: web::Path) -> AppResult { + Ok(HttpResponse::Ok().json(ApiResp::ok(get_entry(&state.pool, ET, path.into_inner()).await?))) +} + +async fn update( + state: web::Data, + req: HttpRequest, + path: web::Path, + body: web::Json, +) -> AppResult { + let user = auth_user(&req)?; + let ip = client_ip(&req, &state.cfg.server.trusted_proxies); + let id = path.into_inner(); + let b = body.into_inner(); + + let cur = get_entry(&state.pool, ET, id).await?; + let cur_node = cur["node_id"].as_i64().unwrap_or(1); + let cur_ip = cur["listen_ip"].as_str().unwrap_or("0.0.0.0").to_string(); + let cur_port = cur["listen_port"].as_i64().unwrap_or(0); + let cur_proto = cur["protocol"].as_str().unwrap_or("tcp").to_string(); + + if let Some(n) = b.name.as_deref() { validate_name(n)?; } + if let Some(p) = b.listen_port { validate_port(p, "监听端口")?; } + if let Some(p) = b.target_port { validate_port(p, "目标端口")?; } + if let Some(t) = b.target_ip.as_deref() { validate_ip(t, "目标 IP")?; } + if let Some(Some(li)) = b.listen_ip.as_ref() { validate_ip(li, "监听 IP")?; } + if let Some(p) = b.protocol.as_deref() { + if !matches!(p, "tcp" | "udp" | "both") { + return Err(AppError::bad("protocol 只能是 tcp/udp/both")); + } + } + if let Some(nid) = b.node_id { node_exists(&state.pool, nid).await?; } + + // 端口冲突检查(用更新后的值) + let new_node = b.node_id.unwrap_or(cur_node); + let new_ip = b.listen_ip.as_ref().and_then(|o| o.as_deref()).map(|s| s.to_string()).unwrap_or(cur_ip.clone()); + let new_port = b.listen_port.unwrap_or(cur_port); + let new_proto = b.protocol.as_deref().unwrap_or(&cur_proto); + check_port_conflict(&state.pool, new_node, &new_ip, new_port, new_proto, Some(id)).await?; + + let expires: Option> = match (&b.expire_preset, &b.expires_at) { + (None, None) => None, + (preset, ea) => Some(calc_expires( + preset.as_deref().unwrap_or("permanent"), + ea.as_ref().and_then(|o| o.as_deref()), + )?), + }; + + let mut tx = state.pool.begin().await?; + macro_rules! set { + ($col:literal, $val:expr) => { + sqlx::query(concat!("UPDATE port_forwards SET ", $col, " = ? WHERE id = ?")) + .bind($val) + .bind(id) + .execute(&mut *tx) + .await?; + }; + } + if let Some(v) = b.node_id { set!("node_id", v); } + if let Some(v) = b.name.as_deref() { set!("name", v.trim()); } + if let Some(v) = b.protocol.as_deref() { set!("protocol", v); } + if let Some(v) = b.listen_ip.as_ref() { set!("listen_ip", v.clone().unwrap_or(cur_ip)); } + if let Some(v) = b.listen_port { set!("listen_port", v); } + if let Some(v) = b.target_ip.as_deref() { set!("target_ip", v.trim()); } + if let Some(v) = b.target_port { set!("target_port", v); } + if let Some(v) = b.remark.as_ref() { set!("remark", v.as_deref().map(str::trim).filter(|s| !s.is_empty())); } + if let Some(v) = b.export_host.as_ref() { set!("export_host", v.as_deref().map(str::trim).filter(|s| !s.is_empty())); } + if let Some(v) = b.export_port { set!("export_port", v); } + if let Some(v) = b.extra.as_ref() { set!("extra", v.as_deref().map(str::trim).filter(|s| !s.is_empty())); } + if let Some(v) = b.group_id { set!("group_id", v); } + if let Some(v) = expires { + let exp: Option = v.clone(); + set!("expires_at", v); + super::entries_common::recover_expired_if_future(&mut tx, ET, id, exp.as_deref()).await?; + } + sqlx::query("UPDATE port_forwards SET updated_at = datetime('now') WHERE id = ?") + .bind(id) + .execute(&mut *tx) + .await?; + if let Some(tags) = b.tag_ids.as_deref() { + set_entry_tags(&mut tx, ET, id, tags).await?; + } + tx.commit().await?; + + audit( + &state.pool, + Some(user.id), + &user.username, + "update_forward", + Some(ET), + Some(id), + None, + serde_json::json!({}), + &ip, + ) + .await; + crate::services::runtime::on_entries_changed(&state).await; + Ok(HttpResponse::Ok().json(ApiResp::ok(get_entry(&state.pool, ET, id).await?))) +} + +async fn delete( + state: web::Data, + req: HttpRequest, + path: web::Path, +) -> AppResult { + let user = auth_user(&req)?; + let ip = client_ip(&req, &state.cfg.server.trusted_proxies); + let id = path.into_inner(); + get_entry(&state.pool, ET, id).await?; + let mut tx = state.pool.begin().await?; + let n = delete_entries_tx(&mut tx, ET, &[id]).await?; + tx.commit().await?; + audit( + &state.pool, + Some(user.id), + &user.username, + "delete_forward", + Some(ET), + Some(id), + None, + serde_json::json!({}), + &ip, + ) + .await; + crate::services::runtime::on_entries_changed(&state).await; + Ok(HttpResponse::Ok().json(ApiResp::ok(serde_json::json!({"deleted": n})))) +} + +async fn toggle( + state: web::Data, + req: HttpRequest, + path: web::Path, + body: web::Json, +) -> AppResult { + let user = auth_user(&req)?; + let ip = client_ip(&req, &state.cfg.server.trusted_proxies); + let id = path.into_inner(); + let cur = get_entry(&state.pool, ET, id).await?; + if body.enabled { + // 启用前再检查端口冲突 + check_port_conflict( + &state.pool, + cur["node_id"].as_i64().unwrap_or(1), + cur["listen_ip"].as_str().unwrap_or("0.0.0.0"), + cur["listen_port"].as_i64().unwrap_or(0), + cur["protocol"].as_str().unwrap_or("tcp"), + Some(id), + ) + .await?; + sqlx::query( + "UPDATE port_forwards SET enabled = 1, disabled_reason = NULL, updated_at = datetime('now') WHERE id = ?", + ) + .bind(id) + .execute(&state.pool) + .await?; + } else { + sqlx::query( + "UPDATE port_forwards SET enabled = 0, disabled_reason = 'manual', updated_at = datetime('now') WHERE id = ?", + ) + .bind(id) + .execute(&state.pool) + .await?; + } + audit( + &state.pool, + Some(user.id), + &user.username, + if body.enabled { "enable_forward" } else { "disable_forward" }, + Some(ET), + Some(id), + None, + serde_json::json!({}), + &ip, + ) + .await; + crate::services::runtime::on_entries_changed(&state).await; + Ok(HttpResponse::Ok().json(ApiResp::ok(get_entry(&state.pool, ET, id).await?))) +} + +async fn batch_delete( + state: web::Data, + req: HttpRequest, + body: web::Json, +) -> AppResult { + let user = auth_user(&req)?; + let ip = client_ip(&req, &state.cfg.server.trusted_proxies); + if body.ids.is_empty() { + return Err(AppError::bad("ids 不能为空")); + } + let mut tx = state.pool.begin().await?; + let n = delete_entries_tx(&mut tx, ET, &body.ids).await?; + tx.commit().await?; + audit( + &state.pool, + Some(user.id), + &user.username, + "batch_delete_forward", + Some(ET), + None, + None, + serde_json::json!({"ids": body.ids, "deleted": n}), + &ip, + ) + .await; + crate::services::runtime::on_entries_changed(&state).await; + Ok(HttpResponse::Ok().json(ApiResp::ok(serde_json::json!({"deleted": n})))) +} + +async fn batch_toggle( + state: web::Data, + req: HttpRequest, + body: web::Json, +) -> AppResult { + let user = auth_user(&req)?; + let ip = client_ip(&req, &state.cfg.server.trusted_proxies); + if body.ids.is_empty() { + return Err(AppError::bad("ids 不能为空")); + } + let mut n = 0u64; + for chunk in body.ids.chunks(500) { + let ph = vec!["?"; chunk.len()].join(","); + let sql = if body.enabled { + format!("UPDATE port_forwards SET enabled = 1, disabled_reason = NULL, updated_at = datetime('now') WHERE id IN ({ph})") + } else { + format!("UPDATE port_forwards SET enabled = 0, disabled_reason = 'manual', updated_at = datetime('now') WHERE id IN ({ph})") + }; + let mut q = sqlx::query(&sql); + for id in chunk { + q = q.bind(id); + } + n += q.execute(&state.pool).await?.rows_affected(); + } + audit( + &state.pool, + Some(user.id), + &user.username, + "batch_toggle_forward", + Some(ET), + None, + None, + serde_json::json!({"ids": body.ids, "enabled": body.enabled, "updated": n}), + &ip, + ) + .await; + crate::services::runtime::on_entries_changed(&state).await; + Ok(HttpResponse::Ok().json(ApiResp::ok(serde_json::json!({"updated": n})))) +} + +async fn batch_move( + state: web::Data, + req: HttpRequest, + body: web::Json, +) -> AppResult { + let user = auth_user(&req)?; + let ip = client_ip(&req, &state.cfg.server.trusted_proxies); + if body.ids.is_empty() { + return Err(AppError::bad("ids 不能为空")); + } + node_exists(&state.pool, body.node_id).await?; + let mut n = 0u64; + for chunk in body.ids.chunks(500) { + let ph = vec!["?"; chunk.len()].join(","); + let sql = format!( + "UPDATE port_forwards SET node_id = ?, updated_at = datetime('now') WHERE id IN ({ph})" + ); + let mut q = sqlx::query(&sql).bind(body.node_id); + for id in chunk { + q = q.bind(id); + } + n += q.execute(&state.pool).await?.rows_affected(); + } + audit( + &state.pool, + Some(user.id), + &user.username, + "batch_move_forward", + Some(ET), + None, + None, + serde_json::json!({"ids": body.ids, "node_id": body.node_id, "updated": n}), + &ip, + ) + .await; + crate::services::runtime::on_entries_changed(&state).await; + Ok(HttpResponse::Ok().json(ApiResp::ok(serde_json::json!({"updated": n})))) +} diff --git a/src/handlers/groups_tags.rs b/src/handlers/groups_tags.rs new file mode 100644 index 0000000..118cc34 --- /dev/null +++ b/src/handlers/groups_tags.rs @@ -0,0 +1,483 @@ +use crate::audit::audit; +use crate::error::{AppError, AppResult}; +use crate::middleware::auth_user; +use crate::models::{ApiResp, EntryGroup, Tag}; +use crate::state::AppState; +use crate::util::{client_ip, table_of}; +use actix_web::{web, HttpRequest, HttpResponse}; +use serde::Deserialize; + +pub fn group_routes(cfg: &mut web::ServiceConfig) { + cfg.route("", web::get().to(list_groups)) + .route("", web::post().to(create_group)) + .route("/reorder", web::post().to(reorder_groups)) + .route("/{id}", web::put().to(update_group)) + .route("/{id}", web::delete().to(delete_group)) + .route("/{id}/merge", web::post().to(merge_group)); +} + +pub fn tag_routes(cfg: &mut web::ServiceConfig) { + cfg.route("", web::get().to(list_tags)) + .route("", web::post().to(create_tag)) + .route("/merge", web::post().to(merge_tags)) + .route("/{id}", web::put().to(update_tag)) + .route("/{id}", web::delete().to(delete_tag)); +} + +fn validate_name(name: &str) -> AppResult<()> { + let t = name.trim(); + if t.is_empty() || t.chars().count() > 60 { + return Err(AppError::bad("名称不能为空且不超过 60 字符")); + } + Ok(()) +} + +fn validate_color(color: &str) -> AppResult<()> { + let t = color.trim(); + let ok = t.len() == 7 && t.starts_with('#') && t[1..].chars().all(|c| c.is_ascii_hexdigit()); + if !ok { + return Err(AppError::bad("颜色必须是 #RRGGBB 格式")); + } + Ok(()) +} + +// ============ 分组 ============ + +#[derive(Deserialize)] +pub struct GroupCreate { + pub name: String, + pub color: Option, + pub description: Option, +} + +#[derive(Deserialize)] +pub struct GroupUpdate { + pub name: Option, + pub color: Option, + pub description: Option>, +} + +#[derive(Deserialize)] +pub struct ReorderReq { + pub ordered_ids: Vec, +} + +#[derive(Deserialize)] +pub struct MergeGroupReq { + pub source_ids: Vec, +} + +/// 分组列表,附带三类条目数量 +async fn list_groups(state: web::Data) -> AppResult { + let groups: Vec = + sqlx::query_as("SELECT * FROM entry_groups ORDER BY sort_order, id") + .fetch_all(&state.pool) + .await?; + let mut items = Vec::with_capacity(groups.len()); + for g in groups { + let mut counts = serde_json::json!({"proxy": 0, "forward": 0, "tunnel": 0}); + for et in ["proxy", "forward", "tunnel"] { + let table = table_of(et)?; + let n: i64 = sqlx::query_scalar(&format!( + "SELECT COUNT(*) FROM {table} WHERE group_id = ?" + )) + .bind(g.id) + .fetch_one(&state.pool) + .await?; + counts[et] = serde_json::json!(n); + } + let mut v = serde_json::to_value(&g).unwrap_or_default(); + v["entry_counts"] = counts; + items.push(v); + } + Ok(HttpResponse::Ok().json(ApiResp::ok(items))) +} + +async fn create_group( + state: web::Data, + req: HttpRequest, + body: web::Json, +) -> AppResult { + let user = auth_user(&req)?; + let ip = client_ip(&req, &state.cfg.server.trusted_proxies); + validate_name(&body.name)?; + let color = body.color.as_deref().unwrap_or("#3b82f6"); + validate_color(color)?; + let max_order: Option = + sqlx::query_scalar("SELECT MAX(sort_order) FROM entry_groups") + .fetch_one(&state.pool) + .await?; + let id: i64 = sqlx::query_scalar( + "INSERT INTO entry_groups (name, color, description, sort_order) + VALUES (?, ?, ?, ?) RETURNING id", + ) + .bind(body.name.trim()) + .bind(color) + .bind(body.description.as_deref().map(str::trim).filter(|s| !s.is_empty())) + .bind(max_order.unwrap_or(0) + 1) + .fetch_one(&state.pool) + .await?; + audit( + &state.pool, Some(user.id), &user.username, "create_group", + Some("entry_groups"), Some(id), None, + serde_json::json!({"name": body.name}), &ip, + ) + .await; + let g: EntryGroup = sqlx::query_as("SELECT * FROM entry_groups WHERE id = ?") + .bind(id) + .fetch_one(&state.pool) + .await?; + Ok(HttpResponse::Ok().json(ApiResp::ok(g))) +} + +async fn update_group( + state: web::Data, + req: HttpRequest, + path: web::Path, + body: web::Json, +) -> AppResult { + let user = auth_user(&req)?; + let ip = client_ip(&req, &state.cfg.server.trusted_proxies); + let id = path.into_inner(); + let b = body.into_inner(); + if let Some(n) = b.name.as_deref() { validate_name(n)?; } + if let Some(c) = b.color.as_deref() { validate_color(c)?; } + let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM entry_groups WHERE id = ?") + .bind(id) + .fetch_one(&state.pool) + .await?; + if n == 0 { + return Err(AppError::not_found("分组不存在")); + } + if let Some(v) = b.name.as_deref() { + sqlx::query("UPDATE entry_groups SET name = ? WHERE id = ?").bind(v.trim()).bind(id).execute(&state.pool).await?; + } + if let Some(v) = b.color.as_deref() { + sqlx::query("UPDATE entry_groups SET color = ? WHERE id = ?").bind(v).bind(id).execute(&state.pool).await?; + } + if let Some(v) = b.description { + sqlx::query("UPDATE entry_groups SET description = ? WHERE id = ?") + .bind(v.as_deref().map(str::trim).filter(|s| !s.is_empty())) + .bind(id) + .execute(&state.pool) + .await?; + } + audit( + &state.pool, Some(user.id), &user.username, "update_group", + Some("entry_groups"), Some(id), None, serde_json::json!({}), &ip, + ) + .await; + let g: EntryGroup = sqlx::query_as("SELECT * FROM entry_groups WHERE id = ?") + .bind(id) + .fetch_one(&state.pool) + .await?; + Ok(HttpResponse::Ok().json(ApiResp::ok(g))) +} + +/// 删除分组:该组条目 group_id 置空 +async fn delete_group( + state: web::Data, + req: HttpRequest, + path: web::Path, +) -> AppResult { + let user = auth_user(&req)?; + let ip = client_ip(&req, &state.cfg.server.trusted_proxies); + let id = path.into_inner(); + let mut tx = state.pool.begin().await?; + let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM entry_groups WHERE id = ?") + .bind(id) + .fetch_one(&mut *tx) + .await?; + if n == 0 { + return Err(AppError::not_found("分组不存在")); + } + for et in ["proxy", "forward", "tunnel"] { + let table = table_of(et)?; + sqlx::query(&format!("UPDATE {table} SET group_id = NULL, updated_at = datetime('now') WHERE group_id = ?")) + .bind(id) + .execute(&mut *tx) + .await?; + } + sqlx::query("DELETE FROM entry_groups WHERE id = ?").bind(id).execute(&mut *tx).await?; + tx.commit().await?; + audit( + &state.pool, Some(user.id), &user.username, "delete_group", + Some("entry_groups"), Some(id), None, serde_json::json!({}), &ip, + ) + .await; + crate::services::runtime::on_entries_changed(&state).await; + Ok(HttpResponse::Ok().json(ApiResp::ok(serde_json::json!({"deleted": 1})))) +} + +async fn reorder_groups( + state: web::Data, + req: HttpRequest, + body: web::Json, +) -> AppResult { + let user = auth_user(&req)?; + let ip = client_ip(&req, &state.cfg.server.trusted_proxies); + let mut tx = state.pool.begin().await?; + for (i, gid) in body.ordered_ids.iter().enumerate() { + sqlx::query("UPDATE entry_groups SET sort_order = ? WHERE id = ?") + .bind(i as i64) + .bind(gid) + .execute(&mut *tx) + .await?; + } + tx.commit().await?; + audit( + &state.pool, Some(user.id), &user.username, "reorder_groups", + Some("entry_groups"), None, None, + serde_json::json!({"ordered_ids": body.ordered_ids}), &ip, + ) + .await; + Ok(HttpResponse::Ok().json(ApiResp::ok(serde_json::json!({"ok": true})))) +} + +/// 合并分组:源分组条目全部迁入目标分组,删除源分组 +async fn merge_group( + state: web::Data, + req: HttpRequest, + path: web::Path, + body: web::Json, +) -> AppResult { + let user = auth_user(&req)?; + let ip = client_ip(&req, &state.cfg.server.trusted_proxies); + let target = path.into_inner(); + let sources: Vec = body.source_ids.iter().copied().filter(|s| *s != target).collect(); + if sources.is_empty() { + return Err(AppError::bad("source_ids 不能为空")); + } + let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM entry_groups WHERE id = ?") + .bind(target) + .fetch_one(&state.pool) + .await?; + if n == 0 { + return Err(AppError::not_found("目标分组不存在")); + } + let mut tx = state.pool.begin().await?; + let mut moved = 0u64; + for et in ["proxy", "forward", "tunnel"] { + let table = table_of(et)?; + for chunk in sources.chunks(500) { + let ph = vec!["?"; chunk.len()].join(","); + let sql = format!( + "UPDATE {table} SET group_id = ?, updated_at = datetime('now') WHERE group_id IN ({ph})" + ); + let mut q = sqlx::query(&sql).bind(target); + for s in chunk { + q = q.bind(s); + } + moved += q.execute(&mut *tx).await?.rows_affected(); + } + } + for chunk in sources.chunks(500) { + let ph = vec!["?"; chunk.len()].join(","); + let sql = format!("DELETE FROM entry_groups WHERE id IN ({ph})"); + let mut q = sqlx::query(&sql); + for s in chunk { + q = q.bind(s); + } + q.execute(&mut *tx).await?; + } + tx.commit().await?; + audit( + &state.pool, Some(user.id), &user.username, "merge_group", + Some("entry_groups"), Some(target), None, + serde_json::json!({"source_ids": sources, "moved": moved}), &ip, + ) + .await; + crate::services::runtime::on_entries_changed(&state).await; + Ok(HttpResponse::Ok().json(ApiResp::ok(serde_json::json!({"moved": moved})))) +} + +// ============ 标签 ============ + +#[derive(Deserialize)] +pub struct TagCreate { + pub name: String, + pub color: Option, +} + +#[derive(Deserialize)] +pub struct TagUpdate { + pub name: Option, + pub color: Option, +} + +#[derive(Deserialize)] +pub struct MergeTagsReq { + pub source_ids: Vec, + pub target_id: i64, +} + +async fn list_tags(state: web::Data) -> AppResult { + let tags: Vec = sqlx::query_as("SELECT * FROM tags ORDER BY name") + .fetch_all(&state.pool) + .await?; + let mut items = Vec::with_capacity(tags.len()); + for t in tags { + let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM entry_tags WHERE tag_id = ?") + .bind(t.id) + .fetch_one(&state.pool) + .await?; + let mut v = serde_json::to_value(&t).unwrap_or_default(); + v["usage_count"] = serde_json::json!(n); + items.push(v); + } + Ok(HttpResponse::Ok().json(ApiResp::ok(items))) +} + +async fn create_tag( + state: web::Data, + req: HttpRequest, + body: web::Json, +) -> AppResult { + let user = auth_user(&req)?; + let ip = client_ip(&req, &state.cfg.server.trusted_proxies); + validate_name(&body.name)?; + let color = body.color.as_deref().unwrap_or("#3b82f6"); + validate_color(color)?; + let id: i64 = sqlx::query_scalar( + "INSERT INTO tags (name, color) VALUES (?, ?) RETURNING id", + ) + .bind(body.name.trim()) + .bind(color) + .fetch_one(&state.pool) + .await + .map_err(|e| { + if e.to_string().contains("UNIQUE") { + AppError::bad("标签名已存在") + } else { + AppError::from(e) + } + })?; + audit( + &state.pool, Some(user.id), &user.username, "create_tag", + Some("tags"), Some(id), None, serde_json::json!({"name": body.name}), &ip, + ) + .await; + let t: Tag = sqlx::query_as("SELECT * FROM tags WHERE id = ?") + .bind(id) + .fetch_one(&state.pool) + .await?; + Ok(HttpResponse::Ok().json(ApiResp::ok(t))) +} + +async fn update_tag( + state: web::Data, + req: HttpRequest, + path: web::Path, + body: web::Json, +) -> AppResult { + let user = auth_user(&req)?; + let ip = client_ip(&req, &state.cfg.server.trusted_proxies); + let id = path.into_inner(); + let b = body.into_inner(); + if let Some(n) = b.name.as_deref() { validate_name(n)?; } + if let Some(c) = b.color.as_deref() { validate_color(c)?; } + let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM tags WHERE id = ?") + .bind(id) + .fetch_one(&state.pool) + .await?; + if n == 0 { + return Err(AppError::not_found("标签不存在")); + } + if let Some(v) = b.name.as_deref() { + sqlx::query("UPDATE tags SET name = ? WHERE id = ?").bind(v.trim()).bind(id).execute(&state.pool).await.map_err(|e| { + if e.to_string().contains("UNIQUE") { AppError::bad("标签名已存在") } else { AppError::from(e) } + })?; + } + if let Some(v) = b.color.as_deref() { + sqlx::query("UPDATE tags SET color = ? WHERE id = ?").bind(v).bind(id).execute(&state.pool).await?; + } + audit( + &state.pool, Some(user.id), &user.username, "update_tag", + Some("tags"), Some(id), None, serde_json::json!({}), &ip, + ) + .await; + let t: Tag = sqlx::query_as("SELECT * FROM tags WHERE id = ?") + .bind(id) + .fetch_one(&state.pool) + .await?; + Ok(HttpResponse::Ok().json(ApiResp::ok(t))) +} + +/// 删除标签:级联清理 entry_tags +async fn delete_tag( + state: web::Data, + req: HttpRequest, + path: web::Path, +) -> AppResult { + let user = auth_user(&req)?; + let ip = client_ip(&req, &state.cfg.server.trusted_proxies); + let id = path.into_inner(); + let mut tx = state.pool.begin().await?; + let r = sqlx::query("DELETE FROM tags WHERE id = ?").bind(id).execute(&mut *tx).await?; + if r.rows_affected() == 0 { + return Err(AppError::not_found("标签不存在")); + } + sqlx::query("DELETE FROM entry_tags WHERE tag_id = ?").bind(id).execute(&mut *tx).await?; + tx.commit().await?; + audit( + &state.pool, Some(user.id), &user.username, "delete_tag", + Some("tags"), Some(id), None, serde_json::json!({}), &ip, + ) + .await; + Ok(HttpResponse::Ok().json(ApiResp::ok(serde_json::json!({"deleted": 1})))) +} + +/// 合并标签:源标签的关联全部迁到目标标签,删除源标签 +async fn merge_tags( + state: web::Data, + req: HttpRequest, + body: web::Json, +) -> AppResult { + let user = auth_user(&req)?; + let ip = client_ip(&req, &state.cfg.server.trusted_proxies); + let sources: Vec = body.source_ids.iter().copied().filter(|s| *s != body.target_id).collect(); + if sources.is_empty() { + return Err(AppError::bad("source_ids 不能为空")); + } + let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM tags WHERE id = ?") + .bind(body.target_id) + .fetch_one(&state.pool) + .await?; + if n == 0 { + return Err(AppError::not_found("目标标签不存在")); + } + let mut tx = state.pool.begin().await?; + let mut moved = 0u64; + for chunk in sources.chunks(500) { + let ph = vec!["?"; chunk.len()].join(","); + let sql = format!( + "UPDATE OR IGNORE entry_tags SET tag_id = ? WHERE tag_id IN ({ph})" + ); + let mut q = sqlx::query(&sql).bind(body.target_id); + for s in chunk { + q = q.bind(s); + } + moved += q.execute(&mut *tx).await?.rows_affected(); + // 残留的重复行删除 + let sql2 = format!("DELETE FROM entry_tags WHERE tag_id IN ({ph})"); + let mut q2 = sqlx::query(&sql2); + for s in chunk { + q2 = q2.bind(s); + } + q2.execute(&mut *tx).await?; + let sql3 = format!("DELETE FROM tags WHERE id IN ({ph})"); + let mut q3 = sqlx::query(&sql3); + for s in chunk { + q3 = q3.bind(s); + } + q3.execute(&mut *tx).await?; + } + tx.commit().await?; + audit( + &state.pool, Some(user.id), &user.username, "merge_tags", + Some("tags"), Some(body.target_id), None, + serde_json::json!({"source_ids": sources, "moved": moved}), &ip, + ) + .await; + Ok(HttpResponse::Ok().json(ApiResp::ok(serde_json::json!({"moved": moved})))) +} diff --git a/src/handlers/import.rs b/src/handlers/import.rs new file mode 100644 index 0000000..fac156c --- /dev/null +++ b/src/handlers/import.rs @@ -0,0 +1,400 @@ +//! P6 —— 订阅导入 API(§10) + +use actix_web::{HttpResponse, web}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use actix_web::HttpRequest; + +use crate::audit::audit; +use crate::error::{AppError, AppResult}; +use crate::middleware::auth_user; +use crate::models::ApiResp; +use crate::services::importer::{CachedParse, fetch, mark_duplicates, parse_content, take_cached}; +use crate::state::AppState; +use crate::util::client_ip; + +pub fn routes(cfg: &mut web::ServiceConfig) { + cfg.service( + web::scope("/api/import") + .route("/parse", web::post().to(parse)) + .route("/confirm", web::post().to(confirm)) + .route("/records", web::get().to(records_list)) + .route("/records/{id}", web::get().to(record_detail)) + .route("/records/{id}", web::delete().to(record_delete)), + ); +} + +// ============ POST /api/import/parse ============ + +#[derive(Debug, Deserialize)] +pub struct ParseReq { + pub url: Option, + pub content: Option, +} + +#[derive(Debug, Serialize)] +pub struct ParseOut { + pub parse_token: String, + pub source_type: String, + pub nodes: Vec, +} + +pub async fn parse( + state: web::Data, + body: web::Json, +) -> AppResult { + let url = body.url.as_deref().map(str::trim).filter(|s| !s.is_empty()); + let content = body + .content + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()); + if url.is_none() && content.is_none() { + return Err(AppError::bad("请提供订阅 URL 或粘贴内容")); + } + let text = match url { + Some(u) => { + fetch::fetch_subscription(&state.http, u, &state.cfg.import.user_agent).await? + } + None => content.unwrap().to_string(), + }; + let (source_type, mut nodes) = parse_content(&text)?; + mark_duplicates(&state.pool, &mut nodes).await?; + + let token = Uuid::new_v4().to_string(); + let cached = CachedParse { + nodes: nodes.clone(), + source_url: url.map(str::to_string), + source_type: source_type.clone(), + created: std::time::Instant::now(), + }; + state.import_cache.lock().await.insert(token.clone(), cached); + + let out: Vec = nodes + .iter() + .enumerate() + .map(|(i, n)| { + serde_json::json!({ + "index": i, "name": n.name, "kind": n.kind, + "host": n.host, "port": n.port, "duplicate": n.duplicate, + }) + }) + .collect(); + Ok(HttpResponse::Ok().json(ApiResp::ok(ParseOut { + parse_token: token, + source_type, + nodes: out, + }))) +} + +// ============ POST /api/import/confirm ============ + +#[derive(Debug, Deserialize)] +pub struct ConfirmReq { + pub parse_token: String, + pub target_node_id: i64, + pub selected: Option>, + pub name_prefix: Option, +} + +pub async fn confirm( + state: web::Data, + req: HttpRequest, + body: web::Json, +) -> AppResult { + let user = auth_user(&req)?; + let cached = take_cached(&state.import_cache, &body.parse_token) + .await + .ok_or_else(|| AppError::bad("解析结果已过期,请重新解析"))?; + + let node_exists: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM nodes WHERE id = ?)") + .bind(body.target_node_id) + .fetch_one(&state.pool) + .await?; + if !node_exists { + return Err(AppError::bad("目标节点不存在")); + } + + // 选择:None = 全部;否则按下标过滤(去重、越界检查) + let parsed = &cached.nodes; + let mut idx_set = std::collections::BTreeSet::new(); + match &body.selected { + None => { + for i in 0..parsed.len() { + idx_set.insert(i); + } + } + Some(idx) => { + for &i in idx { + if i < parsed.len() { + idx_set.insert(i); + } + } + } + } + if idx_set.is_empty() { + return Err(AppError::bad("未选择任何节点")); + } + + let prefix = body + .name_prefix + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + .unwrap_or(""); + + let mut tx = state.pool.begin().await?; + + // 1) 先创建导入记录,拿到 id + let record_id: i64 = sqlx::query_scalar( + "INSERT INTO import_records + (user_id, source_url, source_type, target_node_id, parsed_count, imported_count) + VALUES (?, ?, ?, ?, ?, 0) RETURNING id", + ) + .bind(user.id) + .bind(cached.source_url.as_deref()) + .bind(&cached.source_type) + .bind(body.target_node_id) + .bind(parsed.len() as i64) + .fetch_one(&mut *tx) + .await?; + + // 2) 逐条插入 proxy_rules,写入 source_import_id;重复项跳过 + let (mut ok, mut skipped) = (0i64, 0i64); + for i in idx_set { + let n = &parsed[i]; + let addr = format!("{}:{}", n.host, n.port); + let exists: bool = sqlx::query_scalar( + "SELECT EXISTS(SELECT 1 FROM proxy_rules WHERE upstream_type = ? AND upstream_addr = ?)", + ) + .bind(&n.kind) + .bind(&addr) + .fetch_one(&mut *tx) + .await?; + if exists { + skipped += 1; + continue; + } + let name = if prefix.is_empty() { + n.name.clone() + } else { + format!("{prefix}{}", n.name) + }; + sqlx::query( + "INSERT INTO proxy_rules + (node_id, name, upstream_type, upstream_addr, + auth_user, auth_pass, extra, source_import_id, enabled) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1)", + ) + .bind(body.target_node_id) + .bind(name) + .bind(&n.kind) + .bind(&addr) + .bind(n.username.as_deref()) + .bind(n.password.as_deref()) + .bind(serde_json::to_string(&n.extra).ok()) + .bind(record_id) + .execute(&mut *tx) + .await?; + ok += 1; + } + + // 3) 回写导入数量 + sqlx::query("UPDATE import_records SET imported_count = ? WHERE id = ?") + .bind(ok) + .bind(record_id) + .execute(&mut *tx) + .await?; + + tx.commit().await?; + let ip = client_ip(&req, &state.cfg.server.trusted_proxies); + audit( + &state.pool, Some(user.id), &user.username, "import_confirm", + Some("import_records"), Some(record_id), None, + serde_json::json!({"imported": ok, "skipped": skipped}), &ip, + ) + .await; + + Ok(HttpResponse::Ok().json(ApiResp::ok(serde_json::json!({ + "record_id": record_id, "imported": ok, "skipped": skipped, + })))) +} + +// ============ GET /api/import/records ============ + +#[derive(Debug, Deserialize)] +pub struct RecordsQuery { + pub page: Option, + pub page_size: Option, +} + +pub async fn records_list( + state: web::Data, + q: web::Query, +) -> AppResult { + let page = q.page.unwrap_or(1).max(1); + let page_size = q.page_size.unwrap_or(20).clamp(1, 100); + let total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM import_records") + .fetch_one(&state.pool) + .await?; + let rows: Vec = sqlx::query_as::< + _, + ( + i64, + Option, + Option, + String, + Option, + i64, + i64, + String, + Option, + ), + >( + "SELECT r.id, r.user_id, r.source_url, r.source_type, r.target_node_id, + r.parsed_count, r.imported_count, r.created_at, n.name + FROM import_records r LEFT JOIN nodes n ON n.id = r.target_node_id + ORDER BY r.id DESC LIMIT ? OFFSET ?", + ) + .bind(page_size) + .bind((page - 1) * page_size) + .fetch_all(&state.pool) + .await? + .into_iter() + .map(|(id, uid, url, stype, nid, pc, ic, ca, nname)| { + serde_json::json!({ + "id": id, "user_id": uid, "source_url": url, "source_type": stype, + "target_node_id": nid, "target_node_name": nname, + "parsed_count": pc, "imported_count": ic, "created_at": ca, + }) + }) + .collect(); + Ok(HttpResponse::Ok().json(ApiResp::ok(serde_json::json!({ + "total": total, "page": page, "page_size": page_size, "items": rows, + })))) +} + +// ============ GET /api/import/records/{id} ============ + +pub async fn record_detail( + state: web::Data, + path: web::Path, +) -> AppResult { + let id = path.into_inner(); + let rec: Option = sqlx::query_as::< + _, + ( + i64, + Option, + Option, + String, + Option, + i64, + i64, + String, + Option, + ), + >( + "SELECT r.id, r.user_id, r.source_url, r.source_type, r.target_node_id, + r.parsed_count, r.imported_count, r.created_at, n.name + FROM import_records r LEFT JOIN nodes n ON n.id = r.target_node_id + WHERE r.id = ?", + ) + .bind(id) + .fetch_optional(&state.pool) + .await? + .map(|(rid, uid, url, stype, nid, pc, ic, ca, nname)| { + serde_json::json!({ + "id": rid, "user_id": uid, "source_url": url, "source_type": stype, + "target_node_id": nid, "target_node_name": nname, + "parsed_count": pc, "imported_count": ic, "created_at": ca, + }) + }); + let rec = rec.ok_or_else(|| AppError::not_found("导入记录不存在"))?; + let entries: Vec = sqlx::query_as::<_, (i64, String, String, String, i64)>( + "SELECT id, name, upstream_type, upstream_addr, enabled FROM proxy_rules + WHERE source_import_id = ? ORDER BY id", + ) + .bind(id) + .fetch_all(&state.pool) + .await? + .into_iter() + .map(|(eid, name, kind, addr, en)| { + serde_json::json!({ + "id": eid, "name": name, "upstream_type": kind, + "upstream_addr": addr, "enabled": en, + }) + }) + .collect(); + Ok(HttpResponse::Ok().json(ApiResp::ok(serde_json::json!({ + "record": rec, "entries": entries, + })))) +} + +// ============ DELETE /api/import/records/{id} ============ + +#[derive(Debug, Deserialize)] +pub struct RecordDeleteQuery { + #[serde(default)] + pub with_entries: Option, +} + +impl RecordDeleteQuery { + fn with_entries(&self) -> bool { + matches!(self.with_entries.as_deref(), Some("1") | Some("true")) + } +} + +pub async fn record_delete( + state: web::Data, + req: HttpRequest, + path: web::Path, + q: web::Query, +) -> AppResult { + let user = auth_user(&req)?; + let id = path.into_inner(); + let exists: bool = + sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM import_records WHERE id = ?)") + .bind(id) + .fetch_one(&state.pool) + .await?; + if !exists { + return Err(AppError::not_found("导入记录不存在")); + } + let mut tx = state.pool.begin().await?; + let mut deleted_entries = 0i64; + if q.with_entries() { + // 连带删除:先找出该记录导入的条目,用统一删除函数清理多态关联 + let ids: Vec = + sqlx::query_scalar("SELECT id FROM proxy_rules WHERE source_import_id = ?") + .bind(id) + .fetch_all(&mut *tx) + .await?; + if !ids.is_empty() { + deleted_entries = + crate::handlers::entries_common::delete_entries_tx(&mut tx, "proxy", &ids).await? as i64; + } + } else { + // 仅删记录:条目保留,source_import_id 置空 + sqlx::query("UPDATE proxy_rules SET source_import_id = NULL WHERE source_import_id = ?") + .bind(id) + .execute(&mut *tx) + .await?; + } + sqlx::query("DELETE FROM import_records WHERE id = ?") + .bind(id) + .execute(&mut *tx) + .await?; + tx.commit().await?; + let ip = client_ip(&req, &state.cfg.server.trusted_proxies); + audit( + &state.pool, Some(user.id), &user.username, "import_record_delete", + Some("import_records"), Some(id), None, + serde_json::json!({"with_entries": q.with_entries(), "deleted_entries": deleted_entries}), &ip, + ) + .await; + Ok(HttpResponse::Ok().json(ApiResp::ok(serde_json::json!({ + "deleted_entries": deleted_entries, + })))) +} diff --git a/src/handlers/internal.rs b/src/handlers/internal.rs new file mode 100644 index 0000000..559f1dd --- /dev/null +++ b/src/handlers/internal.rs @@ -0,0 +1,388 @@ +//! P10 —— Master 内部接口(Worker 用 Bearer token 认证) +//! +//! - POST /internal/node/heartbeat +//! - GET /internal/node/config(If-None-Match → 304) +//! - POST /internal/node/logs/access +//! - POST /internal/node/stats/flush +//! - POST /internal/node/metrics +//! - POST /internal/node/runtime_status + +use actix_web::{HttpRequest, HttpResponse, web}; +use serde::{Deserialize, Serialize}; + +use crate::error::{AppError, AppResult}; +use crate::models::ApiResp; +use crate::services::runtime::load_desired_for; +use crate::state::AppState; + +pub fn routes(cfg: &mut web::ServiceConfig) { + cfg.service( + web::scope("/internal/node") + .route("/heartbeat", web::post().to(heartbeat)) + .route("/config", web::get().to(config)) + .route("/logs/access", web::post().to(logs_access)) + .route("/stats/flush", web::post().to(stats_flush)) + .route("/metrics", web::post().to(metrics)) + .route("/runtime_status", web::post().to(runtime_status)), + ); +} + +struct NodeIdent { + id: i64, + name: String, +} + +/// Bearer token → 节点身份(注意不要在日志里打印 token) +async fn auth_node(state: &AppState, req: &HttpRequest) -> AppResult { + let token = req + .headers() + .get("Authorization") + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.strip_prefix("Bearer ")) + .map(str::trim) + .filter(|s| !s.is_empty()) + .ok_or_else(|| AppError::unauthorized("缺少 Bearer token"))?; + let row: Option<(i64, String)> = + sqlx::query_as("SELECT id, name FROM nodes WHERE api_token = ? AND enabled = 1") + .bind(token) + .fetch_optional(&state.pool) + .await?; + match row { + Some((id, name)) => Ok(NodeIdent { id, name }), + None => Err(AppError::unauthorized("无效的节点 token")), + } +} + +// ============ 心跳 ============ + +#[derive(Deserialize)] +pub struct HeartbeatReq { + pub version: Option, + pub load: Option, +} + +#[derive(Deserialize, Serialize)] +pub struct HeartbeatLoad { + pub cpu: Option, + pub mem: Option, + pub conns: Option, +} + +/// Worker 每 10 秒心跳;Master 更新心跳时间/负载/版本 +pub async fn heartbeat( + state: web::Data, + req: HttpRequest, + body: web::Json, +) -> AppResult { + let node = auth_node(&state, &req).await?; + let now = chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string(); + let load_json = body + .load + .as_ref() + .map(|l| serde_json::to_string(l).unwrap_or_default()); + sqlx::query( + "UPDATE nodes SET last_heartbeat_at = ?, last_load = COALESCE(?, last_load), + version = COALESCE(?, version), online_state = 'online', updated_at = datetime('now') + WHERE id = ?", + ) + .bind(&now) + .bind(load_json) + .bind(body.version.as_deref()) + .bind(node.id) + .execute(&state.pool) + .await?; + Ok(HttpResponse::Ok().json(ApiResp::ok(serde_json::json!({ "node_id": node.id })))) +} + +// ============ 配置下发 ============ + +/// config_version:三张条目表 MAX(updated_at) + 条目数 + 启用数的哈希 +async fn config_version(pool: &sqlx::SqlitePool, node_id: i64) -> String { + use std::collections::hash_map::DefaultHasher; + use std::hash::{Hash, Hasher}; + let mut h = DefaultHasher::new(); + for table in ["proxy_rules", "port_forwards", "tunnels"] { + let row: Option<(Option, i64, i64)> = sqlx::query_as(&format!( + "SELECT MAX(updated_at), COUNT(*), SUM(CASE WHEN enabled = 1 THEN 1 ELSE 0 END) + FROM {table} WHERE node_id = ?" + )) + .bind(node_id) + .fetch_optional(pool) + .await + .unwrap_or(None); + if let Some((max_upd, cnt, en)) = row { + max_upd.hash(&mut h); + cnt.hash(&mut h); + en.hash(&mut h); + } + } + format!("{:x}", h.finish()) +} + +/// Worker 每 15 秒拉取本节点全部启用条目;无变化返回 304 +pub async fn config( + state: web::Data, + req: HttpRequest, +) -> AppResult { + let node = auth_node(&state, &req).await?; + let ver = config_version(&state.pool, node.id).await; + if let Some(inm) = req + .headers() + .get("If-None-Match") + .and_then(|v| v.to_str().ok()) + { + if inm.trim().trim_matches('"') == ver { + return Ok(HttpResponse::NotModified().finish()); + } + } + let desired = load_desired_for(&state.pool, node.id).await; + let entries: Vec<_> = desired.into_values().collect(); + Ok(HttpResponse::Ok() + .insert_header(("ETag", format!("\"{ver}\""))) + .json(ApiResp::ok(serde_json::json!({ + "config_version": ver, + "entries": entries, + })))) +} + +// ============ 访问日志上报 ============ + +#[derive(Deserialize)] +pub struct AccessBatch { + pub items: Vec, +} + +#[derive(Deserialize)] +pub struct AccessItem { + pub rule_type: String, + pub rule_id: i64, + pub client_ip: Option, + pub started_at: String, + pub ended_at: Option, + pub bytes_up: i64, + pub bytes_down: i64, + pub status: Option, +} + +pub async fn logs_access( + state: web::Data, + req: HttpRequest, + body: web::Json, +) -> AppResult { + let node = auth_node(&state, &req).await?; + if body.items.len() > 5000 { + return Err(AppError::bad("单次上报最多 5000 条")); + } + let mut tx = state.pool.begin().await?; + for it in &body.items { + sqlx::query( + "INSERT INTO access_logs (node_id, rule_type, rule_id, client_ip, started_at, ended_at, bytes_up, bytes_down, status) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + ) + .bind(node.id) + .bind(&it.rule_type) + .bind(it.rule_id) + .bind(it.client_ip.as_deref().unwrap_or("")) + .bind(&it.started_at) + .bind(it.ended_at.as_deref()) + .bind(it.bytes_up) + .bind(it.bytes_down) + .bind(it.status.as_deref().unwrap_or("ok")) + .execute(&mut *tx) + .await?; + } + tx.commit().await?; + Ok(HttpResponse::Ok().json(ApiResp::ok(serde_json::json!({ "received": body.items.len() })))) +} + +// ============ 统计增量上报 ============ + +#[derive(Deserialize)] +pub struct StatsFlush { + pub items: Vec, +} + +#[derive(Deserialize)] +pub struct StatsDelta { + pub rule_type: String, + pub rule_id: i64, + pub connections: i64, + pub bytes_up: i64, + pub bytes_down: i64, +} + +/// 小时统计增量累加入库;同一事务内对每个条目调用 add_usage 累计配额用量 +pub async fn stats_flush( + state: web::Data, + req: HttpRequest, + body: web::Json, +) -> AppResult { + let node = auth_node(&state, &req).await?; + if body.items.len() > 2000 { + return Err(AppError::bad("单次上报最多 2000 条")); + } + // stat_time 取当前小时(Worker 侧 10 秒批量,落在当前小时) + let stat_time = chrono::Local::now().format("%Y-%m-%dT%H").to_string(); + let mut tx = state.pool.begin().await?; + for it in &body.items { + sqlx::query( + "INSERT INTO stats_hourly (stat_time, node_id, rule_type, rule_id, total_connections, total_bytes_up, total_bytes_down) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (stat_time, node_id, rule_type, rule_id) DO UPDATE SET + total_connections = stats_hourly.total_connections + excluded.total_connections, + total_bytes_up = stats_hourly.total_bytes_up + excluded.total_bytes_up, + total_bytes_down = stats_hourly.total_bytes_down + excluded.total_bytes_down", + ) + .bind(&stat_time) + .bind(node.id) + .bind(&it.rule_type) + .bind(it.rule_id) + .bind(it.connections) + .bind(it.bytes_up) + .bind(it.bytes_down) + .execute(&mut *tx) + .await?; + crate::services::quota::add_usage(&mut tx, &it.rule_type, it.rule_id, it.bytes_up, it.bytes_down) + .await?; + } + tx.commit().await?; + // 精度:flush 完立刻检查本次涉及条目的配额,把"超限→停用"延迟压到约 10 秒 + let tz_offset = state.cfg.time.timezone_offset_hours; + let base_url = state.cfg.server.public_base_url.clone(); + { + use std::collections::HashSet; + let mut seen: HashSet<(String, i64)> = HashSet::new(); + for it in &body.items { + if seen.insert((it.rule_type.clone(), it.rule_id)) { + if let Err(e) = + crate::services::lifecycle::check_quota_now(&state.pool, &it.rule_type, it.rule_id, tz_offset, &base_url) + .await + { + tracing::warn!("check_quota_now 失败 {}/{}: {e}", it.rule_type, it.rule_id); + } + } + } + } + Ok(HttpResponse::Ok().json(ApiResp::ok(serde_json::json!({ "received": body.items.len() })))) +} + +// ============ 指标上报 ============ + +#[derive(Deserialize)] +pub struct MetricsReport { + pub cpu: Option, + pub mem: Option, + pub conns: Option, + pub net_up_bps: Option, + pub net_down_bps: Option, +} + +pub async fn metrics( + state: web::Data, + req: HttpRequest, + body: web::Json, +) -> AppResult { + let node = auth_node(&state, &req).await?; + let at = chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string(); + sqlx::query( + "INSERT INTO metrics_snapshots (node_id, collected_at, cpu, mem, conns, net_up_bps, net_down_bps) + VALUES (?, ?, ?, ?, ?, ?, ?)", + ) + .bind(node.id) + .bind(&at) + .bind(body.cpu) + .bind(body.mem) + .bind(body.conns) + .bind(body.net_up_bps) + .bind(body.net_down_bps) + .execute(&state.pool) + .await?; + Ok(HttpResponse::Ok().json(ApiResp::ok(serde_json::json!({ "ok": true })))) +} + +// ============ 运行状态上报 ============ + +#[derive(Deserialize)] +pub struct RuntimeStatusBatch { + pub items: Vec, +} + +#[derive(Deserialize)] +pub struct RuntimeStatusItem { + pub entry_type: String, + pub entry_id: i64, + pub status: String, + pub conns: i64, +} + +pub async fn runtime_status( + state: web::Data, + req: HttpRequest, + body: web::Json, +) -> AppResult { + let node = auth_node(&state, &req).await?; + let node_name = node.name.clone(); + let base_url = state.cfg.server.public_base_url.clone(); + let mut tx = state.pool.begin().await?; + let mut new_errors: Vec<(String, i64, String)> = Vec::new(); // (entry_type, entry_id, status) + for it in &body.items { + // P12:非 error → error:<原因> 的翻转才告警(冷却在 emit 内按 1 小时规则) + let prev: Option = sqlx::query_scalar( + "SELECT status FROM node_entry_status + WHERE node_id = ? AND entry_type = ? AND entry_id = ?", + ) + .bind(node.id) + .bind(&it.entry_type) + .bind(it.entry_id) + .fetch_optional(&mut *tx) + .await? + .flatten(); + sqlx::query( + "INSERT INTO node_entry_status (node_id, entry_type, entry_id, status, conns, updated_at) + VALUES (?, ?, ?, ?, ?, datetime('now')) + ON CONFLICT (node_id, entry_type, entry_id) DO UPDATE SET + status = excluded.status, conns = excluded.conns, updated_at = datetime('now')", + ) + .bind(node.id) + .bind(&it.entry_type) + .bind(it.entry_id) + .bind(&it.status) + .bind(it.conns) + .execute(&mut *tx) + .await?; + if it.status.starts_with("error:") + && !prev.as_deref().unwrap_or("").starts_with("error:") + { + new_errors.push((it.entry_type.clone(), it.entry_id, it.status.clone())); + } + } + tx.commit().await?; + // 事务外发告警;条目名尽量从本地库查 + for (entry_type, entry_id, status) in new_errors { + let table = match entry_type.as_str() { + "proxy" => "proxy_rules", + "forward" => "port_forwards", + "tunnel" => "tunnels", + _ => continue, + }; + let name: Option = sqlx::query_scalar(&format!("SELECT name FROM {table} WHERE id = ?")) + .bind(entry_id) + .fetch_optional(&state.pool) + .await? + .flatten(); + let reason = status.strip_prefix("error:").unwrap_or(&status); + let _ = crate::services::alert::emit( + &state.pool, + crate::services::alert::AlertDraft::runtime_error( + &entry_type, + entry_id, + name.as_deref().unwrap_or("未知条目"), + reason, + Some(&node_name), + &base_url, + ), + ) + .await; + } + Ok(HttpResponse::Ok().json(ApiResp::ok(serde_json::json!({ "received": body.items.len() })))) +} diff --git a/src/handlers/logs.rs b/src/handlers/logs.rs new file mode 100644 index 0000000..5b142e2 --- /dev/null +++ b/src/handlers/logs.rs @@ -0,0 +1,358 @@ +//! P7 —— 日志与审计 API(§6.7): +//! - GET /api/logs/audit +//! - GET /api/logs/access +//! - GET /api/logs/access/export (CSV) +//! - GET /api/logs/subscription +//! - POST /api/logs/cleanup {"days": N} 手动清理 N 天前日志 + +use actix_web::{HttpResponse, web}; +use serde::Deserialize; + +use crate::audit::audit; +use crate::error::{AppError, AppResult}; +use crate::middleware::auth_user; +use crate::models::ApiResp; +use crate::state::AppState; +use crate::util::client_ip; + +use actix_web::HttpRequest; + +pub fn routes(cfg: &mut web::ServiceConfig) { + cfg.service( + web::scope("/api/logs") + .route("/audit", web::get().to(audit_list)) + .route("/access", web::get().to(access_list)) + .route("/access/export", web::get().to(access_export)) + .route("/subscription", web::get().to(subscription_list)) + .route("/cleanup", web::post().to(cleanup)), + ); +} + +fn page_of(page: Option, page_size: Option) -> (i64, i64) { + (page.unwrap_or(1).max(1), page_size.unwrap_or(20).clamp(1, 100)) +} + +// ============ GET /api/logs/audit ============ + +#[derive(Debug, Deserialize)] +pub struct AuditQuery { + pub user: Option, + pub action: Option, + pub resource_type: Option, + pub from: Option, + pub to: Option, + pub page: Option, + pub page_size: Option, +} + +pub async fn audit_list( + state: web::Data, + q: web::Query, +) -> AppResult { + let (page, page_size) = page_of(q.page, q.page_size); + let user = q.user.as_deref().map(str::trim).filter(|s| !s.is_empty()); + let action = q.action.as_deref().map(str::trim).filter(|s| !s.is_empty()); + let rtype = q.resource_type.as_deref().map(str::trim).filter(|s| !s.is_empty()); + let from = q.from.as_deref().map(str::trim).filter(|s| !s.is_empty()); + let to = q.to.as_deref().map(str::trim).filter(|s| !s.is_empty()); + + let total: i64 = { + let mut qb: sqlx::QueryBuilder = + sqlx::QueryBuilder::new("SELECT COUNT(*) FROM audit_logs WHERE "); + let mut sep = qb.separated(" AND "); + sep.push("1=1"); + if let Some(u) = user { sep.push("actor_name = "); sep.push_bind_unseparated(u); } + if let Some(a) = action { sep.push("action = "); sep.push_bind_unseparated(a); } + if let Some(r) = rtype { sep.push("resource_type = "); sep.push_bind_unseparated(r); } + if let Some(f) = from { sep.push("created_at >= "); sep.push_bind_unseparated(f); } + if let Some(t) = to { sep.push("created_at <= "); sep.push_bind_unseparated(t); } + drop(sep); + qb.build_query_scalar().fetch_one(&state.pool).await? + }; + let rows: Vec = { + let mut qb: sqlx::QueryBuilder = sqlx::QueryBuilder::new( + "SELECT id, actor_user_id, actor_name, action, resource_type, resource_id, + node_id, detail, ip_addr, created_at FROM audit_logs WHERE ", + ); + let mut sep = qb.separated(" AND "); + sep.push("1=1"); + if let Some(u) = user { sep.push("actor_name = "); sep.push_bind_unseparated(u); } + if let Some(a) = action { sep.push("action = "); sep.push_bind_unseparated(a); } + if let Some(r) = rtype { sep.push("resource_type = "); sep.push_bind_unseparated(r); } + if let Some(f) = from { sep.push("created_at >= "); sep.push_bind_unseparated(f); } + if let Some(t) = to { sep.push("created_at <= "); sep.push_bind_unseparated(t); } + drop(sep); + qb.push(" ORDER BY id DESC LIMIT "); + qb.push_bind(page_size); + qb.push(" OFFSET "); + qb.push_bind((page - 1) * page_size); + qb.build_query_as::<( + i64, Option, Option, String, Option, Option, + Option, Option, Option, String, + )>() + .fetch_all(&state.pool) + .await? + .into_iter() + .map(|(id, uid, aname, action, rtype, rid, nid, detail, ip, ca)| { + serde_json::json!({ + "id": id, "actor_user_id": uid, "actor_name": aname, "action": action, + "resource_type": rtype, "resource_id": rid, "node_id": nid, + "detail": detail.and_then(|d| serde_json::from_str::(&d).ok()), + "ip_addr": ip, "created_at": ca, + }) + }) + .collect() + }; + Ok(HttpResponse::Ok().json(ApiResp::ok(serde_json::json!({ + "total": total, "page": page, "page_size": page_size, "items": rows, + })))) +} + +// ============ GET /api/logs/access ============ + +#[derive(Debug, Deserialize)] +pub struct AccessQuery { + pub node_id: Option, + pub rule_type: Option, + pub rule_id: Option, + pub client_ip: Option, + pub from: Option, + pub to: Option, + pub page: Option, + pub page_size: Option, +} + +pub async fn access_list( + state: web::Data, + q: web::Query, +) -> AppResult { + let (page, page_size) = page_of(q.page, q.page_size); + let rtype = q.rule_type.as_deref().map(str::trim).filter(|s| !s.is_empty()); + let cip = q.client_ip.as_deref().map(str::trim).filter(|s| !s.is_empty()); + let from = q.from.as_deref().map(str::trim).filter(|s| !s.is_empty()); + let to = q.to.as_deref().map(str::trim).filter(|s| !s.is_empty()); + + macro_rules! conds { + ($qb:expr) => {{ + let mut sep = $qb.separated(" AND "); + sep.push("1=1"); + if let Some(n) = q.node_id { sep.push("node_id = "); sep.push_bind_unseparated(n); } + if let Some(r) = rtype { sep.push("rule_type = "); sep.push_bind_unseparated(r); } + if let Some(r) = q.rule_id { sep.push("rule_id = "); sep.push_bind_unseparated(r); } + if let Some(c) = cip { sep.push("client_ip LIKE "); sep.push_bind_unseparated(format!("%{c}%")); } + if let Some(f) = from { sep.push("started_at >= "); sep.push_bind_unseparated(f); } + if let Some(t) = to { sep.push("started_at <= "); sep.push_bind_unseparated(t); } + }}; + } + + let total: i64 = { + let mut qb: sqlx::QueryBuilder = + sqlx::QueryBuilder::new("SELECT COUNT(*) FROM access_logs WHERE "); + conds!(qb); + qb.build_query_scalar().fetch_one(&state.pool).await? + }; + let rows: Vec = { + let mut qb: sqlx::QueryBuilder = sqlx::QueryBuilder::new( + "SELECT id, node_id, rule_type, rule_id, client_ip, started_at, ended_at, + bytes_up, bytes_down, status FROM access_logs WHERE ", + ); + conds!(qb); + qb.push(" ORDER BY id DESC LIMIT "); + qb.push_bind(page_size); + qb.push(" OFFSET "); + qb.push_bind((page - 1) * page_size); + qb.build_query_as::<( + i64, i64, String, i64, Option, String, Option, i64, i64, Option, + )>() + .fetch_all(&state.pool) + .await? + .into_iter() + .map(|(id, nid, rtype, rid, cip, sa, ea, up, down, st)| { + serde_json::json!({ + "id": id, "node_id": nid, "rule_type": rtype, "rule_id": rid, + "client_ip": cip, "started_at": sa, "ended_at": ea, + "bytes_up": up, "bytes_down": down, "status": st, + }) + }) + .collect() + }; + Ok(HttpResponse::Ok().json(ApiResp::ok(serde_json::json!({ + "total": total, "page": page, "page_size": page_size, "items": rows, + })))) +} + +// ============ GET /api/logs/access/export (CSV) ============ + +fn csv_esc(s: &str) -> String { + if s.contains([',', '"', '\n', '\r']) { + format!("\"{}\"", s.replace('"', "\"\"")) + } else { + s.to_string() + } +} + +pub async fn access_export( + state: web::Data, + q: web::Query, +) -> AppResult { + let rtype = q.rule_type.as_deref().map(str::trim).filter(|s| !s.is_empty()); + let cip = q.client_ip.as_deref().map(str::trim).filter(|s| !s.is_empty()); + let from = q.from.as_deref().map(str::trim).filter(|s| !s.is_empty()); + let to = q.to.as_deref().map(str::trim).filter(|s| !s.is_empty()); + let mut qb: sqlx::QueryBuilder = sqlx::QueryBuilder::new( + "SELECT id, node_id, rule_type, rule_id, client_ip, started_at, ended_at, + bytes_up, bytes_down, status FROM access_logs WHERE ", + ); + { + let mut sep = qb.separated(" AND "); + sep.push("1=1"); + if let Some(n) = q.node_id { sep.push("node_id = "); sep.push_bind_unseparated(n); } + if let Some(r) = rtype { sep.push("rule_type = "); sep.push_bind_unseparated(r); } + if let Some(r) = q.rule_id { sep.push("rule_id = "); sep.push_bind_unseparated(r); } + if let Some(c) = cip { sep.push("client_ip LIKE "); sep.push_bind_unseparated(format!("%{c}%")); } + if let Some(f) = from { sep.push("started_at >= "); sep.push_bind_unseparated(f); } + if let Some(t) = to { sep.push("started_at <= "); sep.push_bind_unseparated(t); } + } + qb.push(" ORDER BY id DESC LIMIT 50000"); + let rows: Vec<( + i64, i64, String, i64, Option, String, Option, i64, i64, Option, + )> = qb.build_query_as().fetch_all(&state.pool).await?; + + let mut csv = String::from("id,node_id,rule_type,rule_id,client_ip,started_at,ended_at,bytes_up,bytes_down,status\n"); + for (id, nid, rtype, rid, cip, sa, ea, up, down, st) in rows { + csv.push_str(&format!( + "{},{},{},{},{},{},{},{},{},{}\n", + id, + nid, + csv_esc(&rtype), + rid, + csv_esc(cip.as_deref().unwrap_or("")), + csv_esc(&sa), + csv_esc(ea.as_deref().unwrap_or("")), + up, + down, + csv_esc(st.as_deref().unwrap_or("")), + )); + } + Ok(HttpResponse::Ok() + .content_type("text/csv; charset=utf-8") + .insert_header(("Content-Disposition", "attachment; filename=\"access_logs.csv\"")) + .body(format!("\u{FEFF}{csv}"))) +} + +// ============ GET /api/logs/subscription ============ + +#[derive(Debug, Deserialize)] +pub struct SubLogQuery { + pub subscription_id: Option, + pub result: Option, + pub page: Option, + pub page_size: Option, +} + +pub async fn subscription_list( + state: web::Data, + q: web::Query, +) -> AppResult { + let (page, page_size) = page_of(q.page, q.page_size); + let result = q.result.as_deref().map(str::trim).filter(|s| !s.is_empty()); + + macro_rules! conds { + ($qb:expr) => {{ + let mut sep = $qb.separated(" AND "); + sep.push("1=1"); + if let Some(sid) = q.subscription_id { sep.push("l.subscription_id = "); sep.push_bind_unseparated(sid); } + if let Some(r) = result { sep.push("l.result = "); sep.push_bind_unseparated(r); } + }}; + } + + let total: i64 = { + let mut qb: sqlx::QueryBuilder = + sqlx::QueryBuilder::new("SELECT COUNT(*) FROM subscription_logs l WHERE "); + conds!(qb); + qb.build_query_scalar().fetch_one(&state.pool).await? + }; + let rows: Vec = { + let mut qb: sqlx::QueryBuilder = sqlx::QueryBuilder::new( + "SELECT l.id, l.subscription_id, s.name, l.client_ip, l.user_agent, l.format, + l.result, l.accessed_at FROM subscription_logs l + LEFT JOIN subscriptions s ON s.id = l.subscription_id WHERE ", + ); + conds!(qb); + qb.push(" ORDER BY l.id DESC LIMIT "); + qb.push_bind(page_size); + qb.push(" OFFSET "); + qb.push_bind((page - 1) * page_size); + qb.build_query_as::<( + i64, i64, Option, Option, Option, Option, String, String, + )>() + .fetch_all(&state.pool) + .await? + .into_iter() + .map(|(id, sid, sname, cip, ua, fmt, result, ca)| { + serde_json::json!({ + "id": id, "subscription_id": sid, "subscription_name": sname, + "client_ip": cip, "user_agent": ua, "format": fmt, + "result": result, "created_at": ca, + }) + }) + .collect() + }; + Ok(HttpResponse::Ok().json(ApiResp::ok(serde_json::json!({ + "total": total, "page": page, "page_size": page_size, "items": rows, + })))) +} + + +// ============ POST /api/logs/cleanup ============ + +#[derive(Debug, Deserialize)] +pub struct CleanupReq { + pub days: i64, +} + +pub async fn cleanup( + state: web::Data, + req: HttpRequest, + body: web::Json, +) -> AppResult { + let user = auth_user(&req)?; + if body.days < 1 || body.days > 3650 { + return Err(AppError::bad("days 范围 1~3650")); + } + let cutoff = format!("-{} days", body.days); + let mut counts = serde_json::Map::new(); + for (table, col) in [ + ("access_logs", "started_at"), + ("subscription_logs", "accessed_at"), + ("audit_logs", "created_at"), + ] { + let r = sqlx::query(&format!( + "DELETE FROM {table} WHERE {col} < datetime('now', ?)" + )) + .bind(&cutoff) + .execute(&state.pool) + .await?; + counts.insert(table.to_string(), serde_json::json!(r.rows_affected())); + } + // 清理后截断 WAL + let _ = sqlx::query("PRAGMA wal_checkpoint(TRUNCATE)") + .execute(&state.pool) + .await; + let ip = client_ip(&req, &state.cfg.server.trusted_proxies); + audit( + &state.pool, + Some(user.id), + &user.username, + "logs_cleanup", + None, + None, + None, + serde_json::json!({"days": body.days, "deleted": counts}), + &ip, + ) + .await; + Ok(HttpResponse::Ok().json(ApiResp::ok(serde_json::json!({ + "days": body.days, "deleted": counts, + })))) +} diff --git a/src/handlers/metrics.rs b/src/handlers/metrics.rs new file mode 100644 index 0000000..9a6374e --- /dev/null +++ b/src/handlers/metrics.rs @@ -0,0 +1,93 @@ +//! P13 —— Prometheus 指标(GET /metrics,Prometheus 文本格式,需登录) + +use actix_web::{web, HttpRequest, HttpResponse}; +use std::sync::OnceLock; + +use crate::error::AppResult; +use crate::middleware::auth_user; +use crate::state::AppState; + +pub fn routes(cfg: &mut web::ServiceConfig) { + cfg.route("/metrics", web::get().to(metrics)); +} + +fn start_time() -> &'static std::time::Instant { + static T: OnceLock = OnceLock::new(); + T.get_or_init(std::time::Instant::now) +} + +pub async fn metrics(state: web::Data, req: HttpRequest) -> AppResult { + let _user = auth_user(&req)?; + let p = &state.pool; + + let nodes_total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM nodes") + .fetch_one(p) + .await?; + let nodes_online: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM nodes WHERE online_state = 'online' AND enabled = 1") + .fetch_one(p) + .await?; + let mut out = String::new(); + fn g(out: &mut String, name: &str, help: &str, value: impl std::fmt::Display) { + out.push_str(&format!("# HELP {name} {help}\n# TYPE {name} gauge\n{name} {value}\n")); + } + + g(&mut out, "rpa_uptime_seconds", "Master 进程运行时长(秒)", start_time().elapsed().as_secs()); + g(&mut out, "rpa_nodes_total", "节点总数", nodes_total); + g(&mut out, "rpa_nodes_online", "在线节点数", nodes_online); + + for (t, table) in [("proxy", "proxy_rules"), ("forward", "port_forwards"), ("tunnel", "tunnels")] { + let total: i64 = sqlx::query_scalar(&format!("SELECT COUNT(*) FROM {table}")) + .fetch_one(p) + .await?; + let enabled: i64 = sqlx::query_scalar(&format!("SELECT COUNT(*) FROM {table} WHERE enabled = 1")) + .fetch_one(p) + .await?; + out.push_str(&format!( + "# HELP rpa_entries_by_type 按类型统计的条目数\n# TYPE rpa_entries_by_type gauge\nrpa_entries_by_type{{type=\"{t}\"}} {total}\n" + )); + out.push_str(&format!( + "# HELP rpa_entries_enabled_by_type 按类型统计的启用条目数\n# TYPE rpa_entries_enabled_by_type gauge\nrpa_entries_enabled_by_type{{type=\"{t}\"}} {enabled}\n" + )); + } + + let subs_total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM subscriptions") + .fetch_one(p) + .await?; + let subs_enabled: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM subscriptions WHERE enabled = 1") + .fetch_one(p) + .await?; + g(&mut out, "rpa_subscriptions_total", "订阅总数", subs_total); + g(&mut out, "rpa_subscriptions_enabled", "启用的订阅数", subs_enabled); + + let traffic: Option = sqlx::query_scalar("SELECT SUM(used_bytes) FROM entry_quotas") + .fetch_one(p) + .await?; + g(&mut out, + "rpa_traffic_used_bytes_total", + "配额统计的累计用量(字节)", + traffic.unwrap_or(0), + ); + + let ev_pending: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM alert_events WHERE status = 'pending'") + .fetch_one(p) + .await?; + let ev_failed: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM alert_events WHERE status = 'failed'") + .fetch_one(p) + .await?; + g(&mut out, "rpa_alert_events_pending", "待发送的告警事件数", ev_pending); + g(&mut out, "rpa_alert_events_failed", "发送失败的告警事件数", ev_failed); + + let sessions: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM sessions WHERE expires_at > datetime('now')") + .fetch_one(p) + .await?; + g(&mut out, "rpa_sessions_active", "有效会话数", sessions); + + Ok(HttpResponse::Ok() + .content_type("text/plain; version=0.0.4; charset=utf-8") + .body(out)) +} diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs new file mode 100644 index 0000000..2fbf731 --- /dev/null +++ b/src/handlers/mod.rs @@ -0,0 +1,49 @@ +pub mod alerts; +pub mod auth; +pub mod entries_common; +pub mod forward; +pub mod groups_tags; +pub mod import; +pub mod internal; +pub mod logs; +pub mod metrics; +pub mod monitor; +pub mod nodes; +pub mod pages; +pub mod proxy; +pub mod quotas; +pub mod runtime; +pub mod stats; +pub mod subscribe_public; +pub mod subscriptions; +pub mod templates; +pub mod tunnel; +pub mod users; + +use actix_web::web; + +pub fn routes(cfg: &mut web::ServiceConfig) { + cfg.service(web::scope("/api/auth").configure(auth::routes)); + cfg.service(web::scope("/api/proxy").configure(proxy::routes)); + cfg.service(web::scope("/api/forward").configure(forward::routes)); + cfg.service(web::scope("/api/tunnel").configure(tunnel::routes)); + cfg.service(web::scope("/api/entries").configure(entries_common::entries_routes)); + cfg.service(web::scope("/api/groups").configure(groups_tags::group_routes)); + cfg.service(web::scope("/api/tags").configure(groups_tags::tag_routes)); + cfg.configure(subscriptions::routes); + cfg.configure(subscribe_public::routes); + cfg.configure(templates::routes); + cfg.configure(import::routes); + cfg.configure(logs::routes); + cfg.configure(runtime::routes); + cfg.configure(stats::routes); + cfg.configure(monitor::routes); + cfg.configure(nodes::routes); + cfg.configure(quotas::routes); + cfg.configure(alerts::routes); + cfg.configure(users::routes); + cfg.configure(metrics::routes); + cfg.configure(internal::routes); + // P6–P13 在后续阶段接入 + cfg.service(web::scope("/admin").configure(pages::routes)); +} diff --git a/src/handlers/monitor.rs b/src/handlers/monitor.rs new file mode 100644 index 0000000..9722758 --- /dev/null +++ b/src/handlers/monitor.rs @@ -0,0 +1,219 @@ +//! P9 —— 监控 API:节点状态、条目实时状态、节点指标序列 + +use actix_web::{HttpRequest, HttpResponse, web}; +use serde::Deserialize; + +use crate::error::AppResult; +use crate::middleware::auth_user; +use crate::models::ApiResp; +use crate::state::AppState; + +pub fn routes(cfg: &mut web::ServiceConfig) { + cfg.service( + web::scope("/api/monitor") + .route("/nodes", web::get().to(nodes)) + .route("/rules", web::get().to(rules)) + .route("/nodes/{id}/metrics", web::get().to(node_metrics)), + ); +} + +fn is_online(is_local: i64, last_hb: Option<&str>, timeout: i64) -> bool { + if is_local == 1 { + return true; + } + match last_hb { + Some(s) if !s.is_empty() => { + // last_heartbeat_at 是 "YYYY-MM-DD HH:MM:SS" 本地时间 + if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S") { + let hb = dt.and_local_timezone(chrono::Local).single(); + if let Some(hb) = hb { + return (chrono::Local::now() - hb).num_seconds() <= timeout; + } + } + false + } + _ => false, + } +} + +/// GET /api/monitor/nodes —— 各节点在线状态、CPU/内存/连接数、最近心跳 +pub async fn nodes( + state: web::Data, + req: HttpRequest, +) -> AppResult { + let _user = auth_user(&req)?; + let pool = &state.pool; + let timeout = state.cfg.nodes.heartbeat_timeout_secs; + let rows: Vec<(i64, String, i64, Option, Option, Option)> = + sqlx::query_as( + "SELECT id, name, is_local, addr, version, last_heartbeat_at FROM nodes ORDER BY id", + ) + .fetch_all(pool) + .await?; + // 本机实时连接数 + let local_conns: i64 = state + .runtime + .status_snapshot() + .await + .iter() + .map(|s| s["conns"].as_u64().unwrap_or(0) as i64) + .sum(); + let mut items = Vec::new(); + for (id, name, is_local, addr, version, last_hb) in rows { + let online = is_online(is_local, last_hb.as_deref(), timeout); + // 最近一条指标快照 + let m: Option<(Option, Option, Option, Option, Option, String)> = + sqlx::query_as( + "SELECT cpu, mem, conns, net_up_bps, net_down_bps, collected_at + FROM metrics_snapshots WHERE node_id = ? ORDER BY collected_at DESC LIMIT 1", + ) + .bind(id) + .fetch_optional(pool) + .await?; + let (cpu, mem, conns, up_bps, down_bps, at) = m + .map(|(a, b, c, d, e, f)| (a, b, c, d, e, Some(f))) + .unwrap_or((None, None, None, None, None, None)); + items.push(serde_json::json!({ + "id": id, "name": name, "is_local": is_local, "addr": addr, "version": version, + "online": online, "last_heartbeat_at": last_hb, + "cpu": cpu, "mem": mem, + "conns": if is_local == 1 { Some(local_conns) } else { conns }, + "net_up_bps": up_bps, "net_down_bps": down_bps, + "metrics_at": at, + })); + } + Ok(HttpResponse::Ok().json(ApiResp::ok(serde_json::json!({ "items": items })))) +} + +/// GET /api/monitor/rules —— 各条目实时连接数、近 1 分钟速率、运行状态 +pub async fn rules( + state: web::Data, + req: HttpRequest, +) -> AppResult { + let _user = auth_user(&req)?; + let pool = &state.pool; + let snap = state.runtime.status_snapshot().await; + let mut rt: std::collections::HashMap<(String, i64), &serde_json::Value> = + std::collections::HashMap::new(); + for s in &snap { + let et = s["entry_type"].as_str().unwrap_or("").to_string(); + let id = s["entry_id"].as_i64().unwrap_or(0); + rt.insert((et, id), s); + } + // 近 1 分钟的流量(按 node + rule 聚合) + let minute_ago = (chrono::Local::now() - chrono::Duration::seconds(60)) + .format("%Y-%m-%d %H:%M:%S") + .to_string(); + let recent: Vec<(i64, String, i64, Option, Option, Option)> = sqlx::query_as( + "SELECT node_id, rule_type, rule_id, COUNT(*), COALESCE(SUM(bytes_up),0), COALESCE(SUM(bytes_down),0) + FROM access_logs WHERE started_at >= ? GROUP BY node_id, rule_type, rule_id", + ) + .bind(&minute_ago) + .fetch_all(pool) + .await?; + let mut recent_map = std::collections::HashMap::new(); + for (nid, t, id, c, up, down) in recent { + recent_map.insert( + (nid, t, id), + (c.unwrap_or(0), up.unwrap_or(0), down.unwrap_or(0)), + ); + } + let mut items = Vec::new(); + // 所有节点(本机用 runtime 快照,Worker 节点用上报的 node_entry_status) + let nodes: Vec<(i64, String)> = + sqlx::query_as("SELECT id, name FROM nodes WHERE enabled = 1 ORDER BY id") + .fetch_all(pool) + .await?; + // Worker 节点上报的运行状态 + let wstat: Vec<(i64, String, i64, String, i64, String)> = sqlx::query_as( + "SELECT node_id, entry_type, entry_id, status, conns, updated_at FROM node_entry_status", + ) + .fetch_all(pool) + .await?; + let mut wmap = std::collections::HashMap::new(); + for (nid, et, eid, st, c, upd) in wstat { + wmap.insert((nid, et, eid), (st, c, upd)); + } + for (nid, nname) in nodes { + for (table, etype) in [ + ("proxy_rules", "proxy"), + ("port_forwards", "forward"), + ("tunnels", "tunnel"), + ] { + let rows: Vec<(i64, String, i64)> = sqlx::query_as(&format!( + "SELECT id, name, enabled FROM {table} WHERE node_id = ? ORDER BY id" + )) + .bind(nid) + .fetch_all(pool) + .await?; + for (id, name, enabled) in rows { + let key = (etype.to_string(), id); + let (status, conns, status_at): (String, i64, Option) = if nid == 1 { + let s = rt.get(&key); + ( + s.map(|x| x["status"].as_str().unwrap_or("?").to_string()) + .unwrap_or_else(|| "stopped".into()), + s.map(|x| x["conns"].as_u64().unwrap_or(0)).unwrap_or(0) as i64, + None, + ) + } else { + match wmap.get(&(nid, etype.to_string(), id)) { + Some((st, c, upd)) => (st.clone(), *c, Some(upd.clone())), + None => ("unknown".into(), 0, None), + } + }; + let (rc, rup, rdown) = recent_map + .get(&(nid, etype.to_string(), id)) + .copied() + .unwrap_or((0, 0, 0)); + items.push(serde_json::json!({ + "node_id": nid, "node_name": nname, + "entry_type": etype, "entry_id": id, "name": name, "enabled": enabled, + "runtime_status": status, "conns": conns, "status_at": status_at, + "last_minute": { "connections": rc, "bytes_up": rup, "bytes_down": rdown, + "up_bps": rup / 60, "down_bps": rdown / 60 }, + })); + } + } + } + Ok(HttpResponse::Ok().json(ApiResp::ok(serde_json::json!({ "items": items })))) +} + +#[derive(Deserialize)] +pub struct MetricsQuery { + pub minutes: Option, +} + +/// GET /api/monitor/nodes/{id}/metrics —— 最近 N 分钟指标序列(默认 30) +pub async fn node_metrics( + state: web::Data, + req: HttpRequest, + path: web::Path, + q: web::Query, +) -> AppResult { + let _user = auth_user(&req)?; + let minutes = q.minutes.unwrap_or(30).clamp(1, 1440); + let cutoff = (chrono::Local::now() - chrono::Duration::minutes(minutes)) + .format("%Y-%m-%d %H:%M:%S") + .to_string(); + let rows: Vec<(String, Option, Option, Option, Option, Option)> = + sqlx::query_as( + "SELECT collected_at, cpu, mem, conns, net_up_bps, net_down_bps + FROM metrics_snapshots WHERE node_id = ? AND collected_at >= ? + ORDER BY collected_at", + ) + .bind(path.into_inner()) + .bind(&cutoff) + .fetch_all(&state.pool) + .await?; + let items: Vec<_> = rows + .into_iter() + .map(|(at, cpu, mem, conns, up, down)| { + serde_json::json!({ + "at": at, "cpu": cpu, "mem": mem, "conns": conns, + "net_up_bps": up, "net_down_bps": down, + }) + }) + .collect(); + Ok(HttpResponse::Ok().json(ApiResp::ok(serde_json::json!({ "items": items })))) +} diff --git a/src/handlers/nodes.rs b/src/handlers/nodes.rs new file mode 100644 index 0000000..ce75091 --- /dev/null +++ b/src/handlers/nodes.rs @@ -0,0 +1,298 @@ +//! P10 —— 服务器节点管理 API +//! +//! - GET /api/nodes +//! - POST /api/nodes(生成 api_token,返回 Worker 启动命令,仅此次可见) +//! - PUT /api/nodes/{id} +//! - POST /api/nodes/{id}/reset_token +//! - DELETE /api/nodes/{id} + +use actix_web::{HttpRequest, HttpResponse, web}; +use rand::TryRngCore; +use serde::Deserialize; + +use crate::audit::audit; +use crate::error::{AppError, AppResult}; +use crate::middleware::auth_user; +use crate::models::ApiResp; +use crate::state::AppState; +use crate::util::client_ip; + +pub fn routes(cfg: &mut web::ServiceConfig) { + cfg.service( + web::scope("/api/nodes") + .route("", web::get().to(list)) + .route("", web::post().to(create)) + .route("/{id}", web::put().to(update)) + .route("/{id}", web::delete().to(delete)) + .route("/{id}/reset_token", web::post().to(reset_token)), + ); +} + +fn gen_token() -> String { + let mut b = [0u8; 32]; + rand::rngs::OsRng.try_fill_bytes(&mut b).expect("OsRng 失败"); + hex::encode(b) +} + +/// Worker 启动命令(token 仅在创建 / 重置时返回一次) +fn worker_cmd(master: &str, token: &str) -> String { + format!("rust_proxy_admin --mode worker --master {master} --token {token}") +} + +pub async fn list( + state: web::Data, + req: HttpRequest, + q: web::Query, +) -> AppResult { + let _user = auth_user(&req)?; + let page = q.page.unwrap_or(1).max(1); + let ps = q.page_size.unwrap_or(20).clamp(1, 100); + let total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM nodes") + .fetch_one(&state.pool) + .await?; + let rows: Vec = sqlx::query_as::<_, ( + i64, String, i64, Option, Option, i64, Option, Option, + Option, Option, String, + )>( + "SELECT id, name, is_local, addr, public_host, enabled, meta, version, + last_heartbeat_at, last_load, online_state + FROM nodes ORDER BY id LIMIT ? OFFSET ?", + ) + .bind(ps) + .bind((page - 1) * ps) + .fetch_all(&state.pool) + .await? + .into_iter() + .map( + |(id, name, is_local, addr, public_host, enabled, meta, version, hb, load, ostate)| { + serde_json::json!({ + "id": id, "name": name, "is_local": is_local, "addr": addr, + "public_host": public_host, "enabled": enabled, "meta": meta, + "version": version, "last_heartbeat_at": hb, "last_load": load, + "online_state": ostate, + }) + }, + ) + .collect(); + Ok(HttpResponse::Ok().json(ApiResp::ok(crate::models::Page { items: rows, total, page, page_size: ps }))) +} + +#[derive(Deserialize)] +pub struct CreateReq { + pub name: String, + pub public_host: Option, + pub meta: Option, + /// Master 公网地址,用于生成 Worker 启动命令中的 --master + pub master_url: Option, +} + +pub async fn create( + state: web::Data, + req: HttpRequest, + body: web::Json, +) -> AppResult { + + let user = auth_user(&req)?; + let ip = client_ip(&req, &state.cfg.server.trusted_proxies); + let name = body.name.trim(); + if name.is_empty() || name.len() > 64 { + return Err(AppError::bad("节点名称 1~64 字符")); + } + let token = gen_token(); + let r = sqlx::query( + "INSERT INTO nodes (name, is_local, public_host, api_token, meta) VALUES (?, 0, ?, ?, ?)", + ) + .bind(name) + .bind(body.public_host.as_deref()) + .bind(&token) + .bind(body.meta.as_deref()) + .execute(&state.pool) + .await + .map_err(|e| { + if e.to_string().contains("UNIQUE") { + AppError::bad("节点名称已存在") + } else { + AppError::from(e) + } + })?; + let id = r.last_insert_rowid(); + let master_url = body.master_url.as_deref().unwrap_or("https://admin.example.com"); + audit( + &state.pool, + Some(user.id), + &user.username, + "create", + Some("node"), + Some(id), + None, + serde_json::json!({ "name": name }), + &ip, + ) + .await; + Ok(HttpResponse::Ok().json(ApiResp::ok(serde_json::json!({ + "id": id, + "worker_cmd": worker_cmd(master_url, &token), + "note": "token 仅显示这一次,请妥善保存;之后只能重置", + })))) +} + +#[derive(Deserialize)] +pub struct UpdateReq { + pub name: Option, + pub public_host: Option, + pub meta: Option, + pub enabled: Option, +} + +pub async fn update( + state: web::Data, + req: HttpRequest, + path: web::Path, + body: web::Json, +) -> AppResult { + + let user = auth_user(&req)?; + let ip = client_ip(&req, &state.cfg.server.trusted_proxies); + let id = path.into_inner(); + let is_local: Option = sqlx::query_scalar("SELECT is_local FROM nodes WHERE id = ?") + .bind(id) + .fetch_optional(&state.pool) + .await?; + if is_local.is_none() { + return Err(AppError::not_found("节点不存在")); + } + if let Some(n) = body.name.as_deref() { + let n = n.trim(); + if n.is_empty() || n.len() > 64 { + return Err(AppError::bad("节点名称 1~64 字符")); + } + sqlx::query("UPDATE nodes SET name = ?, updated_at = datetime('now') WHERE id = ?") + .bind(n) + .bind(id) + .execute(&state.pool) + .await + .map_err(|e| { + if e.to_string().contains("UNIQUE") { + AppError::bad("节点名称已存在") + } else { + AppError::from(e) + } + })?; + } + if let Some(h) = body.public_host.as_deref() { + sqlx::query("UPDATE nodes SET public_host = ?, updated_at = datetime('now') WHERE id = ?") + .bind(h) + .bind(id) + .execute(&state.pool) + .await?; + } + if let Some(m) = body.meta.as_deref() { + sqlx::query("UPDATE nodes SET meta = ?, updated_at = datetime('now') WHERE id = ?") + .bind(m) + .bind(id) + .execute(&state.pool) + .await?; + } + if let Some(e) = body.enabled { + sqlx::query("UPDATE nodes SET enabled = ?, updated_at = datetime('now') WHERE id = ?") + .bind(if e == 1 { 1 } else { 0 }) + .bind(id) + .execute(&state.pool) + .await?; + } + audit( + &state.pool, + Some(user.id), + &user.username, + "update", + Some("node"), + Some(id), + None, + serde_json::to_value(&serde_json::json!({ "req": "update" })).unwrap(), + &ip, + ) + .await; + Ok(HttpResponse::Ok().json(ApiResp::ok(serde_json::json!({ "id": id })))) +} + +#[derive(Deserialize)] +pub struct ResetTokenReq { + pub master_url: Option, +} + +pub async fn reset_token( + state: web::Data, + req: HttpRequest, + path: web::Path, + body: web::Json, +) -> AppResult { + + let user = auth_user(&req)?; + let ip = client_ip(&req, &state.cfg.server.trusted_proxies); + let id = path.into_inner(); + let exists: Option = sqlx::query_scalar("SELECT id FROM nodes WHERE id = ? AND is_local = 0") + .bind(id) + .fetch_optional(&state.pool) + .await?; + if exists.is_none() { + return Err(AppError::not_found("节点不存在或为本机节点")); + } + let token = gen_token(); + sqlx::query("UPDATE nodes SET api_token = ?, updated_at = datetime('now') WHERE id = ?") + .bind(&token) + .bind(id) + .execute(&state.pool) + .await?; + let master_url = body.master_url.as_deref().unwrap_or("https://admin.example.com"); + audit( + &state.pool, + Some(user.id), + &user.username, + "reset_token", + Some("node"), + Some(id), + None, + serde_json::json!({}), + &ip, + ) + .await; + Ok(HttpResponse::Ok().json(ApiResp::ok(serde_json::json!({ + "id": id, + "worker_cmd": worker_cmd(master_url, &token), + "note": "旧 token 已失效,Worker 需用新命令重启", + })))) +} + +pub async fn delete( + state: web::Data, + req: HttpRequest, + path: web::Path, +) -> AppResult { + + let user = auth_user(&req)?; + let ip = client_ip(&req, &state.cfg.server.trusted_proxies); + let id = path.into_inner(); + let is_local: Option = sqlx::query_scalar("SELECT is_local FROM nodes WHERE id = ?") + .bind(id) + .fetch_optional(&state.pool) + .await?; + match is_local { + None => return Err(AppError::not_found("节点不存在")), + Some(1) => return Err(AppError::bad("不能删除本机节点")), + _ => {} + } + sqlx::query("DELETE FROM nodes WHERE id = ?").bind(id).execute(&state.pool).await?; + audit( + &state.pool, + Some(user.id), + &user.username, + "delete", + Some("node"), + Some(id), + None, + serde_json::json!({}), + &ip, + ) + .await; + Ok(HttpResponse::Ok().json(ApiResp::ok(serde_json::json!({ "id": id })))) +} diff --git a/src/handlers/pages.rs b/src/handlers/pages.rs new file mode 100644 index 0000000..77858e8 --- /dev/null +++ b/src/handlers/pages.rs @@ -0,0 +1,172 @@ +use crate::error::{AppError, AppResult}; +use crate::middleware::auth_user; +use crate::state::AppState; +use crate::util::verify_password; +use actix_web::{web, HttpRequest, HttpResponse}; +use tera::Context; + +/// 渲染后台页面(注入通用变量) +pub async fn render( + state: &AppState, + req: &HttpRequest, + tpl: &str, + active: &str, + mut ctx: Context, +) -> AppResult { + let user = auth_user(req).ok(); + ctx.insert("active", active); + ctx.insert( + "csrf", + &user.as_ref().map(|u| u.csrf_token.clone()).unwrap_or_default(), + ); + ctx.insert( + "username", + &user.as_ref().map(|u| u.username.clone()).unwrap_or_default(), + ); + ctx.insert( + "role", + &user.as_ref().map(|u| u.role.clone()).unwrap_or_default(), + ); + ctx.insert( + "public_base_url", + &state.cfg.server.public_base_url.trim_end_matches('/'), + ); + ctx.insert("tz_offset", &state.cfg.time.timezone_offset_hours); + // 默认密码提示 + let mut is_default = false; + if let Some(u) = &user { + let h: Option = + sqlx::query_scalar("SELECT password_hash FROM users WHERE id = ?") + .bind(u.id) + .fetch_optional(&state.pool) + .await + .unwrap_or(None); + if let Some(h) = h { + is_default = verify_password("ChangeMe123!", &h); + } + } + ctx.insert("is_default_password", &is_default); + let body = state + .tera + .render(tpl, &ctx) + .map_err(|e| AppError::internal(format!("模板渲染失败: {e}")))?; + Ok(HttpResponse::Ok() + .content_type("text/html; charset=utf-8") + .body(body)) +} + +pub fn routes(cfg: &mut web::ServiceConfig) { + cfg.route("/login", web::get().to(login_page)) + .route("", web::get().to(dashboard)) + .route("/", web::get().to(dashboard)) + .route("/account", web::get().to(account_page)) + .route("/groups", web::get().to(groups_page)) + .route("/tags", web::get().to(tags_page)) + .route("/proxy", web::get().to(proxies_page)) + .route("/forward", web::get().to(forwards_page)) + .route("/tunnel", web::get().to(tunnels_page)) + .route("/subscriptions", web::get().to(subscriptions_page)) + .route("/subscriptions/new", web::get().to(subscriptions_page)) + .route("/templates", web::get().to(templates_page)) + .route("/import", web::get().to(import_page)) + .route("/import/records", web::get().to(import_records_page)) + .route("/logs/audit", web::get().to(logs_audit_page)) + .route("/logs/access", web::get().to(logs_access_page)) + .route("/logs/subscription", web::get().to(logs_subscription_page)) + .route("/stats", web::get().to(stats_page)) + .route("/quotas", web::get().to(quotas_page)) + .route("/alerts", web::get().to(alerts_page)) + .route("/monitor/nodes", web::get().to(monitor_nodes_page)) + .route("/monitor/rules", web::get().to(monitor_rules_page)) + .route("/nodes", web::get().to(nodes_page)); +} + +async fn login_page(state: web::Data) -> AppResult { + let body = state + .tera + .render("login.html", &Context::new()) + .map_err(|e| AppError::internal(format!("模板渲染失败: {e}")))?; + Ok(HttpResponse::Ok() + .content_type("text/html; charset=utf-8") + .body(body)) +} + +async fn dashboard(state: web::Data, req: HttpRequest) -> AppResult { + render(&state, &req, "admin/dashboard.html", "dashboard", Context::new()).await +} + +async fn account_page(state: web::Data, req: HttpRequest) -> AppResult { + render(&state, &req, "admin/account.html", "account", Context::new()).await +} + +async fn groups_page(state: web::Data, req: HttpRequest) -> AppResult { + render(&state, &req, "admin/groups.html", "groups", Context::new()).await +} + +async fn tags_page(state: web::Data, req: HttpRequest) -> AppResult { + render(&state, &req, "admin/tags.html", "tags", Context::new()).await +} + +async fn proxies_page(state: web::Data, req: HttpRequest) -> AppResult { + render(&state, &req, "admin/proxies.html", "proxies", Context::new()).await +} + +async fn forwards_page(state: web::Data, req: HttpRequest) -> AppResult { + render(&state, &req, "admin/forwards.html", "forwards", Context::new()).await +} + +async fn tunnels_page(state: web::Data, req: HttpRequest) -> AppResult { + render(&state, &req, "admin/tunnels.html", "tunnels", Context::new()).await +} + +async fn subscriptions_page(state: web::Data, req: HttpRequest) -> AppResult { + render(&state, &req, "admin/subscriptions.html", "subscriptions", Context::new()).await +} + +async fn templates_page(state: web::Data, req: HttpRequest) -> AppResult { + render(&state, &req, "admin/templates.html", "templates", Context::new()).await +} + +async fn import_page(state: web::Data, req: HttpRequest) -> AppResult { + render(&state, &req, "admin/import.html", "import", Context::new()).await +} + +async fn import_records_page(state: web::Data, req: HttpRequest) -> AppResult { + render(&state, &req, "admin/import_records.html", "import_records", Context::new()).await +} + +async fn logs_audit_page(state: web::Data, req: HttpRequest) -> AppResult { + render(&state, &req, "admin/logs_audit.html", "logs_audit", Context::new()).await +} + +async fn logs_access_page(state: web::Data, req: HttpRequest) -> AppResult { + render(&state, &req, "admin/logs_access.html", "logs_access", Context::new()).await +} + +async fn logs_subscription_page(state: web::Data, req: HttpRequest) -> AppResult { + render(&state, &req, "admin/logs_subscription.html", "logs_subscription", Context::new()).await +} + +async fn stats_page(state: web::Data, req: HttpRequest) -> AppResult { + render(&state, &req, "admin/stats.html", "stats", Context::new()).await +} + +async fn nodes_page(state: web::Data, req: HttpRequest) -> AppResult { + render(&state, &req, "admin/nodes.html", "nodes", Context::new()).await +} + +async fn quotas_page(state: web::Data, req: HttpRequest) -> AppResult { + render(&state, &req, "admin/quotas.html", "quotas", Context::new()).await +} + +async fn alerts_page(state: web::Data, req: HttpRequest) -> AppResult { + render(&state, &req, "admin/alerts.html", "alerts", Context::new()).await +} + +async fn monitor_nodes_page(state: web::Data, req: HttpRequest) -> AppResult { + render(&state, &req, "admin/monitor_nodes.html", "monitor_nodes", Context::new()).await +} + +async fn monitor_rules_page(state: web::Data, req: HttpRequest) -> AppResult { + render(&state, &req, "admin/monitor_rules.html", "monitor_rules", Context::new()).await +} diff --git a/src/handlers/proxy.rs b/src/handlers/proxy.rs new file mode 100644 index 0000000..d9a6a07 --- /dev/null +++ b/src/handlers/proxy.rs @@ -0,0 +1,460 @@ +use crate::audit::audit; +use crate::error::{AppError, AppResult}; +use crate::handlers::entries_common::{delete_entries_tx, get_entry, list_entries, set_entry_tags}; +use crate::middleware::auth_user; +use crate::models::{ApiResp, ListQuery}; +use crate::state::AppState; +use crate::util::{calc_expires, client_ip}; +use actix_web::{web, HttpRequest, HttpResponse}; +use serde::Deserialize; + +const ET: &str = "proxy"; + +pub fn routes(cfg: &mut web::ServiceConfig) { + cfg.route("", web::get().to(list)) + .route("", web::post().to(create)) + .route("/{id}", web::get().to(detail)) + .route("/{id}", web::put().to(update)) + .route("/{id}", web::delete().to(delete)) + .route("/{id}/toggle", web::post().to(toggle)) + .route("/batch_delete", web::post().to(batch_delete)) + .route("/batch_toggle", web::post().to(batch_toggle)) + .route("/batch_move", web::post().to(batch_move)); +} + +#[derive(Deserialize)] +pub struct ProxyCreate { + pub node_id: i64, + pub name: String, + pub upstream_type: Option, + pub upstream_addr: String, + pub listen_addr: Option, + pub auth_user: Option, + pub auth_pass: Option, + pub export_host: Option, + pub export_port: Option, + pub extra: Option, + pub remark: Option, + pub group_id: Option, + pub enabled: Option, + pub expire_preset: Option, + pub expires_at: Option, + pub tag_ids: Option>, +} + +#[derive(Deserialize)] +pub struct ProxyUpdate { + pub node_id: Option, + pub name: Option, + pub upstream_type: Option, + pub upstream_addr: Option, + pub listen_addr: Option>, + pub auth_user: Option>, + pub auth_pass: Option>, + pub export_host: Option>, + pub export_port: Option>, + pub extra: Option>, + pub remark: Option>, + pub group_id: Option>, + pub expire_preset: Option, + pub expires_at: Option>, + pub tag_ids: Option>, +} + +#[derive(Deserialize)] +pub struct ToggleReq { + pub enabled: bool, +} + +#[derive(Deserialize)] +pub struct IdsReq { + pub ids: Vec, +} + +#[derive(Deserialize)] +pub struct BatchToggleReq { + pub ids: Vec, + pub enabled: bool, +} + +#[derive(Deserialize)] +pub struct BatchMoveReq { + pub ids: Vec, + pub node_id: i64, +} + +fn validate_name(name: &str) -> AppResult<()> { + let t = name.trim(); + if t.is_empty() || t.chars().count() > 100 { + return Err(AppError::bad("名称不能为空且不超过 100 字符")); + } + Ok(()) +} + +fn validate_addr(addr: &str) -> AppResult<()> { + let t = addr.trim(); + if t.is_empty() || t.len() > 255 { + return Err(AppError::bad("地址不能为空且不超过 255 字符")); + } + Ok(()) +} + +async fn node_exists(pool: &sqlx::SqlitePool, node_id: i64) -> AppResult<()> { + let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM nodes WHERE id = ?") + .bind(node_id) + .fetch_one(pool) + .await?; + if n == 0 { + return Err(AppError::bad("节点不存在")); + } + Ok(()) +} + +async fn list( + state: web::Data, + query: web::Query, +) -> AppResult { + let page = list_entries(&state.pool, ET, &query).await?; + Ok(HttpResponse::Ok().json(ApiResp::ok(page))) +} + +async fn create( + state: web::Data, + req: HttpRequest, + body: web::Json, +) -> AppResult { + let user = auth_user(&req)?; + let ip = client_ip(&req, &state.cfg.server.trusted_proxies); + let b = body.into_inner(); + validate_name(&b.name)?; + let proto = b.upstream_type.unwrap_or_else(|| "http".into()); + if !matches!(proto.as_str(), "direct" | "http" | "https" | "socks5" | "ss" | "vmess" | "vless" | "trojan" | "hysteria2" | "tuic") { + return Err(AppError::bad("upstream_type 不支持")); + } + if proto != "direct" { + validate_addr(&b.upstream_addr)?; + } + if let Some(la) = b.listen_addr.as_deref() { + validate_addr(la)?; + } + node_exists(&state.pool, b.node_id).await?; + let expires = calc_expires( + b.expire_preset.as_deref().unwrap_or("permanent"), + b.expires_at.as_deref(), + )?; + + let mut tx = state.pool.begin().await?; + let id: i64 = sqlx::query_scalar( + "INSERT INTO proxy_rules + (node_id, name, upstream_type, upstream_addr, listen_addr, + auth_user, auth_pass, export_host, export_port, extra, + remark, group_id, enabled, expires_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id", + ) + .bind(b.node_id) + .bind(b.name.trim()) + .bind(&proto) + .bind(b.upstream_addr.trim()) + .bind(b.listen_addr.as_deref().map(str::trim)) + .bind(b.auth_user.as_deref().map(str::trim).filter(|s| !s.is_empty())) + .bind(b.auth_pass.as_deref().filter(|s| !s.is_empty())) + .bind(b.export_host.as_deref().map(str::trim).filter(|s| !s.is_empty())) + .bind(b.export_port) + .bind(b.extra.as_deref().map(str::trim).filter(|s| !s.is_empty())) + .bind(b.remark.as_deref().map(str::trim).filter(|s| !s.is_empty())) + .bind(b.group_id) + .bind(b.enabled.unwrap_or(true) as i64) + .bind(&expires) + .fetch_one(&mut *tx) + .await?; + if let Some(tags) = b.tag_ids.as_deref() { + set_entry_tags(&mut tx, ET, id, tags).await?; + } + tx.commit().await?; + + audit( + &state.pool, + Some(user.id), + &user.username, + "create_proxy", + Some(ET), + Some(id), + None, + serde_json::json!({"name": b.name}), + &ip, + ) + .await; + crate::services::runtime::on_entries_changed(&state).await; + Ok(HttpResponse::Ok().json(ApiResp::ok(get_entry(&state.pool, ET, id).await?))) +} + +async fn detail(state: web::Data, path: web::Path) -> AppResult { + let v = get_entry(&state.pool, ET, path.into_inner()).await?; + Ok(HttpResponse::Ok().json(ApiResp::ok(v))) +} + +async fn update( + state: web::Data, + req: HttpRequest, + path: web::Path, + body: web::Json, +) -> AppResult { + let user = auth_user(&req)?; + let ip = client_ip(&req, &state.cfg.server.trusted_proxies); + let id = path.into_inner(); + let b = body.into_inner(); + + get_entry(&state.pool, ET, id).await?; + if let Some(n) = b.name.as_deref() { + validate_name(n)?; + } + if let Some(a) = b.upstream_addr.as_deref() { + validate_addr(a)?; + } + if let Some(Some(la)) = b.listen_addr.as_ref() { + validate_addr(la)?; + } + if let Some(nid) = b.node_id { + node_exists(&state.pool, nid).await?; + } + if let Some(p) = b.upstream_type.as_deref() { + if !matches!(p, "http" | "https" | "socks5" | "ss" | "vmess" | "vless" | "trojan" | "hysteria2" | "tuic") { + return Err(AppError::bad("upstream_type 不支持")); + } + } + let expires: Option> = match (&b.expire_preset, &b.expires_at) { + (None, None) => None, + (preset, ea) => Some(calc_expires( + preset.as_deref().unwrap_or("permanent"), + ea.as_ref().and_then(|o| o.as_deref()), + )?), + }; + + let mut tx = state.pool.begin().await?; + macro_rules! set { + ($col:literal, $val:expr) => { + sqlx::query(concat!("UPDATE proxy_rules SET ", $col, " = ? WHERE id = ?")) + .bind($val) + .bind(id) + .execute(&mut *tx) + .await?; + }; + } + if let Some(v) = b.node_id { set!("node_id", v); } + if let Some(v) = b.name.as_deref() { set!("name", v.trim()); } + if let Some(v) = b.upstream_type.as_deref() { set!("upstream_type", v); } + if let Some(v) = b.upstream_addr.as_deref() { set!("upstream_addr", v.trim()); } + if let Some(v) = b.listen_addr.as_ref() { set!("listen_addr", v.as_deref().map(str::trim)); } + if let Some(v) = b.auth_user.as_ref() { set!("auth_user", v.as_deref().map(str::trim).filter(|s| !s.is_empty())); } + if let Some(v) = b.auth_pass.as_ref() { set!("auth_pass", v.as_deref().filter(|s| !s.is_empty())); } + if let Some(v) = b.export_host.as_ref() { set!("export_host", v.as_deref().map(str::trim).filter(|s| !s.is_empty())); } + if let Some(v) = b.export_port { set!("export_port", v); } + if let Some(v) = b.extra.as_ref() { set!("extra", v.as_deref().map(str::trim).filter(|s| !s.is_empty())); } + if let Some(v) = b.remark.as_ref() { set!("remark", v.as_deref().map(str::trim).filter(|s| !s.is_empty())); } + if let Some(v) = b.group_id { set!("group_id", v); } + if let Some(v) = expires { + let exp: Option = v.clone(); + set!("expires_at", v); + super::entries_common::recover_expired_if_future(&mut tx, ET, id, exp.as_deref()).await?; + } + sqlx::query("UPDATE proxy_rules SET updated_at = datetime('now') WHERE id = ?") + .bind(id) + .execute(&mut *tx) + .await?; + if let Some(tags) = b.tag_ids.as_deref() { + set_entry_tags(&mut tx, ET, id, tags).await?; + } + tx.commit().await?; + + audit( + &state.pool, + Some(user.id), + &user.username, + "update_proxy", + Some(ET), + Some(id), + None, + serde_json::json!({}), + &ip, + ) + .await; + crate::services::runtime::on_entries_changed(&state).await; + Ok(HttpResponse::Ok().json(ApiResp::ok(get_entry(&state.pool, ET, id).await?))) +} + +async fn delete( + state: web::Data, + req: HttpRequest, + path: web::Path, +) -> AppResult { + let user = auth_user(&req)?; + let ip = client_ip(&req, &state.cfg.server.trusted_proxies); + let id = path.into_inner(); + get_entry(&state.pool, ET, id).await?; + let mut tx = state.pool.begin().await?; + let n = delete_entries_tx(&mut tx, ET, &[id]).await?; + tx.commit().await?; + audit( + &state.pool, + Some(user.id), + &user.username, + "delete_proxy", + Some(ET), + Some(id), + None, + serde_json::json!({}), + &ip, + ) + .await; + crate::services::runtime::on_entries_changed(&state).await; + Ok(HttpResponse::Ok().json(ApiResp::ok(serde_json::json!({"deleted": n})))) +} + +async fn toggle( + state: web::Data, + req: HttpRequest, + path: web::Path, + body: web::Json, +) -> AppResult { + let user = auth_user(&req)?; + let ip = client_ip(&req, &state.cfg.server.trusted_proxies); + let id = path.into_inner(); + get_entry(&state.pool, ET, id).await?; + if body.enabled { + sqlx::query( + "UPDATE proxy_rules SET enabled = 1, disabled_reason = NULL, updated_at = datetime('now') WHERE id = ?", + ) + .bind(id) + .execute(&state.pool) + .await?; + } else { + sqlx::query( + "UPDATE proxy_rules SET enabled = 0, disabled_reason = 'manual', updated_at = datetime('now') WHERE id = ?", + ) + .bind(id) + .execute(&state.pool) + .await?; + } + audit( + &state.pool, + Some(user.id), + &user.username, + if body.enabled { "enable_proxy" } else { "disable_proxy" }, + Some(ET), + Some(id), + None, + serde_json::json!({}), + &ip, + ) + .await; + crate::services::runtime::on_entries_changed(&state).await; + Ok(HttpResponse::Ok().json(ApiResp::ok(get_entry(&state.pool, ET, id).await?))) +} + +async fn batch_delete( + state: web::Data, + req: HttpRequest, + body: web::Json, +) -> AppResult { + let user = auth_user(&req)?; + let ip = client_ip(&req, &state.cfg.server.trusted_proxies); + if body.ids.is_empty() { + return Err(AppError::bad("ids 不能为空")); + } + let mut tx = state.pool.begin().await?; + let n = delete_entries_tx(&mut tx, ET, &body.ids).await?; + tx.commit().await?; + audit( + &state.pool, + Some(user.id), + &user.username, + "batch_delete_proxy", + Some(ET), + None, + None, + serde_json::json!({"ids": body.ids, "deleted": n}), + &ip, + ) + .await; + crate::services::runtime::on_entries_changed(&state).await; + Ok(HttpResponse::Ok().json(ApiResp::ok(serde_json::json!({"deleted": n})))) +} + +async fn batch_toggle( + state: web::Data, + req: HttpRequest, + body: web::Json, +) -> AppResult { + let user = auth_user(&req)?; + let ip = client_ip(&req, &state.cfg.server.trusted_proxies); + if body.ids.is_empty() { + return Err(AppError::bad("ids 不能为空")); + } + let mut n = 0u64; + for chunk in body.ids.chunks(500) { + let ph = vec!["?"; chunk.len()].join(","); + let sql = if body.enabled { + format!("UPDATE proxy_rules SET enabled = 1, disabled_reason = NULL, updated_at = datetime('now') WHERE id IN ({ph})") + } else { + format!("UPDATE proxy_rules SET enabled = 0, disabled_reason = 'manual', updated_at = datetime('now') WHERE id IN ({ph})") + }; + let mut q = sqlx::query(&sql); + for id in chunk { + q = q.bind(id); + } + n += q.execute(&state.pool).await?.rows_affected(); + } + audit( + &state.pool, + Some(user.id), + &user.username, + "batch_toggle_proxy", + Some(ET), + None, + None, + serde_json::json!({"ids": body.ids, "enabled": body.enabled, "updated": n}), + &ip, + ) + .await; + crate::services::runtime::on_entries_changed(&state).await; + Ok(HttpResponse::Ok().json(ApiResp::ok(serde_json::json!({"updated": n})))) +} + +async fn batch_move( + state: web::Data, + req: HttpRequest, + body: web::Json, +) -> AppResult { + let user = auth_user(&req)?; + let ip = client_ip(&req, &state.cfg.server.trusted_proxies); + if body.ids.is_empty() { + return Err(AppError::bad("ids 不能为空")); + } + node_exists(&state.pool, body.node_id).await?; + let mut n = 0u64; + for chunk in body.ids.chunks(500) { + let ph = vec!["?"; chunk.len()].join(","); + let sql = format!( + "UPDATE proxy_rules SET node_id = ?, updated_at = datetime('now') WHERE id IN ({ph})" + ); + let mut q = sqlx::query(&sql).bind(body.node_id); + for id in chunk { + q = q.bind(id); + } + n += q.execute(&state.pool).await?.rows_affected(); + } + audit( + &state.pool, + Some(user.id), + &user.username, + "batch_move_proxy", + Some(ET), + None, + None, + serde_json::json!({"ids": body.ids, "node_id": body.node_id, "updated": n}), + &ip, + ) + .await; + crate::services::runtime::on_entries_changed(&state).await; + Ok(HttpResponse::Ok().json(ApiResp::ok(serde_json::json!({"updated": n})))) +} diff --git a/src/handlers/quotas.rs b/src/handlers/quotas.rs new file mode 100644 index 0000000..efaa019 --- /dev/null +++ b/src/handlers/quotas.rs @@ -0,0 +1,261 @@ +//! P11 —— 配额 HTTP 接口 +//! +//! - GET /api/entries/{etype}/{id}/quota 查看配额与用量 +//! - PUT /api/entries/{etype}/{id}/quota 设置/取消配额 +//! - POST /api/entries/{etype}/{id}/quota/reset 重置用量 +//! - PUT /api/entries/{etype}/{id}/expiry 设置到期时间(preset 或自定义;不传为永久) +//! - GET /api/quotas 配额总览(按使用率排序,支持超限/接近超限过滤) +//! +//! 批量接口在 entries_common(POST /api/entries/batch_set_quota)。 + +use crate::audit::audit; +use crate::error::{AppError, AppResult}; +use crate::handlers::entries_common::{get_entry, upsert_quota}; +use crate::middleware::auth_user; +use crate::models::{ApiResp, QuotaRow}; +use crate::services::lifecycle; +use crate::state::AppState; +use crate::util::{calc_expires, client_ip, table_of}; +use actix_web::{web, HttpRequest, HttpResponse}; +use serde::Deserialize; + +pub fn routes(cfg: &mut web::ServiceConfig) { + // 注意:/api/entries/* 的路由必须注册在同一个 scope 内(见 entries_routes), + // actix 同前缀的第二个 scope 不会被匹配到。 + cfg.route("/api/quotas", web::get().to(overview)); +} + +/// 挂到 /api/entries scope 下的路由(由 entries_common::entries_routes 注册) +pub fn entry_quota_routes(cfg: &mut web::ServiceConfig) { + cfg.route("/{etype}/{id}/quota", web::get().to(get_quota)) + .route("/{etype}/{id}/quota", web::put().to(set_quota)) + .route("/{etype}/{id}/quota/reset", web::post().to(reset_quota)) + .route("/{etype}/{id}/expiry", web::put().to(set_expiry)); +} + +#[derive(Deserialize)] +struct EntryPath { + etype: String, + id: i64, +} + +fn check_etype(t: &str) -> AppResult<&str> { + table_of(t)?; // 校验类型合法 + if !matches!(t, "proxy" | "forward" | "tunnel") { + return Err(AppError::bad("未知的条目类型")); + } + Ok(t) +} + +/// GET /api/entries/{etype}/{id}/quota —— 查看配额与用量 +pub async fn get_quota( + state: web::Data, + req: HttpRequest, + path: web::Path, +) -> AppResult { + auth_user(&req)?; + let p = path.into_inner(); + check_etype(&p.etype)?; + get_entry(&state.pool, &p.etype, p.id).await?; // 404 校验 + let q: Option = + sqlx::query_as("SELECT * FROM entry_quotas WHERE entry_type = ? AND entry_id = ?") + .bind(&p.etype) + .bind(p.id) + .fetch_optional(&state.pool) + .await?; + Ok(HttpResponse::Ok().json(ApiResp::ok(serde_json::json!({ + "quota": q.map(|q| serde_json::to_value(q).unwrap_or(serde_json::Value::Null)) + .unwrap_or(serde_json::Value::Null), + })))) +} + +#[derive(Deserialize)] +struct SetQuotaReq { + quota_bytes: i64, + #[serde(default = "d_total")] + period: String, + reset_day: Option, +} +fn d_total() -> String { + "total".into() +} + +/// PUT /api/entries/{etype}/{id}/quota —— 设置/取消配额(quota_bytes=0 取消) +pub async fn set_quota( + state: web::Data, + req: HttpRequest, + path: web::Path, + body: web::Json, +) -> AppResult { + let user = auth_user(&req)?; + let ip = client_ip(&req, &state.cfg.server.trusted_proxies); + let p = path.into_inner(); + check_etype(&p.etype)?; + get_entry(&state.pool, &p.etype, p.id).await?; + let recovered = upsert_quota( + &state.pool, + &p.etype, + p.id, + body.quota_bytes, + &body.period, + body.reset_day, + Some(&user), + &ip, + ) + .await?; + crate::services::runtime::on_entries_changed(&state).await; + Ok(HttpResponse::Ok().json(ApiResp::ok(serde_json::json!({ + "recovered": recovered, + })))) +} + +/// POST /api/entries/{etype}/{id}/quota/reset —— 手动重置用量 +pub async fn reset_quota( + state: web::Data, + req: HttpRequest, + path: web::Path, +) -> AppResult { + let user = auth_user(&req)?; + let ip = client_ip(&req, &state.cfg.server.trusted_proxies); + let p = path.into_inner(); + check_etype(&p.etype)?; + get_entry(&state.pool, &p.etype, p.id).await?; + let recovered = lifecycle::reset_quota_usage(&state.pool, &p.etype, p.id).await?; + audit( + &state.pool, + Some(user.id), + &user.username, + "reset_quota", + Some(&p.etype), + Some(p.id), + None, + serde_json::json!({"recovered": recovered}), + &ip, + ) + .await; + crate::services::runtime::on_entries_changed(&state).await; + Ok(HttpResponse::Ok().json(ApiResp::ok(serde_json::json!({ + "recovered": recovered, + })))) +} + +#[derive(Deserialize)] +struct SetExpiryReq { + expire_preset: Option, + expires_at: Option, +} + +/// PUT /api/entries/{etype}/{id}/expiry —— 设置到期时间;不传 expires_at 且 preset=permanent 为永久 +pub async fn set_expiry( + state: web::Data, + req: HttpRequest, + path: web::Path, + body: web::Json, +) -> AppResult { + let user = auth_user(&req)?; + let ip = client_ip(&req, &state.cfg.server.trusted_proxies); + let p = path.into_inner(); + let t = check_etype(&p.etype)?; + let table = table_of(t)?; + let mut preset = body.expire_preset.as_deref().unwrap_or("permanent"); + // 显式传了 expires_at 但没传 preset → 按 custom 处理,避免静默忽略 + if body.expires_at.is_some() && preset == "permanent" { + preset = "custom"; + } + let expires = calc_expires(preset, body.expires_at.as_deref())?; + // 到期延到未来 → 自动恢复因 expired 停用的 + let n = sqlx::query(&format!( + "UPDATE {table} SET expires_at = ?, updated_at = datetime('now'), + enabled = CASE WHEN disabled_reason = 'expired' AND (? IS NULL OR ? > datetime('now')) + THEN 1 ELSE enabled END, + disabled_reason = CASE WHEN disabled_reason = 'expired' AND (? IS NULL OR ? > datetime('now')) + THEN NULL ELSE disabled_reason END + WHERE id = ?" + )) + .bind(&expires) + .bind(&expires) + .bind(&expires) + .bind(&expires) + .bind(&expires) + .bind(p.id) + .execute(&state.pool) + .await?; + if n.rows_affected() == 0 { + return Err(AppError::not_found("条目不存在")); + } + audit( + &state.pool, + Some(user.id), + &user.username, + "set_expiry", + Some(t), + Some(p.id), + None, + serde_json::json!({"expires_at": expires}), + &ip, + ) + .await; + crate::services::runtime::on_entries_changed(&state).await; + Ok(HttpResponse::Ok().json(ApiResp::ok(serde_json::json!({ + "expires_at": expires, + })))) +} + +#[derive(Deserialize)] +struct OverviewQuery { + /// exceeded | near | all(默认 all) + filter: Option, +} + +/// GET /api/quotas —— 配额总览:按使用率排序;filter=exceeded 只看超限,near 只看接近超限(≥80%) +pub async fn overview( + state: web::Data, + req: HttpRequest, + query: web::Query, +) -> AppResult { + auth_user(&req)?; + let filter = query.filter.as_deref().unwrap_or("all"); + let rows: Vec = + sqlx::query_as("SELECT * FROM entry_quotas WHERE quota_bytes > 0") + .fetch_all(&state.pool) + .await?; + let mut items: Vec = Vec::new(); + for q in rows { + let table = table_of(&q.entry_type)?; + let info: Option<(String, i64, Option, i64)> = sqlx::query_as(&format!( + "SELECT name, enabled, disabled_reason, node_id FROM {table} WHERE id = ?" + )) + .bind(q.entry_id) + .fetch_optional(&state.pool) + .await?; + let (name, enabled, disabled_reason, node_id) = + info.unwrap_or(("(已删除)".into(), 0, None, 1)); + let pct = q.used_bytes * 100 / q.quota_bytes.max(1); + items.push(serde_json::json!({ + "entry_type": q.entry_type, "entry_id": q.entry_id, "name": name, + "enabled": enabled, "disabled_reason": disabled_reason, "node_id": node_id, + "quota_bytes": q.quota_bytes, "used_bytes": q.used_bytes, "pct": pct, + "period": q.period, "reset_day": q.reset_day, "period_start": q.period_start, + "status": q.status, "exceeded_at": q.exceeded_at, "warn_level": q.warn_level, + })); + } + // 按使用率降序 + items.sort_by(|a, b| b["pct"].as_i64().unwrap_or(0).cmp(&a["pct"].as_i64().unwrap_or(0))); + let filtered: Vec = items + .into_iter() + .filter(|v| match filter { + "exceeded" => v["status"] == "exceeded", + "near" => v["status"] != "exceeded" && v["pct"].as_i64().unwrap_or(0) >= 80, + _ => true, + }) + .collect(); + let exceeded = filtered.iter().filter(|v| v["status"] == "exceeded").count(); + let near = filtered + .iter() + .filter(|v| v["status"] != "exceeded" && v["pct"].as_i64().unwrap_or(0) >= 80) + .count(); + Ok(HttpResponse::Ok().json(ApiResp::ok(serde_json::json!({ + "items": filtered, + "summary": {"total": filtered.len(), "exceeded": exceeded, "near": near}, + })))) +} diff --git a/src/handlers/runtime.rs b/src/handlers/runtime.rs new file mode 100644 index 0000000..5b204f3 --- /dev/null +++ b/src/handlers/runtime.rs @@ -0,0 +1,27 @@ +//! P8 —— 运行时状态 API + +use actix_web::{HttpResponse, web}; + +use crate::error::AppResult; +use crate::models::ApiResp; +use crate::state::AppState; + +pub fn routes(cfg: &mut web::ServiceConfig) { + cfg.service( + web::scope("/api/runtime") + .route("/status", web::get().to(status)) + .route("/sync", web::post().to(sync)), + ); +} + +/// GET /api/runtime/status —— 各条目运行状态与实时计数 +pub async fn status(state: web::Data) -> AppResult { + let items = state.runtime.status_snapshot().await; + Ok(HttpResponse::Ok().json(ApiResp::ok(serde_json::json!({ "items": items })))) +} + +/// POST /api/runtime/sync —— 手动触发热更新对齐 +pub async fn sync(state: web::Data) -> AppResult { + state.runtime.sync().await; + Ok(HttpResponse::Ok().json(ApiResp::ok(serde_json::json!({ "synced": true })))) +} diff --git a/src/handlers/stats.rs b/src/handlers/stats.rs new file mode 100644 index 0000000..7b2b240 --- /dev/null +++ b/src/handlers/stats.rs @@ -0,0 +1,296 @@ +//! P9 —— 统计 API:按小时/天序列、总览 + +use actix_web::{HttpRequest, HttpResponse, web}; +use serde::Deserialize; + +use crate::error::AppResult; +use crate::middleware::auth_user; +use crate::models::ApiResp; +use crate::state::AppState; + +pub fn routes(cfg: &mut web::ServiceConfig) { + cfg.service( + web::scope("/api/stats") + .route("/hourly", web::get().to(hourly)) + .route("/daily", web::get().to(daily)) + .route("/overview", web::get().to(overview)), + ); +} + +#[derive(Deserialize)] +pub struct StatsQuery { + pub from: Option, // "2026-09-01" 或 "2026-09-01T00" + pub to: Option, + pub node_id: Option, + pub rule_type: Option, + pub rule_id: Option, +} + +async fn series( + pool: &sqlx::SqlitePool, + table: &str, + time_col: &str, + q: &StatsQuery, +) -> AppResult> { + // 动态 WHERE:收集条件后拼接(无条件时不加 WHERE) + let mut qb = sqlx::QueryBuilder::new(format!( + "SELECT {time_col} AS t, SUM(total_connections) AS c, SUM(total_bytes_up) AS up, SUM(total_bytes_down) AS down FROM {table}" + )); + let mut has_where = false; + macro_rules! cond { + ($frag:expr, $val:expr) => {{ + if !has_where { + qb.push(" WHERE "); + has_where = true; + } else { + qb.push(" AND "); + } + qb.push($frag); + qb.push_bind($val); + }}; + } + if let Some(from) = q.from.as_deref().filter(|s| !s.is_empty()) { + cond!(format!("{time_col} >= "), from); + } + if let Some(to) = q.to.as_deref().filter(|s| !s.is_empty()) { + cond!(format!("{time_col} <= "), to); + } + if let Some(nid) = q.node_id { + cond!("node_id = ", nid); + } + if let Some(rt) = q.rule_type.as_deref().filter(|s| !s.is_empty()) { + cond!("rule_type = ", rt); + } + if let Some(rid) = q.rule_id { + cond!("rule_id = ", rid); + } + qb.push(format!(" GROUP BY {time_col} ORDER BY {time_col}")); + let rows: Vec<(String, Option, Option, Option)> = + qb.build_query_as().fetch_all(pool).await?; + Ok(rows + .into_iter() + .map(|(t, c, up, down)| { + serde_json::json!({ + "time": t, + "connections": c.unwrap_or(0), + "bytes_up": up.unwrap_or(0), + "bytes_down": down.unwrap_or(0), + }) + }) + .collect()) +} + +/// GET /api/stats/hourly +pub async fn hourly( + state: web::Data, + req: HttpRequest, + q: web::Query, +) -> AppResult { + let _user = auth_user(&req)?; + let items = series(&state.pool, "stats_hourly", "stat_time", &q).await?; + Ok(HttpResponse::Ok().json(ApiResp::ok(serde_json::json!({ "items": items })))) +} + +/// GET /api/stats/daily +pub async fn daily( + state: web::Data, + req: HttpRequest, + q: web::Query, +) -> AppResult { + let _user = auth_user(&req)?; + let items = series(&state.pool, "stats_daily", "stat_date", &q).await?; + Ok(HttpResponse::Ok().json(ApiResp::ok(serde_json::json!({ "items": items })))) +} + +/// GET /api/stats/overview —— 今日/昨日流量连接数、Top N 条目/节点、在线节点数、即将到期、接近超限 +pub async fn overview( + state: web::Data, + req: HttpRequest, +) -> AppResult { + let _user = auth_user(&req)?; + let pool = &state.pool; + let today = chrono::Local::now().format("%Y-%m-%d").to_string(); + let yesterday = (chrono::Local::now() - chrono::Duration::days(1)) + .format("%Y-%m-%d") + .to_string(); + + // 今日/昨日:优先用 stats_daily,没有则回退到 access_logs 实时统计 + async fn day_sum( + pool: &sqlx::SqlitePool, + date: &str, + ) -> AppResult<(i64, i64, i64)> { + let r: Option<(Option, Option, Option)> = sqlx::query_as( + "SELECT SUM(total_connections), SUM(total_bytes_up), SUM(total_bytes_down) + FROM stats_daily WHERE stat_date = ?", + ) + .bind(date) + .fetch_optional(pool) + .await?; + let (c, up, down) = r.unwrap_or((None, None, None)); + if c.unwrap_or(0) > 0 || up.unwrap_or(0) > 0 { + return Ok((c.unwrap_or(0), up.unwrap_or(0), down.unwrap_or(0))); + } + // 回退:直接从 access_logs 按 started_at 前缀统计 + let r2: (Option, Option, Option) = sqlx::query_as( + "SELECT COUNT(*), COALESCE(SUM(bytes_up),0), COALESCE(SUM(bytes_down),0) + FROM access_logs WHERE substr(started_at, 1, 10) = ?", + ) + .bind(date) + .fetch_one(pool) + .await?; + Ok((r2.0.unwrap_or(0), r2.1.unwrap_or(0), r2.2.unwrap_or(0))) + } + let (tc, tup, tdown) = day_sum(pool, &today).await?; + let (yc, yup, ydown) = day_sum(pool, &yesterday).await?; + + // Top N 条目(近 7 天流量) + let week_ago = (chrono::Local::now() - chrono::Duration::days(7)) + .format("%Y-%m-%d") + .to_string(); + let top_rules: Vec<(String, i64, Option, Option, Option)> = sqlx::query_as( + "SELECT rule_type, rule_id, SUM(total_connections), SUM(total_bytes_up), SUM(total_bytes_down) + FROM stats_daily WHERE stat_date >= ? GROUP BY rule_type, rule_id + ORDER BY SUM(total_bytes_up) + SUM(total_bytes_down) DESC LIMIT 10", + ) + .bind(&week_ago) + .fetch_all(pool) + .await?; + // 补条目名称 + let mut top_rules_json = Vec::new(); + for (rt, rid, c, up, down) in top_rules { + let name: Option = entry_name(pool, &rt, rid).await; + top_rules_json.push(serde_json::json!({ + "rule_type": rt, "rule_id": rid, "name": name, + "connections": c.unwrap_or(0), + "bytes_up": up.unwrap_or(0), "bytes_down": down.unwrap_or(0), + })); + } + + // Top N 节点 + let top_nodes: Vec<(i64, Option, Option, Option)> = sqlx::query_as( + "SELECT node_id, SUM(total_connections), SUM(total_bytes_up), SUM(total_bytes_down) + FROM stats_daily WHERE stat_date >= ? GROUP BY node_id + ORDER BY SUM(total_bytes_up) + SUM(total_bytes_down) DESC LIMIT 10", + ) + .bind(&week_ago) + .fetch_all(pool) + .await?; + let mut top_nodes_json = Vec::new(); + for (nid, c, up, down) in top_nodes { + let name: Option = + sqlx::query_scalar("SELECT name FROM nodes WHERE id = ?") + .bind(nid) + .fetch_optional(pool) + .await?; + top_nodes_json.push(serde_json::json!({ + "node_id": nid, "name": name, + "connections": c.unwrap_or(0), + "bytes_up": up.unwrap_or(0), "bytes_down": down.unwrap_or(0), + })); + } + + // 在线节点数 + let timeout = state.cfg.nodes.heartbeat_timeout_secs; + let online_nodes: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM nodes WHERE enabled = 1 AND ( + is_local = 1 OR (last_heartbeat_at IS NOT NULL AND + strftime('%s','now') - strftime('%s', last_heartbeat_at) <= ?))", + ) + .bind(timeout) + .fetch_one(pool) + .await?; + let total_nodes: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM nodes WHERE enabled = 1") + .fetch_one(pool) + .await?; + + // 即将到期(7 天内) + let expiring = expiring_entries(pool).await?; + // 接近超限(使用率 >= 80%) + let near_quota = near_quota_entries(pool).await?; + + Ok(HttpResponse::Ok().json(ApiResp::ok(serde_json::json!({ + "today": { "connections": tc, "bytes_up": tup, "bytes_down": tdown }, + "yesterday": { "connections": yc, "bytes_up": yup, "bytes_down": ydown }, + "top_rules": top_rules_json, + "top_nodes": top_nodes_json, + "online_nodes": online_nodes, + "total_nodes": total_nodes, + "expiring": expiring, + "near_quota": near_quota, + })))) +} + +async fn entry_name(pool: &sqlx::SqlitePool, rule_type: &str, rule_id: i64) -> Option { + let table = match rule_type { + "proxy" => "proxy_rules", + "forward" => "port_forwards", + "tunnel" => "tunnels", + _ => return None, + }; + sqlx::query_scalar(&format!("SELECT name FROM {table} WHERE id = ?")) + .bind(rule_id) + .fetch_optional(pool) + .await + .ok() + .flatten() +} + +/// 7 天内到期的启用条目 +async fn expiring_entries(pool: &sqlx::SqlitePool) -> AppResult> { + let mut out = Vec::new(); + for (table, etype) in [ + ("proxy_rules", "proxy"), + ("port_forwards", "forward"), + ("tunnels", "tunnel"), + ] { + let rows: Vec<(i64, String, String)> = sqlx::query_as(&format!( + "SELECT id, name, expires_at FROM {table} + WHERE enabled = 1 AND expires_at IS NOT NULL AND expires_at != '' + AND date(expires_at) <= date('now', 'localtime', '+7 days') + ORDER BY expires_at LIMIT 20" + )) + .fetch_all(pool) + .await?; + for (id, name, exp) in rows { + out.push(serde_json::json!({ + "entry_type": etype, "entry_id": id, "name": name, "expires_at": exp, + })); + } + } + // 订阅(7 天内到期) + let subs: Vec<(i64, String, String)> = sqlx::query_as( + "SELECT id, name, expires_at FROM subscriptions + WHERE enabled = 1 AND expires_at IS NOT NULL AND expires_at != '' + AND date(expires_at) <= date('now', 'localtime', '+7 days') + ORDER BY expires_at LIMIT 20", + ) + .fetch_all(pool) + .await?; + for (id, name, exp) in subs { + out.push(serde_json::json!({ + "entry_type": "subscription", "entry_id": id, "name": name, "expires_at": exp, + })); + } + Ok(out) +} + +/// 配额使用率 >= 80% 的条目 +async fn near_quota_entries(pool: &sqlx::SqlitePool) -> AppResult> { + let rows: Vec<(String, i64, i64, i64)> = sqlx::query_as( + "SELECT entry_type, entry_id, quota_bytes, used_bytes FROM entry_quotas + WHERE quota_bytes > 0 AND CAST(used_bytes AS REAL) / quota_bytes >= 0.8 + ORDER BY CAST(used_bytes AS REAL) / quota_bytes DESC LIMIT 20", + ) + .fetch_all(pool) + .await?; + let mut out = Vec::new(); + for (et, eid, quota, used) in rows { + let name = entry_name(pool, &et, eid).await; + out.push(serde_json::json!({ + "entry_type": et, "entry_id": eid, "name": name, + "quota_bytes": quota, "used_bytes": used, + "usage": used as f64 / quota as f64, + })); + } + Ok(out) +} diff --git a/src/handlers/subscribe_public.rs b/src/handlers/subscribe_public.rs new file mode 100644 index 0000000..4a36445 --- /dev/null +++ b/src/handlers/subscribe_public.rs @@ -0,0 +1,309 @@ +//! 公开订阅接口 GET /sub/{token}(文档 §9.4) +//! +//! - 不存在 / 停用 / 过期 → 统一 404(不泄露具体原因) +//! - max_ips 超限 → 403 +//! - 同一 IP 每分钟 30 次限速 → 429 +//! - 头:Content-Type 由 render 决定;Profile-Update-Interval: 6;Subscription-Userinfo +//! - result 字段:ok / expired / disabled / ip_limit / not_found / rate_limited + +use actix_web::{web, HttpRequest, HttpResponse}; +use serde::Deserialize; + +use crate::error::AppResult; +use crate::models::Subscription; +use crate::services::export::render_subscription; +use crate::state::AppState; +use crate::util::{client_ip, now_str}; + +pub fn routes(cfg: &mut web::ServiceConfig) { + cfg.route("/sub/{token}", web::get().to(serve)); +} + +#[derive(Deserialize)] +struct SubQ { + format: Option, +} + +// ============ P13:订阅导出 60 秒内存缓存 ============ + +/// 缓存 key = (subscription_id, format, template_id, 条目集合版本) +#[derive(Hash, Eq, PartialEq, Clone)] +pub struct SubExportKey { + pub sub_id: i64, + pub format: String, + pub template_id: Option, + pub entries_ver: String, +} + +#[derive(Clone)] +pub struct CachedExport { + pub content: String, + pub content_type: String, + pub ext: String, + pub at: std::time::Instant, +} + +const EXPORT_CACHE_TTL: std::time::Duration = std::time::Duration::from_secs(60); +const EXPORT_CACHE_MAX: usize = 500; + +/// 条目集合版本:任一张条目表 / 订阅本身 / 模板有更新都会变化 +async fn entries_version(pool: &sqlx::SqlitePool, sub: &Subscription) -> String { + let v: Option = sqlx::query_scalar( + "SELECT MAX(u) FROM ( + SELECT MAX(updated_at) AS u FROM proxy_rules UNION ALL + SELECT MAX(updated_at) FROM port_forwards UNION ALL + SELECT MAX(updated_at) FROM tunnels UNION ALL + SELECT updated_at FROM subscriptions WHERE id = ? UNION ALL + SELECT MAX(updated_at) FROM sub_templates + )", + ) + .bind(sub.id) + .fetch_one(pool) + .await + .unwrap_or(None); + v.unwrap_or_default() +} + +async fn serve( + state: web::Data, + req: HttpRequest, + path: web::Path, + q: web::Query, +) -> AppResult { + let token = path.into_inner(); + let sub: Option = sqlx::query_as("SELECT * FROM subscriptions WHERE token = ?") + .bind(&token) + .fetch_optional(&state.pool) + .await?; + let ip = client_ip(&req, &state.cfg.server.trusted_proxies); + let user_agent = req + .headers() + .get("user-agent") + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_string(); + + let sub = match sub { + Some(s) => s, + None => return Ok(plain_404()), + }; + let fmt = q + .format + .as_deref() + .unwrap_or(&sub.default_format) + .to_string(); + + // 同一 IP 每分钟 30 次限速 + let recent: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM subscription_logs + WHERE subscription_id = ? AND client_ip = ? + AND accessed_at > datetime('now', '-1 minute')", + ) + .bind(sub.id) + .bind(&ip) + .fetch_one(&state.pool) + .await?; + if recent >= 30 { + log_access(&state, sub.id, &ip, &user_agent, &fmt, "rate_limited").await; + return Ok(HttpResponse::TooManyRequests() + .content_type("text/plain; charset=utf-8") + .body("请求过于频繁,请稍后再试")); + } + + // 不存在 / 停用 / 过期 → 统一 404,不泄露具体原因 + let now = now_str(); + if sub.enabled != 1 { + log_access(&state, sub.id, &ip, &user_agent, &fmt, "disabled").await; + return Ok(plain_404()); + } + if let Some(ea) = &sub.expires_at { + if ea.as_str() < now.as_str() { + log_access(&state, sub.id, &ip, &user_agent, &fmt, "expired").await; + return Ok(plain_404()); + } + } + + // max_ips 检查:24h 内不同 IP(排除当前 IP)>= max_ips → 403 + if let Some(max_ips) = sub.max_ips { + let ips: Vec> = sqlx::query_scalar( + "SELECT DISTINCT client_ip FROM subscription_logs + WHERE subscription_id = ? AND result = 'ok' + AND accessed_at > datetime('now', '-1 day')", + ) + .bind(sub.id) + .fetch_all(&state.pool) + .await?; + let others = ips + .into_iter() + .flatten() + .filter(|x| x != &ip) + .count() as i64; + if others >= max_ips { + log_access(&state, sub.id, &ip, &user_agent, &fmt, "ip_limit").await; + // P12:触发 IP 限制告警(冷却在规则内,默认 1 小时) + let base = state.cfg.server.public_base_url.clone(); + let _ = crate::services::alert::emit( + &state.pool, + crate::services::alert::AlertDraft::sub_ip_limit(sub.id, &sub.name, max_ips, &base), + ) + .await; + return Ok(HttpResponse::Forbidden() + .content_type("text/plain; charset=utf-8") + .body(format!("该订阅同时在线 IP 数已达上限({max_ips})"))); + } + } + + let rendered = { + let key = SubExportKey { + sub_id: sub.id, + format: fmt.clone(), + template_id: sub.template_id, + entries_ver: entries_version(&state.pool, &sub).await, + }; + let hit = { + let cache = state.sub_export_cache.lock().await; + cache.get(&key).filter(|c| c.at.elapsed() < EXPORT_CACHE_TTL).cloned() + }; + match hit { + Some(c) => c, + None => { + let r = match render_subscription(&state.pool, &sub, &fmt).await { + Ok(r) => r, + Err(_) => { + log_access(&state, sub.id, &ip, &user_agent, &fmt, "render_error").await; + return Ok(HttpResponse::BadRequest() + .content_type("text/plain; charset=utf-8") + .body("订阅渲染失败")); + } + }; + let cached = CachedExport { + content: r.content, + content_type: r.content_type, + ext: r.ext, + at: std::time::Instant::now(), + }; + let mut cache = state.sub_export_cache.lock().await; + if cache.len() >= EXPORT_CACHE_MAX { + // 简单淘汰最旧的一批 + let mut keys: Vec<_> = cache.iter().map(|(k, v)| (v.at, k.clone())).collect(); + keys.sort_by_key(|(at, _)| *at); + for (_, k) in keys.into_iter().take(EXPORT_CACHE_MAX / 2) { + cache.remove(&k); + } + } + cache.insert(key, cached.clone()); + cached + } + } + }; + + // 访问统计 + 日志(失败不影响响应) + let pool = state.pool.clone(); + let (sid, lip, ua, f) = (sub.id, ip.clone(), user_agent.clone(), fmt.clone()); + tokio::spawn(async move { + let _ = sqlx::query( + "UPDATE subscriptions SET access_count = access_count + 1, last_access_at = datetime('now') WHERE id = ?", + ) + .bind(sid) + .execute(&pool) + .await; + let _ = sqlx::query( + "INSERT INTO subscription_logs (subscription_id, client_ip, user_agent, format, result) VALUES (?, ?, ?, ?, 'ok')", + ) + .bind(sid) + .bind(&lip) + .bind(ua.chars().take(300).collect::()) + .bind(&f) + .execute(&pool) + .await; + }); + + let filename = format!("{}.{}", sanitize_filename(&sub.name), rendered.ext); + let userinfo = userinfo_header(&state.pool, &sub).await; + Ok(HttpResponse::Ok() + .content_type(rendered.content_type) + .insert_header(("Profile-Update-Interval", "6")) + .insert_header(("Subscription-Userinfo", userinfo)) + .insert_header(( + "Content-Disposition", + format!("attachment; filename=\"{filename}\""), + )) + .body(rendered.content)) +} + +async fn userinfo_header(pool: &sqlx::SqlitePool, sub: &Subscription) -> String { + let expire = sub + .expires_at + .as_deref() + .and_then(|s| { + chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S") + .ok() + .map(|dt| dt.and_utc().timestamp()) + }) + .unwrap_or(0); + // 有配额的条目才计入 upload/download/total(§13.6) + let refs = crate::services::export::resolve_entries(pool, sub) + .await + .unwrap_or_default(); + let mut upload = 0i64; + let mut download = 0i64; + let mut total = 0i64; + for r in &refs { + let q: Option<(i64, i64)> = sqlx::query_as( + "SELECT used_bytes, quota_bytes FROM entry_quotas + WHERE entry_type = ? AND entry_id = ? AND quota_bytes > 0", + ) + .bind(&r.entry_type) + .bind(r.entry_id) + .fetch_optional(pool) + .await + .ok() + .flatten(); + if let Some((used, quota)) = q { + download += used; + total += quota; + } + } + format!("upload={upload}; download={download}; total={total}; expire={expire}") +} + +async fn log_access( + state: &web::Data, + sub_id: i64, + ip: &str, + ua: &str, + fmt: &str, + result: &str, +) { + let _ = sqlx::query( + "INSERT INTO subscription_logs (subscription_id, client_ip, user_agent, format, result) VALUES (?, ?, ?, ?, ?)", + ) + .bind(sub_id) + .bind(ip) + .bind(ua.chars().take(300).collect::()) + .bind(fmt) + .bind(result) + .execute(&state.pool) + .await; +} + +fn plain_404() -> HttpResponse { + HttpResponse::NotFound() + .content_type("text/plain; charset=utf-8") + .body("订阅不存在或已失效") +} + +fn sanitize_filename(s: &str) -> String { + s.chars() + .map(|c| { + if c.is_alphanumeric() || c == '-' || c == '_' || c == '.' { + c + } else { + '_' + } + }) + .collect::() + .chars() + .take(60) + .collect() +} diff --git a/src/handlers/subscriptions.rs b/src/handlers/subscriptions.rs new file mode 100644 index 0000000..8ae5046 --- /dev/null +++ b/src/handlers/subscriptions.rs @@ -0,0 +1,825 @@ +//! 订阅管理(文档 §6.9 / §9):CRUD、范围配置、条目/规则管理、导出预览、token 轮换。 + +use actix_web::{web, HttpRequest, HttpResponse}; +use serde::Deserialize; + +use crate::audit::audit; +use crate::error::{AppError, AppResult}; +use crate::middleware::auth_user; +use crate::models::{EntryRef, SubRuleInput, Subscription, SubscriptionUpsert}; +use crate::services::export::{render_subscription, resolve_entries, validate_format}; +use crate::state::AppState; +use crate::util::{calc_expires, client_ip, random_token, table_of}; + +/// P13:viewer 不可见订阅链接 → token 置空 +fn hide_token_for_viewer(sub: &mut Subscription, is_viewer: bool) { + if is_viewer { + sub.token = String::new(); + } +} + +pub fn routes(cfg: &mut web::ServiceConfig) { + cfg.service( + web::scope("/api/subscriptions") + .route("", web::get().to(list)) + .route("", web::post().to(create)) + .route("/batch_delete", web::post().to(batch_delete)) + .route("/resolve_preview", web::post().to(resolve_preview)) + .route("/{id}", web::get().to(detail)) + .route("/{id}", web::put().to(update)) + .route("/{id}", web::delete().to(delete)) + .route("/{id}/toggle", web::post().to(toggle)) + .route("/{id}/rotate_token", web::post().to(rotate_token)) + .route("/{id}/extend", web::post().to(extend)) + .route("/{id}/entries", web::get().to(entries_list)) + .route("/{id}/entries", web::post().to(entries_append)) + .route("/{id}/entries", web::put().to(entries_replace)) + .route("/{id}/entries/remove", web::post().to(entries_remove)) + .route("/{id}/resolved", web::get().to(resolved)) + .route("/{id}/stats", web::get().to(stats)) + .route("/{id}/preview", web::get().to(preview)), + ); +} + +#[derive(Deserialize)] +struct ListQ { + page: Option, + page_size: Option, + q: Option, + enabled: Option, +} + +async fn list( + state: web::Data, + req: HttpRequest, + q: web::Query, +) -> AppResult { + let is_viewer = !auth_user(&req)?.is_admin(); + let page = q.page.unwrap_or(1).max(1); + let page_size = q.page_size.unwrap_or(20).clamp(1, 200); + let offset = (page - 1) * page_size; + let mut cond = "1=1".to_string(); + if let Some(en) = q.enabled { + cond.push_str(&format!(" AND enabled = {en}")); + } + let like = q.q.as_deref().map(|k| format!("%{}%", k.trim())); + let mut list_sql = format!("SELECT * FROM subscriptions WHERE {cond}"); + let mut count_sql = format!("SELECT COUNT(*) FROM subscriptions WHERE {cond}"); + if like.is_some() { + list_sql.push_str(" AND name LIKE ?"); + count_sql.push_str(" AND name LIKE ?"); + } + list_sql.push_str(" ORDER BY id DESC LIMIT ? OFFSET ?"); + let mut lq = sqlx::query_as::<_, Subscription>(&list_sql); + let mut cq = sqlx::query_scalar::<_, i64>(&count_sql); + if let Some(kw) = &like { + lq = lq.bind(kw); + cq = cq.bind(kw); + } + let mut rows = lq.bind(page_size).bind(offset).fetch_all(&state.pool).await?; + if is_viewer { + for r in &mut rows { + hide_token_for_viewer(r, true); + } + } + let total = cq.fetch_one(&state.pool).await?; + Ok(HttpResponse::Ok().json(serde_json::json!({ + "items": rows, "total": total, "page": page, "page_size": page_size, + }))) +} + +fn validate_upsert(b: &SubscriptionUpsert) -> AppResult<()> { + if b.name.trim().is_empty() || b.name.chars().count() > 100 { + return Err(AppError::bad("名称不能为空且不超过 100 字符")); + } + if !matches!(b.scope.as_str(), "single" | "custom" | "all" | "rule") { + return Err(AppError::bad("scope 只能是 single/custom/all/rule")); + } + validate_format(&b.default_format)?; + if let Some(tid) = b.template_id { + if tid <= 0 { + return Err(AppError::bad("template_id 非法")); + } + } + if let Some(m) = b.max_ips { + if m < 0 { + return Err(AppError::bad("max_ips 不能为负数")); + } + } + for e in &b.entries { + table_of(&e.entry_type)?; + } + for r in &b.rules { + if !matches!(r.kind.as_str(), "group" | "tag" | "node" | "type") { + return Err(AppError::bad(format!("规则 kind 非法:{}", r.kind))); + } + if !matches!(r.mode.as_str(), "include" | "exclude") { + return Err(AppError::bad(format!("规则 mode 非法:{}", r.mode))); + } + } + Ok(()) +} + +async fn write_entries_rules( + tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>, + sub_id: i64, + entries: &[EntryRef], + rules: &[SubRuleInput], +) -> AppResult<()> { + for (i, e) in entries.iter().enumerate() { + // 条目存在性校验 + let table = table_of(&e.entry_type)?; + let n: i64 = sqlx::query_scalar(&format!("SELECT COUNT(*) FROM {table} WHERE id = ?")) + .bind(e.entry_id) + .fetch_one(&mut **tx) + .await?; + if n == 0 { + return Err(AppError::bad(format!( + "条目不存在:{} #{}", + e.entry_type, e.entry_id + ))); + } + sqlx::query( + "INSERT INTO subscription_entries (subscription_id, entry_type, entry_id, sort_order) + VALUES (?, ?, ?, ?)", + ) + .bind(sub_id) + .bind(&e.entry_type) + .bind(e.entry_id) + .bind(i as i64) + .execute(&mut **tx) + .await?; + } + for r in rules { + sqlx::query( + "INSERT INTO subscription_rules (subscription_id, kind, value, mode) + VALUES (?, ?, ?, ?)", + ) + .bind(sub_id) + .bind(&r.kind) + .bind(&r.value) + .bind(&r.mode) + .execute(&mut **tx) + .await?; + } + Ok(()) +} + +async fn create( + state: web::Data, + req: HttpRequest, + body: web::Json, +) -> AppResult { + let user = auth_user(&req)?; + let ip = client_ip(&req, &state.cfg.server.trusted_proxies); + let b = body.into_inner(); + validate_upsert(&b)?; + let expires = calc_expires(&b.expire_preset, b.expires_at.as_deref())?; + let token = random_token(32); + + let mut tx = state.pool.begin().await?; + let id: i64 = sqlx::query_scalar( + "INSERT INTO subscriptions + (user_id, token, name, description, scope, default_format, template_id, + enabled, expires_at, max_ips) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id", + ) + .bind(Some(user.id)) + .bind(&token) + .bind(b.name.trim()) + .bind(b.description.as_deref().map(str::trim).filter(|s| !s.is_empty())) + .bind(&b.scope) + .bind(&b.default_format) + .bind(b.template_id) + .bind(b.enabled.unwrap_or(true) as i64) + .bind(&expires) + .bind(b.max_ips) + .fetch_one(&mut *tx) + .await?; + write_entries_rules(&mut tx, id, &b.entries, &b.rules).await?; + tx.commit().await?; + + audit( + &state.pool, Some(user.id), &user.username, "create_subscription", + Some("subscription"), Some(id), None, + serde_json::json!({"name": b.name, "scope": b.scope}), &ip, + ) + .await; + let sub = get_sub(&state.pool, id).await?; + Ok(HttpResponse::Ok().json(crate::models::ApiResp::ok(sub))) +} + +async fn get_sub(pool: &sqlx::SqlitePool, id: i64) -> AppResult { + sqlx::query_as("SELECT * FROM subscriptions WHERE id = ?") + .bind(id) + .fetch_optional(pool) + .await? + .ok_or_else(|| AppError::not_found("订阅不存在")) +} + +async fn detail( + state: web::Data, + req: HttpRequest, + path: web::Path, +) -> AppResult { + let is_viewer = !auth_user(&req)?.is_admin(); + let id = path.into_inner(); + let mut sub = get_sub(&state.pool, id).await?; + hide_token_for_viewer(&mut sub, is_viewer); + let entries: Vec<(String, i64, i64)> = sqlx::query_as( + "SELECT entry_type, entry_id, sort_order FROM subscription_entries + WHERE subscription_id = ? ORDER BY sort_order, id", + ) + .bind(id) + .fetch_all(&state.pool) + .await?; + let rules: Vec = + sqlx::query_as("SELECT * FROM subscription_rules WHERE subscription_id = ? ORDER BY id") + .bind(id) + .fetch_all(&state.pool) + .await?; + let resolved = resolve_entries(&state.pool, &sub).await?; + Ok(HttpResponse::Ok().json(serde_json::json!({ + "subscription": sub, + "entries": entries.iter().map(|(t, i, so)| serde_json::json!({ + "entry_type": t, "entry_id": i, "sort_order": so, + })).collect::>(), + "rules": rules, + "resolved_count": resolved.len(), + }))) +} + +async fn update( + state: web::Data, + req: HttpRequest, + path: web::Path, + body: web::Json, +) -> AppResult { + let user = auth_user(&req)?; + let ip = client_ip(&req, &state.cfg.server.trusted_proxies); + let id = path.into_inner(); + let b = body.into_inner(); + let cur = get_sub(&state.pool, id).await?; + validate_upsert(&b)?; + let expires = calc_expires(&b.expire_preset, b.expires_at.as_deref())?; + + // 若因 expired 被停用且新到期在未来,则自动恢复 + let now = now_str(); + let mut enabled = b.enabled.unwrap_or(cur.enabled == 1) as i64; + let mut reason: Option = cur.disabled_reason.clone(); + if cur.enabled == 0 && cur.disabled_reason.as_deref() == Some("expired") { + let future = expires.as_deref().map(|e| e > now.as_str()).unwrap_or(true); + if future { + enabled = 1; + reason = None; + } + } + + let mut tx = state.pool.begin().await?; + sqlx::query( + "UPDATE subscriptions SET name = ?, description = ?, scope = ?, default_format = ?, + template_id = ?, enabled = ?, disabled_reason = ?, expires_at = ?, max_ips = ?, + updated_at = datetime('now') WHERE id = ?", + ) + .bind(b.name.trim()) + .bind(b.description.as_deref().map(str::trim).filter(|s| !s.is_empty())) + .bind(&b.scope) + .bind(&b.default_format) + .bind(b.template_id) + .bind(enabled) + .bind(&reason) + .bind(&expires) + .bind(b.max_ips) + .bind(id) + .execute(&mut *tx) + .await?; + sqlx::query("DELETE FROM subscription_entries WHERE subscription_id = ?") + .bind(id) + .execute(&mut *tx) + .await?; + sqlx::query("DELETE FROM subscription_rules WHERE subscription_id = ?") + .bind(id) + .execute(&mut *tx) + .await?; + write_entries_rules(&mut tx, id, &b.entries, &b.rules).await?; + tx.commit().await?; + + audit( + &state.pool, Some(user.id), &user.username, "update_subscription", + Some("subscription"), Some(id), None, + serde_json::json!({"name": b.name}), &ip, + ) + .await; + Ok(HttpResponse::Ok().json(crate::models::ApiResp::ok( + get_sub(&state.pool, id).await?, + ))) +} + +async fn delete( + state: web::Data, + req: HttpRequest, + path: web::Path, +) -> AppResult { + let user = auth_user(&req)?; + let ip = client_ip(&req, &state.cfg.server.trusted_proxies); + let id = path.into_inner(); + let sub = get_sub(&state.pool, id).await?; + let mut tx = state.pool.begin().await?; + sqlx::query("DELETE FROM subscription_entries WHERE subscription_id = ?") + .bind(id) + .execute(&mut *tx) + .await?; + sqlx::query("DELETE FROM subscription_rules WHERE subscription_id = ?") + .bind(id) + .execute(&mut *tx) + .await?; + sqlx::query("DELETE FROM subscriptions WHERE id = ?") + .bind(id) + .execute(&mut *tx) + .await?; + tx.commit().await?; + audit( + &state.pool, Some(user.id), &user.username, "delete_subscription", + Some("subscription"), Some(id), None, + serde_json::json!({"name": sub.name}), &ip, + ) + .await; + Ok(HttpResponse::Ok().json(crate::models::ApiResp::ok(serde_json::json!({"deleted": 1})))) +} + +#[derive(Deserialize)] +struct IdsReq { + ids: Vec, +} + +async fn batch_delete( + state: web::Data, + req: HttpRequest, + body: web::Json, +) -> AppResult { + let user = auth_user(&req)?; + let ip = client_ip(&req, &state.cfg.server.trusted_proxies); + if body.ids.is_empty() { + return Err(AppError::bad("ids 不能为空")); + } + let mut tx = state.pool.begin().await?; + let mut n = 0u64; + for chunk in body.ids.chunks(500) { + let ph = vec!["?"; chunk.len()].join(","); + let sql = format!("DELETE FROM subscription_entries WHERE subscription_id IN ({ph})"); + let mut q = sqlx::query(&sql); + for id in chunk { + q = q.bind(id); + } + q.execute(&mut *tx).await?; + let sql = format!("DELETE FROM subscription_rules WHERE subscription_id IN ({ph})"); + let mut q = sqlx::query(&sql); + for id in chunk { + q = q.bind(id); + } + q.execute(&mut *tx).await?; + let sql = format!("DELETE FROM subscriptions WHERE id IN ({ph})"); + let mut q = sqlx::query(&sql); + for id in chunk { + q = q.bind(id); + } + n += q.execute(&mut *tx).await?.rows_affected(); + } + tx.commit().await?; + audit( + &state.pool, Some(user.id), &user.username, "batch_delete_subscription", + Some("subscription"), None, None, + serde_json::json!({"ids": body.ids, "deleted": n}), &ip, + ) + .await; + Ok(HttpResponse::Ok().json(crate::models::ApiResp::ok(serde_json::json!({"deleted": n})))) +} + +#[derive(Deserialize)] +struct ToggleReq { + enabled: bool, +} + +async fn toggle( + state: web::Data, + req: HttpRequest, + path: web::Path, + body: web::Json, +) -> AppResult { + let user = auth_user(&req)?; + let ip = client_ip(&req, &state.cfg.server.trusted_proxies); + let id = path.into_inner(); + let sub = get_sub(&state.pool, id).await?; + if body.enabled { + sqlx::query( + "UPDATE subscriptions SET enabled = 1, disabled_reason = NULL, updated_at = datetime('now') WHERE id = ?", + ) + .bind(id).execute(&state.pool).await?; + } else { + sqlx::query( + "UPDATE subscriptions SET enabled = 0, disabled_reason = 'manual', updated_at = datetime('now') WHERE id = ?", + ) + .bind(id).execute(&state.pool).await?; + } + audit( + &state.pool, Some(user.id), &user.username, + if body.enabled { "enable_subscription" } else { "disable_subscription" }, + Some("subscription"), Some(id), None, + serde_json::json!({"name": sub.name}), &ip, + ) + .await; + Ok(HttpResponse::Ok().json(crate::models::ApiResp::ok( + get_sub(&state.pool, id).await?, + ))) +} + +/// 重置 token(旧链接立即失效) +async fn rotate_token( + state: web::Data, + req: HttpRequest, + path: web::Path, +) -> AppResult { + let user = auth_user(&req)?; + let ip = client_ip(&req, &state.cfg.server.trusted_proxies); + let id = path.into_inner(); + let sub = get_sub(&state.pool, id).await?; + let token = random_token(32); + sqlx::query("UPDATE subscriptions SET token = ?, updated_at = datetime('now') WHERE id = ?") + .bind(&token) + .bind(id) + .execute(&state.pool) + .await?; + audit( + &state.pool, Some(user.id), &user.username, "rotate_sub_token", + Some("subscription"), Some(id), None, + serde_json::json!({"name": sub.name}), &ip, + ) + .await; + let base = state.cfg.server.public_base_url.trim_end_matches('/'); + Ok(HttpResponse::Ok().json(crate::models::ApiResp::ok(serde_json::json!({ + "token": token, "url": format!("{base}/sub/{token}"), + })))) +} + +#[derive(Deserialize)] +struct ExtendReq { + days: i64, +} + +/// 续期:在 max(当前到期, 现在) 上累加;永久订阅返回提示 +async fn extend( + state: web::Data, + req: HttpRequest, + path: web::Path, + body: web::Json, +) -> AppResult { + let user = auth_user(&req)?; + let ip = client_ip(&req, &state.cfg.server.trusted_proxies); + let id = path.into_inner(); + if body.days <= 0 || body.days > 3650 { + return Err(AppError::bad("days 须在 1~3650 之间")); + } + let sub = get_sub(&state.pool, id).await?; + if sub.expires_at.is_none() { + return Ok(HttpResponse::Ok().json(crate::models::ApiResp::ok(serde_json::json!({ + "message": "永久订阅无需续期", + })))); + } + let now = now_str(); + let base = sub.expires_at.as_deref().unwrap().max(now.as_str()); + let new_exp = chrono::NaiveDateTime::parse_from_str(base, "%Y-%m-%d %H:%M:%S") + .map_err(|_| AppError::internal("expires_at 格式错误"))? + + chrono::Duration::days(body.days); + let new_exp = new_exp.format("%Y-%m-%d %H:%M:%S").to_string(); + // 续期后若曾因过期停用,自动恢复 + let (enabled, reason) = + if sub.enabled == 0 && sub.disabled_reason.as_deref() == Some("expired") { + (1i64, None) + } else { + (sub.enabled, sub.disabled_reason.clone()) + }; + sqlx::query( + "UPDATE subscriptions SET expires_at = ?, enabled = ?, disabled_reason = ?, + updated_at = datetime('now') WHERE id = ?", + ) + .bind(&new_exp) + .bind(enabled) + .bind(&reason) + .bind(id) + .execute(&state.pool) + .await?; + audit( + &state.pool, Some(user.id), &user.username, "extend_subscription", + Some("subscription"), Some(id), None, + serde_json::json!({"name": sub.name, "days": body.days, "expires_at": new_exp}), &ip, + ) + .await; + Ok(HttpResponse::Ok().json(crate::models::ApiResp::ok(serde_json::json!({ + "expires_at": new_exp, + })))) +} + +// ============ 手工勾选条目管理 ============ + +async fn entry_detail_json( + pool: &sqlx::SqlitePool, + e: &EntryRef, +) -> AppResult { + let table = table_of(&e.entry_type)?; + let row: Option<(String, i64, i64)> = sqlx::query_as(&format!( + "SELECT name, node_id, enabled FROM {table} WHERE id = ?" + )) + .bind(e.entry_id) + .fetch_optional(pool) + .await?; + match row { + Some((name, node_id, enabled)) => Ok(serde_json::json!({ + "entry_type": e.entry_type, "entry_id": e.entry_id, + "name": name, "node_id": node_id, "enabled": enabled, + })), + None => Ok(serde_json::json!({ + "entry_type": e.entry_type, "entry_id": e.entry_id, "missing": true, + })), + } +} + +async fn entries_list( + state: web::Data, + path: web::Path, +) -> AppResult { + let id = path.into_inner(); + get_sub(&state.pool, id).await?; + let rows: Vec<(String, i64, i64)> = sqlx::query_as( + "SELECT entry_type, entry_id, sort_order FROM subscription_entries + WHERE subscription_id = ? ORDER BY sort_order, id", + ) + .bind(id) + .fetch_all(&state.pool) + .await?; + let mut out = Vec::new(); + for (et, eid, _so) in rows { + out.push(entry_detail_json(&state.pool, &EntryRef { entry_type: et, entry_id: eid }).await?); + } + Ok(HttpResponse::Ok().json(crate::models::ApiResp::ok(out))) +} + +#[derive(Deserialize)] +struct EntriesBody { + entries: Vec, +} + +async fn entries_append( + state: web::Data, + req: HttpRequest, + path: web::Path, + body: web::Json, +) -> AppResult { + let user = auth_user(&req)?; + let ip = client_ip(&req, &state.cfg.server.trusted_proxies); + let id = path.into_inner(); + get_sub(&state.pool, id).await?; + let mut tx = state.pool.begin().await?; + let max_so: Option = + sqlx::query_scalar("SELECT MAX(sort_order) FROM subscription_entries WHERE subscription_id = ?") + .bind(id) + .fetch_one(&mut *tx) + .await?; + let mut so = max_so.unwrap_or(-1) + 1; + for e in &body.entries { + table_of(&e.entry_type)?; + sqlx::query( + "INSERT OR IGNORE INTO subscription_entries + (subscription_id, entry_type, entry_id, sort_order) VALUES (?, ?, ?, ?)", + ) + .bind(id) + .bind(&e.entry_type) + .bind(e.entry_id) + .bind(so) + .execute(&mut *tx) + .await?; + so += 1; + } + tx.commit().await?; + audit( + &state.pool, Some(user.id), &user.username, "sub_entries_append", + Some("subscription"), Some(id), None, + serde_json::json!({"count": body.entries.len()}), &ip, + ) + .await; + Ok(HttpResponse::Ok().json(crate::models::ApiResp::ok(serde_json::json!({"ok": true})))) +} + +async fn entries_replace( + state: web::Data, + req: HttpRequest, + path: web::Path, + body: web::Json, +) -> AppResult { + let user = auth_user(&req)?; + let ip = client_ip(&req, &state.cfg.server.trusted_proxies); + let id = path.into_inner(); + get_sub(&state.pool, id).await?; + let mut tx = state.pool.begin().await?; + sqlx::query("DELETE FROM subscription_entries WHERE subscription_id = ?") + .bind(id) + .execute(&mut *tx) + .await?; + write_entries_rules(&mut tx, id, &body.entries, &[]).await?; + tx.commit().await?; + audit( + &state.pool, Some(user.id), &user.username, "sub_entries_replace", + Some("subscription"), Some(id), None, + serde_json::json!({"count": body.entries.len()}), &ip, + ) + .await; + Ok(HttpResponse::Ok().json(crate::models::ApiResp::ok(serde_json::json!({"ok": true})))) +} + +async fn entries_remove( + state: web::Data, + req: HttpRequest, + path: web::Path, + body: web::Json, +) -> AppResult { + let user = auth_user(&req)?; + let ip = client_ip(&req, &state.cfg.server.trusted_proxies); + let id = path.into_inner(); + get_sub(&state.pool, id).await?; + let mut tx = state.pool.begin().await?; + for e in &body.entries { + table_of(&e.entry_type)?; + sqlx::query( + "DELETE FROM subscription_entries + WHERE subscription_id = ? AND entry_type = ? AND entry_id = ?", + ) + .bind(id) + .bind(&e.entry_type) + .bind(e.entry_id) + .execute(&mut *tx) + .await?; + } + tx.commit().await?; + audit( + &state.pool, Some(user.id), &user.username, "sub_entries_remove", + Some("subscription"), Some(id), None, + serde_json::json!({"count": body.entries.len()}), &ip, + ) + .await; + Ok(HttpResponse::Ok().json(crate::models::ApiResp::ok(serde_json::json!({"ok": true})))) +} + +/// 当前实际命中的条目(scope=rule 时用于"命中 N 个"实时预览) +async fn resolved(state: web::Data, path: web::Path) -> AppResult { + let id = path.into_inner(); + let sub = get_sub(&state.pool, id).await?; + let refs = resolve_entries(&state.pool, &sub).await?; + let mut out = Vec::new(); + for r in &refs { + out.push(entry_detail_json(&state.pool, r).await?); + } + Ok(HttpResponse::Ok().json(crate::models::ApiResp::ok(serde_json::json!({ + "count": out.len(), "items": out, + })))) +} + +#[derive(Deserialize)] +struct PreviewDraft { + scope: String, + entries: Option>, + rules: Option>, +} + +/// 用未保存的草稿(scope + entries + rules)计算命中的条目 +async fn resolve_preview( + state: web::Data, + body: web::Json, +) -> AppResult { + let b = body.into_inner(); + if !matches!(b.scope.as_str(), "single" | "custom" | "all" | "rule") { + return Err(AppError::bad("scope 非法")); + } + // 用一个真实的临时订阅行做解析,算完即删 + let tmp_token = random_token(16); + let mut tx = state.pool.begin().await?; + let tmp_id: i64 = sqlx::query_scalar( + "INSERT INTO subscriptions (token, name, scope, default_format, enabled) + VALUES (?, '__preview__', ?, 'clash', 1) RETURNING id", + ) + .bind(&tmp_token) + .bind(&b.scope) + .fetch_one(&mut *tx) + .await?; + let entries = b.entries.unwrap_or_default(); + let rules = b.rules.unwrap_or_default(); + for (i, e) in entries.iter().enumerate() { + table_of(&e.entry_type)?; + sqlx::query( + "INSERT INTO subscription_entries (subscription_id, entry_type, entry_id, sort_order) + VALUES (?, ?, ?, ?)", + ) + .bind(tmp_id) + .bind(&e.entry_type) + .bind(e.entry_id) + .bind(i as i64) + .execute(&mut *tx) + .await?; + } + for r in &rules { + if !matches!(r.kind.as_str(), "group" | "tag" | "node" | "type") { + return Err(AppError::bad("规则 kind 非法")); + } + if !matches!(r.mode.as_str(), "include" | "exclude") { + return Err(AppError::bad("规则 mode 非法")); + } + sqlx::query( + "INSERT INTO subscription_rules (subscription_id, kind, value, mode) VALUES (?, ?, ?, ?)", + ) + .bind(tmp_id) + .bind(&r.kind) + .bind(&r.value) + .bind(&r.mode) + .execute(&mut *tx) + .await?; + } + tx.commit().await?; + let fake = Subscription { + id: tmp_id, + user_id: None, + token: tmp_token, + name: "__preview__".into(), + description: None, + scope: b.scope.clone(), + default_format: "clash".into(), + template_id: None, + enabled: 1, + disabled_reason: None, + expires_at: None, + max_ips: None, + access_count: 0, + last_access_at: None, + created_at: String::new(), + updated_at: String::new(), + }; + let refs = resolve_entries(&state.pool, &fake).await?; + // 清理临时行 + let _ = sqlx::query("DELETE FROM subscription_entries WHERE subscription_id = ?") + .bind(tmp_id).execute(&state.pool).await; + let _ = sqlx::query("DELETE FROM subscription_rules WHERE subscription_id = ?") + .bind(tmp_id).execute(&state.pool).await; + let _ = sqlx::query("DELETE FROM subscriptions WHERE id = ?") + .bind(tmp_id).execute(&state.pool).await; + Ok(HttpResponse::Ok().json(crate::models::ApiResp::ok(serde_json::json!({ + "count": refs.len(), "items": refs, + })))) +} + +/// 访问次数、最近访问、近 7 天趋势、不同 IP 数 +async fn stats(state: web::Data, path: web::Path) -> AppResult { + let id = path.into_inner(); + let sub = get_sub(&state.pool, id).await?; + let trend: Vec<(String, i64)> = sqlx::query_as( + "SELECT date(accessed_at) AS d, COUNT(*) FROM subscription_logs + WHERE subscription_id = ? AND accessed_at > datetime('now','-7 days') + GROUP BY d ORDER BY d", + ) + .bind(id) + .fetch_all(&state.pool) + .await?; + let distinct_ips: i64 = sqlx::query_scalar( + "SELECT COUNT(DISTINCT client_ip) FROM subscription_logs + WHERE subscription_id = ? AND accessed_at > datetime('now','-1 day')", + ) + .bind(id) + .fetch_one(&state.pool) + .await?; + Ok(HttpResponse::Ok().json(crate::models::ApiResp::ok(serde_json::json!({ + "access_count": sub.access_count, + "last_access_at": sub.last_access_at, + "distinct_ips_24h": distinct_ips, + "trend_7d": trend.iter().map(|(d, c)| serde_json::json!({"date": d, "count": c})).collect::>(), + })))) +} + +#[derive(Deserialize)] +struct PreviewQ { + format: Option, +} + +/// 后台预览导出内容(不计访问) +async fn preview( + state: web::Data, + path: web::Path, + q: web::Query, +) -> AppResult { + let id = path.into_inner(); + let sub = get_sub(&state.pool, id).await?; + let format = q.format.clone().unwrap_or_else(|| sub.default_format.clone()); + let r = render_subscription(&state.pool, &sub, &format).await?; + Ok(HttpResponse::Ok() + .content_type("text/plain; charset=utf-8") + .body(r.content)) +} + +fn now_str() -> String { + chrono::Utc::now().naive_utc().format("%Y-%m-%d %H:%M:%S").to_string() +} diff --git a/src/handlers/templates.rs b/src/handlers/templates.rs new file mode 100644 index 0000000..0397597 --- /dev/null +++ b/src/handlers/templates.rs @@ -0,0 +1,285 @@ +//! P5 —— 订阅模板管理(§12)。 +//! +//! - GET /api/templates +//! - POST /api/templates +//! - POST /api/templates/validate +//! - GET /api/templates/{id} +//! - PUT /api/templates/{id} +//! - DELETE /api/templates/{id} +//! - POST /api/templates/{id}/duplicate +//! - POST /api/templates/{id}/preview + +use actix_web::{HttpRequest, HttpResponse, web}; +use serde::Deserialize; + +use crate::audit::audit; +use crate::error::{AppError, AppResult}; +use crate::middleware::auth_user; +use crate::models::{ApiResp, SubTemplate}; +use crate::services::export::template::{preview_render, validate_template}; +use crate::state::AppState; +use crate::util::client_ip; + +fn validate_name(name: &str) -> AppResult<()> { + let t = name.trim(); + if t.is_empty() || t.chars().count() > 100 { + return Err(AppError::bad("名称不能为空且不超过 100 字符")); + } + Ok(()) +} + +pub fn routes(cfg: &mut web::ServiceConfig) { + cfg.service( + web::scope("/api/templates") + .route("", web::get().to(list)) + .route("", web::post().to(create)) + .route("/validate", web::post().to(validate)) + .route("/preview_raw", web::post().to(preview_raw)) + .route("/{id}", web::get().to(detail)) + .route("/{id}", web::put().to(update)) + .route("/{id}", web::delete().to(delete)) + .route("/{id}/duplicate", web::post().to(duplicate)) + .route("/{id}/preview", web::post().to(preview)), + ); +} + +#[derive(Deserialize)] +pub struct TemplateUpsert { + pub name: String, + pub description: Option, + pub format: String, + pub content: String, +} + +#[derive(Deserialize)] +pub struct ValidateReq { + pub format: String, + pub content: String, +} + +#[derive(serde::Serialize, sqlx::FromRow)] +struct TemplateListItem { + id: i64, + name: String, + description: Option, + format: String, + is_builtin: i64, + used_by: i64, + created_at: String, + updated_at: String, +} + +async fn list(state: web::Data) -> AppResult { + let items: Vec = sqlx::query_as( + r#"SELECT t.id, t.name, t.description, t.format, t.is_builtin, + (SELECT COUNT(*) FROM subscriptions s WHERE s.template_id = t.id) AS used_by, + t.created_at, t.updated_at + FROM sub_templates t ORDER BY t.is_builtin DESC, t.id"#, + ) + .fetch_all(&state.pool) + .await?; + Ok(HttpResponse::Ok().json(ApiResp::ok(items))) +} + +fn check_valid(format: &str, content: &str) -> AppResult<()> { + let errs = validate_template(content, format); + if errs.is_empty() { + Ok(()) + } else { + Err(AppError::bad(format!("模板校验失败:\n{}", errs.join("\n")))) + } +} + +async fn create( + state: web::Data, + req: HttpRequest, + body: web::Json, +) -> AppResult { + let user = auth_user(&req)?; + let ip = client_ip(&req, &state.cfg.server.trusted_proxies); + let b = body.into_inner(); + validate_name(&b.name)?; + check_valid(&b.format, &b.content)?; + + let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM sub_templates WHERE name = ?") + .bind(b.name.trim()) + .fetch_one(&state.pool) + .await?; + if n > 0 { + return Err(AppError::bad("模板名称已存在")); + } + let id: i64 = sqlx::query_scalar( + "INSERT INTO sub_templates (name, description, format, content, is_builtin) + VALUES (?, ?, ?, ?, 0) RETURNING id", + ) + .bind(b.name.trim()) + .bind(b.description.as_deref().map(str::trim).filter(|s| !s.is_empty())) + .bind(b.format.trim()) + .bind(b.content) + .fetch_one(&state.pool) + .await?; + + audit( + &state.pool, Some(user.id), &user.username, "create_template", + None, Some(id), None, serde_json::json!({"name": b.name}), &ip, + ) + .await; + let t = detail_one(&state.pool, id).await?; + Ok(HttpResponse::Ok().json(ApiResp::ok(t))) +} + +async fn validate(body: web::Json) -> AppResult { + let errs = validate_template(&body.content, &body.format); + Ok(HttpResponse::Ok().json(ApiResp::ok(serde_json::json!({ + "valid": errs.is_empty(), + "errors": errs, + })))) +} + +async fn detail_one(pool: &sqlx::SqlitePool, id: i64) -> AppResult { + let t: Option = + sqlx::query_as("SELECT * FROM sub_templates WHERE id = ?") + .bind(id) + .fetch_optional(pool) + .await?; + t.ok_or_else(|| AppError::not_found("模板不存在")) +} + +async fn detail(state: web::Data, path: web::Path) -> AppResult { + let t = detail_one(&state.pool, path.into_inner()).await?; + Ok(HttpResponse::Ok().json(ApiResp::ok(t))) +} + +async fn update( + state: web::Data, + req: HttpRequest, + path: web::Path, + body: web::Json, +) -> AppResult { + let user = auth_user(&req)?; + let ip = client_ip(&req, &state.cfg.server.trusted_proxies); + let id = path.into_inner(); + let b = body.into_inner(); + let old = detail_one(&state.pool, id).await?; + if old.is_builtin == 1 { + return Err(AppError::bad("内置模板不可修改,可复制为新模板后编辑")); + } + validate_name(&b.name)?; + check_valid(&b.format, &b.content)?; + let n: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM sub_templates WHERE name = ? AND id != ?") + .bind(b.name.trim()) + .bind(id) + .fetch_one(&state.pool) + .await?; + if n > 0 { + return Err(AppError::bad("模板名称已存在")); + } + sqlx::query( + "UPDATE sub_templates SET name = ?, description = ?, format = ?, content = ?, + updated_at = datetime('now') WHERE id = ?", + ) + .bind(b.name.trim()) + .bind(b.description.as_deref().map(str::trim).filter(|s| !s.is_empty())) + .bind(b.format.trim()) + .bind(&b.content) + .bind(id) + .execute(&state.pool) + .await?; + audit( + &state.pool, Some(user.id), &user.username, "update_template", + None, Some(id), None, serde_json::json!({"name": b.name}), &ip, + ) + .await; + Ok(HttpResponse::Ok().json(ApiResp::ok(detail_one(&state.pool, id).await?))) +} + +async fn delete( + state: web::Data, + req: HttpRequest, + path: web::Path, +) -> AppResult { + let user = auth_user(&req)?; + let ip = client_ip(&req, &state.cfg.server.trusted_proxies); + let id = path.into_inner(); + let old = detail_one(&state.pool, id).await?; + if old.is_builtin == 1 { + return Err(AppError::bad("内置模板不可删除")); + } + let used: Vec<(i64, String)> = sqlx::query_as( + "SELECT id, name FROM subscriptions WHERE template_id = ?", + ) + .bind(id) + .fetch_all(&state.pool) + .await?; + if !used.is_empty() { + let names: Vec = used.iter().map(|(_, n)| n.clone()).collect(); + return Err(AppError::bad(format!( + "有 {} 个订阅正在使用该模板,无法删除:{}", + used.len(), + names.join("、") + ))); + } + sqlx::query("DELETE FROM sub_templates WHERE id = ?") + .bind(id) + .execute(&state.pool) + .await?; + audit( + &state.pool, Some(user.id), &user.username, "delete_template", + None, Some(id), None, serde_json::json!({"name": old.name}), &ip, + ) + .await; + Ok(HttpResponse::Ok().json(ApiResp::ok(serde_json::json!({"deleted": id})))) +} + +async fn duplicate( + state: web::Data, + req: HttpRequest, + path: web::Path, +) -> AppResult { + let user = auth_user(&req)?; + let ip = client_ip(&req, &state.cfg.server.trusted_proxies); + let id = path.into_inner(); + let old = detail_one(&state.pool, id).await?; + let mut name = format!("{}(副本)", old.name); + let mut i = 2; + while sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM sub_templates WHERE name = ?") + .bind(&name) + .fetch_one(&state.pool) + .await? + > 0 + { + name = format!("{}(副本{i})", old.name); + i += 1; + } + let new_id: i64 = sqlx::query_scalar( + "INSERT INTO sub_templates (name, description, format, content, is_builtin) + VALUES (?, ?, ?, ?, 0) RETURNING id", + ) + .bind(&name) + .bind(old.description.as_deref()) + .bind(&old.format) + .bind(&old.content) + .fetch_one(&state.pool) + .await?; + audit( + &state.pool, Some(user.id), &user.username, "duplicate_template", + None, Some(new_id), None, serde_json::json!({"from": id, "name": name}), &ip, + ) + .await; + Ok(HttpResponse::Ok().json(ApiResp::ok(detail_one(&state.pool, new_id).await?))) +} + +async fn preview( + state: web::Data, + path: web::Path, +) -> AppResult { + let t = detail_one(&state.pool, path.into_inner()).await?; + let content = preview_render(&t.content, &t.format)?; + Ok(HttpResponse::Ok().json(ApiResp::ok(serde_json::json!({ "content": content })))) +} + +async fn preview_raw(body: web::Json) -> AppResult { + let content = preview_render(&body.content, &body.format)?; + Ok(HttpResponse::Ok().json(ApiResp::ok(serde_json::json!({ "content": content })))) +} diff --git a/src/handlers/tunnel.rs b/src/handlers/tunnel.rs new file mode 100644 index 0000000..aca9c54 --- /dev/null +++ b/src/handlers/tunnel.rs @@ -0,0 +1,444 @@ +use crate::audit::audit; +use crate::error::{AppError, AppResult}; +use crate::handlers::entries_common::{delete_entries_tx, get_entry, list_entries, set_entry_tags}; +use crate::middleware::auth_user; +use crate::models::{ApiResp, ListQuery}; +use crate::state::AppState; +use crate::util::{calc_expires, client_ip}; +use actix_web::{web, HttpRequest, HttpResponse}; +use serde::Deserialize; + +const ET: &str = "tunnel"; + +pub fn routes(cfg: &mut web::ServiceConfig) { + cfg.route("", web::get().to(list)) + .route("", web::post().to(create)) + .route("/{id}", web::get().to(detail)) + .route("/{id}", web::put().to(update)) + .route("/{id}", web::delete().to(delete)) + .route("/{id}/toggle", web::post().to(toggle)) + .route("/batch_delete", web::post().to(batch_delete)) + .route("/batch_toggle", web::post().to(batch_toggle)) + .route("/batch_move", web::post().to(batch_move)); +} + +#[derive(Deserialize)] +pub struct TunnelCreate { + pub node_id: i64, + pub name: String, + pub tunnel_type: Option, + pub local_addr: String, + pub remote_addr: String, + pub token: Option, + pub export_host: Option, + pub export_port: Option, + pub extra: Option, + pub remark: Option, + pub group_id: Option, + pub enabled: Option, + pub expire_preset: Option, + pub expires_at: Option, + pub tag_ids: Option>, +} + +#[derive(Deserialize)] +pub struct TunnelUpdate { + pub node_id: Option, + pub name: Option, + pub tunnel_type: Option, + pub local_addr: Option, + pub remote_addr: Option, + pub token: Option>, + pub export_host: Option>, + pub export_port: Option>, + pub extra: Option>, + pub remark: Option>, + pub group_id: Option>, + pub expire_preset: Option, + pub expires_at: Option>, + pub tag_ids: Option>, +} + +#[derive(Deserialize)] +pub struct ToggleReq { + pub enabled: bool, +} +#[derive(Deserialize)] +pub struct IdsReq { + pub ids: Vec, +} +#[derive(Deserialize)] +pub struct BatchToggleReq { + pub ids: Vec, + pub enabled: bool, +} +#[derive(Deserialize)] +pub struct BatchMoveReq { + pub ids: Vec, + pub node_id: i64, +} + +fn validate_name(name: &str) -> AppResult<()> { + let t = name.trim(); + if t.is_empty() || t.chars().count() > 100 { + return Err(AppError::bad("名称不能为空且不超过 100 字符")); + } + Ok(()) +} + +fn validate_addr(addr: &str, what: &str) -> AppResult<()> { + let t = addr.trim(); + if t.is_empty() || t.len() > 255 { + return Err(AppError::bad(format!("{what} 不能为空且不超过 255 字符"))); + } + Ok(()) +} + +async fn node_exists(pool: &sqlx::SqlitePool, node_id: i64) -> AppResult<()> { + let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM nodes WHERE id = ?") + .bind(node_id) + .fetch_one(pool) + .await?; + if n == 0 { + return Err(AppError::bad("节点不存在")); + } + Ok(()) +} + +async fn list( + state: web::Data, + query: web::Query, +) -> AppResult { + let page = list_entries(&state.pool, ET, &query).await?; + Ok(HttpResponse::Ok().json(ApiResp::ok(page))) +} + +async fn create( + state: web::Data, + req: HttpRequest, + body: web::Json, +) -> AppResult { + let user = auth_user(&req)?; + let ip = client_ip(&req, &state.cfg.server.trusted_proxies); + let b = body.into_inner(); + validate_name(&b.name)?; + validate_addr(&b.local_addr, "本地地址")?; + validate_addr(&b.remote_addr, "远端地址")?; + let mode = b.tunnel_type.unwrap_or_else(|| "tcp".into()); + if !matches!(mode.as_str(), "tcp" | "ws" | "wss" | "reverse") { + return Err(AppError::bad("tunnel_type 只能是 tcp/ws/wss/reverse")); + } + node_exists(&state.pool, b.node_id).await?; + let expires = calc_expires( + b.expire_preset.as_deref().unwrap_or("permanent"), + b.expires_at.as_deref(), + )?; + + let mut tx = state.pool.begin().await?; + let id: i64 = sqlx::query_scalar( + "INSERT INTO tunnels + (node_id, name, tunnel_type, local_addr, remote_addr, token, + export_host, export_port, extra, + remark, group_id, enabled, expires_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id", + ) + .bind(b.node_id) + .bind(b.name.trim()) + .bind(&mode) + .bind(b.local_addr.trim()) + .bind(b.remote_addr.trim()) + .bind(b.token.as_deref().map(str::trim).filter(|s| !s.is_empty())) + .bind(b.export_host.as_deref().map(str::trim).filter(|s| !s.is_empty())) + .bind(b.export_port) + .bind(b.extra.as_deref().map(str::trim).filter(|s| !s.is_empty())) + .bind(b.remark.as_deref().map(str::trim).filter(|s| !s.is_empty())) + .bind(b.group_id) + .bind(b.enabled.unwrap_or(true) as i64) + .bind(&expires) + .fetch_one(&mut *tx) + .await?; + if let Some(tags) = b.tag_ids.as_deref() { + set_entry_tags(&mut tx, ET, id, tags).await?; + } + tx.commit().await?; + + audit( + &state.pool, + Some(user.id), + &user.username, + "create_tunnel", + Some(ET), + Some(id), + None, + serde_json::json!({"name": b.name}), + &ip, + ) + .await; + crate::services::runtime::on_entries_changed(&state).await; + Ok(HttpResponse::Ok().json(ApiResp::ok(get_entry(&state.pool, ET, id).await?))) +} + +async fn detail(state: web::Data, path: web::Path) -> AppResult { + Ok(HttpResponse::Ok().json(ApiResp::ok(get_entry(&state.pool, ET, path.into_inner()).await?))) +} + +async fn update( + state: web::Data, + req: HttpRequest, + path: web::Path, + body: web::Json, +) -> AppResult { + let user = auth_user(&req)?; + let ip = client_ip(&req, &state.cfg.server.trusted_proxies); + let id = path.into_inner(); + let b = body.into_inner(); + + get_entry(&state.pool, ET, id).await?; + if let Some(n) = b.name.as_deref() { validate_name(n)?; } + if let Some(a) = b.local_addr.as_deref() { validate_addr(a, "本地地址")?; } + if let Some(a) = b.remote_addr.as_deref() { validate_addr(a, "远端地址")?; } + if let Some(m) = b.tunnel_type.as_deref() { + if !matches!(m, "tcp" | "ws") { + return Err(AppError::bad("tunnel_type 只能是 tcp/ws/wss/reverse")); + } + } + if let Some(nid) = b.node_id { node_exists(&state.pool, nid).await?; } + let expires: Option> = match (&b.expire_preset, &b.expires_at) { + (None, None) => None, + (preset, ea) => Some(calc_expires( + preset.as_deref().unwrap_or("permanent"), + ea.as_ref().and_then(|o| o.as_deref()), + )?), + }; + + let mut tx = state.pool.begin().await?; + macro_rules! set { + ($col:literal, $val:expr) => { + sqlx::query(concat!("UPDATE tunnels SET ", $col, " = ? WHERE id = ?")) + .bind($val) + .bind(id) + .execute(&mut *tx) + .await?; + }; + } + if let Some(v) = b.node_id { set!("node_id", v); } + if let Some(v) = b.name.as_deref() { set!("name", v.trim()); } + if let Some(v) = b.tunnel_type.as_deref() { set!("tunnel_type", v); } + if let Some(v) = b.local_addr.as_deref() { set!("local_addr", v.trim()); } + if let Some(v) = b.remote_addr.as_deref() { set!("remote_addr", v.trim()); } + + + + if let Some(v) = b.token.as_ref() { set!("token", v.as_deref().filter(|s| !s.is_empty())); } + + if let Some(v) = b.remark.as_ref() { set!("remark", v.as_deref().map(str::trim).filter(|s| !s.is_empty())); } + if let Some(v) = b.export_host.as_ref() { set!("export_host", v.as_deref().map(str::trim).filter(|s| !s.is_empty())); } + if let Some(v) = b.export_port { set!("export_port", v); } + if let Some(v) = b.extra.as_ref() { set!("extra", v.as_deref().map(str::trim).filter(|s| !s.is_empty())); } + if let Some(v) = b.group_id { set!("group_id", v); } + if let Some(v) = expires { + let exp: Option = v.clone(); + set!("expires_at", v); + super::entries_common::recover_expired_if_future(&mut tx, ET, id, exp.as_deref()).await?; + } + sqlx::query("UPDATE tunnels SET updated_at = datetime('now') WHERE id = ?") + .bind(id) + .execute(&mut *tx) + .await?; + if let Some(tags) = b.tag_ids.as_deref() { + set_entry_tags(&mut tx, ET, id, tags).await?; + } + tx.commit().await?; + + audit( + &state.pool, + Some(user.id), + &user.username, + "update_tunnel", + Some(ET), + Some(id), + None, + serde_json::json!({}), + &ip, + ) + .await; + crate::services::runtime::on_entries_changed(&state).await; + Ok(HttpResponse::Ok().json(ApiResp::ok(get_entry(&state.pool, ET, id).await?))) +} + +async fn delete( + state: web::Data, + req: HttpRequest, + path: web::Path, +) -> AppResult { + let user = auth_user(&req)?; + let ip = client_ip(&req, &state.cfg.server.trusted_proxies); + let id = path.into_inner(); + get_entry(&state.pool, ET, id).await?; + let mut tx = state.pool.begin().await?; + let n = delete_entries_tx(&mut tx, ET, &[id]).await?; + tx.commit().await?; + audit( + &state.pool, + Some(user.id), + &user.username, + "delete_tunnel", + Some(ET), + Some(id), + None, + serde_json::json!({}), + &ip, + ) + .await; + crate::services::runtime::on_entries_changed(&state).await; + Ok(HttpResponse::Ok().json(ApiResp::ok(serde_json::json!({"deleted": n})))) +} + +async fn toggle( + state: web::Data, + req: HttpRequest, + path: web::Path, + body: web::Json, +) -> AppResult { + let user = auth_user(&req)?; + let ip = client_ip(&req, &state.cfg.server.trusted_proxies); + let id = path.into_inner(); + get_entry(&state.pool, ET, id).await?; + if body.enabled { + sqlx::query( + "UPDATE tunnels SET enabled = 1, disabled_reason = NULL, updated_at = datetime('now') WHERE id = ?", + ) + .bind(id) + .execute(&state.pool) + .await?; + } else { + sqlx::query( + "UPDATE tunnels SET enabled = 0, disabled_reason = 'manual', updated_at = datetime('now') WHERE id = ?", + ) + .bind(id) + .execute(&state.pool) + .await?; + } + audit( + &state.pool, + Some(user.id), + &user.username, + if body.enabled { "enable_tunnel" } else { "disable_tunnel" }, + Some(ET), + Some(id), + None, + serde_json::json!({}), + &ip, + ) + .await; + crate::services::runtime::on_entries_changed(&state).await; + Ok(HttpResponse::Ok().json(ApiResp::ok(get_entry(&state.pool, ET, id).await?))) +} + +async fn batch_delete( + state: web::Data, + req: HttpRequest, + body: web::Json, +) -> AppResult { + let user = auth_user(&req)?; + let ip = client_ip(&req, &state.cfg.server.trusted_proxies); + if body.ids.is_empty() { + return Err(AppError::bad("ids 不能为空")); + } + let mut tx = state.pool.begin().await?; + let n = delete_entries_tx(&mut tx, ET, &body.ids).await?; + tx.commit().await?; + audit( + &state.pool, + Some(user.id), + &user.username, + "batch_delete_tunnel", + Some(ET), + None, + None, + serde_json::json!({"ids": body.ids, "deleted": n}), + &ip, + ) + .await; + crate::services::runtime::on_entries_changed(&state).await; + Ok(HttpResponse::Ok().json(ApiResp::ok(serde_json::json!({"deleted": n})))) +} + +async fn batch_toggle( + state: web::Data, + req: HttpRequest, + body: web::Json, +) -> AppResult { + let user = auth_user(&req)?; + let ip = client_ip(&req, &state.cfg.server.trusted_proxies); + if body.ids.is_empty() { + return Err(AppError::bad("ids 不能为空")); + } + let mut n = 0u64; + for chunk in body.ids.chunks(500) { + let ph = vec!["?"; chunk.len()].join(","); + let sql = if body.enabled { + format!("UPDATE tunnels SET enabled = 1, disabled_reason = NULL, updated_at = datetime('now') WHERE id IN ({ph})") + } else { + format!("UPDATE tunnels SET enabled = 0, disabled_reason = 'manual', updated_at = datetime('now') WHERE id IN ({ph})") + }; + let mut q = sqlx::query(&sql); + for id in chunk { + q = q.bind(id); + } + n += q.execute(&state.pool).await?.rows_affected(); + } + audit( + &state.pool, + Some(user.id), + &user.username, + "batch_toggle_tunnel", + Some(ET), + None, + None, + serde_json::json!({"ids": body.ids, "enabled": body.enabled, "updated": n}), + &ip, + ) + .await; + crate::services::runtime::on_entries_changed(&state).await; + Ok(HttpResponse::Ok().json(ApiResp::ok(serde_json::json!({"updated": n})))) +} + +async fn batch_move( + state: web::Data, + req: HttpRequest, + body: web::Json, +) -> AppResult { + let user = auth_user(&req)?; + let ip = client_ip(&req, &state.cfg.server.trusted_proxies); + if body.ids.is_empty() { + return Err(AppError::bad("ids 不能为空")); + } + node_exists(&state.pool, body.node_id).await?; + let mut n = 0u64; + for chunk in body.ids.chunks(500) { + let ph = vec!["?"; chunk.len()].join(","); + let sql = format!( + "UPDATE tunnels SET node_id = ?, updated_at = datetime('now') WHERE id IN ({ph})" + ); + let mut q = sqlx::query(&sql).bind(body.node_id); + for id in chunk { + q = q.bind(id); + } + n += q.execute(&state.pool).await?.rows_affected(); + } + audit( + &state.pool, + Some(user.id), + &user.username, + "batch_move_tunnel", + Some(ET), + None, + None, + serde_json::json!({"ids": body.ids, "node_id": body.node_id, "updated": n}), + &ip, + ) + .await; + crate::services::runtime::on_entries_changed(&state).await; + Ok(HttpResponse::Ok().json(ApiResp::ok(serde_json::json!({"updated": n})))) +} diff --git a/src/handlers/users.rs b/src/handlers/users.rs new file mode 100644 index 0000000..a79976c --- /dev/null +++ b/src/handlers/users.rs @@ -0,0 +1,225 @@ +//! P13 —— 多用户管理(仅 admin 可操作) +//! +//! - GET /api/users 用户列表 +//! - POST /api/users 新建用户 {username, password, role} +//! - PUT /api/users/{id} 修改角色 / 重置密码(不能改自己的角色) +//! - DELETE /api/users/{id} 删除用户(不能删自己、不能删除最后一个 admin) + +use actix_web::{web, HttpRequest, HttpResponse}; +use serde::Deserialize; + +use crate::audit::audit; +use crate::error::{AppError, AppResult}; +use crate::middleware::auth_user; +use crate::models::{ApiResp, User}; +use crate::util::{client_ip, hash_password}; + +pub fn routes(cfg: &mut web::ServiceConfig) { + cfg.service( + web::scope("/api/users") + .route("", web::get().to(list)) + .route("", web::post().to(create)) + .route("/{id}", web::put().to(update)) + .route("/{id}", web::delete().to(delete)), + ); +} + +fn require_admin(req: &HttpRequest) -> AppResult { + let user = auth_user(req)?; + if !user.is_admin() { + return Err(AppError::forbidden("需要管理员权限")); + } + Ok(user) +} + +async fn list(state: web::Data, req: HttpRequest) -> AppResult { + require_admin(&req)?; + let rows: Vec = sqlx::query_as("SELECT * FROM users ORDER BY id") + .fetch_all(&state.pool) + .await?; + Ok(HttpResponse::Ok().json(ApiResp::ok(rows))) +} + +#[derive(Deserialize)] +pub struct CreateBody { + pub username: String, + pub password: String, + #[serde(default = "default_role")] + pub role: String, +} + +fn default_role() -> String { + "viewer".to_string() +} + +fn check_role(role: &str) -> AppResult<()> { + if !matches!(role, "admin" | "viewer") { + return Err(AppError::bad("role 只能是 admin 或 viewer")); + } + Ok(()) +} + +fn check_password(pw: &str) -> AppResult<()> { + if pw.chars().count() < 8 { + return Err(AppError::bad("密码至少 8 位")); + } + Ok(()) +} + +async fn create( + state: web::Data, + req: HttpRequest, + body: web::Json, +) -> AppResult { + let user = require_admin(&req)?; + let username = body.username.trim(); + if username.is_empty() || username.chars().count() > 64 { + return Err(AppError::bad("用户名不能为空且不超过 64 字符")); + } + check_password(&body.password)?; + check_role(&body.role)?; + let hash = hash_password(&body.password).map_err(|e| AppError::internal(e.to_string()))?; + let id: i64 = sqlx::query_scalar( + "INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?) RETURNING id", + ) + .bind(username) + .bind(&hash) + .bind(&body.role) + .fetch_one(&state.pool) + .await + .map_err(|e| { + if e.to_string().contains("UNIQUE") { + AppError::bad("用户名已存在") + } else { + e.into() + } + })?; + audit( + &state.pool, + Some(user.id), + &user.username, + "user_create", + Some("users"), + Some(id), + None, + serde_json::json!({"username": username, "role": body.role}), + &client_ip(&req, &state.cfg.server.trusted_proxies), + ) + .await; + Ok(HttpResponse::Ok().json(ApiResp::ok(serde_json::json!({ "id": id })))) +} + +#[derive(Deserialize)] +pub struct UpdateBody { + pub role: Option, + pub password: Option, +} + +async fn update( + state: web::Data, + req: HttpRequest, + path: web::Path, + body: web::Json, +) -> AppResult { + let user = require_admin(&req)?; + let id = path.into_inner(); + let target: Option = sqlx::query_as("SELECT * FROM users WHERE id = ?") + .bind(id) + .fetch_optional(&state.pool) + .await?; + let target = target.ok_or_else(|| AppError::not_found("用户不存在"))?; + + if let Some(role) = &body.role { + check_role(role)?; + if id == user.id && role != "admin" { + return Err(AppError::bad("不能把自己的角色降为 viewer")); + } + if target.role == "admin" && role == "viewer" { + let admins: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM users WHERE role = 'admin'") + .fetch_one(&state.pool) + .await?; + if admins <= 1 { + return Err(AppError::bad("不能降级最后一个管理员")); + } + } + sqlx::query("UPDATE users SET role = ?, updated_at = datetime('now') WHERE id = ?") + .bind(role) + .bind(id) + .execute(&state.pool) + .await?; + } + if let Some(pw) = &body.password { + if !pw.is_empty() { + check_password(pw)?; + let hash = hash_password(pw).map_err(|e| AppError::internal(e.to_string()))?; + sqlx::query("UPDATE users SET password_hash = ?, updated_at = datetime('now') WHERE id = ?") + .bind(&hash) + .bind(id) + .execute(&state.pool) + .await?; + // 重置他人密码后使其会话失效 + if id != user.id { + sqlx::query("DELETE FROM sessions WHERE user_id = ?") + .bind(id) + .execute(&state.pool) + .await?; + } + } + } + audit( + &state.pool, + Some(user.id), + &user.username, + "user_update", + Some("users"), + Some(id), + None, + serde_json::json!({"username": target.username}), + &client_ip(&req, &state.cfg.server.trusted_proxies), + ) + .await; + Ok(HttpResponse::Ok().json(ApiResp::ok(serde_json::json!({ "id": id })))) +} + +async fn delete( + state: web::Data, + req: HttpRequest, + path: web::Path, +) -> AppResult { + let user = require_admin(&req)?; + let id = path.into_inner(); + if id == user.id { + return Err(AppError::bad("不能删除自己")); + } + let target: Option = sqlx::query_as("SELECT * FROM users WHERE id = ?") + .bind(id) + .fetch_optional(&state.pool) + .await?; + let target = target.ok_or_else(|| AppError::not_found("用户不存在"))?; + if target.role == "admin" { + let admins: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM users WHERE role = 'admin'") + .fetch_one(&state.pool) + .await?; + if admins <= 1 { + return Err(AppError::bad("不能删除最后一个管理员")); + } + } + sqlx::query("DELETE FROM users WHERE id = ?") + .bind(id) + .execute(&state.pool) + .await?; + audit( + &state.pool, + Some(user.id), + &user.username, + "user_delete", + Some("users"), + Some(id), + None, + serde_json::json!({"username": target.username}), + &client_ip(&req, &state.cfg.server.trusted_proxies), + ) + .await; + Ok(HttpResponse::Ok().json(ApiResp::ok(serde_json::json!({ "id": id })))) +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..c55024b --- /dev/null +++ b/src/main.rs @@ -0,0 +1,175 @@ +mod audit; +mod cli; +mod config; +mod db; +mod error; +mod handlers; +mod middleware; +mod models; +mod seed; +mod services; +mod state; +mod util; +mod worker; + +use actix_files::Files; +use actix_web::{middleware::from_fn, web, App, HttpResponse, HttpServer}; +use clap::{Parser, Subcommand}; +use state::AppState; +use std::sync::Arc; +use std::time::Duration; +use tokio::sync::Mutex; + +#[derive(Parser)] +#[command(name = "rust_proxy_admin", version)] +struct Args { + /// 运行模式: master | worker + #[arg(long, default_value = "master")] + mode: String, + /// Worker 模式: Master 地址,如 https://admin.example.com + #[arg(long)] + master: Option, + /// Worker 模式: 节点 api_token + #[arg(long)] + token: Option, + #[command(subcommand)] + cmd: Option, +} + +#[derive(Subcommand)] +enum Cmd { + /// 重置用户密码(旧会话全部失效) + ResetPassword { + #[arg(long)] + username: String, + #[arg(long)] + password: String, + }, +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let args = Args::parse(); + let cfg = config::Config::load("config.toml")?; + + if let Some(Cmd::ResetPassword { username, password }) = args.cmd { + return cli::reset_password(&cfg.db.url, &username, &password).await; + } + + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_new(&cfg.log.level).unwrap_or_else(|_| "info".into()), + ) + .init(); + + match args.mode.as_str() { + "worker" => { + let master = args.master.ok_or_else(|| anyhow::anyhow!("worker 模式需要 --master"))?; + let token = args.token.ok_or_else(|| anyhow::anyhow!("worker 模式需要 --token"))?; + worker::run_worker(cfg, master, token).await + } + _ => run_master(cfg).await, + } +} + +async fn run_master(cfg: config::Config) -> anyhow::Result<()> { + std::fs::create_dir_all("data").ok(); + + let pool = db::connect(&cfg.db.url).await?; + db::migrate(&pool).await?; + seed::ensure_seed(&pool, &cfg).await?; + + let tera = tera::Tera::new("templates/**/*").unwrap_or_default(); + let http = reqwest::Client::builder() + .timeout(Duration::from_secs(30)) + .build()?; + + let runtime = crate::services::runtime::RuntimeManager::new(pool.clone(), cfg.clone()); + + let state = AppState { + pool, + cfg: cfg.clone(), + tera, + http, + login_guard: Arc::new(Mutex::new(middleware::LoginGuard::default())), + import_cache: Arc::new(Mutex::new(std::collections::HashMap::new())), + sub_export_cache: Arc::new(Mutex::new(std::collections::HashMap::new())), + runtime: runtime.clone(), + }; + + // P8:初始同步运行时(启动已有启用条目的监听) + runtime.sync().await; + + // 后台任务:日志清理(P7) + { + let pool = state.pool.clone(); + let log_cfg = cfg.log.clone(); + tokio::spawn(async move { + crate::services::cleaner::run_cleaner(pool, log_cfg).await; + }); + } + + // P9:统计聚合 + 本机指标采集 + { + crate::services::aggregator::spawn_aggregator(state.pool.clone()); + crate::services::aggregator::spawn_metrics(state.pool.clone(), runtime.clone()); + } + + // P11:生命周期任务(到期/配额/自动停用/即将到期),每 60 秒一轮 + { + let pool = state.pool.clone(); + let lc = cfg.lifecycle.clone(); + let tz = cfg.time.timezone_offset_hours; + let base = cfg.server.public_base_url.clone(); + tokio::spawn(async move { + if let Err(e) = crate::services::lifecycle::lifecycle_tick(&pool, &lc, tz, &base).await { + tracing::warn!("生命周期任务失败: {e:#}"); + } + let mut tick = tokio::time::interval(std::time::Duration::from_secs( + lc.tick_secs.max(10) as u64, + )); + loop { + tick.tick().await; + if let Err(e) = crate::services::lifecycle::lifecycle_tick(&pool, &lc, tz, &base).await { + tracing::warn!("生命周期任务失败: {e:#}"); + } + } + }); + } + + // P12:告警投递器(每 10 秒一轮)+ 节点离线巡检(每 15 秒一轮) + { + let pool = state.pool.clone(); + let http = state.http.clone(); + let sk = cfg.server.secret_key.clone(); + tokio::spawn(async move { + crate::services::alert::dispatcher_loop(pool, http, sk).await; + }); + } + { + let pool = state.pool.clone(); + let tz = cfg.time.timezone_offset_hours; + let base = cfg.server.public_base_url.clone(); + let hb_timeout = cfg.nodes.heartbeat_timeout_secs; + tokio::spawn(async move { + crate::services::node_watch::node_watch_loop(pool, tz, base, hb_timeout).await; + }); + } + + // P0 验收:能启动并建库;后台任务在后续阶段接入 + tracing::info!("RustProxyAdmin 启动,监听 {}", cfg.server.bind); + let bind = cfg.server.bind.clone(); + HttpServer::new(move || { + App::new() + .app_data(web::Data::new(state.clone())) + .wrap(from_fn(middleware::auth_middleware)) + .route("/health", web::get().to(|| async { HttpResponse::Ok().body("ok") })) + .service(Files::new("/static", "./static").prefer_utf8(true)) + .configure(handlers::routes) + }) + .bind(&bind)? + .run() + .await?; + Ok(()) +} + diff --git a/src/middleware.rs b/src/middleware.rs new file mode 100644 index 0000000..1e721b7 --- /dev/null +++ b/src/middleware.rs @@ -0,0 +1,197 @@ +use crate::error::AppError; +use crate::models::User; +use crate::state::AppState; +use crate::util::TIME_FMT; +use actix_web::{ + body::BoxBody, + dev::{ServiceRequest, ServiceResponse}, + http::header, + middleware::Next, + web, Error, HttpMessage, HttpResponse, +}; +use chrono::{Duration, Utc}; + +/// 请求中携带的已认证用户 +#[derive(Debug, Clone)] +pub struct AuthUser { + pub id: i64, + pub username: String, + pub role: String, + pub csrf_token: String, + pub session_id: String, +} + +impl AuthUser { + pub fn is_admin(&self) -> bool { + self.role == "admin" + } +} + +fn is_public_path(path: &str) -> bool { + path == "/health" + || path == "/api/auth/login" + || path == "/admin/login" + || path.starts_with("/sub/") + || path.starts_with("/internal/") + || path.starts_with("/static/") +} + +/// 统一认证 / CSRF 中间件 +pub async fn auth_middleware( + req: ServiceRequest, + next: Next, +) -> Result, Error> { + let path = req.path().to_string(); + let method = req.method().clone(); + + // 公开路径直接放行(/internal/* 由各 handler 自行做 Bearer 校验) + if is_public_path(&path) { + return next.call(req).await; + } + + let state = req + .app_data::>() + .ok_or_else(|| actix_web::error::ErrorInternalServerError("state missing"))?; + let cfg = &state.cfg; + + // 取会话 + let sid = req + .cookie("sid") + .map(|c| c.value().to_string()) + .unwrap_or_default(); + + let user: Option = if sid.is_empty() { + None + } else { + load_session(&state, &sid).await + }; + + let Some(user) = user else { + // 未登录:页面重定向到登录页,API 返回 401 + if path.starts_with("/admin/") { + let resp = HttpResponse::Found() + .insert_header((header::LOCATION, "/admin/login")) + .finish(); + return Ok(req.into_response(resp.map_into_boxed_body())); + } + let resp = HttpResponse::Unauthorized().json(serde_json::json!({ + "ok": false, "data": null, "error": "未登录或会话已过期" + })); + return Ok(req.into_response(resp.map_into_boxed_body())); + }; + + // 滑动续期 + touch_session(&state, &user.session_id, cfg.server.session_hours).await; + + // viewer 只读:写操作一律 403(admin 不受影响) + let is_write = matches!(method.as_str(), "POST" | "PUT" | "DELETE" | "PATCH"); + if is_write && !user.is_admin() { + let allowed = path == "/api/auth/logout" + || path == "/api/auth/logout_all" + || path == "/api/auth/change_password"; + if !allowed { + let resp = HttpResponse::Forbidden().json(serde_json::json!({ + "ok": false, "data": null, "error": "只读账户无权执行写操作" + })); + return Ok(req.into_response(resp.map_into_boxed_body())); + } + } + + // CSRF:/api/* 写操作要求 X-CSRF-Token(登录接口除外) + if is_write && path.starts_with("/api/") && path != "/api/auth/login" { + let token = req + .headers() + .get("X-CSRF-Token") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + if token != user.csrf_token { + let resp = HttpResponse::Forbidden().json(serde_json::json!({ + "ok": false, "data": null, "error": "CSRF 校验失败" + })); + return Ok(req.into_response(resp.map_into_boxed_body())); + } + } + + req.extensions_mut().insert(user); + next.call(req).await +} + +async fn load_session(state: &AppState, sid: &str) -> Option { + let row: Option<(String, i64, String)> = sqlx::query_as( + "SELECT csrf_token, user_id, id FROM sessions WHERE id = ? AND expires_at > datetime('now')", + ) + .bind(sid) + .fetch_optional(&state.pool) + .await + .ok()?; + let (csrf_token, user_id, session_id) = row?; + let u: Option = sqlx::query_as("SELECT * FROM users WHERE id = ?") + .bind(user_id) + .fetch_optional(&state.pool) + .await + .ok()?; + let u = u?; + Some(AuthUser { + id: u.id, + username: u.username, + role: u.role, + csrf_token, + session_id, + }) +} + +async fn touch_session(state: &AppState, sid: &str, hours: i64) { + let exp = (Utc::now() + Duration::hours(hours)) + .format(TIME_FMT) + .to_string(); + let _ = sqlx::query("UPDATE sessions SET expires_at = ? WHERE id = ?") + .bind(exp) + .bind(sid) + .execute(&state.pool) + .await; +} + +/// 从请求扩展中取当前用户(handler 内使用) +pub fn auth_user(req: &actix_web::HttpRequest) -> Result { + req.extensions() + .get::() + .cloned() + .ok_or_else(|| AppError::unauthorized("未登录")) +} + +/// 要求管理员权限 +pub fn require_admin(user: &AuthUser) -> Result<(), AppError> { + if user.is_admin() { + Ok(()) + } else { + Err(AppError::forbidden("需要管理员权限")) + } +} + +/// 登录失败计数(内存) +#[derive(Default)] +pub struct LoginGuard { + pub fails: std::collections::HashMap)>, +} + +impl LoginGuard { + pub fn check(&self, ip: &str, max_fail: i64) -> Result<(), String> { + if let Some((n, until)) = self.fails.get(ip) { + if *n as i64 >= max_fail && Utc::now() < *until { + let secs = (*until - Utc::now()).num_seconds().max(1); + return Err(format!("登录失败次数过多,已锁定 {secs} 秒")); + } + } + Ok(()) + } + pub fn record_fail(&mut self, ip: &str, max_fail: i64, lock_minutes: i64) { + let e = self.fails.entry(ip.to_string()).or_insert((0, Utc::now())); + e.0 += 1; + if e.0 as i64 >= max_fail { + e.1 = Utc::now() + Duration::minutes(lock_minutes); + } + } + pub fn record_ok(&mut self, ip: &str) { + self.fails.remove(ip); + } +} diff --git a/src/models.rs b/src/models.rs new file mode 100644 index 0000000..796a769 --- /dev/null +++ b/src/models.rs @@ -0,0 +1,495 @@ +use serde::{Deserialize, Serialize}; +use sqlx::FromRow; + +// ============ 统一 API 响应 ============ +#[derive(Serialize)] +pub struct ApiResp { + pub ok: bool, + pub data: Option, + pub error: Option, +} + +impl ApiResp { + pub fn ok(data: T) -> Self { + Self { + ok: true, + data: Some(data), + error: None, + } + } +} + +#[derive(Serialize)] +pub struct Page { + pub items: Vec, + pub total: i64, + pub page: i64, + pub page_size: i64, +} + +// ============ 用户 / 会话 / 设置 ============ +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct User { + pub id: i64, + pub username: String, + #[serde(skip_serializing)] + pub password_hash: String, + pub role: String, + pub created_at: String, + pub updated_at: String, +} + +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct Session { + pub id: String, + pub user_id: i64, + pub csrf_token: String, + pub ip_addr: Option, + pub user_agent: Option, + pub expires_at: String, + pub created_at: String, +} + +// ============ 服务器节点 ============ +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct Node { + pub id: i64, + pub name: String, + pub is_local: i64, + pub addr: Option, + pub public_host: Option, + #[serde(skip_serializing)] + pub api_token: String, + pub enabled: i64, + pub meta: Option, + pub version: Option, + pub last_heartbeat_at: Option, + pub last_load: Option, + pub online_state: String, + pub created_at: String, + pub updated_at: String, +} + +// ============ 分组 / 标签 / 输出模板 ============ +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct EntryGroup { + pub id: i64, + pub name: String, + pub color: String, + pub description: Option, + pub sort_order: i64, + pub created_at: String, +} + +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct Tag { + pub id: i64, + pub name: String, + pub color: String, + pub created_at: String, +} + +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct SubTemplate { + pub id: i64, + pub name: String, + pub description: Option, + pub format: String, + pub content: String, + pub is_builtin: i64, + pub created_at: String, + pub updated_at: String, +} + +// ============ 导入记录 ============ +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct ImportRecord { + pub id: i64, + pub user_id: Option, + pub source_url: Option, + pub source_type: String, + pub target_node_id: Option, + pub parsed_count: i64, + pub imported_count: i64, + pub created_at: String, +} + +// ============ 三类条目 ============ +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct ProxyRule { + pub id: i64, + pub node_id: i64, + pub name: String, + pub listen_addr: Option, + pub upstream_type: String, + pub upstream_addr: String, + pub auth_user: Option, + pub auth_pass: Option, + pub export_host: Option, + pub export_port: Option, + pub extra: Option, + pub source_import_id: Option, + pub group_id: Option, + pub expires_at: Option, + pub enabled: i64, + pub disabled_reason: Option, + pub remark: Option, + pub created_at: String, + pub updated_at: String, +} + +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct PortForward { + pub id: i64, + pub node_id: i64, + pub name: String, + pub listen_ip: String, + pub listen_port: i64, + pub target_ip: String, + pub target_port: i64, + pub protocol: String, + pub export_host: Option, + pub export_port: Option, + pub extra: Option, + pub source_import_id: Option, + pub group_id: Option, + pub expires_at: Option, + pub enabled: i64, + pub disabled_reason: Option, + pub remark: Option, + pub created_at: String, + pub updated_at: String, +} + +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct Tunnel { + pub id: i64, + pub node_id: i64, + pub name: String, + pub tunnel_type: String, + pub local_addr: String, + pub remote_addr: String, + pub token: Option, + pub export_host: Option, + pub export_port: Option, + pub extra: Option, + pub source_import_id: Option, + pub group_id: Option, + pub expires_at: Option, + pub enabled: i64, + pub disabled_reason: Option, + pub remark: Option, + pub created_at: String, + pub updated_at: String, +} + +// ============ 订阅 ============ +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct Subscription { + pub id: i64, + pub user_id: Option, + pub token: String, + pub name: String, + pub description: Option, + pub scope: String, + pub default_format: String, + pub template_id: Option, + pub enabled: i64, + pub disabled_reason: Option, + pub expires_at: Option, + pub max_ips: Option, + pub access_count: i64, + pub last_access_at: Option, + pub created_at: String, + pub updated_at: String, +} + +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct SubscriptionEntry { + pub id: i64, + pub subscription_id: i64, + pub entry_type: String, + pub entry_id: i64, + pub sort_order: i64, +} + +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct SubRule { + pub id: i64, + pub subscription_id: i64, + pub kind: String, + pub value: String, + pub mode: String, +} + +// ============ 配额 ============ +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct QuotaRow { + pub id: i64, + pub entry_type: String, + pub entry_id: i64, + pub quota_bytes: i64, + pub used_bytes: i64, + pub period: String, + pub reset_day: i64, + pub period_start: String, + pub warn_level: i64, + pub status: String, + pub exceeded_at: Option, +} + +// ============ 日志 / 统计 ============ +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct AuditLog { + pub id: i64, + pub actor_user_id: Option, + pub actor_name: Option, + pub action: String, + pub resource_type: Option, + pub resource_id: Option, + pub node_id: Option, + pub detail: Option, + pub ip_addr: Option, + pub created_at: String, +} + +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct AccessLog { + pub id: i64, + pub node_id: i64, + pub rule_type: String, + pub rule_id: i64, + pub client_ip: Option, + pub started_at: String, + pub ended_at: Option, + pub bytes_up: i64, + pub bytes_down: i64, + pub status: Option, +} + +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct SubscriptionLog { + pub id: i64, + pub subscription_id: i64, + pub client_ip: Option, + pub user_agent: Option, + pub format: Option, + pub result: String, + pub accessed_at: String, +} + +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct MetricsSnapshot { + pub id: i64, + pub node_id: i64, + pub collected_at: String, + pub cpu: Option, + pub mem: Option, + pub conns: Option, + pub net_up_bps: Option, + pub net_down_bps: Option, +} + +// ============ 告警 ============ +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct AlertChannel { + pub id: i64, + pub name: String, + pub kind: String, + pub config: String, + pub enabled: i64, + pub created_at: String, + pub updated_at: String, +} + +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct AlertRule { + pub id: i64, + pub name: String, + pub event_type: String, + pub params: String, + pub channel_ids: String, + pub cooldown_secs: i64, + pub enabled: i64, +} + +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct AlertEvent { + pub id: i64, + pub rule_id: Option, + pub event_type: String, + pub dedup_key: String, + pub severity: String, + pub title: String, + pub body: String, + pub resource_type: Option, + pub resource_id: Option, + pub status: String, + pub attempts: i64, + pub next_retry_at: Option, + pub last_error: Option, + pub created_at: String, + pub sent_at: Option, +} + +// ============ 三类条目统一转换后的可导出节点 ============ +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ExportNode { + pub name: String, + pub kind: String, + pub host: String, + pub port: u16, + pub username: Option, + pub password: Option, + pub extra: Option, + pub entry_type: String, + pub group: Option, + pub tags: Vec, +} + +// ============ 请求体结构 ============ +#[derive(Deserialize)] +pub struct LoginReq { + pub username: String, + pub password: String, +} +#[derive(Deserialize)] +pub struct ChangePasswordReq { + pub old_password: String, + pub new_password: String, +} +#[derive(Deserialize)] +pub struct ChangeUsernameReq { + pub password: String, + pub new_username: String, +} +#[derive(Deserialize)] +pub struct BatchIds { + pub ids: Vec, +} + +#[derive(Deserialize, Serialize, Clone, Debug)] +pub struct EntryRef { + pub entry_type: String, + pub entry_id: i64, +} + +#[derive(Deserialize)] +pub struct SubscriptionUpsert { + pub name: String, + pub description: Option, + pub scope: String, + pub default_format: String, + pub template_id: Option, + pub enabled: Option, + pub expire_preset: String, + pub expires_at: Option, + pub max_ips: Option, + #[serde(default)] + pub entries: Vec, + #[serde(default)] + pub rules: Vec, +} +#[derive(Deserialize, Clone)] +pub struct SubRuleInput { + pub kind: String, + pub value: String, + pub mode: String, +} + +#[derive(Deserialize)] +pub struct ImportParseReq { + pub url: Option, + pub content: Option, +} +#[derive(Deserialize)] +pub struct ImportConfirmReq { + pub parse_token: String, + pub target_node_id: i64, + pub target_type: String, + pub selected: Option>, + pub name_prefix: Option, +} + +#[derive(Deserialize)] +pub struct QuotaUpsert { + pub quota_bytes: i64, + pub period: String, + pub reset_day: Option, +} + +// ============ 通用列表查询参数 ============ +#[derive(Deserialize)] +pub struct ListQuery { + pub page: Option, + pub page_size: Option, + pub q: Option, + pub node_id: Option, + pub enabled: Option, + pub source_import_id: Option, + pub group_id: Option, + pub no_group: Option, + pub tag_id: Option, + pub disabled_reason: Option, + pub expiring_days: Option, +} + +// ============ 条目创建 / 更新 ============ +#[derive(Deserialize, Clone)] +pub struct ProxyUpsert { + pub node_id: i64, + pub name: String, + pub listen_addr: Option, + pub upstream_type: String, + pub upstream_addr: String, + pub auth_user: Option, + pub auth_pass: Option, + pub export_host: Option, + pub export_port: Option, + pub extra: Option, + pub group_id: Option, + pub tag_ids: Option>, + pub expire_preset: Option, + pub expires_at: Option, + pub remark: Option, + pub enabled: Option, +} + +#[derive(Deserialize, Clone)] +pub struct ForwardUpsert { + pub node_id: i64, + pub name: String, + pub listen_ip: Option, + pub listen_port: i64, + pub target_ip: String, + pub target_port: i64, + pub protocol: Option, + pub export_host: Option, + pub export_port: Option, + pub extra: Option, + pub group_id: Option, + pub tag_ids: Option>, + pub expire_preset: Option, + pub expires_at: Option, + pub remark: Option, + pub enabled: Option, +} + +#[derive(Deserialize, Clone)] +pub struct TunnelUpsert { + pub node_id: i64, + pub name: String, + pub tunnel_type: String, + pub local_addr: String, + pub remote_addr: String, + pub token: Option, + pub export_host: Option, + pub export_port: Option, + pub extra: Option, + pub group_id: Option, + pub tag_ids: Option>, + pub expire_preset: Option, + pub expires_at: Option, + pub remark: Option, + pub enabled: Option, +} diff --git a/src/seed.rs b/src/seed.rs new file mode 100644 index 0000000..0469302 --- /dev/null +++ b/src/seed.rs @@ -0,0 +1,199 @@ +use crate::config::Config; +use crate::util::{hash_password, random_token}; +use sqlx::SqlitePool; + +/// 启动时自动初始化的数据(代码里做,不写进迁移) +pub async fn ensure_seed(pool: &SqlitePool, cfg: &Config) -> anyhow::Result<()> { + // 1) 本机节点 id=1 + let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM nodes") + .fetch_one(pool) + .await?; + if n == 0 { + sqlx::query( + "INSERT INTO nodes (id, name, is_local, api_token, enabled, online_state) + VALUES (1, 'local', 1, ?, 1, 'online')", + ) + .bind(random_token(32)) + .execute(pool) + .await?; + tracing::info!("已创建本机节点 local (id=1)"); + } + + // 2) 管理员账户 + let u: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM users") + .fetch_one(pool) + .await?; + if u == 0 { + let username = if cfg.auth.init_username.is_empty() { + "admin" + } else { + &cfg.auth.init_username + }; + let password = if cfg.auth.init_password.is_empty() { + "ChangeMe123!" + } else { + &cfg.auth.init_password + }; + sqlx::query("INSERT INTO users (username, password_hash, role) VALUES (?, ?, 'admin')") + .bind(username) + .bind(hash_password(password)?) + .execute(pool) + .await?; + tracing::info!("已创建初始管理员账户: {username}"); + if password == "ChangeMe123!" { + tracing::warn!("正在使用默认密码 ChangeMe123!,请立即在后台修改"); + } + } + + // 3) 内置输出模板 + let t: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM sub_templates") + .fetch_one(pool) + .await?; + if t == 0 { + for (name, desc, format, content) in builtin_templates() { + sqlx::query( + "INSERT INTO sub_templates (name, description, format, content, is_builtin) + VALUES (?, ?, ?, ?, 1)", + ) + .bind(name) + .bind(desc) + .bind(format) + .bind(content) + .execute(pool) + .await?; + } + tracing::info!("已写入内置输出模板"); + } + + // 4) 默认告警规则(未绑定渠道) + let r: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM alert_rules") + .fetch_one(pool) + .await?; + if r == 0 { + let defaults: Vec<(&str, &str, &str, i64)> = vec![ + ("节点离线", "node_offline", r#"{"offline_secs":90}"#, 3600), + ("节点恢复", "node_online", "{}", 3600), + ("订阅即将到期", "sub_expiring", r#"{"days_before":[7,3,1]}"#, 86400 * 3650), + ("订阅已过期", "sub_expired", "{}", 86400 * 3650), + ("条目即将到期", "entry_expiring", r#"{"days_before":[7,3,1]}"#, 86400 * 3650), + ("条目已到期", "entry_expired", "{}", 86400 * 3650), + ("配额预警", "quota_warn", r#"{"percent":[80,95]}"#, 86400 * 3650), + ("配额用尽", "quota_exceeded", "{}", 86400 * 3650), + ("条目运行异常", "runtime_error", "{}", 3600), + ("登录失败激增", "login_fail_burst", r#"{"threshold":5}"#, 3600), + ("订阅触发 IP 限制", "sub_ip_limit", "{}", 3600), + ]; + for (name, event_type, params, cooldown) in defaults { + sqlx::query( + "INSERT INTO alert_rules (name, event_type, params, channel_ids, cooldown_secs, enabled) + VALUES (?, ?, ?, '[]', ?, 1)", + ) + .bind(name) + .bind(event_type) + .bind(params) + .bind(cooldown) + .execute(pool) + .await?; + } + tracing::info!("已写入默认告警规则"); + } + Ok(()) +} + +fn builtin_templates() -> Vec<(&'static str, &'static str, &'static str, &'static str)> { + vec![ + ( + "Clash 基础:全部走代理", + "所有流量走代理,内置 PROXY 手选与 AUTO 自动测速分组", + "clash", + r#"mixed-port: 7890 +allow-lan: false +mode: rule +log-level: info +proxy-groups: + - { name: "PROXY", type: select, proxies: ["AUTO", "@ALL", "DIRECT"] } + - { name: "AUTO", type: url-test, url: "http://www.gstatic.com/generate_204", interval: 300, proxies: ["@ALL"] } +rules: + - MATCH,PROXY +"#, + ), + ( + "Clash 国内直连", + "国内直连、国外走代理,附带 DNS 配置", + "clash", + r#"mixed-port: 7890 +allow-lan: false +mode: rule +log-level: info +dns: + enable: true + enhanced-mode: fake-ip + nameserver: [223.5.5.5, 119.29.29.29] +proxy-groups: + - { name: "PROXY", type: select, proxies: ["AUTO", "@ALL", "DIRECT"] } + - { name: "AUTO", type: url-test, url: "http://www.gstatic.com/generate_204", interval: 300, proxies: ["@ALL"] } +rules: + - GEOIP,LAN,DIRECT + - GEOIP,CN,DIRECT + - MATCH,PROXY +"#, + ), + ( + "Clash 按地区分组", + "用 @REGEX 按节点名把 HK / TW 节点分到不同测速组", + "clash", + r#"mixed-port: 7890 +allow-lan: false +mode: rule +log-level: info +proxy-groups: + - { name: "PROXY", type: select, proxies: ["HK", "TW", "OTHER", "DIRECT"] } + - { name: "HK", type: url-test, url: "http://www.gstatic.com/generate_204", interval: 300, proxies: ["@REGEX:(?i)hk|香港|hong"] } + - { name: "TW", type: url-test, url: "http://www.gstatic.com/generate_204", interval: 300, proxies: ["@REGEX:(?i)tw|台湾|taiwan"] } + - { name: "OTHER", type: select, proxies: ["@ALL"] } +rules: + - GEOIP,CN,DIRECT + - MATCH,PROXY +"#, + ), + ( + "Sing-box 基础:全部走代理", + "selector 手选 + urltest 自动测速", + "singbox", + r#"{ + "outbounds": [ + { "type": "selector", "tag": "PROXY", "outbounds": ["AUTO", "@ALL", "direct"] }, + { "type": "urltest", "tag": "AUTO", "outbounds": ["@ALL"], "url": "http://www.gstatic.com/generate_204", "interval": "5m" }, + { "type": "direct", "tag": "direct" } + ], + "route": { + "rules": [], + "final": "PROXY" + } +}"#, + ), + ( + "Sing-box 国内直连", + "geoip cn 直连,其余走代理", + "singbox", + r#"{ + "dns": { + "servers": [ + { "tag": "local", "address": "223.5.5.5", "detour": "direct" } + ] + }, + "outbounds": [ + { "type": "selector", "tag": "PROXY", "outbounds": ["AUTO", "@ALL", "direct"] }, + { "type": "urltest", "tag": "AUTO", "outbounds": ["@ALL"], "url": "http://www.gstatic.com/generate_204", "interval": "5m" }, + { "type": "direct", "tag": "direct" } + ], + "route": { + "rules": [ + { "geoip": "cn", "outbound": "direct" } + ], + "final": "PROXY" + } +}"#, + ), + ] +} diff --git a/src/services/aggregator.rs b/src/services/aggregator.rs new file mode 100644 index 0000000..11d4b90 --- /dev/null +++ b/src/services/aggregator.rs @@ -0,0 +1,179 @@ +//! P9 —— 统计聚合与本机指标采集 +//! +//! - aggregate 任务:每 5 分钟把 access_logs 的新行按小时/天聚合成 stats_hourly/stats_daily(增量 UPSERT) +//! - metrics 任务:每 60 秒采集本机 CPU/内存/实时连接数/网卡速率,写入 metrics_snapshots + +use sqlx::SqlitePool; + +const CURSOR_KEY: &str = "stats_agg_cursor"; + +/// 启动后台聚合任务(每 5 分钟) +pub fn spawn_aggregator(pool: SqlitePool) { + tokio::spawn(async move { + // 启动时立刻跑一次,之后每 5 分钟 + if let Err(e) = aggregate_once(&pool).await { + tracing::warn!("统计聚合失败: {e:#}"); + } + let mut tick = tokio::time::interval(std::time::Duration::from_secs(300)); + loop { + tick.tick().await; + if let Err(e) = aggregate_once(&pool).await { + tracing::warn!("统计聚合失败: {e:#}"); + } + } + }); +} + +/// 单次指标采集:CPU/内存/实时连接数/网卡速率 → metrics_snapshots +async fn collect_metrics_once( + sys: &mut sysinfo::System, + nets: &mut sysinfo::Networks, + prev: &mut (u64, u64, Option), + pool: &SqlitePool, + runtime: &std::sync::Arc, +) { + sys.refresh_all(); + nets.refresh(); + let cpu = sys.global_cpu_usage() as f64; + let mem = if sys.total_memory() > 0 { + sys.used_memory() as f64 / sys.total_memory() as f64 * 100.0 + } else { + 0.0 + }; + let mut rx: u64 = 0; + let mut tx: u64 = 0; + for (_, d) in nets.iter() { + rx += d.received(); + tx += d.transmitted(); + } + let now = std::time::Instant::now(); + let (up_bps, down_bps) = match prev.2 { + Some(t) => { + let dt = now.duration_since(t).as_secs_f64().max(1.0); + ( + ((tx.saturating_sub(prev.1)) as f64 / dt * 8.0) as i64, + ((rx.saturating_sub(prev.0)) as f64 / dt * 8.0) as i64, + ) + } + None => (0, 0), + }; + prev.0 = rx; + prev.1 = tx; + prev.2 = Some(now); + let conns: i64 = runtime + .status_snapshot() + .await + .iter() + .map(|s| s["conns"].as_u64().unwrap_or(0) as i64) + .sum(); + let at = chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string(); + if let Err(e) = sqlx::query( + "INSERT INTO metrics_snapshots (node_id, collected_at, cpu, mem, conns, net_up_bps, net_down_bps) + VALUES (1, ?, ?, ?, ?, ?, ?)", + ) + .bind(&at) + .bind(cpu) + .bind(mem) + .bind(conns) + .bind(up_bps) + .bind(down_bps) + .execute(pool) + .await + { + tracing::warn!("写入本机指标失败: {e:#}"); + } +} + +/// 启动本机指标采集任务(每 60 秒) +pub fn spawn_metrics(pool: SqlitePool, runtime: std::sync::Arc) { + tokio::spawn(async move { + let mut sys = sysinfo::System::new_all(); + let mut nets = sysinfo::Networks::new_with_refreshed_list(); + let mut prev = (0u64, 0u64, None); + collect_metrics_once(&mut sys, &mut nets, &mut prev, &pool, &runtime).await; + let mut tick = tokio::time::interval(std::time::Duration::from_secs(60)); + loop { + tick.tick().await; + collect_metrics_once(&mut sys, &mut nets, &mut prev, &pool, &runtime).await; + } + }); +} + +/// 单次聚合:把水位线之后的新 access_logs 行聚合成小时/天统计 +pub async fn aggregate_once(pool: &SqlitePool) -> anyhow::Result<(i64, i64)> { + let cursor: i64 = sqlx::query_scalar::<_, Option>( + "SELECT value FROM settings WHERE key = ?", + ) + .bind(CURSOR_KEY) + .fetch_optional(pool) + .await? + .flatten() + .and_then(|v| v.parse().ok()) + .unwrap_or(0); + let mut tx = pool.begin().await?; + let max_id: Option = sqlx::query_scalar("SELECT MAX(id) FROM access_logs") + .fetch_one(&mut *tx) + .await?; + let max_id = max_id.unwrap_or(0); + if max_id <= cursor { + return Ok((0, 0)); + } + // 按小时聚合(started_at 是本地时间字符串 "YYYY-MM-DD HH:MM:SS",直接截断) + let h = sqlx::query( + "INSERT INTO stats_hourly (stat_time, node_id, rule_type, rule_id, total_connections, total_bytes_up, total_bytes_down) + SELECT replace(substr(started_at, 1, 13), ' ', 'T'), node_id, rule_type, rule_id, + COUNT(*), COALESCE(SUM(bytes_up), 0), COALESCE(SUM(bytes_down), 0) + FROM access_logs WHERE id > ? AND id <= ? + GROUP BY 1, 2, 3, 4 + ON CONFLICT(stat_time, node_id, rule_type, rule_id) DO UPDATE SET + total_connections = stats_hourly.total_connections + excluded.total_connections, + total_bytes_up = stats_hourly.total_bytes_up + excluded.total_bytes_up, + total_bytes_down = stats_hourly.total_bytes_down + excluded.total_bytes_down", + ) + .bind(cursor) + .bind(max_id) + .execute(&mut *tx) + .await? + .rows_affected(); + // 按天聚合 + let d = sqlx::query( + "INSERT INTO stats_daily (stat_date, node_id, rule_type, rule_id, total_connections, total_bytes_up, total_bytes_down) + SELECT substr(started_at, 1, 10), node_id, rule_type, rule_id, + COUNT(*), COALESCE(SUM(bytes_up), 0), COALESCE(SUM(bytes_down), 0) + FROM access_logs WHERE id > ? AND id <= ? + GROUP BY 1, 2, 3, 4 + ON CONFLICT(stat_date, node_id, rule_type, rule_id) DO UPDATE SET + total_connections = stats_daily.total_connections + excluded.total_connections, + total_bytes_up = stats_daily.total_bytes_up + excluded.total_bytes_up, + total_bytes_down = stats_daily.total_bytes_down + excluded.total_bytes_down", + ) + .bind(cursor) + .bind(max_id) + .execute(&mut *tx) + .await? + .rows_affected(); + // 配额用量记账:本机运行时产生的流量与 Worker 上报走同一个 add_usage + let groups: Vec<(String, i64, i64, i64)> = sqlx::query_as( + "SELECT rule_type, rule_id, COALESCE(SUM(bytes_up), 0), COALESCE(SUM(bytes_down), 0) + FROM access_logs WHERE id > ? AND id <= ? + GROUP BY rule_type, rule_id", + ) + .bind(cursor) + .bind(max_id) + .fetch_all(&mut *tx) + .await?; + for (rule_type, rule_id, up, down) in groups { + crate::services::quota::add_usage(&mut tx, &rule_type, rule_id, up, down).await?; + } + sqlx::query( + "INSERT INTO settings (key, value) VALUES (?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value", + ) + .bind(CURSOR_KEY) + .bind(max_id.to_string()) + .execute(&mut *tx) + .await?; + tx.commit().await?; + tracing::info!("统计聚合:access_logs {cursor}..{max_id} → 小时 {h} 行 / 天 {d} 行"); + Ok((h as i64, d as i64)) +} diff --git a/src/services/alert/crypto.rs b/src/services/alert/crypto.rs new file mode 100644 index 0000000..0bccd31 --- /dev/null +++ b/src/services/alert/crypto.rs @@ -0,0 +1,135 @@ +//! P13 —— 告警渠道密钥的 AES-256-GCM 加密(§14.2 密钥保护)。 +//! +//! - 密钥来自 `config.toml [server] secret_key`(或环境变量 `RPA__SERVER__SECRET_KEY`), +//! 经 SHA-256 派生为 32 字节。 +//! - 仅加密渠道配置 JSON 中的密钥字段:`bot_token`、`secret`、`password`。 +//! - 密文格式:`enc:v1:` + base64(nonce(12) || ciphertext),存回原字段。 +//! - 未配置 secret_key 时保持明文(兼容旧库);读取时自动识别 `enc:v1:` 前缀。 + +use aes_gcm::{aead::{Aead, KeyInit, OsRng}, Aes256Gcm, Nonce}; +use serde_json::Value; + +/// 需要加密的渠道配置字段 +pub const SECRET_FIELDS: &[&str] = &["bot_token", "secret", "password"]; + +/// 密文前缀 +const PREFIX: &str = "enc:v1:"; + +pub fn key_bytes(secret: &str) -> [u8; 32] { + use sha2::Digest; + let mut h = sha2::Sha256::new(); + h.update(secret.as_bytes()); + h.finalize().into() +} + +fn encrypt_str(plain: &str, key: &[u8; 32]) -> String { + use aes_gcm::aead::rand_core::RngCore; + let cipher = Aes256Gcm::new_from_slice(key).expect("key 长度固定 32"); + let mut nb = [0u8; 12]; + RngCore::fill_bytes(&mut OsRng, &mut nb); + let nonce = Nonce::from_slice(&nb); + let ct = cipher + .encrypt(nonce, plain.as_bytes()) + .expect("AES-GCM 加密不应失败"); + let mut raw = Vec::with_capacity(12 + ct.len()); + raw.extend_from_slice(&nb); + raw.extend_from_slice(&ct); + format!("{PREFIX}{}", base64::Engine::encode(&base64::engine::general_purpose::STANDARD, raw)) +} + +fn decrypt_str(enc: &str, key: &[u8; 32]) -> Option { + let b64 = enc.strip_prefix(PREFIX)?; + let raw = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, b64).ok()?; + if raw.len() < 13 { + return None; + } + let cipher = Aes256Gcm::new_from_slice(key).ok()?; + let nonce = Nonce::from_slice(&raw[..12]); + let pt = cipher.decrypt(nonce, &raw[12..]).ok()?; + String::from_utf8(pt).ok() +} + +/// 写库前:把渠道配置中的密钥字段加密(未配置 secret_key 则原样返回)。 +pub fn encrypt_config(cfg: &mut Value, secret_key: Option<&str>) { + let Some(sk) = secret_key else { return }; + let key = key_bytes(sk); + let Some(obj) = cfg.as_object_mut() else { return }; + for f in SECRET_FIELDS { + if let Some(Value::String(s)) = obj.get(*f) { + if !s.is_empty() && !s.starts_with(PREFIX) { + obj.insert(f.to_string(), Value::String(encrypt_str(s, &key))); + } + } + } +} + +/// 读取后:把渠道配置中的 `enc:v1:` 密文解密(无密钥或解密失败则保持原样)。 +pub fn decrypt_config(cfg: &Value, secret_key: Option<&str>) -> Value { + let Some(sk) = secret_key else { + return cfg.clone(); + }; + let key = key_bytes(sk); + let mut out = cfg.clone(); + let Some(obj) = out.as_object_mut() else { + return out; + }; + for f in SECRET_FIELDS { + if let Some(Value::String(s)) = obj.get(*f).cloned() { + if s.starts_with(PREFIX) { + if let Some(pt) = decrypt_str(&s, &key) { + obj.insert(f.to_string(), Value::String(pt)); + } + } + } + } + out +} + +/// API 返回前对 admin 脱敏:密钥字段 → `******`。 +pub fn mask_config(cfg: &mut Value) { + let Some(obj) = cfg.as_object_mut() else { return }; + for f in SECRET_FIELDS { + if let Some(Value::String(s)) = obj.get(*f) { + if !s.is_empty() { + obj.insert(f.to_string(), Value::String("******".to_string())); + } + } + } +} + +/// API 返回前对 viewer 隐藏:直接删除密钥字段。 +pub fn strip_secrets(cfg: &mut Value) { + let Some(obj) = cfg.as_object_mut() else { return }; + for f in SECRET_FIELDS { + obj.remove(*f); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn roundtrip() { + let key = key_bytes("test-secret-key"); + let c = encrypt_str("hello-token", &key); + assert!(c.starts_with(PREFIX)); + assert_eq!(decrypt_str(&c, &key).as_deref(), Some("hello-token")); + // 换密钥解不开 + assert!(decrypt_str(&c, &key_bytes("other")).is_none()); + } + + #[test] + fn config_roundtrip() { + let mut cfg = serde_json::json!({"bot_token": "abc123", "chat_id": "-1001"}); + encrypt_config(&mut cfg, Some("sk")); + assert!(cfg["bot_token"].as_str().unwrap().starts_with(PREFIX)); + assert_eq!(cfg["chat_id"].as_str().unwrap(), "-1001"); + let dec = decrypt_config(&cfg, Some("sk")); + assert_eq!(dec["bot_token"].as_str().unwrap(), "abc123"); + // 不加密(无密钥)时原样 + let mut cfg2 = serde_json::json!({"secret": "s3"}); + encrypt_config(&mut cfg2, None); + assert_eq!(cfg2["secret"].as_str().unwrap(), "s3"); + } +} diff --git a/src/services/alert/dispatcher.rs b/src/services/alert/dispatcher.rs new file mode 100644 index 0000000..f7a6198 --- /dev/null +++ b/src/services/alert/dispatcher.rs @@ -0,0 +1,220 @@ +//! P12 —— 告警投递器(§14.6) +//! +//! 每 10 秒一轮:取 pending 且到重试时间的事件 → 按规则绑定的渠道逐个发送。 +//! - 只要有一个渠道成功就算成功; +//! - 规则未绑定渠道:直接标 sent,last_error="规则未绑定渠道"; +//! - 失败:attempts+1,退避 60 / 120 / 240 / 480 秒,超过 5 次 status=failed。 + +use reqwest::Client; +use sqlx::SqlitePool; + +use super::{email, telegram, webhook, AlertDraft, AlertEvent}; + +/// 后台任务入口(main.rs 里 spawn) +pub async fn dispatcher_loop(pool: SqlitePool, http: Client, secret_key: Option) { + let mut tick = tokio::time::interval(std::time::Duration::from_secs(10)); + loop { + tick.tick().await; + let evs: Vec = match sqlx::query_as( + "SELECT * FROM alert_events + WHERE status = 'pending' AND (next_retry_at IS NULL OR next_retry_at <= datetime('now')) + ORDER BY id LIMIT 50", + ) + .fetch_all(&pool) + .await + { + Ok(v) => v, + Err(e) => { + tracing::error!("告警投递:查询事件失败: {e:?}"); + continue; + } + }; + + for ev in evs { + match send_event(&pool, &http, &ev, secret_key.as_deref()).await { + Ok(()) => { + let _ = sqlx::query( + "UPDATE alert_events SET status='sent', sent_at=datetime('now'), + attempts=attempts+1, last_error=NULL WHERE id=?", + ) + .bind(ev.id) + .execute(&pool) + .await; + } + Err(SendError::NoChannel) => { + // §14.6:规则未绑定渠道的事件直接标 sent,避免堆积 + let _ = sqlx::query( + "UPDATE alert_events SET status='sent', sent_at=datetime('now'), + attempts=attempts+1, last_error='规则未绑定渠道' WHERE id=?", + ) + .bind(ev.id) + .execute(&pool) + .await; + } + Err(SendError::Other(e)) => { + let attempts = ev.attempts + 1; + let (status, delay) = if attempts >= 5 { + ("failed", 0) + } else { + ("pending", 30 * (1 << attempts)) // 60,120,240,480 秒 + }; + let err = truncate(&e.to_string(), 300); + let _ = sqlx::query( + "UPDATE alert_events SET status=?, attempts=?, last_error=?, + next_retry_at=datetime('now', ?) WHERE id=?", + ) + .bind(status) + .bind(attempts) + .bind(err) + .bind(format!("+{delay} seconds")) + .bind(ev.id) + .execute(&pool) + .await; + } + } + } + } +} + +/// 发送失败分类:NoChannel(规则未绑定渠道)直接标 sent,避免堆积 +#[derive(Debug)] +pub enum SendError { + NoChannel, + Other(anyhow::Error), +} + +impl From for SendError { + fn from(e: anyhow::Error) -> Self { + SendError::Other(e) + } +} + +impl std::fmt::Display for SendError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + SendError::NoChannel => write!(f, "规则未绑定渠道"), + SendError::Other(e) => write!(f, "{e}"), + } + } +} + +async fn send_event( + pool: &SqlitePool, + http: &Client, + ev: &AlertEvent, + secret_key: Option<&str>, +) -> Result<(), SendError> { + let rule_id = ev + .rule_id + .ok_or_else(|| anyhow::anyhow!("事件没有关联规则"))?; + let channel_ids: String = sqlx::query_scalar( + "SELECT channel_ids FROM alert_rules WHERE id = ?", + ) + .bind(rule_id) + .fetch_optional(pool) + .await + .map_err(|e| SendError::Other(anyhow::anyhow!("查规则失败: {e}")))? + .flatten() + .unwrap_or_else(|| "[]".to_string()); + let ids: Vec = serde_json::from_str(&channel_ids).unwrap_or_default(); + if ids.is_empty() { + return Err(SendError::NoChannel); + } + + let mut ok = 0; + let mut errs: Vec = Vec::new(); + for cid in ids { + let ch: Option<(String, String, i64)> = sqlx::query_as( + "SELECT kind, config, enabled FROM alert_channels WHERE id = ?", + ) + .bind(cid) + .fetch_optional(pool) + .await + .map_err(|e| SendError::Other(anyhow::anyhow!("查渠道失败: {e}")))?; + let Some((kind, config_str, enabled)) = ch else { + continue; + }; + if enabled == 0 { + continue; + } + let cfg: serde_json::Value = serde_json::from_str(&config_str).unwrap_or_default(); + // P13:渠道密钥可能是 AES-GCM 密文,先解密 + let cfg = super::crypto::decrypt_config(&cfg, secret_key); + let r = match kind.as_str() { + "telegram" => telegram::send(http, &cfg, &text_of(ev)).await, + "webhook" => webhook::send(http, &cfg, ev).await, + "email" => email::send(http, &cfg, ev).await, + k => Err(anyhow::anyhow!("未知渠道类型: {k}")), + }; + match r { + Ok(()) => ok += 1, + Err(e) => errs.push(truncate(&e.to_string(), 150)), + } + } + if ok > 0 { + Ok(()) + } else if errs.is_empty() { + Err(SendError::Other(anyhow::anyhow!( + "规则绑定的渠道均不可用(已删除或被禁用)" + ))) + } else { + Err(SendError::Other(anyhow::anyhow!("{}", errs.join(";")))) + } +} + +/// Telegram 渠道的发送文本 +fn text_of(ev: &AlertEvent) -> String { + format!("{}\n\n{}", ev.title, ev.body) +} + +/// 直接用草稿发送到某个渠道(用于"发送测试"按钮,不经过规则与事件表) +pub async fn send_draft_to_channel( + pool: &SqlitePool, + http: &Client, + channel_id: i64, + draft: &AlertDraft, + secret_key: Option<&str>, +) -> anyhow::Result<()> { + let ch: Option<(String, String, i64)> = + sqlx::query_as("SELECT kind, config, enabled FROM alert_channels WHERE id = ?") + .bind(channel_id) + .fetch_optional(pool) + .await?; + let (kind, config_str, enabled) = ch.ok_or_else(|| anyhow::anyhow!("渠道不存在"))?; + if enabled == 0 { + anyhow::bail!("渠道已禁用"); + } + let cfg: serde_json::Value = serde_json::from_str(&config_str).unwrap_or_default(); + let cfg = super::crypto::decrypt_config(&cfg, secret_key); + let ev = AlertEvent { + id: 0, + rule_id: None, + event_type: draft.event_type.to_string(), + dedup_key: draft.dedup_key.clone(), + severity: draft.severity.to_string(), + title: draft.title.clone(), + body: draft.body.clone(), + resource_type: draft.resource_type.as_ref().map(|s| s.to_string()), + resource_id: draft.resource_id, + status: "pending".into(), + attempts: 0, + next_retry_at: None, + last_error: None, + created_at: String::new(), + sent_at: None, + }; + match kind.as_str() { + "telegram" => telegram::send(http, &cfg, &text_of(&ev)).await, + "webhook" => webhook::send(http, &cfg, &ev).await, + "email" => email::send(http, &cfg, &ev).await, + k => Err(anyhow::anyhow!("未知渠道类型: {k}")), + } +} + +fn truncate(s: &str, n: usize) -> String { + if s.chars().count() > n { + s.chars().take(n).collect::() + "…" + } else { + s.to_string() + } +} diff --git a/src/services/alert/email.rs b/src/services/alert/email.rs new file mode 100644 index 0000000..daede8b --- /dev/null +++ b/src/services/alert/email.rs @@ -0,0 +1,75 @@ +//! P12 —— Email 渠道发送(§14.2,lettre SMTP) +//! +//! config:{host, port, tls("starttls"|"plain"), username, password, from, to:[...]} + +use anyhow::Context; +use reqwest::Client; +use serde_json::Value; + +use lettre::message::{header, MultiPart, SinglePart}; +use lettre::transport::smtp::authentication::Credentials; +use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor}; + +use super::AlertEvent; + +pub async fn send(_http: &Client, cfg: &Value, ev: &AlertEvent) -> anyhow::Result<()> { + let host = cfg + .get("host") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .context("缺少 host")?; + let port = cfg.get("port").and_then(|v| v.as_u64()).unwrap_or(587) as u16; + let from = cfg + .get("from") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .context("缺少 from")?; + let to_list: Vec = cfg + .get("to") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str()) + .map(|s| s.to_string()) + .collect() + }) + .unwrap_or_default(); + if to_list.is_empty() { + anyhow::bail!("收件人 to 列表为空"); + } + + let mut builder = match cfg.get("tls").and_then(|v| v.as_str()).unwrap_or("starttls") { + "plain" => AsyncSmtpTransport::::builder_dangerous(host), + _ => AsyncSmtpTransport::::starttls_relay(host) + .map_err(|e| anyhow::anyhow!("SMTP 配置失败: {e}"))?, + }; + builder = builder.port(port); + if let (Some(u), Some(p)) = ( + cfg.get("username").and_then(|v| v.as_str()), + cfg.get("password").and_then(|v| v.as_str()), + ) { + if !u.is_empty() { + builder = builder.credentials(Credentials::new(u.to_string(), p.to_string())); + } + } + let mailer = builder.build(); + + let mut msg = Message::builder().from(from.parse().map_err(|e| anyhow::anyhow!("from 无效: {e}"))?); + for to in &to_list { + msg = msg.to(to.parse().map_err(|e| anyhow::anyhow!("to 无效 {to}: {e}"))?); + } + let email = msg + .subject(format!("[RustProxyAdmin 告警] {}", ev.title)) + .header(header::ContentType::TEXT_PLAIN) + .multipart( + MultiPart::alternative() + .singlepart(SinglePart::plain(ev.body.clone())), + ) + .map_err(|e| anyhow::anyhow!("构建邮件失败: {e}"))?; + + mailer + .send(email) + .await + .map_err(|e| anyhow::anyhow!("SMTP 发送失败: {e}"))?; + Ok(()) +} diff --git a/src/services/alert/emit.rs b/src/services/alert/emit.rs new file mode 100644 index 0000000..6de704f --- /dev/null +++ b/src/services/alert/emit.rs @@ -0,0 +1,41 @@ +//! P12 —— 事件写入(规则匹配 + 冷却去重,§14.4) + +use sqlx::SqlitePool; + +use super::{AlertDraft, AlertRule}; +use crate::error::AppError; + +/// 事件源唯一入口:匹配已启用的同 event_type 规则,冷却窗口内去重后写入 alert_events。 +pub async fn emit(pool: &SqlitePool, a: AlertDraft) -> Result<(), AppError> { + let rules: Vec = sqlx::query_as( + "SELECT * FROM alert_rules WHERE enabled = 1 AND event_type = ?", + ) + .bind(a.event_type) + .fetch_all(pool) + .await?; + for r in rules { + let window = format!("-{} seconds", r.cooldown_secs.max(0)); + sqlx::query( + "INSERT INTO alert_events + (rule_id, event_type, dedup_key, severity, title, body, resource_type, resource_id, status) + SELECT ?, ?, ?, ?, ?, ?, ?, ?, 'pending' + WHERE NOT EXISTS ( + SELECT 1 FROM alert_events + WHERE rule_id = ? AND dedup_key = ? AND created_at > datetime('now', ?))", + ) + .bind(r.id) + .bind(a.event_type) + .bind(&a.dedup_key) + .bind(a.severity) + .bind(&a.title) + .bind(&a.body) + .bind(&a.resource_type) + .bind(a.resource_id) + .bind(r.id) + .bind(&a.dedup_key) + .bind(&window) + .execute(pool) + .await?; + } + Ok(()) +} diff --git a/src/services/alert/mod.rs b/src/services/alert/mod.rs new file mode 100644 index 0000000..70610a2 --- /dev/null +++ b/src/services/alert/mod.rs @@ -0,0 +1,388 @@ +//! P12 —— 告警服务 +//! +//! 结构(§14.1):事件源调用 [`emit`] 写入 `alert_events`(status=pending,规则匹配 + 冷却去重) +//! → [`dispatcher_loop`] 每 10 秒取到期事件,按规则绑定的渠道发送,失败指数退避。 + +pub mod crypto; +pub mod dispatcher; +pub mod email; +pub mod emit; +pub mod telegram; +pub mod webhook; + +pub use dispatcher::dispatcher_loop; +pub use emit::emit; + +use chrono::NaiveDateTime; +use serde::{Deserialize, Serialize}; +use sqlx::FromRow; + +/// 告警草稿(§14.4):业务代码只构造它,`emit` 负责按规则匹配 + 冷却去重后写库。 +#[derive(Debug, Clone)] +pub struct AlertDraft { + pub event_type: &'static str, + pub dedup_key: String, + pub severity: &'static str, // info | warning | critical + pub title: String, + pub body: String, + pub resource_type: Option, + pub resource_id: Option, +} + +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct AlertRule { + pub id: i64, + pub name: String, + pub event_type: String, + pub params: String, // JSON 阈值参数 + pub channel_ids: String, // JSON 数组 + pub cooldown_secs: i64, + pub enabled: i64, + pub created_at: String, + pub updated_at: String, +} + +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct AlertChannel { + pub id: i64, + pub name: String, + pub kind: String, // webhook | telegram | email + pub config: String, + pub enabled: i64, + pub created_at: String, + pub updated_at: String, +} + +#[derive(Debug, Clone, FromRow, Serialize)] +pub struct AlertEvent { + pub id: i64, + pub rule_id: Option, + pub event_type: String, + pub dedup_key: String, + pub severity: String, + pub title: String, + pub body: String, + pub resource_type: Option, + pub resource_id: Option, + pub status: String, // pending | sent | failed + pub attempts: i64, + pub next_retry_at: Option, + pub last_error: Option, + pub created_at: String, + pub sent_at: Option, +} + +/// UTC 时间字符串("YYYY-MM-DD HH:MM:SS")→ 本地时区显示(默认 +8) +pub fn local_time_str(utc: &str, tz_offset_hours: i64) -> String { + match NaiveDateTime::parse_from_str(utc.trim(), "%Y-%m-%d %H:%M:%S") { + Ok(dt) => (dt + chrono::Duration::hours(tz_offset_hours)) + .format("%Y-%m-%d %H:%M") + .to_string(), + Err(_) => utc.to_string(), + } +} + +#[derive(Debug, Deserialize, Default)] +pub struct DaysBefore { + #[serde(default)] + pub days_before: Vec, +} + +impl DaysBefore { + /// 取"大于等于 days_left 的最小阈值"(§14.5:只发最贴近当前剩余天数的一条) + pub fn pick(&self, days_left: i64) -> Option { + let mut ds = self.days_before.clone(); + ds.sort_unstable(); + ds.into_iter().find(|d| *d >= days_left) + } +} + +/// 剩余天数:ceil((expires_at - now) / 1 天);已过期返回 None +pub fn days_left(expires_at_utc: &str, now: NaiveDateTime) -> Option { + let exp = NaiveDateTime::parse_from_str(expires_at_utc.trim(), "%Y-%m-%d %H:%M:%S").ok()?; + let secs = (exp - now).num_seconds(); + if secs <= 0 { + return None; + } + Some((secs + 86399) / 86400) +} + +fn reason_hash(s: &str) -> String { + use sha2::Digest; + let mut h = sha2::Sha256::new(); + h.update(s.as_bytes()); + hex::encode(&h.finalize())[..16].to_string() +} + +pub fn fmt_bytes(v: i64) -> String { + const U: &[&str] = &["B", "KB", "MB", "GB", "TB"]; + let mut f = v.max(0) as f64; + let mut i = 0; + while f >= 1024.0 && i + 1 < U.len() { + f /= 1024.0; + i += 1; + } + format!("{f:.1} {}", U[i]) +} + +impl AlertDraft { + pub fn node_offline( + node_id: i64, + name: &str, + last_hb: &str, + offline_secs: i64, + tz: i64, + base: &str, + ) -> Self { + Self { + event_type: "node_offline", + dedup_key: format!("node_offline:{node_id}:{last_hb}"), + severity: "critical", + title: format!("🔴 节点离线:{name}"), + body: format!( + "已超过 {offline_secs} 秒未收到心跳。\n最后心跳:{}(本地)\n后台:{base}/admin/nodes", + local_time_str(last_hb, tz) + ), + resource_type: Some("node".to_string()), + resource_id: Some(node_id), + } + } + + pub fn node_online(node_id: i64, name: &str, base: &str) -> Self { + Self { + event_type: "node_online", + dedup_key: format!( + "node_online:{node_id}:{}", + chrono::Utc::now().format("%Y-%m-%dT%H") + ), + severity: "info", + title: format!("🟢 节点恢复:{name}"), + body: format!("节点 {name} 已重新上线。\n后台:{base}/admin/nodes"), + resource_type: Some("node".to_string()), + resource_id: Some(node_id), + } + } + + pub fn sub_expired(id: i64, name: &str, base: &str) -> Self { + Self { + event_type: "sub_expired", + dedup_key: format!("sub_expired:{id}"), + severity: "warning", + title: format!("🟡 订阅已到期并停用:{name}"), + body: format!( + "订阅「{name}」已到期,已自动停用。\n后台:{base}/admin/subscriptions/{id}" + ), + resource_type: Some("subscription".to_string()), + resource_id: Some(id), + } + } + + pub fn sub_expiring( + id: i64, + name: &str, + days: i64, + expires_at: &str, + tz: i64, + base: &str, + ) -> Self { + Self { + event_type: "sub_expiring", + dedup_key: format!("sub_expiring:{id}:{days}:{expires_at}"), + severity: "warning", + title: format!("🟡 订阅即将到期:{name}"), + body: format!( + "剩余 {days} 天,到期时间 {}(本地)\n后台:{base}/admin/subscriptions/{id}", + local_time_str(expires_at, tz) + ), + resource_type: Some("subscription".to_string()), + resource_id: Some(id), + } + } + + pub fn entry_expired( + entry_type: &str, + id: i64, + name: &str, + expires_at: &str, + base: &str, + ) -> Self { + Self { + event_type: "entry_expired", + dedup_key: format!("entry_expired:{entry_type}:{id}:{expires_at}"), + severity: "warning", + title: format!("🟡 条目已到期自动停用:{name}"), + body: format!( + "{entry_type}/{id}「{name}」已到期,已自动停用。\n后台:{base}/admin" + ), + resource_type: Some(entry_type.to_string()), + resource_id: Some(id), + } + } + + pub fn entry_expiring( + entry_type: &str, + id: i64, + name: &str, + days: i64, + expires_at: &str, + tz: i64, + base: &str, + ) -> Self { + Self { + event_type: "entry_expiring", + dedup_key: format!("entry_expiring:{entry_type}:{id}:{days}:{expires_at}"), + severity: "warning", + title: format!("🟡 条目即将到期:{name}"), + body: format!( + "{entry_type}/{id}「{name}」剩余 {days} 天,到期时间 {}(本地)\n后台:{base}/admin", + local_time_str(expires_at, tz) + ), + resource_type: Some(entry_type.to_string()), + resource_id: Some(id), + } + } + + pub fn quota_warn( + entry_type: &str, + id: i64, + name: &str, + period_start: &str, + pct: i64, + used: i64, + quota: i64, + base: &str, + ) -> Self { + Self { + event_type: "quota_warn", + // 同一周期同一阈值只发一次(§14.5) + dedup_key: format!("quota_warn:{entry_type}:{id}:{period_start}:{pct}"), + severity: "warning", + title: format!("🟠 流量预警:{name}"), + body: format!( + "已使用 {} / {}({pct}%)\n后台:{base}/admin/quotas", + fmt_bytes(used), + fmt_bytes(quota) + ), + resource_type: Some(entry_type.to_string()), + resource_id: Some(id), + } + } + + pub fn quota_exceeded( + entry_type: &str, + id: i64, + name: &str, + period_start: &str, + tz: i64, + base: &str, + ) -> Self { + Self { + event_type: "quota_exceeded", + dedup_key: format!("quota_exceeded:{entry_type}:{id}:{period_start}"), + severity: "critical", + title: format!("🔴 流量用尽,已自动停用:{name}"), + body: format!( + "{entry_type}/{id}「{name}」流量已用尽,已自动停用。\n下个周期重置:{}(本地)\n后台:{base}/admin/quotas", + local_time_str(period_start, tz) + ), + resource_type: Some(entry_type.to_string()), + resource_id: Some(id), + } + } + + pub fn runtime_error( + entry_type: &str, + id: i64, + name: &str, + reason: &str, + node: Option<&str>, + base: &str, + ) -> Self { + let where_ = node.map(|n| format!("(节点 {n})")).unwrap_or_default(); + Self { + event_type: "runtime_error", + dedup_key: format!( + "runtime_error:{entry_type}:{id}:{}", + reason_hash(reason) + ), + severity: "warning", + title: format!("🟡 条目运行异常{where_}:{name}"), + body: format!( + "{entry_type}/{id}「{name}」运行异常:{reason}\n后台:{base}/admin/monitor/rules" + ), + resource_type: Some(entry_type.to_string()), + resource_id: Some(id), + } + } + + pub fn login_fail_burst(ip: &str, fails: i64, lock_minutes: i64, base: &str) -> Self { + Self { + event_type: "login_fail_burst", + dedup_key: format!( + "login_fail_burst:{ip}:{}", + chrono::Utc::now().format("%Y-%m-%dT%H") + ), + severity: "warning", + title: format!("🟡 登录失败激增:{ip}"), + body: format!( + "IP {ip} 短时间内登录失败 {fails} 次,已锁定 {lock_minutes} 分钟。\n后台:{base}/admin/logs/audit" + ), + resource_type: None, + resource_id: None, + } + } + + pub fn sub_ip_limit(sub_id: i64, name: &str, max_ips: i64, base: &str) -> Self { + Self { + event_type: "sub_ip_limit", + dedup_key: format!( + "sub_ip_limit:{sub_id}:{}", + chrono::Utc::now().format("%Y-%m-%dT%H") + ), + severity: "info", + title: format!("ℹ️ 订阅触发 IP 限制:{name}"), + body: format!( + "订阅「{name}」同时在线 IP 数达到上限({max_ips}),已有请求被拒绝。\n后台:{base}/admin/subscriptions/{sub_id}" + ), + resource_type: Some("subscription".to_string()), + resource_id: Some(sub_id), + } + } + + pub fn test_channel(name: &str, kind: &str, base: &str) -> Self { + let nonce = chrono::Utc::now().timestamp_nanos_opt().unwrap_or(0); + Self { + event_type: "__test__", + dedup_key: format!("__test__:{nonce}"), + severity: "info", + title: format!("✅ 告警渠道测试:{name}"), + body: format!( + "这是一条来自 RustProxyAdmin 的测试告警(渠道类型:{kind})。\n后台:{base}/admin/alerts" + ), + resource_type: None, + resource_id: None, + } + } +} + +/// 规则参数里读 days_before(无/非法时返回默认 [7,3,1]) +pub fn days_before_of(params_json: &str) -> Vec { + serde_json::from_str::(params_json) + .map(|d| d.days_before) + .ok() + .filter(|v| !v.is_empty()) + .unwrap_or_else(|| vec![7, 3, 1]) +} + +/// 规则参数里读 offline_secs(默认 90) +pub fn offline_secs_of(params_json: &str) -> i64 { + #[derive(Deserialize)] + struct P { + #[serde(default)] + offline_secs: i64, + } + match serde_json::from_str::

(params_json) { + Ok(p) if p.offline_secs > 0 => p.offline_secs, + _ => 90, + } +} diff --git a/src/services/alert/telegram.rs b/src/services/alert/telegram.rs new file mode 100644 index 0000000..2c9b97d --- /dev/null +++ b/src/services/alert/telegram.rs @@ -0,0 +1,86 @@ +//! P12 —— Telegram 渠道发送 +//! +//! POST https://api.telegram.org/bot/sendMessage。 +//! 安全:URL 里含 bot_token,日志与错误信息中不得打印完整 URL。 + +use anyhow::Context; +use reqwest::Client; +use serde_json::Value; + +/// 发送纯文本消息(Markdown 未启用时按原样显示)。 +pub async fn send(http: &Client, cfg: &Value, text: &str) -> anyhow::Result<()> { + let token = cfg + .get("bot_token") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .context("缺少 bot_token")?; + let chat = cfg + .get("chat_id") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .context("缺少 chat_id")?; + + let client = if let Some(proxy) = cfg.get("proxy").and_then(|v| v.as_str()).filter(|s| !s.is_empty()) { + let p = reqwest::Proxy::all(proxy).context("代理地址无效")?; + Client::builder().proxy(p).build().context("构建代理客户端失败")? + } else { + http.clone() + }; + + let resp = client + .post(format!("https://api.telegram.org/bot{token}/sendMessage")) + .json(&serde_json::json!({ + "chat_id": chat, + "text": text, + "disable_web_page_preview": true, + })) + .timeout(std::time::Duration::from_secs(15)) + .send() + .await + // reqwest 的错误可能把 URL(含 token)带出来,截断 URL 部分 + .map_err(|e| anyhow::anyhow!("Telegram 请求失败: {}", scrub_url(&e.to_string())))?; + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + anyhow::bail!( + "Telegram 返回 HTTP {}: {}", + status, + truncate(&body, 200) + ); + } + Ok(()) +} + +/// 错误文本中移除疑似 bot URL 的片段(形如 bot/...) +fn scrub_url(s: &str) -> String { + let re = regex_lite_token(s); + re +} + +fn regex_lite_token(s: &str) -> String { + // 无 regex 依赖:手工把 "bot<...>/" 片段替换掉 + let mut out = String::with_capacity(s.len()); + let bytes = s.as_bytes(); + let mut i = 0; + while i < bytes.len() { + if s[i..].starts_with("bot") && i + 4 < bytes.len() && !s[i + 3..i + 4].eq("/") { + // 从 "bot" 起到下一个 '/' 为止全部替换 + if let Some(end) = s[i..].find('/') { + out.push_str("bot/"); + i += end + 1; + continue; + } + } + out.push(bytes[i] as char); + i += 1; + } + out +} + +fn truncate(s: &str, n: usize) -> String { + if s.chars().count() > n { + s.chars().take(n).collect::() + "…" + } else { + s.to_string() + } +} diff --git a/src/services/alert/webhook.rs b/src/services/alert/webhook.rs new file mode 100644 index 0000000..77c32dc --- /dev/null +++ b/src/services/alert/webhook.rs @@ -0,0 +1,67 @@ +//! P12 —— Webhook 渠道发送(§14.2) +//! +//! POST JSON:{event_type, severity, title, body, resource_type, resource_id, created_at} +//! 配置 secret 时附带 `X-RPA-Signature: sha256=`。 + +use anyhow::Context; +use hmac::{Hmac, Mac}; +use reqwest::Client; +use serde_json::Value; +use sha2::Sha256; + +use super::AlertEvent; + +/// 发送一条告警事件到 webhook。 +pub async fn send(http: &Client, cfg: &Value, ev: &AlertEvent) -> anyhow::Result<()> { + let url = cfg + .get("url") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .context("缺少 url")?; + + let body = serde_json::json!({ + "event_type": ev.event_type, + "severity": ev.severity, + "title": ev.title, + "body": ev.body, + "resource_type": ev.resource_type, + "resource_id": ev.resource_id, + "created_at": ev.created_at, + }); + let body_str = serde_json::to_string(&body)?; + + let mut req = http + .post(url) + .header("Content-Type", "application/json") + .body(body_str.clone()) + .timeout(std::time::Duration::from_secs(15)); + + // 可选签名 + if let Some(secret) = cfg.get("secret").and_then(|v| v.as_str()).filter(|s| !s.is_empty()) { + req = req.header("X-RPA-Signature", sign(secret, body_str.as_bytes())); + } + // 可选自定义请求头 + if let Some(headers) = cfg.get("headers").and_then(|v| v.as_object()) { + for (k, v) in headers { + if let Some(vs) = v.as_str() { + req = req.header(k.as_str(), vs); + } + } + } + + let resp = req + .send() + .await + .map_err(|e| anyhow::anyhow!("Webhook 请求失败: {}", e.without_url()))?; + if !resp.status().is_success() { + anyhow::bail!("Webhook 返回 HTTP {}", resp.status()); + } + Ok(()) +} + +fn sign(secret: &str, body: &[u8]) -> String { + let mut mac = Hmac::::new_from_slice(secret.as_bytes()) + .expect("HMAC key"); + mac.update(body); + format!("sha256={}", hex::encode(mac.finalize().into_bytes())) +} diff --git a/src/services/cleaner.rs b/src/services/cleaner.rs new file mode 100644 index 0000000..f6d0f6d --- /dev/null +++ b/src/services/cleaner.rs @@ -0,0 +1,68 @@ +//! P7 —— 日志清理后台任务: +//! 每天凌晨(本地时区)按保留期清理 access_logs / subscription_logs / audit_logs, +//! 清理后执行 PRAGMA wal_checkpoint(TRUNCATE)。 + +use sqlx::SqlitePool; + +use crate::config::LogCfg; + +/// 执行一轮清理,返回各表删除行数 +pub async fn cleanup_once(pool: &SqlitePool, cfg: &LogCfg) -> anyhow::Result> { + let mut out = Vec::new(); + for (table, col, days) in [ + ("access_logs", "started_at", cfg.access_log_retention_days), + ( + "subscription_logs", + "created_at", + cfg.subscription_log_retention_days, + ), + ("audit_logs", "created_at", cfg.audit_log_retention_days), + // 指标快照保留 7 天(与 access 日志保留期解耦,避免图表断档) + ("metrics_snapshots", "collected_at", 7), + ] { + if days <= 0 { + continue; + } + let r = sqlx::query(&format!( + "DELETE FROM {table} WHERE {col} < datetime('now', ?)" + )) + .bind(format!("-{} days", days)) + .execute(pool) + .await?; + out.push((table.to_string(), r.rows_affected())); + } + let _ = sqlx::query("PRAGMA wal_checkpoint(TRUNCATE)") + .execute(pool) + .await; + Ok(out) +} + +/// 距离下一次本地时区 03:00 的时长 +fn until_next_3am() -> std::time::Duration { + let now = chrono::Local::now(); + let next = now + .date_naive() + .and_hms_opt(3, 0, 0) + .map(|t| { + let dt = t.and_local_timezone(chrono::Local).single().unwrap_or(now); + if dt <= now { dt + chrono::Duration::days(1) } else { dt } + }) + .unwrap_or(now + chrono::Duration::days(1)); + (next - now).to_std().unwrap_or(std::time::Duration::from_secs(3600)) +} + +/// 后台任务:每天凌晨 03:00(本地时区)执行清理 +pub async fn run_cleaner(pool: SqlitePool, cfg: LogCfg) { + loop { + tokio::time::sleep(until_next_3am()).await; + match cleanup_once(&pool, &cfg).await { + Ok(rows) => { + let total: u64 = rows.iter().map(|(_, n)| n).sum(); + if total > 0 { + tracing::info!("日志清理完成,删除 {} 行: {:?}", total, rows); + } + } + Err(e) => tracing::error!("日志清理失败: {e:#}"), + } + } +} diff --git a/src/services/export/base64.rs b/src/services/export/base64.rs new file mode 100644 index 0000000..e4e29b7 --- /dev/null +++ b/src/services/export/base64.rs @@ -0,0 +1,68 @@ +//! P4 —— Base64 订阅导出(每行一个代理 URI,整体 base64) + +use crate::models::ExportNode; +use base64::engine::general_purpose::STANDARD; +use base64::Engine; + +/// 各协议 URI: +/// - ss://base64(method:password)@host:port#name +/// - trojan://password@host:port#name +/// - vless://uuid@host:port?encryption=none#name +/// - socks5://[user:pass@]host:port#name +/// - http(s)://[user:pass@]host:port#name +pub fn render(nodes: &[ExportNode], _sub_name: &str) -> (String, &'static str, &'static str) { + // 每行一个 URI;无法转成 URI 的条目跳过 + let mut lines: Vec = Vec::new(); + for n in nodes.iter().filter(|n| n.entry_type == "proxy") { + if let Some(uri) = proxy_uri(n) { + lines.push(uri); + } + } + let joined = lines.join("\n"); + (STANDARD.encode(joined), "text/plain; charset=utf-8", "txt") +} + +fn enc(s: &str) -> String { + urlencoding::encode(s).into_owned() +} + +fn userinfo(n: &ExportNode) -> String { + match (&n.username, &n.password) { + (Some(u), Some(p)) => format!("{}:{}@", enc(u), enc(p)), + (Some(u), None) => format!("{}@", enc(u)), + _ => String::new(), + } +} + +fn proxy_uri(n: &ExportNode) -> Option { + let name = enc(&n.name); + Some(match n.kind.as_str() { + "ss" => { + let method = "aes-256-gcm"; // 未存储加密方式,默认 + let pw = n.password.as_deref().unwrap_or(""); + let up = STANDARD.encode(format!("{method}:{pw}")); + format!("ss://{up}@{}:{}#{}", n.host, n.port, name) + } + "trojan" => { + let pw = enc(n.password.as_deref().unwrap_or("")); + format!("trojan://{pw}@{}:{}#{}", n.host, n.port, name) + } + "vless" => { + let uuid = enc(n.password.as_deref().unwrap_or("")); + format!("vless://{uuid}@{}:{}?encryption=none#{}", n.host, n.port, name) + } + "vmess" => { + // vmess URI 需要完整 JSON,这里只能输出最小可用结构 + let vm = serde_json::json!({ + "v": "2", "ps": n.name, "add": n.host, "port": n.port.to_string(), + "id": n.password.as_deref().unwrap_or(""), "aid": "0", + "net": "tcp", "type": "none", "host": "", "path": "", "tls": "", + }); + format!("vmess://{}", STANDARD.encode(vm.to_string())) + } + "socks5" => format!("socks5://{}{}:{}#{}", userinfo(n), n.host, n.port, name), + "https" => format!("https://{}{}:{}#{}", userinfo(n), n.host, n.port, name), + "http" => format!("http://{}{}:{}#{}", userinfo(n), n.host, n.port, name), + _ => return None, // 无法转成 URI 的条目跳过 + }) +} diff --git a/src/services/export/clash.rs b/src/services/export/clash.rs new file mode 100644 index 0000000..0d34fd8 --- /dev/null +++ b/src/services/export/clash.rs @@ -0,0 +1,132 @@ +//! Clash YAML 导出(仅代理类条目) +//! +//! 无模板时输出 proxies + select 类型的 PROXY 组 + url-test 类型的 AUTO 组 + rules [MATCH,PROXY]。 +//! 带 extra 的条目把 extra 字段合并进去(extra 优先,最后强制覆盖 name)。 + +use crate::models::ExportNode; + +/// 渲染 clash 配置。返回 (content, content_type, filename_ext) +pub fn render(nodes: &[ExportNode], sub_name: &str) -> (String, &'static str, &'static str) { + let proxies: Vec<&ExportNode> = nodes.iter().filter(|n| n.entry_type == "proxy").collect(); + let mut out = String::new(); + out.push_str(&format!("# {sub_name} - 由 RustProxyAdmin 生成\n")); + out.push_str("proxies:\n"); + for n in &proxies { + out.push_str(&render_proxy(n)); + } + let names: Vec = proxies.iter().map(|n| n.name.clone()).collect(); + out.push_str("proxy-groups:\n"); + out.push_str(" - name: PROXY\n type: select\n proxies:\n - AUTO\n"); + for nm in &names { + out.push_str(&format!(" - {}\n", yaml_str(nm))); + } + if names.is_empty() { + out.push_str(" - DIRECT\n"); + } + out.push_str(" - name: AUTO\n type: url-test\n url: \"http://www.gstatic.com/generate_204\"\n interval: 300\n proxies:\n"); + for nm in &names { + out.push_str(&format!(" - {}\n", yaml_str(nm))); + } + out.push_str("rules:\n - MATCH,PROXY\n"); + (out, "text/yaml; charset=utf-8", "yaml") +} + +fn render_proxy(n: &ExportNode) -> String { + let name = yaml_str(&n.name); + let mut s = format!(" - name: {name}\n"); + match n.kind.as_str() { + "ss" => { + s.push_str(" type: ss\n"); + s.push_str(&format!(" server: {}\n port: {}\n", n.host, n.port)); + // 本系统未存储加密方式,默认 aes-256-gcm;如需指定请使用自定义模板 + s.push_str(" cipher: aes-256-gcm\n"); + s.push_str(&format!( + " password: {}\n", + yaml_str(n.password.as_deref().unwrap_or("")) + )); + s.push_str(" udp: true\n"); + } + "trojan" => { + s.push_str(" type: trojan\n"); + s.push_str(&format!(" server: {}\n port: {}\n", n.host, n.port)); + s.push_str(&format!( + " password: {}\n", + yaml_str(n.password.as_deref().unwrap_or("")) + )); + s.push_str(" udp: true\n"); + } + "vless" => { + s.push_str(" type: vless\n"); + s.push_str(&format!(" server: {}\n port: {}\n", n.host, n.port)); + s.push_str(&format!( + " uuid: {}\n", + yaml_str(n.password.as_deref().unwrap_or("")) + )); + s.push_str(" encryption: none\n udp: true\n"); + } + "vmess" => { + // extra 里若有完整 vmess 参数则合并;否则输出最小可用结构 + s.push_str(" type: vmess\n"); + s.push_str(&format!(" server: {}\n port: {}\n", n.host, n.port)); + s.push_str(&format!( + " uuid: {}\n", + yaml_str(n.password.as_deref().unwrap_or("")) + )); + s.push_str(" alterId: 0\n cipher: auto\n udp: true\n"); + } + "socks5" => { + s.push_str(" type: socks5\n"); + s.push_str(&format!(" server: {}\n port: {}\n", n.host, n.port)); + if let Some(u) = &n.username { + s.push_str(&format!(" username: {}\n", yaml_str(u))); + } + if let Some(p) = &n.password { + s.push_str(&format!(" password: {}\n", yaml_str(p))); + } + s.push_str(" udp: true\n"); + } + _ => { + // http / https + s.push_str(" type: http\n"); + s.push_str(&format!(" server: {}\n port: {}\n", n.host, n.port)); + if n.kind == "https" { + s.push_str(" tls: true\n"); + } + if let Some(u) = &n.username { + s.push_str(&format!(" username: {}\n", yaml_str(u))); + } + if let Some(p) = &n.password { + s.push_str(&format!(" password: {}\n", yaml_str(p))); + } + } + } + // 合并 extra(extra 优先),最后强制覆盖 name + if let Some(ex) = n.extra.as_ref().and_then(|v| v.as_object()) { + for (k, v) in ex { + if k == "name" { + continue; + } + s.push_str(&format!(" {k}: {}\n", yaml_scalar(v))); + } + } + s +} + +fn yaml_scalar(v: &serde_json::Value) -> String { + match v { + serde_json::Value::String(s) => yaml_str(s), + serde_json::Value::Number(n) => n.to_string(), + serde_json::Value::Bool(b) => b.to_string(), + serde_json::Value::Null => "null".to_string(), + other => yaml_str(&other.to_string()), + } +} + +fn yaml_str(s: &str) -> String { + // 简单转义:含特殊字符则加双引号 + if s.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_' || c == '.') && !s.is_empty() { + s.to_string() + } else { + format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\"")) + } +} diff --git a/src/services/export/json.rs b/src/services/export/json.rs new file mode 100644 index 0000000..92f7298 --- /dev/null +++ b/src/services/export/json.rs @@ -0,0 +1,11 @@ +//! JSON 导出:ExportNode 数组,供自研客户端或脚本使用。不使用模板。 + +use crate::models::ExportNode; + +pub fn render(nodes: &[ExportNode], _sub_name: &str) -> (String, &'static str, &'static str) { + ( + serde_json::to_string_pretty(&nodes).unwrap_or_default(), + "application/json; charset=utf-8", + "json", + ) +} diff --git a/src/services/export/mod.rs b/src/services/export/mod.rs new file mode 100644 index 0000000..4d74cf9 --- /dev/null +++ b/src/services/export/mod.rs @@ -0,0 +1,11 @@ +//! P4 订阅导出服务 + +pub mod base64; +pub mod clash; +pub mod json; +pub mod resolve; +pub mod singbox; +pub mod template; + +pub use resolve::resolve_entries; +pub use template::{render_subscription, validate_format}; diff --git a/src/services/export/resolve.rs b/src/services/export/resolve.rs new file mode 100644 index 0000000..583ffce --- /dev/null +++ b/src/services/export/resolve.rs @@ -0,0 +1,381 @@ +//! 订阅导出 —— 范围解析(文档 §9.2)与条目 → ExportNode(文档 §9.1)。 +//! +//! 解析顺序: +//! 1. 基础集合:all → 三张表全部启用条目;single/custom → subscription_entries; +//! rule → include 规则并集 + subscription_entries 例外条目; +//! 2. 减去 exclude 规则命中的条目(custom 与 rule 都支持); +//! 3. 过滤掉 enabled = 0 的条目; +//! 4. 按分组排序 → 条目 id 排序(custom 按 sort_order)。 + +use sqlx::SqlitePool; + +use crate::error::{AppError, AppResult}; + +use crate::models::{EntryRef, ExportNode, SubRule, Subscription}; +use crate::util::table_of; + +/// 计算订阅实际命中的条目引用 +pub async fn resolve_entries(pool: &SqlitePool, sub: &Subscription) -> AppResult> { + let mut set: std::collections::BTreeSet<(String, i64)> = Default::default(); + // custom 的手工条目需要 sort_order,单独记 + let mut custom_order: std::collections::HashMap<(String, i64), i64> = + std::collections::HashMap::new(); + + if sub.scope == "all" { + for t in ["proxy", "forward", "tunnel"] { + let table = table_of(t)?; + let sql = format!("SELECT id FROM {table} WHERE enabled = 1"); + for (id,) in sqlx::query_as::<_, (i64,)>(&sql).fetch_all(pool).await? { + set.insert((t.to_string(), id)); + } + } + } else { + for e in sqlx::query_as::<_, (String, i64, i64)>( + "SELECT entry_type, entry_id, sort_order FROM subscription_entries WHERE subscription_id = ?", + ) + .bind(sub.id) + .fetch_all(pool) + .await? + { + set.insert((e.0.clone(), e.1)); + custom_order.insert((e.0, e.1), e.2); + } + let rules: Vec = + sqlx::query_as("SELECT * FROM subscription_rules WHERE subscription_id = ?") + .bind(sub.id) + .fetch_all(pool) + .await?; + if sub.scope == "rule" { + for r in rules.iter().filter(|r| r.mode == "include") { + for k in match_rule(pool, r).await? { + set.insert(k); + } + } + } else if sub.scope != "single" && sub.scope != "custom" { + return Err(AppError::bad(format!("未知订阅范围:{}", sub.scope))); + } + for r in rules.iter().filter(|r| r.mode == "exclude") { + for k in match_rule(pool, r).await? { + set.remove(&k); + custom_order.remove(&k); + } + } + } + + // 过滤 enabled = 0 的条目 + let mut out: Vec = Vec::new(); + for (t, id) in set { + let table = table_of(&t)?; + let sql = format!("SELECT enabled FROM {table} WHERE id = ?"); + if let Some((en,)) = + sqlx::query_as::<_, (i64,)>(&sql).bind(id).fetch_optional(pool).await? + { + if en == 1 { + out.push(EntryRef { entry_type: t, entry_id: id }); + } + } + } + + // 排序:custom 按 sort_order;其余按分组排序 → 条目 id + if sub.scope == "custom" { + out.sort_by_key(|e| { + ( + custom_order + .get(&(e.entry_type.clone(), e.entry_id)) + .copied() + .unwrap_or(i64::MAX), + e.entry_id, + ) + }); + } else { + // 分组排序:(group_sort_order, group_id) → id + let mut keyed: Vec<((i64, i64, i64), EntryRef)> = Vec::new(); + for e in out { + let table = table_of(&e.entry_type)?; + let g: Option<(i64,)> = sqlx::query_as(&format!( + "SELECT group_id FROM {table} WHERE id = ?" + )) + .bind(e.entry_id) + .fetch_optional(pool) + .await?; + let gid = g.map(|x| x.0).unwrap_or(0); + let gsort: Option<(i64,)> = + sqlx::query_as("SELECT sort_order FROM entry_groups WHERE id = ?") + .bind(gid) + .fetch_optional(pool) + .await?; + let so = gsort.map(|x| x.0).unwrap_or(i64::MAX); + keyed.push(((so, gid, e.entry_id), e)); + } + keyed.sort_by_key(|(k, _)| *k); + out = keyed.into_iter().map(|(_, e)| e).collect(); + } + Ok(out) +} + +/// 规则命中:kind = group | tag | node | type +async fn match_rule(pool: &SqlitePool, r: &SubRule) -> AppResult> { + let mut out = Vec::new(); + match r.kind.as_str() { + "group" => { + let gid: i64 = r + .value + .parse() + .map_err(|_| AppError::bad("规则 value 不是合法分组 id"))?; + for t in ["proxy", "forward", "tunnel"] { + let table = table_of(t)?; + let sql = format!("SELECT id FROM {table} WHERE group_id = ?"); + for (id,) in sqlx::query_as::<_, (i64,)>(&sql).bind(gid).fetch_all(pool).await? { + out.push((t.to_string(), id)); + } + } + } + "tag" => { + let tid: i64 = r + .value + .parse() + .map_err(|_| AppError::bad("规则 value 不是合法标签 id"))?; + for (et, eid) in sqlx::query_as::<_, (String, i64)>( + "SELECT entry_type, entry_id FROM entry_tags WHERE tag_id = ?", + ) + .bind(tid) + .fetch_all(pool) + .await? + { + if table_of(&et).is_ok() { + out.push((et, eid)); + } + } + } + "node" => { + let nid: i64 = r + .value + .parse() + .map_err(|_| AppError::bad("规则 value 不是合法节点 id"))?; + for t in ["proxy", "forward", "tunnel"] { + let table = table_of(t)?; + let sql = format!("SELECT id FROM {table} WHERE node_id = ?"); + for (id,) in sqlx::query_as::<_, (i64,)>(&sql).bind(nid).fetch_all(pool).await? { + out.push((t.to_string(), id)); + } + } + } + "type" => { + let t = r.value.as_str(); + table_of(t)?; + let table = table_of(t)?; + let sql = format!("SELECT id FROM {table}"); + for (id,) in sqlx::query_as::<_, (i64,)>(&sql).fetch_all(pool).await? { + out.push((t.to_string(), id)); + } + } + other => return Err(AppError::bad(format!("未知规则类型:{other}"))), + } + Ok(out) +} + +/// 条目 → ExportNode(文档 §9.1);host 为空则跳过(返回 None) +pub async fn load_export_nodes( + pool: &SqlitePool, + refs: &[EntryRef], +) -> AppResult> { + let mut out = Vec::new(); + for r in refs { + if let Some(n) = build_export_node(pool, &r.entry_type, r.entry_id).await? { + out.push(n); + } + } + Ok(out) +} + +async fn build_export_node( + pool: &SqlitePool, + entry_type: &str, + entry_id: i64, +) -> AppResult> { + let table = table_of(entry_type)?; + let row: Option<(i64, String, Option)> = sqlx::query_as(&format!( + "SELECT node_id, name, group_id FROM {table} WHERE id = ? AND enabled = 1" + )) + .bind(entry_id) + .fetch_optional(pool) + .await?; + let (node_id, name, group_id) = match row { + Some(r) => r, + None => return Ok(None), + }; + + let public_host: Option = + sqlx::query_scalar("SELECT public_host FROM nodes WHERE id = ?") + .bind(node_id) + .fetch_optional(pool) + .await? + .flatten(); + + let group: Option = match group_id { + Some(gid) if gid != 0 => { + sqlx::query_scalar("SELECT name FROM entry_groups WHERE id = ?") + .bind(gid) + .fetch_optional(pool) + .await? + } + _ => None, + }; + let tags: Vec = sqlx::query_scalar( + "SELECT t.name FROM tags t JOIN entry_tags et ON et.tag_id = t.id + WHERE et.entry_type = ? AND et.entry_id = ? ORDER BY t.name", + ) + .bind(entry_type) + .bind(entry_id) + .fetch_all(pool) + .await?; + + let (kind, host, port, username, password, extra) = match entry_type { + "proxy" => { + let p: Option = sqlx::query_as( + "SELECT upstream_type, upstream_addr, auth_user, auth_pass, + export_host, export_port, extra + FROM proxy_rules WHERE id = ?", + ) + .bind(entry_id) + .fetch_optional(pool) + .await?; + let p = match p { + Some(p) => p, + None => return Ok(None), + }; + let (uh, up) = split_host_port(&p.upstream_addr).unwrap_or_default(); + let host = p + .export_host + .filter(|s| !s.trim().is_empty()) + .unwrap_or(uh); + let port = p.export_port.unwrap_or(up as i64) as u16; + let mut extra = p + .extra + .and_then(|s| serde_json::from_str::(&s).ok()); + if extra.is_none() { + extra = Some(serde_json::json!({})); + } + ( + p.upstream_type, + host, + port, + p.auth_user, + p.auth_pass, + extra, + ) + } + "forward" => { + let f: Option = sqlx::query_as( + "SELECT listen_port, protocol, export_host, export_port, extra + FROM port_forwards WHERE id = ?", + ) + .bind(entry_id) + .fetch_optional(pool) + .await?; + let f = match f { + Some(f) => f, + None => return Ok(None), + }; + let host = f + .export_host + .filter(|s| !s.trim().is_empty()) + .or_else(|| public_host.clone().filter(|s| !s.trim().is_empty())) + .unwrap_or_default(); + let port = f.export_port.unwrap_or(f.listen_port) as u16; + let extra = f + .extra + .and_then(|s| serde_json::from_str::(&s).ok()) + .or_else(|| Some(serde_json::json!({ "protocol": f.protocol }))); + ("tcp".to_string(), host, port, None, None, extra) + } + "tunnel" => { + let t: Option = sqlx::query_as( + "SELECT tunnel_type, local_addr, export_host, export_port, extra + FROM tunnels WHERE id = ?", + ) + .bind(entry_id) + .fetch_optional(pool) + .await?; + let t = match t { + Some(t) => t, + None => return Ok(None), + }; + let (_, lp) = split_host_port(&t.local_addr).unwrap_or_default(); + let host = t + .export_host + .filter(|s| !s.trim().is_empty()) + .or_else(|| public_host.clone().filter(|s| !s.trim().is_empty())) + .unwrap_or_default(); + let port = t.export_port.unwrap_or(lp as i64) as u16; + let extra = t + .extra + .and_then(|s| serde_json::from_str::(&s).ok()) + .or_else(|| Some(serde_json::json!({ "tunnel_type": t.tunnel_type }))); + (format!("tunnel/{}", t.tunnel_type), host, port, None, None, extra) + } + _ => return Ok(None), + }; + + if host.trim().is_empty() || port == 0 { + return Ok(None); // host 为空则跳过 + } + Ok(Some(ExportNode { + name, + kind, + host, + port, + username, + password, + extra, + entry_type: entry_type.to_string(), + group, + tags, + })) +} + +#[derive(sqlx::FromRow)] +struct ProxyLite { + upstream_type: String, + upstream_addr: String, + auth_user: Option, + auth_pass: Option, + export_host: Option, + export_port: Option, + extra: Option, +} + +#[derive(sqlx::FromRow)] +struct ForwardLite { + listen_port: i64, + protocol: String, + export_host: Option, + export_port: Option, + extra: Option, +} + +#[derive(sqlx::FromRow)] +struct TunnelLite { + tunnel_type: String, + local_addr: String, + export_host: Option, + export_port: Option, + extra: Option, +} + +pub fn split_host_port(addr: &str) -> Option<(String, u16)> { + let addr = addr.trim(); + if addr.is_empty() { + return None; + } + if let Some(idx) = addr.rfind(':') { + let (h, p) = addr.split_at(idx); + if let Ok(port) = p[1..].parse::() { + let h = h.trim_matches(|c| c == '[' || c == ']').to_string(); + if !h.is_empty() { + return Some((h, port)); + } + } + } + None +} diff --git a/src/services/export/singbox.rs b/src/services/export/singbox.rs new file mode 100644 index 0000000..491f15f --- /dev/null +++ b/src/services/export/singbox.rs @@ -0,0 +1,98 @@ +//! sing-box JSON 导出(仅代理类条目) +//! +//! outbounds(每个节点一个)+ selector + urltest;绑定模板时按第 12 章渲染。 + +use crate::models::ExportNode; +use serde_json::{json, Value}; + +pub fn render(nodes: &[ExportNode], sub_name: &str) -> (String, &'static str, &'static str) { + let proxies: Vec<&ExportNode> = nodes.iter().filter(|n| n.entry_type == "proxy").collect(); + let mut outbounds: Vec = Vec::new(); + let mut names: Vec = Vec::new(); + for n in &proxies { + names.push(json!(n.name)); + outbounds.push(render_outbound(n)); + } + outbounds.push(json!({ + "type": "selector", "tag": "proxy", + "outbounds": names, "default": names.first().unwrap_or(&json!("direct")), + })); + outbounds.push(json!({ + "type": "urltest", "tag": "auto", + "outbounds": names, + "url": "http://www.gstatic.com/generate_204", + "interval": "5m", + })); + outbounds.push(json!({ "type": "direct", "tag": "direct" })); + let cfg = json!({ + "_comment": format!("{sub_name} - 由 RustProxyAdmin 生成"), + "outbounds": outbounds, + "route": { "final": "proxy" }, + }); + ( + serde_json::to_string_pretty(&cfg).unwrap_or_default(), + "application/json; charset=utf-8", + "json", + ) +} + +fn render_outbound(n: &ExportNode) -> Value { + let base = json!({ + "tag": n.name, + "server": n.host, + "server_port": n.port, + }); + let mut o = match n.kind.as_str() { + "ss" => json!({ + "type": "shadowsocks", "method": "aes-256-gcm", + "password": n.password.as_deref().unwrap_or(""), + }), + "trojan" => json!({ + "type": "trojan", "password": n.password.as_deref().unwrap_or(""), + }), + "vless" => json!({ + "type": "vless", "uuid": n.password.as_deref().unwrap_or(""), + }), + "vmess" => json!({ + "type": "vmess", + "uuid": n.password.as_deref().unwrap_or(""), + "alter_id": 0, "security": "auto", + }), + "socks5" => { + let mut v = json!({ "type": "socks", "version": "5" }); + if let Some(u) = &n.username { + v["username"] = json!(u); + } + if let Some(p) = &n.password { + v["password"] = json!(p); + } + v + } + _ => { + let mut v = json!({ "type": "http" }); + if n.kind == "https" { + v["tls"] = json!({ "enabled": true }); + } + if let Some(u) = &n.username { + v["username"] = json!(u); + } + if let Some(p) = &n.password { + v["password"] = json!(p); + } + v + } + }; + // extra 合并(extra 优先),最后强制覆盖 tag + if let Some(ex) = n.extra.as_ref().and_then(|v| v.as_object()) { + for (k, v) in ex { + if k == "tag" { + continue; + } + o[k] = v.clone(); + } + } + for (k, v) in base.as_object().unwrap() { + o[k] = v.clone(); + } + o +} diff --git a/src/services/export/template.rs b/src/services/export/template.rs new file mode 100644 index 0000000..ec06f07 --- /dev/null +++ b/src/services/export/template.rs @@ -0,0 +1,608 @@ +//! P5 —— 订阅模板:结构化标记渲染 + 保存前校验。 +//! +//! 标记(§12.2):@ALL / @TAG:x / @GROUP:x / @TYPE:x / @REGEX:re;普通字符串原样保留。 +//! 渲染:Clash=YAML 解析后填 proxies 并展开 proxy-groups 成员; +//! Sing-box=JSON 解析后展开 selector/urltest 的 outbounds 并追加节点。 + +use std::collections::{HashMap, HashSet}; + +use serde_json::Value as JsonValue; +use serde_yaml::{Mapping, Value as YamlValue}; + +use crate::error::{AppError, AppResult}; +use crate::models::{ExportNode, SubTemplate, Subscription}; + +pub struct Rendered { + pub content: String, + pub content_type: String, + pub ext: String, +} + +pub const FORMATS: &[&str] = &["clash", "singbox", "json", "base64"]; + +pub fn validate_format(f: &str) -> AppResult<()> { + if FORMATS.contains(&f) { + Ok(()) + } else { + Err(AppError::bad(format!( + "不支持的订阅格式:{f}(可选:{})", + FORMATS.join("/") + ))) + } +} + +/// 渲染订阅:resolve 条目 → 模板优先(仅格式匹配)→ 默认输出 +pub async fn render_subscription( + pool: &sqlx::SqlitePool, + sub: &Subscription, + format: &str, +) -> AppResult { + validate_format(format)?; + let refs = super::resolve::resolve_entries(pool, sub).await?; + let nodes = super::resolve::load_export_nodes(pool, &refs).await?; + + if let Some(tid) = sub.template_id { + let tpl: Option = + sqlx::query_as("SELECT * FROM sub_templates WHERE id = ?") + .bind(tid) + .fetch_optional(pool) + .await?; + if let Some(tpl) = tpl { + if tpl.format == format { + let content = match format { + "clash" => render_clash_with_template(&tpl.content, &nodes)?, + "singbox" => render_singbox_with_template(&tpl.content, &nodes)?, + _ => unreachable!(), + }; + let ct = if format == "singbox" { + "application/json; charset=utf-8" + } else { + "text/yaml; charset=utf-8" + }; + let ext = if format == "clash" { "yaml" } else { "json" }; + return Ok(Rendered { + content, + content_type: ct.to_string(), + ext: ext.to_string(), + }); + } + } + } + + let (content, ct, ext) = match format { + "clash" => super::clash::render(&nodes, &sub.name), + "singbox" => super::singbox::render(&nodes, &sub.name), + "json" => super::json::render(&nodes, &sub.name), + "base64" => super::base64::render(&nodes, &sub.name), + _ => unreachable!(), + }; + Ok(Rendered { + content, + content_type: ct.to_string(), + ext: ext.to_string(), + }) +} + +// ================= 渲染 ================= + +/// 重名追加 " #2" / " #3" +pub fn dedup_names(nodes: &[ExportNode]) -> Vec { + let mut counts: HashMap = HashMap::new(); + nodes + .iter() + .map(|n| { + let c = counts.entry(n.name.clone()).or_insert(0); + *c += 1; + let mut n = n.clone(); + if *c > 1 { + n.name = format!("{} #{}", n.name, c); + } + n + }) + .collect() +} + +/// 展开分组/选择器成员标记;结果按原顺序去重;为空则补 DIRECT +pub fn expand_members(items: &[String], nodes: &[ExportNode]) -> Vec { + let mut out: Vec = Vec::new(); + for s in items { + if s == "@ALL" { + out.extend(nodes.iter().map(|n| n.name.clone())); + } else if let Some(t) = s.strip_prefix("@TAG:") { + out.extend( + nodes + .iter() + .filter(|n| n.tags.iter().any(|x| x == t)) + .map(|n| n.name.clone()), + ); + } else if let Some(g) = s.strip_prefix("@GROUP:") { + out.extend( + nodes + .iter() + .filter(|n| n.group.as_deref() == Some(g)) + .map(|n| n.name.clone()), + ); + } else if let Some(t) = s.strip_prefix("@TYPE:") { + out.extend( + nodes + .iter() + .filter(|n| n.entry_type == t) + .map(|n| n.name.clone()), + ); + } else if let Some(r) = s.strip_prefix("@REGEX:") { + if r.len() <= 200 { + if let Ok(re) = regex::Regex::new(r) { + out.extend( + nodes + .iter() + .filter(|n| re.is_match(&n.name)) + .map(|n| n.name.clone()), + ); + } + } + } else { + out.push(s.clone()); + } + } + let mut seen = HashSet::new(); + out.retain(|x| seen.insert(x.clone())); + if out.is_empty() { + out.push("DIRECT".into()); + } + out +} + +fn yaml_str_val(s: &str) -> YamlValue { + YamlValue::String(s.to_string()) +} + +/// Clash 节点字典(Value 版):基础字段 + extra 合并(extra 优先),name 强制覆盖 +pub fn clash_proxy_value(n: &ExportNode) -> YamlValue { + let mut m = Mapping::new(); + m.insert(yaml_str_val("name"), yaml_str_val(&n.name)); + match n.kind.as_str() { + "ss" => { + m.insert(yaml_str_val("type"), yaml_str_val("ss")); + m.insert(yaml_str_val("server"), yaml_str_val(&n.host)); + m.insert(yaml_str_val("port"), YamlValue::Number(n.port.into())); + m.insert(yaml_str_val("cipher"), yaml_str_val("aes-256-gcm")); + m.insert( + yaml_str_val("password"), + yaml_str_val(n.password.as_deref().unwrap_or("")), + ); + m.insert(yaml_str_val("udp"), YamlValue::Bool(true)); + } + "trojan" => { + m.insert(yaml_str_val("type"), yaml_str_val("trojan")); + m.insert(yaml_str_val("server"), yaml_str_val(&n.host)); + m.insert(yaml_str_val("port"), YamlValue::Number(n.port.into())); + m.insert( + yaml_str_val("password"), + yaml_str_val(n.password.as_deref().unwrap_or("")), + ); + m.insert(yaml_str_val("udp"), YamlValue::Bool(true)); + } + "vless" => { + m.insert(yaml_str_val("type"), yaml_str_val("vless")); + m.insert(yaml_str_val("server"), yaml_str_val(&n.host)); + m.insert(yaml_str_val("port"), YamlValue::Number(n.port.into())); + m.insert( + yaml_str_val("uuid"), + yaml_str_val(n.password.as_deref().unwrap_or("")), + ); + m.insert(yaml_str_val("encryption"), yaml_str_val("none")); + m.insert(yaml_str_val("udp"), YamlValue::Bool(true)); + } + "vmess" => { + m.insert(yaml_str_val("type"), yaml_str_val("vmess")); + m.insert(yaml_str_val("server"), yaml_str_val(&n.host)); + m.insert(yaml_str_val("port"), YamlValue::Number(n.port.into())); + m.insert( + yaml_str_val("uuid"), + yaml_str_val(n.password.as_deref().unwrap_or("")), + ); + m.insert(yaml_str_val("alterId"), YamlValue::Number(0.into())); + m.insert(yaml_str_val("cipher"), yaml_str_val("auto")); + m.insert(yaml_str_val("udp"), YamlValue::Bool(true)); + } + "socks5" => { + m.insert(yaml_str_val("type"), yaml_str_val("socks5")); + m.insert(yaml_str_val("server"), yaml_str_val(&n.host)); + m.insert(yaml_str_val("port"), YamlValue::Number(n.port.into())); + if let Some(u) = &n.username { + m.insert(yaml_str_val("username"), yaml_str_val(u)); + } + if let Some(p) = &n.password { + m.insert(yaml_str_val("password"), yaml_str_val(p)); + } + m.insert(yaml_str_val("udp"), YamlValue::Bool(true)); + } + _ => { + m.insert(yaml_str_val("type"), yaml_str_val("http")); + m.insert(yaml_str_val("server"), yaml_str_val(&n.host)); + m.insert(yaml_str_val("port"), YamlValue::Number(n.port.into())); + if n.kind == "https" { + m.insert(yaml_str_val("tls"), YamlValue::Bool(true)); + } + if let Some(u) = &n.username { + m.insert(yaml_str_val("username"), yaml_str_val(u)); + } + if let Some(p) = &n.password { + m.insert(yaml_str_val("password"), yaml_str_val(p)); + } + } + } + if let Some(ex) = n.extra.as_ref().and_then(|v| v.as_object()) { + for (k, v) in ex { + if k == "name" { + continue; + } + let yv = serde_json::from_str::(&v.to_string()) + .unwrap_or(YamlValue::String(v.to_string())); + m.insert(yaml_str_val(k), yv); + } + } + YamlValue::Mapping(m) +} + +pub fn render_clash_with_template(tpl: &str, nodes: &[ExportNode]) -> AppResult { + let mut root: YamlValue = serde_yaml::from_str(tpl) + .map_err(|e| AppError::bad(&format!("模板解析失败:{e}")))?; + let map = root + .as_mapping_mut() + .ok_or_else(|| AppError::bad("模板顶层必须是对象"))?; + + let nodes = dedup_names(nodes); + let proxies: Vec = nodes.iter().map(clash_proxy_value).collect(); + map.insert(yaml_str_val("proxies"), YamlValue::Sequence(proxies)); + + if let Some(YamlValue::Sequence(groups)) = map.get_mut(&yaml_str_val("proxy-groups")) { + for g in groups.iter_mut() { + if let Some(YamlValue::Sequence(list)) = g.get_mut(&yaml_str_val("proxies")) { + let items: Vec = + list.iter().filter_map(|v| v.as_str().map(str::to_string)).collect(); + let expanded = expand_members(&items, &nodes); + *list = expanded.into_iter().map(|s| yaml_str_val(&s)).collect(); + } + } + } + serde_yaml::to_string(&root).map_err(|e| AppError::internal(&e.to_string())) +} + +/// Sing-box 节点出站(与 singbox.rs 的 render_outbound 对齐) +pub fn singbox_outbound_value(n: &ExportNode) -> JsonValue { + let mut o = match n.kind.as_str() { + "ss" => serde_json::json!({ + "type": "shadowsocks", "method": "aes-256-gcm", + "password": n.password.as_deref().unwrap_or(""), + }), + "trojan" => serde_json::json!({ + "type": "trojan", "password": n.password.as_deref().unwrap_or(""), + }), + "vless" => serde_json::json!({ + "type": "vless", "uuid": n.password.as_deref().unwrap_or(""), + }), + "vmess" => serde_json::json!({ + "type": "vmess", "uuid": n.password.as_deref().unwrap_or(""), + "alter_id": 0, "security": "auto", + }), + "socks5" => { + let mut v = serde_json::json!({ "type": "socks", "version": "5" }); + if let Some(u) = &n.username { + v["username"] = serde_json::json!(u); + } + if let Some(p) = &n.password { + v["password"] = serde_json::json!(p); + } + v + } + _ => { + let mut v = serde_json::json!({ "type": "http" }); + if n.kind == "https" { + v["tls"] = serde_json::json!({ "enabled": true }); + } + if let Some(u) = &n.username { + v["username"] = serde_json::json!(u); + } + if let Some(p) = &n.password { + v["password"] = serde_json::json!(p); + } + v + } + }; + if let Some(ex) = n.extra.as_ref().and_then(|v| v.as_object()) { + for (k, v) in ex { + if k == "tag" { + continue; + } + o[k] = v.clone(); + } + } + o["tag"] = serde_json::json!(&n.name); + o["server"] = serde_json::json!(&n.host); + o["server_port"] = serde_json::json!(n.port); + o +} + +pub fn render_singbox_with_template(tpl: &str, nodes: &[ExportNode]) -> AppResult { + let mut root: JsonValue = serde_json::from_str(tpl) + .map_err(|e| AppError::bad(&format!("模板解析失败:{e}")))?; + let map = root + .as_object_mut() + .ok_or_else(|| AppError::bad("模板顶层必须是对象"))?; + + let nodes = dedup_names(nodes); + // 展开 selector / urltest 的 outbounds 标记 + if let Some(JsonValue::Array(outbounds)) = map.get_mut("outbounds") { + for ob in outbounds.iter_mut() { + let is_group = ob + .get("type") + .and_then(|t| t.as_str()) + .map(|t| t == "selector" || t == "urltest") + .unwrap_or(false); + if !is_group { + continue; + } + if let Some(JsonValue::Array(list)) = ob.get_mut("outbounds") { + let items: Vec = + list.iter().filter_map(|v| v.as_str().map(str::to_string)).collect(); + let expanded = expand_members(&items, &nodes); + *list = expanded.into_iter().map(JsonValue::String).collect(); + } + } + // 节点写入同一个 outbounds 数组(tag 去重) + let mut tags: HashSet = outbounds + .iter() + .filter_map(|o| o.get("tag").and_then(|t| t.as_str()).map(str::to_string)) + .collect(); + for n in &nodes { + if tags.insert(n.name.clone()) { + outbounds.push(singbox_outbound_value(n)); + } + } + } + serde_json::to_string_pretty(&root).map_err(|e| AppError::internal(&e.to_string())) +} + +// ================= 校验(§12.4) ================= + +/// 收集模板中出现的所有 @REGEX: 表达式 +fn collect_regexes(content: &str) -> Vec { + let mut out = Vec::new(); + let mut rest = content; + while let Some(i) = rest.find("@REGEX:") { + let after = &rest[i + 7..]; + let end = after + .find(|c: char| c == '"' || c == '\'' || c == ',' || c == ']' || c == '\n' || c == ' ') + .unwrap_or(after.len()); + out.push(after[..end].to_string()); + rest = &after[end..]; + } + out +} + +/// 返回错误列表;为空表示通过 +pub fn validate_template(content: &str, format: &str) -> Vec { + let mut errs: Vec = Vec::new(); + if content.len() > 256 * 1024 { + errs.push("模板大小超过 256 KB".to_string()); + return errs; + } + if format != "clash" && format != "singbox" { + errs.push("format 只能是 clash 或 singbox".to_string()); + return errs; + } + for re_str in collect_regexes(content) { + if re_str.len() > 200 { + errs.push(format!("@REGEX 表达式超过 200 字符:{re_str}")); + } else if regex::Regex::new(&re_str).is_err() { + errs.push(format!("@REGEX 无法编译:{re_str}")); + } + } + + if format == "clash" { + let root: Result = serde_yaml::from_str(content); + let root = match root { + Ok(v) => v, + Err(e) => { + errs.push(format!("YAML 解析失败:{e}")); + return errs; + } + }; + let map = match root.as_mapping() { + Some(m) => m, + None => { + errs.push("模板顶层必须是对象".to_string()); + return errs; + } + }; + if map.contains_key(&yaml_str_val("proxies")) { + errs.push("模板里禁止自带 proxies(系统会自动写入,删掉该字段)".to_string()); + } + let mut group_names: HashSet = HashSet::new(); + let mut dup: HashSet = HashSet::new(); + if let Some(groups) = map.get(&yaml_str_val("proxy-groups")) { + match groups.as_sequence() { + None => errs.push("proxy-groups 必须是数组".to_string()), + Some(seq) => { + for (i, g) in seq.iter().enumerate() { + let gm = match g.as_mapping() { + Some(m) => m, + None => { + errs.push(format!("proxy-groups[{i}] 必须是对象")); + continue; + } + }; + let name = gm + .get(&yaml_str_val("name")) + .and_then(|v| v.as_str()) + .unwrap_or(""); + let typ = gm + .get(&yaml_str_val("type")) + .and_then(|v| v.as_str()) + .unwrap_or(""); + if name.is_empty() || typ.is_empty() { + errs.push(format!("proxy-groups[{i}] 缺少 name 或 type")); + continue; + } + if !group_names.insert(name.to_string()) { + dup.insert(name.to_string()); + } + if let Some(members) = gm.get(&yaml_str_val("proxies")) { + match members.as_sequence() { + None => errs.push(format!("分组 {name} 的 proxies 必须是数组")), + Some(list) => { + for m in list { + if let Some(s) = m.as_str() { + if !s.starts_with('@') + && s != "DIRECT" + && s != "REJECT" + { + // 稍后统一检查引用是否存在 + } + } + } + } + } + } + } + } + } + } + for d in &dup { + errs.push(format!("分组名重复:{d}")); + } + // 引用检查:分组成员 / rules 引用的目标 + let valid_targets: HashSet<&str> = + group_names.iter().map(String::as_str).collect(); + let check_ref = |s: &str, errs: &mut Vec, ctx: &str| { + if s.starts_with('@') || s == "DIRECT" || s == "REJECT" { + return; + } + if !valid_targets.contains(s) { + errs.push(format!("{ctx} 引用了不存在的分组 {s}")); + } + }; + if let Some(seq) = map + .get(&yaml_str_val("proxy-groups")) + .and_then(|v| v.as_sequence()) + { + for g in seq { + if let (Some(name), Some(list)) = ( + g.get(&yaml_str_val("name")).and_then(|v| v.as_str()), + g.get(&yaml_str_val("proxies")).and_then(|v| v.as_sequence()), + ) { + for m in list { + if let Some(s) = m.as_str() { + check_ref(s, &mut errs, &format!("分组 {name}")); + } + } + } + } + } + if let Some(rules) = map.get(&yaml_str_val("rules")).and_then(|v| v.as_sequence()) + { + for r in rules { + if let Some(s) = r.as_str() { + if let Some(target) = s.rsplit(',').next() { + let target = target.trim(); + if !target.is_empty() { + check_ref(target, &mut errs, "rules"); + } + } + } + } + } + } else { + let root: Result = serde_json::from_str(content); + let root = match root { + Ok(v) => v, + Err(e) => { + errs.push(format!("JSON 解析失败:{e}")); + return errs; + } + }; + let map = match root.as_object() { + Some(m) => m, + None => { + errs.push("模板顶层必须是对象".to_string()); + return errs; + } + }; + if let Some(JsonValue::Array(outbounds)) = map.get("outbounds") { + let mut tags: HashSet = HashSet::new(); + let mut dup: HashSet = HashSet::new(); + for (i, ob) in outbounds.iter().enumerate() { + let typ = ob.get("type").and_then(|t| t.as_str()).unwrap_or(""); + let tag = ob.get("tag").and_then(|t| t.as_str()).unwrap_or(""); + if matches!( + typ, + "shadowsocks" | "trojan" | "vless" | "vmess" | "socks" | "http" | "hysteria2" | "tuic" | "wireguard" + ) { + errs.push(format!( + "outbounds[{i}] 是节点类型({typ}),模板里禁止自带节点(系统会自动写入)" + )); + } + if !tag.is_empty() && !tags.insert(tag.to_string()) { + dup.insert(tag.to_string()); + } + } + for d in &dup { + errs.push(format!("outbound tag 重复:{d}")); + } + } + } + errs +} + +/// 预览用假节点 +pub fn sample_nodes() -> Vec { + vec![ + ExportNode { + name: "香港-01".into(), + kind: "ss".into(), + host: "1.2.3.4".into(), + port: 8388, + username: None, + password: Some("pass123".into()), + extra: None, + entry_type: "proxy".into(), + group: Some("家宽".into()), + tags: vec!["香港".into()], + }, + ExportNode { + name: "美国-01".into(), + kind: "vmess".into(), + host: "5.6.7.8".into(), + port: 443, + username: None, + password: Some("uuid-xxx".into()), + extra: None, + entry_type: "proxy".into(), + group: None, + tags: vec!["美国".into()], + }, + ExportNode { + name: "转发-游戏".into(), + kind: "tcp".into(), + host: "9.9.9.9".into(), + port: 25565, + username: None, + password: None, + extra: None, + entry_type: "forward".into(), + group: Some("家宽".into()), + tags: vec![], + }, + ] +} + +/// 用假节点渲染预览 +pub fn preview_render(content: &str, format: &str) -> AppResult { + let nodes = dedup_names(&sample_nodes()); + match format { + "clash" => render_clash_with_template(content, &nodes), + "singbox" => render_singbox_with_template(content, &nodes), + _ => Err(AppError::bad("format 只能是 clash 或 singbox")), + } +} diff --git a/src/services/heartbeat.rs b/src/services/heartbeat.rs new file mode 100644 index 0000000..9a90264 --- /dev/null +++ b/src/services/heartbeat.rs @@ -0,0 +1 @@ +// TODO: phase implementation diff --git a/src/services/importer/fetch.rs b/src/services/importer/fetch.rs new file mode 100644 index 0000000..fe11c89 --- /dev/null +++ b/src/services/importer/fetch.rs @@ -0,0 +1,115 @@ +//! P6 —— 安全拉取(防 SSRF,§10.2): +//! 只允许 http/https;DNS 解析后拒绝回环/私网/链路本地/云元数据地址; +//! 超时 15s,响应体上限 5MB,最多跟随 3 次重定向(每次重定向后重新校验)。 + +use std::net::IpAddr; + +use crate::error::{AppError, AppResult}; + +const MAX_BODY: usize = 5 * 1024 * 1024; +const MAX_REDIRECTS: usize = 3; + +fn is_blocked_ip(ip: &IpAddr) -> bool { + match ip { + IpAddr::V4(v4) => { + let o = v4.octets(); + o[0] == 127 // 127.0.0.0/8 回环 + || o[0] == 10 // 10.0.0.0/8 + || (o[0] == 172 && (16..=31).contains(&o[1])) // 172.16.0.0/12 + || (o[0] == 192 && o[1] == 168) // 192.168.0.0/16 + || (o[0] == 169 && o[1] == 254) // 169.254.0.0/16 链路本地/云元数据 + || o[0] == 0 // 0.0.0.0/8 + || (o[0] == 100 && (64..=127).contains(&o[1])) // CGNAT + } + IpAddr::V6(v6) => { + v6.is_loopback() + || v6.is_unspecified() + || (v6.segments()[0] & 0xfe00) == 0xfc00 // fc00::/7 ULA + || (v6.segments()[0] & 0xffc0) == 0xfe80 // fe80::/10 链路本地 + } + } +} + +/// 校验 URL 并解析主机 DNS,所有解析结果都不能是内网地址 +async fn check_url(url: &str) -> AppResult<(String, u16)> { + let parsed = url::Url::parse(url).map_err(|_| AppError::bad("URL 格式错误"))?; + let scheme = parsed.scheme(); + if scheme != "http" && scheme != "https" { + return Err(AppError::bad("只允许 http/https 订阅地址")); + } + let host = parsed + .host_str() + .ok_or_else(|| AppError::bad("URL 缺少主机名"))?; + let port = parsed.port_or_known_default().unwrap_or(80); + // DNS 解析后逐个检查 + let addrs: Vec = tokio::net::lookup_host(format!("{host}:{port}")) + .await + .map_err(|_| AppError::bad("DNS 解析失败"))? + .map(|sa| sa.ip()) + .collect(); + if addrs.is_empty() { + return Err(AppError::bad("DNS 解析失败")); + } + for ip in &addrs { + if is_blocked_ip(ip) { + return Err(AppError::bad("订阅地址解析到内网/保留地址,已拒绝(防 SSRF)")); + } + } + Ok((host.to_string(), port)) +} + +/// 安全拉取订阅内容(手动处理重定向,每次重定向后重新校验) +pub async fn fetch_subscription( + client: &reqwest::Client, + url: &str, + user_agent: &str, +) -> AppResult { + let mut current = url.to_string(); + for _ in 0..=MAX_REDIRECTS { + check_url(¤t).await?; + // 禁用 reqwest 自动重定向,手动处理以便每次校验 + let resp = client + .get(¤t) + .header("User-Agent", user_agent) + .timeout(std::time::Duration::from_secs(15)) + .send() + .await + .map_err(|e| AppError::bad(format!("拉取失败:{e}")))?; + let status = resp.status(); + if status.is_redirection() { + let loc = resp + .headers() + .get("location") + .and_then(|v| v.to_str().ok()) + .ok_or_else(|| AppError::bad("重定向缺少 Location"))?; + let base = url::Url::parse(¤t).map_err(|_| AppError::bad("URL 错误"))?; + let next = base + .join(loc) + .map_err(|_| AppError::bad("重定向地址错误"))?; + current = next.to_string(); + continue; + } + if !status.is_success() { + return Err(AppError::bad(format!("订阅地址返回 HTTP {status}"))); + } + let len: Option = resp + .headers() + .get("content-length") + .and_then(|v| v.to_str().ok()) + .and_then(|s| s.parse().ok()); + if let Some(l) = len { + if l > MAX_BODY { + return Err(AppError::bad("订阅内容超过 5 MB 上限")); + } + } + let bytes = resp + .bytes() + .await + .map_err(|e| AppError::bad(format!("读取响应失败:{e}")))?; + if bytes.len() > MAX_BODY { + return Err(AppError::bad("订阅内容超过 5 MB 上限")); + } + return Ok(String::from_utf8_lossy(&bytes).into_owned()); + } + Err(AppError::bad("重定向次数过多(超过 3 次)")) +} diff --git a/src/services/importer/mod.rs b/src/services/importer/mod.rs new file mode 100644 index 0000000..f079339 --- /dev/null +++ b/src/services/importer/mod.rs @@ -0,0 +1,172 @@ +//! P6 —— 订阅导入:安全拉取 / 格式识别 / 解析 / 解析缓存(§10) + +pub mod fetch; +pub mod parse_clash; +pub mod parse_singbox; +pub mod parse_uri; + +use std::collections::HashMap; +use std::time::{Duration, Instant}; + +use base64::Engine as _; +use serde::{Deserialize, Serialize}; +use sqlx::SqlitePool; + +use crate::error::{AppError, AppResult}; + +/// §10.3 解析结果 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ParsedNode { + pub name: String, + pub kind: String, // ss | vmess | vless | trojan | socks5 | http | ... + pub host: String, + pub port: u16, + pub username: Option, + pub password: Option, + pub extra: serde_json::Value, // 原始字段全量保留 + pub duplicate: bool, // 与现有条目 (kind, host, port) 重复 +} + +/// 服务端解析缓存条目(parse_token → 结果,10 分钟过期) +#[derive(Debug, Clone)] +pub struct CachedParse { + pub nodes: Vec, + pub source_url: Option, + pub source_type: String, + pub created: Instant, +} + +impl CachedParse { + pub fn is_expired(&self) -> bool { + self.created.elapsed() > Duration::from_secs(600) + } +} + +/// 从解析缓存取结果(过期/不存在返回 None,调用方提示重新解析) +pub async fn take_cached( + cache: &tokio::sync::Mutex>, + token: &str, +) -> Option { + let mut g = cache.lock().await; + let c = g.get(token)?; + if c.is_expired() { + g.remove(token); + return None; + } + Some(c.clone()) +} + +pub fn b64_decode(s: &str) -> AppResult { + let s = s.trim(); + // 补齐 padding + let mut owned; + let s = if s.len() % 4 != 0 { + owned = s.to_string(); + while owned.len() % 4 != 0 { + owned.push('='); + } + &owned + } else { + s + }; + for eng in [ + base64::engine::general_purpose::STANDARD, + base64::engine::general_purpose::URL_SAFE, + ] { + if let Ok(b) = eng.decode(s) { + if let Ok(t) = String::from_utf8(b) { + return Ok(t); + } + } + } + Err(AppError::bad("base64 解码失败")) +} + +/// 格式识别:JSON → YAML → base64 解码后再判断 → 按行 URI(§10.1) +pub fn parse_content(content: &str) -> AppResult<(String, Vec)> { + let trimmed = content.trim(); + if trimmed.is_empty() { + return Err(AppError::bad("内容为空")); + } + // 1) Sing-box JSON:含 outbounds 数组 + if trimmed.starts_with('{') || trimmed.starts_with('[') { + if let Ok(v) = serde_json::from_str::(trimmed) { + if v.get("outbounds").and_then(|o| o.as_array()).is_some() { + return Ok(("singbox".into(), parse_singbox::parse_singbox(trimmed)?)); + } + } + } + // 2) Clash YAML:含 proxies 数组 + if let Ok(v) = serde_yaml::from_str::(trimmed) { + let has_proxies = v + .get(&serde_yaml::Value::String("proxies".to_string())) + .and_then(|p| p.as_sequence()) + .is_some(); + if has_proxies { + return Ok(("clash".into(), parse_clash::parse_clash(trimmed)?)); + } + } + // 3) base64 解码后再判断 + if let Ok(decoded) = b64_decode(trimmed) { + let d = decoded.trim(); + let looks_uri = d.lines().any(|l| l.trim().contains("://")); + if looks_uri { + match parse_uri::parse_uri_list(d) { + Ok(nodes) => return Ok(("base64".into(), nodes)), + Err(_) => {} + } + } + // 解码后也可能是 JSON/YAML + if let Ok((t, nodes)) = parse_content_boxed(d) { + let t = if t == "uri_list" { "base64".into() } else { t }; + return Ok((t, nodes)); + } + } + // 4) 按行 URI + if let Ok(nodes) = parse_uri::parse_uri_list(trimmed) { + return Ok(("uri_list".into(), nodes)); + } + Err(AppError::bad("无法识别的订阅格式")) +} + +/// 内部递归用的识别(不含 base64 层,避免无限递归) +fn parse_content_boxed(content: &str) -> AppResult<(String, Vec)> { + let trimmed = content.trim(); + if (trimmed.starts_with('{') || trimmed.starts_with('[')) + && serde_json::from_str::(trimmed) + .ok() + .and_then(|v| v.get("outbounds").and_then(|o| o.as_array()).cloned()) + .is_some() + { + return Ok(("singbox".into(), parse_singbox::parse_singbox(trimmed)?)); + } + if let Ok(v) = serde_yaml::from_str::(trimmed) { + if v + .get(&serde_yaml::Value::String("proxies".to_string())) + .and_then(|p| p.as_sequence()) + .is_some() + { + return Ok(("clash".into(), parse_clash::parse_clash(trimmed)?)); + } + } + if let Ok(nodes) = parse_uri::parse_uri_list(trimmed) { + return Ok(("uri_list".into(), nodes)); + } + Err(AppError::bad("无法识别的订阅格式")) +} + +/// 标记与现有 proxy_rules 条目 (upstream_type, upstream_addr) 重复的节点 +pub async fn mark_duplicates(pool: &SqlitePool, nodes: &mut [ParsedNode]) -> AppResult<()> { + for n in nodes.iter_mut() { + let addr = format!("{}:{}", n.host, n.port); + let exists: bool = sqlx::query_scalar( + "SELECT EXISTS(SELECT 1 FROM proxy_rules WHERE upstream_type = ? AND upstream_addr = ?)", + ) + .bind(&n.kind) + .bind(&addr) + .fetch_one(pool) + .await?; + n.duplicate = exists; + } + Ok(()) +} diff --git a/src/services/importer/parse_clash.rs b/src/services/importer/parse_clash.rs new file mode 100644 index 0000000..1c0d3f1 --- /dev/null +++ b/src/services/importer/parse_clash.rs @@ -0,0 +1,85 @@ +//! P6 —— Clash YAML 解析:proxies 数组 → ParsedNode(原始字段全量保留为 extra) + +use serde_yaml::Value; + +use crate::error::{AppError, AppResult}; +use crate::services::importer::ParsedNode; + +fn get_str(m: &serde_yaml::Mapping, key: &str) -> Option { + m.get(&Value::String(key.to_string())) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) +} + +fn get_port(m: &serde_yaml::Mapping) -> Option { + let v = m.get(&Value::String("port".to_string()))?; + if let Some(n) = v.as_u64() { + return u16::try_from(n).ok(); + } + if let Some(s) = v.as_str() { + return s.parse().ok(); + } + None +} + +pub fn parse_clash(content: &str) -> AppResult> { + let root: Value = + serde_yaml::from_str(content).map_err(|e| AppError::bad(format!("YAML 解析失败:{e}")))?; + let proxies = root + .get(&Value::String("proxies".to_string())) + .and_then(|v| v.as_sequence()) + .ok_or_else(|| AppError::bad("不是 Clash 订阅:缺少 proxies 数组"))?; + + let mut out = Vec::new(); + for p in proxies { + let m = match p.as_mapping() { + Some(m) => m, + None => continue, + }; + let name = get_str(m, "name").unwrap_or_else(|| "未命名".to_string()); + let kind = get_str(m, "type").unwrap_or_default().to_lowercase(); + let host = get_str(m, "server").unwrap_or_default(); + let port = get_port(m).unwrap_or(0); + if host.is_empty() || port == 0 { + continue; + } + let kind = match kind.as_str() { + "ss" | "shadowsocks" => "ss", + "vmess" => "vmess", + "vless" => "vless", + "trojan" => "trojan", + "socks5" | "socks" => "socks5", + "http" => "http", + "https" => "https", + "hysteria2" | "hysteria" => "hysteria2", + "tuic" => "tuic", + _ => continue, // 不支持的类型跳过 + } + .to_string(); + let (username, password) = match kind.as_str() { + "ss" => (None, get_str(m, "password")), + "trojan" => (None, get_str(m, "password")), + "vmess" | "vless" => (None, get_str(m, "uuid")), + "hysteria2" => (None, get_str(m, "password").or_else(|| get_str(m, "auth"))), + "tuic" => (None, get_str(m, "password").or_else(|| get_str(m, "uuid"))), + _ => (get_str(m, "username"), get_str(m, "password")), + }; + // extra:整个节点字典转 JSON 全量保留 + let extra = + serde_json::to_value(p).unwrap_or(serde_json::Value::Object(Default::default())); + out.push(ParsedNode { + name, + kind, + host, + port, + username, + password, + extra, + duplicate: false, + }); + } + if out.is_empty() { + return Err(AppError::bad("Clash 订阅中未解析出可用节点")); + } + Ok(out) +} diff --git a/src/services/importer/parse_singbox.rs b/src/services/importer/parse_singbox.rs new file mode 100644 index 0000000..64af49d --- /dev/null +++ b/src/services/importer/parse_singbox.rs @@ -0,0 +1,107 @@ +//! P6 —— Sing-box JSON 解析:outbounds 数组 → ParsedNode(过滤非节点类型) + +use serde_json::Value; + +use crate::error::{AppError, AppResult}; +use crate::services::importer::ParsedNode; + +/// 非节点类型(选择器/测速/直连/拦截/dns 等),解析时过滤 +const NON_NODE_TYPES: &[&str] = &[ + "selector", "urltest", "direct", "block", "dns", "tun", "redirect", "tproxy", "shadowsocksr", +]; + +pub fn parse_singbox(content: &str) -> AppResult> { + let root: Value = + serde_json::from_str(content).map_err(|e| AppError::bad(format!("JSON 解析失败:{e}")))?; + let outbounds = root + .get("outbounds") + .and_then(|v| v.as_array()) + .ok_or_else(|| AppError::bad("不是 Sing-box 订阅:缺少 outbounds 数组"))?; + + let mut out = Vec::new(); + for ob in outbounds { + let typ = ob.get("type").and_then(|t| t.as_str()).unwrap_or(""); + if typ.is_empty() || NON_NODE_TYPES.contains(&typ) { + continue; + } + let kind = match typ { + "shadowsocks" => "ss", + "vmess" => "vmess", + "vless" => "vless", + "trojan" => "trojan", + "socks" => "socks5", + "http" => { + if ob + .get("tls") + .and_then(|t| t.get("enabled")) + .and_then(|e| e.as_bool()) + .unwrap_or(false) + { + "https" + } else { + "http" + } + } + "hysteria2" => "hysteria2", + "tuic" => "tuic", + "wireguard" => "wireguard", + _ => continue, + } + .to_string(); + let host = ob + .get("server") + .and_then(|s| s.as_str()) + .unwrap_or("") + .to_string(); + let port: u16 = ob + .get("server_port") + .and_then(|p| p.as_u64().and_then(|x| u16::try_from(x).ok())) + .unwrap_or(0); + if host.is_empty() || port == 0 { + continue; + } + let name = ob + .get("tag") + .and_then(|t| t.as_str()) + .filter(|s| !s.is_empty()) + .map(str::to_string) + .unwrap_or_else(|| format!("{host}:{port}")); + let (username, password) = match kind.as_str() { + "ss" => (None, ob.get("password").and_then(|x| x.as_str()).map(str::to_string)), + "trojan" => (None, ob.get("password").and_then(|x| x.as_str()).map(str::to_string)), + "vmess" | "vless" => (None, ob.get("uuid").and_then(|x| x.as_str()).map(str::to_string)), + "hysteria2" => ( + None, + ob.get("password") + .and_then(|x| x.as_str()) + .or_else(|| ob.get("auth").and_then(|x| x.as_str())) + .map(str::to_string), + ), + "tuic" => ( + None, + ob.get("uuid") + .and_then(|x| x.as_str()) + .or_else(|| ob.get("password").and_then(|x| x.as_str())) + .map(str::to_string), + ), + _ => ( + ob.get("username").and_then(|x| x.as_str()).map(str::to_string), + ob.get("password").and_then(|x| x.as_str()).map(str::to_string), + ), + }; + out.push(ParsedNode { + name, + kind, + host, + port, + username, + password, + extra: ob.clone(), + duplicate: false, + }); + } + if out.is_empty() { + return Err(AppError::bad("Sing-box 订阅中未解析出可用节点")); + } + Ok(out) +} diff --git a/src/services/importer/parse_uri.rs b/src/services/importer/parse_uri.rs new file mode 100644 index 0000000..a67889f --- /dev/null +++ b/src/services/importer/parse_uri.rs @@ -0,0 +1,298 @@ +//! P6 —— URI 解析:ss / vmess / vless / trojan / hysteria2 / tuic / socks5 / http(s) + +use serde_json::json; + +use crate::error::{AppError, AppResult}; +use crate::services::importer::{ParsedNode, b64_decode}; + +/// 解析单行 URI;无法识别返回 None(调用方跳过) +pub fn parse_uri_line(line: &str) -> Option { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + return None; + } + let scheme_end = line.find("://")?; + let scheme = line[..scheme_end].to_lowercase(); + match scheme.as_str() { + "ss" => parse_ss(line), + "vmess" => parse_vmess(line), + "vless" => parse_vless(line), + "trojan" => parse_trojan(line), + "hysteria2" | "hy2" => parse_hysteria2(line), + "tuic" => parse_tuic(line), + "socks5" | "socks" => parse_socks5(line), + "http" | "https" => parse_http(line, &scheme), + _ => None, + } +} + +fn name_from_fragment(line: &str, fallback: &str) -> String { + line.rfind('#') + .map(|i| percent_decode(&line[i + 1..]).trim().to_string()) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| fallback.to_string()) +} + +fn percent_decode(s: &str) -> String { + let mut bytes: Vec = Vec::with_capacity(s.len()); + let mut it = s.as_bytes().iter().peekable(); + while let Some(&b) = it.next() { + if b == b'%' { + let h: Vec = it.by_ref().take(2).copied().collect(); + if h.len() == 2 { + if let (Ok(hex),) = (std::str::from_utf8(&h),) { + if let Ok(v) = u8::from_str_radix(hex, 16) { + bytes.push(v); + continue; + } + } + bytes.push(b'%'); + bytes.extend_from_slice(&h); + } else { + bytes.push(b'%'); + bytes.extend_from_slice(&h); + } + } else if b == b'+' { + bytes.push(b' '); + } else { + bytes.push(b); + } + } + String::from_utf8(bytes).unwrap_or_else(|_| s.to_string()) +} + +fn split_user_host(rest: &str) -> Option<((String, Option), String, u16)> { + // rest: [userinfo@]host:port[/...][?...][#...] + let no_frag = rest.split('#').next().unwrap_or(rest); + let no_query = no_frag.split('?').next().unwrap_or(no_frag); + let no_path = no_query.split('/').next().unwrap_or(no_query); + let (userinfo, hostport) = match no_path.rfind('@') { + Some(i) => (Some(&no_path[..i]), &no_path[i + 1..]), + None => (None, no_path), + }; + let (host, port) = if hostport.starts_with('[') { + let end = hostport.find(']')?; + let h = &hostport[1..end]; + let p: u16 = hostport[end + 1..].trim_start_matches(':').parse().ok()?; + (h.to_string(), p) + } else { + let i = hostport.rfind(':')?; + let h = hostport[..i].to_string(); + let p: u16 = hostport[i + 1..].parse().ok()?; + if h.is_empty() { + return None; + } + (h, p) + }; + let (user, pass) = match userinfo { + Some(u) => match u.find(':') { + Some(i) => ( + percent_decode(&u[..i]), + Some(percent_decode(&u[i + 1..])), + ), + None => (percent_decode(u), None), + }, + None => (String::new(), None), + }; + Some(((user, pass), host, port)) +} + +fn parse_ss(line: &str) -> Option { + let rest = &line[5..]; + if let Some(((user, pass), host, port)) = split_user_host(rest) { + let (method, password) = if !user.is_empty() && pass.is_none() { + let dec = b64_decode(&user).ok()?; + let i = dec.find(':')?; + (dec[..i].to_string(), dec[i + 1..].to_string()) + } else if let Some(p) = pass { + (user, p) + } else { + return None; + }; + let name = name_from_fragment(line, &format!("{host}:{port}")); + return Some(ParsedNode { + name, + kind: "ss".into(), + host, + port, + username: None, + password: Some(password), + extra: json!({"method": method}), + duplicate: false, + }); + } + // 整体 base64 形式:ss://base64(method:password@host:port) + let no_frag = rest.split('#').next().unwrap_or(rest); + let dec = b64_decode(no_frag).ok()?; + let frag = line.rfind('#').map(|i| &line[i..]).unwrap_or(""); + parse_ss(&format!("ss://{dec}{frag}")) +} + +fn parse_vmess(line: &str) -> Option { + let rest = line[8..].split('#').next().unwrap_or(&line[8..]); + let dec = b64_decode(rest.trim()).ok()?; + let v: serde_json::Value = serde_json::from_str(&dec).ok()?; + let host = v.get("add")?.as_str()?.to_string(); + let port: u16 = v + .get("port") + .and_then(|p| { + p.as_str() + .and_then(|s| s.parse().ok()) + .or_else(|| p.as_u64().and_then(|x| u16::try_from(x).ok())) + }) + .unwrap_or(0); + if host.is_empty() || port == 0 { + return None; + } + let name = v + .get("ps") + .and_then(|x| x.as_str()) + .filter(|s| !s.is_empty()) + .map(str::to_string) + .unwrap_or_else(|| name_from_fragment(line, &format!("{host}:{port}"))); + let mut extra = v.clone(); + if let Some(m) = extra.as_object_mut() { + m.remove("ps"); + } + Some(ParsedNode { + name, + kind: "vmess".into(), + host, + port, + username: None, + password: v.get("id").and_then(|x| x.as_str()).map(str::to_string), + extra, + duplicate: false, + }) +} + +fn parse_vless(line: &str) -> Option { + let rest = &line[8..]; + let ((user, _), host, port) = split_user_host(rest)?; + if user.is_empty() { + return None; + } + Some(ParsedNode { + name: name_from_fragment(line, &format!("{host}:{port}")), + kind: "vless".into(), + host, + port, + username: None, + password: Some(user), + extra: json!({}), + duplicate: false, + }) +} + +fn parse_trojan(line: &str) -> Option { + let rest = &line[9..]; + let ((user, _), host, port) = split_user_host(rest)?; + if user.is_empty() { + return None; + } + Some(ParsedNode { + name: name_from_fragment(line, &format!("{host}:{port}")), + kind: "trojan".into(), + host, + port, + username: None, + password: Some(user), + extra: json!({}), + duplicate: false, + }) +} + +fn parse_hysteria2(line: &str) -> Option { + let scheme_len = if line.starts_with("hysteria2://") { + 13 + } else { + 6 + }; + let rest = &line[scheme_len..]; + let ((user, _), host, port) = split_user_host(rest)?; + if user.is_empty() { + return None; + } + Some(ParsedNode { + name: name_from_fragment(line, &format!("{host}:{port}")), + kind: "hysteria2".into(), + host, + port, + username: None, + password: Some(user), + extra: json!({}), + duplicate: false, + }) +} + +fn parse_tuic(line: &str) -> Option { + let rest = &line[7..]; + let ((user, pass), host, port) = split_user_host(rest)?; + if user.is_empty() { + return None; + } + Some(ParsedNode { + name: name_from_fragment(line, &format!("{host}:{port}")), + kind: "tuic".into(), + host, + port, + username: None, + password: pass.or(Some(user)), + extra: json!({}), + duplicate: false, + }) +} + +fn parse_socks5(line: &str) -> Option { + let scheme_len = if line.starts_with("socks5://") { 9 } else { 8 }; + let rest = &line[scheme_len..]; + let ((user, pass), host, port) = split_user_host(rest)?; + Some(ParsedNode { + name: name_from_fragment(line, &format!("{host}:{port}")), + kind: "socks5".into(), + host, + port, + username: if user.is_empty() { None } else { Some(user) }, + password: pass, + extra: json!({}), + duplicate: false, + }) +} + +fn parse_http(line: &str, scheme: &str) -> Option { + let rest = &line[scheme.len() + 3..]; + let ((user, pass), host, port) = split_user_host(rest).or_else(|| { + let no_frag = rest.split('#').next().unwrap_or(rest); + let no_query = no_frag.split('?').next().unwrap_or(no_frag); + let host = no_query.split('/').next().unwrap_or(no_query).to_string(); + if host.is_empty() { + return None; + } + let port = if scheme == "https" { 443 } else { 80 }; + Some(((String::new(), None), host, port)) + })?; + Some(ParsedNode { + name: name_from_fragment(line, &format!("{host}:{port}")), + kind: scheme.to_string(), + host, + port, + username: if user.is_empty() { None } else { Some(user) }, + password: pass, + extra: json!({}), + duplicate: false, + }) +} + +/// 按行解析 URI 列表 +pub fn parse_uri_list(content: &str) -> AppResult> { + let mut out = Vec::new(); + for line in content.lines() { + if let Some(n) = parse_uri_line(line) { + out.push(n); + } + } + if out.is_empty() { + return Err(AppError::bad("未解析出任何节点 URI")); + } + Ok(out) +} diff --git a/src/services/lifecycle.rs b/src/services/lifecycle.rs new file mode 100644 index 0000000..82e4241 --- /dev/null +++ b/src/services/lifecycle.rs @@ -0,0 +1,522 @@ +//! P11/P12 —— 生命周期任务:到期 / 配额 / 自动停用 / 即将到期扫描 +//! +//! 每 60 秒一轮(`LifecycleCfg.tick_secs`),单个 tokio 任务。数据库操作放在一个短事务内, +//! 事务提交后统一调用 alert::emit 写入告警事件(P12:按规则匹配 + 冷却去重,投递器异步发送)。 +//! +//! 停用原因规则(§13.2,务必遵守): +//! - 管理员手动停用 → enabled=0, disabled_reason='manual' +//! - 到期自动停用 → enabled=0, disabled_reason='expired' +//! - 配额用尽自动停用 → enabled=0, disabled_reason='quota' +//! - 自动恢复只处理 disabled_reason='expired'(到期已延到未来)或 'quota'(配额已恢复)的对象 +//! - 所有自动停用/恢复都要更新 updated_at(Worker 靠它判断配置变化)并写审计日志(actor='system') + +use chrono::{Datelike, NaiveDate, NaiveDateTime}; +use sqlx::SqlitePool; + +use crate::audit::audit_system; +use crate::config::LifecycleCfg; +use crate::error::AppError; +use crate::models::QuotaRow; +use crate::services::alert::{days_before_of, emit, AlertDraft}; + +const ENTRY_TABLES: [(&str, &str); 3] = [ + ("proxy", "proxy_rules"), + ("forward", "port_forwards"), + ("tunnel", "tunnels"), +]; + +/// 配额预警默认阈值(百分比);以 quota_warn 规则的 params 为准 +const DEFAULT_WARN_THRESHOLDS: [i64; 2] = [80, 95]; + +/// 返回包含 now 的那个月度周期起点(00:00:00,UTC 存储)。 +/// tz_offset_hours 用于按本地时区划分周期(默认 8)。 +pub fn cycle_start(now_utc: NaiveDateTime, reset_day: u32, tz_offset_hours: i64) -> NaiveDateTime { + let local = now_utc + chrono::Duration::hours(tz_offset_hours); + let d = reset_day.clamp(1, 28); + let (y, m) = if local.day() >= d { + (local.year(), local.month()) + } else if local.month() == 1 { + (local.year() - 1, 12) + } else { + (local.year(), local.month() - 1) + }; + let start_local = NaiveDate::from_ymd_opt(y, m, d) + .unwrap() + .and_hms_opt(0, 0, 0) + .unwrap(); + start_local - chrono::Duration::hours(tz_offset_hours) // 转回 UTC 存储 +} + +fn table_of(entry_type: &str) -> Result<&'static str, AppError> { + ENTRY_TABLES + .iter() + .find(|(t, _)| *t == entry_type) + .map(|(_, table)| *table) + .ok_or_else(|| AppError::bad("未知的条目类型")) +} + +pub async fn lifecycle_tick( + pool: &SqlitePool, + cfg: &LifecycleCfg, + tz_offset_hours: i64, + base_url: &str, +) -> anyhow::Result<()> { + let mut alerts: Vec = Vec::new(); + // 告警阈值以规则参数为准(事务外读取一次) + let quota_thresholds = warn_thresholds_of(pool).await?; + let entry_days = days_before_for(pool, "entry_expiring").await?; + let sub_days = days_before_for(pool, "sub_expiring").await?; + let mut tx = pool.begin().await?; + + // 1) 条目到期 → 自动停用(只处理 enabled=1 的;manual 停用的不改写原因) + for (t, table) in ENTRY_TABLES { + let rows: Vec<(i64, String, String)> = sqlx::query_as(&format!( + "SELECT id, name, expires_at FROM {table} + WHERE enabled = 1 AND expires_at IS NOT NULL AND expires_at <= datetime('now')" + )) + .fetch_all(&mut *tx) + .await?; + for (id, name, expires_at) in rows { + sqlx::query(&format!( + "UPDATE {table} SET enabled = 0, disabled_reason = 'expired', + updated_at = datetime('now') WHERE id = ?" + )) + .bind(id) + .execute(&mut *tx) + .await?; + audit_system( + pool, + "entry_auto_expired", + Some(t), + Some(id), + serde_json::json!({"name": name}), + ) + .await; + alerts.push(AlertDraft::entry_expired(t, id, &name, &expires_at, base_url)); + } + } + + // 2) 订阅到期 → 标记停用 + let subs: Vec<(i64, String)> = sqlx::query_as( + "SELECT id, name FROM subscriptions + WHERE enabled = 1 AND expires_at IS NOT NULL AND expires_at <= datetime('now')", + ) + .fetch_all(&mut *tx) + .await?; + for (id, name) in subs { + sqlx::query( + "UPDATE subscriptions SET enabled = 0, disabled_reason = 'expired', + updated_at = datetime('now') WHERE id = ?", + ) + .bind(id) + .execute(&mut *tx) + .await?; + audit_system( + pool, + "sub_auto_expired", + Some("subscription"), + Some(id), + serde_json::json!({"name": name}), + ) + .await; + alerts.push(AlertDraft::sub_expired(id, &name, base_url)); + } + + // 3) 月度配额重置 + 恢复因配额停用的条目 + reset_monthly_quotas(&mut tx, tz_offset_hours).await?; + + // 4) 配额检查:预警 + 超限停用 + check_quotas(&mut tx, "a_thresholds, tz_offset_hours, base_url, &mut alerts).await?; + + // 5) 即将到期提醒(订阅与条目,按阈值) + collect_expiring_notices(&mut tx, &entry_days, &sub_days, tz_offset_hours, base_url, &mut alerts) + .await?; + + // 6) 过期订阅自动清理(可选) + if cfg.expired_sub_cleanup_days > 0 { + sqlx::query( + "DELETE FROM subscriptions + WHERE disabled_reason = 'expired' + AND expires_at <= datetime('now', ?)", + ) + .bind(format!("-{} days", cfg.expired_sub_cleanup_days)) + .execute(&mut *tx) + .await?; + } + + tx.commit().await?; + + // 7) 事务外写入告警事件(去重在 emit 内完成,§13.5 / §14.4) + for a in alerts { + emit(pool, a).await?; + } + Ok(()) +} + +/// 读取 quota_warn 规则的 percent 阈值(无规则时用默认 [80, 95]) +async fn warn_thresholds_of(pool: &SqlitePool) -> anyhow::Result> { + let params: Option = sqlx::query_scalar( + "SELECT params FROM alert_rules WHERE enabled = 1 AND event_type = 'quota_warn' LIMIT 1", + ) + .fetch_optional(pool) + .await? + .flatten(); + Ok(params + .and_then(|p| serde_json::from_str::(&p).ok()) + .and_then(|v| v.get("percent").cloned()) + .and_then(|v| serde_json::from_value::>(v).ok()) + .filter(|v| !v.is_empty()) + .unwrap_or_else(|| DEFAULT_WARN_THRESHOLDS.to_vec())) +} + +/// 读取某 event_type 规则的 days_before(无规则时用默认 [7,3,1]) +async fn days_before_for(pool: &SqlitePool, event_type: &str) -> anyhow::Result> { + let params: Option = sqlx::query_scalar( + "SELECT params FROM alert_rules WHERE enabled = 1 AND event_type = ? LIMIT 1", + ) + .bind(event_type) + .fetch_optional(pool) + .await? + .flatten(); + Ok(params + .map(|p| days_before_of(&p)) + .filter(|v| !v.is_empty()) + .unwrap_or_else(|| vec![7, 3, 1])) +} + +/// 月度配额重置:进入新周期 → 用量清零、状态恢复,并恢复 disabled_reason='quota' 的条目 +async fn reset_monthly_quotas( + tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>, + tz_offset_hours: i64, +) -> anyhow::Result<()> { + let now_utc = chrono::Utc::now().naive_utc(); + let rows: Vec = + sqlx::query_as("SELECT * FROM entry_quotas WHERE period = 'monthly' AND quota_bytes > 0") + .fetch_all(&mut **tx) + .await?; + for q in rows { + let start = cycle_start(now_utc, q.reset_day as u32, tz_offset_hours); + let period_start = + NaiveDateTime::parse_from_str(&q.period_start, "%Y-%m-%d %H:%M:%S") + .unwrap_or(NaiveDateTime::MIN); + if period_start >= start { + continue; // 还在本周期内 + } + let start_s = start.format("%Y-%m-%d %H:%M:%S").to_string(); + sqlx::query( + "UPDATE entry_quotas SET used_bytes = 0, status = 'ok', warn_level = 0, + exceeded_at = NULL, period_start = ?, updated_at = datetime('now') + WHERE id = ?", + ) + .bind(&start_s) + .bind(q.id) + .execute(&mut **tx) + .await?; + // 只恢复因配额停用的条目 + let table = table_of(&q.entry_type)?; + let n = sqlx::query(&format!( + "UPDATE {table} SET enabled = 1, disabled_reason = NULL, updated_at = datetime('now') + WHERE id = ? AND disabled_reason = 'quota'" + )) + .bind(q.entry_id) + .execute(&mut **tx) + .await? + .rows_affected(); + if n > 0 { + tracing::info!( + "配额月度重置:{}/{}(quota_id={})恢复启用", + q.entry_type, + q.entry_id, + q.id + ); + } + } + Ok(()) +} + +/// 配额检查:超限停用 + 阈值预警 +async fn check_quotas( + tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>, + warn_thresholds: &[i64], + tz_offset_hours: i64, + base_url: &str, + alerts: &mut Vec, +) -> anyhow::Result<()> { + let rows: Vec = + sqlx::query_as("SELECT * FROM entry_quotas WHERE quota_bytes > 0 AND status = 'ok'") + .fetch_all(&mut **tx) + .await?; + for q in rows { + check_one_quota(tx, &q, warn_thresholds, tz_offset_hours, base_url, alerts).await?; + } + Ok(()) +} + +/// 对单个配额做超限/预警检查(flush 后立刻调用,把超限到停用的延迟压到约 10 秒) +pub async fn check_quota_now( + pool: &SqlitePool, + entry_type: &str, + entry_id: i64, + tz_offset_hours: i64, + base_url: &str, +) -> anyhow::Result<()> { + let warn_thresholds = warn_thresholds_of(pool).await?; + let mut tx = pool.begin().await?; + let mut alerts: Vec = Vec::new(); + if let Some(q) = sqlx::query_as::<_, QuotaRow>( + "SELECT * FROM entry_quotas WHERE entry_type = ? AND entry_id = ? AND quota_bytes > 0 AND status = 'ok'", + ) + .bind(entry_type) + .bind(entry_id) + .fetch_optional(&mut *tx) + .await? + { + check_one_quota(&mut tx, &q, &warn_thresholds, tz_offset_hours, base_url, &mut alerts).await?; + } + tx.commit().await?; + for a in alerts { + emit(pool, a).await?; + } + Ok(()) +} + +async fn check_one_quota( + tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>, + q: &QuotaRow, + warn_thresholds: &[i64], + tz_offset_hours: i64, + base_url: &str, + alerts: &mut Vec, +) -> anyhow::Result<()> { + let table = table_of(&q.entry_type)?; + if q.used_bytes >= q.quota_bytes { + // 超限停用:只停用当前启用的;manual 停用的保持原因不变(enabled=1 才改) + sqlx::query(&format!( + "UPDATE {table} SET enabled = 0, disabled_reason = 'quota', + updated_at = datetime('now') WHERE id = ? AND enabled = 1" + )) + .bind(q.entry_id) + .execute(&mut **tx) + .await?; + sqlx::query( + "UPDATE entry_quotas SET status = 'exceeded', exceeded_at = datetime('now'), + updated_at = datetime('now') WHERE id = ?", + ) + .bind(q.id) + .execute(&mut **tx) + .await?; + let name: Option = + sqlx::query_scalar(&format!("SELECT name FROM {table} WHERE id = ?")) + .bind(q.entry_id) + .fetch_optional(&mut **tx) + .await? + .flatten(); + alerts.push(AlertDraft::quota_exceeded( + &q.entry_type, + q.entry_id, + name.as_deref().unwrap_or("未知条目"), + &q.period_start, + tz_offset_hours, + base_url, + )); + } else { + // 预警:used_pct 落到哪个阈值区间,仅当超过已发出的最高阈值时触发 + let pct = q.used_bytes * 100 / q.quota_bytes.max(1); + let level = warn_thresholds + .iter() + .filter(|t| **t <= pct) + .max() + .copied() + .unwrap_or(0); + if level > q.warn_level { + sqlx::query( + "UPDATE entry_quotas SET warn_level = ?, updated_at = datetime('now') WHERE id = ?", + ) + .bind(level) + .bind(q.id) + .execute(&mut **tx) + .await?; + let name: Option = + sqlx::query_scalar(&format!("SELECT name FROM {table} WHERE id = ?")) + .bind(q.entry_id) + .fetch_optional(&mut **tx) + .await? + .flatten(); + alerts.push(AlertDraft::quota_warn( + &q.entry_type, + q.entry_id, + name.as_deref().unwrap_or("未知条目"), + &q.period_start, + level, + q.used_bytes, + q.quota_bytes, + base_url, + )); + } + } + Ok(()) +} + +/// 即将到期提醒(§14.5):按阈值 days_before,只发"最贴近当前剩余天数"的一条。 +/// 去重靠 emit(dedup_key 带阈值与到期时间 + 规则 10 年冷却),同一阈值只发一次。 +async fn collect_expiring_notices( + tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>, + entry_days: &[i64], + sub_days: &[i64], + tz_offset_hours: i64, + base_url: &str, + alerts: &mut Vec, +) -> anyhow::Result<()> { + let now = chrono::Utc::now().naive_utc(); + let entry_max = entry_days.iter().max().copied().unwrap_or(7); + let sub_max = sub_days.iter().max().copied().unwrap_or(7); + + // 条目 + for (t, table) in ENTRY_TABLES { + let rows: Vec<(i64, String, String)> = sqlx::query_as(&format!( + "SELECT id, name, expires_at FROM {table} + WHERE enabled = 1 AND expires_at IS NOT NULL + AND expires_at > datetime('now') + AND expires_at <= datetime('now', '+{entry_max} days')" + )) + .fetch_all(&mut **tx) + .await?; + for (id, name, expires_at) in rows { + let Some(left) = crate::services::alert::days_left(&expires_at, now) else { + continue; + }; + let days_cfg = crate::services::alert::DaysBefore { + days_before: entry_days.to_vec(), + }; + if let Some(d) = days_cfg.pick(left) { + alerts.push(AlertDraft::entry_expiring( + t, + id, + &name, + d, + &expires_at, + tz_offset_hours, + base_url, + )); + } + } + } + + // 订阅 + let subs: Vec<(i64, String, String)> = sqlx::query_as(&format!( + "SELECT id, name, expires_at FROM subscriptions + WHERE enabled = 1 AND expires_at IS NOT NULL + AND expires_at > datetime('now') + AND expires_at <= datetime('now', '+{sub_max} days')" + )) + .fetch_all(&mut **tx) + .await?; + for (id, name, expires_at) in subs { + let Some(left) = crate::services::alert::days_left(&expires_at, now) else { + continue; + }; + let days_cfg = crate::services::alert::DaysBefore { + days_before: sub_days.to_vec(), + }; + if let Some(d) = days_cfg.pick(left) { + alerts.push(AlertDraft::sub_expiring( + id, + &name, + d, + &expires_at, + tz_offset_hours, + base_url, + )); + } + } + Ok(()) +} + +/// 管理员手动"重置用量":used_bytes=0、status='ok'、warn_level=0,并恢复因配额停用的条目 +pub async fn reset_quota_usage( + pool: &SqlitePool, + entry_type: &str, + entry_id: i64, +) -> Result { + let table = table_of(entry_type)?; + let mut tx = pool.begin().await?; + let q: Option = + sqlx::query_as("SELECT * FROM entry_quotas WHERE entry_type = ? AND entry_id = ?") + .bind(entry_type) + .bind(entry_id) + .fetch_optional(&mut *tx) + .await + ?; + let q = q.ok_or_else(|| AppError::not_found("该条目未设置配额"))?; + // monthly 配额重置后 period_start 设为当前周期起点,避免立刻又被判定进入新周期 + let now_utc = chrono::Utc::now().naive_utc(); + let start_s = if q.period == "monthly" { + cycle_start(now_utc, q.reset_day as u32, 8) + .format("%Y-%m-%d %H:%M:%S") + .to_string() + } else { + q.period_start.clone() + }; + sqlx::query( + "UPDATE entry_quotas SET used_bytes = 0, status = 'ok', warn_level = 0, + exceeded_at = NULL, period_start = ?, updated_at = datetime('now') + WHERE id = ?", + ) + .bind(&start_s) + .bind(q.id) + .execute(&mut *tx) + .await + ?; + let n = sqlx::query(&format!( + "UPDATE {table} SET enabled = 1, disabled_reason = NULL, updated_at = datetime('now') + WHERE id = ? AND disabled_reason = 'quota'" + )) + .bind(entry_id) + .execute(&mut *tx) + .await + ? + .rows_affected(); + // 条目 updated_at 刷新,Worker 感知 + sqlx::query(&format!( + "UPDATE {table} SET updated_at = datetime('now') WHERE id = ?" + )) + .bind(entry_id) + .execute(&mut *tx) + .await + ?; + tx.commit().await?; + Ok(n > 0) +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::NaiveDate; + + fn ndt(y: i32, m: u32, d: u32, h: u32, mi: u32) -> NaiveDateTime { + NaiveDate::from_ymd_opt(y, m, d).unwrap().and_hms_opt(h, mi, 0).unwrap() + } + + #[test] + fn cycle_start_shanghai() { + // UTC 2026-09-30 06:53 → 北京时间 14:53(30日)≥1日 → 周期起点=本地9月1日00:00 = UTC 8月31日16:00 + let got = cycle_start(ndt(2026, 9, 30, 6, 53), 1, 8); + assert_eq!(got, ndt(2026, 8, 31, 16, 0)); + // UTC 2026-09-01 00:30 → 北京时间 08:30(1日)≥1日 → 周期起点=本地9月1日00:00 + let got = cycle_start(ndt(2026, 9, 1, 0, 30), 1, 8); + assert_eq!(got, ndt(2026, 8, 31, 16, 0)); + // UTC 2026-08-31 15:00 → 北京时间 23:00(31日)≥1 → 周期起点=本地8月1日00:00 = UTC 7月31日16:00 + let got = cycle_start(ndt(2026, 8, 31, 15, 0), 1, 8); + assert_eq!(got, ndt(2026, 7, 31, 16, 0)); + } + + #[test] + fn cycle_start_before_reset_day() { + // reset_day=15;UTC 2026-09-10 00:00 → 本地 08:00(10日)<15 → 上月15日00:00本地 = UTC 8月14日16:00 + let got = cycle_start(ndt(2026, 9, 10, 0, 0), 15, 8); + assert_eq!(got, ndt(2026, 8, 14, 16, 0)); + // 1月回绕:UTC 2026-01-05 00:00 → 本地1月5日 <15 → 2025年12月15日00:00本地 + let got = cycle_start(ndt(2026, 1, 5, 0, 0), 15, 8); + assert_eq!(got, ndt(2025, 12, 14, 16, 0)); + } +} diff --git a/src/services/mod.rs b/src/services/mod.rs new file mode 100644 index 0000000..b4397c2 --- /dev/null +++ b/src/services/mod.rs @@ -0,0 +1,10 @@ +pub mod aggregator; +pub mod alert; +pub mod cleaner; +pub mod export; +pub mod heartbeat; +pub mod importer; +pub mod lifecycle; +pub mod node_watch; +pub mod quota; +pub mod runtime; diff --git a/src/services/node_watch.rs b/src/services/node_watch.rs new file mode 100644 index 0000000..e4f09a5 --- /dev/null +++ b/src/services/node_watch.rs @@ -0,0 +1,98 @@ +//! P12 —— 节点在线状态巡检(§14.5) +//! +//! 每 15 秒一轮:非本机、已启用的节点按 `nodes.online_state` 状态机判定, +//! 只在状态翻转时 emit(一次离线只发一次,恢复时再发一次,离线期间不反复轰炸)。 +//! 从未上线过的新节点(last_heartbeat_at 为空)保持 unknown,不误报。 + +use chrono::NaiveDateTime; +use sqlx::SqlitePool; + +use crate::services::alert::{emit, offline_secs_of, AlertDraft}; + +pub async fn node_watch_loop( + pool: SqlitePool, + tz_offset_hours: i64, + base_url: String, + heartbeat_timeout_secs: i64, +) { + let mut tick = tokio::time::interval(std::time::Duration::from_secs(15)); + loop { + tick.tick().await; + if let Err(e) = node_watch_tick(&pool, tz_offset_hours, &base_url, heartbeat_timeout_secs).await { + tracing::warn!("节点巡检失败: {e:#}"); + } + } +} + +pub async fn node_watch_tick( + pool: &SqlitePool, + tz_offset_hours: i64, + base_url: &str, + heartbeat_timeout_secs: i64, +) -> anyhow::Result<()> { + // offline_secs 取自 node_offline 规则参数(默认 90),必须 >= heartbeat_timeout + let offline_secs = { + let params: Option = sqlx::query_scalar( + "SELECT params FROM alert_rules WHERE enabled = 1 AND event_type = 'node_offline' LIMIT 1", + ) + .fetch_optional(pool) + .await? + .flatten(); + params + .map(|p| offline_secs_of(&p)) + .unwrap_or(90) + .max(heartbeat_timeout_secs.max(1)) + }; + + let nodes: Vec<(i64, String, Option, String)> = sqlx::query_as( + "SELECT id, name, last_heartbeat_at, online_state FROM nodes + WHERE is_local = 0 AND enabled = 1", + ) + .fetch_all(pool) + .await?; + + let now = chrono::Utc::now().naive_utc(); + let timeout = chrono::Duration::seconds(heartbeat_timeout_secs.max(1)); + let offline_dur = chrono::Duration::seconds(offline_secs); + + for (id, name, last_hb_opt, state) in nodes { + let Some(last_hb) = last_hb_opt else { continue }; // 从未上线,保持 unknown + let Ok(last) = NaiveDateTime::parse_from_str(last_hb.trim(), "%Y-%m-%d %H:%M:%S") else { + continue; + }; + let age = now - last; + let is_online = age <= timeout; + let is_offline = age >= offline_dur; + + match (state.as_str(), is_online, is_offline) { + ("online", false, true) => { + sqlx::query("UPDATE nodes SET online_state = 'offline' WHERE id = ?") + .bind(id) + .execute(pool) + .await?; + tracing::warn!("节点离线:{name}(id={id})"); + emit( + pool, + AlertDraft::node_offline(id, &name, &last_hb, offline_secs, tz_offset_hours, base_url), + ) + .await?; + } + ("offline", true, _) => { + sqlx::query("UPDATE nodes SET online_state = 'online' WHERE id = ?") + .bind(id) + .execute(pool) + .await?; + tracing::info!("节点恢复:{name}(id={id})"); + emit(pool, AlertDraft::node_online(id, &name, base_url)).await?; + } + ("unknown", true, _) => { + sqlx::query("UPDATE nodes SET online_state = 'online' WHERE id = ?") + .bind(id) + .execute(pool) + .await?; + } + _ => {} + } + } + Ok(()) +} diff --git a/src/services/quota.rs b/src/services/quota.rs new file mode 100644 index 0000000..958a7f2 --- /dev/null +++ b/src/services/quota.rs @@ -0,0 +1,31 @@ +//! P10/P11 —— 配额用量累计 +//! +//! 统计入口只有两个:Worker 上报(`/internal/node/stats/flush`)和本机运行时(P11 接入)。 +//! 二者都走 `add_usage`,保证口径一致。 + +use crate::error::AppError; + +/// 累计配额用量(上行 + 下行都计入),在写 stats_hourly 的同一个事务里调用。 +pub async fn add_usage( + tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>, + entry_type: &str, // 与 stats_hourly.rule_type 词汇一致:proxy | forward | tunnel + entry_id: i64, + bytes_up: i64, + bytes_down: i64, +) -> Result<(), AppError> { + let total = bytes_up.saturating_add(bytes_down); + if total <= 0 { + return Ok(()); + } + sqlx::query( + "UPDATE entry_quotas + SET used_bytes = used_bytes + ?, updated_at = datetime('now') + WHERE entry_type = ? AND entry_id = ? AND quota_bytes > 0", + ) + .bind(total) + .bind(entry_type) + .bind(entry_id) + .execute(&mut **tx) + .await?; + Ok(()) +} diff --git a/src/services/runtime.rs b/src/services/runtime.rs new file mode 100644 index 0000000..97d294a --- /dev/null +++ b/src/services/runtime.rs @@ -0,0 +1,1199 @@ +//! P8 —— 运行时(§15):真实转发 / 代理 / 隧道。 +//! +//! - 每个**启用**的本机节点条目对应 tokio 任务,由 [`RuntimeManager`] 持有 +//! `HashMap<(entry_type, id), RunningTask>`(JoinHandle + CancellationToken)。 +//! - 配置变化时:新增 → spawn;删除 / 禁用 / 到期 / 配额停用 → cancel;修改 → cancel 后重建。 +//! - TCP 转发:accept → 连接目标 → copy_bidirectional,结束记字节数与状态。 +//! - UDP 转发:按客户端地址维护会话表,空闲 60 秒超时。 +//! - 落地代理:监听端提供 HTTP CONNECT 与 SOCKS5 入口,出站支持 http / socks5 上游。 +//! - 中转隧道:TCP 中转与 WebSocket 隧道。 +//! - 启动失败(端口占用、权限不足)写入运行状态 `error:<原因>`。 +//! - 每个任务持有原子计数器(当前连接数、累计上下行字节)。 + +use std::collections::HashMap; +use std::net::SocketAddr; +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; + +use sqlx::SqlitePool; +use tokio::task::JoinHandle; +use tokio_util::sync::CancellationToken; + +use crate::config::Config; + +pub type EntryKey = (String, i64); // (entry_type, id),entry_type: proxy | forward | tunnel + +/// 每个运行中条目的原子计数器 +#[derive(Debug, Default)] +pub struct Counters { + /// 当前连接数 + pub conns: AtomicU64, + /// 累计上行字节 + pub bytes_up: AtomicU64, + /// 累计下行字节 + pub bytes_down: AtomicU64, +} + +struct RunningTask { + cancel: CancellationToken, + handles: Vec>, + counters: Arc, + /// 运行状态:running | idle | error:<原因> + status: String, + name: String, + spec_hash: u64, +} + +/// 运行时管理器 +pub struct RuntimeManager { + pool: SqlitePool, + cfg: Config, + tasks: tokio::sync::Mutex>, +} + +impl RuntimeManager { + pub fn new(pool: SqlitePool, cfg: Config) -> Arc { + Arc::new(Self { + pool, + cfg, + tasks: tokio::sync::Mutex::new(HashMap::new()), + }) + } + + /// 运行状态快照:给 API / 列表页用 + pub async fn status_snapshot(&self) -> Vec { + let tasks = self.tasks.lock().await; + tasks + .iter() + .map(|((etype, id), t)| { + serde_json::json!({ + "entry_type": etype, + "entry_id": id, + "name": t.name, + "status": t.status, + "conns": t.counters.conns.load(Ordering::Relaxed), + "bytes_up": t.counters.bytes_up.load(Ordering::Relaxed), + "bytes_down": t.counters.bytes_down.load(Ordering::Relaxed), + }) + }) + .collect() + } + + /// 热更新:与 DB 期望状态对齐(新增→spawn;删除/禁用/修改→cancel 后重建) + pub async fn sync(&self) { + let desired = load_desired(&self.pool).await; + self.sync_with(desired).await; + } + + /// 与给定期望配置对齐(Worker 模式用 Master 下发的配置调用) + pub async fn sync_with(&self, desired: HashMap) { + let mut tasks = self.tasks.lock().await; + + // 1. 取消不再需要 / 配置变化的任务 + let mut to_remove: Vec = Vec::new(); + for (key, task) in tasks.iter() { + match desired.get(key) { + None => to_remove.push(key.clone()), + Some(spec) if spec.hash != task.spec_hash => to_remove.push(key.clone()), + _ => {} + } + } + for key in to_remove { + if let Some(task) = tasks.remove(&key) { + task.cancel.cancel(); + tracing::info!("运行时:停止 {}/{}({})", key.0, key.1, task.name); + } + } + + // 2. 启动新增的任务 + let specs: Vec = desired + .into_iter() + .filter(|(key, _)| !tasks.contains_key(key)) + .map(|(_, spec)| spec) + .collect(); + drop(tasks); + for spec in specs { + self.spawn_spec(spec).await; + } + } + + async fn spawn_spec(&self, spec: EntrySpec) { + let key: EntryKey = (spec.entry_type.clone(), spec.id); + let cancel = CancellationToken::new(); + let counters = Arc::new(Counters::default()); + let ctx = TaskCtx { + pool: self.pool.clone(), + cfg: self.cfg.clone(), + key: key.clone(), + cancel: cancel.clone(), + counters: counters.clone(), + }; + // 先做黑名单 / 参数校验,再真正 bind + let spawn_result = match &spec.kind { + SpecKind::TcpForward { .. } | SpecKind::UdpForward { .. } => { + run_forward(ctx, spec.clone()).await + } + SpecKind::Proxy { .. } => run_proxy(ctx, spec.clone()).await, + SpecKind::Tunnel { .. } => run_tunnel(ctx, spec.clone()).await, + SpecKind::Idle { reason } => { + let reason = reason.clone(); + Ok((vec![], format!("idle:{reason}"))) + } + }; + let mut tasks = self.tasks.lock().await; + let spec_hash = spec.hash; + match spawn_result { + Ok((handles, status)) => { + tracing::info!( + "运行时:启动 {}/{}({})状态={}", + key.0, key.1, spec.name, status + ); + tasks.insert( + key, + RunningTask { + cancel, + handles, + counters, + status, + name: spec.name, + spec_hash, + }, + ); + } + Err(e) => { + tracing::warn!("运行时:{}/{}({})启动失败: {e}", key.0, key.1, spec.name); + // P12:条目启动失败 → runtime_error 告警(同一原因 1 小时内只提醒一次) + let base = self.cfg.server.public_base_url.clone(); + if let Err(e2) = crate::services::alert::emit( + &self.pool, + crate::services::alert::AlertDraft::runtime_error( + &key.0, key.1, &spec.name, &e.to_string(), None, &base, + ), + ) + .await + { + tracing::warn!("runtime_error 告警写入失败: {e2:#}"); + } + tasks.insert( + key, + RunningTask { + cancel, + handles: vec![], + counters, + status: format!("error:{e}"), + name: spec.name, + spec_hash, + }, + ); + } + } + } +} + +// ============ 期望配置加载 ============ + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct EntrySpec { + pub entry_type: String, // proxy | forward | tunnel + pub id: i64, + pub name: String, + pub node_id: i64, + pub hash: u64, + pub kind: SpecKind, +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum SpecKind { + TcpForward { + listen_ip: String, + listen_port: i64, + target_ip: String, + target_port: i64, + }, + UdpForward { + listen_ip: String, + listen_port: i64, + target_ip: String, + target_port: i64, + }, + Proxy { + listen_addr: String, + upstream_type: String, + upstream_addr: String, + auth_user: Option, + auth_pass: Option, + extra: Option, + }, + Tunnel { + tunnel_type: String, // tcp | ws + local_addr: String, + remote_addr: String, + }, + /// 无需监听的条目(纯导入节点等) + Idle { reason: String }, +} + +pub(crate) fn spec_hash_of(parts: &[&str]) -> u64 { + use std::collections::hash_map::DefaultHasher; + use std::hash::{Hash, Hasher}; + let mut h = DefaultHasher::new(); + for p in parts { + p.hash(&mut h); + } + h.finish() +} + +/// 从 DB 加载本机节点(node_id=1)的启用条目 +async fn load_desired(pool: &SqlitePool) -> HashMap { + load_desired_for(pool, 1).await +} + +/// 加载指定节点的启用条目(Worker 配置下发 / Master 本机都用这个) +pub async fn load_desired_for(pool: &SqlitePool, node_id: i64) -> HashMap { + let mut out = HashMap::new(); + // ---- 落地代理 ---- + let proxies: Vec<(i64, String, Option, String, String, Option, Option, Option)> = + sqlx::query_as( + "SELECT id, name, listen_addr, upstream_type, upstream_addr, auth_user, auth_pass, extra + FROM proxy_rules WHERE node_id = ? AND enabled = 1 + AND (expires_at IS NULL OR expires_at > datetime('now'))", + ) + .bind(node_id) + .fetch_all(pool) + .await + .unwrap_or_default(); + for (id, name, listen_addr, utype, uaddr, auser, apass, extra) in proxies { + let kind = match listen_addr.as_deref().map(str::trim).filter(|s| !s.is_empty()) { + None => SpecKind::Idle { reason: "no-listen".into() }, + Some(la) => SpecKind::Proxy { + listen_addr: la.to_string(), + upstream_type: utype.clone(), + upstream_addr: uaddr.clone(), + auth_user: auser.clone(), + auth_pass: apass.clone(), + extra: extra.clone(), + }, + }; + let hash = spec_hash_of(&[ + "proxy", &id.to_string(), + listen_addr.as_deref().unwrap_or(""), &utype, &uaddr, + auser.as_deref().unwrap_or(""), extra.as_deref().unwrap_or(""), + ]); + out.insert( + ("proxy".to_string(), id), + EntrySpec { entry_type: "proxy".into(), id, name, node_id, hash, kind }, + ); + } + // ---- 端口转发 ---- + let forwards: Vec<(i64, String, String, i64, String, i64, String)> = sqlx::query_as( + "SELECT id, name, listen_ip, listen_port, target_ip, target_port, protocol + FROM port_forwards WHERE node_id = ? AND enabled = 1 + AND (expires_at IS NULL OR expires_at > datetime('now'))", + ) + .bind(node_id) + .fetch_all(pool) + .await + .unwrap_or_default(); + for (id, name, lip, lport, tip, tport, proto) in forwards { + let hash = spec_hash_of(&[ + "forward", &id.to_string(), &lip, &lport.to_string(), &tip, &tport.to_string(), &proto, + ]); + // protocol=both 拆成两个 key:forward/tcp#id 与 forward/udp#id 用不同 entry_type 区分 + let mk = |etype: &str, kind| EntrySpec { + entry_type: etype.into(), + id, + name: name.clone(), + node_id, + hash, + kind, + }; + if proto == "tcp" || proto == "both" { + out.insert( + ("forward".to_string(), id), + mk( + "forward", + SpecKind::TcpForward { + listen_ip: lip.clone(), + listen_port: lport, + target_ip: tip.clone(), + target_port: tport, + }, + ), + ); + } + if proto == "udp" || proto == "both" { + // both 时 udp 用 key ("forward_udp", id) 避免与 tcp 冲突 + let etype = if proto == "both" { "forward_udp" } else { "forward" }; + out.insert( + (etype.to_string(), id), + mk( + etype, + SpecKind::UdpForward { + listen_ip: lip.clone(), + listen_port: lport, + target_ip: tip.clone(), + target_port: tport, + }, + ), + ); + } + } + // ---- 中转隧道 ---- + let tunnels: Vec<(i64, String, String, String, String)> = sqlx::query_as( + "SELECT id, name, tunnel_type, local_addr, remote_addr + FROM tunnels WHERE node_id = ? AND enabled = 1 + AND (expires_at IS NULL OR expires_at > datetime('now'))", + ) + .bind(node_id) + .fetch_all(pool) + .await + .unwrap_or_default(); + for (id, name, ttype, local, remote) in tunnels { + let hash = spec_hash_of(&["tunnel", &id.to_string(), &ttype, &local, &remote]); + let kind = match ttype.as_str() { + "tcp" | "ws" => SpecKind::Tunnel { + tunnel_type: ttype.clone(), + local_addr: local.clone(), + remote_addr: remote.clone(), + }, + other => SpecKind::Idle { reason: format!("unsupported-tunnel-{other}") }, + }; + out.insert( + ("tunnel".to_string(), id), + EntrySpec { entry_type: "tunnel".into(), id, name, node_id, hash, kind }, + ); + } + out +} + +// ============ 任务上下文 ============ + +#[derive(Clone)] +pub struct TaskCtx { + pub pool: SqlitePool, + pub cfg: Config, + pub key: EntryKey, + pub cancel: CancellationToken, + pub counters: Arc, +} + +impl TaskCtx { + /// 连接结束:记 access_logs(按采样率)+ 累计计数器 + pub async fn finish_conn( + &self, + client_ip: Option, + started: chrono::DateTime, + up: u64, + down: u64, + status: &str, + ) { + self.counters.conns.fetch_sub(1, Ordering::Relaxed); + self.counters.bytes_up.fetch_add(up, Ordering::Relaxed); + self.counters.bytes_down.fetch_add(down, Ordering::Relaxed); + let rate = self.cfg.log.access_log_sample_rate.clamp(0.0, 1.0); + if rate <= 0.0 { + return; + } + if rate < 1.0 && rand::random::() > rate { + return; + } + let (etype, rule_id) = (&self.key.0, self.key.1); + // forward_udp 归一化为 forward + let etype = etype.strip_prefix("forward").map(|_| "forward").unwrap_or(etype); + let ended = chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string(); + let started_s = started.format("%Y-%m-%d %H:%M:%S").to_string(); + let r = sqlx::query( + "INSERT INTO access_logs (node_id, rule_type, rule_id, client_ip, started_at, ended_at, bytes_up, bytes_down, status) + VALUES (1, ?, ?, ?, ?, ?, ?, ?, ?)", + ) + .bind(etype) + .bind(rule_id) + .bind(client_ip) + .bind(started_s) + .bind(ended) + .bind(up as i64) + .bind(down as i64) + .bind(status) + .execute(&self.pool) + .await; + if let Err(e) = r { + tracing::warn!("finish_conn 写入 access_logs 失败: {e:#}"); + } + } + + pub fn begin_conn(&self) { + self.counters.conns.fetch_add(1, Ordering::Relaxed); + } +} + +// ============ 转发目标黑名单 ============ + +/// 默认禁止:云元数据地址、本机管理端口(可在配置里调整) +pub fn target_blocked(target_ip: &str, target_port: i64, mgmt_port: u16) -> Option { + let ip = target_ip.trim().trim_matches(|c| c == '[' || c == ']'); + // 云元数据 + for meta in ["169.254.169.254", "100.100.100.200", "169.254.169.123"] { + if ip == meta { + return Some(format!("禁止转发到云元数据地址 {ip}")); + } + } + // 回环地址上的管理端口 + let is_loopback = ip == "127.0.0.1" || ip == "::1" || ip == "localhost"; + if is_loopback && target_port as u16 == mgmt_port { + return Some(format!("禁止转发到本机管理端口 {mgmt_port}")); + } + None +} + +pub fn mgmt_port_of(cfg: &Config) -> u16 { + cfg.server + .bind + .rsplit(':') + .next() + .and_then(|p| p.parse().ok()) + .unwrap_or(8080) +} + +// ============ TCP / UDP 端口转发 ============ + +/// 返回 (JoinHandle 列表, 状态字符串) +async fn run_forward(ctx: TaskCtx, spec: EntrySpec) -> anyhow::Result<(Vec>, String)> { + let (lip, lport, tip, tport) = match &spec.kind { + SpecKind::TcpForward { listen_ip, listen_port, target_ip, target_port } => { + (listen_ip.clone(), *listen_port, target_ip.clone(), *target_port) + } + SpecKind::UdpForward { listen_ip, listen_port, target_ip, target_port } => { + (listen_ip.clone(), *listen_port, target_ip.clone(), *target_port) + } + _ => anyhow::bail!("内部错误:转发任务收到非转发 spec"), + }; + if let Some(reason) = target_blocked(&tip, tport, mgmt_port_of(&ctx.cfg)) { + anyhow::bail!("{reason}"); + } + let is_tcp = matches!(spec.kind, SpecKind::TcpForward { .. }); + if is_tcp { + let listener = tokio::net::TcpListener::bind(format!("{lip}:{lport}")) + .await + .map_err(|e| anyhow::anyhow!("监听 {lip}:{lport} 失败: {e}"))?; + let h = tokio::spawn(tcp_forward_loop(ctx, listener, tip, tport)); + Ok((vec![h], "running".into())) + } else { + let sock = tokio::net::UdpSocket::bind(format!("{lip}:{lport}")) + .await + .map_err(|e| anyhow::anyhow!("监听 {lip}:{lport} 失败: {e}"))?; + let h = tokio::spawn(udp_forward_loop(ctx, sock, tip, tport)); + Ok((vec![h], "running".into())) + } +} + +async fn tcp_forward_loop(ctx: TaskCtx, listener: tokio::net::TcpListener, tip: String, tport: i64) { + loop { + tokio::select! { + _ = ctx.cancel.cancelled() => break, + accepted = listener.accept() => { + let (inbound, peer) = match accepted { + Ok(v) => v, + Err(e) => { tracing::warn!("转发 accept 失败: {e}"); continue; } + }; + let ctx2 = ctx.clone(); + let tip2 = tip.clone(); + tokio::spawn(async move { + let started = chrono::Local::now(); + ctx2.begin_conn(); + let client_ip = peer.ip().to_string(); + let (mut up, mut down) = (0u64, 0u64); + let mut status = "ok"; + match tokio::net::TcpStream::connect(format!("{tip2}:{tport}")).await { + Ok(outbound) => { + match relay_tcp(inbound, outbound).await { + Ok((u, d)) => { up = u; down = d; } + Err(_) => status = "relay_error", + } + } + Err(_) => status = "target_refused", + } + ctx2.finish_conn(Some(client_ip), started, up, down, status).await; + }); + } + } + } +} + +/// 双向转发,返回 (上行字节, 下行字节)。上行 = inbound→outbound +async fn relay_tcp( + inbound: tokio::net::TcpStream, + outbound: tokio::net::TcpStream, +) -> std::io::Result<(u64, u64)> { + let (mut ri, mut wi) = inbound.into_split(); + let (mut ro, mut wo) = outbound.into_split(); + let up = tokio::io::copy(&mut ri, &mut wo); + let down = tokio::io::copy(&mut ro, &mut wi); + let (u, d) = tokio::join!(up, down); + Ok((u.unwrap_or(0), d.unwrap_or(0))) +} + +// ---- UDP 转发:按客户端地址维护会话表,空闲 60 秒超时 ---- + +struct UdpSession { + upstream: Arc, + last_active: tokio::time::Instant, + started: chrono::DateTime, + up: u64, + down: u64, +} + +async fn udp_forward_loop(ctx: TaskCtx, sock: tokio::net::UdpSocket, tip: String, tport: i64) { + let sock = Arc::new(sock); + let mut sessions: HashMap = HashMap::new(); + let mut buf = vec![0u8; 65535]; + let mut cleanup_tick = tokio::time::interval(std::time::Duration::from_secs(10)); + let target: SocketAddr = match tokio::net::lookup_host(format!("{tip}:{tport}")) + .await + .ok() + .and_then(|mut it| it.next()) + { + Some(a) => a, + None => { + tracing::warn!("UDP 转发目标解析失败: {tip}:{tport}"); + return; + } + }; + loop { + tokio::select! { + _ = ctx.cancel.cancelled() => break, + _ = cleanup_tick.tick() => { + // 清理空闲超 60 秒的会话 + let now = tokio::time::Instant::now(); + let idle: Vec = sessions.iter() + .filter(|(_, s)| now.duration_since(s.last_active).as_secs() > 60) + .map(|(a, _)| *a) + .collect(); + for a in idle { + if let Some(s) = sessions.remove(&a) { + let ctx2 = ctx.clone(); + tokio::spawn(async move { + ctx2.finish_conn(Some(a.ip().to_string()), s.started, s.up, s.down, "ok").await; + }); + } + } + } + recvd = sock.recv_from(&mut buf) => { + let (n, peer) = match recvd { + Ok(v) => v, + Err(e) => { tracing::warn!("UDP recv 失败: {e}"); continue; } + }; + let data = &buf[..n]; + let session = match sessions.get_mut(&peer) { + Some(s) => s, + None => { + let upstream = match tokio::net::UdpSocket::bind("0.0.0.0:0").await { + Ok(s) => Arc::new(s), + Err(e) => { tracing::warn!("UDP 会话 socket 创建失败: {e}"); continue; } + }; + // 启动上行→下行回包循环 + let sock_c = sock.clone(); + let up_c = upstream.clone(); + let ctx_c = ctx.clone(); + let cancel_c = ctx.cancel.clone(); + tokio::spawn(async move { + let mut rbuf = vec![0u8; 65535]; + loop { + tokio::select! { + _ = cancel_c.cancelled() => break, + r = up_c.recv(&mut rbuf) => { + match r { + Ok(m) => { + let _ = sock_c.send_to(&rbuf[..m], peer).await; + ctx_c.counters.bytes_down.fetch_add(m as u64, Ordering::Relaxed); + } + Err(_) => break, + } + } + } + } + }); + ctx.begin_conn(); + sessions.insert(peer, UdpSession { + upstream, + last_active: tokio::time::Instant::now(), + started: chrono::Local::now(), + up: 0, down: 0, + }); + sessions.get_mut(&peer).unwrap() + } + }; + session.last_active = tokio::time::Instant::now(); + session.up += n as u64; + ctx.counters.bytes_up.fetch_add(n as u64, Ordering::Relaxed); + if session.upstream.send_to(data, target).await.is_err() { + tracing::warn!("UDP 转发到目标失败"); + } + } + } + } + // 退出时结算所有会话 + for (peer, s) in sessions { + ctx.finish_conn(Some(peer.ip().to_string()), s.started, s.up, s.down, "shutdown").await; + } +} + +// ============ 落地代理:HTTP CONNECT + SOCKS5 入站 ============ + +#[derive(Clone)] +struct ProxyCfg { + upstream_type: String, + upstream_addr: String, + auth_user: Option, + auth_pass: Option, + extra: Option, +} + +async fn run_proxy(ctx: TaskCtx, spec: EntrySpec) -> anyhow::Result<(Vec>, String)> { + let pcfg = match &spec.kind { + SpecKind::Proxy { listen_addr, upstream_type, upstream_addr, auth_user, auth_pass, extra } => { + let listen_addr = listen_addr.clone(); + let pcfg = ProxyCfg { + upstream_type: upstream_type.clone(), + upstream_addr: upstream_addr.clone(), + auth_user: auth_user.clone(), + auth_pass: auth_pass.clone(), + extra: extra.clone(), + }; + // 上游类型检查:MVP 支持 direct 直连 / http / https(明文 CONNECT) / socks5 出站 + match pcfg.upstream_type.as_str() { + "direct" | "http" | "https" | "socks5" => {} + other => anyhow::bail!("暂不支持的上游类型: {other}(MVP 仅支持 direct/http/https/socks5 出站)"), + } + (listen_addr, pcfg) + } + _ => anyhow::bail!("内部错误:代理任务收到非代理 spec"), + }; + let (listen_addr, pcfg) = pcfg; + let listener = tokio::net::TcpListener::bind(&listen_addr) + .await + .map_err(|e| anyhow::anyhow!("监听 {listen_addr} 失败: {e}"))?; + let h = tokio::spawn(proxy_accept_loop(ctx, listener, pcfg)); + Ok((vec![h], "running".into())) +} + +async fn proxy_accept_loop(ctx: TaskCtx, listener: tokio::net::TcpListener, pcfg: ProxyCfg) { + loop { + tokio::select! { + _ = ctx.cancel.cancelled() => break, + accepted = listener.accept() => { + let (inbound, peer) = match accepted { + Ok(v) => v, + Err(e) => { tracing::warn!("代理 accept 失败: {e}"); continue; } + }; + let ctx2 = ctx.clone(); + let pcfg2 = pcfg.clone(); + tokio::spawn(async move { + let started = chrono::Local::now(); + ctx2.begin_conn(); + let client_ip = peer.ip().to_string(); + let (up, down, status) = handle_proxy_conn(inbound, &pcfg2).await; + ctx2.finish_conn(Some(client_ip), started, up, down, status).await; + }); + } + } + } +} + +/// 处理一个入站代理连接:peek 首字节嗅探协议(SOCKS5 首字节 0x05 / 否则 HTTP),返回 (up, down, status) +async fn handle_proxy_conn( + inbound: tokio::net::TcpStream, + pcfg: &ProxyCfg, +) -> (u64, u64, &'static str) { + let mut one = [0u8; 1]; + let n = match tokio::time::timeout( + std::time::Duration::from_secs(10), + inbound.peek(&mut one), + ) + .await + { + Ok(Ok(n)) if n > 0 => n, + _ => return (0, 0, "handshake_timeout"), + }; + let _ = n; + if one[0] == 0x05 { + handle_socks5_inbound(inbound, pcfg).await + } else { + handle_http_inbound(inbound, pcfg).await + } +} + +// ---- SOCKS5 入站 ---- + +async fn handle_socks5_inbound( + inbound: tokio::net::TcpStream, + pcfg: &ProxyCfg, +) -> (u64, u64, &'static str) { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + let (mut r, mut w) = inbound.into_split(); + // 握手:VER, NMETHODS, METHODS... + let mut hello = [0u8; 2]; + if r.read_exact(&mut hello).await.is_err() || hello[0] != 0x05 { + return (0, 0, "socks_bad_hello"); + } + let nmethods = hello[1] as usize; + let mut methods = vec![0u8; nmethods]; + if nmethods > 0 && r.read_exact(&mut methods).await.is_err() { + return (0, 0, "socks_bad_hello"); + } + let need_auth = pcfg.auth_user.as_deref().map(|s| !s.is_empty()).unwrap_or(false); + let chosen: u8 = if need_auth { + if methods.contains(&0x02) { 0x02 } else { 0xff } + } else if methods.contains(&0x00) { + 0x00 + } else { + 0xff + }; + if w.write_all(&[0x05, chosen]).await.is_err() || chosen == 0xff { + return (0, 0, "socks_no_method"); + } + if chosen == 0x02 { + // 用户名密码认证:VER(0x01) ULEN UNAME PLEN PASSWD + let mut hdr = [0u8; 2]; + if r.read_exact(&mut hdr).await.is_err() { + return (0, 0, "socks_auth_fail"); + } + let ulen = hdr[1] as usize; + let mut ubuf = vec![0u8; ulen + 1]; + if r.read_exact(&mut ubuf).await.is_err() { + return (0, 0, "socks_auth_fail"); + } + let plen = ubuf[ulen] as usize; + let mut pbuf = vec![0u8; plen]; + if r.read_exact(&mut pbuf).await.is_err() { + return (0, 0, "socks_auth_fail"); + } + let user = String::from_utf8_lossy(&ubuf[..ulen]); + let pass = String::from_utf8_lossy(&pbuf); + let ok = pcfg.auth_user.as_deref() == Some(user.as_ref()) + && pcfg.auth_pass.as_deref() == Some(pass.as_ref()); + let _ = w.write_all(&[0x01, if ok { 0x00 } else { 0x01 }]).await; + if !ok { + return (0, 0, "socks_auth_fail"); + } + } + // 请求:VER CMD RSV ATYP ADDR PORT + let mut hdr = [0u8; 4]; + if r.read_exact(&mut hdr).await.is_err() { + return (0, 0, "socks_bad_req"); + } + if hdr[1] != 0x01 { + let _ = w.write_all(&[0x05, 0x07, 0x00, 0x01, 0, 0, 0, 0, 0, 0]).await; + return (0, 0, "socks_unsupported_cmd"); + } + let target: String = match hdr[3] { + 0x01 => { + let mut b = [0u8; 6]; + if r.read_exact(&mut b).await.is_err() { return (0, 0, "socks_bad_req"); } + let port = u16::from_be_bytes([b[4], b[5]]); + format!("{}.{}.{}.{}:{port}", b[0], b[1], b[2], b[3]) + } + 0x03 => { + let mut l = [0u8; 1]; + if r.read_exact(&mut l).await.is_err() { return (0, 0, "socks_bad_req"); } + let mut b = vec![0u8; l[0] as usize + 2]; + if r.read_exact(&mut b).await.is_err() { return (0, 0, "socks_bad_req"); } + let host = String::from_utf8_lossy(&b[..l[0] as usize]); + let port = u16::from_be_bytes([b[l[0] as usize], b[l[0] as usize + 1]]); + format!("{host}:{port}") + } + 0x04 => { + let mut b = [0u8; 18]; + if r.read_exact(&mut b).await.is_err() { return (0, 0, "socks_bad_req"); } + let port = u16::from_be_bytes([b[16], b[17]]); + let ip = std::net::Ipv6Addr::from(<[u8; 16]>::try_from(&b[..16]).unwrap()); + format!("[{ip}]:{port}") + } + _ => return (0, 0, "socks_bad_atyp"), + }; + // 出站 + match dial_upstream(pcfg, &target).await { + Ok(outbound) => { + let _ = w.write_all(&[0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0]).await; + let inbound = r.reunite(w).unwrap_or_else(|_| unreachable!()); + match relay_tcp(inbound, outbound).await { + Ok((u, d)) => (u, d, "ok"), + Err(_) => (0, 0, "relay_error"), + } + } + Err(e) => { + tracing::debug!("SOCKS5 出站失败 {target}: {e}"); + let _ = w.write_all(&[0x05, 0x05, 0x00, 0x01, 0, 0, 0, 0, 0, 0]).await; + (0, 0, "upstream_fail") + } + } +} + +// ---- HTTP CONNECT 入站 ---- + +async fn handle_http_inbound( + inbound: tokio::net::TcpStream, + pcfg: &ProxyCfg, +) -> (u64, u64, &'static str) { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + let (mut r, mut w) = inbound.into_split(); + // 读完整个 HTTP 头 + let mut head = Vec::new(); + while !head.windows(4).any(|w| w == b"\r\n\r\n") { + if head.len() > 16384 { + return (0, 0, "http_head_too_large"); + } + let mut tmp = [0u8; 1024]; + match r.read(&mut tmp).await { + Ok(0) => return (0, 0, "http_eof"), + Ok(n) => head.extend_from_slice(&tmp[..n]), + Err(_) => return (0, 0, "http_read_error"), + } + } + let head_str = String::from_utf8_lossy(&head); + let mut lines = head_str.lines(); + let request_line = lines.next().unwrap_or(""); + let mut parts = request_line.split_whitespace(); + let method = parts.next().unwrap_or(""); + let target = parts.next().unwrap_or(""); + if method != "CONNECT" { + let _ = w.write_all(b"HTTP/1.1 405 Method Not Allowed\r\nContent-Length: 0\r\n\r\n").await; + return (0, 0, "http_method_not_allowed"); + } + // 入站认证 + if pcfg.auth_user.as_deref().map(|s| !s.is_empty()).unwrap_or(false) { + let mut authed = false; + for line in lines { + if line.to_ascii_lowercase().starts_with("proxy-authorization:") { + let val = line.splitn(2, ':').nth(1).unwrap_or("").trim(); + if let Some(b64) = val.strip_prefix("Basic ") { + use base64::Engine; + if let Ok(decoded) = base64::engine::general_purpose::STANDARD.decode(b64) { + let cred = String::from_utf8_lossy(&decoded); + let mut kv = cred.splitn(2, ':'); + if kv.next() == pcfg.auth_user.as_deref() + && kv.next() == pcfg.auth_pass.as_deref() + { + authed = true; + } + } + } + } + if line.is_empty() { + break; + } + } + if !authed { + let _ = w + .write_all(b"HTTP/1.1 407 Proxy Authentication Required\r\nProxy-Authenticate: Basic realm=\"proxy\"\r\nContent-Length: 0\r\n\r\n") + .await; + return (0, 0, "http_auth_fail"); + } + } + match dial_upstream(pcfg, target).await { + Ok(outbound) => { + if w.write_all(b"HTTP/1.1 200 Connection Established\r\n\r\n").await.is_err() { + return (0, 0, "http_write_error"); + } + let inbound = r.reunite(w).unwrap_or_else(|_| unreachable!()); + match relay_tcp(inbound, outbound).await { + Ok((u, d)) => (u, d, "ok"), + Err(_) => (0, 0, "relay_error"), + } + } + Err(e) => { + tracing::debug!("HTTP CONNECT 出站失败 {target}: {e}"); + let _ = w.write_all(b"HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\n\r\n").await; + (0, 0, "upstream_fail") + } + } +} + +// ============ 出站拨号 ============ + +/// 通过上游代理连接到 target(host:port),返回已建立的 TcpStream +async fn dial_upstream(pcfg: &ProxyCfg, target: &str) -> anyhow::Result { + match pcfg.upstream_type.as_str() { + "direct" => tokio::net::TcpStream::connect(target) + .await + .map_err(|e| anyhow::anyhow!("直连 {target} 失败: {e}")), + "http" | "https" => dial_via_http_proxy(pcfg, target).await, + "socks5" => dial_via_socks5(pcfg, target).await, + other => anyhow::bail!("不支持的上游类型: {other}"), + } +} + +/// 从 extra(Clash 风格 JSON)取上游认证 +fn upstream_creds(pcfg: &ProxyCfg) -> (Option, Option) { + if let Some(extra) = pcfg.extra.as_deref() { + if let Ok(v) = serde_json::from_str::(extra) { + let u = v.get("username").or(v.get("user")).and_then(|x| x.as_str()).map(|s| s.to_string()); + let p = v.get("password").or(v.get("pass")).and_then(|x| x.as_str()).map(|s| s.to_string()); + return (u, p); + } + } + (None, None) +} + +async fn dial_via_http_proxy(pcfg: &ProxyCfg, target: &str) -> anyhow::Result { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + let mut s = tokio::net::TcpStream::connect(&pcfg.upstream_addr) + .await + .map_err(|e| anyhow::anyhow!("连接上游 {} 失败: {e}", pcfg.upstream_addr))?; + let mut req = format!("CONNECT {target} HTTP/1.1\r\nHost: {target}\r\n"); + let (u, p) = upstream_creds(pcfg); + if let (Some(u), Some(p)) = (u, p) { + use base64::Engine; + let cred = base64::engine::general_purpose::STANDARD.encode(format!("{u}:{p}")); + req.push_str(&format!("Proxy-Authorization: Basic {cred}\r\n")); + } + req.push_str("\r\n"); + s.write_all(req.as_bytes()).await?; + // 读响应头 + let mut head = Vec::new(); + let mut tmp = [0u8; 1024]; + loop { + let n = s.read(&mut tmp).await?; + if n == 0 { + anyhow::bail!("上游过早关闭连接"); + } + head.extend_from_slice(&tmp[..n]); + if head.windows(4).any(|w| w == b"\r\n\r\n") || head.len() > 16384 { + break; + } + } + let status = String::from_utf8_lossy(&head); + let code: u16 = status + .lines() + .next() + .and_then(|l| l.split_whitespace().nth(1)) + .and_then(|c| c.parse().ok()) + .unwrap_or(0); + if code != 200 { + anyhow::bail!("上游 CONNECT 返回 {code}"); + } + Ok(s) +} + +async fn dial_via_socks5(pcfg: &ProxyCfg, target: &str) -> anyhow::Result { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + let mut s = tokio::net::TcpStream::connect(&pcfg.upstream_addr) + .await + .map_err(|e| anyhow::anyhow!("连接上游 {} 失败: {e}", pcfg.upstream_addr))?; + let (u, p) = upstream_creds(pcfg); + if u.is_some() { + s.write_all(&[0x05, 0x01, 0x02]).await?; + } else { + s.write_all(&[0x05, 0x01, 0x00]).await?; + } + let mut resp = [0u8; 2]; + s.read_exact(&mut resp).await?; + if resp[0] != 0x05 || resp[1] == 0xff { + anyhow::bail!("上游 SOCKS5 握手失败"); + } + if resp[1] == 0x02 { + let (u, p) = (u.unwrap_or_default(), p.unwrap_or_default()); + let mut req = vec![0x01, u.len() as u8]; + req.extend_from_slice(u.as_bytes()); + req.push(p.len() as u8); + req.extend_from_slice(p.as_bytes()); + s.write_all(&req).await?; + let mut aresp = [0u8; 2]; + s.read_exact(&mut aresp).await?; + if aresp[1] != 0x00 { + anyhow::bail!("上游 SOCKS5 认证失败"); + } + } + // CONNECT 请求(域名形式) + let (host, port) = target.rsplit_once(':').ok_or_else(|| anyhow::anyhow!("目标地址格式错误"))?; + let port: u16 = port.parse().map_err(|_| anyhow::anyhow!("目标端口错误"))?; + let mut req = vec![0x05, 0x01, 0x00, 0x03, host.len() as u8]; + req.extend_from_slice(host.as_bytes()); + req.extend_from_slice(&port.to_be_bytes()); + s.write_all(&req).await?; + let mut hdr = [0u8; 4]; + s.read_exact(&mut hdr).await?; + if hdr[1] != 0x00 { + anyhow::bail!("上游 SOCKS5 CONNECT 失败: {}", hdr[1]); + } + // 跳过 BND.ADDR + let skip = match hdr[3] { + 0x01 => 6, + 0x03 => { + let mut l = [0u8; 1]; + s.read_exact(&mut l).await?; + l[0] as usize + 2 + } + 0x04 => 18, + _ => anyhow::bail!("上游返回未知 ATYP"), + }; + let mut junk = vec![0u8; skip]; + s.read_exact(&mut junk).await?; + Ok(s) +} + +// ============ 中转隧道:TCP 中转 / WebSocket 隧道 ============ + +async fn run_tunnel(ctx: TaskCtx, spec: EntrySpec) -> anyhow::Result<(Vec>, String)> { + let (ttype, local_addr, remote_addr) = match &spec.kind { + SpecKind::Tunnel { tunnel_type, local_addr, remote_addr } => { + (tunnel_type.clone(), local_addr.clone(), remote_addr.clone()) + } + _ => anyhow::bail!("内部错误:隧道任务收到非隧道 spec"), + }; + // remote 黑名单检查(取 host 部分) + if let Some(host) = remote_addr.split('/').next().and_then(|h| h.split('@').last()) { + let host = host.trim_start_matches("ws://").trim_start_matches("wss://"); + let (hip, hport) = match host.rsplit_once(':') { + Some((h, p)) => (h, p.parse::().unwrap_or(0)), + None => (host, 0), + }; + if let Some(reason) = target_blocked(hip, hport, mgmt_port_of(&ctx.cfg)) { + anyhow::bail!("{reason}"); + } + } + match ttype.as_str() { + "tcp" => { + let listener = tokio::net::TcpListener::bind(&local_addr) + .await + .map_err(|e| anyhow::anyhow!("监听 {local_addr} 失败: {e}"))?; + let h = tokio::spawn(tunnel_tcp_loop(ctx, listener, remote_addr)); + Ok((vec![h], "running".into())) + } + "ws" => { + let listener = tokio::net::TcpListener::bind(&local_addr) + .await + .map_err(|e| anyhow::anyhow!("监听 {local_addr} 失败: {e}"))?; + let h = tokio::spawn(tunnel_ws_loop(ctx, listener, remote_addr)); + Ok((vec![h], "running".into())) + } + other => anyhow::bail!("暂不支持的隧道类型: {other}"), + } +} + +/// TCP 中转:本地监听 → 连接远端 → 双向转发 +async fn tunnel_tcp_loop(ctx: TaskCtx, listener: tokio::net::TcpListener, remote_addr: String) { + loop { + tokio::select! { + _ = ctx.cancel.cancelled() => break, + accepted = listener.accept() => { + let (inbound, peer) = match accepted { + Ok(v) => v, + Err(e) => { tracing::warn!("隧道 accept 失败: {e}"); continue; } + }; + let ctx2 = ctx.clone(); + let remote2 = remote_addr.clone(); + tokio::spawn(async move { + let started = chrono::Local::now(); + ctx2.begin_conn(); + let client_ip = peer.ip().to_string(); + let (up, down, status) = match tokio::net::TcpStream::connect(&remote2).await { + Ok(outbound) => match relay_tcp(inbound, outbound).await { + Ok((u, d)) => (u, d, "ok"), + Err(_) => (0, 0, "relay_error"), + }, + Err(_) => (0, 0, "remote_refused"), + }; + ctx2.finish_conn(Some(client_ip), started, up, down, status).await; + }); + } + } + } +} + +/// WebSocket 隧道:本地 TCP 监听 → 每条连接拨一条 WS 到远端 → TCP 流桥接到 WS 二进制帧 +async fn tunnel_ws_loop(ctx: TaskCtx, listener: tokio::net::TcpListener, remote_addr: String) { + // remote_addr 规范化为 ws:// URL + let url = if remote_addr.starts_with("ws://") || remote_addr.starts_with("wss://") { + remote_addr.clone() + } else { + format!("ws://{remote_addr}") + }; + if url.starts_with("wss://") { + tracing::warn!("隧道 {} 的 wss 远端暂不支持,任务空转", ctx.key.1); + ctx.cancel.cancelled().await; + return; + } + loop { + tokio::select! { + _ = ctx.cancel.cancelled() => break, + accepted = listener.accept() => { + let (inbound, peer) = match accepted { + Ok(v) => v, + Err(e) => { tracing::warn!("WS 隧道 accept 失败: {e}"); continue; } + }; + let ctx2 = ctx.clone(); + let url2 = url.clone(); + tokio::spawn(async move { + let started = chrono::Local::now(); + ctx2.begin_conn(); + let client_ip = peer.ip().to_string(); + let (up, down, status) = bridge_tcp_ws(inbound, &url2).await; + ctx2.finish_conn(Some(client_ip), started, up, down, status).await; + }); + } + } + } +} + +async fn bridge_tcp_ws( + inbound: tokio::net::TcpStream, + url: &str, +) -> (u64, u64, &'static str) { + use futures_util::{SinkExt, StreamExt}; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use tokio_tungstenite::tungstenite::Message; + + let (ws_stream, _) = match tokio_tungstenite::connect_async(url).await { + Ok(v) => v, + Err(e) => { + tracing::debug!("WS 拨号 {url} 失败: {e}"); + return (0, 0, "ws_dial_fail"); + } + }; + let (mut ws_sink, mut ws_src) = ws_stream.split(); + let (mut tcp_r, mut tcp_w) = inbound.into_split(); + let (mut up, mut down) = (0u64, 0u64); + + // tcp → ws + let t2w = async { + let mut buf = [0u8; 16384]; + loop { + match tcp_r.read(&mut buf).await { + Ok(0) => break, + Ok(n) => { + up += n as u64; + if ws_sink.send(Message::Binary(buf[..n].to_vec().into())).await.is_err() { + break; + } + } + Err(_) => break, + } + } + let _ = ws_sink.send(Message::Close(None)).await; + }; + // ws → tcp + let w2t = async { + while let Some(msg) = ws_src.next().await { + match msg { + Ok(Message::Binary(data)) => { + down += data.len() as u64; + if tcp_w.write_all(&data).await.is_err() { + break; + } + } + Ok(Message::Close(_)) | Err(_) => break, + _ => {} + } + } + }; + tokio::join!(t2w, w2t); + (up, down, "ok") +} + +// ============ 条目变更通知入口 ============ + +/// 条目 CRUD / 启停后调用:触发运行时热更新对齐 +pub async fn on_entries_changed(state: &crate::state::AppState) { + state.runtime.sync().await; +} diff --git a/src/state.rs b/src/state.rs new file mode 100644 index 0000000..97f6b89 --- /dev/null +++ b/src/state.rs @@ -0,0 +1,29 @@ +use crate::config::Config; +use crate::middleware::LoginGuard; +use sqlx::SqlitePool; +use std::sync::Arc; +use tera::Tera; +use tokio::sync::Mutex; + +#[derive(Clone)] +pub struct AppState { + pub pool: SqlitePool, + pub cfg: Config, + pub tera: Tera, + pub http: reqwest::Client, + /// 登录失败计数(IP 维度防爆破) + pub login_guard: Arc>, + /// 导入解析缓存(parse_token → 解析结果,10 分钟过期) + pub import_cache: Arc>>, + /// P13:订阅导出缓存(60 秒,key 含条目集合版本) + pub sub_export_cache: Arc< + Mutex< + std::collections::HashMap< + crate::handlers::subscribe_public::SubExportKey, + crate::handlers::subscribe_public::CachedExport, + >, + >, + >, + /// P8 运行时管理器(转发/代理/隧道任务) + pub runtime: Arc, +} diff --git a/src/util.rs b/src/util.rs new file mode 100644 index 0000000..57e4411 --- /dev/null +++ b/src/util.rs @@ -0,0 +1,211 @@ +use crate::error::{AppError, AppResult}; +use actix_web::HttpRequest; +use argon2::password_hash::{rand_core::OsRng, SaltString}; +use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier}; +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; +use chrono::{Datelike, Duration, NaiveDate, NaiveDateTime, Utc}; +use rand::TryRngCore; +use std::net::IpAddr; + +pub const TIME_FMT: &str = "%Y-%m-%d %H:%M:%S"; + +/// 当前 UTC 时间字符串 +pub fn now_str() -> String { + Utc::now().format(TIME_FMT).to_string() +} + +pub fn now_utc() -> NaiveDateTime { + Utc::now().naive_utc() +} + +pub fn parse_time(s: &str) -> AppResult { + NaiveDateTime::parse_from_str(s, TIME_FMT) + .map_err(|_| AppError::bad("时间格式应为 YYYY-MM-DD HH:MM:SS(UTC)")) +} + +/// 32 字节随机数 base64url(256 bit),用于订阅 token / Node api_token / session id / csrf +pub fn random_token(bytes: usize) -> String { + let mut buf = vec![0u8; bytes]; + rand::rngs::OsRng + .try_fill_bytes(&mut buf) + .expect("OsRng 失败"); + URL_SAFE_NO_PAD.encode(buf) +} + +pub fn hash_password(pw: &str) -> anyhow::Result { + let salt = SaltString::generate(&mut OsRng); + Ok(Argon2::default() + .hash_password(pw.as_bytes(), &salt) + .map_err(|e| anyhow::anyhow!(e.to_string()))? + .to_string()) +} + +pub fn verify_password(pw: &str, hash: &str) -> bool { + PasswordHash::new(hash) + .map(|h| Argon2::default().verify_password(pw.as_bytes(), &h).is_ok()) + .unwrap_or(false) +} + +/// 条目类型 → 表名白名单,杜绝表名注入 +pub fn table_of(t: &str) -> AppResult<&'static str> { + match t { + "proxy" => Ok("proxy_rules"), + "forward" => Ok("port_forwards"), + "tunnel" => Ok("tunnels"), + _ => Err(AppError::bad("未知条目类型")), + } +} + +pub fn valid_entry_type(t: &str) -> bool { + matches!(t, "proxy" | "forward" | "tunnel") +} + +/// 客户端真实 IP:仅可信反代才读 X-Forwarded-For +pub fn client_ip(req: &HttpRequest, trusted_proxies: &[String]) -> String { + let peer = req + .peer_addr() + .map(|a| a.ip().to_string()) + .unwrap_or_default(); + let trusted = trusted_proxies.iter().any(|p| p == &peer || p == "*"); + if trusted { + if let Some(xff) = req.headers().get("x-forwarded-for") { + if let Ok(s) = xff.to_str() { + if let Some(first) = s.split(',').next() { + let ip = first.trim(); + if !ip.is_empty() { + return ip.to_string(); + } + } + } + } + } + peer +} + +pub fn header_str(req: &HttpRequest, name: &str) -> String { + req.headers() + .get(name) + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_string() +} + +/// "host:port" 拆分,host 可能带方括号 IPv6 +pub fn split_host_port(addr: &str) -> (String, Option) { + let addr = addr.trim(); + if addr.starts_with('[') { + if let Some(end) = addr.find(']') { + let host = addr[1..end].to_string(); + let rest = &addr[end + 1..]; + let port = rest.strip_prefix(':').and_then(|p| p.parse().ok()); + return (host, port); + } + } + match addr.rfind(':') { + Some(i) => { + let (h, p) = addr.split_at(i); + // 避免把 IPv6 无端口当成 host:port + if h.contains(':') { + (addr.to_string(), None) + } else { + (h.to_string(), p[1..].parse().ok()) + } + } + None => (addr.to_string(), None), + } +} + +pub fn fmt_bytes(b: i64) -> String { + const UNITS: &[&str] = &["B", "KB", "MB", "GB", "TB", "PB"]; + let mut v = b as f64; + let mut u = 0; + while v >= 1024.0 && u + 1 < UNITS.len() { + v /= 1024.0; + u += 1; + } + if u == 0 { + format!("{b} B") + } else { + format!("{v:.1} {}", UNITS[u]) + } +} + +/// 过期时间计算:1d | 7d | 30d | 90d | permanent | custom +pub fn calc_expires(preset: &str, custom: Option<&str>) -> AppResult> { + let d = match preset { + "1d" => 1, + "7d" => 7, + "30d" => 30, + "90d" => 90, + "permanent" => return Ok(None), + "custom" => { + let s = custom.ok_or_else(|| AppError::bad("缺少 expires_at"))?; + parse_time(s)?; + return Ok(Some(s.to_string())); + } + _ => return Err(AppError::bad("未知的过期预设")), + }; + Ok(Some( + (Utc::now() + Duration::days(d)).format(TIME_FMT).to_string(), + )) +} + +/// 返回包含 now 的那个月度周期起点(00:00:00,UTC 存储) +pub fn cycle_start(now_utc: NaiveDateTime, reset_day: u32, tz_offset_hours: i64) -> NaiveDateTime { + let local = now_utc + Duration::hours(tz_offset_hours); + let d = reset_day.clamp(1, 28); + let (y, m) = if local.day() >= d { + (local.year(), local.month()) + } else if local.month() == 1 { + (local.year() - 1, 12) + } else { + (local.year(), local.month() - 1) + }; + let start_local = NaiveDate::from_ymd_opt(y, m, d) + .unwrap() + .and_hms_opt(0, 0, 0) + .unwrap(); + start_local - Duration::hours(tz_offset_hours) +} + +/// UTC 字符串 → 本地(台北)显示字符串 +pub fn to_local_str(utc_s: &str, tz_offset_hours: i64) -> String { + match NaiveDateTime::parse_from_str(utc_s, TIME_FMT) { + Ok(dt) => (dt + Duration::hours(tz_offset_hours)) + .format(TIME_FMT) + .to_string(), + Err(_) => utc_s.to_string(), + } +} + +/// 是否为内网 / 回环 / 链路本地 / 元数据地址(SSRF 防护用) +pub fn is_private_ip(ip: &IpAddr) -> bool { + match ip { + IpAddr::V4(v4) => { + let o = v4.octets(); + o[0] == 10 + || (o[0] == 172 && (16..=31).contains(&o[1])) + || (o[0] == 192 && o[1] == 168) + || o[0] == 127 + || (o[0] == 169 && o[1] == 254) + || o[0] == 0 + } + IpAddr::V6(v6) => { + v6.is_loopback() + || v6.is_unspecified() + || (v6.segments()[0] & 0xfe00) == 0xfc00 // fc00::/7 + || (v6.segments()[0] & 0xffc0) == 0xfe80 // fe80::/10 + } + } +} + +pub fn truncate(s: &str, n: usize) -> String { + let mut out = String::new(); + for (i, c) in s.char_indices() { + if i >= n { + break; + } + out.push(c); + } + out +} diff --git a/src/worker.rs b/src/worker.rs new file mode 100644 index 0000000..4741b64 --- /dev/null +++ b/src/worker.rs @@ -0,0 +1,400 @@ +//! P10 —— Worker 模式 +//! +//! ```bash +//! rust_proxy_admin --mode worker --master https://admin.example.com --token +//! ``` +//! +//! - 心跳:每 10 秒 POST /internal/node/heartbeat(版本 + 负载) +//! - 配置:每 15 秒 GET /internal/node/config(If-None-Match / 304),有变化则 diff 启停任务 +//! - 上报:每 10 秒批量上报访问日志 + 统计增量 + 运行状态;每 60 秒上报指标 +//! 上报失败本地缓冲(access_logs 本来就在本地库),下次重试;超 10 万行丢弃最旧。 + +use std::collections::HashMap; +use std::sync::Arc; +use std::time::Duration; + +use sqlx::SqlitePool; + +use crate::config::Config; +use crate::services::runtime::{EntryKey, EntrySpec, RuntimeManager}; + +const VERSION: &str = env!("CARGO_PKG_VERSION"); +const MAX_BUFFER_ROWS: i64 = 100_000; + +struct Worker { + cfg: Config, + master: String, + token: String, + http: reqwest::Client, + pool: SqlitePool, + runtime: Arc, + sys: tokio::sync::Mutex, + nets: tokio::sync::Mutex, + prev_net: tokio::sync::Mutex<(u64, u64, Option)>, +} + +impl Worker { + fn url(&self, path: &str) -> String { + format!("{}{}", self.master, path) + } + + async fn post_json( + &self, + path: &str, + body: &T, + ) -> anyhow::Result { + let resp = self + .http + .post(self.url(path)) + .header("Authorization", format!("Bearer {}", self.token)) + .json(body) + .send() + .await?; + if resp.status() == reqwest::StatusCode::UNAUTHORIZED { + anyhow::bail!("Master 拒绝:token 无效(401)"); + } + Ok(resp) + } + + /// 拉取配置并做热更新;返回 true 表示配置有变化 + async fn sync_config(&self, etag: &mut Option) -> anyhow::Result { + let mut rb = self + .http + .get(self.url("/internal/node/config")) + .header("Authorization", format!("Bearer {}", self.token)); + if let Some(e) = etag.as_deref() { + rb = rb.header("If-None-Match", e); + } + let resp = rb.send().await?; + if resp.status() == reqwest::StatusCode::UNAUTHORIZED { + anyhow::bail!("Master 拒绝:token 无效(401)"); + } + if resp.status() == reqwest::StatusCode::NOT_MODIFIED { + return Ok(false); + } + let new_etag = resp + .headers() + .get("ETag") + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()); + let body: serde_json::Value = resp.json().await?; + let entries: Vec = serde_json::from_value( + body.pointer("/data/entries").cloned().unwrap_or(serde_json::Value::Null), + )?; + let mut desired: HashMap = HashMap::new(); + for e in entries { + desired.insert((e.entry_type.clone(), e.id), e); + } + self.runtime.sync_with(desired).await; + *etag = new_etag; + Ok(true) + } + + /// 本机负载(心跳用) + async fn local_load(&self) -> serde_json::Value { + let mut sys = self.sys.lock().await; + sys.refresh_all(); + let cpu = sys.global_cpu_usage() as f64; + let mem = if sys.total_memory() > 0 { + sys.used_memory() as f64 / sys.total_memory() as f64 * 100.0 + } else { + 0.0 + }; + drop(sys); + let conns: i64 = self + .runtime + .status_snapshot() + .await + .iter() + .map(|s| s["conns"].as_u64().unwrap_or(0) as i64) + .sum(); + serde_json::json!({ "cpu": cpu, "mem": mem, "conns": conns }) + } + + async fn heartbeat(&self) -> anyhow::Result<()> { + let load = self.local_load().await; + let resp = self + .post_json( + "/internal/node/heartbeat", + &serde_json::json!({ "version": VERSION, "load": load }), + ) + .await?; + if !resp.status().is_success() { + anyhow::bail!("心跳失败: {}", resp.status()); + } + Ok(()) + } + + /// 统计上报水位线(worker_stats_cursor):只统计 id 大于它的行 + async fn stats_cursor(&self) -> i64 { + sqlx::query_scalar::<_, Option>("SELECT value FROM settings WHERE key = 'worker_stats_cursor'") + .fetch_optional(&self.pool) + .await + .unwrap_or(None) + .flatten() + .and_then(|v| v.parse().ok()) + .unwrap_or(0) + } + + /// 上报访问日志:读本地 access_logs → POST → 成功后删除已上报行 + async fn flush_access_logs(&self) -> anyhow::Result { + // 缓冲上限:超限丢弃最旧 + let cnt: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM access_logs") + .fetch_one(&self.pool) + .await?; + if cnt > MAX_BUFFER_ROWS { + sqlx::query( + "DELETE FROM access_logs WHERE id IN (SELECT id FROM access_logs ORDER BY id LIMIT ?)", + ) + .bind(cnt - MAX_BUFFER_ROWS) + .execute(&self.pool) + .await?; + tracing::warn!("Worker 上报缓冲超限,丢弃 {} 条最旧日志", cnt - MAX_BUFFER_ROWS); + } + let rows: Vec<(i64, String, i64, String, String, Option, i64, i64, String)> = + sqlx::query_as( + "SELECT id, rule_type, rule_id, client_ip, started_at, ended_at, bytes_up, bytes_down, status + FROM access_logs ORDER BY id LIMIT 2000", + ) + .fetch_all(&self.pool) + .await?; + if rows.is_empty() { + return Ok(0); + } + let max_id = rows.iter().map(|r| r.0).max().unwrap_or(0); + let items: Vec<_> = rows + .iter() + .map(|(_, rt, rid, cip, st, et, up, dn, status)| { + serde_json::json!({ + "rule_type": rt, "rule_id": rid, "client_ip": cip, + "started_at": st, "ended_at": et, + "bytes_up": up, "bytes_down": dn, "status": status, + }) + }) + .collect(); + let n = items.len(); + let resp = self + .post_json("/internal/node/logs/access", &serde_json::json!({ "items": items })) + .await?; + if !resp.status().is_success() { + anyhow::bail!("上报访问日志失败: {}", resp.status()); + } + sqlx::query("DELETE FROM access_logs WHERE id <= ?") + .bind(max_id) + .execute(&self.pool) + .await?; + Ok(n) + } + + /// 上报统计增量:只统计 id > 水位线的行;成功后推进水位线。 + /// 与 flush_access_logs 串行执行(先 stats 后 logs),保证不丢不重。 + async fn flush_stats(&self) -> anyhow::Result { + let cursor = self.stats_cursor().await; + let grouped: Vec<(String, i64, i64, i64, i64)> = sqlx::query_as( + "SELECT rule_type, rule_id, COUNT(*), COALESCE(SUM(bytes_up),0), COALESCE(SUM(bytes_down),0) + FROM access_logs WHERE id > ? GROUP BY rule_type, rule_id", + ) + .bind(cursor) + .fetch_all(&self.pool) + .await?; + if grouped.is_empty() { + return Ok(0); + } + let max_id: i64 = sqlx::query_scalar("SELECT COALESCE(MAX(id),0) FROM access_logs WHERE id > ?") + .bind(cursor) + .fetch_one(&self.pool) + .await?; + let items: Vec<_> = grouped + .into_iter() + .map(|(rt, rid, c, up, dn)| { + serde_json::json!({ + "rule_type": rt, "rule_id": rid, + "connections": c, "bytes_up": up, "bytes_down": dn, + }) + }) + .collect(); + let n = items.len(); + let resp = self + .post_json("/internal/node/stats/flush", &serde_json::json!({ "items": items })) + .await?; + if !resp.status().is_success() { + anyhow::bail!("上报统计增量失败: {}", resp.status()); + } + sqlx::query( + "INSERT INTO settings (key, value) VALUES ('worker_stats_cursor', ?) + ON CONFLICT (key) DO UPDATE SET value = excluded.value", + ) + .bind(max_id.to_string()) + .execute(&self.pool) + .await?; + Ok(n) + } + + /// 上报运行状态 + async fn report_runtime(&self) -> anyhow::Result<()> { + let snap = self.runtime.status_snapshot().await; + let items: Vec<_> = snap + .iter() + .map(|s| { + serde_json::json!({ + "entry_type": s["entry_type"], "entry_id": s["entry_id"], + "status": s["status"], "conns": s["conns"], + }) + }) + .collect(); + let resp = self + .post_json("/internal/node/runtime_status", &serde_json::json!({ "items": items })) + .await?; + if !resp.status().is_success() { + anyhow::bail!("上报运行状态失败: {}", resp.status()); + } + Ok(()) + } + + /// 上报指标(每 60 秒) + async fn report_metrics(&self) -> anyhow::Result<()> { + let mut sys = self.sys.lock().await; + let mut nets = self.nets.lock().await; + let mut prev = self.prev_net.lock().await; + sys.refresh_all(); + nets.refresh(); + let cpu = sys.global_cpu_usage() as f64; + let mem = if sys.total_memory() > 0 { + sys.used_memory() as f64 / sys.total_memory() as f64 * 100.0 + } else { + 0.0 + }; + let mut rx: u64 = 0; + let mut tx: u64 = 0; + for (_, d) in nets.iter() { + rx += d.received(); + tx += d.transmitted(); + } + let now = std::time::Instant::now(); + let (up_bps, down_bps) = match prev.2 { + Some(t) => { + let dt = now.duration_since(t).as_secs_f64().max(1.0); + ( + ((tx.saturating_sub(prev.1)) as f64 / dt * 8.0) as i64, + ((rx.saturating_sub(prev.0)) as f64 / dt * 8.0) as i64, + ) + } + None => (0, 0), + }; + prev.0 = rx; + prev.1 = tx; + prev.2 = Some(now); + drop(prev); + drop(nets); + drop(sys); + let conns: i64 = self + .runtime + .status_snapshot() + .await + .iter() + .map(|s| s["conns"].as_u64().unwrap_or(0) as i64) + .sum(); + let resp = self + .post_json( + "/internal/node/metrics", + &serde_json::json!({ + "cpu": cpu, "mem": mem, "conns": conns, + "net_up_bps": up_bps, "net_down_bps": down_bps, + }), + ) + .await?; + if !resp.status().is_success() { + anyhow::bail!("上报指标失败: {}", resp.status()); + } + Ok(()) + } +} + +pub async fn run_worker(cfg: Config, master: String, token: String) -> anyhow::Result<()> { + // tracing 已在 main 里初始化 + let master = master.trim_end_matches('/').to_string(); + tracing::info!("Worker 模式启动,Master: {master}"); + + // 本地库只做上报缓冲(access_logs / settings) + let db_url = "sqlite:data/worker.db"; + std::fs::create_dir_all("data").ok(); + let pool = crate::db::connect(db_url).await?; + crate::db::migrate(&pool).await?; + + let runtime = RuntimeManager::new(pool.clone(), cfg.clone()); + let http = reqwest::Client::builder() + .timeout(Duration::from_secs(30)) + .build()?; + + let w = Arc::new(Worker { + cfg, + master, + token, + http, + pool, + runtime, + sys: tokio::sync::Mutex::new(sysinfo::System::new_all()), + nets: tokio::sync::Mutex::new(sysinfo::Networks::new_with_refreshed_list()), + prev_net: tokio::sync::Mutex::new((0, 0, None)), + }); + + // 首次拉配置(失败则退出,让外部 supervisor 重启) + let mut etag = None; + w.sync_config(&mut etag).await?; + tracing::info!("Worker 首次配置同步完成"); + + // 心跳循环(10 秒) + let w2 = w.clone(); + tokio::spawn(async move { + let mut tick = tokio::time::interval(Duration::from_secs(10)); + loop { + tick.tick().await; + if let Err(e) = w2.heartbeat().await { + tracing::warn!("心跳失败: {e:#}"); + } + } + }); + // 配置拉取循环(15 秒) + let w2 = w.clone(); + tokio::spawn(async move { + let mut etag = etag; + let mut tick = tokio::time::interval(Duration::from_secs(15)); + loop { + tick.tick().await; + match w2.sync_config(&mut etag).await { + Ok(true) => tracing::info!("Worker 配置已更新"), + Ok(false) => {} + Err(e) => tracing::warn!("拉取配置失败: {e:#}"), + } + } + }); + // 上报循环(访问日志 + 统计增量 + 运行状态,每 10 秒;指标每 60 秒) + let w2 = w.clone(); + tokio::spawn(async move { + let mut tick = tokio::time::interval(Duration::from_secs(10)); + let mut rounds: u64 = 0; + loop { + tick.tick().await; + rounds += 1; + // 先统计增量,后删日志(顺序保证不丢不重) + if let Err(e) = w2.flush_stats().await { + tracing::warn!("上报统计增量失败: {e:#}"); + } else if let Err(e) = w2.flush_access_logs().await { + tracing::warn!("上报访问日志失败: {e:#}"); + } + if let Err(e) = w2.report_runtime().await { + tracing::warn!("上报运行状态失败: {e:#}"); + } + if rounds % 6 == 0 { + if let Err(e) = w2.report_metrics().await { + tracing::warn!("上报指标失败: {e:#}"); + } + } + } + }); + + // 主任务挂起 + loop { + tokio::time::sleep(Duration::from_secs(3600)).await; + } +} diff --git a/static/css/style.css b/static/css/style.css new file mode 100644 index 0000000..46ad8fd --- /dev/null +++ b/static/css/style.css @@ -0,0 +1,430 @@ +/* RustProxyAdmin — Modern SaaS Dashboard 风格 + 灵感:Linear / Vercel / Stripe —— 轻量卡片 + 柔和渐变 + 轻微 Glassmorphism */ +:root { + --bg: #f3f5fa; + --card: rgba(255, 255, 255, .78); + --card-solid: #ffffff; + --text: #0f172a; + --muted: #64748b; + --faint: #94a3b8; + --border: rgba(15, 23, 42, .08); + --border-strong: rgba(15, 23, 42, .14); + --primary: #6366f1; + --primary-d: #4f46e5; + --grad-primary: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%); + --grad-indigo: linear-gradient(135deg, #6366f1, #818cf8); + --grad-violet: linear-gradient(135deg, #8b5cf6, #c084fc); + --grad-cyan: linear-gradient(135deg, #06b6d4, #22d3ee); + --grad-amber: linear-gradient(135deg, #f59e0b, #fbbf24); + --grad-emerald: linear-gradient(135deg, #10b981, #34d399); + --grad-rose: linear-gradient(135deg, #f43f5e, #fb7185); + --grad-blue: linear-gradient(135deg, #3b82f6, #60a5fa); + --ok: #16a34a; --ok-bg: #dcfce7; + --warn: #b45309; --warn-bg: #fef3c7; + --danger: #dc2626; --danger-bg: #fee2e2; + --info: #0284c7; --info-bg: #e0f2fe; + --radius: 14px; + --shadow-sm: 0 1px 2px rgba(15, 23, 42, .05); + --shadow: 0 8px 24px -10px rgba(15, 23, 42, .14); + --shadow-lg: 0 24px 56px -16px rgba(15, 23, 42, .22); + --font: -apple-system, BlinkMacSystemFont, "SF Pro Text", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif; +} +* { box-sizing: border-box; } +html { -webkit-text-size-adjust: 100%; } +body { + margin: 0; color: var(--text); font-family: var(--font); font-size: 14px; line-height: 1.55; + background-color: var(--bg); + background-image: + radial-gradient(640px 420px at 8% -6%, rgba(99, 102, 241, .10), transparent 60%), + radial-gradient(720px 480px at 96% 4%, rgba(139, 92, 246, .08), transparent 60%), + radial-gradient(900px 600px at 50% 110%, rgba(34, 211, 238, .07), transparent 60%); + background-attachment: fixed; + min-height: 100vh; +} +a { color: var(--primary-d); text-decoration: none; } +a:hover { text-decoration: underline; } +::selection { background: rgba(99, 102, 241, .22); } +::-webkit-scrollbar { width: 10px; height: 10px; } +::-webkit-scrollbar-thumb { background: rgba(15, 23, 42, .16); border-radius: 8px; border: 2px solid transparent; background-clip: content-box; } +::-webkit-scrollbar-track { background: transparent; } + +/* ---------- 布局 ---------- */ +.layout { display: flex; min-height: 100vh; } +.sidebar { + width: 248px; flex-shrink: 0; position: sticky; top: 0; height: 100vh; overflow-y: auto; + background: rgba(255, 255, 255, .72); + -webkit-backdrop-filter: blur(18px); backdrop-filter: blur(18px); + border-right: 1px solid var(--border); + display: flex; flex-direction: column; z-index: 20; +} +.logo { display: flex; align-items: center; gap: 10px; padding: 18px 18px 14px; } +.logo-mark { + width: 34px; height: 34px; border-radius: 10px; background: var(--grad-primary); + display: flex; align-items: center; justify-content: center; color: #fff; + box-shadow: 0 4px 12px rgba(99, 102, 241, .4); flex-shrink: 0; +} +.logo-mark svg { width: 20px; height: 20px; } +.logo-text { font-weight: 700; font-size: 15.5px; letter-spacing: -.01em; } +.logo-text small { display: block; font-weight: 500; font-size: 11px; color: var(--muted); } +.sidebar nav { padding: 4px 12px 20px; flex: 1; } +.nav-group { padding: 16px 10px 6px; font-size: 11px; font-weight: 600; letter-spacing: .08em; text-transform: uppercase; color: var(--faint); } +.sidebar nav a { + display: flex; align-items: center; gap: 10px; + padding: 7px 10px; border-radius: 10px; color: #334155; margin: 1px 0; + font-size: 13.5px; font-weight: 500; transition: background .15s, color .15s; +} +.sidebar nav a:hover { background: rgba(15, 23, 42, .05); text-decoration: none; color: var(--text); } +.sidebar nav a.active { background: rgba(99, 102, 241, .12); color: var(--primary-d); font-weight: 600; } +.sidebar nav a.active .nav-ic { background: var(--grad-primary); color: #fff; box-shadow: 0 3px 8px rgba(99, 102, 241, .35); } +.nav-ic { + width: 30px; height: 30px; border-radius: 9px; flex-shrink: 0; + display: inline-flex; align-items: center; justify-content: center; + background: rgba(15, 23, 42, .05); color: #64748b; transition: all .15s; +} +.nav-ic svg { width: 17px; height: 17px; } +.sidebar-foot { padding: 12px 18px 18px; border-top: 1px solid var(--border); font-size: 12px; color: var(--faint); } + +.main { flex: 1; min-width: 0; display: flex; flex-direction: column; } +.topbar { + display: flex; justify-content: space-between; align-items: center; + padding: 10px 28px; position: sticky; top: 0; z-index: 10; + background: rgba(255, 255, 255, .66); + -webkit-backdrop-filter: blur(18px); backdrop-filter: blur(18px); + border-bottom: 1px solid var(--border); +} +.topbar-crumb { font-size: 13px; color: var(--muted); display: flex; align-items: center; gap: 8px; } +.topbar-crumb b { color: var(--text); font-weight: 600; } +.user-chip { display: flex; align-items: center; gap: 10px; } +.avatar { + width: 32px; height: 32px; border-radius: 50%; background: var(--grad-primary); color: #fff; + display: inline-flex; align-items: center; justify-content: center; font-weight: 700; font-size: 14px; + box-shadow: 0 3px 8px rgba(99, 102, 241, .35); +} +.user-meta { line-height: 1.25; } +.user-meta .uname { font-weight: 600; font-size: 13px; } +.role-badge { font-size: 11px; padding: 1px 8px; border-radius: 20px; background: rgba(99,102,241,.12); color: var(--primary-d); font-weight: 600; } +.role-badge.viewer { background: rgba(100,116,139,.14); color: #475569; } + +.content { padding: 28px; max-width: 1440px; width: 100%; margin: 0 auto; animation: fadeUp .35s ease; } +@keyframes fadeUp { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } } +@media (prefers-reduced-motion: reduce) { .content { animation: none; } * { transition: none !important; } } + +/* 页头:标题 + 描述 + 操作区 */ +.page-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 20px; flex-wrap: wrap; } +.page-head-title { font-size: 22px; font-weight: 700; letter-spacing: -.02em; margin: 0; } +.page-head-desc { color: var(--muted); font-size: 13.5px; margin: 4px 0 0; } +.page-head-actions { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; } +.page-head-actions:empty { display: none; } + +.warn-banner { + background: linear-gradient(135deg, rgba(245,158,11,.16), rgba(251,191,36,.10)); + border-bottom: 1px solid rgba(245, 158, 11, .3); color: #92400e; + padding: 10px 28px; text-align: center; font-size: 13px; + -webkit-backdrop-filter: blur(8px); backdrop-filter: blur(8px); +} +.warn-banner a { color: #b45309; font-weight: 700; } + +/* ---------- 按钮 ---------- */ +.btn { + display: inline-flex; align-items: center; justify-content: center; gap: 6px; + background: var(--card-solid); border: 1px solid var(--border-strong); color: var(--text); + padding: 8px 16px; border-radius: 10px; cursor: pointer; font-size: 13.5px; font-weight: 550; + font-family: var(--font); box-shadow: var(--shadow-sm); transition: all .15s; white-space: nowrap; +} +.btn:hover { border-color: rgba(15,23,42,.24); box-shadow: var(--shadow); transform: translateY(-1px); } +.btn:active { transform: translateY(0); } +.btn:disabled { opacity: .45; cursor: not-allowed; transform: none; box-shadow: var(--shadow-sm); } +.btn-primary { background: var(--grad-primary); border: none; color: #fff; box-shadow: 0 4px 14px rgba(99, 102, 241, .4); } +.btn-primary:hover { box-shadow: 0 8px 20px rgba(99, 102, 241, .5); border: none; filter: brightness(1.05); } +.btn-danger { background: #fef2f2; border: 1px solid #fecaca; color: var(--danger); } +.btn-danger:hover { background: var(--danger); border-color: var(--danger); color: #fff; } +.btn-ok { background: #f0fdf4; border: 1px solid #bbf7d0; color: var(--ok); } +.btn-ok:hover { background: var(--ok); border-color: var(--ok); color: #fff; } +.btn-sm { padding: 5px 11px; font-size: 12.5px; border-radius: 8px; } +.btn-block { width: 100%; margin-top: 10px; padding: 11px; font-size: 14.5px; border-radius: 12px; } +.btn-ghost { background: transparent; border: none; box-shadow: none; color: var(--muted); } +.btn-ghost:hover { background: rgba(15,23,42,.06); color: var(--text); box-shadow: none; } + +/* ---------- 表单 ---------- */ +input, select, textarea { + background: var(--card-solid); border: 1px solid var(--border-strong); color: var(--text); + padding: 9px 12px; border-radius: 10px; font-size: 13.5px; width: 100%; font-family: var(--font); + transition: border-color .15s, box-shadow .15s; +} +input:focus, select:focus, textarea:focus { + outline: none; border-color: var(--primary); + box-shadow: 0 0 0 3px rgba(99, 102, 241, .16); +} +input::placeholder, textarea::placeholder { color: var(--faint); } +input[type=checkbox] { width: 16px; height: 16px; accent-color: var(--primary-d); cursor: pointer; } +label { display: block; margin-bottom: 6px; color: #475569; font-size: 13px; font-weight: 550; } +.form-row { margin-bottom: 14px; } +.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; } +.form-hint { font-size: 12px; color: var(--faint); margin-top: 4px; } + +/* ---------- 工具栏 ---------- */ +.toolbar { + display: flex; gap: 10px; margin-bottom: 16px; flex-wrap: wrap; align-items: center; + background: var(--card); -webkit-backdrop-filter: blur(14px); backdrop-filter: blur(14px); + border: 1px solid var(--border); border-radius: var(--radius); padding: 12px 16px; box-shadow: var(--shadow-sm); +} +.toolbar input, .toolbar select { width: auto; } +.toolbar .spacer { flex: 1; } + +/* ---------- 表格 ---------- */ +table.tbl { + width: 100%; border-collapse: separate; border-spacing: 0; + background: var(--card); -webkit-backdrop-filter: blur(14px); backdrop-filter: blur(14px); + border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; + box-shadow: var(--shadow-sm); +} +.tbl th, .tbl td { padding: 11px 14px; text-align: left; border-bottom: 1px solid var(--border); font-size: 13.5px; } +.tbl thead th { + background: rgba(248, 250, 252, .8); color: var(--muted); + font-weight: 600; font-size: 12px; letter-spacing: .04em; text-transform: uppercase; white-space: nowrap; + border-bottom: 1px solid var(--border); +} +.tbl tbody tr { transition: background .12s; } +.tbl tbody tr:hover td { background: rgba(99, 102, 241, .045); } +.tbl tbody tr:last-child td { border-bottom: none; } +.tbl .num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; } +.tbl-wrap { overflow-x: auto; border-radius: var(--radius); } +.pager { display: flex; gap: 10px; align-items: center; margin-top: 14px; color: var(--muted); font-size: 13px; } + +/* ---------- 徽章 / 标签 ---------- */ +.badge { + display: inline-flex; align-items: center; gap: 4px; + padding: 3px 10px; border-radius: 20px; font-size: 12px; font-weight: 600; + background: rgba(100, 116, 139, .12); color: #475569; border: 1px solid transparent; white-space: nowrap; +} +.badge.ok { background: var(--ok-bg); color: #15803d; } +.badge.warn { background: var(--warn-bg); color: var(--warn); } +.badge.danger, .badge.bad { background: var(--danger-bg); color: #b91c1c; } +.badge.muted { background: rgba(100,116,139,.1); color: var(--muted); } +.badge.info { background: var(--info-bg); color: var(--info); } +.badge.primary { background: rgba(99,102,241,.12); color: var(--primary-d); } +.tagchip, .groupchip { + display: inline-block; padding: 2px 10px; border-radius: 20px; font-size: 12px; font-weight: 600; + margin: 1px 3px; color: #fff; box-shadow: var(--shadow-sm); +} +.groupchip { border-radius: 7px; } + +/* ---------- 卡片 / 统计 ---------- */ +.card { + background: var(--card); -webkit-backdrop-filter: blur(14px); backdrop-filter: blur(14px); + border: 1px solid var(--border); border-radius: 16px; padding: 20px; margin-bottom: 16px; + box-shadow: var(--shadow-sm); transition: box-shadow .2s, transform .2s; +} +.card h3 { margin: 0 0 14px; font-size: 15px; font-weight: 650; letter-spacing: -.01em; display: flex; align-items: center; gap: 8px; } +.card h3 .sub { font-weight: 500; font-size: 12.5px; color: var(--muted); } +.grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; } +.grid3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; } +.grid4 { display: grid; grid-template-columns: repeat(auto-fill, minmax(210px, 1fr)); gap: 16px; } + +/* 统计卡片(仪表盘) */ +.stat-card { + background: var(--card); -webkit-backdrop-filter: blur(14px); backdrop-filter: blur(14px); + border: 1px solid var(--border); border-radius: 16px; padding: 18px; + box-shadow: var(--shadow-sm); display: flex; gap: 14px; align-items: flex-start; + transition: transform .18s, box-shadow .18s; position: relative; overflow: hidden; +} +.stat-card::after { + content: ""; position: absolute; inset: 0; pointer-events: none; opacity: .5; + background: radial-gradient(220px 120px at 110% -20%, var(--glow, rgba(99,102,241,.10)), transparent 70%); +} +.stat-card:hover { transform: translateY(-2px); box-shadow: var(--shadow); } +.stat-icon { + width: 44px; height: 44px; border-radius: 13px; flex-shrink: 0; + display: flex; align-items: center; justify-content: center; color: #fff; + box-shadow: 0 6px 14px -4px rgba(15, 23, 42, .3); +} +.stat-icon svg { width: 22px; height: 22px; } +.stat-icon.indigo { background: var(--grad-indigo); } +.stat-icon.violet { background: var(--grad-violet); } +.stat-icon.cyan { background: var(--grad-cyan); } +.stat-icon.amber { background: var(--grad-amber); } +.stat-icon.emerald { background: var(--grad-emerald); } +.stat-icon.rose { background: var(--grad-rose); } +.stat-icon.blue { background: var(--grad-blue); } +.stat-body { min-width: 0; } +.stat-num { font-size: 25px; font-weight: 750; letter-spacing: -.02em; font-variant-numeric: tabular-nums; line-height: 1.2; } +.stat-label { color: var(--muted); font-size: 12.5px; margin-top: 2px; } +.stat-delta { font-size: 12px; font-weight: 600; margin-top: 4px; display: inline-block; padding: 1px 8px; border-radius: 12px; } +.stat-delta.up { background: var(--ok-bg); color: #15803d; } +.stat-delta.down { background: var(--danger-bg); color: #b91c1c; } + +/* ---------- 进度条 ---------- */ +.progress { height: 8px; background: rgba(15,23,42,.07); border-radius: 6px; overflow: hidden; min-width: 80px; } +.progress > div { height: 100%; background: var(--grad-primary); border-radius: 6px; transition: width .3s; } +.progress > div.warn { background: var(--grad-amber); } +.progress > div.danger { background: var(--grad-rose); } +.pbar { display: inline-block; width: 120px; height: 8px; background: rgba(15,23,42,.07); border-radius: 6px; overflow: hidden; vertical-align: middle; margin-right: 8px; } +.pfill { height: 100%; background: linear-gradient(90deg, #34d399, #10b981); border-radius: 6px; transition: width .3s; } +.pfill.warn { background: linear-gradient(90deg, #fbbf24, #f59e0b); } +.pfill.danger { background: linear-gradient(90deg, #fb7185, #f43f5e); } + +/* ---------- 选项卡 ---------- */ +.tabs { + display: inline-flex; gap: 4px; margin-bottom: 18px; padding: 4px; + background: rgba(15, 23, 42, .05); border-radius: 12px; +} +.tabs button { + background: none; border: none; color: var(--muted); padding: 8px 18px; cursor: pointer; + font-size: 13.5px; font-weight: 550; border-radius: 9px; font-family: var(--font); transition: all .15s; +} +.tabs button:hover { color: var(--text); } +.tabs button.active { color: var(--text); background: var(--card-solid); box-shadow: var(--shadow-sm); } + +/* ---------- 弹窗 ---------- */ +.modal-mask { + position: fixed; inset: 0; background: rgba(15, 23, 42, .42); + -webkit-backdrop-filter: blur(6px); backdrop-filter: blur(6px); + display: flex; align-items: center; justify-content: center; z-index: 100; padding: 20px; + animation: fadeIn .18s ease; +} +@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } } +.modal { + background: rgba(255, 255, 255, .94); -webkit-backdrop-filter: blur(20px); backdrop-filter: blur(20px); + border: 1px solid var(--border); border-radius: 18px; padding: 26px; + width: 580px; max-width: 94vw; max-height: 88vh; overflow-y: auto; + box-shadow: var(--shadow-lg); animation: popIn .22s cubic-bezier(.2, .9, .3, 1.2); +} +@keyframes popIn { from { opacity: 0; transform: scale(.96) translateY(10px); } to { opacity: 1; transform: none; } } +.modal h3 { margin: 0 0 18px; font-size: 17px; font-weight: 700; letter-spacing: -.01em; } +.modal .actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 20px; padding-top: 16px; border-top: 1px solid var(--border); } + +/* ---------- toast ---------- */ +#toast { position: fixed; bottom: 26px; right: 26px; z-index: 200; display: flex; flex-direction: column; gap: 8px; } +.toast-msg { + background: rgba(15, 23, 42, .92); color: #f1f5f9; padding: 12px 18px; border-radius: 12px; + box-shadow: var(--shadow-lg); font-size: 13.5px; font-weight: 500; + -webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px); + animation: slideIn .25s cubic-bezier(.2, .9, .3, 1.2); border: 1px solid rgba(255,255,255,.1); + display: flex; align-items: center; gap: 8px; +} +.toast-msg::before { content: "✓"; color: #34d399; font-weight: 700; } +.toast-msg.err { border-color: rgba(248, 113, 113, .4); } +.toast-msg.err::before { content: "⚠"; color: #f87171; } +@keyframes slideIn { from { opacity: 0; transform: translateX(24px); } to { opacity: 1; transform: none; } } + +/* ---------- 登录页 ---------- */ +.login-body { + display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; + background-color: #eef0f8; + background-image: + radial-gradient(720px 480px at 12% 8%, rgba(99, 102, 241, .22), transparent 60%), + radial-gradient(680px 520px at 88% 90%, rgba(139, 92, 246, .18), transparent 60%), + radial-gradient(560px 420px at 85% 12%, rgba(34, 211, 238, .14), transparent 60%), + radial-gradient(600px 500px at 8% 92%, rgba(244, 114, 182, .10), transparent 60%); +} +.login-wrap { display: flex; flex-direction: column; align-items: center; gap: 22px; padding: 24px; animation: fadeUp .4s ease; } +.login-brand { display: flex; align-items: center; gap: 12px; } +.login-brand .logo-mark { width: 44px; height: 44px; border-radius: 13px; } +.login-brand .logo-mark svg { width: 26px; height: 26px; } +.login-brand b { font-size: 21px; letter-spacing: -.02em; } +.login-card { + background: rgba(255, 255, 255, .8); -webkit-backdrop-filter: blur(24px); backdrop-filter: blur(24px); + border: 1px solid rgba(255, 255, 255, .7); border-radius: 22px; + padding: 38px 40px; width: 400px; max-width: 92vw; + box-shadow: var(--shadow-lg); +} +.login-card h1 { margin: 0 0 4px; font-size: 23px; font-weight: 750; letter-spacing: -.02em; } +.login-card .muted { color: var(--muted); margin: 0 0 24px; font-size: 13.5px; } +.login-card label { display: block; margin-bottom: 14px; font-size: 13px; color: #475569; font-weight: 550; } +.login-card label input { margin-top: 6px; padding: 11px 14px; } +.error { + background: var(--danger-bg); color: #b91c1c; padding: 10px 14px; border-radius: 10px; + margin-bottom: 14px; font-size: 13px; font-weight: 550; border: 1px solid #fecaca; +} +.login-foot { color: var(--muted); font-size: 12.5px; } + +/* ---------- 文本 / 代码 ---------- */ +.muted { color: var(--muted); } +.mono { font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 12.5px; word-break: break-all; } +.codebox { + background: #0f172a; color: #c7d2fe; border: 1px solid rgba(15,23,42,.2); + border-radius: 12px; padding: 14px 16px; white-space: pre-wrap; word-break: break-all; + max-height: 400px; overflow-y: auto; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; + font-size: 12.5px; line-height: 1.6; +} +pre.mark { background: #0f172a; color: #e2e8f0; padding: 12px 16px; border-radius: 12px; overflow-x: auto; font-size: 12.5px; } + +/* ---------- 开关 ---------- */ +.switch { position: relative; display: inline-block; width: 42px; height: 24px; flex-shrink: 0; } +.switch input { opacity: 0; width: 0; height: 0; } +.slider { position: absolute; inset: 0; background: rgba(15,23,42,.14); border-radius: 24px; cursor: pointer; transition: .2s; } +.slider:before { + content: ""; position: absolute; width: 18px; height: 18px; left: 3px; top: 3px; + background: #fff; border-radius: 50%; transition: .2s; box-shadow: 0 2px 6px rgba(0,0,0,.2); +} +.switch input:checked + .slider { background: var(--grad-primary); } +.switch input:checked + .slider:before { transform: translateX(18px); } + +/* ---------- 批量操作条 ---------- */ +.batchbar { + position: sticky; bottom: 16px; background: rgba(255,255,255,.9); + -webkit-backdrop-filter: blur(16px); backdrop-filter: blur(16px); + border: 1px solid var(--border); border-radius: 14px; padding: 12px 18px; margin-top: 14px; + display: flex; gap: 10px; align-items: center; flex-wrap: wrap; box-shadow: var(--shadow); +} + +/* ---------- P9 统计/监控 ---------- */ +.legend { display: flex; gap: 16px; margin-top: 10px; font-size: 13px; color: var(--muted); flex-wrap: wrap; } +.legend .dot { display: inline-block; width: 10px; height: 10px; border-radius: 50%; margin-right: 5px; } +.bar-row { display: flex; align-items: center; gap: 10px; margin: 8px 0; font-size: 13px; } +.bar-row > span:first-child { width: 110px; flex-shrink: 0; font-weight: 550; } +.bar-row .mono { margin-left: auto; white-space: nowrap; color: var(--muted); } +.bar { flex: 1; height: 14px; background: rgba(15,23,42,.07); border-radius: 8px; overflow: hidden; } +.bar > div { height: 100%; border-radius: 8px; background: var(--grad-primary); transition: width .4s; } +.node-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(310px, 1fr)); gap: 16px; } +.node-cards .card:hover { transform: translateY(-2px); box-shadow: var(--shadow); } +.spark { width: 100%; height: 60px; } +.warn-row td { background: rgba(245, 158, 11, .07) !important; } +.danger-row td { background: rgba(244, 63, 94, .06) !important; } + +/* ---------- 空状态 ---------- */ +.empty-state { text-align: center; padding: 48px 20px; color: var(--muted); } +.empty-state .empty-ic { + width: 56px; height: 56px; border-radius: 18px; margin: 0 auto 14px; + background: rgba(99,102,241,.1); color: var(--primary-d); + display: flex; align-items: center; justify-content: center; +} +.empty-state .empty-ic svg { width: 28px; height: 28px; } +.empty-state p { margin: 6px 0 0; font-size: 13.5px; } +.empty-state b { color: var(--text); font-size: 15px; } + +/* ---------- 详情定义列表 ---------- */ +.kv { display: grid; grid-template-columns: 130px 1fr; gap: 8px 12px; font-size: 13.5px; } +.kv dt { color: var(--muted); font-weight: 550; } +.kv dd { margin: 0; word-break: break-all; } + +/* ---------- 步骤 / 时间线(告警事件等) ---------- */ +.timeline { position: relative; padding-left: 22px; } +.timeline::before { content: ""; position: absolute; left: 7px; top: 6px; bottom: 6px; width: 2px; background: rgba(15,23,42,.1); border-radius: 2px; } +.timeline-item { position: relative; padding: 8px 0; } +.timeline-item::before { + content: ""; position: absolute; left: -19px; top: 14px; width: 10px; height: 10px; + border-radius: 50%; background: var(--card-solid); border: 2px solid var(--primary); +} + +/* ---------- 响应式 ---------- */ +@media (max-width: 1100px) { + .grid3 { grid-template-columns: 1fr 1fr; } +} +@media (max-width: 900px) { + .sidebar { width: 72px; } + .sidebar .logo-text, .sidebar .nav-group, .sidebar nav a span.nav-txt, .sidebar-foot { display: none; } + .sidebar nav a { justify-content: center; padding: 8px; } + .logo { justify-content: center; padding: 16px 8px 12px; } + .content { padding: 18px; } + .grid2, .grid3, .form-grid { grid-template-columns: 1fr; } + .topbar { padding: 10px 16px; } + .user-meta { display: none; } +} +.page-head-desc:empty { display: none; } + +/* 提醒行(仪表盘) */ +.alert-line { display: flex; align-items: center; gap: 10px; padding: 10px 12px; border-radius: 12px; background: rgba(15,23,42,.03); border: 1px solid var(--border); margin-bottom: 8px; font-size: 13px; } +.alert-line.warn { background: rgba(245,158,11,.08); border-color: rgba(245,158,11,.28); } +.alert-line.danger { background: rgba(244,63,94,.07); border-color: rgba(244,63,94,.25); } +.alert-line .grow { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 550; } diff --git a/static/js/alerts.js b/static/js/alerts.js new file mode 100644 index 0000000..98a9f42 --- /dev/null +++ b/static/js/alerts.js @@ -0,0 +1,237 @@ +// P12 —— 告警中心前端(渠道 / 规则 / 事件历史 三个标签页) +let ALERT_EVENT_TYPES = []; // [{event_type, name, default_params}] +let ALERT_CHANNELS = []; // [{id, name, kind}] +let evPage = 1; + +function initAlerts(){ + switchTab('channels'); + loadEventTypes(); +} + +function switchTab(t){ + document.querySelectorAll('#tab-channels,#tab-rules,#tab-events').forEach(d => d.style.display = 'none'); + document.getElementById('tab-'+t).style.display = ''; + document.querySelectorAll('.tabs button').forEach(b => b.classList.toggle('active', b.dataset.tab === t)); + if (t === 'channels') loadChannels(); + if (t === 'rules') loadRules(); + if (t === 'events') loadEvents(); +} + +async function loadEventTypes(){ + try { + const r = await api('GET', '/api/alerts/rules'); + ALERT_EVENT_TYPES = r.data.event_types || []; + const sel = document.getElementById('ev-type'); + sel.innerHTML = '' + ALERT_EVENT_TYPES.map(e => + ``).join(''); + } catch(e){} +} + +// ============ 渠道 ============ + +function channelFormHtml(kind, cfg){ + cfg = cfg || {}; + if (kind === 'telegram') return ` +

+
+
`; + if (kind === 'webhook') return ` +
+
+
`; + if (kind === 'email') return ` +
+
+
+
+
+
`; + return ''; +} + +function collectChannelCfg(kind){ + const v = id => document.getElementById(id).value.trim(); + if (kind === 'telegram') return { bot_token: v('f-bot_token'), chat_id: v('f-chat_id'), proxy: v('f-proxy') }; + if (kind === 'webhook') { + let headers = {}; + try { if (v('f-headers')) headers = JSON.parse(v('f-headers')); } catch(e){ toast('请求头 JSON 格式错误', true); return null; } + return { url: v('f-url'), secret: v('f-secret'), headers }; + } + if (kind === 'email') return { + host: v('f-host'), port: parseInt(v('f-port'))||587, tls: document.getElementById('f-tls').value, + username: v('f-username'), password: v('f-password'), from: v('f-from'), + to: v('f-to').split('\n').map(s=>s.trim()).filter(Boolean), + }; + return null; +} + +async function loadChannels(){ + try { + const r = await api('GET', '/api/alerts/channels'); + const items = r.data || []; + ALERT_CHANNELS = items; + document.getElementById('ch-rows').innerHTML = items.map(c => { + let cfg = ''; + const cf = c.config || {}; + if (c.kind === 'telegram') cfg = `chat_id=${esc(cf.chat_id||'-')}`; + else if (c.kind === 'webhook') cfg = esc(cf.url||'-'); + else if (c.kind === 'email') cfg = `${esc(cf.host||'-')}:${esc(cf.port||'')} → ${esc((cf.to||[]).join(', '))}`; + return `${esc(c.name)}${esc(c.kind)}${cfg} + ${c.enabled ? '启用' : '停用'} + + ${IS_VIEWER ? '' : ` + + + `} + `; + }).join('') || '暂无渠道,点击"新建渠道"添加'; + } catch(e){ toast('加载渠道失败:'+e.message, true); } +} + +async function editChannel(id){ + const ch = id ? ALERT_CHANNELS.find(c => c.id === id) : null; + const kind = ch ? ch.kind : 'telegram'; + const kinds = ['telegram','webhook','email']; + openModal(id ? '编辑渠道' : '新建渠道', ` +
+
+
${channelFormHtml(kind, ch ? ch.config : {})}
+
+ `, async () => { + const name = document.getElementById('f-name').value.trim(); + if (!name) { toast('名称不能为空', true); return false; } + const cfg = collectChannelCfg(document.getElementById('f-kind').value); + if (!cfg) return false; + try { + if (id) await api('PUT', '/api/alerts/channels/'+id, { name, kind: ch.kind, config: cfg, enabled: parseInt(document.getElementById('f-enabled').value) }); + else await api('POST', '/api/alerts/channels', { name, kind: document.getElementById('f-kind').value, config: cfg, enabled: parseInt(document.getElementById('f-enabled').value) }); + toast(id ? '已更新' : '已创建'); loadChannels(); + } catch(e){ toast('保存失败:'+e.message, true); return false; } + }); + document.getElementById('f-kind').onchange = e => { + document.getElementById('ch-form').innerHTML = channelFormHtml(e.target.value, {}); + }; +} + +async function testChannel(id){ + try { + await api('POST', `/api/alerts/channels/${id}/test`); + toast('测试消息已发送'); + } catch(e){ toast('测试发送失败:'+e.message, true); } +} + +function delChannel(id){ + confirmDo('删除该渠道?已绑定到规则的会被自动移除。', async () => { + try { await api('DELETE', '/api/alerts/channels/'+id); toast('已删除'); loadChannels(); } + catch(e){ toast('删除失败:'+e.message, true); } + }, '删除'); +} + +// ============ 规则 ============ + +async function loadRules(){ + try { + const r = await api('GET', '/api/alerts/rules'); + const items = r.data.items || []; + const nameOf = id => { const c = ALERT_CHANNELS.find(x => x.id === id); return c ? esc(c.name) : '#'+id; }; + document.getElementById('ru-rows').innerHTML = items.map(ru => { + const et = ALERT_EVENT_TYPES.find(e => e.event_type === ru.event_type); + const params = JSON.stringify(ru.params); + const chs = (ru.channel_ids||[]).map(nameOf).join('、') || '未绑定渠道'; + return `${esc(ru.name)} + ${esc(et?et.name:ru.event_type)} ${esc(ru.event_type)} + ${esc(params)} + ${chs} + ${ru.cooldown_secs}s + ${ru.enabled ? '启用' : '停用'} + + ${IS_VIEWER ? '' : ` + + + `} + `; + }).join('') || '暂无规则'; + } catch(e){ toast('加载规则失败:'+e.message, true); } +} + +let RULE_CACHE = []; +async function editRule(id){ + const r = id ? (await api('GET', '/api/alerts/rules')).data.items.find(x => x.id === id) : null; + if (id && !r) return; + const et = ALERT_EVENT_TYPES.find(e => e.event_type === (r ? r.event_type : 'node_offline')); + openModal(id ? '编辑规则' : '新建规则', ` +
+
+
+
${ALERT_CHANNELS.map(c=>``).join('') || '暂无渠道'}
+
+
+ `, async () => { + const name = document.getElementById('f-name').value.trim(); + if (!name) { toast('名称不能为空', true); return false; } + let params = {}; + try { params = JSON.parse(document.getElementById('f-params').value || '{}'); } + catch(e){ toast('参数 JSON 格式错误', true); return false; } + const channel_ids = [...document.querySelectorAll('.f-ch:checked')].map(el => parseInt(el.value)); + const payload = { name, event_type: document.getElementById('f-event_type').value, params, channel_ids, + cooldown_secs: parseInt(document.getElementById('f-cooldown').value)||0, enabled: parseInt(document.getElementById('f-enabled').value) }; + try { + if (id) await api('PUT', '/api/alerts/rules/'+id, payload); + else await api('POST', '/api/alerts/rules', payload); + toast(id ? '已更新' : '已创建'); loadRules(); + } catch(e){ toast('保存失败:'+e.message, true); return false; } + }); + document.getElementById('f-event_type').onchange = e => { + const t = ALERT_EVENT_TYPES.find(x => x.event_type === e.target.value); + if (t) document.getElementById('f-params').value = t.default_params; + }; +} + +async function toggleRule(id){ + try { await api('POST', `/api/alerts/rules/${id}/toggle`); loadRules(); } + catch(e){ toast('操作失败:'+e.message, true); } +} + +function delRule(id){ + confirmDo('删除该规则?', async () => { + try { await api('DELETE', '/api/alerts/rules/'+id); toast('已删除'); loadRules(); } + catch(e){ toast('删除失败:'+e.message, true); } + }, '删除'); +} + +// ============ 事件历史 ============ + +const SEV = { info: 'ℹ info', warning: '⚠ warning', critical: '🔴 critical' }; +const EST = { pending: '待发送', sent: '已发送', failed: '失败' }; + +async function loadEvents(){ + try { + const qs = `page=${evPage}&page_size=20&status=${document.getElementById('ev-status').value}&event_type=${document.getElementById('ev-type').value}`; + const r = await api('GET', '/api/alerts/events?'+qs); + const d = r.data; + document.getElementById('ev-rows').innerHTML = d.items.map(e => + `${esc(e.created_at)}${esc(e.event_type)} + ${esc(e.title)}
${esc(e.body).slice(0,120)}
+ ${SEV[e.severity]||esc(e.severity)}${EST[e.status]||esc(e.status)} + ${e.attempts}${esc(e.last_error||'-')} + ${(e.status==='failed' && !IS_VIEWER) ? `` : ''}` + ).join('') || '暂无事件'; + const pages = Math.max(1, Math.ceil(d.total / d.page_size)); + document.getElementById('ev-pager').innerHTML = + `共 ${d.total} 条 ${evPage}/${pages} `; + } catch(e){ toast('加载事件失败:'+e.message, true); } +} + +async function retryEvent(id){ + try { await api('POST', `/api/alerts/events/${id}/retry`); toast('已重新投递'); loadEvents(); } + catch(e){ toast('重试失败:'+e.message, true); } +} + +function cleanupEvents(){ + openModal('清理历史事件', `
`, async () => { + try { + const r = await api('POST', '/api/alerts/events/cleanup', { days: parseInt(document.getElementById('f-days').value)||30 }); + toast(`已清理 ${r.data.deleted} 条`); loadEvents(); + } catch(e){ toast('清理失败:'+e.message, true); return false; } + }, '清理'); +} diff --git a/static/js/app.js b/static/js/app.js new file mode 100644 index 0000000..6544e4d --- /dev/null +++ b/static/js/app.js @@ -0,0 +1,158 @@ +// 全局 JS 工具:API 调用(自动带 CSRF)、toast、弹窗、表格分页 +function csrf() { + return sessionStorage.getItem('csrf') || document.querySelector('meta[name=csrf]')?.content || ''; +} + +async function api(method, url, body) { + const opt = { method, headers: {} }; + if (body !== undefined) { + opt.headers['Content-Type'] = 'application/json'; + opt.body = JSON.stringify(body); + } + if (method !== 'GET' && method !== 'HEAD') { + opt.headers['X-CSRF-Token'] = csrf(); + } + const r = await fetch(url, opt); + if (r.status === 401) { location.href = '/admin/login'; throw new Error('未登录'); } + const j = await r.json().catch(() => ({ ok: false, error: 'HTTP ' + r.status })); + if (!j.ok) throw new Error(j.error || ('HTTP ' + r.status)); + return j.data; +} + +function toast(msg, isErr) { + const box = document.getElementById('toast'); + const d = document.createElement('div'); + d.className = 'toast-msg' + (isErr ? ' err' : ''); + d.textContent = msg; + box.appendChild(d); + setTimeout(() => d.remove(), 3500); +} + +async function logout() { + try { await api('POST', '/api/auth/logout'); } catch (e) {} + sessionStorage.removeItem('csrf'); + location.href = '/admin/login'; +} + +function esc(s) { + return String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); +} + +function fmtBytes(b) { + b = Number(b) || 0; + const u = ['B','KB','MB','GB','TB','PB']; + let i = 0; + while (b >= 1024 && i < u.length - 1) { b /= 1024; i++; } + return (i === 0 ? b : b.toFixed(1)) + ' ' + u[i]; +} + +// UTC "YYYY-MM-DD HH:MM:SS" → 本地显示(默认 +8) +const TZ = 8; +function fmtTime(s) { + if (!s) return '-'; + const d = new Date(s.replace(' ', 'T') + 'Z'); + if (isNaN(d)) return s; + const l = new Date(d.getTime() + TZ * 3600 * 1000); + return l.toISOString().slice(0, 19).replace('T', ' '); +} + +function openModal(title, bodyHtml, onOk, okText) { + const root = document.getElementById('modal-root'); + root.innerHTML = ``; + document.getElementById('modal-ok').onclick = async () => { + try { await onOk(); closeModal(); } catch (e) { toast(e.message, true); } + }; +} +function closeModal() { document.getElementById('modal-root').innerHTML = ''; } + +async function confirmDo(msg, fn, okText) { + openModal('确认', `

${esc(msg)}

`, fn, okText || '确定'); +} + +function pagerHtml(total, page, pageSize, fnName) { + const pages = Math.max(1, Math.ceil(total / pageSize)); + return `
共 ${total} 条 · 第 ${page}/${pages} 页 + +
`; +} + +function qs(obj) { + return Object.entries(obj).filter(([, v]) => v !== undefined && v !== null && v !== '') + .map(([k, v]) => encodeURIComponent(k) + '=' + encodeURIComponent(v)).join('&'); +} + +function badgeFor(enabled, reason) { + if (enabled) return '启用'; + const m = { manual: '手动停用', expired: '已过期', quota: '流量用尽' }; + return `${m[reason] || '停用'}`; +} + +// ---- P11 配额 / 到期单元格 ---- +function quotaCell(r) { + const q = r.quota; + if (!q || !q.quota_bytes) return '-'; + const pct = Math.min(100, Math.round(q.used_bytes * 100 / q.quota_bytes)); + const cls = q.status === 'exceeded' ? 'danger' : (pct >= 80 ? 'warn' : ''); + const st = q.status === 'exceeded' ? ' 超限' : ''; + const period = q.period === 'monthly' ? ' 月' : ''; + return `
` + + `${pct}%${period}${st}`; +} + +function expireCell(r) { + if (!r.expires_at) return '永久'; + const past = r.expires_at <= __nowStr(); + return `${esc(r.expires_at)}`; +} + +function __nowStr() { + const d = new Date(); + const p = n => String(n).padStart(2, '0'); + return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`; +} + +function copyText(t) { + navigator.clipboard.writeText(t).then(() => toast('已复制'), () => toast('复制失败', true)); +} + +// ---- P8 运行时状态徽章 ---- +let __rtCache = null, __rtCacheAt = 0; +async function loadRuntimeStatus(force) { + const now = Date.now(); + if (!force && __rtCache && now - __rtCacheAt < 5000) return __rtCache; + try { + const d = await api('GET', '/api/runtime/status'); + __rtCache = {}; + for (const it of d.items) __rtCache[it.entry_type + ':' + it.entry_id] = it; + __rtCacheAt = now; + } catch (e) { __rtCache = __rtCache || {}; } + return __rtCache; +} +function runtimeBadge(etype, id) { + const m = (__rtCache || {})[etype + ':' + id] || (__rtCache || {})[etype + '_udp:' + id]; + if (!m) return '-'; + const st = m.status || ''; + if (st === 'running') return `运行中`; + if (st.startsWith('idle')) return '待机'; + if (st.startsWith('error:')) return `异常`; + return `${esc(st)}`; +} +async function refreshRuntimeColumn(etype) { + await loadRuntimeStatus(true); + document.querySelectorAll('[data-rt]').forEach(td => { + const [t, id] = td.dataset.rt.split(':'); + td.innerHTML = runtimeBadge(t, +id); + }); +} + +function fmtBps(bps) { + if (bps == null) return '—'; + const b = bps / 8; + if (b < 1024) return b.toFixed(0) + ' B/s'; + if (b < 1048576) return (b / 1024).toFixed(1) + ' KB/s'; + if (b < 1073741824) return (b / 1048576).toFixed(1) + ' MB/s'; + return (b / 1073741824).toFixed(2) + ' GB/s'; +} diff --git a/static/js/entries.js b/static/js/entries.js new file mode 100644 index 0000000..e5c58b4 --- /dev/null +++ b/static/js/entries.js @@ -0,0 +1,306 @@ +// 通用条目列表页逻辑(三类条目共用) +let GT = { groups: [], tags: [], nodes: [] }; + +async function loadGT() { + try { GT.groups = await api('GET', '/api/groups'); } catch (e) {} + try { GT.tags = await api('GET', '/api/tags'); } catch (e) {} + try { const n = await api('GET', '/api/nodes'); GT.nodes = n.items || n; } catch (e) { GT.nodes = [{ id: 1, name: '本地节点' }]; } +} + +function groupChip(g) { + if (!g) return '未分组'; + return `${esc(g.name)}`; +} +function tagChips(tags) { + return (tags || []).map(t => `${esc(t.name)}`).join(''); +} +function expireCell(row) { + if (!row.expires_at) return '永久'; + const exp = new Date(row.expires_at.replace(' ', 'T') + 'Z').getTime(); + const days = (exp - Date.now()) / 86400000; + const cls = days < 0 ? 'danger' : days <= 7 ? 'warn' : 'ok'; + return `${fmtTime(row.expires_at)}`; +} +function quotaCell(row) { + const q = row.quota; + if (!q || q.quota_bytes === 0) return '-'; + const pct = Math.min(100, (q.used_bytes / q.quota_bytes) * 100); + const cls = q.status === 'exceeded' ? 'danger' : pct >= 80 ? 'warn' : ''; + return `
${pct.toFixed(0)}%
`; +} + +function initEntryPage(o) { + const L = { + api: o.api, etype: o.etype, page: 1, pageSize: 20, + selected: new Set(), + async reload() { + const f = this.readFilters(); + const d = await api('GET', this.api + '?' + qs({ page: this.page, page_size: this.pageSize, ...f })); + this.render(d); + if (o.runtimeBadge) refreshRuntimeColumn().catch(() => {}); + }, + readFilters() { + const g = id => document.getElementById(id)?.value; + const f = { + q: g('f-q') || undefined, + node_id: g('f-node') || undefined, + enabled: g('f-enabled') || undefined, + group_id: g('f-group') || undefined, + tag_id: g('f-tag') || undefined, + disabled_reason: g('f-reason') || undefined, + expiring_days: g('f-expiring') || undefined, + }; + if (o.readExtraFilters) Object.assign(f, o.readExtraFilters()); + return f; + }, + render(d) { + this.rows = (d && d.data && d.data.items) || d.items || []; + const thead = document.getElementById('thead'); + thead.innerHTML = '' + + o.columns.map(c => `${esc(c.label)}`).join('') + '操作'; + document.getElementById('sel-all').onchange = e => { + document.querySelectorAll('.row-sel').forEach(cb => { cb.checked = e.target.checked; }); + this.syncSel(); + }; + const tb = document.getElementById('tbody'); + tb.innerHTML = d.items.map(r => { + const tds = o.columns.map(c => `${c.cell(r)}`).join(''); + return `${tds} + + + + + + + `; + }).join('') || `暂无数据`; + tb.querySelectorAll('.row-sel').forEach(cb => cb.onchange = () => this.syncSel()); + document.getElementById('pager').innerHTML = pagerHtml(d.total, d.page, d.page_size, 'L.goto'); + this.selected.clear(); + this.syncBar(); + }, + goto(p) { this.page = p; this.reload().catch(e => toast(e.message, true)); }, + syncSel() { + this.selected = new Set([...document.querySelectorAll('.row-sel:checked')].map(cb => +cb.dataset.id)); + this.syncBar(); + }, + syncBar() { + const bar = document.getElementById('batchbar'); + bar.style.display = this.selected.size ? 'flex' : 'none'; + document.getElementById('sel-count').textContent = this.selected.size; + }, + items() { return [...this.selected].map(id => ({ entry_type: this.etype, entry_id: id })); }, + async toggleOne(id, enabled) { + if (enabled) { + // P11:手动恢复因配额停用的条目时提示确认(配额未重置前流量会计入,立刻超限) + const row = (this.rows || []).find(r => r.id === id); + if (row && row.disabled_reason === 'quota') { + const q = row.quota || {}; + const pct = q.quota_bytes ? Math.round(q.used_bytes * 100 / q.quota_bytes) : 100; + confirmDo( + `该条目因流量配额用尽被自动停用(已用 ${pct}%)。手动启用后若配额未重置或调大,会立即再次超限停用。建议先在"流量配额"页重置用量或调大配额。仍要手动启用吗?`, + async () => { + await api('POST', `${this.api}/${id}/toggle`, { enabled: true }); + toast('已启用'); + this.reload(); + }, '仍要启用'); + return; + } + } + await api('POST', `${this.api}/${id}/toggle`, { enabled: !!enabled }); + toast(enabled ? '已启用' : '已停用'); + this.reload(); + }, + // P11:配额设置弹窗 + async quotaForm(id) { + let q = null; + try { q = (await api('GET', `/api/entries/${this.etype}/${id}/quota`)).quota; } catch (e) { toast('加载配额失败:' + e.message, true); return; } + const cur = q && q.quota_bytes ? `${fmtBytes(q.used_bytes)} / ${fmtBytes(q.quota_bytes)}(${Math.round(q.used_bytes * 100 / q.quota_bytes)}%)` : '未设置'; + const row = (this.rows || []).find(r => r.id === id); + const name = row ? row.name : ('#' + id); + openModal(`配额 - ${esc(name)}`, ` +
${cur}
+
+
+
+
常用:1GB=${1073741824},10GB=${10737418240};仅落地代理(有监听地址)/端口转发/中转隧道的流量可计量。
+ ${q && q.quota_bytes ? '' : ''}`, + async () => { + const body = { + quota_bytes: parseInt(document.getElementById('qf-quota').value || '0'), + period: document.getElementById('qf-period').value, + reset_day: parseInt(document.getElementById('qf-day').value || '1'), + }; + try { + const r = await api('PUT', `/api/entries/${this.etype}/${id}/quota`, body); + toast(r.recovered ? '配额已更新,条目已恢复' : '配额已更新'); + this.reload(); + } catch (e) { toast('更新失败:' + e.message, true); } + }, '保存'); + const rb = document.getElementById('qf-reset'); + if (rb) rb.onclick = async () => { + try { + const r = await api('POST', `/api/entries/${this.etype}/${id}/quota/reset`); + toast(r.recovered ? '用量已重置,条目已恢复' : '用量已重置'); + closeModal(); this.reload(); + } catch (e) { toast('重置失败:' + e.message, true); } + }; + }, + async delOne(id) { + const d = await api('GET', `${this.api}/${id}`); + const refs = d.sub_refs || 0; + confirmDo(`确定删除「${d.name}」?${refs ? `该条目被 ${refs} 个订阅引用,删除后将从这些订阅中移除。` : ''}`, async () => { + await api('DELETE', `${this.api}/${id}`); + toast('已删除'); + this.reload(); + }, '删除'); + }, + openForm(id) { + const isNew = !id; + const load = isNew ? Promise.resolve(null) : api('GET', `${this.api}/${id}`); + load.then(item => { + openModal(isNew ? '新建' : '编辑', o.formHtml(item, isNew), async () => { + const payload = o.formData(isNew); + if (isNew) await api('POST', this.api, payload); + else await api('PUT', `${this.api}/${id}`, payload); + toast('已保存'); + this.reload(); + }, '保存'); + if (o.afterFormOpen) o.afterFormOpen(item, isNew); + }).catch(e => toast(e.message, true)); + }, + async quickSub(id) { + openModal('一键生成订阅', ` +
+
+
`, async () => { + const d = await api('POST', `/api/entries/${this.etype}/${id}/quick_subscription`, { + name: document.getElementById('qs-name').value, + expire_preset: document.getElementById('qs-preset').value, + }); + openModal('订阅已生成', `
+
${esc(d.url)}
+ `, () => {}, '关闭'); + }, '生成'); + }, + // ---- 批量 ---- + async bToggle(enabled) { + await api('POST', `${this.api}/batch_toggle`, { ids: [...this.selected], enabled }); + toast('已更新'); this.reload(); + }, + async bDelete() { + confirmDo(`确定删除选中的 ${this.selected.size} 个条目?关联的标签、配额、订阅引用将一并清理。`, async () => { + await api('POST', `${this.api}/batch_delete`, { ids: [...this.selected] }); + toast('已删除'); this.reload(); + }, '删除'); + }, + async bMove() { + const nid = document.getElementById('b-node').value; + if (!nid) return toast('请选择节点', true); + await api('POST', `${this.api}/batch_move`, { ids: [...this.selected], node_id: +nid }); + toast('已迁移'); this.reload(); + }, + async bSetGroup() { + const gid = document.getElementById('b-group').value; + await api('POST', '/api/entries/batch_set_group', { items: this.items(), group_id: gid ? +gid : null }); + toast('已设置'); this.reload(); + }, + async bAddTag() { + const tid = document.getElementById('b-tag').value; + if (!tid) return toast('请选择标签', true); + await api('POST', '/api/entries/batch_add_tags', { items: this.items(), tag_ids: [+tid] }); + toast('已添加'); this.reload(); + }, + async bRemoveTag() { + const tid = document.getElementById('b-tag').value; + if (!tid) return toast('请选择标签', true); + await api('POST', '/api/entries/batch_remove_tags', { items: this.items(), tag_ids: [+tid] }); + toast('已移除'); this.reload(); + }, + async bSetExpiry() { + const preset = document.getElementById('b-expiry').value; + await api('POST', '/api/entries/batch_set_expiry', { items: this.items(), expire_preset: preset }); + toast('已设置'); this.reload(); + }, + async bSetQuota() { + openModal('批量设置配额', ` +
+
+

不经过本系统的条目(如无监听地址的代理)会被跳过并提示。

`, async () => { + const gb = parseFloat(document.getElementById('bq-gb').value) || 0; + const d = await api('POST', '/api/entries/batch_set_quota', { + items: this.items(), quota_bytes: Math.round(gb * 1073741824), + period: document.getElementById('bq-period').value, + }); + toast(`已设置 ${d.ok} 个${d.skipped.length ? `,跳过 ${d.skipped.length} 个` : ''}`); + this.reload(); + }, '设置'); + }, + }; + window.L = L; + window.__entryPage = L; + return L; +} + +function commonFiltersHtml() { + const gopts = GT.groups.map(g => ``).join(''); + const topts = GT.tags.map(t => ``).join(''); + const nopts = GT.nodes.map(n => ``).join(''); + return ` + + + + + + `; +} + +function batchBarHtml() { + const nopts = GT.nodes.map(n => ``).join(''); + const gopts = GT.groups.map(g => ``).join(''); + const topts = GT.tags.map(t => ``).join(''); + return `已选 0 项 + + + + + + + + + + + + + `; +} + +function expirePresetHtml(sel) { + const opts = [['permanent', '永久'], ['7d', '7 天'], ['30d', '30 天'], ['90d', '90 天'], ['1y', '1 年']]; + return ``; +} +function nodeSelectHtml(selId, sel) { + return ``; +} +function groupSelectHtml(selId, sel) { + return ``; +} +function tagCheckHtml(selIds) { + const sel = new Set(selIds || []); + return GT.tags.map(t => ``).join('') || '暂无标签'; +} +function readTagIds() { + return [...document.querySelectorAll('.fm-tag:checked')].map(c => +c.value); +} diff --git a/templates/admin/account.html b/templates/admin/account.html new file mode 100644 index 0000000..a4fbd02 --- /dev/null +++ b/templates/admin/account.html @@ -0,0 +1,116 @@ +{% extends "base.html" %} +{% block title %}账户设置 - RustProxyAdmin{% endblock %} +{% block page_title %}账户设置{% endblock %} +{% block page_desc %}账户设置、多用户管理与密码修改{% endblock %} +{% block content %} +
+
+

修改密码

+
+
+
+ +

修改后其他设备的会话将立即失效。

+
+
+

修改用户名

+
+
+ +
+
+
+

会话管理

+ +

最近登录记录

+
时间IP设备
+
+{% if role == 'admin' %} +
+

用户管理 (viewer 为只读角色,所有写接口返回 403)

+
+ + + + + +
+
ID用户名角色创建时间操作
+
+{% endif %} + +{% endblock %} diff --git a/templates/admin/alerts.html b/templates/admin/alerts.html new file mode 100644 index 0000000..62a0e59 --- /dev/null +++ b/templates/admin/alerts.html @@ -0,0 +1,49 @@ +{% extends "base.html" %} +{% block title %}告警中心 - RustProxyAdmin{% endblock %} +{% block page_title %}告警中心{% endblock %} +{% block page_desc %}告警渠道、规则配置与事件历史{% endblock %} +{% block content %} +
+ + + +
+ +
+
+ + +
+
+ +
名称类型配置状态操作
+

⚠️ 渠道密钥明文存于数据库,请保证 data.db 权限为 600;备份文件请妥善保管。{% if role != 'viewer' %}如已在 config.toml [server] 配置 secret_key,新建/修改的渠道密钥将自动 AES-256-GCM 加密存储。{% endif %}

+
+ + + + +{% endblock %} +{% block script %} + + +{% endblock %} diff --git a/templates/admin/dashboard.html b/templates/admin/dashboard.html new file mode 100644 index 0000000..8f5b3bb --- /dev/null +++ b/templates/admin/dashboard.html @@ -0,0 +1,57 @@ +{% extends "base.html" %} +{% block title %}仪表盘 - RustProxyAdmin{% endblock %} +{% block page_title %}仪表盘{% endblock %} +{% block page_desc %}流量、节点与条目运行状态一览{% endblock %} +{% block content %} +
+
+
+

Top 条目 近 7 天流量

+ +
条目类型流量
+
+
+

提醒

+
+
+
+ +{% endblock %} diff --git a/templates/admin/forwards.html b/templates/admin/forwards.html new file mode 100644 index 0000000..9d10983 --- /dev/null +++ b/templates/admin/forwards.html @@ -0,0 +1,73 @@ +{% extends "base.html" %} +{% block title %}端口转发 - RustProxyAdmin{% endblock %} +{% block page_title %}端口转发{% endblock %} +{% block page_desc %}管理端口转发条目(TCP/UDP){% endblock %} +{% block content %} +
+ + + + +
+
+
+ + + +{% endblock %} diff --git a/templates/admin/groups.html b/templates/admin/groups.html new file mode 100644 index 0000000..017eff5 --- /dev/null +++ b/templates/admin/groups.html @@ -0,0 +1,78 @@ +{% extends "base.html" %} +{% block title %}分组管理 - RustProxyAdmin{% endblock %} +{% block page_title %}分组管理{% endblock %} +{% block page_desc %}分组与标签管理,用于订阅规则匹配{% endblock %} +{% block content %} +
+ + 拖拽排序、合并分组请在操作列中进行 +
+ + + +
排序名称颜色描述落地代理端口转发中转隧道操作
+ +{% endblock %} diff --git a/templates/admin/import.html b/templates/admin/import.html new file mode 100644 index 0000000..752d9a2 --- /dev/null +++ b/templates/admin/import.html @@ -0,0 +1,98 @@ +{% extends "base.html" %} +{% block title %}导入订阅 - RustProxyAdmin{% endblock %} +{% block page_title %}导入订阅{% endblock %} +{% block page_desc %}从外部订阅链接导入节点{% endblock %} +{% block content %} +
+

1. 获取订阅内容

+
+
+
+
+ + +
+ + + + + + + +{% endblock %} diff --git a/templates/admin/import_records.html b/templates/admin/import_records.html new file mode 100644 index 0000000..392a135 --- /dev/null +++ b/templates/admin/import_records.html @@ -0,0 +1,54 @@ +{% extends "base.html" %} +{% block title %}导入记录 - RustProxyAdmin{% endblock %} +{% block page_title %}导入记录{% endblock %} +{% block page_desc %}导入批次记录与明细{% endblock %} +{% block content %} + + + +
ID来源格式目标节点解析数导入数时间操作
+
+ +{% endblock %} diff --git a/templates/admin/logs_access.html b/templates/admin/logs_access.html new file mode 100644 index 0000000..ef6da67 --- /dev/null +++ b/templates/admin/logs_access.html @@ -0,0 +1,58 @@ +{% extends "base.html" %} +{% block title %}访问日志 - RustProxyAdmin{% endblock %} +{% block page_title %}访问日志{% endblock %} +{% block page_desc %}代理访问日志(连接与流量){% endblock %} +{% block content %} +
+ + + + + + + + +
+ + + +
ID节点类型条目客户端 IP开始结束上行下行状态
+
+ +{% endblock %} diff --git a/templates/admin/logs_audit.html b/templates/admin/logs_audit.html new file mode 100644 index 0000000..d4a9bbc --- /dev/null +++ b/templates/admin/logs_audit.html @@ -0,0 +1,60 @@ +{% extends "base.html" %} +{% block title %}操作日志 - RustProxyAdmin{% endblock %} +{% block page_title %}操作日志{% endblock %} +{% block page_desc %}操作审计日志(谁在何时做了什么){% endblock %} +{% block content %} +
+ + + + + + + +
+ + +
ID操作人动作资源IP详情时间
+
+ +{% endblock %} diff --git a/templates/admin/logs_subscription.html b/templates/admin/logs_subscription.html new file mode 100644 index 0000000..3eba923 --- /dev/null +++ b/templates/admin/logs_subscription.html @@ -0,0 +1,46 @@ +{% extends "base.html" %} +{% block title %}订阅访问日志 - RustProxyAdmin{% endblock %} +{% block page_title %}订阅访问日志{% endblock %} +{% block page_desc %}订阅链接访问日志{% endblock %} +{% block content %} +
+ + + + 订阅链接 404 时,到这里看真实原因 +
+ + +
ID订阅客户端 IP格式结果User-Agent时间
+
+ +{% endblock %} diff --git a/templates/admin/monitor_nodes.html b/templates/admin/monitor_nodes.html new file mode 100644 index 0000000..a84e738 --- /dev/null +++ b/templates/admin/monitor_nodes.html @@ -0,0 +1,56 @@ +{% extends "base.html" %} +{% block title %}节点监控 - RustProxyAdmin{% endblock %} +{% block page_title %}节点监控{% endblock %} +{% block page_desc %}节点实时状态监控{% endblock %} +{% block content %} +
+ +{% endblock %} diff --git a/templates/admin/monitor_rules.html b/templates/admin/monitor_rules.html new file mode 100644 index 0000000..1cb21cc --- /dev/null +++ b/templates/admin/monitor_rules.html @@ -0,0 +1,51 @@ +{% extends "base.html" %} +{% block title %}条目监控 - RustProxyAdmin{% endblock %} +{% block page_title %}条目监控{% endblock %} +{% block page_desc %}条目运行状态监控{% endblock %} +{% block content %} +
+ + + 每 15 秒自动刷新 · 速率 = 近 1 分钟均值 +
+ + + + +
条目类型启用运行状态实时连接近1分钟连接近1分钟上行近1分钟下行
+ +{% endblock %} diff --git a/templates/admin/nodes.html b/templates/admin/nodes.html new file mode 100644 index 0000000..c4f821e --- /dev/null +++ b/templates/admin/nodes.html @@ -0,0 +1,135 @@ +{% extends "base.html" %} +{% block title %}节点管理 - RustProxyAdmin{% endblock %} +{% block page_title %}节点管理{% endblock %} +{% block page_desc %}服务器节点管理与 Worker 配对{% endblock %} +{% block content %} +
+ + 本机节点不可删除;Worker token 仅在创建/重置时显示一次 +
+
+ + +
ID名称类型公网地址状态最近心跳版本负载 CPU/内存操作
+ + + + + + +{% endblock %} diff --git a/templates/admin/proxies.html b/templates/admin/proxies.html new file mode 100644 index 0000000..3cf66d2 --- /dev/null +++ b/templates/admin/proxies.html @@ -0,0 +1,76 @@ +{% extends "base.html" %} +{% block title %}落地代理 - RustProxyAdmin{% endblock %} +{% block page_title %}落地代理{% endblock %} +{% block page_desc %}管理落地代理条目(出站代理服务){% endblock %} +{% block content %} +
+ + + + +
+
+
+ + + +{% endblock %} diff --git a/templates/admin/quotas.html b/templates/admin/quotas.html new file mode 100644 index 0000000..3856a1e --- /dev/null +++ b/templates/admin/quotas.html @@ -0,0 +1,80 @@ +{% extends "base.html" %} +{% block title %}流量配额 - RustProxyAdmin{% endblock %} +{% block page_title %}流量配额{% endblock %} +{% block page_desc %}流量配额设置与用量总览{% endblock %} +{% block content %} +
+ + +
+
+
+ + +
条目类型周期用量 / 配额使用率状态操作
+
+{% endblock %} +{% block script %} + +{% endblock %} diff --git a/templates/admin/stats.html b/templates/admin/stats.html new file mode 100644 index 0000000..22ab004 --- /dev/null +++ b/templates/admin/stats.html @@ -0,0 +1,119 @@ +{% extends "base.html" %} +{% block title %}统计 - RustProxyAdmin{% endblock %} +{% block page_title %}统计{% endblock %} +{% block page_desc %}流量、连接与节点统计{% endblock %} +{% block content %} +
+ + + + + + +
+
+

流量趋势

+
+
+ 下行 + 上行 + 连接数 +
+
+
+
+

类型占比(近 7 天流量)

+
+
+
+

Top 条目(近 7 天)

+ +
条目类型连接数流量
+
+
+ +{% endblock %} diff --git a/templates/admin/subscriptions.html b/templates/admin/subscriptions.html new file mode 100644 index 0000000..6916706 --- /dev/null +++ b/templates/admin/subscriptions.html @@ -0,0 +1,280 @@ +{% extends "base.html" %} +{% block title %}订阅管理 - RustProxyAdmin{% endblock %} +{% block page_title %}订阅管理{% endblock %} +{% block page_desc %}订阅管理、分享链接与访问统计{% endblock %} +{% block content %} +
+ + + + {% if role != 'viewer' %}{% endif %} + 订阅地址形如:/sub/<token> +
+ + + +
名称范围默认格式节点数访问次数最后访问到期状态操作
+
+ +{% endblock %} diff --git a/templates/admin/tags.html b/templates/admin/tags.html new file mode 100644 index 0000000..4b27fa4 --- /dev/null +++ b/templates/admin/tags.html @@ -0,0 +1,63 @@ +{% extends "base.html" %} +{% block title %}标签管理 - RustProxyAdmin{% endblock %} +{% block page_title %}标签管理{% endblock %} +{% block page_desc %}标签管理{% endblock %} +{% block content %} +
+ + +
+ + +
名称颜色使用次数操作
+ +{% endblock %} diff --git a/templates/admin/templates.html b/templates/admin/templates.html new file mode 100644 index 0000000..9f0f7be --- /dev/null +++ b/templates/admin/templates.html @@ -0,0 +1,101 @@ +{% extends "base.html" %} +{% block title %}输出模板 - RustProxyAdmin{% endblock %} +{% block page_title %}输出模板{% endblock %} +{% block page_desc %}Clash / Sing-box 输出模板{% endblock %} +{% block content %} +
+ + 标记说明: + @ALL 全部节点 · + @TAG:名 · + @GROUP:名 · + @TYPE:proxy · + @REGEX:re + +
+ + +
名称格式类型被订阅引用更新时间操作
+ +{% endblock %} diff --git a/templates/admin/tunnels.html b/templates/admin/tunnels.html new file mode 100644 index 0000000..b83cd87 --- /dev/null +++ b/templates/admin/tunnels.html @@ -0,0 +1,77 @@ +{% extends "base.html" %} +{% block title %}中转隧道 - RustProxyAdmin{% endblock %} +{% block page_title %}中转隧道{% endblock %} +{% block page_desc %}管理中转隧道条目{% endblock %} +{% block content %} +
+ + + + +
+
+
+ + + +{% endblock %} diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..f2c43c2 --- /dev/null +++ b/templates/base.html @@ -0,0 +1,80 @@ + + + + + + +{% block title %}RustProxyAdmin{% endblock %} + + +{% block head %}{% endblock %} + + +{% if is_default_password %} +
⚠️ 正在使用默认初始密码,请立即前往 账户设置 修改。
+{% endif %} +
+ +
+
+ 控制台 + + {{ username }}
{{ role }}
+ ? + +
+
+
+
+
+

{% block page_title %}{% endblock %}

+

{% block page_desc %}{% endblock %}

+
+
{% block page_actions %}{% endblock %}
+
+ {% block content %}{% endblock %} +
+
+
+
+ + +{% block script %}{% endblock %} + + diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..c6dc112 --- /dev/null +++ b/templates/login.html @@ -0,0 +1,54 @@ + + + + + +登录 - RustProxyAdmin + + + + + + +