diff --git a/.gitignore b/.gitignore index db99c0c..70a73e3 100644 --- a/.gitignore +++ b/.gitignore @@ -41,9 +41,7 @@ release/ ../win-backup/ win-backup/ -# ---- 在建工程(用户 WIP,暂不入库;2026-10-07 保护性添加)---- -# 背景:发版脚本的 add -A 曾把这些未跟踪文件误提交到公开仓库,已移出跟踪。 -# 这些是皇上正在开发的 Rust 客户端与控制台方案,待其评审后再决定是否入库。 -/crates/ -/docs/console-plan.md -/docs/ROADMAP.md +# Rust 构建产物 +# ⚠️ 2026-10-07:crates/ 此前整体被忽略,target/ 是「顺带」没进库的;crates/ 正式入库后 +# 必须显式忽略,否则 4GB 级的 cargo target 会被 add -A 卷进提交。 +/crates/target/ diff --git a/crates/Cargo.lock b/crates/Cargo.lock new file mode 100644 index 0000000..fd93db1 --- /dev/null +++ b/crates/Cargo.lock @@ -0,0 +1,1185 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[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.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 = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f74872d07caf508b30a21f6836e7d7016a2eaf7d9ff4f48deaa58cd8a0407630" +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 = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures", + "rand_core", +] + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[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 = "ctutils" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03bb0e1cc970d482d121d9a1744999169b69a07470b3d644a7894e53fcaf4574" +dependencies = [ + "cmov", +] + +[[package]] +name = "curve25519-dalek" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5eed333089e2e1c1ac8c6c0398e5e2497b4c9926ca6d0365ed1e099afa5bc23" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[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.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a878c850e9e421b20262e9b41f9c860e4785fa07541c266b62ff9d1ef998a80a" +dependencies = [ + "const-oid", + "zeroize", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "ctutils", +] + +[[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 = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "ed25519" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ebaa1a2bf1290ab3bfe5a7b771d050ebffab2711c19a81691c683a5144a25de" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2", + "signature", + "subtle", + "zeroize", +] + +[[package]] +name = "fiat-crypto" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24" + +[[package]] +name = "find-msvc-tools" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + +[[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-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[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-macro", + "futures-sink", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "rand_core", +] + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hybrid-array" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" +dependencies = [ + "typenum", +] + +[[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 = "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 = "libc" +version = "0.2.190" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mio" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nodeagent-client" +version = "0.1.0" +dependencies = [ + "base64", + "ed25519-dalek", + "futures-util", + "hmac", + "rand", + "rustls", + "rustls-pemfile", + "serde", + "serde_json", + "sha2", + "thiserror", + "tokio", + "tokio-tungstenite", + "url", + "webpki-roots 1.0.9", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkcs8" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" +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 = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[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 = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[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 = "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", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "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 = "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 = "sha1" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signature" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" + +[[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 = "spki" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef958a98b9d5da290cfc78946e9f3e61e1e62a18db0d92cac0b83cc161491a9" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[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 = "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 = "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 = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.53.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "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-tungstenite" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17a073bfed563fa236697a068031408a93cd9522e08abf9933ead3e73411bd71" +dependencies = [ + "futures-util", + "log", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tungstenite", + "webpki-roots 0.26.11", +] + +[[package]] +name = "tungstenite" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e48ac77174b19c110a50ab2128b24215ac9cb40e0e12e093fb602d175c569d22" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand", + "rustls", + "rustls-pki-types", + "sha1", + "thiserror", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[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 = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[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 = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[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.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[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.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[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.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[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.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[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.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", + "synstructure", +] + +[[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 = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/crates/Cargo.toml b/crates/Cargo.toml new file mode 100644 index 0000000..7502396 --- /dev/null +++ b/crates/Cargo.toml @@ -0,0 +1,5 @@ +[workspace] +resolver = "2" +members = ["nodeagent-client"] + +[workspace.dependencies] diff --git a/crates/nodeagent-client/Cargo.toml b/crates/nodeagent-client/Cargo.toml new file mode 100644 index 0000000..5792be9 --- /dev/null +++ b/crates/nodeagent-client/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "nodeagent-client" +version = "0.1.0" +edition = "2021" +description = "nodeagent 控制端 Rust 客户端(行为对齐 @nodeagent/client)" +license = "MIT" + +[dependencies] +base64 = "0.23.1" +ed25519-dalek = { version = "3.0.0", features = ["pkcs8", "alloc"] } +futures-util = "0.3.34" +hmac = "0.13.0" +rand = "0.10.3" +rustls = "0.23.45" +rustls-pemfile = "2.2.0" +serde = { version = "1.0.229", features = ["derive"] } +serde_json = "1.0.151" +sha2 = "0.11.0" +thiserror = "2.0.21" +tokio = { version = "1.53.2", features = ["rt-multi-thread", "net", "time", "sync", "macros", "io-util"] } +tokio-tungstenite = { version = "0.30.0", features = ["rustls-tls-webpki-roots"] } +url = "2.5.8" +webpki-roots = "1.0.9" diff --git a/crates/nodeagent-client/examples/connect.rs b/crates/nodeagent-client/examples/connect.rs new file mode 100644 index 0000000..c60ccf3 --- /dev/null +++ b/crates/nodeagent-client/examples/connect.rs @@ -0,0 +1,53 @@ +//! 连接一个真实 nodeagent 被控端的最小示例。 +//! +//! 用法:`cargo run --example connect -- [client_id]` +//! 例如:`cargo run --example connect -- ws://127.0.0.1:8765 test-psk-key` + +use nodeagent_client::{Client, ClientEvent, ClientOptions}; +use serde_json::json; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let args: Vec = std::env::args().collect(); + let url = args + .get(1) + .cloned() + .unwrap_or_else(|| "ws://127.0.0.1:8765".to_string()); + let key = args.get(2).cloned().unwrap_or_default(); + let client_id = args + .get(3) + .cloned() + .unwrap_or_else(|| "rust-example".to_string()); + + let client = Client::new(ClientOptions { + url: url.clone(), + key, + client_id, + insecure: true, + auto_reconnect: false, + ..Default::default() + }); + + client.set_handler(std::sync::Arc::new(|evt| { + if let ClientEvent::Log(msg) = evt { + println!("[log] {msg}"); + } + })); + + let caps = client.connect().await?; + println!("握手成功,{} 项能力", caps.len()); + println!("agent 版本: {:?}", client.agent_version()); + println!("证书指纹: {:?}", client.peer_cert_fingerprint()); + for c in caps.iter().take(12) { + println!(" - {} (risk={}, v{})", c.name, c.risk, c.version); + } + + let res = client.invoke("system.info", json!({})).await?; + println!("\nsystem.info → status={}", res.status); + if let Some(data) = &res.data { + println!("{}", serde_json::to_string_pretty(data)?); + } + + client.close(); + Ok(()) +} diff --git a/crates/nodeagent-client/examples/probe.rs b/crates/nodeagent-client/examples/probe.rs new file mode 100644 index 0000000..6526936 --- /dev/null +++ b/crates/nodeagent-client/examples/probe.rs @@ -0,0 +1,188 @@ +//! M3 能力探针:对真实被控端逐项调用「文件 / 审计 / 软件 / 进程服务」能力,核对契约。 +//! +//! 输出**字段契约**而非数据预览——顶层键 + 数组首元素的键, +//! 便于与前端 `apps/console/src/types/index.ts` 的类型声明逐项比对。 +//! +//! 用法:`cargo run --example probe -- [base_dir] [--full]` +//! --full 额外打印完整 JSON(长字段截断到 400 字符) + +use nodeagent_client::{Client, ClientOptions}; +use serde_json::{json, Value}; + +/// 一行对象 → `a, b, c` 形式的键清单。 +fn keys_of(v: &Value) -> String { + match v { + Value::Object(m) => m.keys().cloned().collect::>().join(", "), + other => format!("<{}>", type_name(other)), + } +} + +fn type_name(v: &Value) -> &'static str { + match v { + Value::Null => "null", + Value::Bool(_) => "bool", + Value::Number(_) => "number", + Value::String(_) => "string", + Value::Array(_) => "array", + Value::Object(_) => "object", + } +} + +/// 打印「字段契约」:顶层键 + 各数组字段的首元素键。 +fn contract(v: &Value, indent: &str) { + if let Value::Object(map) = v { + for (k, val) in map { + match val { + Value::Array(items) if !items.is_empty() => { + println!("{indent}{k}: array[{}] → {{ {} }}", items.len(), keys_of(&items[0])); + } + other if other.is_object() => { + println!("{indent}{k}: object → {{ {} }}", keys_of(other)); + } + other => { + let shown = match other { + Value::String(s) if s.chars().count() > 60 => { + format!("string(\"{}…\")", s.chars().take(60).collect::()) + } + Value::Array(a) => format!("array[{}]", a.len()), + _ => format!("{}({other})", type_name(other)), + }; + println!("{indent}{k}: {shown}"); + } + } + } + } else { + println!("{indent}{}", type_name(v)); + } +} + +async fn call(client: &Client, cap: &str, args: Value, full: bool) -> bool { + match client.invoke(cap, args).await { + Ok(r) if r.status == "ok" => { + println!(" ✓ {cap}"); + if let Some(d) = &r.data { + contract(d, " "); + if full { + let s = serde_json::to_string_pretty(d).unwrap_or_default(); + let s: String = s.chars().take(400).collect(); + println!(" ── full ── {s}"); + } + } + true + } + Ok(r) => { + let e = r.error.unwrap_or_default(); + println!(" ✗ {cap}: [{}] {}", e.name, e.message); + false + } + Err(e) => { + println!(" ✗ {cap}: {e}"); + false + } + } +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + let argv: Vec = std::env::args().skip(1).collect(); + let full = argv.iter().any(|a| a == "--full"); + let pos: Vec = argv.into_iter().filter(|a| !a.starts_with("--")).collect(); + + let url = pos.first().cloned().unwrap_or_else(|| "ws://127.0.0.1:8765".to_string()); + let key = pos.get(1).cloned().unwrap_or_default(); + let base = pos.get(2).cloned().unwrap_or_else(|| "/tmp/nodeagent-e2e".to_string()); + + let client = Client::new(ClientOptions { + url, + key, + client_id: "probe".to_string(), + insecure: true, + ..Default::default() + }); + + let caps = client.connect().await?; + println!("连接成功,{} 项能力", caps.len()); + + // 平台探测——决定 platform-only 能力是否预期可用。 + let info = client.invoke("system.info", json!({})).await?; + let os = info + .data + .as_ref() + .and_then(|d| d.get("os")) + .and_then(|v| v.as_str()) + .unwrap_or("?") + .to_string(); + let is_windows = os == "Windows"; + println!("被控端平台:{os}(platform-only 能力{}预期可用)\n", if is_windows { "" } else { "不" }); + + let mut pass = 0usize; + let mut fail = 0usize; + + println!("【文件 fs.*】"); + for (cap, args) in [ + ("fs.list", json!({ "path": base })), + ("fs.stat", json!({ "path": format!("{base}/agent.json") })), + ("fs.read", json!({ "path": format!("{base}/agent.json"), "max_bytes": 2048 })), + ( + "fs.write", + json!({ "path": format!("{base}/probe.txt"), "data": "hello from rust probe", "append": false, "create_dirs": true }), + ), + ] { + if call(&client, cap, args, full).await { + pass += 1 + } else { + fail += 1 + } + } + + println!("\n【审计 system.audit.*】"); + for (cap, args) in [ + ("system.audit.list", json!({ "limit": 5 })), + ("system.audit.verify", json!({})), + ] { + if call(&client, cap, args, full).await { + pass += 1 + } else { + fail += 1 + } + } + + println!("\n【软件 / 进程 / 服务】"); + let platform_only = ["app.list", "app.install", "system.service.list"]; + for (cap, args) in [ + ("app.list", json!({})), + ("app.install", json!({ "package": "__nonexistent_probe__", "silent": true })), + ("system.process.list", json!({ "limit": 5 })), + ("system.service.list", json!({ "limit": 5 })), + ] { + let ok = call(&client, cap, args, full).await; + // platform-only 能力在非 Windows 被控端上失败属**预期**,不计入失败。 + if ok { + pass += 1; + } else if !is_windows && platform_only.contains(&cap) { + println!(" ↑ 非 Windows 被控端,属预期"); + } else { + fail += 1; + } + } + + println!("\n【其他页面依赖】"); + for (cap, args) in [ + ("system.info", json!({})), + ("system.status", json!({})), + ("system.shell.exec", json!({ "command": "echo probe-ok" })), + ] { + if call(&client, cap, args, full).await { + pass += 1 + } else { + fail += 1 + } + } + + println!("\n═══ 通过 {pass} · 失败 {fail} ═══"); + client.close(); + if fail > 0 { + std::process::exit(1); + } + Ok(()) +} diff --git a/crates/nodeagent-client/src/client.rs b/crates/nodeagent-client/src/client.rs new file mode 100644 index 0000000..b5e8538 --- /dev/null +++ b/crates/nodeagent-client/src/client.rs @@ -0,0 +1,628 @@ +//! 控制端客户端主体(对齐 `packages/client/src/client.ts` 的 `NodeAgentClient`)。 +//! +//! 复刻行为:连接 → 握手(hello → challenge → auth → auth_ok)→ 泛型能力调用 → +//! 事件通知(无 id 的 `event` 方法)→ 断线指数退避重连(1s → 30s,±15% 抖动)。 +//! +//! 并发结构(监督者模式): +//! - `connect()` 派生一个 `supervise` 任务,串行「建立+握手 → 等读循环断开信号 → 退避重连」。 +//! - `establish()` 负责单次连接 + 握手,并派生**读循环**与**写任务**两个子任务。 +//! - 读循环在流结束时只 `notify` 监督者、绝不回调 `establish`——从而打破 +//! `establish → spawn(read_loop) → establish` 的类型级递归,让每个 future 都可证 `Send`。 + +use std::collections::HashMap; +use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; +use std::sync::{Arc, Mutex, RwLock}; +use std::time::Duration; + +use futures_util::stream::{SplitSink, SplitStream}; +use futures_util::{SinkExt, StreamExt}; +use serde_json::{json, Value}; +use tokio::sync::{mpsc, oneshot, Notify}; +use tokio_tungstenite::tungstenite::Message; + +use crate::crypto::{compute_hmac, sign_nonce}; +use crate::error::{ClientError, ErrorCodes}; +use crate::tls; +use crate::types::*; +use crate::ulid::ulid; + +/// WebSocket 流类型(TLS 下为 `MaybeTlsStream`)。 +type WsStream = + tokio_tungstenite::WebSocketStream>; +type WsSink = SplitSink; +type WsReader = SplitStream; + +/// 控制面方法名(对齐 `protocol/src/methods.ts` 的 `Methods`)。 +const M_HELLO: &str = "hello"; +const M_AUTH: &str = "auth"; +const M_INVOKE: &str = "invoke"; +const M_EVENT: &str = "event"; + +/// 连接状态。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConnState { + Connected, + Reconnecting, + Closed, +} + +/// 对外事件:连接状态变化 / 被控端主动事件 / 日志。 +#[derive(Debug, Clone)] +pub enum ClientEvent { + State(ConnState), + Event(Value), + Log(String), +} + +/// 事件回调(`Arc` 便于共享给读循环)。 +pub type EventHandler = Arc; + +/// 认证模式。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AuthMode { + Psk, + Ed25519, +} + +/// 客户端选项。 +#[derive(Clone)] +pub struct ClientOptions { + /// `ws://` 或 `wss://` 地址。 + pub url: String, + /// psk 预共享密钥。 + pub key: String, + /// 控制端身份标识。 + pub client_id: String, + /// 跳过自签证书校验(TLS 仍加密)。 + pub insecure: bool, + /// 钉住的被控端证书指纹(sha256 hex,64 位)。 + pub cert_sha256: Option, + /// 认证模式。 + pub auth_mode: AuthMode, + /// ed25519 模式:私钥(Base64 PKCS8 DER)。 + pub private_key: Option, + /// 握手超时(默认 10s)。 + pub handshake_timeout_ms: u64, + /// 单次调用默认超时(默认 60s)。 + pub default_timeout_ms: u64, + /// 断线后自动重连。 + pub auto_reconnect: bool, + /// 重连退避上限(默认 30s)。 + pub max_reconnect_delay_ms: u64, +} + +impl Default for ClientOptions { + fn default() -> Self { + Self { + url: String::new(), + key: String::new(), + client_id: String::new(), + insecure: false, + cert_sha256: None, + auth_mode: AuthMode::Psk, + private_key: None, + handshake_timeout_ms: 10_000, + default_timeout_ms: 60_000, + auto_reconnect: false, + max_reconnect_delay_ms: 30_000, + } + } +} + +/// 连接元信息(能力清单 / 授权 / 指纹 / 版本)。 +#[derive(Default, Clone)] +struct Meta { + capabilities: Vec, + authorized: Option>, + peer_cert_fp: Option, + agent_version: Option, + agent_build: Option, +} + +/// 跨任务共享的可变状态。 +/// +/// 关键约束:`Client` 必须 `Sync`(多处 `&self` 跨 await)。这里的可变字段一律用 +/// `std` 的 `Mutex`/`RwLock`(天然 `Send + Sync`),并保证**不在持锁期间 await**: +/// 发送半部分通过「专用写任务 + mpsc 通道」解耦,`pending` 的增删改全是同步短临界区。 +struct Shared { + /// 到「写任务」的通道;写任务独占 sink 并转发消息(发送不跨 await 持锁)。 + write: RwLock>>, + /// 待响应映射(id → oneshot 发送端);同步锁即可。 + pending: Mutex>>>, + /// 读循环在流结束时通知监督者(「连接已断开」信号)。 + disconnected: Notify, + meta: RwLock, + state: RwLock, + reconnect_attempts: AtomicU32, + stopped: AtomicBool, + handler: RwLock>, +} + +/// 控制端客户端。 +#[derive(Clone)] +pub struct Client { + opts: Arc, + shared: Arc, +} + +impl Client { + pub fn new(opts: ClientOptions) -> Self { + Self { + opts: Arc::new(opts), + shared: Arc::new(Shared { + write: RwLock::new(None), + pending: Mutex::new(HashMap::new()), + disconnected: Notify::new(), + meta: RwLock::new(Meta::default()), + state: RwLock::new(ConnState::Closed), + reconnect_attempts: AtomicU32::new(0), + stopped: AtomicBool::new(false), + handler: RwLock::new(None), + }), + } + } + + /// 设置事件回调。 + pub fn set_handler(&self, handler: EventHandler) { + *self.shared.handler.write().unwrap() = Some(handler); + } + + /// 建立连接并完成握手,返回能力清单。 + /// 派生监督任务后,仅等待**首次**握手结果(失败即返回错误,不自动重连)。 + pub async fn connect(&self) -> Result, ClientError> { + self.shared.stopped.store(false, Ordering::SeqCst); + + let (tx, rx) = oneshot::channel(); + let this = self.clone(); + tokio::spawn(async move { + this.supervise(Some(tx)).await; + }); + + match rx.await { + Ok(result) => result, + Err(_) => Err(ClientError::offline("连接监督任务异常退出")), + } + } + + /// 调用一项能力(`args` 为 JSON 对象),用默认超时。 + pub async fn invoke( + &self, + capability: &str, + args: Value, + ) -> Result { + self.invoke_with_timeout(capability, args, self.opts.default_timeout_ms) + .await + } + + /// 调用一项能力并显式指定 RPC 超时(长任务如 `app.install` / 长命令用)。 + pub async fn invoke_with_timeout( + &self, + capability: &str, + args: Value, + timeout_ms: u64, + ) -> Result { + let params = json!({ "capability": capability, "args": args }); + let value = self.request(M_INVOKE, params, timeout_ms).await?; + serde_json::from_value::(value) + .map_err(|e| ClientError::new(ErrorCodes::PARSE_ERROR, format!("解析调用结果失败: {e}"))) + } + + /// 已获取的能力清单。 + pub fn list_capabilities(&self) -> Vec { + self.shared.meta.read().unwrap().capabilities.clone() + } + + /// ed25519 模式下本次被授权的能力;psk 模式返回 `None`。 + pub fn list_authorized(&self) -> Option> { + self.shared.meta.read().unwrap().authorized.clone() + } + + /// 本次连接观测到的对端证书指纹(sha256 hex)。 + pub fn peer_cert_fingerprint(&self) -> Option { + self.shared.meta.read().unwrap().peer_cert_fp.clone() + } + + /// 被控端语义化版本。 + pub fn agent_version(&self) -> Option { + self.shared.meta.read().unwrap().agent_version.clone() + } + + /// 被控端构建信息。 + pub fn agent_build(&self) -> Option { + self.shared.meta.read().unwrap().agent_build.clone() + } + + /// 当前连接状态。 + pub fn state(&self) -> ConnState { + *self.shared.state.read().unwrap() + } + + /// 主动关闭连接(停止自动重连)。 + pub fn close(&self) { + self.shared.stopped.store(true, Ordering::SeqCst); + // 丢弃发送通道 → 写任务退出 → sink 释放 → 连接关闭;并唤醒监督者及时退出。 + *self.shared.write.write().unwrap() = None; + self.shared.disconnected.notify_one(); + self.set_state(ConnState::Closed); + self.emit_log("主动关闭"); + } + + // ---------- 内部 ---------- + + /// 监督循环:反复「建立+握手 → 等断开 → 退避重连」。 + /// 首次握手结果经 `first_tx` 回传给 `connect()`;首次失败直接返回,不重连。 + async fn supervise( + &self, + first_tx: Option, ClientError>>>, + ) { + let mut first_tx = first_tx; + + loop { + let established = match self.establish().await { + Ok(caps) => { + self.shared.reconnect_attempts.store(0, Ordering::SeqCst); + self.set_state(ConnState::Connected); + // 首次成功:把能力清单回传给 `connect()` 调用方。 + if let Some(tx) = first_tx.take() { + let _ = tx.send(Ok(caps)); + } + true + } + Err(e) => { + // 首次失败:直接报错返回(不做自动重连,对齐 Node 行为)。 + if let Some(tx) = first_tx.take() { + let _ = tx.send(Err(e)); + return; + } + self.emit_log(format!("重连失败: {e}")); + false + } + }; + + // 本次建立成功才有读循环会发「断开」信号;失败则直接进入退避。 + if established { + self.shared.disconnected.notified().await; + } + + if self.shared.stopped.load(Ordering::SeqCst) { + self.set_state(ConnState::Closed); + return; + } + if !self.opts.auto_reconnect { + self.set_state(ConnState::Closed); + return; + } + + let delay = self.next_backoff_delay(); + self.set_state(ConnState::Reconnecting); + self.emit_log(format!("将在 {}ms 后重连", delay.as_millis())); + tokio::time::sleep(delay).await; + + if self.shared.stopped.load(Ordering::SeqCst) { + self.set_state(ConnState::Closed); + return; + } + } + } + + /// 建立一次连接:TCP/TLS/WS → split → 读循环 + 写任务 → 协议握手。 + async fn establish(&self) -> Result, ClientError> { + // TLS 下抓取对端证书指纹(自签 + insecure 时留下的中间人空档,靠钉住兜底)。 + let captured: Arc>> = Arc::new(Mutex::new(None)); + + let (ws, _resp) = if self.opts.url.starts_with("wss://") { + let config = tls::build_client_config( + self.opts.insecure, + self.opts.cert_sha256.clone(), + captured.clone(), + )?; + let connector = tokio_tungstenite::Connector::Rustls(Arc::new(config)); + tokio_tungstenite::connect_async_tls_with_config( + self.opts.url.as_str(), + None, + false, + Some(connector), + ) + .await + .map_err(|e| self.map_connect_error(e))? + } else { + tokio_tungstenite::connect_async(self.opts.url.as_str()) + .await + .map_err(|e| self.map_connect_error(e))? + }; + + let peer_fp = captured.lock().unwrap().clone(); + + let (sink, stream) = ws.split(); + let (tx, rx) = mpsc::unbounded_channel(); + *self.shared.write.write().unwrap() = Some(tx); + + // 写任务独占 sink;读循环独立跑;二者都只持有局部 Send 类型。 + tokio::spawn(async move { writer_task(sink, rx).await }); + let this = self.clone(); + tokio::spawn(async move { + this.read_loop(stream).await; + }); + + // 握手;失败则关闭连接(丢弃发送通道 → 写任务退出 → sink 释放 → 读循环结束)。 + let result = self.handshake(peer_fp).await; + if result.is_err() { + *self.shared.write.write().unwrap() = None; + } + result + } + + /// 握手:hello → challenge → auth → auth_ok;成功后写回元信息。 + async fn handshake(&self, peer_fp: Option) -> Result, ClientError> { + let challenge: ChallengeParams = { + let value = self + .request( + M_HELLO, + json!({ "protocol": PROTOCOL_VERSION, "client_id": self.opts.client_id }), + self.opts.handshake_timeout_ms, + ) + .await?; + serde_json::from_value(value).map_err(|e| { + ClientError::new(ErrorCodes::PARSE_ERROR, format!("解析 challenge 失败: {e}")) + })? + }; + + let auth_params = match self.opts.auth_mode { + AuthMode::Ed25519 => { + let private_key = self.opts.private_key.as_deref().ok_or_else(|| { + ClientError::new(ErrorCodes::AUTH_FAILED, "ed25519 模式需要私钥,请先 keygen") + })?; + let signature = sign_nonce(private_key, &challenge.nonce).map_err(|e| { + ClientError::new(ErrorCodes::AUTH_FAILED, format!("签名失败: {e}")) + })?; + json!({ "client_id": self.opts.client_id, "nonce": challenge.nonce, "signature": signature }) + } + AuthMode::Psk => { + let hmac = compute_hmac(&self.opts.key, &challenge.nonce); + json!({ "client_id": self.opts.client_id, "nonce": challenge.nonce, "hmac": hmac }) + } + }; + + let auth_ok: AuthOkParams = { + let value = self + .request(M_AUTH, auth_params, self.opts.handshake_timeout_ms) + .await?; + serde_json::from_value(value).map_err(|e| { + ClientError::new(ErrorCodes::PARSE_ERROR, format!("解析 auth_ok 失败: {e}")) + })? + }; + + // v21 交叉确认:握手抓到的实际证书 vs 服务端自报 —— 不一致说明自报被篡改/冒充。 + if let (Some(fp), Some(self_reported)) = ( + peer_fp.as_ref(), + auth_ok + .build + .as_ref() + .and_then(|b| b.cert_sha256.as_ref()), + ) { + if fp != self_reported { + return Err(ClientError::cert_mismatch( + "被控端自报的证书指纹与实际握手证书不一致,拒绝使用(可能被冒充)", + )); + } + } + + let capabilities = auth_ok.capabilities.clone(); + let authorized_len = auth_ok.authorized.as_ref().map(|a| a.len()); + { + let mut meta = self.shared.meta.write().unwrap(); + meta.capabilities = auth_ok.capabilities.clone(); + meta.authorized = auth_ok.authorized.clone(); + meta.peer_cert_fp = peer_fp; + meta.agent_version = auth_ok.agent_version.clone(); + meta.agent_build = auth_ok.build.clone(); + } + + let capabilities_len = capabilities.len(); + let label = if auth_ok.auth_mode.as_deref() == Some("ed25519") { + format!("ed25519,授权 {}/{capabilities_len} 项能力", authorized_len.unwrap_or(0)) + } else { + format!("psk,被控端声明 {capabilities_len} 项能力") + }; + self.emit_log(format!("握手成功({label})")); + + Ok(capabilities) + } + + /// 底层请求(带超时与 id 匹配)。 + async fn request( + &self, + method: &str, + params: Value, + timeout_ms: u64, + ) -> Result { + let id = ulid(); + let (tx, rx) = oneshot::channel(); + + self.shared.pending.lock().unwrap().insert(id.clone(), tx); + + let req = RpcRequest { + jsonrpc: "2.0".to_string(), + id: id.clone(), + method: method.to_string(), + params: Some(params), + }; + let payload = serde_json::to_string(&req) + .map_err(|e| ClientError::new(ErrorCodes::PARSE_ERROR, e.to_string()))?; + + { + let sender = self + .shared + .write + .read() + .unwrap() + .as_ref() + .cloned() + .ok_or_else(|| ClientError::offline("连接未就绪"))?; + // 同步发送(不持锁 await):消息经通道转交写任务。 + sender + .send(Message::Text(payload.into())) + .map_err(|e| ClientError::offline(format!("发送失败: {e}")))?; + } + + match tokio::time::timeout(Duration::from_millis(timeout_ms), rx).await { + Err(_) => { + self.shared.pending.lock().unwrap().remove(&id); + Err(ClientError::timeout(format!( + "调用超时 ({timeout_ms}ms): {method}" + ))) + } + Ok(Err(_)) => Err(ClientError::offline("连接已断开")), + Ok(Ok(result)) => result, + } + } + + /// 读循环:分发响应与事件;流结束后清空 pending 并通知监督者。 + async fn read_loop(&self, mut stream: WsReader) { + while let Some(Ok(msg)) = stream.next().await { + if let Err(e) = self.handle_message(msg).await { + self.emit_log(format!("消息处理失败: {e}")); + } + } + // 流结束(对端关闭 / 网络断开)→ 清空 pending,由监督者决定是否重连。 + self.reject_all_pending(); + self.shared.disconnected.notify_one(); + } + + /// 处理单条消息。 + async fn handle_message(&self, msg: Message) -> Result<(), ClientError> { + // WebSocket ping 帧:回 pong(保持连接)。 + if msg.is_ping() { + let pong = msg.into_data(); + if let Some(sender) = self.shared.write.read().unwrap().as_ref() { + let _ = sender.send(Message::Pong(pong)); + } + return Ok(()); + } + if !msg.is_text() && !msg.is_binary() { + return Ok(()); + } + + let text = match msg { + Message::Text(t) => t.to_string(), + Message::Binary(b) => match String::from_utf8(b.to_vec()) { + Ok(s) => s, + Err(_) => return Ok(()), + }, + _ => return Ok(()), + }; + + let value: Value = match serde_json::from_str(&text) { + Ok(v) => v, + Err(_) => { + self.emit_log(format!("收到无法解析的消息: {}", &text[..text.len().min(200)])); + return Ok(()); + } + }; + + // 有 id → 响应;无 id → 通知(事件)。 + if let Some(id) = value.get("id").and_then(|v| v.as_str()) { + if let Some(tx) = self.shared.pending.lock().unwrap().remove(id) { + let result = self.parse_response(&value); + let _ = tx.send(result); + } + return Ok(()); + } + + let method = value.get("method").and_then(|m| m.as_str()).unwrap_or(""); + if method == M_EVENT { + if let Some(params) = value.get("params").cloned() { + self.emit_event(params); + } + } + Ok(()) + } + + /// 把响应消息解析为 `Result`。 + fn parse_response(&self, value: &Value) -> Result { + if is_error_response(value) { + let err = value.get("error").cloned().unwrap_or(Value::Null); + let code = err + .get("code") + .and_then(|c| c.as_i64()) + .unwrap_or(ErrorCodes::PARSE_ERROR); + let name = err + .get("name") + .and_then(|n| n.as_str()) + .unwrap_or("E_UNKNOWN") + .to_string(); + let message = err + .get("message") + .and_then(|m| m.as_str()) + .unwrap_or("") + .to_string(); + Err(ClientError { + code, + name, + message, + data: err.get("data").cloned(), + }) + } else { + value + .get("result") + .cloned() + .ok_or_else(|| ClientError::new(ErrorCodes::PARSE_ERROR, "响应缺少 result 字段")) + } + } + + /// 指数退避 + ±15% 抖动(1s → 30s)。 + fn next_backoff_delay(&self) -> Duration { + let attempts = self.shared.reconnect_attempts.fetch_add(1, Ordering::SeqCst); + let base = (1000u64 << attempts).min(self.opts.max_reconnect_delay_ms); + let jitter = 0.85 + rand::random::() * 0.3; // 0.85..=1.15 + Duration::from_millis((base as f64 * jitter) as u64) + } + + fn reject_all_pending(&self) { + let drained: Vec>> = { + let mut map = self.shared.pending.lock().unwrap(); + map.drain().map(|(_, tx)| tx).collect() + }; + for tx in drained { + let _ = tx.send(Err(ClientError::offline("连接已断开"))); + } + } + + fn map_connect_error(&self, e: tokio_tungstenite::tungstenite::Error) -> ClientError { + let msg = e.to_string(); + if msg.contains("证书指纹不匹配") { + ClientError::cert_mismatch(msg) + } else { + ClientError::offline(format!("无法连接被控端: {msg}")) + } + } + + fn set_state(&self, state: ConnState) { + *self.shared.state.write().unwrap() = state; + if let Some(h) = self.shared.handler.read().unwrap().as_ref() { + h(ClientEvent::State(state)); + } + } + + fn emit_event(&self, params: Value) { + if let Some(h) = self.shared.handler.read().unwrap().as_ref() { + h(ClientEvent::Event(params)); + } + } + + fn emit_log(&self, msg: impl Into) { + if let Some(h) = self.shared.handler.read().unwrap().as_ref() { + h(ClientEvent::Log(msg.into())); + } + } +} + +/// 写任务:独占 sink,把 mpsc 通道里的消息逐条发出去。 +/// 通道关闭(`close()` 丢弃 sender)或发送失败时退出,sink 随之释放。 +async fn writer_task(mut sink: WsSink, mut rx: mpsc::UnboundedReceiver) { + while let Some(msg) = rx.recv().await { + if sink.send(msg).await.is_err() { + break; + } + } +} diff --git a/crates/nodeagent-client/src/crypto.rs b/crates/nodeagent-client/src/crypto.rs new file mode 100644 index 0000000..d468be0 --- /dev/null +++ b/crates/nodeagent-client/src/crypto.rs @@ -0,0 +1,131 @@ +//! 密码学工具:HMAC / ed25519 签名 / 证书指纹 / nonce。 +//! 逐一对齐 `packages/protocol/src/{hmac,ed25519}.ts` 与 `net.ts`。 + +use base64::engine::general_purpose::STANDARD; +use base64::Engine as _; +use hmac::{Hmac, KeyInit, Mac}; +use sha2::{Digest, Sha256}; + +type HmacSha256 = Hmac; + +/// 计算 HMAC-SHA256,返回 Base64(对齐 hmac.ts 的 `computeHmac`)。 +/// 密钥为预共享密钥字符串,nonce 以 UTF-8 编码。 +pub fn compute_hmac(key: &str, nonce: &str) -> String { + // HMAC 接受任意长度 key,此处不可能失败,unwrap 仅为类型收敛。 + let mut mac = HmacSha256::new_from_slice(key.as_bytes()).expect("HMAC 接受任意长度 key"); + mac.update(nonce.as_bytes()); + STANDARD.encode(mac.finalize().into_bytes()) +} + +/// 生成随机 nonce(32 字节,Base64),对齐 `generateNonce`。 +pub fn generate_nonce() -> String { + let mut bytes = [0u8; 32]; + for b in bytes.iter_mut() { + *b = rand::random::(); + } + STANDARD.encode(bytes) +} + +/// 证书指纹:sha256(DER) 的十六进制小写 64 位(对齐 net.ts 的 `certFingerprint`)。 +pub fn cert_fingerprint(der: &[u8]) -> String { + let digest = Sha256::digest(der); + digest.iter().map(|b| format!("{b:02x}")).collect() +} + +/// 对 nonce 原文做 ed25519 签名,返回 Base64(对齐 ed25519.ts 的 `signNonce`)。 +/// 私钥为 Base64(PKCS8 DER)。 +pub fn sign_nonce(private_key_b64: &str, nonce: &str) -> Result { + use ed25519_dalek::pkcs8::DecodePrivateKey; + use ed25519_dalek::Signer; + + let der = STANDARD + .decode(private_key_b64) + .map_err(|e| format!("私钥 Base64 解码失败: {e}"))?; + let signing_key = ed25519_dalek::SigningKey::from_pkcs8_der(&der) + .map_err(|e| format!("PKCS8 私钥解析失败: {e}"))?; + let sig = signing_key.sign(nonce.as_bytes()); + Ok(STANDARD.encode(sig.to_bytes())) +} + +/// 验签(对齐 `verifyNonce`);任何异常均视为失败(不抛出)。 +pub fn verify_nonce(public_key_b64: &str, nonce: &str, signature_b64: &str) -> bool { + use ed25519_dalek::pkcs8::DecodePublicKey; + use ed25519_dalek::Verifier; + + let Ok(pk_der) = STANDARD.decode(public_key_b64) else { + return false; + }; + let Ok(vk) = ed25519_dalek::VerifyingKey::from_public_key_der(&pk_der) else { + return false; + }; + let Ok(sig_bytes) = STANDARD.decode(signature_b64) else { + return false; + }; + let Ok(sig) = ed25519_dalek::Signature::from_slice(&sig_bytes) else { + return false; + }; + vk.verify(nonce.as_bytes(), &sig).is_ok() +} + +/// 公钥指纹(SHA-256 前 16 位 hex),对齐 `keyId`。 +pub fn key_id(public_key_b64: &str) -> String { + let der = STANDARD.decode(public_key_b64).unwrap_or_default(); + let digest = Sha256::digest(&der); + // 前 16 个 hex 字符 = 前 8 字节。 + digest.iter().take(8).map(|b| format!("{b:02x}")).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use base64::engine::general_purpose::STANDARD; + use ed25519_dalek::pkcs8::{EncodePrivateKey, EncodePublicKey}; + + fn gen_keypair() -> (String, String) { + let sk = ed25519_dalek::SigningKey::from_bytes(&[7u8; 32]); + let pk = sk.verifying_key(); + ( + STANDARD.encode(pk.to_public_key_der().expect("编码公钥").as_bytes()), + STANDARD.encode(sk.to_pkcs8_der().expect("编码私钥").as_bytes()), + ) + } + + #[test] + fn hmac_matches_known_vector() { + // 与 Node 版对拍的固定向量:HMAC-SHA256("key", "nonce")。 + let out = compute_hmac("secret", "hello"); + // 用独立的 hmac 重算并比对,避免自证。 + let mut mac = HmacSha256::new_from_slice(b"secret").unwrap(); + mac.update(b"hello"); + let expect = STANDARD.encode(mac.finalize().into_bytes()); + assert_eq!(out, expect); + // Base64(HMAC-SHA256("secret","hello")) 的已知值(由 RFC 测试向量推得,供跨端核对)。 + assert_eq!(out, "iKqz7ejTrflNJquQ07r9SiCDBww7zOnAFO4EpEOEfAs="); + } + + #[test] + fn sign_and_verify_roundtrip() { + let (pk, sk) = gen_keypair(); + let nonce = generate_nonce(); + let sig = sign_nonce(&sk, &nonce).unwrap(); + assert!(verify_nonce(&pk, &nonce, &sig)); + assert!(!verify_nonce(&pk, "tampered", &sig)); + } + + #[test] + fn key_id_is_16_hex() { + let (pk, _sk) = gen_keypair(); + let id = key_id(&pk); + assert_eq!(id.len(), 16); + assert!(id.chars().all(|c| c.is_ascii_hexdigit())); + } + + #[test] + fn cert_fingerprint_sha256_der() { + // 空 DER 的 sha256 是已知常量。 + assert_eq!( + cert_fingerprint(b""), + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + ); + } +} diff --git a/crates/nodeagent-client/src/discovery.rs b/crates/nodeagent-client/src/discovery.rs new file mode 100644 index 0000000..26f495e --- /dev/null +++ b/crates/nodeagent-client/src/discovery.rs @@ -0,0 +1,145 @@ +//! 局域网发现(对齐 `packages/client/src/discovery.ts`)。 +//! +//! 只监听被控端 UDP 广播、不发送任何数据,因此不引入额外暴露面。 +//! M1 实现基础解析;v22 最小化广播的 HMAC 验签留待后续(需对齐 JSON 序列化顺序)。 + +use std::collections::HashMap; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use serde::{Deserialize, Serialize}; +use tokio::net::UdpSocket; + +/// 发现到的被控端。 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DiscoveredNode { + pub node_id: String, + /// 可达地址:取报文来源 IP(一定可达)。 + pub host: String, + /// 被控端自报地址(多网卡时可能与来源不同,仅供参考)。 + pub advertised_host: String, + pub port: u16, + pub tls: bool, + pub auth_mode: String, + pub input_enabled: bool, + pub platform: String, + /// v22:详情是否通过 HMAC 验签。false = 明文广播,仅作展示、不可当凭据。 + pub authenticated: bool, + /// 最近一次收到广播的时间(Unix 毫秒)。 + pub last_seen: u64, +} + +#[derive(Debug, Deserialize)] +struct RawBeacon { + service: Option, + node_id: Option, + host: Option, + port: Option, + tls: Option, + auth_mode: Option, + input_enabled: Option, + platform: Option, + minimal: Option, + #[serde(default)] + detail: Option, +} + +#[derive(Debug, Deserialize)] +struct BeaconDetail { + host: Option, + port: Option, + tls: Option, + auth_mode: Option, + input_enabled: Option, + platform: Option, +} + +/// 默认发现端口(对齐 methods.ts 的 `DEFAULT_DISCOVERY_PORT`)。 +pub const DEFAULT_DISCOVERY_PORT: u16 = 8766; + +/// 默认被控端端口。 +const DEFAULT_AGENT_PORT: u16 = 8765; + +/// 监听一段时间后返回发现的设备(对齐 `discoverOnce`)。 +pub async fn discover_once(wait_ms: u64, port: u16) -> Result, String> { + let socket = UdpSocket::bind(("0.0.0.0", port)) + .await + .map_err(|e| format!("发现监听失败(UDP {port}): {e}"))?; + + let mut nodes: HashMap = HashMap::new(); + let deadline = tokio::time::Instant::now() + Duration::from_millis(wait_ms); + let mut buf = [0u8; 2048]; + + loop { + let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); + if remaining.is_zero() { + break; + } + match tokio::time::timeout(remaining, socket.recv_from(&mut buf)).await { + Ok(Ok((len, src))) => { + if let Some(node) = parse_beacon(&buf[..len], src) { + nodes.insert(node.node_id.clone(), node); + } + } + _ => break, + } + } + + let now = now_ms(); + let mut list: Vec = nodes.into_values().collect(); + // 过滤 TTL 过期项(30s),按最近出现排序。 + list.retain(|n| now - n.last_seen <= 30_000); + list.sort_by_key(|n| std::cmp::Reverse(n.last_seen)); + Ok(list) +} + +fn parse_beacon(data: &[u8], src: std::net::SocketAddr) -> Option { + let raw: RawBeacon = serde_json::from_slice(data).ok()?; + if raw.service.as_deref() != Some("nodeagent") { + return None; + } + let node_id = raw.node_id?; + + // v22 最小化广播:顶层只剩存在性,真实信息在 detail 里。 + let detail = raw.minimal.unwrap_or(false).then_some(raw.detail).flatten(); + + Some(DiscoveredNode { + node_id, + // 地址永远取「报文来源 IP」—— 一定可达。 + host: src.ip().to_string(), + advertised_host: detail + .as_ref() + .and_then(|d| d.host.clone()) + .or(raw.host) + .unwrap_or_else(|| src.ip().to_string()), + port: detail + .as_ref() + .and_then(|d| d.port) + .or(raw.port) + .unwrap_or(DEFAULT_AGENT_PORT), + tls: detail.as_ref().and_then(|d| d.tls).or(raw.tls).unwrap_or(true), + auth_mode: detail + .as_ref() + .and_then(|d| d.auth_mode.clone()) + .or(raw.auth_mode) + .unwrap_or_else(|| "psk".to_string()), + input_enabled: detail + .as_ref() + .and_then(|d| d.input_enabled) + .or(raw.input_enabled) + .unwrap_or(false), + platform: detail + .as_ref() + .and_then(|d| d.platform.clone()) + .or(raw.platform) + .unwrap_or_else(|| "unknown".to_string()), + authenticated: false, + last_seen: now_ms(), + }) +} + +fn now_ms() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0) +} diff --git a/crates/nodeagent-client/src/error.rs b/crates/nodeagent-client/src/error.rs new file mode 100644 index 0000000..3bead7a --- /dev/null +++ b/crates/nodeagent-client/src/error.rs @@ -0,0 +1,96 @@ +//! 协议错误码与客户端错误类型(对齐 `packages/protocol/src/errors.ts`)。 + +use std::fmt; + +/// 协议错误码常量(对齐 errors.ts 的 `ErrorCodes`)。 +#[allow(non_snake_case)] +pub mod ErrorCodes { + pub const PARSE_ERROR: i64 = -32700; + pub const INVALID_REQUEST: i64 = -32600; + pub const METHOD_NOT_FOUND: i64 = -32601; + pub const PARAM_INVALID: i64 = -32602; + pub const AUTH_FAILED: i64 = -32401; + pub const AUTH_REQUIRED: i64 = -32402; + pub const CAPABILITY_NOT_FOUND: i64 = -32403; + pub const CAPABILITY_DISABLED: i64 = -32404; + pub const NODE_OFFLINE: i64 = -32405; + pub const ACL_DENIED: i64 = -32406; + pub const RATE_LIMITED: i64 = -32407; + pub const TIMEOUT: i64 = -32412; + pub const EXECUTION_FAILED: i64 = -32419; + pub const PROTOCOL_MISMATCH: i64 = -32420; + pub const UNSUPPORTED_PLATFORM: i64 = -32421; + /// v21:被控端证书指纹与控制端钉住的不一致(可能被冒充)。 + pub const CERT_MISMATCH: i64 = -32422; +} + +/// 错误码 → 名称。 +pub fn error_name(code: i64) -> &'static str { + match code { + ErrorCodes::PARSE_ERROR => "E_PARSE_ERROR", + ErrorCodes::INVALID_REQUEST => "E_INVALID_REQUEST", + ErrorCodes::METHOD_NOT_FOUND => "E_METHOD_NOT_FOUND", + ErrorCodes::PARAM_INVALID => "E_PARAM_INVALID", + ErrorCodes::AUTH_FAILED => "E_AUTH_FAILED", + ErrorCodes::AUTH_REQUIRED => "E_AUTH_REQUIRED", + ErrorCodes::CAPABILITY_NOT_FOUND => "E_CAPABILITY_NOT_FOUND", + ErrorCodes::CAPABILITY_DISABLED => "E_CAPABILITY_DISABLED", + ErrorCodes::NODE_OFFLINE => "E_NODE_OFFLINE", + ErrorCodes::ACL_DENIED => "E_ACL_DENIED", + ErrorCodes::RATE_LIMITED => "E_RATE_LIMITED", + ErrorCodes::TIMEOUT => "E_TIMEOUT", + ErrorCodes::EXECUTION_FAILED => "E_EXECUTION_FAILED", + ErrorCodes::PROTOCOL_MISMATCH => "E_PROTOCOL_MISMATCH", + ErrorCodes::UNSUPPORTED_PLATFORM => "E_UNSUPPORTED_PLATFORM", + ErrorCodes::CERT_MISMATCH => "E_CERT_MISMATCH", + _ => "E_UNKNOWN", + } +} + +/// 客户端错误(携带协议错误码,对齐 client.ts 的 `ClientError`)。 +#[derive(Debug, Clone)] +pub struct ClientError { + pub code: i64, + pub name: String, + pub message: String, + pub data: Option, +} + +impl ClientError { + pub fn new(code: i64, message: impl Into) -> Self { + Self { + code, + name: error_name(code).to_string(), + message: message.into(), + data: None, + } + } + + pub fn with_data(mut self, data: serde_json::Value) -> Self { + self.data = Some(data); + self + } + + /// 被控端离线/不可达。 + pub fn offline(message: impl Into) -> Self { + Self::new(ErrorCodes::NODE_OFFLINE, message) + } + + /// 调用超时。 + pub fn timeout(message: impl Into) -> Self { + Self::new(ErrorCodes::TIMEOUT, message) + } + + /// 证书指纹不一致。 + pub fn cert_mismatch(message: impl Into) -> Self { + Self::new(ErrorCodes::CERT_MISMATCH, message) + } +} + +impl fmt::Display for ClientError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}: {}", self.name, self.message) + } +} + +impl std::error::Error for ClientError {} diff --git a/crates/nodeagent-client/src/lib.rs b/crates/nodeagent-client/src/lib.rs new file mode 100644 index 0000000..71549f2 --- /dev/null +++ b/crates/nodeagent-client/src/lib.rs @@ -0,0 +1,34 @@ +//! nodeagent 控制端 Rust 客户端。 +//! +//! 行为对齐 `packages/client`(Node 版),复刻: +//! - 握手 hello → challenge → auth → auth_ok(psk HMAC / ed25519 签名) +//! - 泛型能力调用 invoke(带超时与 id 匹配) +//! - 事件通知(无 id 的 JSON-RPC notification,`event` 方法) +//! - 断线指数退避重连(1s → 30s,±15% 抖动) +//! - TLS 自签证书 + 指纹钉住(v21,E_CERT_MISMATCH) + +pub mod client; +pub mod crypto; +pub mod discovery; +pub mod error; +pub mod tls; +pub mod types; +pub mod ulid; + +pub use client::{Client, ClientEvent, ClientOptions, ConnState}; +pub use error::{ClientError, ErrorCodes}; +pub use types::{CapabilityDescriptor, InvokeResult}; + +#[cfg(test)] +mod send_sync_checks { + use super::*; + + fn assert_send_sync() {} + + #[test] + fn client_is_send_sync() { + assert_send_sync::(); + assert_send_sync::(); + assert_send_sync::(); + } +} diff --git a/crates/nodeagent-client/src/tls.rs b/crates/nodeagent-client/src/tls.rs new file mode 100644 index 0000000..6bd9a76 --- /dev/null +++ b/crates/nodeagent-client/src/tls.rs @@ -0,0 +1,119 @@ +//! TLS 配置:自签证书 + 证书指纹钉住(对齐 client.ts 的 TLS 处理,v21)。 +//! +//! nodeagent 的安全模型:TLS 只负责**传输加密**,身份认证由应用层 +//! HMAC / ed25519 握手兜底。因此 `insecure` 跳过的是「证书链是否由可信 CA +//! 签发」,TLS 层的签名仍真正验签(委托 rustls)。 + +use std::sync::{Arc, Mutex}; + +use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier}; +use rustls::crypto::{ + verify_tls12_signature, verify_tls13_signature, CryptoProvider, WebPkiSupportedAlgorithms, +}; +use rustls::pki_types::{CertificateDer, ServerName, UnixTime}; +use rustls::{ClientConfig, DigitallySignedStruct, Error, SignatureScheme}; + +use crate::crypto::cert_fingerprint; +use crate::error::{ClientError, ErrorCodes}; + +/// 构建 rustls `ClientConfig`。 +/// +/// - `insecure = false`:webpki 严格链验证(自签证书会被拒,与 Node `rejectUnauthorized=true` 一致)。 +/// - `insecure = true`:接受任意证书,但抓取对端证书 DER 的 sha256 指纹。 +/// - `expect_fp`:钉住的指纹(64 位小写 hex);给了就严格比对,不一致拒绝(`E_CERT_MISMATCH`)。 +/// - `captured`:回填本次握手实际抓到的指纹(对齐 `getPeerCertFingerprint`)。 +pub fn build_client_config( + insecure: bool, + expect_fp: Option, + captured: Arc>>, +) -> Result { + let expect_fp = expect_fp.map(|s| s.trim().to_ascii_lowercase()); + + if !insecure { + let mut roots = rustls::RootCertStore::empty(); + roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned()); + return Ok(ClientConfig::builder() + .with_root_certificates(roots) + .with_no_client_auth()); + } + + let supported = default_supported_algorithms()?; + let verifier = Arc::new(InsecureVerifier { + expect_fp, + captured, + supported, + }); + + Ok(ClientConfig::builder() + .dangerous() + .with_custom_certificate_verifier(verifier) + .with_no_client_auth()) +} + +fn default_supported_algorithms() -> Result { + let provider = CryptoProvider::get_default() + .ok_or_else(|| ClientError::new(ErrorCodes::CERT_MISMATCH, "无默认 CryptoProvider"))?; + Ok(provider.signature_verification_algorithms) +} + +/// 接受任意证书、但抓指纹的验证器。 +/// `verify_server_cert` 跳过链校验(只做指纹钉住),签名仍由 rustls 真正验签。 +#[derive(Debug)] +struct InsecureVerifier { + expect_fp: Option, + captured: Arc>>, + supported: WebPkiSupportedAlgorithms, +} + +impl ServerCertVerifier for InsecureVerifier { + fn verify_server_cert( + &self, + end_entity: &CertificateDer<'_>, + _intermediates: &[CertificateDer<'_>], + _server_name: &ServerName<'_>, + _ocsp_response: &[u8], + _now: UnixTime, + ) -> Result { + let fp = cert_fingerprint(end_entity.as_ref()); + + // 回填实际指纹(TOFU 钉住)。 + if let Ok(mut guard) = self.captured.lock() { + *guard = Some(fp.clone()); + } + + // 钉住比对:不一致 → 拒绝(对齐 client.ts 的 E_CERT_MISMATCH)。 + if let Some(expected) = &self.expect_fp { + if expected != &fp { + let expect_head = &expected[..expected.len().min(16)]; + let actual_head = &fp[..fp.len().min(16)]; + return Err(Error::General(format!( + "证书指纹不匹配,拒绝连接:期望 {expect_head}… 实际 {actual_head}…(被控端可能重装、或中间人冒充)" + ))); + } + } + + Ok(ServerCertVerified::assertion()) + } + + fn verify_tls12_signature( + &self, + message: &[u8], + cert: &CertificateDer<'_>, + dss: &DigitallySignedStruct, + ) -> Result { + verify_tls12_signature(message, cert, dss, &self.supported) + } + + fn verify_tls13_signature( + &self, + message: &[u8], + cert: &CertificateDer<'_>, + dss: &DigitallySignedStruct, + ) -> Result { + verify_tls13_signature(message, cert, dss, &self.supported) + } + + fn supported_verify_schemes(&self) -> Vec { + self.supported.supported_schemes() + } +} diff --git a/crates/nodeagent-client/src/types.rs b/crates/nodeagent-client/src/types.rs new file mode 100644 index 0000000..dec875e --- /dev/null +++ b/crates/nodeagent-client/src/types.rs @@ -0,0 +1,212 @@ +//! 协议消息类型(对齐 `packages/protocol/src/messages.ts`)。 +//! +//! 所有对外字段名保持 snake_case 与 TS 侧一致,经 serde 序列化/反序列化。 + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +/// 协议版本(对齐 methods.ts 的 `PROTOCOL_VERSION`)。 +pub const PROTOCOL_VERSION: &str = "1.0"; + +/// JSON-RPC 2.0 请求信封(nodeagent 简化版)。 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RpcRequest { + pub jsonrpc: String, + /// ULID,响应回填相同 id。 + pub id: String, + pub method: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub params: Option, +} + +/// JSON-RPC 2.0 成功响应。 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RpcResponse { + pub jsonrpc: String, + pub id: String, + pub result: Value, +} + +/// 协议错误对象。 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RpcError { + pub code: i64, + pub name: String, + pub message: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub data: Option, +} + +/// JSON-RPC 2.0 错误响应。 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RpcErrorResponse { + pub jsonrpc: String, + #[serde(default)] + pub id: Option, + pub error: RpcError, +} + +/// 任意入站消息:成功响应或错误响应(无 id 的通知由上层单独处理)。 +#[derive(Debug, Clone, Deserialize)] +#[serde(untagged)] +pub enum InboundMessage { + Response(RpcResponse), + Error(RpcErrorResponse), + Notification { + #[serde(default)] + id: Option, + method: String, + #[serde(default)] + params: Option, + }, +} + +/// 判断消息是否为错误响应。 +pub fn is_error_response(msg: &Value) -> bool { + msg.get("error").is_some() +} + +// ---------- 握手 ---------- + +/// hello 入参。 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HelloParams { + pub protocol: String, + pub client_id: String, +} + +/// challenge 出参。 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ChallengeParams { + pub nonce: String, + pub expires_at: i64, +} + +/// auth 入参。 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AuthParams { + pub client_id: String, + pub nonce: String, + /// psk 模式:Base64(HMAC-SHA256(pre_shared_key, nonce))。 + #[serde(skip_serializing_if = "Option::is_none")] + pub hmac: Option, + /// ed25519 模式:Base64(Ed25519_Sign(privkey, nonce))。 + #[serde(skip_serializing_if = "Option::is_none")] + pub signature: Option, +} + +/// auth_ok 出参。 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AuthOkParams { + pub capabilities: Vec, + /// ed25519 模式:本次实际被授权的能力(全量子集)。 + #[serde(skip_serializing_if = "Option::is_none")] + pub authorized: Option>, + /// 本次连接采用的认证模式。 + #[serde(skip_serializing_if = "Option::is_none")] + pub auth_mode: Option, + /// v20:被控端语义化版本。 + #[serde(skip_serializing_if = "Option::is_none")] + pub agent_version: Option, + /// v20:被控端构建信息。 + #[serde(skip_serializing_if = "Option::is_none")] + pub build: Option, + /// v20:被控端支持的协议版本。 + #[serde(skip_serializing_if = "Option::is_none")] + pub protocol: Option, +} + +/// 被控端构建信息(v20)。 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BuildInfo { + #[serde(skip_serializing_if = "Option::is_none")] + pub hash: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub commit: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub built_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub cert_sha256: Option, +} + +// ---------- 调用 ---------- + +/// invoke 入参。 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct InvokeParams { + pub capability: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub args: Option, +} + +/// 能力执行结果(业务层)。 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct InvokeResult { + pub status: String, // "ok" | "failed" + #[serde(skip_serializing_if = "Option::is_none")] + pub data: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +impl InvokeResult { + pub fn is_ok(&self) -> bool { + self.status == "ok" + } +} + +/// 能力执行失败的错误信息。 +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct InvokeError { + pub name: String, + pub message: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub data: Option, +} + +/// 能力描述(能力清单条目)。 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CapabilityDescriptor { + pub name: String, + pub version: String, + pub description: String, + pub risk: String, // "low" | "medium" | "high" + pub params_schema: JsonSchema, + #[serde(skip_serializing_if = "Option::is_none")] + pub returns_schema: Option, +} + +/// JSON Schema 子集(见 DEVELOPMENT.md 4.3)。 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct JsonSchema { + #[serde(rename = "type", skip_serializing_if = "Option::is_none")] + pub schema_type: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub properties: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub required: Option>, + #[serde(rename = "additionalProperties", skip_serializing_if = "Option::is_none")] + pub additional_properties: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub r#enum: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub default: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub minimum: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub maximum: Option, + #[serde(rename = "minLength", skip_serializing_if = "Option::is_none")] + pub min_length: Option, + #[serde(rename = "maxLength", skip_serializing_if = "Option::is_none")] + pub max_length: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub pattern: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub items: Option>, + #[serde(rename = "minItems", skip_serializing_if = "Option::is_none")] + pub min_items: Option, + #[serde(rename = "maxItems", skip_serializing_if = "Option::is_none")] + pub max_items: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, +} diff --git a/crates/nodeagent-client/src/ulid.rs b/crates/nodeagent-client/src/ulid.rs new file mode 100644 index 0000000..da318c2 --- /dev/null +++ b/crates/nodeagent-client/src/ulid.rs @@ -0,0 +1,72 @@ +//! ULID 生成器(Crockford Base32,26 字符:10 位时间戳 + 16 位随机)。 +//! 对齐 `packages/protocol/src/ulid.ts`(自实现,避免额外依赖)。 + +use std::time::{SystemTime, UNIX_EPOCH}; + +/// Crockford Base32 字母表(与 ulid.ts 完全一致)。 +const ENCODING: &[u8; 32] = b"0123456789ABCDEFGHJKMNPQRSTVWXYZ"; + +/// 生成一个 ULID(单调递增:同一毫秒内保证字典序递增)。 +pub fn ulid() -> String { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0); + ulid_at(now) +} + +/// 按指定时间戳生成 ULID(供测试注入确定性时间)。 +pub fn ulid_at(now: u64) -> String { + // 时间戳部分:10 字符,低位在前地填充(不足 10 位高位补 '0')。 + let mut time_part = [b'0'; 10]; + let mut t = now; + for i in (0..10).rev() { + time_part[i] = ENCODING[(t % 32) as usize]; + t /= 32; + } + + // 随机部分:16 字符。256 % 32 == 0,取模无偏差。 + let mut rand_part = [b'0'; 16]; + for slot in rand_part.iter_mut() { + *slot = ENCODING[(rand::random::() % 32) as usize]; + } + + let mut out = String::with_capacity(26); + out.push_str(std::str::from_utf8(&time_part).unwrap()); + out.push_str(std::str::from_utf8(&rand_part).unwrap()); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn length_is_26() { + assert_eq!(ulid().len(), 26); + } + + #[test] + fn only_valid_charset() { + let s = ulid(); + let set: Vec = ENCODING.iter().map(|&b| b as char).collect(); + assert!(s.chars().all(|c| set.contains(&c))); + } + + #[test] + fn monotonic_same_millis_lexicographic() { + // 同一毫秒内,两个 ULID 的时间前缀必须相同(保证字典序可比较)。 + let a = ulid_at(1_700_000_000_000); + let b = ulid_at(1_700_000_000_000); + assert_eq!(&a[..10], &b[..10]); + } + + #[test] + fn distinct() { + let mut seen = std::collections::HashSet::new(); + for _ in 0..1000 { + seen.insert(ulid()); + } + assert_eq!(seen.len(), 1000); + } +} diff --git a/crates/nodeagent-client/tests/handshake.rs b/crates/nodeagent-client/tests/handshake.rs new file mode 100644 index 0000000..ee5fb92 --- /dev/null +++ b/crates/nodeagent-client/tests/handshake.rs @@ -0,0 +1,136 @@ +//! 端到端:起一个 mock 被控端(WebSocket),验证 Rust client 的 +//! psk 握手(hello → challenge → auth → auth_ok)+ invoke 全链路。 + +use nodeagent_client::{Client, ClientOptions}; +use serde_json::{json, Value}; +use tokio::net::TcpListener; +use tokio_tungstenite::tungstenite::Message; + +/// 从 mock 被控端读取下一条文本消息并解析为 JSON。 +async fn read_json( + ws: &mut tokio_tungstenite::WebSocketStream, +) -> Value { + use futures_util::StreamExt; + while let Some(msg) = ws.next().await { + if let Ok(Message::Text(t)) = msg { + return serde_json::from_str(&t).expect("消息应为合法 JSON"); + } + } + panic!("mock 被控端连接提前关闭"); +} + +#[tokio::test] +async fn psk_handshake_and_invoke_roundtrip() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + + // mock 被控端:应答 hello / auth / invoke。 + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap(); + use futures_util::SinkExt; + + // hello → challenge + let hello = read_json(&mut ws).await; + assert_eq!(hello["method"], "hello"); + let hello_id = hello["id"].as_str().unwrap(); + let nonce = "nonce-abc123"; + ws.send(Message::Text( + json!({ + "jsonrpc": "2.0", + "id": hello_id, + "result": { "nonce": nonce, "expires_at": 4_102_444_800_000_i64 } + }) + .to_string() + .into(), + )) + .await + .unwrap(); + + // auth → auth_ok + let auth = read_json(&mut ws).await; + assert_eq!(auth["method"], "auth"); + assert_eq!(auth["params"]["nonce"], nonce); + assert!(auth["params"]["hmac"].is_string(), "psk 模式应携带 hmac"); + let auth_id = auth["id"].as_str().unwrap(); + ws.send(Message::Text( + json!({ + "jsonrpc": "2.0", + "id": auth_id, + "result": { + "capabilities": [{ + "name": "system.ping", + "version": "1.0", + "description": "探活", + "risk": "low", + "params_schema": { "type": "object" } + }], + "authorized": null, + "auth_mode": "psk", + "agent_version": "1.0.0" + } + }) + .to_string() + .into(), + )) + .await + .unwrap(); + + // invoke → result + let invoke = read_json(&mut ws).await; + assert_eq!(invoke["method"], "invoke"); + assert_eq!(invoke["params"]["capability"], "system.ping"); + let invoke_id = invoke["id"].as_str().unwrap(); + ws.send(Message::Text( + json!({ + "jsonrpc": "2.0", + "id": invoke_id, + "result": { "status": "ok", "data": { "pong": true, "ts": 42 } } + }) + .to_string() + .into(), + )) + .await + .unwrap(); + }); + + // 控制端:连接 → 握手 → 校验元信息 → 调用。 + let client = Client::new(ClientOptions { + url: format!("ws://{addr}"), + key: "secret".to_string(), + client_id: "control-1".to_string(), + ..Default::default() + }); + + let caps = client.connect().await.unwrap(); + assert_eq!(caps.len(), 1); + assert_eq!(caps[0].name, "system.ping"); + assert_eq!(client.agent_version().as_deref(), Some("1.0.0")); + assert_eq!(client.state(), nodeagent_client::ConnState::Connected); + + let res = client.invoke("system.ping", json!({})).await.unwrap(); + assert_eq!(res.status, "ok"); + assert_eq!(res.data.unwrap()["pong"], true); + + client.close(); + server.await.unwrap(); +} + +#[tokio::test] +async fn connect_to_closed_port_fails() { + // 连接到一个必然拒绝的端口,应返回离线错误而非 panic。 + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + drop(listener); // 立即关闭,确保端口无监听。 + + let client = Client::new(ClientOptions { + url: format!("ws://{addr}"), + key: "secret".to_string(), + client_id: "control-1".to_string(), + handshake_timeout_ms: 1_000, + ..Default::default() + }); + + let err = client.connect().await.unwrap_err(); + assert_eq!(err.code, nodeagent_client::ErrorCodes::NODE_OFFLINE); +}