diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f598d8a38..11a77b30e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -176,6 +176,15 @@ jobs: - name: Test run: make -C clients/openframe-client test + - name: Lint updater (cargo fmt --check + cargo clippy -D warnings) + run: make -C clients/openframe-client-updater lint + + - name: Build updater binary + run: make -C clients/openframe-client-updater build + + - name: Test updater + run: make -C clients/openframe-client-updater test + cleanup: name: Cleanup diff --git a/clients/openframe-client-updater/.cargo/config.toml b/clients/openframe-client-updater/.cargo/config.toml new file mode 100644 index 000000000..330bb6ada --- /dev/null +++ b/clients/openframe-client-updater/.cargo/config.toml @@ -0,0 +1,5 @@ +# Statically link the Visual C++ Runtime, same as openframe-client: the +# recovery service must start on machines without the VC++ redistributable. + +[target.x86_64-pc-windows-msvc] +rustflags = ["-C", "target-feature=+crt-static"] diff --git a/clients/openframe-client-updater/Cargo.lock b/clients/openframe-client-updater/Cargo.lock new file mode 100644 index 000000000..19bc39239 --- /dev/null +++ b/clients/openframe-client-updater/Cargo.lock @@ -0,0 +1,3575 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "async-nats" +version = "0.42.0" +source = "git+https://github.com/flamingo-stack/nats.rs.git?branch=main#744876ef3d64c4bab14ee0fbb76743a0ee8db191" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-util", + "memchr", + "nkeys", + "nuid", + "once_cell", + "pin-project", + "portable-atomic", + "rand", + "regex", + "ring", + "rustls-native-certs", + "rustls-pemfile 2.2.0", + "rustls-webpki 0.102.8", + "serde", + "serde_json", + "serde_nanos", + "serde_repr", + "thiserror 1.0.69", + "time", + "tokio", + "tokio-rustls 0.26.4", + "tokio-util", + "tokio-websockets", + "tracing", + "tryhard", + "url", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +dependencies = [ + "serde", +] + +[[package]] +name = "bzip2" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdb116a6ef3f6c3698828873ad02c3014b3c85cadb88496095628e3ef1e347f8" +dependencies = [ + "bzip2-sys", + "libc", +] + +[[package]] +name = "bzip2-sys" +version = "0.1.13+1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14" +dependencies = [ + "cc", + "pkg-config", +] + +[[package]] +name = "cc" +version = "1.2.61" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "codepage" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48f68d061bc2828ae826206326e61251aca94c1e4a5305cf52d9138639c918b4" +dependencies = [ + "encoding_rs", +] + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "constant_time_eq" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "245097e9a4535ee1e3e3931fcfcd55a796a44c643e8596ff6566d68f09b87bbc" + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "rand_core", + "typenum", +] + +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + +[[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", +] + +[[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", +] + +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", + "serde_core", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + +[[package]] +name = "directories" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a49173b84e034382284f27f1af4dcbbd231ffa358c0fe316541a7337f376a35" +dependencies = [ + "dirs-sys 0.4.1", +] + +[[package]] +name = "dirs" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3aa72a6f96ea37bbc5aa912f6788242832f75369bdfdadcb0e38423f100059" +dependencies = [ + "dirs-sys 0.3.7", +] + +[[package]] +name = "dirs-sys" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b1d1d91c932ef41c0f2663aa8b0ca0342d444d842c06914aa0a7e352d0bada6" +dependencies = [ + "libc", + "redox_users", + "winapi", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "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", + "sha2", + "signature", + "subtle", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "embed-resource" +version = "2.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d506610004cfc74a6f5ee7e8c632b355de5eca1f03ee5e5e0ec11b77d4eb3d61" +dependencies = [ + "cc", + "memchr", + "rustc_version", + "toml", + "vswhom", + "winreg 0.52.0", +] + +[[package]] +name = "encoding-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87b881ab2524b96a5ce932056c7482ba6152e2226fed3936b3e592adeb95ca6d" +dependencies = [ + "codepage", + "encoding_rs", + "windows-sys 0.52.0", +] + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "filetime" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" +dependencies = [ + "cfg-if", + "libc", + "libredox", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + +[[package]] +name = "h2" +version = "0.3.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http 0.2.12", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http 0.2.12", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "0.14.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2", + "http 0.2.12", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2 0.5.10", + "tokio", + "tower-service", + "tracing", + "want", +] + +[[package]] +name = "hyper-tls" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905" +dependencies = [ + "bytes", + "hyper", + "native-tls", + "tokio", + "tokio-native-tls", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.62.2", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.0", + "serde", + "serde_core", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "is_elevated" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5299060ff5db63e788015dcb9525ad9b84f4fd9717ed2cbdeba5018cbf42f9b5" +dependencies = [ + "winapi", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.97" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1840c94c045fbcf8ba2812c95db44499f7c64910a912551aaaa541decebcacf" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libredox" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" +dependencies = [ + "bitflags 2.11.1", + "libc", + "plain", + "redox_syscall 0.7.5", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe 0.2.1", + "openssl-sys", + "schannel", + "security-framework 3.7.0", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "nkeys" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879011babc47a1c7fdf5a935ae3cfe94f34645ca0cac1c7f6424b36fc743d1bf" +dependencies = [ + "data-encoding", + "ed25519", + "ed25519-dalek", + "getrandom 0.2.17", + "log", + "rand", + "signatory", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "nuid" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc895af95856f929163a0aa20c26a78d26bfdc839f51b9d5aa7a5b79e52b7e83" +dependencies = [ + "rand", +] + +[[package]] +name = "num-conv" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" + +[[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 = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "openframe-client-updater" +version = "0.1.0" +dependencies = [ + "aes-gcm", + "anyhow", + "async-nats", + "base64 0.21.7", + "bytes", + "chrono", + "clap", + "directories", + "embed-resource", + "flate2", + "futures", + "is_elevated", + "libc", + "plist", + "reqwest", + "rustls-pemfile 1.0.4", + "semver", + "serde", + "serde_json", + "service-manager", + "tar", + "tempfile", + "thiserror 1.0.69", + "tokio", + "tokio-rustls 0.24.1", + "tracing", + "tracing-appender", + "tracing-subscriber", + "uuid", + "winapi", + "windows", + "windows-service", + "winreg 0.52.0", + "zip", +] + +[[package]] +name = "openssl" +version = "0.10.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf0b434746ee2832f4f0baf10137e1cabb18cbe6912c69e2e33263c45250f542" +dependencies = [ + "bitflags 2.11.1", + "cfg-if", + "foreign-types", + "libc", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.115" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "158fe5b292746440aa6e7a7e690e55aeb72d41505e2804c23c6973ad0e9c9781" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "password-hash" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7676374caaee8a325c9e7a2ae557f216c5563a171d6997b0ef8a65af35147700" +dependencies = [ + "base64ct", + "rand_core", + "subtle", +] + +[[package]] +name = "pbkdf2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83a0692ec44e4cf1ef28ca317f14f8f07da2d95ec3fa01f86e4467b725e60917" +dependencies = [ + "digest", + "hmac", + "password-hash", + "sha2", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project" +version = "1.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbf0d9e68100b3a7989b4901972f265cd542e560a3a8a724e1e20322f4d06ce9" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a990e22f43e84855daf260dded30524ef4a9021cc7541c26540500a50b624389" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[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 = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "plist" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1" +dependencies = [ + "base64 0.22.1", + "indexmap", + "quick-xml", + "serde", + "time", +] + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quick-xml" +version = "0.39.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "721da970c312655cde9b4ffe0547f20a8494866a4af5ff51f18b7c633d0c870b" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.11.1", +] + +[[package]] +name = "redox_syscall" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4666a1a60d8412eab19d94f6d13dcc9cea0a5ef4fdf6a5db306537413c661b1b" +dependencies = [ + "bitflags 2.11.1", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "reqwest" +version = "0.11.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd67538700a17451e7cba03ac727fb961abb7607553461627b97de0b89cf4a62" +dependencies = [ + "base64 0.21.7", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2", + "http 0.2.12", + "http-body", + "hyper", + "hyper-tls", + "ipnet", + "js-sys", + "log", + "mime", + "native-tls", + "once_cell", + "percent-encoding", + "pin-project-lite", + "rustls-pemfile 1.0.4", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "system-configuration", + "tokio", + "tokio-native-tls", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "winreg 0.50.0", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.11.1", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.11.1", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.21.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" +dependencies = [ + "log", + "ring", + "rustls-webpki 0.101.7", + "sct", +] + +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki 0.103.13", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5bfb394eeed242e909609f56089eecfe5fda225042e8b171791b9c95f5931e5" +dependencies = [ + "openssl-probe 0.1.6", + "rustls-pemfile 2.2.0", + "rustls-pki-types", + "schannel", + "security-framework 2.11.1", +] + +[[package]] +name = "rustls-pemfile" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" +dependencies = [ + "base64 0.21.7", +] + +[[package]] +name = "rustls-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.101.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" +dependencies = [ + "ring", + "untrusted", +] + +[[package]] +name = "rustls-webpki" +version = "0.102.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64ca1bc8749bd4cf37b5ce386cc146580777b4e8572c7b97baf22c83f444bee9" +dependencies = [ + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sct" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" +dependencies = [ + "ring", + "untrusted", +] + +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags 2.11.1", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.11.1", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_nanos" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a93142f0367a4cc53ae0fead1bcda39e85beccfad3dcd717656cacab94b12985" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "service-manager" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cae942acfe9cecd4450998408f52e1c1ee083145226b7b803bd0d82e1c86912" +dependencies = [ + "cfg-if", + "dirs", + "encoding-utils", + "encoding_rs", + "plist", + "which", + "xml-rs", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signatory" +version = "0.27.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1e303f8205714074f6068773f0e29527e0453937fe837c9717d066635b65f31" +dependencies = [ + "pkcs8", + "rand_core", + "signature", + "zeroize", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core", +] + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "symlink" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "system-configuration" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75fb188eb626b924683e3b95e3a48e63551fcfb51949de2f06a9d91dbee93c9" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tar" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973" +dependencies = [ + "filetime", + "libc", + "xattr", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.52.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "110a78583f19d5cdb2c5ccf321d1290344e71313c6c37d43520d386027d18386" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2 0.6.3", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" +dependencies = [ + "rustls 0.21.12", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls 0.23.40", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-websockets" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f591660438b3038dd04d16c938271c79e7e06260ad2ea2885a4861bfb238605d" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-sink", + "http 1.4.0", + "httparse", + "rand", + "ring", + "rustls-pki-types", + "tokio", + "tokio-rustls 0.26.4", + "tokio-util", + "webpki-roots 0.26.11", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-appender" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c" +dependencies = [ + "crossbeam-channel", + "symlink", + "thiserror 2.0.18", + "time", + "tracing-subscriber", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "tryhard" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fe58ebd5edd976e0fe0f8a14d2a04b7c81ef153ea9a54eebc42e67c2c23b4e5" +dependencies = [ + "pin-project-lite", + "tokio", +] + +[[package]] +name = "typenum" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vswhom" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be979b7f07507105799e854203b470ff7c78a1639e330a58f183b5fea574608b" +dependencies = [ + "libc", + "vswhom-sys", +] + +[[package]] +name = "vswhom-sys" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb067e4cbd1ff067d1df46c9194b5de0e98efd2810bbc95c5d5e5f25a3231150" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.120" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df52b6d9b87e0c74c9edfa1eb2d9bf85e5d63515474513aa50fa181b3c4f5db1" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.70" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af934872acec734c2d80e6617bbb5ff4f12b052dd8e6332b0817bce889516084" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.120" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78b1041f495fb322e64aca85f5756b2172e35cd459376e67f2a6c9dffcedb103" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.120" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dcd0ff20416988a18ac686d4d4d0f6aae9ebf08a389ff5d29012b05af2a1b41" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.120" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49757b3c82ebf16c57d69365a142940b384176c24df52a087fb748e2085359ea" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.11.1", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.97" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2eadbac71025cd7b0834f20d1fe8472e8495821b4e9801eb0a60bd1f19827602" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.7", +] + +[[package]] +name = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "which" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87ba24419a2078cd2b0f2ede2691b6c66d8e47836da3b6db8265ebad47afbfc7" +dependencies = [ + "either", + "home", + "once_cell", + "rustix 0.38.44", +] + +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e48a53791691ab099e5e2ad123536d0fff50652600abaf43bbf952894110d0be" +dependencies = [ + "windows-core 0.52.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-service" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd9db37ecb5b13762d95468a2fc6009d4b2c62801243223aabd44fca13ad13c8" +dependencies = [ + "bitflags 1.3.2", + "widestring", + "windows-sys 0.45.0", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "winreg" +version = "0.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + +[[package]] +name = "winreg" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a277a57398d4bfa075df44f501a17cfdf8542d224f0d36095a2adc7aee4ef0a5" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.11.1", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix 1.1.4", +] + +[[package]] +name = "xml-rs" +version = "0.8.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" + +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zip" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "760394e246e4c28189f19d488c058bf16f564016aefac5d32bb1f3b51d5e9261" +dependencies = [ + "aes", + "byteorder", + "bzip2", + "constant_time_eq", + "crc32fast", + "crossbeam-utils", + "flate2", + "hmac", + "pbkdf2", + "sha1", + "time", + "zstd", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zstd" +version = "0.11.2+zstd.1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20cc960326ece64f010d2d2107537f26dc589a6573a316bd5b1dba685fa5fde4" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "5.0.2+zstd.1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d2a5585e04f9eea4b2a3d1eca508c4dee9592a89ef6f450c11719da0726f4db" +dependencies = [ + "libc", + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/clients/openframe-client-updater/Cargo.toml b/clients/openframe-client-updater/Cargo.toml new file mode 100644 index 000000000..54f45c35e --- /dev/null +++ b/clients/openframe-client-updater/Cargo.toml @@ -0,0 +1,64 @@ +[package] +name = "openframe-client-updater" +version = "0.1.0" +edition = "2021" +authors = ["Flamingo Team"] +description = "OpenFrame - Client updater service" +license = "MIT" + +[[bin]] +name = "openframe-client-updater" +path = "src/main.rs" + +[lib] +name = "openframe_updater" +path = "src/lib.rs" + +[dependencies] +tokio = { version = "1.0", features = ["full", "macros", "rt-multi-thread", "sync"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +tracing = { version = "0.1", features = ["attributes", "async-await"] } +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +tracing-appender = "0.2" +anyhow = "1.0" +thiserror = "1.0" +reqwest = { version = "0.11", features = ["json", "native-tls"] } +async-nats = { git = "https://github.com/flamingo-stack/nats.rs.git", branch = "main", features = ["websockets"] } +futures = "0.3" +chrono = { version = "0.4", features = ["serde"] } +semver = "1.0" +uuid = { version = "1.7", features = ["v4"] } +bytes = "1.10.1" +zip = "0.6" +flate2 = "1.0" +tar = "0.4" +clap = { version = "4.5", features = ["derive", "env", "std"] } +directories = "5.0" +service-manager = "0.8" +plist = "1.7.1" +rustls-pemfile = "1.0" +aes-gcm = "0.10" +base64 = "0.21" +tokio-rustls = "0.24" + +[target.'cfg(windows)'.dependencies] +winreg = "0.52" +is_elevated = "0.1" +windows-service = "0.6" +windows = { version = "0.52", features = [ + "Win32_Foundation", + "Win32_System_Services", + "Win32_System_Threading", + "Win32_UI_WindowsAndMessaging", +] } +winapi = { version = "0.3", features = ["winuser", "shellapi", "securitybaseapi", "errhandlingapi", "winerror"] } + +[target.'cfg(unix)'.dependencies] +libc = "0.2" + +[dev-dependencies] +tempfile = "3.10" + +[build-dependencies] +embed-resource = "2.4" diff --git a/clients/openframe-client-updater/Makefile b/clients/openframe-client-updater/Makefile new file mode 100644 index 000000000..f2e7f6b72 --- /dev/null +++ b/clients/openframe-client-updater/Makefile @@ -0,0 +1,29 @@ +CARGO ?= cargo +OPENFRAME_VERSION ?= 0.0.0-dev + +TARGET ?= +ifneq ($(TARGET),) + TARGET_FLAG := --target $(TARGET) +endif + +.PHONY: fmt fmt-check clippy lint build test clean + +fmt: + $(CARGO) fmt --all + +fmt-check: + $(CARGO) fmt --all -- --check + +clippy: + OPENFRAME_UPDATER_VERSION=$(OPENFRAME_VERSION) $(CARGO) clippy --all-targets -- -D warnings + +lint: fmt-check clippy + +build: + OPENFRAME_UPDATER_VERSION=$(OPENFRAME_VERSION) $(CARGO) build --release --locked $(TARGET_FLAG) + +test: build + OPENFRAME_UPDATER_VERSION=$(OPENFRAME_VERSION) $(CARGO) test --release --locked + +clean: + $(CARGO) clean diff --git a/clients/openframe-client-updater/build.rs b/clients/openframe-client-updater/build.rs new file mode 100644 index 000000000..484b8fe7a --- /dev/null +++ b/clients/openframe-client-updater/build.rs @@ -0,0 +1,6 @@ +fn main() { + let version = + std::env::var("OPENFRAME_UPDATER_VERSION").unwrap_or_else(|_| "0.1.0".to_string()); + println!("cargo:rustc-env=OPENFRAME_UPDATER_VERSION={version}"); + println!("cargo:rerun-if-env-changed=OPENFRAME_UPDATER_VERSION"); +} diff --git a/clients/openframe-client-updater/src/clients/auth_client.rs b/clients/openframe-client-updater/src/clients/auth_client.rs new file mode 100644 index 000000000..bb8d3d9f8 --- /dev/null +++ b/clients/openframe-client-updater/src/clients/auth_client.rs @@ -0,0 +1,65 @@ +use anyhow::{Context, Result}; +use reqwest::{ + header::{HeaderMap, HeaderValue}, + Client, +}; +use std::collections::HashMap; + +use crate::models::AgentTokenResponse; + +#[derive(Clone)] +pub struct AuthClient { + http_client: Client, + base_url: String, +} + +impl AuthClient { + pub fn new(base_url: String, http_client: Client) -> Self { + Self { + http_client, + base_url, + } + } + + pub async fn authenticate_with_secret( + &self, + client_id: String, + client_secret: String, + ) -> Result { + let url = format!("{}/clients/oauth/token", self.base_url); + + let mut headers = HeaderMap::new(); + headers.insert( + "Content-Type", + HeaderValue::from_static("application/x-www-form-urlencoded"), + ); + + let mut form_data = HashMap::new(); + form_data.insert("grant_type", "client_credentials".to_string()); + form_data.insert("client_id", client_id); + form_data.insert("client_secret", client_secret); + + let response = self + .http_client + .post(&url) + .headers(headers) + .form(&form_data) + .send() + .await + .context("Failed to send token request")?; + + let status = response.status(); + if !status.is_success() { + return Err(anyhow::anyhow!( + "Failed to obtain access token: status {} body {}", + status, + response.text().await? + )); + } + + response + .json::() + .await + .context("Failed to parse token response") + } +} diff --git a/clients/openframe-client-updater/src/clients/mod.rs b/clients/openframe-client-updater/src/clients/mod.rs new file mode 100644 index 000000000..27e572721 --- /dev/null +++ b/clients/openframe-client-updater/src/clients/mod.rs @@ -0,0 +1,3 @@ +pub mod auth_client; + +pub use auth_client::AuthClient; diff --git a/clients/openframe-client-updater/src/config/mod.rs b/clients/openframe-client-updater/src/config/mod.rs new file mode 100644 index 000000000..f62eee8d4 --- /dev/null +++ b/clients/openframe-client-updater/src/config/mod.rs @@ -0,0 +1,2 @@ +pub mod updater_config; +pub use updater_config::*; diff --git a/clients/openframe-client-updater/src/config/updater_config.rs b/clients/openframe-client-updater/src/config/updater_config.rs new file mode 100644 index 000000000..6c4bc06ef --- /dev/null +++ b/clients/openframe-client-updater/src/config/updater_config.rs @@ -0,0 +1,72 @@ +// Download retry settings +pub const MAX_DOWNLOAD_RETRIES: u32 = 3; +pub const DOWNLOAD_TIMEOUT_SECS: u64 = 300; +/// The download service's own HTTP client budget — must not be lower than +/// DOWNLOAD_TIMEOUT_SECS or the reqwest timeout fires first and shrinks the +/// download window (the general-purpose client keeps its shorter timeout). +pub const DOWNLOAD_CLIENT_TIMEOUT_SECS: u64 = 300; +pub const MIN_BINARY_SIZE_BYTES: u64 = 100 * 1024; // 100 KB + +// NATS consumer settings +pub const CLIENT_UPDATE_STREAM: &str = "CLIENT_UPDATE"; +pub const CLIENT_UPDATE_FILTER_SUBJECT: &str = "machine.all.client-update"; +/// Must comfortably exceed a normal update's time-to-ACK (download + service +/// stop + swap + up to 90s boot wait) — at 120s the same message was +/// redelivered mid-update and killed the observation window right after boot. +pub const CLIENT_UPDATE_ACK_WAIT_SECS: u64 = 600; +pub const CLIENT_UPDATE_MAX_DELIVER: i64 = 10; +pub const RECONNECTION_DELAY_MS: u64 = 5000; + +// Consumer creation retry settings +pub const CONSUMER_RETRY_ATTEMPTS_PER_CYCLE: u32 = 5; +pub const CONSUMER_INITIAL_RETRY_DELAY_MS: u64 = 1000; +pub const CONSUMER_MAX_RETRY_DELAY_MS: u64 = 30000; +pub const CONSUMER_CYCLE_PAUSE_MS: u64 = 30000; + +// Service stop/start timeouts +pub const SERVICE_STOP_TIMEOUT_SECS: u64 = 30; +pub const SERVICE_START_TIMEOUT_SECS: u64 = 30; + +// After starting the client service, wait this long before checking Running state +pub const SERVICE_START_VERIFY_WAIT_SECS: u64 = 5; + +// Last-known-good update ratchet — parity with the in-client updater (Hotfix #2169). +/// How long to wait for the new client binary to write its boot marker. +pub const BOOT_MARKER_WAIT_SECS: u64 = 90; +/// Poll interval while waiting for the boot marker. +pub const BOOT_MARKER_POLL_INTERVAL_SECS: u64 = 2; +/// Settle time between service start and the first Running-state check. +pub const SERVICE_START_SETTLE_SECS: u64 = 3; +/// Refuse update messages below the LKG anchor (flip to force a downgrade). +pub const ALLOW_DOWNGRADE: bool = false; +/// Attempts to restore a binary during rollback (file may be briefly locked). +pub const ROLLBACK_RESTORE_ATTEMPTS: u32 = 3; +/// Delay between rollback restore attempts. +pub const ROLLBACK_RESTORE_RETRY_DELAY_SECS: u64 = 2; + +// Post-boot observation — automatic rollback when the new client degrades +// after a verified boot (stops running, crash-loops). The anchor is promoted +// only after the window passes, so a backend-pushed downgrade is never blocked +// by a bad version that slipped past the boot check. +/// How long to watch the new client after its boot marker verified. +pub const POST_BOOT_OBSERVATION_SECS: u64 = 600; +/// Poll interval during the observation window. +pub const POST_BOOT_POLL_INTERVAL_SECS: u64 = 10; +/// Client restarts (boot-marker rewrites) tolerated inside the window. +pub const OBSERVATION_MAX_CLIENT_RESTARTS: u32 = 3; + +// Atomic binary replace: retries with backoff on Windows file locking +pub const REPLACE_MAX_RETRIES: u32 = 10; +pub const REPLACE_RETRY_DELAY_MS: u64 = 500; + +// Subject patterns — format with machine_id at runtime +pub const SUBJECT_UPDATE_PROGRESS: &str = "machine.{machine_id}.client-update-progress"; +pub const SUBJECT_INSTALLED_AGENT: &str = "machine.{machine_id}.installed-agent"; + +// The service name of the main client — used by ServiceManagerService to stop/start it +pub const CLIENT_SERVICE_FULL_NAME: &str = "com.openframe.client"; + +// The updater's own service name +pub const UPDATER_SERVICE_FULL_NAME: &str = "com.openframe.client-updater"; + +pub const UPDATER_VERSION: &str = env!("OPENFRAME_UPDATER_VERSION"); diff --git a/clients/openframe-client-updater/src/lib.rs b/clients/openframe-client-updater/src/lib.rs new file mode 100644 index 000000000..3d7d335aa --- /dev/null +++ b/clients/openframe-client-updater/src/lib.rs @@ -0,0 +1,11 @@ +pub mod clients; +pub mod config; +pub mod listener; +pub mod models; +pub mod platform; +pub mod service; +pub mod service_adapter; +pub mod services; +pub mod utils; + +pub use services::updater_orchestrator::UpdaterOrchestrator; diff --git a/clients/openframe-client-updater/src/listener/client_update_listener.rs b/clients/openframe-client-updater/src/listener/client_update_listener.rs new file mode 100644 index 000000000..227de2074 --- /dev/null +++ b/clients/openframe-client-updater/src/listener/client_update_listener.rs @@ -0,0 +1,213 @@ +use anyhow::Result; +use async_nats::jetstream::{self, consumer::push, consumer::DeliverPolicy, Message}; +use futures::StreamExt; +use tokio::time::Duration; +use tracing::{error, info, warn}; + +use crate::config::updater_config::{ + CLIENT_UPDATE_ACK_WAIT_SECS, CLIENT_UPDATE_MAX_DELIVER, CLIENT_UPDATE_STREAM, + CONSUMER_CYCLE_PAUSE_MS, CONSUMER_INITIAL_RETRY_DELAY_MS, CONSUMER_MAX_RETRY_DELAY_MS, + CONSUMER_RETRY_ATTEMPTS_PER_CYCLE, RECONNECTION_DELAY_MS, +}; +use crate::models::ClientUpdateMessage; +use crate::services::{AgentConfigurationService, ClientUpdateService, NatsConnectionManager}; + +#[derive(Clone)] +pub struct ClientUpdateListener { + nats: NatsConnectionManager, + update_service: ClientUpdateService, + config_service: AgentConfigurationService, +} + +impl ClientUpdateListener { + pub fn new( + nats: NatsConnectionManager, + update_service: ClientUpdateService, + config_service: AgentConfigurationService, + ) -> Self { + Self { + nats, + update_service, + config_service, + } + } + + pub async fn start(&self) -> tokio::task::JoinHandle<()> { + let listener = self.clone(); + tokio::spawn(async move { + loop { + info!("Starting CLIENT_UPDATE listener"); + match listener.listen().await { + Ok(_) => warn!("CLIENT_UPDATE listener exited unexpectedly"), + Err(e) => error!("CLIENT_UPDATE listener error: {:#}", e), + } + info!("Reconnecting in {}ms", RECONNECTION_DELAY_MS); + tokio::time::sleep(Duration::from_millis(RECONNECTION_DELAY_MS)).await; + } + }) + } + + async fn listen(&self) -> Result<()> { + let machine_id = self.config_service.get_machine_id().await?; + + loop { + let client = self.nats.get_client().await?; + let mut reconnect_rx = self.nats.subscribe_reconnect(); + let js = jetstream::new((*client).clone()); + + let consumer = self.acquire_consumer(&js, &machine_id).await; + + info!( + "Listening for CLIENT_UPDATE messages (machine_id={})", + machine_id + ); + + let mut messages = consumer.messages().await?; + loop { + tokio::select! { + msg_result = messages.next() => { + match msg_result { + Some(Ok(msg)) => { + if let Err(e) = self.handle_message(msg).await { + error!("Message handling error: {:#}", e); + } + } + Some(Err(e)) => { + error!("Message stream error, recreating consumer: {:#}", e); + return Err(anyhow::anyhow!("Message stream error: {}", e)); + } + None => { + warn!("Message stream ended, rebinding consumer"); + break; + } + } + } + _ = reconnect_rx.recv() => { + info!("NATS reconnected, re-provisioning CLIENT_UPDATE consumer"); + self.acquire_consumer(&js, &machine_id).await; + } + } + } + } + } + + async fn handle_message(&self, message: Message) -> Result<()> { + let payload = String::from_utf8_lossy(&message.payload); + info!("Received CLIENT_UPDATE message: {}", payload); + + let update_msg: ClientUpdateMessage = match serde_json::from_str(&payload) { + Ok(m) => m, + Err(e) => { + error!("Failed to parse CLIENT_UPDATE message: {:#}", e); + // ACK immediately — a malformed message will never become valid + if let Err(e) = message.ack().await { + warn!("Failed to ACK malformed message: {}", e); + } + return Ok(()); + } + }; + + let version = update_msg.version.clone(); + + match self.update_service.process_update(update_msg).await { + Ok(_) => { + info!("ACKing successful update message for v{}", version); + message + .ack() + .await + .map_err(|e| anyhow::anyhow!("Failed to ACK message: {}", e))?; + } + Err(e) => { + error!( + "Update failed for v{}: {:#} — leaving unacked for redelivery", + version, e + ); + } + } + + Ok(()) + } + + async fn acquire_consumer( + &self, + js: &jetstream::Context, + machine_id: &str, + ) -> async_nats::jetstream::consumer::PushConsumer { + let config = self.build_consumer_config(machine_id); + let durable = Self::durable_name(machine_id); + let mut cycle = 0u32; + + loop { + cycle += 1; + let mut delay_ms = CONSUMER_INITIAL_RETRY_DELAY_MS; + + for attempt in 1..=CONSUMER_RETRY_ATTEMPTS_PER_CYCLE { + info!( + "Consumer create attempt {}/{} (cycle {})", + attempt, CONSUMER_RETRY_ATTEMPTS_PER_CYCLE, cycle + ); + + match js + .create_consumer_on_stream(config.clone(), CLIENT_UPDATE_STREAM) + .await + { + Ok(consumer) => { + info!("Consumer ready on stream {}", CLIENT_UPDATE_STREAM); + return consumer; + } + Err(e) => { + let msg = format!("{:?}", e); + if msg.contains("consumer name already in use") || msg.contains("10013") { + warn!("Consumer exists — attaching to existing"); + if let Ok(c) = js + .get_consumer_from_stream(CLIENT_UPDATE_STREAM, &durable) + .await + { + info!("Attached to existing consumer"); + return c; + } + } + + if attempt < CONSUMER_RETRY_ATTEMPTS_PER_CYCLE { + warn!( + "Attempt {}/{} failed: {:#}. Retrying in {}ms", + attempt, CONSUMER_RETRY_ATTEMPTS_PER_CYCLE, e, delay_ms + ); + tokio::time::sleep(Duration::from_millis(delay_ms)).await; + delay_ms = (delay_ms * 2).min(CONSUMER_MAX_RETRY_DELAY_MS); + } else { + warn!("All attempts in cycle {} failed: {:#}", cycle, e); + } + } + } + } + + info!("Pausing {}ms before next cycle", CONSUMER_CYCLE_PAUSE_MS); + tokio::time::sleep(Duration::from_millis(CONSUMER_CYCLE_PAUSE_MS)).await; + } + } + + // Deliberately NOT the old in-client listener's durable/inbox: sharing them + // would fan every delivery out to both a pre-migration client and this + // updater, and both would swap the same binary. A fresh durable with + // DeliverPolicy::New also leaves pending pre-migration updates to the old + // consumer. + fn build_consumer_config(&self, machine_id: &str) -> push::Config { + push::Config { + filter_subject: "machine.all.client-update".to_string(), + deliver_subject: format!("machine.{}.client-updater.client-update.inbox", machine_id), + durable_name: Some(Self::durable_name(machine_id)), + ack_wait: Duration::from_secs(CLIENT_UPDATE_ACK_WAIT_SECS), + deliver_policy: DeliverPolicy::New, + max_deliver: CLIENT_UPDATE_MAX_DELIVER, + ..Default::default() + } + } + + fn durable_name(machine_id: &str) -> String { + format!( + "machine_{}_client-updater_client-update_consumer", + machine_id + ) + } +} diff --git a/clients/openframe-client-updater/src/listener/mod.rs b/clients/openframe-client-updater/src/listener/mod.rs new file mode 100644 index 000000000..ae9dfbf32 --- /dev/null +++ b/clients/openframe-client-updater/src/listener/mod.rs @@ -0,0 +1,3 @@ +pub mod client_update_listener; + +pub use client_update_listener::ClientUpdateListener; diff --git a/clients/openframe-client-updater/src/main.rs b/clients/openframe-client-updater/src/main.rs new file mode 100644 index 000000000..37a13aa5d --- /dev/null +++ b/clients/openframe-client-updater/src/main.rs @@ -0,0 +1,204 @@ +use anyhow::Result; +use clap::{Parser, Subcommand}; +use openframe_updater::platform::directories::DirectoryManager; +use openframe_updater::platform::permissions::PermissionUtils; +use openframe_updater::service::UpdaterService; +use std::path::PathBuf; +use std::process; +use tokio::runtime::Runtime; +use tracing::{error, info}; +use tracing_appender::non_blocking::WorkerGuard; +use tracing_subscriber::layer::SubscriberExt; +use tracing_subscriber::util::SubscriberInitExt; + +const TOOL_AGENT_ID: &str = "openframe-client-updater"; + +#[cfg(unix)] +fn ensure_admin_privileges() { + if unsafe { libc::geteuid() } != 0 { + eprintln!("Please run with administrator/root privileges"); + process::exit(1); + } +} + +#[cfg(windows)] +fn ensure_admin_privileges() { + if !PermissionUtils::is_admin() { + eprintln!("Please run with administrator privileges"); + process::exit(1); + } +} + +#[derive(Parser)] +#[command(author, version, about = "OpenFrame Client Updater Service")] +struct Cli { + #[command(subcommand)] + command: Option, +} + +#[derive(Subcommand)] +enum Commands { + /// Install the updater as an OS service (main client must be installed first) + Install, + /// Uninstall the updater service + Uninstall, + /// Run the updater directly (foreground / dev mode) + Run, + /// Run as a service (called by the OS service manager) + #[command(hide = true)] + RunAsService, +} + +/// Resolves the log file path: +/// {app_support_dir}/openframe-client-updater/openframe-client-updater.log +/// This mirrors the mesh pattern: {app_support_dir}/meshcentral-agent/meshcentral-agent.log +fn log_file_path(dir_manager: &DirectoryManager) -> PathBuf { + dir_manager + .app_support_dir() + .join(TOOL_AGENT_ID) + .join(format!("{}.log", TOOL_AGENT_ID)) +} + +/// Initialises tracing with two layers: +/// - stdout (compact, for service manager capture) +/// - file (non-blocking, same path openframe-client will tail) +/// +/// Returns the `WorkerGuard` — drop it only when the process exits. +fn init_logging(dir_manager: &DirectoryManager) -> WorkerGuard { + let log_path = log_file_path(dir_manager); + + // Ensure the tool folder exists before opening the file. + if let Some(parent) = log_path.parent() { + if let Err(e) = std::fs::create_dir_all(parent) { + eprintln!("Failed to create log directory {}: {}", parent.display(), e); + } + } + + let file = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&log_path) + .unwrap_or_else(|e| { + eprintln!("Failed to open log file {}: {}", log_path.display(), e); + process::exit(1); + }); + + let (file_writer, guard) = tracing_appender::non_blocking::NonBlockingBuilder::default() + .lossy(false) + .thread_name("updater-log-writer") + .finish(file); + + let env_filter = tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")); + + let stdout_layer = tracing_subscriber::fmt::layer() + .with_target(true) + .with_level(true) + .compact() + .with_ansi(false); + + let file_layer = tracing_subscriber::fmt::layer() + .with_target(true) + .with_level(true) + .compact() + .with_ansi(false) + .with_writer(file_writer); + + tracing_subscriber::registry() + .with(env_filter) + .with(stdout_layer) + .with(file_layer) + .init(); + + guard +} + +fn main() -> Result<()> { + ensure_admin_privileges(); + + let dir_manager = if std::env::var("OPENFRAME_DEV_MODE").is_ok() { + DirectoryManager::for_development() + } else { + DirectoryManager::new() + }; + + // Guard must be held for the entire process lifetime — dropping it flushes + // and closes the file writer thread. + let _log_guard = init_logging(&dir_manager); + + info!( + "OpenFrame Client Updater v{}", + openframe_updater::config::updater_config::UPDATER_VERSION + ); + info!("Log file: {}", log_file_path(&dir_manager).display()); + + let cli = Cli::parse(); + let rt = Runtime::new()?; + + match cli.command { + Some(Commands::Install) => { + if !PermissionUtils::is_admin() { + error!("Admin privileges required for installation"); + process::exit(1); + } + rt.block_on(async { + match UpdaterService::install().await { + Ok(_) => { + info!("OpenFrame Client Updater installed successfully"); + process::exit(0); + } + Err(e) => { + error!("Installation failed: {:#}", e); + process::exit(1); + } + } + }); + } + + Some(Commands::Uninstall) => { + if !PermissionUtils::is_admin() { + error!("Admin privileges required for uninstallation"); + process::exit(1); + } + rt.block_on(async { + match UpdaterService::uninstall().await { + Ok(_) => { + info!("OpenFrame Client Updater uninstalled successfully"); + process::exit(0); + } + Err(e) => { + error!("Uninstallation failed: {:#}", e); + process::exit(1); + } + } + }); + } + + Some(Commands::Run) => { + info!("Running in foreground mode"); + if let Err(e) = rt.block_on(UpdaterService::run()) { + error!("Updater failed: {:#}", e); + process::exit(1); + } + } + + Some(Commands::RunAsService) => { + info!("Starting as OS service"); + if let Err(e) = UpdaterService::run_as_service() { + error!("Service failed: {:#}", e); + process::exit(1); + } + } + + None => { + info!("No command specified, running as service (legacy mode)"); + if let Err(e) = rt.block_on(UpdaterService::run()) { + error!("Updater failed: {:#}", e); + process::exit(1); + } + } + } + + info!("OpenFrame Client Updater shutting down"); + Ok(()) +} diff --git a/clients/openframe-client-updater/src/models/agent_configuration.rs b/clients/openframe-client-updater/src/models/agent_configuration.rs new file mode 100644 index 000000000..87c71112f --- /dev/null +++ b/clients/openframe-client-updater/src/models/agent_configuration.rs @@ -0,0 +1,10 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +pub struct AgentConfiguration { + pub machine_id: String, + pub client_id: String, + pub client_secret: String, + pub access_token: String, + pub refresh_token: String, +} diff --git a/clients/openframe-client-updater/src/models/agent_token_response.rs b/clients/openframe-client-updater/src/models/agent_token_response.rs new file mode 100644 index 000000000..8a7473323 --- /dev/null +++ b/clients/openframe-client-updater/src/models/agent_token_response.rs @@ -0,0 +1,10 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentTokenResponse { + pub access_token: String, + pub refresh_token: String, + pub token_type: String, + pub expires_in: Option, +} diff --git a/clients/openframe-client-updater/src/models/client_update_message.rs b/clients/openframe-client-updater/src/models/client_update_message.rs new file mode 100644 index 000000000..f330c3db4 --- /dev/null +++ b/clients/openframe-client-updater/src/models/client_update_message.rs @@ -0,0 +1,19 @@ +use serde::{Deserialize, Serialize}; + +use super::DownloadConfiguration; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ClientUpdateMessage { + pub version: String, + pub download_configurations: Vec, + /// Backend-initiated rollback: bypasses the downgrade guard, and when the + /// requested version matches the local last-known-good reserve the binary + /// is restored from disk without downloading. + #[serde(default)] + pub rollback: bool, +} + +#[cfg(test)] +#[path = "client_update_message_tests.rs"] +mod tests; diff --git a/clients/openframe-client-updater/src/models/client_update_message_tests.rs b/clients/openframe-client-updater/src/models/client_update_message_tests.rs new file mode 100644 index 000000000..e2a2ba86c --- /dev/null +++ b/clients/openframe-client-updater/src/models/client_update_message_tests.rs @@ -0,0 +1,36 @@ +use super::*; + +#[test] +fn deserializes_backend_camel_case() { + let json = r#"{ + "version": "1.2.3", + "downloadConfigurations": [{ + "os": "WINDOWS", + "fileName": "openframe-client_windows.zip", + "targetFileName": "openframe-client.exe", + "link": "https://github.com/flamingo-stack/openframe-oss-tenant/releases/download/1.2.3/openframe-client_windows.zip" + }] + }"#; + + let msg: ClientUpdateMessage = serde_json::from_str(json).unwrap(); + assert_eq!(msg.version, "1.2.3"); + assert_eq!(msg.download_configurations.len(), 1); + assert_eq!( + msg.download_configurations[0].target_file_name, + "openframe-client.exe" + ); +} + +#[test] +fn rollback_defaults_to_false_when_absent() { + let json = r#"{"version": "1.0.0", "downloadConfigurations": []}"#; + let msg: ClientUpdateMessage = serde_json::from_str(json).unwrap(); + assert!(!msg.rollback); +} + +#[test] +fn rollback_true_is_parsed() { + let json = r#"{"version": "1.0.0", "downloadConfigurations": [], "rollback": true}"#; + let msg: ClientUpdateMessage = serde_json::from_str(json).unwrap(); + assert!(msg.rollback); +} diff --git a/clients/openframe-client-updater/src/models/download_configuration.rs b/clients/openframe-client-updater/src/models/download_configuration.rs new file mode 100644 index 000000000..e050b4f6e --- /dev/null +++ b/clients/openframe-client-updater/src/models/download_configuration.rs @@ -0,0 +1,30 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DownloadConfiguration { + pub os: String, + pub file_name: String, + #[serde(alias = "agentFileName", alias = "assetFileName")] + pub target_file_name: String, + pub link: String, +} + +impl DownloadConfiguration { + pub fn matches_current_os(&self) -> bool { + let current_os = if cfg!(target_os = "windows") { + "windows" + } else if cfg!(target_os = "macos") { + "macos" + } else if cfg!(target_os = "linux") { + "linux" + } else { + return false; + }; + self.os.eq_ignore_ascii_case(current_os) + } +} + +#[cfg(test)] +#[path = "download_configuration_tests.rs"] +mod tests; diff --git a/clients/openframe-client-updater/src/models/download_configuration_tests.rs b/clients/openframe-client-updater/src/models/download_configuration_tests.rs new file mode 100644 index 000000000..d412fefd8 --- /dev/null +++ b/clients/openframe-client-updater/src/models/download_configuration_tests.rs @@ -0,0 +1,46 @@ +use super::*; + +fn config(os: &str) -> DownloadConfiguration { + DownloadConfiguration { + os: os.to_string(), + file_name: "archive.zip".to_string(), + target_file_name: "binary".to_string(), + link: "https://example.com/archive.zip".to_string(), + } +} + +#[test] +fn matches_current_os_case_insensitively() { + let current = if cfg!(target_os = "windows") { + "WINDOWS" + } else if cfg!(target_os = "macos") { + "MacOS" + } else { + "Linux" + }; + assert!(config(current).matches_current_os()); + assert!(config(¤t.to_lowercase()).matches_current_os()); +} + +#[test] +fn does_not_match_other_os() { + assert!(!config("solaris").matches_current_os()); + let other = if cfg!(target_os = "windows") { + "macos" + } else { + "windows" + }; + assert!(!config(other).matches_current_os()); +} + +#[test] +fn accepts_legacy_target_file_name_aliases() { + for key in ["targetFileName", "agentFileName", "assetFileName"] { + let json = format!( + r#"{{"os": "windows", "fileName": "a.zip", "{}": "client.exe", "link": "https://x"}}"#, + key + ); + let cfg: DownloadConfiguration = serde_json::from_str(&json).unwrap(); + assert_eq!(cfg.target_file_name, "client.exe", "alias {} failed", key); + } +} diff --git a/clients/openframe-client-updater/src/models/initial_configuration.rs b/clients/openframe-client-updater/src/models/initial_configuration.rs new file mode 100644 index 000000000..11d99b3b2 --- /dev/null +++ b/clients/openframe-client-updater/src/models/initial_configuration.rs @@ -0,0 +1,16 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Serialize, Deserialize, Default)] +pub struct InitialConfiguration { + pub server_host: String, + pub initial_key: String, + pub local_mode: bool, + #[serde(default)] + pub org_id: String, + #[serde(default)] + pub local_ca_cert_path: String, +} + +#[cfg(test)] +#[path = "initial_configuration_tests.rs"] +mod tests; diff --git a/clients/openframe-client-updater/src/models/initial_configuration_tests.rs b/clients/openframe-client-updater/src/models/initial_configuration_tests.rs new file mode 100644 index 000000000..33ad20342 --- /dev/null +++ b/clients/openframe-client-updater/src/models/initial_configuration_tests.rs @@ -0,0 +1,28 @@ +use super::*; + +#[test] +fn optional_fields_default_when_absent() { + let json = r#"{"server_host": "of.example.com", "initial_key": "k", "local_mode": false}"#; + let cfg: InitialConfiguration = serde_json::from_str(json).unwrap(); + assert_eq!(cfg.server_host, "of.example.com"); + assert!(!cfg.local_mode); + assert_eq!(cfg.org_id, ""); + assert_eq!(cfg.local_ca_cert_path, ""); +} + +#[test] +fn full_configuration_round_trips() { + let cfg = InitialConfiguration { + server_host: "localhost:8080".to_string(), + initial_key: "secret".to_string(), + local_mode: true, + org_id: "org-1".to_string(), + local_ca_cert_path: "/tmp/ca.pem".to_string(), + }; + let json = serde_json::to_string(&cfg).unwrap(); + let restored: InitialConfiguration = serde_json::from_str(&json).unwrap(); + assert_eq!(restored.server_host, cfg.server_host); + assert_eq!(restored.local_mode, cfg.local_mode); + assert_eq!(restored.org_id, cfg.org_id); + assert_eq!(restored.local_ca_cert_path, cfg.local_ca_cert_path); +} diff --git a/clients/openframe-client-updater/src/models/installed_agent_message.rs b/clients/openframe-client-updater/src/models/installed_agent_message.rs new file mode 100644 index 000000000..11831c2db --- /dev/null +++ b/clients/openframe-client-updater/src/models/installed_agent_message.rs @@ -0,0 +1,8 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct InstalledAgentMessage { + pub agent_type: String, + pub version: String, +} diff --git a/clients/openframe-client-updater/src/models/mod.rs b/clients/openframe-client-updater/src/models/mod.rs new file mode 100644 index 000000000..beb54dafa --- /dev/null +++ b/clients/openframe-client-updater/src/models/mod.rs @@ -0,0 +1,17 @@ +pub mod agent_configuration; +pub mod agent_token_response; +pub mod client_update_message; +pub mod download_configuration; +pub mod initial_configuration; +pub mod installed_agent_message; +pub mod update_progress_message; +pub mod updater_state; + +pub use agent_configuration::AgentConfiguration; +pub use agent_token_response::AgentTokenResponse; +pub use client_update_message::ClientUpdateMessage; +pub use download_configuration::DownloadConfiguration; +pub use initial_configuration::InitialConfiguration; +pub use installed_agent_message::InstalledAgentMessage; +pub use update_progress_message::UpdateProgressMessage; +pub use updater_state::{UpdaterPhase, UpdaterState}; diff --git a/clients/openframe-client-updater/src/models/update_progress_message.rs b/clients/openframe-client-updater/src/models/update_progress_message.rs new file mode 100644 index 000000000..8e6f4ac14 --- /dev/null +++ b/clients/openframe-client-updater/src/models/update_progress_message.rs @@ -0,0 +1,41 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateProgressMessage { + pub phase: String, + pub version: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub reason: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub rolled_back: Option, +} + +impl UpdateProgressMessage { + pub fn new(phase: impl Into, version: impl Into) -> Self { + Self { + phase: phase.into(), + version: version.into(), + reason: None, + rolled_back: None, + } + } + + pub fn with_failure( + phase: impl Into, + version: impl Into, + reason: impl Into, + rolled_back: bool, + ) -> Self { + Self { + phase: phase.into(), + version: version.into(), + reason: Some(reason.into()), + rolled_back: Some(rolled_back), + } + } +} + +#[cfg(test)] +#[path = "update_progress_message_tests.rs"] +mod tests; diff --git a/clients/openframe-client-updater/src/models/update_progress_message_tests.rs b/clients/openframe-client-updater/src/models/update_progress_message_tests.rs new file mode 100644 index 000000000..8ddadafab --- /dev/null +++ b/clients/openframe-client-updater/src/models/update_progress_message_tests.rs @@ -0,0 +1,20 @@ +use super::*; + +#[test] +fn progress_message_omits_failure_fields() { + let msg = UpdateProgressMessage::new("downloading", "1.2.3"); + let json = serde_json::to_value(&msg).unwrap(); + assert_eq!(json["phase"], "downloading"); + assert_eq!(json["version"], "1.2.3"); + assert!(json.get("reason").is_none()); + assert!(json.get("rolledBack").is_none()); +} + +#[test] +fn failure_message_carries_reason_and_rolled_back_in_camel_case() { + let msg = UpdateProgressMessage::with_failure("failed", "1.2.3", "boot not verified", true); + let json = serde_json::to_value(&msg).unwrap(); + assert_eq!(json["phase"], "failed"); + assert_eq!(json["reason"], "boot not verified"); + assert_eq!(json["rolledBack"], true); +} diff --git a/clients/openframe-client-updater/src/models/updater_state.rs b/clients/openframe-client-updater/src/models/updater_state.rs new file mode 100644 index 000000000..fbf84298b --- /dev/null +++ b/clients/openframe-client-updater/src/models/updater_state.rs @@ -0,0 +1,72 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum UpdaterPhase { + Idle, + Downloading, + Verifying, + StoppingService, + ReplacingBinary, + StartingService, + VerifyingBoot, + Observing, + Completed, + Failed, + RollingBack, + RolledBack, +} + +impl std::fmt::Display for UpdaterPhase { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let s = match self { + UpdaterPhase::Idle => "idle", + UpdaterPhase::Downloading => "downloading", + UpdaterPhase::Verifying => "verifying", + UpdaterPhase::StoppingService => "stopping_service", + UpdaterPhase::ReplacingBinary => "replacing_binary", + UpdaterPhase::StartingService => "starting_service", + UpdaterPhase::VerifyingBoot => "verifying_boot", + UpdaterPhase::Observing => "observing", + UpdaterPhase::Completed => "completed", + UpdaterPhase::Failed => "failed", + UpdaterPhase::RollingBack => "rolling_back", + UpdaterPhase::RolledBack => "rolled_back", + }; + write!(f, "{}", s) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UpdaterState { + pub target_version: String, + pub phase: UpdaterPhase, + pub backup_path: Option, + pub downloaded_binary_path: Option, + pub started_at: String, + pub failure_reason: Option, +} + +impl UpdaterState { + pub fn new(target_version: String) -> Self { + Self { + target_version, + phase: UpdaterPhase::Idle, + backup_path: None, + downloaded_binary_path: None, + started_at: chrono::Utc::now().to_rfc3339(), + failure_reason: None, + } + } + + pub fn is_terminal(&self) -> bool { + matches!( + self.phase, + UpdaterPhase::Completed | UpdaterPhase::Failed | UpdaterPhase::RolledBack + ) + } +} + +#[cfg(test)] +#[path = "updater_state_tests.rs"] +mod tests; diff --git a/clients/openframe-client-updater/src/models/updater_state_tests.rs b/clients/openframe-client-updater/src/models/updater_state_tests.rs new file mode 100644 index 000000000..2da23bc68 --- /dev/null +++ b/clients/openframe-client-updater/src/models/updater_state_tests.rs @@ -0,0 +1,90 @@ +use super::*; + +// openframe-client's ordering guard (platform/client_update_probe.rs) parses +// updater_state.json and matches these exact strings — a rename here silently +// disables the guard on the client side. +#[test] +fn phase_serde_strings_match_client_probe_contract() { + let expected = [ + (UpdaterPhase::Idle, "idle"), + (UpdaterPhase::Downloading, "downloading"), + (UpdaterPhase::Verifying, "verifying"), + (UpdaterPhase::StoppingService, "stopping_service"), + (UpdaterPhase::ReplacingBinary, "replacing_binary"), + (UpdaterPhase::StartingService, "starting_service"), + (UpdaterPhase::VerifyingBoot, "verifying_boot"), + (UpdaterPhase::Observing, "observing"), + (UpdaterPhase::Completed, "completed"), + (UpdaterPhase::Failed, "failed"), + (UpdaterPhase::RollingBack, "rolling_back"), + (UpdaterPhase::RolledBack, "rolled_back"), + ]; + for (phase, s) in expected { + assert_eq!(serde_json::to_string(&phase).unwrap(), format!("\"{}\"", s)); + assert_eq!(phase.to_string(), s); + let parsed: UpdaterPhase = serde_json::from_str(&format!("\"{}\"", s)).unwrap(); + assert_eq!(parsed, phase); + } +} + +#[test] +fn only_completed_failed_and_rolled_back_are_terminal() { + let terminal = [ + UpdaterPhase::Completed, + UpdaterPhase::Failed, + UpdaterPhase::RolledBack, + ]; + let non_terminal = [ + UpdaterPhase::Idle, + UpdaterPhase::Downloading, + UpdaterPhase::Verifying, + UpdaterPhase::StoppingService, + UpdaterPhase::ReplacingBinary, + UpdaterPhase::StartingService, + UpdaterPhase::VerifyingBoot, + UpdaterPhase::Observing, + UpdaterPhase::RollingBack, + ]; + for phase in terminal { + let mut state = UpdaterState::new("1.0.0".to_string()); + state.phase = phase; + assert!(state.is_terminal()); + } + for phase in non_terminal { + let mut state = UpdaterState::new("1.0.0".to_string()); + state.phase = phase; + assert!(!state.is_terminal()); + } +} + +#[test] +fn new_state_starts_idle_and_empty() { + let state = UpdaterState::new("1.2.3".to_string()); + assert_eq!(state.target_version, "1.2.3"); + assert_eq!(state.phase, UpdaterPhase::Idle); + assert!(state.backup_path.is_none()); + assert!(state.downloaded_binary_path.is_none()); + assert!(state.failure_reason.is_none()); + assert!(!state.started_at.is_empty()); +} + +#[test] +fn state_round_trips_through_json() { + let mut state = UpdaterState::new("2.0.0".to_string()); + state.phase = UpdaterPhase::ReplacingBinary; + state.backup_path = Some("/tmp/openframe-client.backup".to_string()); + state.downloaded_binary_path = Some("/tmp/new-binary".to_string()); + state.failure_reason = Some("boom".to_string()); + + let json = serde_json::to_string(&state).unwrap(); + let restored: UpdaterState = serde_json::from_str(&json).unwrap(); + assert_eq!(restored.target_version, state.target_version); + assert_eq!(restored.phase, state.phase); + assert_eq!(restored.backup_path, state.backup_path); + assert_eq!( + restored.downloaded_binary_path, + state.downloaded_binary_path + ); + assert_eq!(restored.failure_reason, state.failure_reason); + assert_eq!(restored.started_at, state.started_at); +} diff --git a/clients/openframe-client-updater/src/platform/atomic_replace.rs b/clients/openframe-client-updater/src/platform/atomic_replace.rs new file mode 100644 index 000000000..9041038e8 --- /dev/null +++ b/clients/openframe-client-updater/src/platform/atomic_replace.rs @@ -0,0 +1,160 @@ +use anyhow::{anyhow, Context, Result}; +use std::path::{Path, PathBuf}; +use tracing::{info, warn}; + +use crate::config::updater_config::{REPLACE_MAX_RETRIES, REPLACE_RETRY_DELAY_MS}; + +/// Renames `target` to `backup_path`, then renames `new_binary` to `target`. +/// The caller persists `backup_path` before calling, so crash recovery can +/// find the backup even if this never returns. +pub fn replace(target: &Path, new_binary: &Path, backup_path: &Path) -> Result<()> { + if target.exists() { + rename_with_retry(target, backup_path) + .with_context(|| format!("Failed to move {} to backup", target.display()))?; + info!("Backed up current binary: {}", backup_path.display()); + } else { + warn!( + "No binary at {} — installing new binary directly", + target.display() + ); + } + + if let Err(e) = rename_with_retry(new_binary, target) { + warn!("Failed to activate new binary, restoring backup: {:#}", e); + if backup_path.exists() { + if let Err(restore_err) = rename_with_retry(backup_path, target) { + warn!("Restore also failed: {:#}", restore_err); + } + } + return Err(anyhow!("Failed to activate new binary: {}", e)); + } + + info!("New binary activated: {}", target.display()); + Ok(()) +} + +pub fn restore(backup: &Path, target: &Path) -> Result<()> { + if target.exists() { + std::fs::remove_file(target) + .with_context(|| format!("Failed to remove failed binary at {}", target.display()))?; + } + + std::fs::rename(backup, target).with_context(|| { + format!( + "Failed to restore {} to {}", + backup.display(), + target.display() + ) + })?; + + info!("Restored backup to: {}", target.display()); + Ok(()) +} + +/// Restores `source` to `target` by copy, leaving `source` in place. +/// Used for the last-known-good reserve, which must survive the rollback. +pub fn restore_copy(source: &Path, target: &Path) -> Result<()> { + if target.exists() { + std::fs::remove_file(target) + .with_context(|| format!("Failed to remove failed binary at {}", target.display()))?; + } + + std::fs::copy(source, target).with_context(|| { + format!( + "Failed to copy {} to {}", + source.display(), + target.display() + ) + })?; + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let perms = std::fs::Permissions::from_mode(0o755); + std::fs::set_permissions(target, perms) + .with_context(|| format!("Failed to set permissions on {}", target.display()))?; + } + + info!("Restored reserve copy to: {}", target.display()); + Ok(()) +} + +/// Writes bytes to a temp file in the same directory as `target` (same filesystem → atomic rename). +pub fn write_temp(bytes: &[u8], target: &Path) -> Result { + let dir = target + .parent() + .ok_or_else(|| anyhow!("Target path has no parent directory: {}", target.display()))?; + + let temp_path = dir.join(format!( + ".openframe-client-update-{}.tmp", + uuid::Uuid::new_v4() + )); + + { + use std::io::Write; + let mut file = std::fs::File::create(&temp_path) + .with_context(|| format!("Failed to create temp binary at {}", temp_path.display()))?; + file.write_all(bytes) + .with_context(|| format!("Failed to write temp binary to {}", temp_path.display()))?; + file.sync_all() + .with_context(|| format!("Failed to sync temp binary at {}", temp_path.display()))?; + } + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let perms = std::fs::Permissions::from_mode(0o755); + std::fs::set_permissions(&temp_path, perms) + .with_context(|| format!("Failed to set permissions on {}", temp_path.display()))?; + let dir_handle = std::fs::File::open(dir) + .with_context(|| format!("Failed to open {} for directory sync", dir.display()))?; + dir_handle + .sync_all() + .with_context(|| format!("Failed to sync directory {}", dir.display()))?; + } + + info!( + "Temp binary written: {} ({} bytes)", + temp_path.display(), + bytes.len() + ); + Ok(temp_path) +} + +pub fn backup_path_for(target: &Path) -> PathBuf { + let timestamp = chrono::Utc::now().format("%Y%m%d%H%M%S"); + let filename = target + .file_name() + .map(|n| format!("{}.backup.{}", n.to_string_lossy(), timestamp)) + .unwrap_or_else(|| format!("backup.{}", timestamp)); + + target.parent().unwrap_or(Path::new(".")).join(filename) +} + +// On Windows, AV/SCM can hold the handle briefly after service stop — rename is the probe. +fn rename_with_retry(from: &Path, to: &Path) -> Result<()> { + for attempt in 1..=REPLACE_MAX_RETRIES { + match std::fs::rename(from, to) { + Ok(()) => return Ok(()), + Err(e) => { + if attempt == REPLACE_MAX_RETRIES { + return Err(anyhow!( + "rename failed after {} attempts: {}", + REPLACE_MAX_RETRIES, + e + )); + } + warn!( + "rename attempt {}/{} failed ({}), retrying in {}ms", + attempt, REPLACE_MAX_RETRIES, e, REPLACE_RETRY_DELAY_MS + ); + std::thread::sleep(std::time::Duration::from_millis(REPLACE_RETRY_DELAY_MS)); + } + } + } + unreachable!() +} + +#[cfg(test)] +#[path = "atomic_replace_tests.rs"] +mod tests; diff --git a/clients/openframe-client-updater/src/platform/atomic_replace_tests.rs b/clients/openframe-client-updater/src/platform/atomic_replace_tests.rs new file mode 100644 index 000000000..73b1b38f6 --- /dev/null +++ b/clients/openframe-client-updater/src/platform/atomic_replace_tests.rs @@ -0,0 +1,120 @@ +use super::*; + +fn read(path: &Path) -> Vec { + std::fs::read(path).unwrap() +} + +#[test] +fn write_temp_lands_next_to_target_with_content() { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("openframe-client"); + + let temp = write_temp(b"new-binary", &target).unwrap(); + + assert_eq!(temp.parent().unwrap(), dir.path()); + let name = temp.file_name().unwrap().to_string_lossy().to_string(); + assert!(name.starts_with(".openframe-client-update-")); + assert!(name.ends_with(".tmp")); + assert_eq!(read(&temp), b"new-binary"); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(&temp).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o755, "temp binary must be executable"); + } +} + +#[test] +fn write_temp_fails_without_parent_directory() { + assert!(write_temp(b"x", Path::new("/")).is_err()); +} + +#[test] +fn backup_path_for_uses_timestamped_sibling() { + let backup = backup_path_for(Path::new("/opt/bin/openframe-client")); + assert_eq!(backup.parent().unwrap(), Path::new("/opt/bin")); + assert!(backup + .file_name() + .unwrap() + .to_string_lossy() + .starts_with("openframe-client.backup.")); +} + +#[test] +fn replace_backs_up_old_and_activates_new() { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("openframe-client"); + let new_binary = dir.path().join("incoming"); + let backup = backup_path_for(&target); + std::fs::write(&target, b"old").unwrap(); + std::fs::write(&new_binary, b"new").unwrap(); + + replace(&target, &new_binary, &backup).unwrap(); + + assert_eq!(read(&target), b"new"); + assert_eq!(read(&backup), b"old"); + assert!(!new_binary.exists(), "new binary is renamed, not copied"); +} + +// A half-failed swap can leave no binary at the target — a retry installs directly. +#[test] +fn replace_installs_directly_when_target_missing() { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("openframe-client"); + let new_binary = dir.path().join("incoming"); + let backup = backup_path_for(&target); + std::fs::write(&new_binary, b"new").unwrap(); + + replace(&target, &new_binary, &backup).unwrap(); + + assert_eq!(read(&target), b"new"); + assert!(!backup.exists(), "nothing existed to back up"); +} + +#[test] +fn replace_restores_backup_when_activation_fails() { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("openframe-client"); + let backup = backup_path_for(&target); + std::fs::write(&target, b"old").unwrap(); + let missing_new = dir.path().join("does-not-exist"); + + assert!(replace(&target, &missing_new, &backup).is_err()); + assert_eq!(read(&target), b"old", "old binary must be back in place"); +} + +#[test] +fn restore_consumes_the_backup() { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("openframe-client"); + let backup = dir.path().join("openframe-client.backup.1"); + std::fs::write(&target, b"broken").unwrap(); + std::fs::write(&backup, b"good").unwrap(); + + restore(&backup, &target).unwrap(); + + assert_eq!(read(&target), b"good"); + assert!(!backup.exists()); +} + +#[test] +fn restore_copy_preserves_the_source() { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("openframe-client"); + let reserve = dir.path().join("openframe-client.lkg"); + std::fs::write(&target, b"broken").unwrap(); + std::fs::write(&reserve, b"known-good").unwrap(); + + restore_copy(&reserve, &target).unwrap(); + + assert_eq!(read(&target), b"known-good"); + assert_eq!(read(&reserve), b"known-good", "reserve must survive"); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(&target).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o755, "restored binary must be executable"); + } +} diff --git a/clients/openframe-client-updater/src/platform/directories.rs b/clients/openframe-client-updater/src/platform/directories.rs new file mode 100644 index 000000000..5087929ec --- /dev/null +++ b/clients/openframe-client-updater/src/platform/directories.rs @@ -0,0 +1,216 @@ +use anyhow::Result; +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; +use tracing::info; + +use super::permissions::{PermissionError, Permissions}; + +#[derive(Debug)] +pub enum DirectoryError { + CreateFailed(PathBuf, io::Error), + PermissionDenied(PathBuf), + ValidationFailed(PathBuf, String), + FixFailed(PathBuf, String), +} + +impl std::fmt::Display for DirectoryError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + DirectoryError::CreateFailed(path, err) => { + write!(f, "Failed to create directory {}: {}", path.display(), err) + } + DirectoryError::PermissionDenied(path) => { + write!(f, "Permission denied for {}", path.display()) + } + DirectoryError::ValidationFailed(path, reason) => { + write!(f, "Validation failed for {}: {}", path.display(), reason) + } + DirectoryError::FixFailed(path, reason) => { + write!( + f, + "Failed to fix permissions for {}: {}", + path.display(), + reason + ) + } + } + } +} + +impl std::error::Error for DirectoryError {} + +impl From for DirectoryError { + fn from(err: PermissionError) -> Self { + DirectoryError::FixFailed(PathBuf::new(), err.to_string()) + } +} + +pub fn get_app_support_directory() -> PathBuf { + #[cfg(target_os = "windows")] + { + let program_data = + std::env::var_os("ProgramData").expect("ProgramData environment variable not found"); + PathBuf::from(program_data).join("OpenFrame") + } + + #[cfg(target_os = "macos")] + { + PathBuf::from("/Library/Application Support/OpenFrame") + } + + #[cfg(target_os = "linux")] + { + PathBuf::from("/var/lib/openframe") + } +} + +pub fn get_logs_directory() -> PathBuf { + if let Ok(log_dir) = std::env::var("OPENFRAME_LOG_DIR") { + return PathBuf::from(log_dir); + } + + #[cfg(target_os = "windows")] + { + let program_data = + std::env::var_os("ProgramData").expect("ProgramData environment variable not found"); + PathBuf::from(program_data).join("OpenFrame").join("logs") + } + + #[cfg(target_os = "macos")] + { + PathBuf::from("/Library/Logs/OpenFrame") + } + + #[cfg(target_os = "linux")] + { + PathBuf::from("/var/log/openframe") + } +} + +pub fn get_secured_directory() -> PathBuf { + #[cfg(target_os = "windows")] + { + let program_data = + std::env::var_os("ProgramData").expect("ProgramData environment variable not found"); + PathBuf::from(program_data) + .join("OpenFrame") + .join("secured") + } + + #[cfg(target_os = "macos")] + { + PathBuf::from("/Library/Application Support/OpenFrame/secured") + } + + #[cfg(target_os = "linux")] + { + PathBuf::from("/var/lib/openframe/secured") + } +} + +#[derive(Debug, Clone)] +pub struct DirectoryManager { + logs_dir: PathBuf, + app_support_dir: PathBuf, + secured_dir: PathBuf, +} + +impl Default for DirectoryManager { + fn default() -> Self { + Self::new() + } +} + +impl DirectoryManager { + pub fn new() -> Self { + Self { + logs_dir: get_logs_directory(), + app_support_dir: get_app_support_directory(), + secured_dir: get_secured_directory(), + } + } + + pub fn for_development() -> Self { + let dev_dir = std::env::temp_dir().join("OpenFrame-dev"); + Self { + logs_dir: dev_dir.join("logs"), + app_support_dir: dev_dir.clone(), + secured_dir: dev_dir.join("secured"), + } + } + + pub fn with_custom_dirs( + logs_dir: PathBuf, + app_support_dir: PathBuf, + secured_dir: PathBuf, + ) -> Self { + Self { + logs_dir, + app_support_dir, + secured_dir, + } + } + + pub fn logs_dir(&self) -> &Path { + &self.logs_dir + } + + pub fn app_support_dir(&self) -> &Path { + &self.app_support_dir + } + + pub fn secured_dir(&self) -> &Path { + &self.secured_dir + } + + pub fn perform_health_check(&self) -> Result<(), DirectoryError> { + info!("Performing directory health check"); + self.ensure_directories()?; + info!("Directory health check completed"); + Ok(()) + } + + pub fn ensure_directories(&self) -> Result<(), DirectoryError> { + let dir_perms = Permissions::directory(); + self.create_directory(&self.logs_dir, &dir_perms)?; + self.create_directory(&self.app_support_dir, &dir_perms)?; + self.create_directory(&self.secured_dir, &Permissions::secured_directory())?; + Ok(()) + } + + fn create_directory(&self, path: &Path, perms: &Permissions) -> Result<(), DirectoryError> { + if !path.exists() { + info!("Creating directory: {}", path.display()); + fs::create_dir_all(path) + .map_err(|e| DirectoryError::CreateFailed(path.to_path_buf(), e))?; + } + + perms + .apply(path) + .map_err(|e| DirectoryError::FixFailed(path.to_path_buf(), e.to_string()))?; + + if !self.can_write_to(path) { + return Err(DirectoryError::PermissionDenied(path.to_path_buf())); + } + + Ok(()) + } + + fn can_write_to(&self, path: &Path) -> bool { + let probe = path.join(".write_test"); + let result = std::fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .open(&probe); + if probe.exists() { + let _ = fs::remove_file(&probe); + } + result.is_ok() + } +} + +#[cfg(test)] +#[path = "directories_tests.rs"] +mod tests; diff --git a/clients/openframe-client-updater/src/platform/directories_tests.rs b/clients/openframe-client-updater/src/platform/directories_tests.rs new file mode 100644 index 000000000..1738d8118 --- /dev/null +++ b/clients/openframe-client-updater/src/platform/directories_tests.rs @@ -0,0 +1,67 @@ +use super::*; + +fn manager(root: &Path) -> DirectoryManager { + DirectoryManager::with_custom_dirs(root.join("logs"), root.join("app"), root.join("secured")) +} + +#[test] +fn with_custom_dirs_exposes_the_paths() { + let dir = tempfile::tempdir().unwrap(); + let dm = manager(dir.path()); + assert_eq!(dm.logs_dir(), dir.path().join("logs")); + assert_eq!(dm.app_support_dir(), dir.path().join("app")); + assert_eq!(dm.secured_dir(), dir.path().join("secured")); +} + +#[test] +fn ensure_directories_creates_all_three() { + let dir = tempfile::tempdir().unwrap(); + let dm = manager(dir.path()); + + dm.ensure_directories().unwrap(); + + assert!(dm.logs_dir().is_dir()); + assert!(dm.app_support_dir().is_dir()); + assert!(dm.secured_dir().is_dir()); +} + +#[test] +fn ensure_directories_leaves_no_write_probe_behind() { + let dir = tempfile::tempdir().unwrap(); + let dm = manager(dir.path()); + dm.ensure_directories().unwrap(); + for d in [dm.logs_dir(), dm.app_support_dir(), dm.secured_dir()] { + assert!(!d.join(".write_test").exists()); + } +} + +// The secured directory must stay owner-only; the plain directories 0755. +#[cfg(unix)] +#[test] +fn secured_directory_is_owner_only() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().unwrap(); + let dm = manager(dir.path()); + dm.ensure_directories().unwrap(); + + let mode = |p: &Path| fs::metadata(p).unwrap().permissions().mode() & 0o777; + assert_eq!(mode(dm.secured_dir()), 0o700); + assert_eq!(mode(dm.logs_dir()), 0o755); + assert_eq!(mode(dm.app_support_dir()), 0o755); +} + +// Health checks run repeatedly — a second pass must keep (not widen) 0700. +#[cfg(unix)] +#[test] +fn repeated_health_checks_keep_secured_mode() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().unwrap(); + let dm = manager(dir.path()); + dm.ensure_directories().unwrap(); + dm.perform_health_check().unwrap(); + + let mode = fs::metadata(dm.secured_dir()).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o700); +} diff --git a/clients/openframe-client-updater/src/platform/mod.rs b/clients/openframe-client-updater/src/platform/mod.rs new file mode 100644 index 000000000..a2aedd800 --- /dev/null +++ b/clients/openframe-client-updater/src/platform/mod.rs @@ -0,0 +1,9 @@ +pub mod atomic_replace; +pub mod directories; +pub mod permissions; + +pub use directories::{ + get_app_support_directory, get_logs_directory, get_secured_directory, DirectoryError, + DirectoryManager, +}; +pub use permissions::{Capability, PermissionError, PermissionUtils, Permissions}; diff --git a/clients/openframe-client-updater/src/platform/permissions.rs b/clients/openframe-client-updater/src/platform/permissions.rs new file mode 100644 index 000000000..cabacd9b6 --- /dev/null +++ b/clients/openframe-client-updater/src/platform/permissions.rs @@ -0,0 +1,127 @@ +use std::fs; +use std::io; +use std::path::Path; +use std::sync::atomic::{AtomicBool, Ordering}; + +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt; + +static ADMIN_PRIVILEGES_GRANTED: AtomicBool = AtomicBool::new(false); + +#[derive(Debug)] +pub enum PermissionError { + Io(io::Error), + InvalidMode(String), + InvalidPath(String), + AdminCheckFailed(String), + ElevationRequired, + CommandFailed(i32), +} + +impl std::fmt::Display for PermissionError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + PermissionError::Io(e) => write!(f, "IO error: {}", e), + PermissionError::InvalidMode(msg) => write!(f, "Invalid mode: {}", msg), + PermissionError::InvalidPath(msg) => write!(f, "Invalid path: {}", msg), + PermissionError::AdminCheckFailed(msg) => write!(f, "Admin check failed: {}", msg), + PermissionError::ElevationRequired => write!(f, "Elevation to admin/root required"), + PermissionError::CommandFailed(code) => write!(f, "Command failed with code: {}", code), + } + } +} + +impl std::error::Error for PermissionError {} + +impl From for PermissionError { + fn from(err: io::Error) -> Self { + PermissionError::Io(err) + } +} + +#[derive(Debug, Clone)] +pub struct Permissions { + pub mode: u32, +} + +impl Permissions { + pub fn directory() -> Self { + Self { mode: 0o755 } + } + + /// Owner-only, matching the client's `create_secured_directory` (0700) so health + /// checks never relax the shared secured directory the client locked down. + pub fn secured_directory() -> Self { + Self { mode: 0o700 } + } + + pub fn file() -> Self { + Self { mode: 0o644 } + } + + pub fn apply(&self, path: &Path) -> Result<(), PermissionError> { + #[cfg(unix)] + { + let perms = fs::Permissions::from_mode(self.mode); + fs::set_permissions(path, perms).map_err(PermissionError::Io) + } + + #[cfg(not(unix))] + { + if self.mode & 0o200 != 0 && path.exists() { + let metadata = fs::metadata(path)?; + let mut perms = metadata.permissions(); + if perms.readonly() { + #[allow(clippy::permissions_set_readonly_false)] + perms.set_readonly(false); + fs::set_permissions(path, perms)?; + } + } + Ok(()) + } + } +} + +pub struct PermissionUtils; + +impl PermissionUtils { + pub fn is_admin() -> bool { + if ADMIN_PRIVILEGES_GRANTED.load(Ordering::Relaxed) { + return true; + } + + #[cfg(unix)] + { + unsafe { libc::geteuid() == 0 } + } + + #[cfg(target_os = "windows")] + { + is_elevated::is_elevated() + } + + #[cfg(all(not(unix), not(target_os = "windows")))] + { + false + } + } +} + +#[derive(Debug, Clone, Copy)] +pub enum Capability { + ManageServices, + WriteSystemDirectories, +} + +impl PermissionUtils { + pub fn has_capability(capability: Capability) -> bool { + match capability { + Capability::ManageServices => Self::is_admin(), + Capability::WriteSystemDirectories => Self::is_admin(), + } + } +} + +#[cfg(test)] +#[path = "permissions_tests.rs"] +mod tests; diff --git a/clients/openframe-client-updater/src/platform/permissions_tests.rs b/clients/openframe-client-updater/src/platform/permissions_tests.rs new file mode 100644 index 000000000..358a93200 --- /dev/null +++ b/clients/openframe-client-updater/src/platform/permissions_tests.rs @@ -0,0 +1,56 @@ +use super::*; + +// The secured directory holds shared_token.enc; the client creates it 0700 and +// the updater's health check must never widen that. +#[test] +fn preset_modes() { + assert_eq!(Permissions::directory().mode, 0o755); + assert_eq!(Permissions::secured_directory().mode, 0o700); + assert_eq!(Permissions::file().mode, 0o644); +} + +#[cfg(unix)] +#[test] +fn apply_sets_unix_mode() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("secured"); + fs::create_dir(&path).unwrap(); + + Permissions::secured_directory().apply(&path).unwrap(); + assert_eq!( + fs::metadata(&path).unwrap().permissions().mode() & 0o777, + 0o700 + ); + + Permissions::directory().apply(&path).unwrap(); + assert_eq!( + fs::metadata(&path).unwrap().permissions().mode() & 0o777, + 0o755 + ); +} + +#[cfg(not(unix))] +#[test] +fn apply_clears_readonly_for_writable_modes() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("state.json"); + fs::write(&path, b"x").unwrap(); + + let mut perms = fs::metadata(&path).unwrap().permissions(); + perms.set_readonly(true); + fs::set_permissions(&path, perms).unwrap(); + + Permissions::file().apply(&path).unwrap(); + assert!(!fs::metadata(&path).unwrap().permissions().readonly()); +} + +#[cfg(not(unix))] +#[test] +fn apply_on_missing_path_is_a_no_op() { + let dir = tempfile::tempdir().unwrap(); + assert!(Permissions::file() + .apply(&dir.path().join("missing")) + .is_ok()); +} diff --git a/clients/openframe-client-updater/src/service.rs b/clients/openframe-client-updater/src/service.rs new file mode 100644 index 000000000..babf7f0bf --- /dev/null +++ b/clients/openframe-client-updater/src/service.rs @@ -0,0 +1,338 @@ +use anyhow::{Context, Result}; +use std::path::PathBuf; +use std::sync::Arc; +use tokio::runtime::Runtime; +use tracing::{info, warn}; + +use crate::platform::permissions::PermissionUtils; +use crate::platform::DirectoryManager; +use crate::service_adapter::{CrossPlatformServiceManager, RecoveryConfig, ServiceConfig}; + +#[cfg(windows)] +use windows_service::{ + define_windows_service, + service::{ + ServiceControl, ServiceControlAccept, ServiceExitCode, ServiceState, ServiceStatus, + ServiceType, + }, + service_control_handler::{self, ServiceControlHandlerResult, ServiceStatusHandle}, + service_dispatcher, +}; + +const SERVICE_NAME: &str = "client-updater"; +const DISPLAY_NAME: &str = "OpenFrame Client Updater Service"; +const DESCRIPTION: &str = "Manages self-update of the OpenFrame client binary"; + +pub const FULL_SERVICE_NAME: &str = crate::config::updater_config::UPDATER_SERVICE_FULL_NAME; + +const FIRST_RESTART_SERVICE_SECS: u64 = 10; +const SECOND_RESTART_SERVICE_SECS: u64 = 60; +const SUBSEQUENT_RESTART_SERVICE_SECS: u64 = 300; +const RECOVERY_RESET_PERIOD_DAYS: u32 = 1; + +#[cfg(windows)] +define_windows_service!(ffi_service_main, windows_service_main); + +#[cfg(windows)] +fn windows_service_main(_args: Vec) { + let (shutdown_tx, shutdown_rx) = std::sync::mpsc::channel::<()>(); + let shutdown_tx = Arc::new(std::sync::Mutex::new(Some(shutdown_tx))); + + let status_handle = match service_control_handler::register(FULL_SERVICE_NAME, { + let shutdown_tx = Arc::clone(&shutdown_tx); + move |control_event| match control_event { + ServiceControl::Stop | ServiceControl::Shutdown => { + info!("Received stop/shutdown signal from Windows SCM"); + if let Some(tx) = shutdown_tx.lock().unwrap().take() { + let _ = tx.send(()); + } + ServiceControlHandlerResult::NoError + } + ServiceControl::Interrogate => ServiceControlHandlerResult::NoError, + _ => ServiceControlHandlerResult::NotImplemented, + } + }) { + Ok(handle) => handle, + Err(e) => { + eprintln!("Failed to register service control handler: {:?}", e); + return; + } + }; + + let _ = set_service_status(&status_handle, ServiceState::Running); + + let rt = match Runtime::new() { + Ok(runtime) => runtime, + Err(e) => { + eprintln!("Failed to create Tokio runtime: {:?}", e); + let _ = set_service_status(&status_handle, ServiceState::Stopped); + return; + } + }; + + let result = rt.block_on(async { + let service_handle = tokio::spawn(UpdaterService::run()); + tokio::select! { + result = service_handle => { + result.unwrap_or_else(|e| Err(anyhow::anyhow!("Updater panicked: {}", e))) + } + _ = tokio::task::spawn_blocking(move || shutdown_rx.recv()) => { + info!("Shutdown signal received"); + Ok(()) + } + } + }); + + if let Err(e) = result { + eprintln!("Updater service failed: {:?}", e); + } + let _ = set_service_status(&status_handle, ServiceState::Stopped); +} + +#[cfg(windows)] +fn set_service_status(status_handle: &ServiceStatusHandle, state: ServiceState) -> Result<()> { + let status = ServiceStatus { + service_type: ServiceType::OWN_PROCESS, + current_state: state, + controls_accepted: if state == ServiceState::Running { + ServiceControlAccept::STOP | ServiceControlAccept::SHUTDOWN + } else { + ServiceControlAccept::empty() + }, + exit_code: ServiceExitCode::Win32(0), + checkpoint: 0, + wait_hint: std::time::Duration::from_secs(5), + process_id: None, + }; + status_handle + .set_service_status(status) + .context("Failed to set service status") +} + +pub struct UpdaterService; + +impl UpdaterService { + pub fn is_installed() -> bool { + #[cfg(target_os = "windows")] + { + std::process::Command::new("sc") + .args(["query", FULL_SERVICE_NAME]) + .output() + .map(|o| o.status.success()) + .unwrap_or(false) + } + + #[cfg(target_os = "macos")] + { + PathBuf::from(format!( + "/Library/LaunchDaemons/{}.plist", + FULL_SERVICE_NAME + )) + .exists() + } + + #[cfg(target_os = "linux")] + { + PathBuf::from(format!("/etc/systemd/system/{}.service", FULL_SERVICE_NAME)).exists() + } + } + + /// Install the updater as an OS service. + /// Requires agent_config.json to already exist (populated by main client install). + pub async fn install() -> Result<()> { + if !PermissionUtils::is_admin() { + return Err(anyhow::anyhow!( + "Admin privileges required for service installation" + )); + } + + let dir_manager = DirectoryManager::new(); + dir_manager + .perform_health_check() + .map_err(|e| anyhow::anyhow!("Directory health check failed: {}", e))?; + + // Validate that the main client has already been registered + let agent_config_path = dir_manager.secured_dir().join("agent_config.json"); + if !agent_config_path.exists() { + return Err(anyhow::anyhow!( + "agent_config.json not found at {}. Install the main client first.", + agent_config_path.display() + )); + } + + let config_str = std::fs::read_to_string(&agent_config_path) + .context("Failed to read agent_config.json")?; + let config: serde_json::Value = + serde_json::from_str(&config_str).context("Failed to parse agent_config.json")?; + + let machine_id = config + .get("machine_id") + .and_then(|v| v.as_str()) + .unwrap_or(""); + if machine_id.is_empty() { + return Err(anyhow::anyhow!( + "machine_id is empty in agent_config.json. Ensure the main client is registered." + )); + } + + info!( + "Installing OpenFrame Client Updater service (machine_id: {})", + machine_id + ); + + let current_exe = std::env::current_exe().context("Failed to get current exe path")?; + let install_path = Self::get_install_location(); + + if current_exe != install_path { + if let Some(parent) = install_path.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("Failed to create dir {}", parent.display()))?; + } + std::fs::copy(¤t_exe, &install_path) + .with_context(|| format!("Failed to copy binary to {}", install_path.display()))?; + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = std::fs::metadata(&install_path)?.permissions(); + perms.set_mode(0o755); + std::fs::set_permissions(&install_path, perms)?; + } + + info!("Binary installed to: {}", install_path.display()); + } + + info!("Updater binary ready at {}", install_path.display()); + + let (user_name, group_name) = match std::env::consts::OS { + "windows" => (Some("LocalSystem".to_string()), None), + "macos" => (Some("root".to_string()), Some("wheel".to_string())), + _ => (Some("root".to_string()), Some("root".to_string())), + }; + + let config = ServiceConfig { + name: SERVICE_NAME.to_string(), + display_name: DISPLAY_NAME.to_string(), + description: DESCRIPTION.to_string(), + exec_path: install_path, + run_at_load: true, + keep_alive: true, + restart_on_crash: true, + restart_throttle_seconds: 10, + working_directory: Some(dir_manager.app_support_dir().to_path_buf()), + stdout_path: Some(dir_manager.logs_dir().join("updater_output.log")), + stderr_path: Some(dir_manager.logs_dir().join("updater_error.log")), + user_name, + group_name, + file_limit: Some(4096), + exit_timeout_seconds: Some(10), + is_interactive: true, + recovery: Some(RecoveryConfig { + first_restart_secs: FIRST_RESTART_SERVICE_SECS, + second_restart_secs: SECOND_RESTART_SERVICE_SECS, + subsequent_restart_secs: SUBSEQUENT_RESTART_SERVICE_SECS, + reset_period_days: RECOVERY_RESET_PERIOD_DAYS, + enable_on_non_crash_failures: true, + }), + ..ServiceConfig::default() + }; + + CrossPlatformServiceManager::with_config(config) + .install() + .context("Failed to register OS service")?; + + info!("OpenFrame Client Updater service installed successfully"); + Ok(()) + } + + pub async fn uninstall() -> Result<()> { + if !PermissionUtils::is_admin() { + return Err(anyhow::anyhow!( + "Admin privileges required for uninstallation" + )); + } + + info!("Uninstalling OpenFrame Client Updater service"); + + let install_path = Self::get_install_location(); + let config = ServiceConfig { + name: SERVICE_NAME.to_string(), + display_name: DISPLAY_NAME.to_string(), + description: DESCRIPTION.to_string(), + exec_path: install_path.clone(), + ..ServiceConfig::default() + }; + + CrossPlatformServiceManager::with_config(config) + .uninstall() + .context("Failed to unregister OS service")?; + + if install_path.exists() { + std::fs::remove_file(&install_path).with_context(|| { + format!( + "Failed to remove updater binary at {}", + install_path.display() + ) + })?; + info!("Removed updater binary at {}", install_path.display()); + } else { + warn!( + "Updater binary not found at {}, skipping removal", + install_path.display() + ); + } + + info!("OpenFrame Client Updater service uninstalled"); + Ok(()) + } + + pub async fn run() -> Result<()> { + info!("Starting OpenFrame Client Updater"); + + let dir_manager = if std::env::var("OPENFRAME_DEV_MODE").is_ok() { + DirectoryManager::for_development() + } else { + DirectoryManager::new() + }; + + dir_manager + .perform_health_check() + .map_err(|e| anyhow::anyhow!("Directory health check: {}", e))?; + + crate::UpdaterOrchestrator::new(dir_manager).start().await + } + + pub fn run_as_service() -> Result<()> { + info!("Running as OS service"); + + #[cfg(windows)] + { + service_dispatcher::start(FULL_SERVICE_NAME, ffi_service_main) + .context("Failed to start service dispatcher")?; + Ok(()) + } + + #[cfg(not(windows))] + { + let rt = Runtime::new().context("Failed to create Tokio runtime")?; + rt.block_on(Self::run()) + } + } + + pub fn get_install_location() -> PathBuf { + #[cfg(target_os = "windows")] + { + let program_files = + std::env::var("ProgramFiles").unwrap_or_else(|_| "C:\\Program Files".to_string()); + PathBuf::from(program_files) + .join("OpenFrame") + .join("bin") + .join("openframe-client-updater.exe") + } + + #[cfg(not(target_os = "windows"))] + { + PathBuf::from("/usr/local/bin/openframe-client-updater") + } + } +} diff --git a/clients/openframe-client-updater/src/service_adapter.rs b/clients/openframe-client-updater/src/service_adapter.rs new file mode 100644 index 000000000..2e1d7cd3d --- /dev/null +++ b/clients/openframe-client-updater/src/service_adapter.rs @@ -0,0 +1,421 @@ +use anyhow::{Context, Result}; +use service_manager::{ + ServiceInstallCtx, ServiceLabel, ServiceManager, ServiceStartCtx, ServiceStopCtx, + ServiceUninstallCtx, +}; +use std::ffi::OsString; +use std::path::PathBuf; +use std::str::FromStr; +use tracing::{info, warn}; + +#[derive(Debug, Clone)] +pub struct RecoveryConfig { + pub first_restart_secs: u64, + pub second_restart_secs: u64, + pub subsequent_restart_secs: u64, + pub reset_period_days: u32, + pub enable_on_non_crash_failures: bool, +} + +#[derive(Debug, Clone)] +pub struct ServiceConfig { + pub name: String, + pub display_name: String, + pub description: String, + pub exec_path: PathBuf, + pub run_at_load: bool, + pub keep_alive: bool, + pub restart_on_crash: bool, + pub restart_throttle_seconds: u32, + pub working_directory: Option, + pub environment_vars: Vec<(String, String)>, + pub stdout_path: Option, + pub stderr_path: Option, + pub user_name: Option, + pub group_name: Option, + pub file_limit: Option, + pub exit_timeout_seconds: Option, + pub is_interactive: bool, + pub recovery: Option, +} + +impl Default for ServiceConfig { + fn default() -> Self { + Self { + name: String::new(), + display_name: String::new(), + description: String::new(), + exec_path: PathBuf::new(), + run_at_load: true, + keep_alive: true, + restart_on_crash: true, + restart_throttle_seconds: 10, + working_directory: None, + environment_vars: vec![], + stdout_path: None, + stderr_path: None, + user_name: None, + group_name: None, + file_limit: None, + exit_timeout_seconds: None, + is_interactive: true, + recovery: None, + } + } +} + +pub struct CrossPlatformServiceManager { + pub config: ServiceConfig, +} + +impl CrossPlatformServiceManager { + pub fn with_config(config: ServiceConfig) -> Self { + Self { config } + } + + pub fn install(&self) -> Result<()> { + let label = self.label()?; + let manager = + ::native().context("Failed to detect native service manager")?; + + let working_dir = self + .config + .working_directory + .clone() + .unwrap_or_else(|| self.app_support_dir()); + + let mut ctx = ServiceInstallCtx { + label: label.clone(), + program: self.config.exec_path.clone(), + args: vec![OsString::from("run-as-service")], + contents: None, + username: self.service_username(), + working_directory: Some(working_dir), + environment: Some(self.config.environment_vars.clone()), + autostart: self.config.run_at_load, + disable_restart_on_failure: !self.config.restart_on_crash, + }; + + self.apply_platform_config(&mut ctx); + self.create_log_dirs()?; + + info!("Installing service '{}'", self.config.name); + manager.install(ctx).context("Failed to install service")?; + + #[cfg(target_os = "windows")] + if let Some(recovery) = &self.config.recovery { + let svc_name = format!("com.openframe.{}", self.config.name.to_lowercase()); + apply_windows_recovery(&svc_name, recovery) + .context("Failed to configure Windows recovery actions")?; + } + + self.start()?; + Ok(()) + } + + pub fn uninstall(&self) -> Result<()> { + let label = self.label()?; + let manager = + ::native().context("Failed to detect native service manager")?; + + if let Err(e) = self.stop() { + warn!("Could not stop service before uninstall: {}", e); + } + + #[cfg(target_os = "windows")] + { + if let Err(e) = self.wait_for_process_stop(60) { + warn!("Service process did not stop cleanly: {}", e); + } + } + + let ctx = ServiceUninstallCtx { label }; + + #[cfg(target_os = "windows")] + { + match manager.uninstall(ctx) { + Ok(_) => {} + Err(e) => { + let msg = e.to_string(); + if msg.contains("1072") || msg.contains("1060") { + info!("Service already removed"); + } else { + return Err(e).context("Failed to uninstall service"); + } + } + } + } + + #[cfg(not(target_os = "windows"))] + manager + .uninstall(ctx) + .context("Failed to uninstall service")?; + + Ok(()) + } + + pub fn start(&self) -> Result<()> { + let label = self.label()?; + let manager = + ::native().context("Failed to detect native service manager")?; + manager + .start(ServiceStartCtx { label }) + .context("Failed to start service") + } + + pub fn stop(&self) -> Result<()> { + let label = self.label()?; + let manager = + ::native().context("Failed to detect native service manager")?; + manager + .stop(ServiceStopCtx { label }) + .context("Failed to stop service") + } + + fn label(&self) -> Result { + ServiceLabel::from_str(&format!( + "com.openframe.{}", + self.config.name.to_lowercase() + )) + .context("Failed to create service label") + } + + fn app_support_dir(&self) -> PathBuf { + #[cfg(target_os = "windows")] + { + let pd = std::env::var("PROGRAMDATA").unwrap_or_else(|_| "C:\\ProgramData".to_string()); + PathBuf::from(pd).join("OpenFrame") + } + #[cfg(target_os = "macos")] + { + PathBuf::from("/Library/Application Support/OpenFrame") + } + #[cfg(all(unix, not(target_os = "macos")))] + { + PathBuf::from("/var/lib/openframe") + } + } + + fn service_username(&self) -> Option { + if let Some(u) = &self.config.user_name { + return Some(u.clone()); + } + #[cfg(target_os = "windows")] + return Some("LocalSystem".to_string()); + #[cfg(target_os = "macos")] + return Some("root".to_string()); + #[cfg(all(unix, not(target_os = "macos")))] + return Some("root".to_string()); + } + + fn create_log_dirs(&self) -> Result<()> { + for path in [&self.config.stdout_path, &self.config.stderr_path] + .into_iter() + .flatten() + { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).ok(); + } + } + Ok(()) + } + + fn apply_platform_config(&self, _ctx: &mut ServiceInstallCtx) { + #[cfg(target_os = "macos")] + self.apply_macos_config(_ctx); + + #[cfg(all(unix, not(target_os = "macos")))] + self.apply_linux_config(_ctx); + } + + #[cfg(target_os = "macos")] + fn apply_macos_config(&self, ctx: &mut ServiceInstallCtx) { + use plist::Dictionary; + use tracing::debug; + + let mut dict = Dictionary::new(); + + dict.insert( + "Label".into(), + plist::Value::String(format!("com.openframe.{}", self.config.name.to_lowercase())), + ); + + let args = vec![ + plist::Value::String(self.config.exec_path.to_string_lossy().to_string()), + plist::Value::String("run-as-service".to_string()), + ]; + dict.insert("ProgramArguments".into(), plist::Value::Array(args)); + dict.insert( + "RunAtLoad".into(), + plist::Value::Boolean(self.config.run_at_load), + ); + + let mut keep_alive = Dictionary::new(); + keep_alive.insert("SuccessfulExit".into(), plist::Value::Boolean(false)); + keep_alive.insert("Crashed".into(), plist::Value::Boolean(true)); + dict.insert("KeepAlive".into(), plist::Value::Dictionary(keep_alive)); + + if let Some(p) = &self.config.stdout_path { + dict.insert( + "StandardOutPath".into(), + plist::Value::String(p.to_string_lossy().to_string()), + ); + } + if let Some(p) = &self.config.stderr_path { + dict.insert( + "StandardErrorPath".into(), + plist::Value::String(p.to_string_lossy().to_string()), + ); + } + if let Some(limit) = self.config.file_limit { + let mut limits = Dictionary::new(); + limits.insert("NumberOfFiles".into(), plist::Value::Integer(limit.into())); + dict.insert( + "SoftResourceLimits".into(), + plist::Value::Dictionary(limits), + ); + } + if self.config.is_interactive { + dict.insert( + "ProcessType".into(), + plist::Value::String("Interactive".to_string()), + ); + } + if self.config.restart_on_crash { + dict.insert( + "ThrottleInterval".into(), + plist::Value::Integer(self.config.restart_throttle_seconds.into()), + ); + } + dict.insert( + "ExitTimeOut".into(), + plist::Value::Integer(self.config.exit_timeout_seconds.unwrap_or(10).into()), + ); + dict.insert("AbandonProcessGroup".into(), plist::Value::Boolean(false)); + if let Some(u) = &self.config.user_name { + dict.insert("UserName".into(), plist::Value::String(u.clone())); + } + if let Some(g) = &self.config.group_name { + dict.insert("GroupName".into(), plist::Value::String(g.clone())); + } + if let Some(wd) = &self.config.working_directory { + dict.insert( + "WorkingDirectory".into(), + plist::Value::String(wd.to_string_lossy().to_string()), + ); + } + + let value = plist::Value::Dictionary(dict); + let mut xml = Vec::new(); + match plist::to_writer_xml(&mut xml, &value) { + Ok(_) => match String::from_utf8(xml) { + Ok(s) => { + debug!("macOS plist: {}", s); + ctx.contents = Some(s); + } + Err(e) => warn!("Failed to encode plist as UTF-8: {}", e), + }, + Err(e) => warn!("Failed to serialize plist: {}", e), + } + } + + #[cfg(all(unix, not(target_os = "macos")))] + fn apply_linux_config(&self, _ctx: &mut ServiceInstallCtx) { + use tracing::debug; + + // Overriding `contents` requires a complete systemd unit; until linux is a shipped + // target the service-manager default template is used and the extras stay unapplied. + debug!( + "Linux service uses the default systemd unit template (stdout={:?}, stderr={:?}, file_limit={:?}, restart_on_crash={})", + self.config.stdout_path, + self.config.stderr_path, + self.config.file_limit, + self.config.restart_on_crash + ); + } + + #[cfg(target_os = "windows")] + fn wait_for_process_stop(&self, timeout_secs: u64) -> Result<()> { + use std::time::{Duration, Instant}; + let svc = format!("com.openframe.{}", self.config.name.to_lowercase()); + let deadline = Instant::now() + Duration::from_secs(timeout_secs); + while Instant::now() < deadline { + if !Self::service_process_running(&svc) { + return Ok(()); + } + std::thread::sleep(Duration::from_millis(500)); + } + Err(anyhow::anyhow!( + "Service did not stop within {}s", + timeout_secs + )) + } + + #[cfg(target_os = "windows")] + fn service_process_running(service_name: &str) -> bool { + std::process::Command::new("sc") + .args(["query", service_name]) + .output() + .map(|o| { + let out = String::from_utf8_lossy(&o.stdout); + !out.contains("STOPPED") && o.status.success() + }) + .unwrap_or(false) + } +} + +#[cfg(target_os = "windows")] +fn apply_windows_recovery(service_name: &str, cfg: &RecoveryConfig) -> Result<()> { + use std::time::Duration; + use windows_service::{ + service::{ + ServiceAccess, ServiceAction, ServiceActionType, ServiceFailureActions, + ServiceFailureResetPeriod, + }, + service_manager::{ServiceManager, ServiceManagerAccess}, + }; + + let scm = ServiceManager::local_computer(None::<&str>, ServiceManagerAccess::CONNECT) + .context("Failed to connect to SCM")?; + + let service = scm + .open_service( + service_name, + ServiceAccess::CHANGE_CONFIG | ServiceAccess::START, + ) + .context("Failed to open service")?; + + let actions = vec![ + ServiceAction { + action_type: ServiceActionType::Restart, + delay: Duration::from_secs(cfg.first_restart_secs), + }, + ServiceAction { + action_type: ServiceActionType::Restart, + delay: Duration::from_secs(cfg.second_restart_secs), + }, + ServiceAction { + action_type: ServiceActionType::Restart, + delay: Duration::from_secs(cfg.subsequent_restart_secs), + }, + ]; + + service + .update_failure_actions(ServiceFailureActions { + reset_period: ServiceFailureResetPeriod::After(Duration::from_secs( + cfg.reset_period_days as u64 * 86_400, + )), + reboot_msg: None, + command: None, + actions: Some(actions), + }) + .context("Failed to update failure actions")?; + + if cfg.enable_on_non_crash_failures { + service + .set_failure_actions_on_non_crash_failures(true) + .context("Failed to enable failure actions on non-crash failures")?; + } + + Ok(()) +} diff --git a/clients/openframe-client-updater/src/services/agent_configuration_service.rs b/clients/openframe-client-updater/src/services/agent_configuration_service.rs new file mode 100644 index 000000000..cc21414fd --- /dev/null +++ b/clients/openframe-client-updater/src/services/agent_configuration_service.rs @@ -0,0 +1,71 @@ +use anyhow::{Context, Result}; +use std::fs; +use std::path::PathBuf; +use std::sync::Arc; +use tokio::sync::RwLock; + +use crate::models::AgentConfiguration; +use crate::platform::DirectoryManager; + +// Credentials read from agent_config.json (owned by the main client, never written here). +// Tokens are in-memory only — avoids racing the main client on the shared file. +#[derive(Clone)] +pub struct AgentConfigurationService { + config_file_path: PathBuf, + access_token: Arc>, + refresh_token: Arc>, +} + +impl AgentConfigurationService { + pub fn new(directory_manager: &DirectoryManager) -> Result { + let config_file_path = directory_manager.secured_dir().join("agent_config.json"); + + directory_manager + .ensure_directories() + .with_context(|| "Failed to ensure secured directory exists")?; + + Ok(Self { + config_file_path, + access_token: Arc::new(RwLock::new(String::new())), + refresh_token: Arc::new(RwLock::new(String::new())), + }) + } + + pub async fn get_machine_id(&self) -> Result { + Ok(self.read_config()?.machine_id) + } + + pub async fn get_client_credentials(&self) -> Result<(String, String)> { + let cfg = self.read_config()?; + Ok((cfg.client_id, cfg.client_secret)) + } + + pub async fn get_access_token(&self) -> Result { + Ok(self.access_token.read().await.clone()) + } + + pub async fn get_refresh_token(&self) -> Result { + Ok(self.refresh_token.read().await.clone()) + } + + pub async fn update_tokens(&self, access_token: String, refresh_token: String) -> Result<()> { + *self.access_token.write().await = access_token; + *self.refresh_token.write().await = refresh_token; + Ok(()) + } + + fn read_config(&self) -> Result { + if !self.config_file_path.exists() { + return Err(anyhow::anyhow!( + "agent_config.json not found at {}. Is the main client installed?", + self.config_file_path.display() + )); + } + + let json = fs::read_to_string(&self.config_file_path) + .with_context(|| format!("Failed to read {}", self.config_file_path.display()))?; + + serde_json::from_str::(&json) + .context("Failed to deserialize agent_config.json") + } +} diff --git a/clients/openframe-client-updater/src/services/client_update_service.rs b/clients/openframe-client-updater/src/services/client_update_service.rs new file mode 100644 index 000000000..52f165693 --- /dev/null +++ b/clients/openframe-client-updater/src/services/client_update_service.rs @@ -0,0 +1,670 @@ +use anyhow::{anyhow, Result}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; +use tokio::sync::Mutex; +use tracing::{error, info, warn}; + +use crate::config::updater_config::{ + ALLOW_DOWNGRADE, BOOT_MARKER_POLL_INTERVAL_SECS, BOOT_MARKER_WAIT_SECS, + CLIENT_SERVICE_FULL_NAME, MIN_BINARY_SIZE_BYTES, OBSERVATION_MAX_CLIENT_RESTARTS, + POST_BOOT_OBSERVATION_SECS, POST_BOOT_POLL_INTERVAL_SECS, ROLLBACK_RESTORE_ATTEMPTS, + ROLLBACK_RESTORE_RETRY_DELAY_SECS, SERVICE_START_SETTLE_SECS, +}; +use crate::models::{ClientUpdateMessage, UpdaterPhase, UpdaterState}; +use crate::platform::atomic_replace; +use crate::services::last_known_good_service::LastKnownGoodService; +use crate::services::service_manager_service::ServiceManagerService; +use crate::services::{GithubDownloadService, UpdateProgressPublisher, UpdaterStateService}; + +#[derive(Clone)] +pub struct ClientUpdateService { + download_service: GithubDownloadService, + state_service: UpdaterStateService, + progress_publisher: UpdateProgressPublisher, + lkg_service: LastKnownGoodService, + in_progress: Arc>, + /// Bumped at the start of every update; a running observation task exits + /// silently when it sees a newer generation (superseded by a new update). + generation: Arc, +} + +impl ClientUpdateService { + pub fn new( + download_service: GithubDownloadService, + state_service: UpdaterStateService, + progress_publisher: UpdateProgressPublisher, + lkg_service: LastKnownGoodService, + ) -> Self { + Self { + download_service, + state_service, + progress_publisher, + lkg_service, + in_progress: Arc::new(Mutex::new(false)), + generation: Arc::new(AtomicU64::new(0)), + } + } + + pub async fn process_update(&self, msg: ClientUpdateMessage) -> Result<()> { + let mut guard = self.in_progress.lock().await; + if *guard { + // Only an observation-rollback holds the slot here (the listener is + // sequential); an ACK would silently consume a genuinely new update. + warn!( + "Update already in progress — leaving message for v{} unacked for redelivery", + msg.version + ); + return Err(anyhow!( + "update already in progress, deferring v{} to redelivery", + msg.version + )); + } + *guard = true; + drop(guard); + + let result = self.run_update(&msg).await; + + *self.in_progress.lock().await = false; + + result + } + + async fn run_update(&self, msg: &ClientUpdateMessage) -> Result<()> { + let requested_semver = match semver::Version::parse(msg.version.trim_start_matches('v')) { + Ok(v) => v, + Err(_) => { + let reason = format!("Invalid version string: '{}'", msg.version); + error!("{}", reason); + self.progress_publisher + .publish_failure(&UpdaterPhase::Failed, &msg.version, &reason, false) + .await; + // Ok → ACK: a malformed version never becomes valid on redelivery. + return Ok(()); + } + }; + let canonical_version = requested_semver.to_string(); + let version = &canonical_version; + info!("Starting update to v{}", version); + + // The boot marker holds the version the client last booted with — the + // updater's only view of the running client version. + if self + .lkg_service + .boot_marker_version() + .map(|m| Self::versions_match(&m, version)) + .unwrap_or(false) + { + info!( + "Client already booted with version {}, ignoring update request", + version + ); + return Ok(()); + } + + // An explicit rollback is allowed below the anchor — that's its point. + if !ALLOW_DOWNGRADE && !msg.rollback { + match self.lkg_service.load_anchor() { + Ok(Some(anchor)) => match semver::Version::parse(anchor.trim_start_matches('v')) { + Ok(anchor_semver) if requested_semver < anchor_semver => { + let reason = format!( + "Refusing downgrade to {} — last-known-good anchored at {}", + version, anchor + ); + warn!("{}", reason); + self.progress_publisher + .publish_failure(&UpdaterPhase::Failed, version, &reason, false) + .await; + // Ok → ACK: redelivering a refused downgrade changes nothing. + return Ok(()); + } + Ok(_) => {} + Err(e) => warn!( + "Failed to parse last-known-good anchor '{}': {:#}", + anchor, e + ), + }, + Ok(None) => {} + Err(e) => warn!( + "Failed to load last-known-good anchor, skipping downgrade guard: {:#}", + e + ), + } + } + + // Bumped after the guards: a redelivered duplicate must not cancel the + // observation task of the update it duplicates. + self.generation.fetch_add(1, Ordering::SeqCst); + + let mut state = UpdaterState::new(version.clone()); + + // A rollback whose target matches the local reserve restores from disk + // — no download, so it works even when the machine can't reach GitHub. + let anchor_matches_target = matches!( + self.lkg_service.load_anchor(), + Ok(Some(ref anchor)) + if semver::Version::parse(anchor.trim_start_matches('v')) + .map(|a| a == requested_semver) + .unwrap_or(false) + ); + let restore_from_reserve = + msg.rollback && anchor_matches_target && self.lkg_service.reserve_path().exists(); + + let binary_bytes = if restore_from_reserve { + info!( + "Rollback to v{}: restoring from local last-known-good reserve (no download)", + version + ); + match std::fs::read(self.lkg_service.reserve_path()) { + Ok(bytes) => bytes::Bytes::from(bytes), + Err(e) => { + let reason = format!("Failed to read last-known-good reserve: {:#}", e); + error!("{}", reason); + self.fail(&mut state, version, &reason, false).await; + return Err(anyhow!(reason)); + } + } + } else { + let config = match self + .download_service + .find_for_current_os(&msg.download_configurations) + { + Ok(c) => c, + Err(e) => { + let reason = + format!("No download config for current OS (v{}): {:#}", version, e); + error!("{}", reason); + self.fail(&mut state, version, &reason, false).await; + // Ok → ACK: a message with no artifact for this OS never becomes valid. + return Ok(()); + } + }; + + self.state_service + .transition(&mut state, UpdaterPhase::Downloading)?; + self.progress_publisher + .publish(&UpdaterPhase::Downloading, version) + .await; + + match self.download_service.download_and_extract(config).await { + Ok(bytes) => bytes, + Err(e) => { + let reason = format!("Download failed: {:#}", e); + error!("{}", reason); + self.fail(&mut state, version, &reason, false).await; + return Err(anyhow!(reason)); + } + } + }; + + self.state_service + .transition(&mut state, UpdaterPhase::Verifying)?; + self.progress_publisher + .publish(&UpdaterPhase::Verifying, version) + .await; + + if binary_bytes.len() < MIN_BINARY_SIZE_BYTES as usize { + let reason = format!( + "Binary too small ({} bytes, minimum {})", + binary_bytes.len(), + MIN_BINARY_SIZE_BYTES + ); + error!("{}", reason); + self.fail(&mut state, version, &reason, false).await; + return Err(anyhow!(reason)); + } + + let target = ServiceManagerService::client_binary_path(); + let temp_path = match atomic_replace::write_temp(&binary_bytes, &target) { + Ok(p) => p, + Err(e) => { + let reason = format!("Failed to write temp binary: {:#}", e); + error!("{}", reason); + self.fail(&mut state, version, &reason, false).await; + return Err(anyhow!(reason)); + } + }; + state.downloaded_binary_path = Some(temp_path.to_string_lossy().to_string()); + self.state_service.save(&state)?; + + self.state_service + .transition(&mut state, UpdaterPhase::StoppingService)?; + self.progress_publisher + .publish(&UpdaterPhase::StoppingService, version) + .await; + + if let Err(e) = ServiceManagerService::stop_async(CLIENT_SERVICE_FULL_NAME).await { + let reason = format!("Failed to stop service: {:#}", e); + error!("{}", reason); + self.cleanup_temp(&temp_path); + self.fail(&mut state, version, &reason, false).await; + return Err(anyhow!(reason)); + } + + // Point of no return: from here a state-persistence failure must not abort + // the swap — the file only drives crash recovery, while aborting strands a + // stopped client. The backup path is persisted before the binary is + // touched, so recovery knows where the previous binary went even if the + // swap never returns. + let backup_path = atomic_replace::backup_path_for(&target); + state.backup_path = Some(backup_path.to_string_lossy().to_string()); + self.transition_best_effort(&mut state, UpdaterPhase::ReplacingBinary); + self.progress_publisher + .publish(&UpdaterPhase::ReplacingBinary, version) + .await; + + if let Err(e) = atomic_replace::replace(&target, &temp_path, &backup_path) { + let reason = format!("Binary replacement failed: {:#}", e); + error!("{}", reason); + if target.exists() { + self.try_start_service(version, false).await; + self.fail(&mut state, version, &reason, true).await; + } else { + // Half-swapped: keep the state file (it holds the backup path + // crash recovery needs) and leave the message unacked. + self.progress_publisher + .publish_failure(&UpdaterPhase::Failed, version, &reason, false) + .await; + } + return Err(anyhow!(reason)); + } + + // Stale markers must go before the new binary starts: the verification + // below treats any marker that doesn't match the target as a failure. + if let Err(e) = self.lkg_service.clear_boot_marker() { + let reason = format!("Failed to clear boot marker before start: {:#}", e); + error!("{}", reason); + return self + .rollback(&mut state, &target, &backup_path, version, &reason) + .await; + } + + self.transition_best_effort(&mut state, UpdaterPhase::StartingService); + self.progress_publisher + .publish(&UpdaterPhase::StartingService, version) + .await; + + if let Err(e) = ServiceManagerService::start_async(CLIENT_SERVICE_FULL_NAME).await { + let reason = format!("Failed to start new service: {:#}", e); + error!("{}", reason); + return self + .rollback(&mut state, &target, &backup_path, version, &reason) + .await; + } + + tokio::time::sleep(tokio::time::Duration::from_secs(SERVICE_START_SETTLE_SECS)).await; + + match ServiceManagerService::is_running_async(CLIENT_SERVICE_FULL_NAME).await { + Ok(true) => {} + Ok(false) => { + let reason = "Service is not running after start".to_string(); + error!("{}", reason); + return self + .rollback(&mut state, &target, &backup_path, version, &reason) + .await; + } + Err(e) => { + let reason = format!("Failed to check service state: {:#}", e); + error!("{}", reason); + return self + .rollback(&mut state, &target, &backup_path, version, &reason) + .await; + } + } + + self.transition_best_effort(&mut state, UpdaterPhase::VerifyingBoot); + self.progress_publisher + .publish(&UpdaterPhase::VerifyingBoot, version) + .await; + + if let Err(reason) = self.wait_for_boot_marker(version).await { + error!("{}", reason); + return self + .rollback(&mut state, &target, &backup_path, version, &reason) + .await; + } + + // Boot verified — report success (the NATS message ACKs when this + // returns), then keep watching the client before promoting the anchor. + // A client that dies or crash-loops after a good boot is rolled back + // automatically; deferring the promotion also means a backend-pushed + // downgrade to the previous version is never blocked by the bad build. + self.transition_best_effort(&mut state, UpdaterPhase::Observing); + self.progress_publisher.publish_success(version).await; + + self.spawn_observation(state, target, backup_path); + + info!( + "Update to v{} boot-verified — observing for {}s before anchor promotion", + version, POST_BOOT_OBSERVATION_SECS + ); + Ok(()) + } + + fn spawn_observation(&self, state: UpdaterState, target: PathBuf, backup_path: PathBuf) { + let service = self.clone(); + let my_generation = self.generation.load(Ordering::SeqCst); + tokio::spawn(async move { + service + .observe_and_finalize(state, target, backup_path, my_generation) + .await; + }); + } + + /// Watch the freshly updated client for the observation window. Healthy → + /// promote the anchor and clean up. Service gone or crash-looping (boot + /// marker rewrites) → automatic rollback to the last known-good binary. + async fn observe_and_finalize( + &self, + mut state: UpdaterState, + target: PathBuf, + backup_path: PathBuf, + my_generation: u64, + ) { + let version = state.target_version.clone(); + let mut restarts = 0u32; + let mut last_marker_mtime = self.lkg_service.boot_marker_mtime(); + let mut elapsed = 0u64; + let mut failure: Option = None; + + while elapsed < POST_BOOT_OBSERVATION_SECS { + tokio::time::sleep(tokio::time::Duration::from_secs( + POST_BOOT_POLL_INTERVAL_SECS, + )) + .await; + elapsed += POST_BOOT_POLL_INTERVAL_SECS; + + if self.generation.load(Ordering::SeqCst) != my_generation { + info!( + "Observation of v{} superseded by a newer update — stopping", + version + ); + return; + } + + match ServiceManagerService::is_running_async(CLIENT_SERVICE_FULL_NAME).await { + Ok(true) => {} + Ok(false) => { + failure = Some(format!( + "Client service stopped {}s after update to {}", + elapsed, version + )); + break; + } + Err(e) => warn!("Observation: failed to query service state: {:#}", e), + } + + let marker_mtime = self.lkg_service.boot_marker_mtime(); + if marker_mtime != last_marker_mtime { + last_marker_mtime = marker_mtime; + restarts += 1; + warn!( + "Observation: client restarted ({}/{} tolerated)", + restarts, OBSERVATION_MAX_CLIENT_RESTARTS + ); + if restarts >= OBSERVATION_MAX_CLIENT_RESTARTS { + failure = Some(format!( + "Client crash-looped after update to {} ({} restarts within {}s)", + version, restarts, elapsed + )); + break; + } + } + } + + if self.generation.load(Ordering::SeqCst) != my_generation { + info!( + "Observation of v{} superseded by a newer update — stopping", + version + ); + return; + } + + match failure { + None => { + if let Err(e) = self.lkg_service.promote(&version) { + warn!( + "Failed to raise last-known-good anchor to {} (keeping previous anchor): {:#}", + version, e + ); + } + if backup_path.exists() { + if let Err(e) = std::fs::remove_file(&backup_path) { + warn!( + "Failed to remove backup file {}: {}", + backup_path.display(), + e + ); + } + } + if let Err(e) = self.state_service.clear() { + warn!("Failed to clear updater state after observation: {}", e); + } + info!( + "Update to v{} completed — anchor promoted after {}s observation", + version, POST_BOOT_OBSERVATION_SECS + ); + } + Some(reason) => { + // Take the update slot before rolling back so a concurrently arriving + // CLIENT_UPDATE can't interleave its own swap with this restore; if a + // newer update already claimed it, that update owns the binary now. + let mut guard = self.in_progress.lock().await; + if *guard || self.generation.load(Ordering::SeqCst) != my_generation { + info!( + "Observation rollback of v{} superseded by a newer update — skipping", + version + ); + return; + } + *guard = true; + drop(guard); + + error!("{} — rolling back automatically", reason); + let _ = self + .rollback(&mut state, &target, &backup_path, &version, &reason) + .await; + + *self.in_progress.lock().await = false; + } + } + } + + /// Wait for the new client binary to write a boot marker with the target + /// version. A marker carrying a different version means the wrong binary + /// booted — fail immediately instead of waiting out the window. + async fn wait_for_boot_marker(&self, version: &str) -> std::result::Result<(), String> { + info!( + "Waiting up to {}s for boot marker with version {}", + BOOT_MARKER_WAIT_SECS, version + ); + + let mut elapsed = 0u64; + while elapsed < BOOT_MARKER_WAIT_SECS { + match self.lkg_service.boot_marker_version() { + Some(marker) if Self::versions_match(&marker, version) => { + info!("Boot marker matched target version {}", version); + return Ok(()); + } + Some(marker) => { + return Err(format!( + "Boot marker reports '{}', expected '{}' — wrong binary booted", + marker, version + )); + } + None => {} + } + tokio::time::sleep(tokio::time::Duration::from_secs( + BOOT_MARKER_POLL_INTERVAL_SECS, + )) + .await; + elapsed += BOOT_MARKER_POLL_INTERVAL_SECS; + } + + Err(format!( + "New binary did not report target version '{}' within {}s", + version, BOOT_MARKER_WAIT_SECS + )) + } + + async fn rollback( + &self, + state: &mut UpdaterState, + target: &Path, + backup_path: &Path, + version: &str, + reason: &str, + ) -> Result<()> { + warn!("Rolling back update to v{}: {}", version, reason); + + self.transition_best_effort(state, UpdaterPhase::RollingBack); + self.progress_publisher + .publish(&UpdaterPhase::RollingBack, version) + .await; + + // The service may still be running the bad binary (e.g. it booted but + // reported the wrong version) — stop it before touching the file. + if let Ok(true) = ServiceManagerService::is_running_async(CLIENT_SERVICE_FULL_NAME).await { + if let Err(e) = ServiceManagerService::stop_async(CLIENT_SERVICE_FULL_NAME).await { + warn!("Failed to stop service before rollback restore: {:#}", e); + } + } + + // Prefer the last-known-good reserve over the pre-swap backup: the + // backup is merely what ran before, the reserve is what verified. + // The reserve is copied (it must survive); the backup is renamed. + let reserve = self.lkg_service.reserve_path(); + let use_reserve = reserve.exists(); + let restore_source: &Path = if use_reserve { reserve } else { backup_path }; + + let mut restored = false; + for attempt in 1..=ROLLBACK_RESTORE_ATTEMPTS { + let result = if use_reserve { + atomic_replace::restore_copy(restore_source, target) + } else { + atomic_replace::restore(restore_source, target) + }; + match result { + Ok(()) => { + info!( + "Rollback restored client binary from {}", + restore_source.display() + ); + restored = true; + break; + } + Err(e) => { + warn!( + "Restore attempt {}/{} failed: {:#}", + attempt, ROLLBACK_RESTORE_ATTEMPTS, e + ); + tokio::time::sleep(tokio::time::Duration::from_secs( + ROLLBACK_RESTORE_RETRY_DELAY_SECS, + )) + .await; + } + } + } + + if restored && use_reserve && backup_path.exists() { + if let Err(e) = std::fs::remove_file(backup_path) { + warn!( + "Failed to remove leftover backup {}: {}", + backup_path.display(), + e + ); + } + } + + if !restored { + let full_reason = format!( + "{} — rollback also failed: all restore attempts failed", + reason + ); + error!("{}", full_reason); + self.fail(state, version, &full_reason, false).await; + return Err(anyhow!(full_reason)); + } + + self.try_start_service(version, true).await; + + self.transition_best_effort(state, UpdaterPhase::RolledBack); + self.progress_publisher + .publish_failure(&UpdaterPhase::RolledBack, version, reason, true) + .await; + + if let Err(e) = self.state_service.clear() { + warn!("Failed to clear updater state after rollback: {:#}", e); + } + Err(anyhow!("Update failed and was rolled back: {}", reason)) + } + + async fn try_start_service(&self, version: &str, after_rollback: bool) { + if let Err(e) = ServiceManagerService::start_async(CLIENT_SERVICE_FULL_NAME).await { + let ctx = if after_rollback { + "after rollback" + } else { + "after failed replace" + }; + error!("Failed to restart service {}: {:#}", ctx, e); + self.progress_publisher + .publish_failure( + &UpdaterPhase::Failed, + version, + &format!("Service restart failed {}: {:#}", ctx, e), + after_rollback, + ) + .await; + } + } + + /// Boot-marker versions come from the client's build env, requested versions + /// from the backend — compare semver-first so cosmetic differences (leading + /// 'v', metadata formatting) never read as a wrong-binary boot. + fn versions_match(marker: &str, requested: &str) -> bool { + match ( + semver::Version::parse(marker.trim_start_matches('v')), + semver::Version::parse(requested.trim_start_matches('v')), + ) { + (Ok(m), Ok(r)) => m == r, + _ => marker == requested, + } + } + + /// Past the point of no return a state-persistence failure must not abort + /// the swap: the file only drives crash recovery, while aborting strands a + /// stopped client. Log and continue. + fn transition_best_effort(&self, state: &mut UpdaterState, phase: UpdaterPhase) { + let label = phase.to_string(); + if let Err(e) = self.state_service.transition(state, phase) { + warn!("Failed to persist {} state (continuing): {:#}", label, e); + } + } + + async fn fail(&self, state: &mut UpdaterState, version: &str, reason: &str, rolled_back: bool) { + state.failure_reason = Some(reason.to_string()); + if let Err(e) = self.state_service.transition(state, UpdaterPhase::Failed) { + warn!("Failed to persist Failed state: {}", e); + } + self.progress_publisher + .publish_failure(&UpdaterPhase::Failed, version, reason, rolled_back) + .await; + let _ = self.state_service.clear(); + } + + fn cleanup_temp(&self, temp_path: &PathBuf) { + if temp_path.exists() { + if let Err(e) = std::fs::remove_file(temp_path) { + warn!( + "Failed to clean up temp file {}: {}", + temp_path.display(), + e + ); + } + } + } +} + +#[cfg(test)] +#[path = "client_update_service_tests.rs"] +mod tests; diff --git a/clients/openframe-client-updater/src/services/client_update_service_tests.rs b/clients/openframe-client-updater/src/services/client_update_service_tests.rs new file mode 100644 index 000000000..1edb8e383 --- /dev/null +++ b/clients/openframe-client-updater/src/services/client_update_service_tests.rs @@ -0,0 +1,31 @@ +use super::*; + +// Boot-marker versions come from the client build, requested versions from the +// backend — cosmetic differences must never read as a wrong-binary boot. +#[test] +fn versions_match_is_semver_aware() { + assert!(ClientUpdateService::versions_match("1.2.3", "1.2.3")); + assert!(ClientUpdateService::versions_match("v1.2.3", "1.2.3")); + assert!(ClientUpdateService::versions_match("1.2.3", "v1.2.3")); + assert!(ClientUpdateService::versions_match( + "1.2.3-beta.1", + "v1.2.3-beta.1" + )); +} + +#[test] +fn versions_match_rejects_different_versions() { + assert!(!ClientUpdateService::versions_match("1.2.3", "1.2.4")); + assert!(!ClientUpdateService::versions_match("2.0.0", "1.0.0")); + assert!(!ClientUpdateService::versions_match( + "1.2.3-beta.1", + "1.2.3" + )); +} + +#[test] +fn versions_match_falls_back_to_string_equality_for_non_semver() { + assert!(ClientUpdateService::versions_match("2024.1", "2024.1")); + assert!(!ClientUpdateService::versions_match("2024.1", "2024.2")); + assert!(!ClientUpdateService::versions_match("dev", "1.0.0")); +} diff --git a/clients/openframe-client-updater/src/services/encryption_service.rs b/clients/openframe-client-updater/src/services/encryption_service.rs new file mode 100644 index 000000000..5c52b3a77 --- /dev/null +++ b/clients/openframe-client-updater/src/services/encryption_service.rs @@ -0,0 +1,69 @@ +use aes_gcm::{ + aead::{generic_array::GenericArray, rand_core::RngCore, Aead, KeyInit, OsRng}, + Aes256Gcm, +}; +use anyhow::Result; +use base64::{engine::general_purpose, Engine as _}; + +#[derive(Clone)] +pub struct EncryptionService; + +impl Default for EncryptionService { + fn default() -> Self { + Self::new() + } +} + +impl EncryptionService { + // TODO: use generated key + const KEY: &'static str = "12345678901234567890123456789012"; + + pub fn new() -> Self { + Self + } + + pub fn encrypt(&self, data: &str) -> Result { + let key = Aes256Gcm::new_from_slice(Self::KEY.as_bytes()) + .map_err(|e| anyhow::anyhow!("Failed to create encryption key: {}", e))?; + + let mut nonce_bytes = [0u8; 12]; + OsRng.fill_bytes(&mut nonce_bytes); + let nonce = GenericArray::from_slice(&nonce_bytes); + + let ciphertext = key + .encrypt(nonce, data.as_bytes()) + .map_err(|e| anyhow::anyhow!("Failed to encrypt data: {}", e))?; + + let mut combined = nonce_bytes.to_vec(); + combined.extend_from_slice(&ciphertext); + + Ok(general_purpose::STANDARD.encode(combined)) + } + + pub fn decrypt(&self, encrypted_data: &str) -> Result { + let combined = general_purpose::STANDARD + .decode(encrypted_data) + .map_err(|e| anyhow::anyhow!("Failed to decode base64: {}", e))?; + + if combined.len() < 12 { + anyhow::bail!("Encrypted data too short"); + } + + let (nonce_bytes, ciphertext) = combined.split_at(12); + let nonce = GenericArray::from_slice(nonce_bytes); + + let cipher = Aes256Gcm::new_from_slice(Self::KEY.as_bytes()) + .map_err(|e| anyhow::anyhow!("Failed to create cipher: {}", e))?; + + let plaintext = cipher + .decrypt(nonce, ciphertext) + .map_err(|e| anyhow::anyhow!("Failed to decrypt: {}", e))?; + + String::from_utf8(plaintext) + .map_err(|e| anyhow::anyhow!("Failed to convert to UTF-8: {}", e)) + } +} + +#[cfg(test)] +#[path = "encryption_service_tests.rs"] +mod tests; diff --git a/clients/openframe-client-updater/src/services/encryption_service_tests.rs b/clients/openframe-client-updater/src/services/encryption_service_tests.rs new file mode 100644 index 000000000..62791e4aa --- /dev/null +++ b/clients/openframe-client-updater/src/services/encryption_service_tests.rs @@ -0,0 +1,43 @@ +use super::*; + +#[test] +fn encrypt_decrypt_round_trips() { + let svc = EncryptionService::new(); + let plaintext = r#"{"access_token":"abc","refresh_token":"def"}"#; + let encrypted = svc.encrypt(plaintext).unwrap(); + assert_ne!(encrypted, plaintext); + assert_eq!(svc.decrypt(&encrypted).unwrap(), plaintext); +} + +// Random nonce: identical plaintext must never produce identical ciphertext. +#[test] +fn ciphertexts_differ_between_calls() { + let svc = EncryptionService::new(); + assert_ne!(svc.encrypt("same").unwrap(), svc.encrypt("same").unwrap()); +} + +#[test] +fn decrypt_rejects_invalid_base64() { + assert!(EncryptionService::new().decrypt("not base64 !!!").is_err()); +} + +#[test] +fn decrypt_rejects_truncated_payload() { + use base64::{engine::general_purpose, Engine as _}; + let short = general_purpose::STANDARD.encode([0u8; 8]); + assert!(EncryptionService::new().decrypt(&short).is_err()); +} + +#[test] +fn decrypt_rejects_tampered_ciphertext() { + use base64::{engine::general_purpose, Engine as _}; + let svc = EncryptionService::new(); + let encrypted = svc.encrypt("secret").unwrap(); + + let mut bytes = general_purpose::STANDARD.decode(&encrypted).unwrap(); + let last = bytes.len() - 1; + bytes[last] ^= 0xFF; + let tampered = general_purpose::STANDARD.encode(bytes); + + assert!(svc.decrypt(&tampered).is_err(), "AES-GCM must authenticate"); +} diff --git a/clients/openframe-client-updater/src/services/github_download_service.rs b/clients/openframe-client-updater/src/services/github_download_service.rs new file mode 100644 index 000000000..7379b4c6f --- /dev/null +++ b/clients/openframe-client-updater/src/services/github_download_service.rs @@ -0,0 +1,249 @@ +use anyhow::{anyhow, Context, Result}; +use bytes::Bytes; +use reqwest::Client; +use std::io::Cursor; +use tokio::time::Duration; +use tracing::{info, warn}; + +use crate::config::updater_config::{ + DOWNLOAD_TIMEOUT_SECS, MAX_DOWNLOAD_RETRIES, MIN_BINARY_SIZE_BYTES, +}; +use crate::models::DownloadConfiguration; + +#[derive(Clone)] +pub struct GithubDownloadService { + http_client: Client, +} + +impl GithubDownloadService { + pub fn new(http_client: Client) -> Self { + Self { http_client } + } + + /// Downloads the archive for `config` and extracts the client binary from it. + /// Returns raw binary bytes ready to be written to disk. + pub async fn download_and_extract(&self, config: &DownloadConfiguration) -> Result { + info!("Downloading from: {}", config.link); + + let archive_bytes = self + .download_with_retry(&config.link) + .await + .with_context(|| format!("Failed to download from: {}", config.link))?; + + info!("Downloaded {} bytes", archive_bytes.len()); + + if archive_bytes.len() < MIN_BINARY_SIZE_BYTES as usize { + return Err(anyhow!( + "Downloaded archive too small ({} bytes, minimum {})", + archive_bytes.len(), + MIN_BINARY_SIZE_BYTES + )); + } + + let binary_bytes = if config.file_name.ends_with(".zip") { + info!("Extracting from ZIP: target={}", config.target_file_name); + self.extract_from_zip(archive_bytes, &config.target_file_name) + .context("Failed to extract from ZIP archive")? + } else if config.file_name.ends_with(".tar.gz") || config.file_name.ends_with(".tgz") { + info!("Extracting from tar.gz: target={}", config.target_file_name); + self.extract_from_tar_gz(archive_bytes, &config.target_file_name) + .context("Failed to extract from tar.gz archive")? + } else { + return Err(anyhow!("Unsupported archive format: {}", config.file_name)); + }; + + if binary_bytes.len() < MIN_BINARY_SIZE_BYTES as usize { + return Err(anyhow!( + "Extracted binary too small ({} bytes, minimum {})", + binary_bytes.len(), + MIN_BINARY_SIZE_BYTES + )); + } + + info!( + "Extracted binary: {} ({} bytes)", + config.target_file_name, + binary_bytes.len() + ); + Ok(binary_bytes) + } + + /// Returns the `DownloadConfiguration` matching the current OS. + pub fn find_for_current_os<'a>( + &self, + configs: &'a [DownloadConfiguration], + ) -> Result<&'a DownloadConfiguration> { + configs + .iter() + .find(|c| c.matches_current_os()) + .ok_or_else(|| anyhow!("No download configuration for current OS")) + } + + // ── internals ────────────────────────────────────────────────────────── + + async fn download_with_retry(&self, url: &str) -> Result { + let mut last_error = None; + + for attempt in 1..=MAX_DOWNLOAD_RETRIES { + info!( + "Download attempt {}/{}: {}", + attempt, MAX_DOWNLOAD_RETRIES, url + ); + + match tokio::time::timeout( + Duration::from_secs(DOWNLOAD_TIMEOUT_SECS), + self.download(url), + ) + .await + { + Ok(Ok(bytes)) => { + info!("Download succeeded on attempt {}", attempt); + return Ok(bytes); + } + Ok(Err(e)) => { + if e.to_string().contains("429") { + warn!("GitHub rate limit (429) — trying jsDelivr CDN fallback"); + let cdn_url = Self::github_to_cdn_url(url); + info!("CDN URL: {}", cdn_url); + + match tokio::time::timeout( + Duration::from_secs(DOWNLOAD_TIMEOUT_SECS), + self.download(&cdn_url), + ) + .await + { + Ok(Ok(bytes)) => { + info!("Downloaded from jsDelivr CDN"); + return Ok(bytes); + } + Ok(Err(cdn_err)) => { + return Err(anyhow!( + "GitHub rate limited and CDN also failed. GitHub: {:#} CDN: {:#}", + e, cdn_err + )); + } + Err(_) => { + return Err(anyhow!("GitHub rate limited and CDN timed out")); + } + } + } + warn!("Attempt {} failed: {:#}", attempt, e); + last_error = Some(e); + } + Err(_) => { + warn!( + "Attempt {} timed out after {}s", + attempt, DOWNLOAD_TIMEOUT_SECS + ); + last_error = Some(anyhow!("Timeout after {}s", DOWNLOAD_TIMEOUT_SECS)); + } + } + + if attempt < MAX_DOWNLOAD_RETRIES { + let delay = attempt * 2; + info!("Retrying in {}s", delay); + tokio::time::sleep(Duration::from_secs(delay as u64)).await; + } + } + + Err(last_error + .unwrap_or_else(|| anyhow!("Download failed after {} attempts", MAX_DOWNLOAD_RETRIES))) + } + + async fn download(&self, url: &str) -> Result { + let response = self + .http_client + .get(url) + .send() + .await + .context("Failed to send request")?; + + if !response.status().is_success() { + return Err(anyhow!("HTTP {} — {}", response.status(), url)); + } + + response + .bytes() + .await + .context("Failed to read response bytes") + } + + fn github_to_cdn_url(github_url: &str) -> String { + github_url + .replace("github.com/", "cdn.jsdelivr.net/gh/") + .replace("/releases/download/", "@") + } + + #[cfg(target_os = "windows")] + fn extract_from_zip(&self, archive_bytes: Bytes, target_filename: &str) -> Result { + use zip::ZipArchive; + + let cursor = Cursor::new(archive_bytes); + let mut archive = ZipArchive::new(cursor).context("Failed to open ZIP")?; + + for i in 0..archive.len() { + let mut entry = archive.by_index(i).context("Failed to read ZIP entry")?; + let name = entry.name().to_string(); + + if name + .to_lowercase() + .ends_with(&target_filename.to_lowercase()) + { + info!("Found in ZIP: {}", name); + let mut buf = Vec::new(); + std::io::copy(&mut entry, &mut buf).context("Failed to read ZIP entry bytes")?; + return Ok(Bytes::from(buf)); + } + } + + Err(anyhow!("'{}' not found in ZIP", target_filename)) + } + + #[cfg(not(target_os = "windows"))] + fn extract_from_zip(&self, _bytes: Bytes, target: &str) -> Result { + Err(anyhow!( + "ZIP extraction not supported on this platform for '{}'", + target + )) + } + + #[cfg(not(target_os = "windows"))] + fn extract_from_tar_gz(&self, archive_bytes: Bytes, target_filename: &str) -> Result { + use flate2::read::GzDecoder; + use tar::Archive; + + let cursor = Cursor::new(archive_bytes); + let mut archive = Archive::new(GzDecoder::new(cursor)); + + for entry_result in archive.entries().context("Failed to read tar entries")? { + let mut entry = entry_result.context("Failed to read tar entry")?; + let path = entry.path().context("Failed to get entry path")?; + + let basename = path + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_default(); + + if basename.eq_ignore_ascii_case(target_filename) && !basename.starts_with("._") { + info!("Found in tar.gz: {}", path.display()); + let mut buf = Vec::new(); + std::io::copy(&mut entry, &mut buf).context("Failed to read tar entry bytes")?; + return Ok(Bytes::from(buf)); + } + } + + Err(anyhow!("'{}' not found in tar.gz", target_filename)) + } + + #[cfg(target_os = "windows")] + fn extract_from_tar_gz(&self, _bytes: Bytes, target: &str) -> Result { + Err(anyhow!( + "tar.gz extraction not supported on Windows for '{}'", + target + )) + } +} + +#[cfg(test)] +#[path = "github_download_service_tests.rs"] +mod tests; diff --git a/clients/openframe-client-updater/src/services/github_download_service_tests.rs b/clients/openframe-client-updater/src/services/github_download_service_tests.rs new file mode 100644 index 000000000..5b4ae3088 --- /dev/null +++ b/clients/openframe-client-updater/src/services/github_download_service_tests.rs @@ -0,0 +1,145 @@ +use super::*; + +fn service() -> GithubDownloadService { + GithubDownloadService::new(Client::new()) +} + +fn config(os: &str) -> DownloadConfiguration { + DownloadConfiguration { + os: os.to_string(), + file_name: "archive".to_string(), + target_file_name: "openframe-client".to_string(), + link: "https://example.com/archive".to_string(), + } +} + +#[test] +fn cdn_fallback_rewrites_github_release_urls() { + let github = "https://github.com/flamingo-stack/openframe-oss-tenant/releases/download/1.2.3/openframe-client_windows.zip"; + assert_eq!( + GithubDownloadService::github_to_cdn_url(github), + "https://cdn.jsdelivr.net/gh/flamingo-stack/openframe-oss-tenant@1.2.3/openframe-client_windows.zip" + ); +} + +#[test] +fn cdn_fallback_leaves_non_github_urls_alone() { + let url = "https://downloads.example.com/openframe-client.zip"; + assert_eq!(GithubDownloadService::github_to_cdn_url(url), url); +} + +#[test] +fn find_for_current_os_picks_the_matching_config() { + let current = if cfg!(target_os = "windows") { + "windows" + } else if cfg!(target_os = "macos") { + "macos" + } else { + "linux" + }; + let configs = vec![config("solaris"), config(current)]; + let found = service().find_for_current_os(&configs).unwrap(); + assert_eq!(found.os, current); +} + +#[test] +fn find_for_current_os_errors_when_nothing_matches() { + assert!(service().find_for_current_os(&[config("solaris")]).is_err()); +} + +#[cfg(target_os = "windows")] +#[test] +fn extracts_target_from_zip_by_suffix() { + use std::io::Write; + use zip::write::FileOptions; + + let mut writer = zip::ZipWriter::new(Cursor::new(Vec::new())); + writer + .start_file("release/OpenFrame-Client.exe", FileOptions::default()) + .unwrap(); + writer.write_all(b"exe-bytes").unwrap(); + writer + .start_file("README.md", FileOptions::default()) + .unwrap(); + writer.write_all(b"docs").unwrap(); + let archive = Bytes::from(writer.finish().unwrap().into_inner()); + + let extracted = service() + .extract_from_zip(archive, "openframe-client.exe") + .unwrap(); + assert_eq!(extracted.as_ref(), b"exe-bytes"); +} + +#[cfg(target_os = "windows")] +#[test] +fn zip_extraction_errors_when_target_absent() { + use std::io::Write; + use zip::write::FileOptions; + + let mut writer = zip::ZipWriter::new(Cursor::new(Vec::new())); + writer + .start_file("other.bin", FileOptions::default()) + .unwrap(); + writer.write_all(b"x").unwrap(); + let archive = Bytes::from(writer.finish().unwrap().into_inner()); + + assert!(service() + .extract_from_zip(archive, "openframe-client.exe") + .is_err()); +} + +#[cfg(not(target_os = "windows"))] +#[test] +fn extracts_target_from_tar_gz_by_basename() { + use flate2::write::GzEncoder; + use flate2::Compression; + + let gz = GzEncoder::new(Vec::new(), Compression::default()); + let mut builder = tar::Builder::new(gz); + + // AppleDouble sidecar (._name) comes first and must be skipped. + let mut sidecar = tar::Header::new_gnu(); + sidecar.set_size(4); + sidecar.set_mode(0o644); + sidecar.set_cksum(); + builder + .append_data(&mut sidecar, "release/._openframe-client", &b"meta"[..]) + .unwrap(); + + let payload = b"elf-bytes"; + let mut header = tar::Header::new_gnu(); + header.set_size(payload.len() as u64); + header.set_mode(0o755); + header.set_cksum(); + builder + .append_data(&mut header, "release/OpenFrame-Client", &payload[..]) + .unwrap(); + + let archive = Bytes::from(builder.into_inner().unwrap().finish().unwrap()); + + let extracted = service() + .extract_from_tar_gz(archive, "openframe-client") + .unwrap(); + assert_eq!(extracted.as_ref(), b"elf-bytes"); +} + +#[cfg(not(target_os = "windows"))] +#[test] +fn tar_gz_extraction_errors_when_target_absent() { + use flate2::write::GzEncoder; + use flate2::Compression; + + let gz = GzEncoder::new(Vec::new(), Compression::default()); + let mut builder = tar::Builder::new(gz); + let mut header = tar::Header::new_gnu(); + header.set_size(1); + header.set_cksum(); + builder + .append_data(&mut header, "other", &b"x"[..]) + .unwrap(); + let archive = Bytes::from(builder.into_inner().unwrap().finish().unwrap()); + + assert!(service() + .extract_from_tar_gz(archive, "openframe-client") + .is_err()); +} diff --git a/clients/openframe-client-updater/src/services/initial_configuration_service.rs b/clients/openframe-client-updater/src/services/initial_configuration_service.rs new file mode 100644 index 000000000..23ce020fc --- /dev/null +++ b/clients/openframe-client-updater/src/services/initial_configuration_service.rs @@ -0,0 +1,54 @@ +use anyhow::{Context, Result}; +use std::fs; +use std::path::PathBuf; + +use crate::models::InitialConfiguration; +use crate::platform::DirectoryManager; + +#[derive(Clone)] +pub struct InitialConfigurationService { + config_file_path: PathBuf, +} + +impl InitialConfigurationService { + pub fn new(directory_manager: &DirectoryManager) -> Result { + let config_file_path = directory_manager.secured_dir().join("initial_config.json"); + + directory_manager + .ensure_directories() + .with_context(|| "Failed to ensure secured directory exists")?; + + Ok(Self { config_file_path }) + } + + pub fn get_server_url(&self) -> Result { + Ok(self.read_config()?.server_host) + } + + pub fn get_initial_key(&self) -> Result { + Ok(self.read_config()?.initial_key) + } + + pub fn is_local_mode(&self) -> Result { + Ok(self.read_config()?.local_mode) + } + + pub fn get_local_ca_cert_path(&self) -> Result { + Ok(self.read_config()?.local_ca_cert_path) + } + + fn read_config(&self) -> Result { + if !self.config_file_path.exists() { + return Err(anyhow::anyhow!( + "initial_config.json not found at {}. Is the main client installed?", + self.config_file_path.display() + )); + } + + let json = fs::read_to_string(&self.config_file_path) + .with_context(|| format!("Failed to read {}", self.config_file_path.display()))?; + + serde_json::from_str::(&json) + .context("Failed to deserialize initial_config.json") + } +} diff --git a/clients/openframe-client-updater/src/services/last_known_good_service.rs b/clients/openframe-client-updater/src/services/last_known_good_service.rs new file mode 100644 index 000000000..2bde98392 --- /dev/null +++ b/clients/openframe-client-updater/src/services/last_known_good_service.rs @@ -0,0 +1,154 @@ +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::{Path, PathBuf}; +use tracing::{debug, info}; + +use crate::platform::DirectoryManager; + +#[derive(Debug, Serialize, Deserialize)] +struct LastKnownGood { + version: String, +} + +/// Last-known-good ratchet for the openframe-client binary, owned by the updater. +/// +/// Same artifacts as the in-client implementation (Hotfix #2169), so existing +/// fleets carry their anchor over unchanged: +/// - `{secured}/last_known_good.json` — highest version that verified a boot +/// - `{client_exe}.lkg` — reserve copy of that binary +/// - `{secured}/boot.marker` — written by openframe-client on every +/// boot with its running version; the updater's health signal +/// +/// The client keeps writing the boot marker (and seeding the anchor on its own +/// boots). The updater consumes the marker, enforces the downgrade guard, and +/// promotes the anchor after a verified update. +#[derive(Clone)] +pub struct LastKnownGoodService { + anchor_file_path: PathBuf, + boot_marker_path: PathBuf, + client_exe: PathBuf, + reserve_path: PathBuf, +} + +impl LastKnownGoodService { + pub fn new(directory_manager: &DirectoryManager, client_exe: PathBuf) -> Self { + let secured = directory_manager.secured_dir(); + + let mut reserve = client_exe.clone().into_os_string(); + reserve.push(".lkg"); + + Self { + anchor_file_path: secured.join("last_known_good.json"), + boot_marker_path: secured.join("boot.marker"), + client_exe, + reserve_path: PathBuf::from(reserve), + } + } + + pub fn load_anchor(&self) -> Result> { + if !self.anchor_file_path.exists() { + debug!( + "No last-known-good file found at: {}", + self.anchor_file_path.display() + ); + return Ok(None); + } + + let json_content = fs::read_to_string(&self.anchor_file_path).with_context(|| { + format!( + "Failed to read last-known-good file: {:?}", + self.anchor_file_path + ) + })?; + + let anchor: LastKnownGood = serde_json::from_str(&json_content) + .context("Failed to deserialize last-known-good from JSON")?; + + Ok(Some(anchor.version)) + } + + /// Raise the anchor to `version` and refresh the reserve from the binary + /// currently at the client path (the one that just verified its boot). + pub fn promote(&self, version: &str) -> Result<()> { + let temp_reserve = self.reserve_path.with_extension("lkg.tmp"); + fs::copy(&self.client_exe, &temp_reserve).with_context(|| { + format!( + "Failed to copy client binary {} to temp reserve {}", + self.client_exe.display(), + temp_reserve.display() + ) + })?; + fs::rename(&temp_reserve, &self.reserve_path).with_context(|| { + format!( + "Failed to move temp reserve into place: {}", + self.reserve_path.display() + ) + })?; + + let json_content = serde_json::to_string_pretty(&LastKnownGood { + version: version.to_string(), + }) + .context("Failed to serialize last-known-good to JSON")?; + let temp_anchor = self.anchor_file_path.with_extension("json.tmp"); + fs::write(&temp_anchor, json_content).with_context(|| { + format!( + "Failed to write temp last-known-good file: {:?}", + temp_anchor + ) + })?; + fs::rename(&temp_anchor, &self.anchor_file_path).with_context(|| { + format!( + "Failed to move last-known-good file into place: {:?}", + self.anchor_file_path + ) + })?; + + info!( + "Last-known-good anchor set to {} (reserve: {})", + version, + self.reserve_path.display() + ); + Ok(()) + } + + /// Version the client reported on its most recent boot, if any. + pub fn boot_marker_version(&self) -> Option { + let content = fs::read_to_string(&self.boot_marker_path).ok()?; + let trimmed = content.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } + } + + /// Last modification time of the boot marker. Each client boot rewrites the + /// marker, so an mtime change during observation means the client restarted. + pub fn boot_marker_mtime(&self) -> Option { + fs::metadata(&self.boot_marker_path) + .and_then(|m| m.modified()) + .ok() + } + + /// Remove the boot marker so the next marker observed is from the new binary. + pub fn clear_boot_marker(&self) -> Result<()> { + if self.boot_marker_path.exists() { + fs::remove_file(&self.boot_marker_path).with_context(|| { + format!( + "Failed to remove boot marker: {}", + self.boot_marker_path.display() + ) + })?; + } + Ok(()) + } + + pub fn reserve_path(&self) -> &Path { + &self.reserve_path + } +} + +#[cfg(test)] +#[path = "last_known_good_service_tests.rs"] +mod tests; diff --git a/clients/openframe-client-updater/src/services/last_known_good_service_tests.rs b/clients/openframe-client-updater/src/services/last_known_good_service_tests.rs new file mode 100644 index 000000000..38e81c9e1 --- /dev/null +++ b/clients/openframe-client-updater/src/services/last_known_good_service_tests.rs @@ -0,0 +1,113 @@ +use super::*; + +struct Setup { + _dir: tempfile::TempDir, + svc: LastKnownGoodService, + client_exe: PathBuf, + secured: PathBuf, +} + +fn setup() -> Setup { + let dir = tempfile::tempdir().unwrap(); + let secured = dir.path().join("secured"); + let bin_dir = dir.path().join("bin"); + fs::create_dir_all(&secured).unwrap(); + fs::create_dir_all(&bin_dir).unwrap(); + + let client_exe = bin_dir.join("openframe-client"); + fs::write(&client_exe, b"binary-v1").unwrap(); + + let dm = DirectoryManager::with_custom_dirs( + dir.path().join("logs"), + dir.path().to_path_buf(), + secured.clone(), + ); + let svc = LastKnownGoodService::new(&dm, client_exe.clone()); + Setup { + _dir: dir, + svc, + client_exe, + secured, + } +} + +#[test] +fn anchor_is_none_before_first_promotion() { + let s = setup(); + assert!(s.svc.load_anchor().unwrap().is_none()); +} + +#[test] +fn reserve_path_appends_lkg_to_the_full_binary_name() { + let s = setup(); + assert_eq!( + s.svc.reserve_path().file_name().unwrap().to_string_lossy(), + "openframe-client.lkg" + ); +} + +#[test] +fn promote_writes_anchor_and_reserve_copy() { + let s = setup(); + s.svc.promote("1.2.3").unwrap(); + + assert_eq!(s.svc.load_anchor().unwrap().as_deref(), Some("1.2.3")); + assert_eq!(fs::read(s.svc.reserve_path()).unwrap(), b"binary-v1"); + assert!( + s.client_exe.exists(), + "promotion copies, it must not consume the client binary" + ); +} + +#[test] +fn promote_refreshes_reserve_from_current_binary() { + let s = setup(); + s.svc.promote("1.0.0").unwrap(); + + fs::write(&s.client_exe, b"binary-v2").unwrap(); + s.svc.promote("2.0.0").unwrap(); + + assert_eq!(s.svc.load_anchor().unwrap().as_deref(), Some("2.0.0")); + assert_eq!(fs::read(s.svc.reserve_path()).unwrap(), b"binary-v2"); +} + +#[test] +fn promote_fails_when_client_binary_is_missing() { + let s = setup(); + fs::remove_file(&s.client_exe).unwrap(); + assert!(s.svc.promote("1.0.0").is_err()); + assert!( + s.svc.load_anchor().unwrap().is_none(), + "anchor must not move" + ); +} + +#[test] +fn boot_marker_version_trims_and_rejects_empty() { + let s = setup(); + assert!(s.svc.boot_marker_version().is_none()); + + fs::write(s.secured.join("boot.marker"), b" 1.2.3\n").unwrap(); + assert_eq!(s.svc.boot_marker_version().as_deref(), Some("1.2.3")); + + fs::write(s.secured.join("boot.marker"), b" \n").unwrap(); + assert!(s.svc.boot_marker_version().is_none()); +} + +#[test] +fn boot_marker_mtime_tracks_the_file() { + let s = setup(); + assert!(s.svc.boot_marker_mtime().is_none()); + fs::write(s.secured.join("boot.marker"), b"1.0.0").unwrap(); + assert!(s.svc.boot_marker_mtime().is_some()); +} + +#[test] +fn clear_boot_marker_removes_and_tolerates_absence() { + let s = setup(); + s.svc.clear_boot_marker().unwrap(); + + fs::write(s.secured.join("boot.marker"), b"1.0.0").unwrap(); + s.svc.clear_boot_marker().unwrap(); + assert!(s.svc.boot_marker_version().is_none()); +} diff --git a/clients/openframe-client-updater/src/services/local_tls_config_provider.rs b/clients/openframe-client-updater/src/services/local_tls_config_provider.rs new file mode 100644 index 000000000..5d53235a1 --- /dev/null +++ b/clients/openframe-client-updater/src/services/local_tls_config_provider.rs @@ -0,0 +1,71 @@ +use anyhow::{anyhow, Context, Result}; +use std::fs; +use std::io::Cursor; +use std::path::PathBuf; +use tracing::info; + +use async_nats::rustls::{ClientConfig, RootCertStore}; + +use crate::services::InitialConfigurationService; + +#[derive(Clone)] +pub struct LocalTlsConfigProvider { + initial_configuration_service: InitialConfigurationService, +} + +impl LocalTlsConfigProvider { + pub fn new(initial_configuration_service: InitialConfigurationService) -> Self { + Self { + initial_configuration_service, + } + } + + pub fn create_tls_config(&self) -> Result { + info!("Creating local-mode TLS configuration"); + + let cert_path = self.get_certificate_path()?; + info!("Using CA certificate: {}", cert_path); + + let cert_data = fs::read(&cert_path) + .with_context(|| format!("Failed to read CA certificate from {}", cert_path))?; + + let mut cursor = Cursor::new(cert_data); + let certs = rustls_pemfile::certs(&mut cursor).context("Failed to parse certificate")?; + + let mut root_store = RootCertStore::empty(); + for cert in certs { + root_store + .add(cert.into()) + .context("Failed to add CA certificate to root store")?; + } + + let config = ClientConfig::builder() + .with_root_certificates(root_store) + .with_no_client_auth(); + + Ok(config) + } + + fn get_certificate_path(&self) -> Result { + let saved_path = self + .initial_configuration_service + .get_local_ca_cert_path() + .context("Failed to read local_ca_cert_path from initial configuration")?; + + if saved_path.is_empty() { + return Err(anyhow!( + "local_ca_cert_path is not set in initial_config.json" + )); + } + + let path = PathBuf::from(&saved_path); + if !path.exists() { + return Err(anyhow!( + "local_ca_cert_path points to non-existent file: {}", + saved_path + )); + } + + Ok(saved_path) + } +} diff --git a/clients/openframe-client-updater/src/services/mod.rs b/clients/openframe-client-updater/src/services/mod.rs new file mode 100644 index 000000000..65df8f8db --- /dev/null +++ b/clients/openframe-client-updater/src/services/mod.rs @@ -0,0 +1,26 @@ +pub mod agent_configuration_service; +pub mod client_update_service; +pub mod encryption_service; +pub mod github_download_service; +pub mod initial_configuration_service; +pub mod last_known_good_service; +pub mod local_tls_config_provider; +pub mod nats_connection_manager; +pub mod nats_message_publisher; +pub mod service_manager_service; +pub mod token_provider; +pub mod update_progress_publisher; +pub mod updater_orchestrator; +pub mod updater_state_service; + +pub use agent_configuration_service::AgentConfigurationService; +pub use client_update_service::ClientUpdateService; +pub use encryption_service::EncryptionService; +pub use github_download_service::GithubDownloadService; +pub use initial_configuration_service::InitialConfigurationService; +pub use local_tls_config_provider::LocalTlsConfigProvider; +pub use nats_connection_manager::NatsConnectionManager; +pub use nats_message_publisher::NatsMessagePublisher; +pub use service_manager_service::ServiceManagerService; +pub use update_progress_publisher::UpdateProgressPublisher; +pub use updater_state_service::UpdaterStateService; diff --git a/clients/openframe-client-updater/src/services/nats_connection_manager.rs b/clients/openframe-client-updater/src/services/nats_connection_manager.rs new file mode 100644 index 000000000..57b916013 --- /dev/null +++ b/clients/openframe-client-updater/src/services/nats_connection_manager.rs @@ -0,0 +1,148 @@ +use anyhow::{Context, Result}; +use async_nats::{Client, Event}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use tokio::sync::{broadcast, RwLock}; +use tracing::{error, info, warn}; + +use crate::services::agent_configuration_service::AgentConfigurationService; +use crate::services::initial_configuration_service::InitialConfigurationService; +use crate::services::local_tls_config_provider::LocalTlsConfigProvider; + +#[derive(Clone)] +pub struct NatsConnectionManager { + client: Arc>>>, + nats_server_url: String, + config_service: AgentConfigurationService, + initial_configuration_service: InitialConfigurationService, + token: Arc>>, + tls_config_provider: LocalTlsConfigProvider, + reconnect_tx: broadcast::Sender<()>, +} + +impl NatsConnectionManager { + const NATS_DEVICE_USER: &'static str = "machine"; + const NATS_DEVICE_PASSWORD: &'static str = ""; + + pub fn new( + nats_server_url: String, + config_service: AgentConfigurationService, + initial_configuration_service: InitialConfigurationService, + token: Arc>>, + tls_config_provider: LocalTlsConfigProvider, + ) -> Self { + let (reconnect_tx, _) = broadcast::channel(16); + Self { + client: Arc::new(RwLock::new(None)), + nats_server_url, + config_service, + initial_configuration_service, + token, + tls_config_provider, + reconnect_tx, + } + } + + pub fn subscribe_reconnect(&self) -> broadcast::Receiver<()> { + self.reconnect_tx.subscribe() + } + + pub async fn connect(&self) -> Result<()> { + let machine_id = self.config_service.get_machine_id().await?; + + info!(hostname = %self.nats_server_url, "Connecting to NATS server"); + + let connection_url = self.build_nats_connection_url().await?; + + let token = self.token.clone(); + let nats_server_url = self.nats_server_url.clone(); + let nats_server_url_for_reconnect = self.nats_server_url.clone(); + let reconnect_tx = self.reconnect_tx.clone(); + let connected_once = Arc::new(AtomicBool::new(false)); + + let mut connect_options = async_nats::ConnectOptions::new() + .name(machine_id) + .user_and_password( + Self::NATS_DEVICE_USER.to_string(), + Self::NATS_DEVICE_PASSWORD.to_string(), + ) + .retry_on_initial_connect() + .max_reconnects(None) + .reconnect_delay_callback(move |attempt| { + warn!( + attempt = attempt, + hostname = %nats_server_url_for_reconnect, + "NATS reconnect attempt" + ); + std::time::Duration::from_secs(5) + }) + .ping_interval(std::time::Duration::from_secs(10)) + .event_callback(move |event| { + let reconnect_tx = reconnect_tx.clone(); + let connected_once = connected_once.clone(); + async move { + info!("NATS event: {:?}", event); + if matches!(event, Event::Connected) + && connected_once.swap(true, Ordering::SeqCst) + { + let _ = reconnect_tx.send(()); + } + } + }) + .auth_url_callback(move |()| { + info!("Auth URL callback triggered — reading latest shared token"); + let token = token.clone(); + let nats_server_url = nats_server_url.clone(); + + async move { + match token.read().await.clone() { + Some(t) => { + info!("Built new NATS URL from shared token"); + Ok(format!("{}/ws/nats?authorization={}", nats_server_url, t)) + } + None => { + error!("Shared token not available for NATS re-auth"); + Err(async_nats::AuthError::new("Shared token not available")) + } + } + } + }); + + if self.initial_configuration_service.is_local_mode()? { + let tls_config = self + .tls_config_provider + .create_tls_config() + .context("Failed to create local-mode TLS configuration")?; + connect_options = connect_options.tls_client_config(tls_config); + } + + let client: Client = connect_options + .connect(&connection_url) + .await + .context("Failed to connect to NATS server")?; + + *self.client.write().await = Some(Arc::new(client)); + + info!("Connected to NATS server"); + Ok(()) + } + + async fn build_nats_connection_url(&self) -> Result { + let token = + self.token.read().await.clone().context( + "Shared token not available — token watcher has not received a token yet", + )?; + Ok(format!( + "{}/ws/nats?authorization={}", + self.nats_server_url, token + )) + } + + pub async fn get_client(&self) -> Result> { + self.client + .read() + .await + .clone() + .context("NATS client not initialized — call connect() first") + } +} diff --git a/clients/openframe-client-updater/src/services/nats_message_publisher.rs b/clients/openframe-client-updater/src/services/nats_message_publisher.rs new file mode 100644 index 000000000..a7349a409 --- /dev/null +++ b/clients/openframe-client-updater/src/services/nats_message_publisher.rs @@ -0,0 +1,28 @@ +use anyhow::{Context, Result}; +use serde::Serialize; + +use crate::services::nats_connection_manager::NatsConnectionManager; + +#[derive(Clone)] +pub struct NatsMessagePublisher { + nats_connection_manager: NatsConnectionManager, +} + +impl NatsMessagePublisher { + pub fn new(nats_connection_manager: NatsConnectionManager) -> Self { + Self { + nats_connection_manager, + } + } + + pub async fn publish(&self, subject: &str, payload: T) -> Result<()> { + let json = serde_json::to_string(&payload).context("Failed to serialize NATS payload")?; + + let client = self.nats_connection_manager.get_client().await?; + + client + .publish(subject.to_string(), json.into()) + .await + .context("Failed to publish message to NATS") + } +} diff --git a/clients/openframe-client-updater/src/services/service_manager_service.rs b/clients/openframe-client-updater/src/services/service_manager_service.rs new file mode 100644 index 000000000..3d7249a3d --- /dev/null +++ b/clients/openframe-client-updater/src/services/service_manager_service.rs @@ -0,0 +1,281 @@ +use anyhow::{anyhow, Context, Result}; +use std::path::PathBuf; +use tracing::info; + +use crate::config::updater_config::SERVICE_STOP_TIMEOUT_SECS; + +/// Stops and starts `com.openframe.client` using native OS APIs. +/// No PowerShell, no subprocesses on Windows. +pub struct ServiceManagerService; + +impl ServiceManagerService { + /// Stops the given service and waits until it reaches the Stopped state. + pub fn stop(service_name: &str) -> Result<()> { + info!("Stopping service: {}", service_name); + Self::stop_impl(service_name)?; + info!("Service stopped: {}", service_name); + Ok(()) + } + + /// Starts the given service. + pub fn start(service_name: &str) -> Result<()> { + info!("Starting service: {}", service_name); + Self::start_impl(service_name)?; + info!("Service started: {}", service_name); + Ok(()) + } + + /// Returns true if the service is currently in the Running state. + pub fn is_running(service_name: &str) -> Result { + Self::is_running_impl(service_name) + } + + /// Async wrappers: the sync implementations block (SCM polling up to + /// SERVICE_STOP_TIMEOUT_SECS) and must run on the blocking pool, not a + /// tokio worker. + pub async fn stop_async(service_name: &'static str) -> Result<()> { + tokio::task::spawn_blocking(move || Self::stop(service_name)) + .await + .context("Service stop task failed to join")? + } + + pub async fn start_async(service_name: &'static str) -> Result<()> { + tokio::task::spawn_blocking(move || Self::start(service_name)) + .await + .context("Service start task failed to join")? + } + + pub async fn is_running_async(service_name: &'static str) -> Result { + tokio::task::spawn_blocking(move || Self::is_running(service_name)) + .await + .context("Service status task failed to join")? + } + + /// Returns the standard install path for the openframe-client binary. + pub fn client_binary_path() -> PathBuf { + #[cfg(target_os = "windows")] + { + let program_files = + std::env::var("ProgramFiles").unwrap_or_else(|_| "C:\\Program Files".to_string()); + PathBuf::from(program_files) + .join("OpenFrame") + .join("bin") + .join("openframe-client.exe") + } + + #[cfg(not(target_os = "windows"))] + { + PathBuf::from("/usr/local/bin/openframe-client") + } + } + + // ── Windows ────────────────────────────────────────────────────────── + + #[cfg(target_os = "windows")] + fn stop_impl(service_name: &str) -> Result<()> { + use windows_service::service::ServiceAccess; + use windows_service::service_manager::{ServiceManager, ServiceManagerAccess}; + + let manager = ServiceManager::local_computer(None::<&str>, ServiceManagerAccess::CONNECT) + .context("Failed to open Service Control Manager")?; + + let service = manager + .open_service( + service_name, + ServiceAccess::STOP | ServiceAccess::QUERY_STATUS, + ) + .with_context(|| format!("Failed to open service '{}'", service_name))?; + + let status = service + .query_status() + .context("Failed to query service status")?; + + if status.current_state == windows_service::service::ServiceState::Stopped { + info!("Service '{}' is already stopped", service_name); + return Ok(()); + } + + service.stop().context("Failed to send stop control")?; + + // Poll until stopped or timeout + let deadline = + std::time::Instant::now() + std::time::Duration::from_secs(SERVICE_STOP_TIMEOUT_SECS); + + loop { + std::thread::sleep(std::time::Duration::from_millis(500)); + + let status = service + .query_status() + .context("Failed to query service status while waiting for stop")?; + + if status.current_state == windows_service::service::ServiceState::Stopped { + return Ok(()); + } + + if std::time::Instant::now() >= deadline { + return Err(anyhow!( + "Service '{}' did not stop within {}s", + service_name, + SERVICE_STOP_TIMEOUT_SECS + )); + } + } + } + + #[cfg(target_os = "windows")] + fn start_impl(service_name: &str) -> Result<()> { + use windows_service::service::ServiceAccess; + use windows_service::service_manager::{ServiceManager, ServiceManagerAccess}; + + let manager = ServiceManager::local_computer(None::<&str>, ServiceManagerAccess::CONNECT) + .context("Failed to open Service Control Manager")?; + + let service = manager + .open_service(service_name, ServiceAccess::START) + .with_context(|| format!("Failed to open service '{}'", service_name))?; + + service + .start(&[] as &[&str]) + .context("Failed to start service")?; + Ok(()) + } + + #[cfg(target_os = "windows")] + fn is_running_impl(service_name: &str) -> Result { + use windows_service::service::ServiceAccess; + use windows_service::service_manager::{ServiceManager, ServiceManagerAccess}; + + let manager = ServiceManager::local_computer(None::<&str>, ServiceManagerAccess::CONNECT) + .context("Failed to open Service Control Manager")?; + + let service = manager + .open_service(service_name, ServiceAccess::QUERY_STATUS) + .with_context(|| format!("Failed to open service '{}'", service_name))?; + + let status = service + .query_status() + .context("Failed to query service status")?; + + Ok(status.current_state == windows_service::service::ServiceState::Running) + } + + // ── macOS ───────────────────────────────────────────────────────────── + + #[cfg(target_os = "macos")] + fn stop_impl(service_name: &str) -> Result<()> { + let plist = format!("/Library/LaunchDaemons/{}.plist", service_name); + let status = std::process::Command::new("launchctl") + .args(["unload", &plist]) + .status() + .context("Failed to run launchctl unload")?; + + if !status.success() { + // A not-loaded service is already stopped — failing here would + // wedge every future update at StoppingService. + if !Self::is_loaded(service_name)? { + info!( + "Service '{}' is not loaded — treating as stopped", + service_name + ); + return Ok(()); + } + return Err(anyhow!("launchctl unload failed with: {:?}", status.code())); + } + + // unload returns before the process exits — wait, or the binary swap + // races the dying client. + let deadline = + std::time::Instant::now() + std::time::Duration::from_secs(SERVICE_STOP_TIMEOUT_SECS); + loop { + if !matches!(Self::is_running_impl(service_name), Ok(true)) { + return Ok(()); + } + if std::time::Instant::now() >= deadline { + return Err(anyhow!( + "Service '{}' did not stop within {}s after unload", + service_name, + SERVICE_STOP_TIMEOUT_SECS + )); + } + std::thread::sleep(std::time::Duration::from_millis(500)); + } + } + + #[cfg(target_os = "macos")] + fn start_impl(service_name: &str) -> Result<()> { + let plist = format!("/Library/LaunchDaemons/{}.plist", service_name); + let status = std::process::Command::new("launchctl") + .args(["load", &plist]) + .status() + .context("Failed to run launchctl load")?; + + if !status.success() { + // load errors when the service is already loaded — not a start failure. + if Self::is_loaded(service_name)? { + info!("Service '{}' is already loaded", service_name); + return Ok(()); + } + return Err(anyhow!("launchctl load failed with: {:?}", status.code())); + } + Ok(()) + } + + /// True when launchd knows the service (loaded), running or not. + #[cfg(target_os = "macos")] + fn is_loaded(service_name: &str) -> Result { + let output = std::process::Command::new("launchctl") + .args(["list", service_name]) + .output() + .context("Failed to run launchctl list")?; + Ok(output.status.success()) + } + + #[cfg(target_os = "macos")] + fn is_running_impl(service_name: &str) -> Result { + let output = std::process::Command::new("launchctl") + .args(["list", service_name]) + .output() + .context("Failed to run launchctl list")?; + + // launchctl list returns 0 and prints a PID if the service is running + Ok(output.status.success() && String::from_utf8_lossy(&output.stdout).contains("\"PID\"")) + } + + // ── Linux ───────────────────────────────────────────────────────────── + + #[cfg(all(not(target_os = "windows"), not(target_os = "macos")))] + fn stop_impl(service_name: &str) -> Result<()> { + let status = std::process::Command::new("systemctl") + .args(["stop", service_name]) + .status() + .context("Failed to run systemctl stop")?; + + if !status.success() { + return Err(anyhow!("systemctl stop failed with: {:?}", status.code())); + } + Ok(()) + } + + #[cfg(all(not(target_os = "windows"), not(target_os = "macos")))] + fn start_impl(service_name: &str) -> Result<()> { + let status = std::process::Command::new("systemctl") + .args(["start", service_name]) + .status() + .context("Failed to run systemctl start")?; + + if !status.success() { + return Err(anyhow!("systemctl start failed with: {:?}", status.code())); + } + Ok(()) + } + + #[cfg(all(not(target_os = "windows"), not(target_os = "macos")))] + fn is_running_impl(service_name: &str) -> Result { + let output = std::process::Command::new("systemctl") + .args(["is-active", "--quiet", service_name]) + .status() + .context("Failed to run systemctl is-active")?; + + Ok(output.success()) + } +} diff --git a/clients/openframe-client-updater/src/services/token_provider.rs b/clients/openframe-client-updater/src/services/token_provider.rs new file mode 100644 index 000000000..d9296d1c8 --- /dev/null +++ b/clients/openframe-client-updater/src/services/token_provider.rs @@ -0,0 +1,145 @@ +use chrono::Utc; +use std::fs; +use std::path::PathBuf; +use std::sync::Arc; +use std::time::Duration; +use tokio::sync::RwLock; +use tracing::{error, info, warn}; + +use crate::clients::AuthClient; +use crate::services::agent_configuration_service::AgentConfigurationService; +use crate::services::encryption_service::EncryptionService; +use crate::utils::jwt; + +/// How often the shared token file is polled. +const POLL_INTERVAL_SECS: u64 = 5; +/// Self-refresh when the current token expires within this margin and the +/// shared file has not produced a fresher one (openframe-client presumed dead). +const SELF_REFRESH_MARGIN_SECS: i64 = 120; +/// Minimum delay between self-refresh attempts. +const SELF_REFRESH_COOLDOWN_SECS: i64 = 60; + +/// Supplies the updater with a valid access token. +/// +/// Primary source: `shared_token.enc`, written and refreshed by openframe-client +/// (same consumer pattern as openframe-chat). Fallback: if the client stops +/// refreshing the file — crashed, uninstalled mid-flight, or wedged — the +/// provider authenticates on its own via OAuth2 client_credentials using the +/// credentials in `agent_config.json`. Self-obtained tokens are held in memory +/// only; the provider never writes `shared_token.enc` (the client owns it). +pub struct TokenProvider; + +impl TokenProvider { + pub fn start( + token_file_path: PathBuf, + auth_client: AuthClient, + config_service: AgentConfigurationService, + ) -> Arc>> { + let encryption_service = EncryptionService::new(); + let current_token: Arc>> = Arc::new(RwLock::new(None)); + let token_ref = current_token.clone(); + + tokio::spawn(async move { + let mut last_self_refresh_attempt: i64 = 0; + + loop { + let file_token = Self::read_and_decrypt(&token_file_path, &encryption_service); + let current = token_ref.read().await.clone(); + + if let Some(adopted) = Self::pick_fresher(¤t, file_token) { + match ¤t { + None => info!("Shared token received"), + Some(_) => info!("Shared token updated"), + } + *token_ref.write().await = Some(adopted); + } else if Self::needs_self_refresh(¤t) { + let now = Utc::now().timestamp(); + if now - last_self_refresh_attempt >= SELF_REFRESH_COOLDOWN_SECS { + last_self_refresh_attempt = now; + match Self::self_refresh(&auth_client, &config_service).await { + Ok(token) => { + warn!( + "Shared token stale and not refreshed by openframe-client — \ + obtained own token via client_credentials" + ); + *token_ref.write().await = Some(token); + } + Err(e) => error!( + "Self token refresh failed (will retry in {}s): {:#}", + SELF_REFRESH_COOLDOWN_SECS, e + ), + } + } + } + + tokio::time::sleep(Duration::from_secs(POLL_INTERVAL_SECS)).await; + } + }); + + current_token + } + + /// Returns the file token when it is strictly fresher (later `exp`) than the + /// current one, or when there is no current token yet. + fn pick_fresher(current: &Option, file_token: Option) -> Option { + let file_token = file_token?; + + match current { + None => Some(file_token), + Some(cur) if *cur == file_token => None, + Some(cur) => { + let cur_exp = jwt::token_exp_unix(cur).unwrap_or(0); + let file_exp = jwt::token_exp_unix(&file_token).unwrap_or(0); + if file_exp > cur_exp { + Some(file_token) + } else { + None + } + } + } + } + + /// True when there is no usable token: missing, undecodable, or inside the + /// expiry margin without a fresher token in the shared file. + fn needs_self_refresh(current: &Option) -> bool { + let Some(token) = current else { + return true; + }; + let Some(exp) = jwt::token_exp_unix(token) else { + // Undecodable exp: assume the client is managing it; don't churn. + return false; + }; + exp - Utc::now().timestamp() < SELF_REFRESH_MARGIN_SECS + } + + async fn self_refresh( + auth_client: &AuthClient, + config_service: &AgentConfigurationService, + ) -> anyhow::Result { + let (client_id, client_secret) = config_service.get_client_credentials().await?; + let response = auth_client + .authenticate_with_secret(client_id, client_secret) + .await?; + Ok(response.access_token) + } + + fn read_and_decrypt(path: &PathBuf, encryption_service: &EncryptionService) -> Option { + let content = match fs::read_to_string(path) { + Ok(c) => c, + Err(_) => return None, + }; + + let trimmed = content.trim(); + if trimmed.is_empty() { + return None; + } + + match encryption_service.decrypt(trimmed) { + Ok(token) => Some(token), + Err(e) => { + error!("Failed to decrypt shared token: {}", e); + None + } + } + } +} diff --git a/clients/openframe-client-updater/src/services/update_progress_publisher.rs b/clients/openframe-client-updater/src/services/update_progress_publisher.rs new file mode 100644 index 000000000..478da7b8c --- /dev/null +++ b/clients/openframe-client-updater/src/services/update_progress_publisher.rs @@ -0,0 +1,92 @@ +use tracing::{info, warn}; + +use crate::models::{InstalledAgentMessage, UpdateProgressMessage, UpdaterPhase}; +use crate::services::NatsMessagePublisher; + +#[derive(Clone)] +pub struct UpdateProgressPublisher { + nats_publisher: NatsMessagePublisher, + machine_id: String, +} + +impl UpdateProgressPublisher { + pub fn new(nats_publisher: NatsMessagePublisher, machine_id: String) -> Self { + Self { + nats_publisher, + machine_id, + } + } + + // Errors are swallowed — a NATS hiccup must never abort the binary swap. + pub async fn publish(&self, phase: &UpdaterPhase, version: &str) { + let subject = self.progress_subject(); + let msg = UpdateProgressMessage::new(phase.to_string(), version); + info!(phase = %phase, version = %version, subject = %subject, "Publishing update progress"); + + if let Err(e) = self.nats_publisher.publish(&subject, &msg).await { + warn!("Failed to publish update progress ({}): {}", phase, e); + } + } + + pub async fn publish_failure( + &self, + phase: &UpdaterPhase, + version: &str, + reason: &str, + rolled_back: bool, + ) { + let subject = self.progress_subject(); + let msg = + UpdateProgressMessage::with_failure(phase.to_string(), version, reason, rolled_back); + warn!( + phase = %phase, + version = %version, + reason = %reason, + rolled_back = rolled_back, + "Publishing update failure" + ); + + if let Err(e) = self.nats_publisher.publish(&subject, &msg).await { + warn!("Failed to publish update failure ({}): {}", phase, e); + } + } + + // Reports the updater's own version to the backend on startup. + pub async fn publish_updater_version(&self) { + let version = env!("OPENFRAME_UPDATER_VERSION"); + let subject = self.installed_agent_subject(); + let msg = InstalledAgentMessage { + agent_type: "openframe-client-updater".to_string(), + version: version.to_string(), + }; + info!(version = %version, subject = %subject, "Reporting updater version to backend"); + if let Err(e) = self.nats_publisher.publish(&subject, &msg).await { + warn!("Failed to publish updater version: {}", e); + } + } + + // Also publishes installed-agent for backward compat with the existing backend handler. + pub async fn publish_success(&self, version: &str) { + self.publish(&UpdaterPhase::Completed, version).await; + + let subject = self.installed_agent_subject(); + let msg = InstalledAgentMessage { + agent_type: "openframe-client".to_string(), + version: version.to_string(), + }; + + info!(version = %version, subject = %subject, "Publishing installed-agent (update success)"); + + if let Err(e) = self.nats_publisher.publish(&subject, &msg).await { + warn!("Failed to publish installed-agent message: {}", e); + } + } + + fn progress_subject(&self) -> String { + format!("machine.{}.client-update-progress", self.machine_id) + } + + fn installed_agent_subject(&self) -> String { + format!("machine.{}.installed-agent", self.machine_id) + } +} diff --git a/clients/openframe-client-updater/src/services/updater_orchestrator.rs b/clients/openframe-client-updater/src/services/updater_orchestrator.rs new file mode 100644 index 000000000..1e71bb2e4 --- /dev/null +++ b/clients/openframe-client-updater/src/services/updater_orchestrator.rs @@ -0,0 +1,423 @@ +use anyhow::{Context, Result}; +use std::path::{Path, PathBuf}; +use std::time::Duration; +use tracing::{error, info, warn}; + +use crate::clients::AuthClient; +use crate::config::updater_config::{ + BOOT_MARKER_POLL_INTERVAL_SECS, BOOT_MARKER_WAIT_SECS, CLIENT_SERVICE_FULL_NAME, + DOWNLOAD_CLIENT_TIMEOUT_SECS, SERVICE_START_VERIFY_WAIT_SECS, +}; +use crate::listener::ClientUpdateListener; +use crate::models::UpdaterPhase; +use crate::platform::{atomic_replace, DirectoryManager}; +use crate::services::last_known_good_service::LastKnownGoodService; +use crate::services::token_provider::TokenProvider; +use crate::services::{ + AgentConfigurationService, ClientUpdateService, GithubDownloadService, + InitialConfigurationService, LocalTlsConfigProvider, NatsConnectionManager, + NatsMessagePublisher, ServiceManagerService, UpdateProgressPublisher, UpdaterStateService, +}; + +/// Outcome of crash recovery — recovery itself is purely local; the report is +/// published once NATS is up. +enum RecoveryReport { + Success { + version: String, + }, + Failure { + phase: UpdaterPhase, + version: String, + reason: String, + rolled_back: bool, + }, +} + +pub struct UpdaterOrchestrator { + dir_manager: DirectoryManager, +} + +impl UpdaterOrchestrator { + pub fn new(dir_manager: DirectoryManager) -> Self { + Self { dir_manager } + } + + pub async fn start(&self) -> Result<()> { + let initial_config_service = InitialConfigurationService::new(&self.dir_manager) + .context("Failed to init initial configuration service")?; + + let agent_config_service = AgentConfigurationService::new(&self.dir_manager) + .context("Failed to init agent configuration service")?; + + // Before the token wait and NATS connect: a machine that crashed + // mid-swap while offline must get its client binary back without the + // network. + let state_service = UpdaterStateService::new(&self.dir_manager); + let lkg_service = LastKnownGoodService::new( + &self.dir_manager, + ServiceManagerService::client_binary_path(), + ); + state_service.cleanup_legacy_state(); + let recovery_report = self + .recover_from_crash(&state_service, &lkg_service) + .await?; + + let server_host = initial_config_service + .get_server_url() + .context("Failed to read server_host from initial_config.json")?; + + let ws_url = format!("wss://{}", server_host); + + let http_client = reqwest::Client::builder() + .timeout(Duration::from_secs(120)) + .danger_accept_invalid_certs(initial_config_service.is_local_mode()?) + .no_proxy() + .pool_max_idle_per_host(0) + .build() + .context("Failed to build HTTP client")?; + + // The general-purpose client's total-request timeout would cap a slow + // download below DOWNLOAD_CLIENT_TIMEOUT_SECS. + let download_client = reqwest::Client::builder() + .timeout(Duration::from_secs(DOWNLOAD_CLIENT_TIMEOUT_SECS)) + .danger_accept_invalid_certs(initial_config_service.is_local_mode()?) + .no_proxy() + .pool_max_idle_per_host(0) + .build() + .context("Failed to build download HTTP client")?; + + // Token supply: primary is shared_token.enc written by openframe-client; + // if the client stops refreshing it, the provider authenticates on its own + // via client_credentials from agent_config.json (held in memory only). + let token_file_path = self.dir_manager.secured_dir().join("shared_token.enc"); + info!( + "Starting token provider (file: {})", + token_file_path.display() + ); + let auth_client = AuthClient::new(format!("https://{}", server_host), http_client.clone()); + let token = + TokenProvider::start(token_file_path, auth_client, agent_config_service.clone()); + + // Wait for the initial token before connecting to NATS. The provider + // self-authenticates when the shared file is missing or stale, so this + // resolves even if openframe-client is down. + info!("Waiting for an access token to become available"); + loop { + if token.read().await.is_some() { + info!("Access token available"); + break; + } + warn!("No access token yet — retrying in 10 seconds"); + tokio::time::sleep(Duration::from_secs(10)).await; + } + + let tls_config_provider = LocalTlsConfigProvider::new(initial_config_service.clone()); + + let nats_manager = NatsConnectionManager::new( + ws_url, + agent_config_service.clone(), + initial_config_service.clone(), + token, + tls_config_provider, + ); + + nats_manager + .connect() + .await + .context("Failed to connect to NATS")?; + info!("NATS connected"); + + // The updater only writes its own .log file; openframe-client tails it and ships + // it to NATS (LogSourceKind::Updater), the same way it handles the MeshCentral + // agent log. The updater does not run its own log-streaming pipeline. + + let nats_publisher = NatsMessagePublisher::new(nats_manager.clone()); + + let machine_id = agent_config_service + .get_machine_id() + .await + .context("Failed to read machine_id")?; + + let progress_publisher = UpdateProgressPublisher::new(nats_publisher, machine_id.clone()); + + match recovery_report { + Some(RecoveryReport::Success { version }) => { + progress_publisher.publish_success(&version).await; + } + Some(RecoveryReport::Failure { + phase, + version, + reason, + rolled_back, + }) => { + progress_publisher + .publish_failure(&phase, &version, &reason, rolled_back) + .await; + } + None => {} + } + + progress_publisher.publish_updater_version().await; + + let download_service = GithubDownloadService::new(download_client); + + let update_service = ClientUpdateService::new( + download_service, + state_service, + progress_publisher, + lkg_service, + ); + + let listener = + ClientUpdateListener::new(nats_manager, update_service, agent_config_service); + + info!("Updater ready — listening for update commands"); + let handle = listener.start().await; + handle.await.ok(); + + Ok(()) + } + + async fn recover_from_crash( + &self, + state_service: &UpdaterStateService, + lkg_service: &LastKnownGoodService, + ) -> Result> { + let state = match state_service.load() { + Ok(None) => return Ok(None), + Ok(Some(s)) => s, + Err(e) => { + // An unreadable state file must not crash-loop the service via KeepAlive — + // drop it and start clean; the NATS message redelivers any lost update. + warn!( + "Updater state file unreadable — removing it and skipping crash recovery: {:#}", + e + ); + if let Err(remove_err) = std::fs::remove_file(state_service.state_file_path()) { + warn!("Failed to remove unreadable state file: {}", remove_err); + } + return Ok(None); + } + }; + + info!( + phase = %state.phase, + version = %state.target_version, + "Crash recovery: found pending state" + ); + + let version = &state.target_version; + let target = ServiceManagerService::client_binary_path(); + + let report = match state.phase { + UpdaterPhase::Downloading | UpdaterPhase::Verifying | UpdaterPhase::Idle => { + if let Some(path) = &state.downloaded_binary_path { + let p = PathBuf::from(path); + if p.exists() { + if let Err(e) = std::fs::remove_file(&p) { + warn!("Failed to remove temp binary during recovery: {}", e); + } + } + } + let report = RecoveryReport::Failure { + phase: UpdaterPhase::Failed, + version: version.clone(), + reason: "Updater crashed before stopping service — no changes made".to_string(), + rolled_back: false, + }; + state_service.clear()?; + Some(report) + } + + UpdaterPhase::StoppingService | UpdaterPhase::ReplacingBinary => { + let report = self.restore_and_start( + &state.backup_path, + &target, + version, + lkg_service, + false, + ); + state_service.clear()?; + Some(report) + } + + // The boot marker was cleared before StartingService, so in these + // phases the marker is the source of truth: only a marker carrying + // the target version proves the new binary booted. The marker alone + // proves a *past* boot though — promotion additionally requires the + // service to be running now (started here if needed). + UpdaterPhase::StartingService + | UpdaterPhase::VerifyingBoot + | UpdaterPhase::Observing => { + if !matches!( + ServiceManagerService::is_running(CLIENT_SERVICE_FULL_NAME), + Ok(true) + ) { + info!("Crash recovery: service not running — attempting start"); + if let Err(e) = ServiceManagerService::start(CLIENT_SERVICE_FULL_NAME) { + warn!("Crash recovery: start failed: {:#}", e); + } + tokio::time::sleep(Duration::from_secs(SERVICE_START_VERIFY_WAIT_SECS)).await; + } + + let mut verified = + lkg_service.boot_marker_version().as_deref() == Some(version.as_str()); + if !verified { + // Give the (possibly just-started) binary one marker window + // before rolling back. + let mut elapsed = 0u64; + while elapsed < BOOT_MARKER_WAIT_SECS { + if lkg_service.boot_marker_version().as_deref() == Some(version.as_str()) { + verified = true; + break; + } + tokio::time::sleep(Duration::from_secs(BOOT_MARKER_POLL_INTERVAL_SECS)) + .await; + elapsed += BOOT_MARKER_POLL_INTERVAL_SECS; + } + } + + let running = matches!( + ServiceManagerService::is_running(CLIENT_SERVICE_FULL_NAME), + Ok(true) + ); + + let report = if verified && running { + info!("Crash recovery: boot marker matches target and service is running — marking success"); + if let Err(e) = lkg_service.promote(version) { + warn!( + "Crash recovery: failed to raise last-known-good anchor to {}: {:#}", + version, e + ); + } + RecoveryReport::Success { + version: version.clone(), + } + } else { + warn!( + "Crash recovery: not healthy (marker verified: {}, running: {}) — rolling back", + verified, running + ); + self.restore_and_start(&state.backup_path, &target, version, lkg_service, true) + }; + state_service.clear()?; + Some(report) + } + + UpdaterPhase::Completed + | UpdaterPhase::Failed + | UpdaterPhase::RollingBack + | UpdaterPhase::RolledBack => { + info!("Crash recovery: clearing terminal state ({})", state.phase); + state_service.clear()?; + None + } + }; + + Ok(report) + } + + fn restore_and_start( + &self, + backup_path: &Option, + target: &Path, + version: &str, + lkg_service: &LastKnownGoodService, + prefer_reserve: bool, + ) -> RecoveryReport { + let backup = backup_path + .as_ref() + .map(PathBuf::from) + .filter(|p| p.exists()); + let reserve = lkg_service.reserve_path().to_path_buf(); + let reserve = reserve.exists().then_some(reserve); + + // Restore sources in preference order. The reserve is copied (it must + // survive), the backup is renamed (consumed). After a failed boot the + // reserve is the stronger choice — it verified; the backup merely ran. + // Before the swap the backup is authoritative, and the reserve is only + // a last resort when the client binary itself is gone. + let mut candidates: Vec<(PathBuf, bool)> = Vec::new(); + if prefer_reserve { + if let Some(r) = &reserve { + candidates.push((r.clone(), true)); + } + if let Some(b) = &backup { + candidates.push((b.clone(), false)); + } + } else { + if let Some(b) = &backup { + candidates.push((b.clone(), false)); + } + if !target.exists() { + if let Some(r) = &reserve { + candidates.push((r.clone(), true)); + } + } + } + + if candidates.is_empty() { + if target.exists() { + info!("Crash recovery: client binary intact — ensuring service is running"); + if let Err(e) = ServiceManagerService::start(CLIENT_SERVICE_FULL_NAME) { + error!("Crash recovery: failed to start service: {}", e); + } + return RecoveryReport::Failure { + phase: UpdaterPhase::Failed, + version: version.to_string(), + reason: "Updater crashed mid-update; client binary untouched".to_string(), + rolled_back: false, + }; + } + return RecoveryReport::Failure { + phase: UpdaterPhase::Failed, + version: version.to_string(), + reason: "Updater crashed mid-update, no backup or reserve available".to_string(), + rolled_back: false, + }; + } + + let mut restored = false; + for (source, is_reserve) in &candidates { + let result = if *is_reserve { + atomic_replace::restore_copy(source, target) + } else { + atomic_replace::restore(source, target) + }; + match result { + Ok(()) => { + info!( + "Crash recovery: restored client binary from {}", + source.display() + ); + restored = true; + break; + } + Err(e) => error!( + "Crash recovery: restore from {} failed: {:#}", + source.display(), + e + ), + } + } + + if !restored { + return RecoveryReport::Failure { + phase: UpdaterPhase::Failed, + version: version.to_string(), + reason: "Updater crashed mid-update and all restore attempts failed".to_string(), + rolled_back: false, + }; + } + + if let Err(e) = ServiceManagerService::start(CLIENT_SERVICE_FULL_NAME) { + error!("Crash recovery: failed to start restored service: {}", e); + } + RecoveryReport::Failure { + phase: UpdaterPhase::RolledBack, + version: version.to_string(), + reason: "Updater crashed mid-update, previous binary restored".to_string(), + rolled_back: true, + } + } +} diff --git a/clients/openframe-client-updater/src/services/updater_state_service.rs b/clients/openframe-client-updater/src/services/updater_state_service.rs new file mode 100644 index 000000000..9aa787d42 --- /dev/null +++ b/clients/openframe-client-updater/src/services/updater_state_service.rs @@ -0,0 +1,103 @@ +use anyhow::{Context, Result}; +use std::fs; +use std::path::PathBuf; +use tracing::{info, warn}; + +use crate::models::{UpdaterPhase, UpdaterState}; +use crate::platform::DirectoryManager; + +#[derive(Clone)] +pub struct UpdaterStateService { + state_file_path: PathBuf, +} + +impl UpdaterStateService { + pub fn new(directory_manager: &DirectoryManager) -> Self { + Self { + state_file_path: directory_manager.secured_dir().join("updater_state.json"), + } + } + + pub fn load(&self) -> Result> { + if !self.state_file_path.exists() { + return Ok(None); + } + + let json = fs::read_to_string(&self.state_file_path) + .with_context(|| format!("Failed to read {}", self.state_file_path.display()))?; + + let state: UpdaterState = serde_json::from_str(&json) + .with_context(|| format!("Failed to deserialize {}", self.state_file_path.display()))?; + + info!( + phase = %state.phase, + version = %state.target_version, + "Loaded updater state from disk" + ); + + Ok(Some(state)) + } + + pub fn save(&self, state: &UpdaterState) -> Result<()> { + if let Some(parent) = self.state_file_path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("Failed to create dir {}", parent.display()))?; + } + + let json = + serde_json::to_string_pretty(state).context("Failed to serialize updater state")?; + + let temp_path = self.state_file_path.with_extension("json.tmp"); + fs::write(&temp_path, json) + .with_context(|| format!("Failed to write {}", temp_path.display()))?; + fs::rename(&temp_path, &self.state_file_path).with_context(|| { + format!( + "Failed to move state file into place: {}", + self.state_file_path.display() + ) + })?; + + info!(phase = %state.phase, version = %state.target_version, "Saved updater state"); + Ok(()) + } + + pub fn clear(&self) -> Result<()> { + if self.state_file_path.exists() { + fs::remove_file(&self.state_file_path) + .with_context(|| format!("Failed to remove {}", self.state_file_path.display()))?; + info!("Cleared updater state file"); + } + Ok(()) + } + + // Removes update_state.json left by the old client update flow after Phase 7 migration. + pub fn cleanup_legacy_state(&self) { + let legacy_path = self + .state_file_path + .parent() + .map(|p| p.join("update_state.json")); + + if let Some(path) = legacy_path { + if path.exists() { + if let Err(e) = fs::remove_file(&path) { + warn!("Failed to remove legacy update_state.json: {}", e); + } else { + info!("Removed legacy update_state.json"); + } + } + } + } + + pub fn state_file_path(&self) -> &PathBuf { + &self.state_file_path + } + + pub fn transition(&self, state: &mut UpdaterState, phase: UpdaterPhase) -> Result<()> { + state.phase = phase; + self.save(state) + } +} + +#[cfg(test)] +#[path = "updater_state_service_tests.rs"] +mod tests; diff --git a/clients/openframe-client-updater/src/services/updater_state_service_tests.rs b/clients/openframe-client-updater/src/services/updater_state_service_tests.rs new file mode 100644 index 000000000..ea521c27e --- /dev/null +++ b/clients/openframe-client-updater/src/services/updater_state_service_tests.rs @@ -0,0 +1,98 @@ +use super::*; + +fn service(root: &std::path::Path) -> UpdaterStateService { + let dm = DirectoryManager::with_custom_dirs( + root.join("logs"), + root.join("app"), + root.join("secured"), + ); + UpdaterStateService::new(&dm) +} + +#[test] +fn load_returns_none_when_no_state_file() { + let dir = tempfile::tempdir().unwrap(); + assert!(service(dir.path()).load().unwrap().is_none()); +} + +#[test] +fn save_then_load_round_trips() { + let dir = tempfile::tempdir().unwrap(); + let svc = service(dir.path()); + + let mut state = UpdaterState::new("1.2.3".to_string()); + state.phase = UpdaterPhase::ReplacingBinary; + state.backup_path = Some("/tmp/backup".to_string()); + svc.save(&state).unwrap(); + + let loaded = svc.load().unwrap().unwrap(); + assert_eq!(loaded.target_version, "1.2.3"); + assert_eq!(loaded.phase, UpdaterPhase::ReplacingBinary); + assert_eq!(loaded.backup_path.as_deref(), Some("/tmp/backup")); +} + +// Crash-safety: the state is written to a temp file and renamed into place, +// and the temp file must not linger. +#[test] +fn save_leaves_no_temp_file() { + let dir = tempfile::tempdir().unwrap(); + let svc = service(dir.path()); + svc.save(&UpdaterState::new("1.0.0".to_string())).unwrap(); + + let secured = dir.path().join("secured"); + let names: Vec = std::fs::read_dir(&secured) + .unwrap() + .map(|e| e.unwrap().file_name().to_string_lossy().into_owned()) + .collect(); + assert_eq!(names, vec!["updater_state.json".to_string()]); +} + +#[test] +fn load_fails_on_corrupt_state() { + let dir = tempfile::tempdir().unwrap(); + let svc = service(dir.path()); + std::fs::create_dir_all(dir.path().join("secured")).unwrap(); + std::fs::write(svc.state_file_path(), b"{not json").unwrap(); + assert!(svc.load().is_err()); +} + +#[test] +fn clear_removes_the_file_and_tolerates_absence() { + let dir = tempfile::tempdir().unwrap(); + let svc = service(dir.path()); + + svc.clear().unwrap(); + + svc.save(&UpdaterState::new("1.0.0".to_string())).unwrap(); + svc.clear().unwrap(); + assert!(svc.load().unwrap().is_none()); +} + +#[test] +fn transition_updates_phase_and_persists() { + let dir = tempfile::tempdir().unwrap(); + let svc = service(dir.path()); + + let mut state = UpdaterState::new("1.0.0".to_string()); + svc.transition(&mut state, UpdaterPhase::Downloading) + .unwrap(); + + assert_eq!(state.phase, UpdaterPhase::Downloading); + assert_eq!( + svc.load().unwrap().unwrap().phase, + UpdaterPhase::Downloading + ); +} + +#[test] +fn cleanup_legacy_state_removes_old_client_file() { + let dir = tempfile::tempdir().unwrap(); + let svc = service(dir.path()); + let secured = dir.path().join("secured"); + std::fs::create_dir_all(&secured).unwrap(); + let legacy = secured.join("update_state.json"); + std::fs::write(&legacy, b"{}").unwrap(); + + svc.cleanup_legacy_state(); + assert!(!legacy.exists()); +} diff --git a/clients/openframe-client-updater/src/utils/jwt.rs b/clients/openframe-client-updater/src/utils/jwt.rs new file mode 100644 index 000000000..4cf4b4451 --- /dev/null +++ b/clients/openframe-client-updater/src/utils/jwt.rs @@ -0,0 +1,26 @@ +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use serde::Deserialize; + +#[derive(Deserialize)] +struct ExpClaim { + exp: i64, +} + +/// Decode a JWT's `exp` claim (seconds since the Unix epoch) without verifying the signature. +/// Returns `None` if the token is malformed or carries no `exp`. +pub fn token_exp_unix(token: &str) -> Option { + // Require a well-formed `header.payload.signature` — reject tokens with missing or extra parts. + let mut parts = token.split('.'); + let (Some(_header), Some(payload), Some(_signature), None) = + (parts.next(), parts.next(), parts.next(), parts.next()) + else { + return None; + }; + let bytes = URL_SAFE_NO_PAD.decode(payload.trim_end_matches('=')).ok()?; + let claim: ExpClaim = serde_json::from_slice(&bytes).ok()?; + Some(claim.exp) +} + +#[cfg(test)] +#[path = "jwt_tests.rs"] +mod tests; diff --git a/clients/openframe-client-updater/src/utils/jwt_tests.rs b/clients/openframe-client-updater/src/utils/jwt_tests.rs new file mode 100644 index 000000000..621c8e392 --- /dev/null +++ b/clients/openframe-client-updater/src/utils/jwt_tests.rs @@ -0,0 +1,30 @@ +use super::*; + +fn make_token(payload: &str) -> String { + format!("header.{}.sig", URL_SAFE_NO_PAD.encode(payload.as_bytes())) +} + +#[test] +fn decodes_exp() { + let token = make_token(r#"{"exp":1700000000,"sub":"machine"}"#); + assert_eq!(token_exp_unix(&token), Some(1700000000)); +} + +#[test] +fn none_without_exp_claim() { + let token = make_token(r#"{"sub":"machine"}"#); + assert_eq!(token_exp_unix(&token), None); +} + +#[test] +fn none_when_malformed() { + assert_eq!(token_exp_unix("not-a-jwt"), None); + assert_eq!(token_exp_unix(""), None); +} + +#[test] +fn none_when_wrong_segment_count() { + let payload = URL_SAFE_NO_PAD.encode(r#"{"exp":1700000000}"#.as_bytes()); + assert_eq!(token_exp_unix(&format!("header.{payload}")), None); + assert_eq!(token_exp_unix(&format!("header.{payload}.sig.extra")), None); +} diff --git a/clients/openframe-client-updater/src/utils/mod.rs b/clients/openframe-client-updater/src/utils/mod.rs new file mode 100644 index 000000000..417233c08 --- /dev/null +++ b/clients/openframe-client-updater/src/utils/mod.rs @@ -0,0 +1 @@ +pub mod jwt; diff --git a/clients/openframe-client/Cargo.lock b/clients/openframe-client/Cargo.lock index c78a1dee5..05fee0c3e 100644 --- a/clients/openframe-client/Cargo.lock +++ b/clients/openframe-client/Cargo.lock @@ -135,15 +135,6 @@ version = "1.0.100" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" -[[package]] -name = "arbitrary" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" -dependencies = [ - "derive_arbitrary", -] - [[package]] name = "arraydeque" version = "0.5.1" @@ -165,7 +156,7 @@ dependencies = [ "once_cell", "pin-project", "portable-atomic", - "rand 0.8.5", + "rand", "regex", "ring", "rustls-native-certs", @@ -175,7 +166,7 @@ dependencies = [ "serde_json", "serde_nanos", "serde_repr", - "thiserror 1.0.69", + "thiserror", "time", "tokio", "tokio-rustls", @@ -194,7 +185,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn", ] [[package]] @@ -321,7 +312,7 @@ dependencies = [ "num-traits", "serde", "wasm-bindgen", - "windows-link 0.2.1", + "windows-link", ] [[package]] @@ -365,7 +356,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.106", + "syn", ] [[package]] @@ -530,7 +521,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" dependencies = [ "generic-array", - "rand_core 0.6.4", + "rand_core", "typenum", ] @@ -566,7 +557,7 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn", ] [[package]] @@ -596,28 +587,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "derivative" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fcc3dd5e9e9c0b295d6e1e4d811fb6f157d5ffd784b8d202fc62eac8035a770b" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] - -[[package]] -name = "derive_arbitrary" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.106", -] - [[package]] name = "digest" version = "0.10.7" @@ -678,7 +647,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn", ] [[package]] @@ -894,7 +863,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn", ] [[package]] @@ -970,12 +939,6 @@ dependencies = [ "polyval", ] -[[package]] -name = "glob" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" - [[package]] name = "h2" version = "0.3.27" @@ -1052,7 +1015,7 @@ checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd" dependencies = [ "cfg-if", "libc", - "windows-link 0.2.1", + "windows-link", ] [[package]] @@ -1489,7 +1452,7 @@ dependencies = [ "ed25519-dalek", "getrandom 0.2.16", "log", - "rand 0.8.5", + "rand", "signatory", ] @@ -1503,15 +1466,6 @@ dependencies = [ "minimal-lexical", ] -[[package]] -name = "normpath" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf23ab2b905654b4cb177e30b629937b3868311d4e1cba859f899c041046e69b" -dependencies = [ - "windows-sys 0.61.2", -] - [[package]] name = "ntapi" version = "0.4.1" @@ -1536,7 +1490,7 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc895af95856f929163a0aa20c26a78d26bfdc839f51b9d5aa7a5b79e52b7e83" dependencies = [ - "rand 0.8.5", + "rand", ] [[package]] @@ -1608,20 +1562,19 @@ dependencies = [ "sysinfo", "tar", "tempfile", - "thiserror 1.0.69", + "thiserror", "tokio", "toml", "tracing", "tracing-appender", "tracing-subscriber", "uuid", - "velopack", "whoami", "winapi", - "windows 0.52.0", + "windows", "windows-service", "winreg 0.52.0", - "zip 0.6.6", + "zip", ] [[package]] @@ -1647,7 +1600,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn", ] [[package]] @@ -1704,7 +1657,7 @@ dependencies = [ "libc", "redox_syscall", "smallvec", - "windows-link 0.2.1", + "windows-link", ] [[package]] @@ -1714,7 +1667,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7676374caaee8a325c9e7a2ae557f216c5563a171d6997b0ef8a65af35147700" dependencies = [ "base64ct", - "rand_core 0.6.4", + "rand_core", "subtle", ] @@ -1781,7 +1734,7 @@ dependencies = [ "pest_meta", "proc-macro2", "quote", - "syn 2.0.106", + "syn", ] [[package]] @@ -1811,7 +1764,7 @@ checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn", ] [[package]] @@ -1937,18 +1890,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ "libc", - "rand_chacha 0.3.1", - "rand_core 0.6.4", -] - -[[package]] -name = "rand" -version = "0.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" -dependencies = [ - "rand_chacha 0.9.0", - "rand_core 0.9.3", + "rand_chacha", + "rand_core", ] [[package]] @@ -1958,17 +1901,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core 0.6.4", -] - -[[package]] -name = "rand_chacha" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" -dependencies = [ - "ppv-lite86", - "rand_core 0.9.3", + "rand_core", ] [[package]] @@ -1980,15 +1913,6 @@ dependencies = [ "getrandom 0.2.16", ] -[[package]] -name = "rand_core" -version = "0.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" -dependencies = [ - "getrandom 0.3.3", -] - [[package]] name = "rayon" version = "1.11.0" @@ -2035,7 +1959,7 @@ checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" dependencies = [ "getrandom 0.2.16", "libredox", - "thiserror 1.0.69", + "thiserror", ] [[package]] @@ -2184,7 +2108,6 @@ version = "0.23.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cd3c25631629d034ce7cd9940adc9d45762d46de2b0f57193c4443b92c6d4d40" dependencies = [ - "log", "once_cell", "ring", "rustls-pki-types", @@ -2337,7 +2260,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn", ] [[package]] @@ -2370,7 +2293,7 @@ checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn", ] [[package]] @@ -2463,7 +2386,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c1e303f8205714074f6068773f0e29527e0453937fe837c9717d066635b65f31" dependencies = [ "pkcs8", - "rand_core 0.6.4", + "rand_core", "signature", "zeroize", ] @@ -2475,7 +2398,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ "digest", - "rand_core 0.6.4", + "rand_core", ] [[package]] @@ -2544,17 +2467,6 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" -[[package]] -name = "syn" -version = "1.0.109" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - [[package]] name = "syn" version = "2.0.106" @@ -2580,7 +2492,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn", ] [[package]] @@ -2605,7 +2517,7 @@ dependencies = [ "ntapi", "once_cell", "rayon", - "windows 0.52.0", + "windows", ] [[package]] @@ -2659,16 +2571,7 @@ 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.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" -dependencies = [ - "thiserror-impl 2.0.17", + "thiserror-impl", ] [[package]] @@ -2679,18 +2582,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", -] - -[[package]] -name = "thiserror-impl" -version = "2.0.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.106", + "syn", ] [[package]] @@ -2777,7 +2669,7 @@ checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn", ] [[package]] @@ -2825,7 +2717,7 @@ dependencies = [ "futures-sink", "http 1.3.1", "httparse", - "rand 0.8.5", + "rand", "ring", "rustls-pki-types", "tokio", @@ -2899,7 +2791,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3566e8ce28cc0a3fe42519fc80e6b4c943cc4c8cef275620eb8dac2d3d4e06cf" dependencies = [ "crossbeam-channel", - "thiserror 1.0.69", + "thiserror", "time", "tracing-subscriber", ] @@ -2912,7 +2804,7 @@ checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn", ] [[package]] @@ -3023,36 +2915,6 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" -[[package]] -name = "ureq" -version = "3.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "99ba1025f18a4a3fc3e9b48c868e9beb4f24f4b4b1a325bada26bd4119f46537" -dependencies = [ - "base64 0.22.1", - "flate2", - "log", - "percent-encoding", - "rustls", - "rustls-pemfile 2.2.0", - "rustls-pki-types", - "ureq-proto", - "utf-8", - "webpki-roots 1.0.3", -] - -[[package]] -name = "ureq-proto" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60b4531c118335662134346048ddb0e54cc86bd7e81866757873055f0e38f5d2" -dependencies = [ - "base64 0.22.1", - "http 1.3.1", - "httparse", - "log", -] - [[package]] name = "url" version = "2.5.7" @@ -3065,12 +2927,6 @@ dependencies = [ "serde", ] -[[package]] -name = "utf-8" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" - [[package]] name = "utf8_iter" version = "1.0.4" @@ -3091,23 +2947,10 @@ checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2" dependencies = [ "getrandom 0.3.3", "js-sys", - "rand 0.9.2", "serde", - "uuid-macro-internal", "wasm-bindgen", ] -[[package]] -name = "uuid-macro-internal" -version = "1.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9384a660318abfbd7f8932c34d67e4d1ec511095f95972ddc01e19d7ba8413f" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.106", -] - [[package]] name = "valuable" version = "0.1.1" @@ -3120,36 +2963,6 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" -[[package]] -name = "velopack" -version = "0.0.1213-g57cf68d" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "282f5093f5299559f6aa92813b34ed19812582d1eea13c592e34ef9f834798d6" -dependencies = [ - "bitflags 2.9.4", - "derivative", - "glob", - "lazy_static", - "libc", - "log", - "normpath", - "rand 0.9.2", - "regex", - "semver", - "serde", - "serde_json", - "sha1", - "sha2", - "thiserror 2.0.17", - "ureq", - "url", - "uuid", - "windows 0.60.0", - "xml", - "zip 2.4.2", - "zstd 0.13.3", -] - [[package]] name = "version_check" version = "0.9.5" @@ -3238,7 +3051,7 @@ dependencies = [ "log", "proc-macro2", "quote", - "syn 2.0.106", + "syn", "wasm-bindgen-shared", ] @@ -3273,7 +3086,7 @@ checksum = "9f07d2f20d4da7b26400c9f4a0511e6e0345b040694e8a75bd41d578fa4421d7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn", "wasm-bindgen-backend", "wasm-bindgen-shared", ] @@ -3376,28 +3189,6 @@ dependencies = [ "windows-targets 0.52.6", ] -[[package]] -name = "windows" -version = "0.60.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddf874e74c7a99773e62b1c671427abf01a425e77c3d3fb9fb1e4883ea934529" -dependencies = [ - "windows-collections", - "windows-core 0.60.1", - "windows-future", - "windows-link 0.1.3", - "windows-numerics", -] - -[[package]] -name = "windows-collections" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5467f79cc1ba3f52ebb2ed41dbb459b8e7db636cc3429458d9a852e15bc24dec" -dependencies = [ - "windows-core 0.60.1", -] - [[package]] name = "windows-core" version = "0.52.0" @@ -3407,51 +3198,17 @@ dependencies = [ "windows-targets 0.52.6", ] -[[package]] -name = "windows-core" -version = "0.60.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca21a92a9cae9bf4ccae5cf8368dce0837100ddf6e6d57936749e85f152f6247" -dependencies = [ - "windows-implement 0.59.0", - "windows-interface", - "windows-link 0.1.3", - "windows-result 0.3.4", - "windows-strings 0.3.1", -] - [[package]] name = "windows-core" version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ - "windows-implement 0.60.2", + "windows-implement", "windows-interface", - "windows-link 0.2.1", - "windows-result 0.4.1", - "windows-strings 0.5.1", -] - -[[package]] -name = "windows-future" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a787db4595e7eb80239b74ce8babfb1363d8e343ab072f2ffe901400c03349f0" -dependencies = [ - "windows-core 0.60.1", - "windows-link 0.1.3", -] - -[[package]] -name = "windows-implement" -version = "0.59.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83577b051e2f49a058c308f17f273b570a6a758386fc291b5f6a934dd84e48c1" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.106", + "windows-link", + "windows-result", + "windows-strings", ] [[package]] @@ -3462,7 +3219,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn", ] [[package]] @@ -3473,47 +3230,22 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn", ] -[[package]] -name = "windows-link" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" - [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" -[[package]] -name = "windows-numerics" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "005dea54e2f6499f2cee279b8f703b3cf3b5734a2d8d21867c8f44003182eeed" -dependencies = [ - "windows-core 0.60.1", - "windows-link 0.1.3", -] - -[[package]] -name = "windows-result" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" -dependencies = [ - "windows-link 0.1.3", -] - [[package]] name = "windows-result" version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" dependencies = [ - "windows-link 0.2.1", + "windows-link", ] [[package]] @@ -3527,22 +3259,13 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "windows-strings" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87fa48cc5d406560701792be122a10132491cff9d0aeb23583cc2dcafc847319" -dependencies = [ - "windows-link 0.1.3", -] - [[package]] name = "windows-strings" version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" dependencies = [ - "windows-link 0.2.1", + "windows-link", ] [[package]] @@ -3587,7 +3310,7 @@ version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ - "windows-link 0.2.1", + "windows-link", ] [[package]] @@ -3627,7 +3350,7 @@ version = "0.53.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" dependencies = [ - "windows-link 0.2.1", + "windows-link", "windows_aarch64_gnullvm 0.53.1", "windows_aarch64_msvc 0.53.1", "windows_i686_gnu 0.53.1", @@ -3827,15 +3550,6 @@ dependencies = [ "rustix 1.1.2", ] -[[package]] -name = "xml" -version = "0.8.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ede1c99c55b4b3ad0349018ef0eccbe954ce9c342334410707ee87177fcf2ab4" -dependencies = [ - "xml-rs", -] - [[package]] name = "xml-rs" version = "0.8.27" @@ -3873,7 +3587,7 @@ checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn", "synstructure", ] @@ -3894,7 +3608,7 @@ checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn", ] [[package]] @@ -3914,7 +3628,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn", "synstructure", ] @@ -3954,7 +3668,7 @@ checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.106", + "syn", ] [[package]] @@ -3974,36 +3688,7 @@ dependencies = [ "pbkdf2", "sha1", "time", - "zstd 0.11.2+zstd.1.5.2", -] - -[[package]] -name = "zip" -version = "2.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" -dependencies = [ - "arbitrary", - "crc32fast", - "crossbeam-utils", - "displaydoc", - "flate2", - "indexmap", - "memchr", - "thiserror 2.0.17", - "zopfli", -] - -[[package]] -name = "zopfli" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edfc5ee405f504cd4984ecc6f14d02d55cfda60fa4b689434ef4102aae150cd7" -dependencies = [ - "bumpalo", - "crc32fast", - "log", - "simd-adler32", + "zstd", ] [[package]] @@ -4012,16 +3697,7 @@ version = "0.11.2+zstd.1.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20cc960326ece64f010d2d2107537f26dc589a6573a316bd5b1dba685fa5fde4" dependencies = [ - "zstd-safe 5.0.2+zstd.1.5.2", -] - -[[package]] -name = "zstd" -version = "0.13.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" -dependencies = [ - "zstd-safe 7.2.4", + "zstd-safe", ] [[package]] @@ -4034,15 +3710,6 @@ dependencies = [ "zstd-sys", ] -[[package]] -name = "zstd-safe" -version = "7.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" -dependencies = [ - "zstd-sys", -] - [[package]] name = "zstd-sys" version = "2.0.16+zstd.1.5.7" diff --git a/clients/openframe-client/Cargo.toml b/clients/openframe-client/Cargo.toml index 465949b60..7750b22a9 100644 --- a/clients/openframe-client/Cargo.toml +++ b/clients/openframe-client/Cargo.toml @@ -59,6 +59,7 @@ bin = [] openframe-chat-version = [] meshcentral-agent-version = [] fleetmdm-agent-version = [] +openframe-client-updater-version = [] # Asset-level version overrides. Keyed by Asset.id inside a ToolInstallationMessage. # These binaries ship as assets under another tool and can be pinned independently. @@ -69,6 +70,7 @@ all-tool-versions = [ "openframe-chat-version", "meshcentral-agent-version", "fleetmdm-agent-version", + "openframe-client-updater-version", "osquery-version", ] @@ -100,7 +102,6 @@ zip = "0.6" tempfile = "3.10" semver = "1.0" sys-info = "0.9" -velopack = "0.0.1213-g57cf68d" aes-gcm = "0.10" base64 = "0.21" log = "0.4.27" @@ -150,20 +151,3 @@ OpenFrame is a powerful system management and monitoring solution that provides: - Integration with various management platforms """ -[package.metadata.velopack] -version = "0.1.0" -product-name = "OpenFrame" -executable = "openframe-client" -authors = ["Flamingo Team"] -first-run = true -license = "MIT" -support-url = "https://github.com/flamingo-stack/openframe-oss-tenant" -icon = "icons/icon.ico" -splash-image = "icons/splash.png" -release-notes = "Initial release of OpenFrame" -setup-icon = "icons/setup.ico" -company-name = "Flamingo" -copyright = "Copyright © 2024 Flamingo Team" -suggest-reboot = false -compression-level = "normal" -delta = true diff --git a/clients/openframe-client/build.rs b/clients/openframe-client/build.rs index 8f6e057d1..9c6255e54 100644 --- a/clients/openframe-client/build.rs +++ b/clients/openframe-client/build.rs @@ -9,6 +9,8 @@ fn main() { forward_required("FLEETMDM_AGENT_VERSION"); #[cfg(feature = "osquery-version")] forward_required("OSQUERY_VERSION"); + #[cfg(feature = "openframe-client-updater-version")] + forward_required("OPENFRAME_CLIENT_UPDATER_VERSION"); } #[allow(dead_code)] diff --git a/clients/openframe-client/src/.updater.md b/clients/openframe-client/src/.updater.md deleted file mode 100644 index 5e2a41865..000000000 --- a/clients/openframe-client/src/.updater.md +++ /dev/null @@ -1,37 +0,0 @@ - -Manages automatic application updates using the Velopack framework, handling update checks, downloads, and restarts for versioned release channels. - -## Key Components - -### `VelopackUpdater` -Main updater struct that wraps a Velopack `UpdateManager`. Holds the current semver version, update channel name, a temporary directory for downloads, and an optional update URL. When no URL is provided, updates are silently disabled via a dummy `localhost` source. - -**Methods:** -- `new(current_version, update_channel, update_url)` — Initializes `VelopackApp`, creates a temp directory, and configures the `UpdateManager` against an `HttpSource`. -- `check_for_updates() -> Result>` — Polls the remote source; returns `Some(UpdateInfo)` if a new version is available. -- `download_and_apply_update(update_info) -> Result` — Re-checks, downloads, and immediately applies the update, triggering an application restart. -- `restart_to_apply_update() -> Result<()>` — Convenience method to apply a pending update and restart without a prior `check_for_updates` call. - -### `UpdateInfo` -Lightweight DTO carrying `version`, `download_url`, and `release_notes` strings returned by an update check. - -## Usage Example - -```rust -use semver::Version; -use updater::VelopackUpdater; - -let updater = VelopackUpdater::new( - Version::parse("1.2.0").unwrap(), - "stable".to_string(), - Some("https://releases.example.com/myapp".to_string()), -)?; - -if let Some(info) = updater.check_for_updates()? { - println!("Update available: {}", info.version); - updater.download_and_apply_update(&info)?; - // Application restarts automatically after this point -} -``` - -> **Note:** When `update_url` is `None`, all update methods return `None`/`Ok(false)` without making network requests, making the updater safe to instantiate in offline or dev environments. \ No newline at end of file diff --git a/clients/openframe-client/src/config/update_config.rs b/clients/openframe-client/src/config/update_config.rs index 6f3728f65..14affff2e 100644 --- a/clients/openframe-client/src/config/update_config.rs +++ b/clients/openframe-client/src/config/update_config.rs @@ -24,24 +24,9 @@ pub const FLUSH_PUBLISH_TIMEOUT_SECS: u64 = 30; pub const NATS_PAYLOAD_FALLBACK_BYTES: usize = 5 * 1024 * 1024; pub const NATS_PAYLOAD_HEADROOM_BYTES: usize = 64 * 1024; -// Last-known-good update ratchet -/// How long the updater waits for the new binary's boot marker. -pub const BOOT_MARKER_WAIT_SECS: u64 = 90; -/// Unverified boots tolerated before an update is treated as failed. -pub const CRASH_LOOP_MAX_BOOT_ATTEMPTS: u32 = 3; -/// Refuse update messages below the LKG anchor (flip to force a downgrade). -pub const ALLOW_DOWNGRADE: bool = false; -/// Updater transcripts kept after pruning; one is written per update attempt. -pub const UPDATER_TRANSCRIPTS_KEPT: usize = 5; -/// Minimum age before a temp update leftover is swept (a live updater's files are younger). -pub const TEMP_LEFTOVER_MIN_AGE_SECS: u64 = 3600; - // NATS message settings pub const CONSUMER_ACK_WAIT_SECS: u64 = 120; pub const CONSUMER_MAX_DELIVER: i64 = 10; // Maximum delivery attempts +pub const PROGRESS_ACK_INTERVAL_SECS: u64 = 60; // parked-message heartbeat, well inside ack_wait (120s) pub const UNINSTALL_CONSUMER_MAX_DELIVER: i64 = 20; // Larger budget: uninstall may defer behind a long install holding the tool lock pub const RESTART_CONSUMER_MAX_DELIVER: i64 = 20; // Larger budget: restart may defer behind a long install holding the tool lock - -// Client-before-tool update ordering -pub const CLIENT_UPDATE_PENDING_TTL_SECS: u64 = 300; // > ack_wait (120s) so the flag survives redelivery gaps of a deferred client update -pub const PROGRESS_ACK_INTERVAL_SECS: u64 = 60; // well inside ack_wait (120s) diff --git a/clients/openframe-client/src/lib.rs b/clients/openframe-client/src/lib.rs index db57acee7..1daa16abb 100644 --- a/clients/openframe-client/src/lib.rs +++ b/clients/openframe-client/src/lib.rs @@ -27,7 +27,6 @@ pub mod service; /// management details behind a common API. pub mod service_adapter; pub mod system; -pub mod updater; pub mod utils; pub mod cli; @@ -41,7 +40,6 @@ use crate::clients::{AuthClient, RegistrationClient, ToolApiClient}; use crate::config::update_config::{DOWNLOAD_CLIENT_TIMEOUT_SECS, HTTP_CLIENT_TIMEOUT_SECS}; use crate::listener::client_uninstall_message_listener::ClientUninstallMessageListener; use crate::listener::execution_listener::ExecutionListener; -use crate::listener::openframe_client_update_listener::OpenFrameClientUpdateListener; use crate::listener::tool_agent_update_listener::ToolAgentUpdateListener; use crate::listener::tool_installation_message_listener::ToolInstallationMessageListener; use crate::listener::tool_restart_message_listener::ToolRestartMessageListener; @@ -65,7 +63,6 @@ use crate::services::mesh_self_heal_service::MeshSelfHealService; use crate::services::nats_connection_manager::NatsConnectionManager; use crate::services::nats_message_publisher::NatsMessagePublisher; use crate::services::openframe_client_info_service::OpenFrameClientInfoService; -use crate::services::openframe_client_update_service::OpenFrameClientUpdateService; use crate::services::registration_processor::RegistrationProcessor; use crate::services::result_outbox_run_manager::ResultOutboxRunManager; use crate::services::result_store::ResultStore; @@ -83,10 +80,7 @@ use crate::services::{ ToolCommandParamsResolver, ToolConnectionProcessingManager, ToolKillService, ToolRunManager, ToolUrlParamsResolver, }; -use crate::services::{ - InitialKeyService, LastKnownGoodService, UpdateCleanupService, UpdateHandlerService, - UpdateStateService, -}; +use crate::services::{InitialKeyService, LastKnownGoodService}; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ServerConfig { @@ -157,7 +151,6 @@ pub struct Client { tool_uninstall_message_listener: ToolUninstallMessageListener, #[allow(dead_code)] // TODO: remove when tool-restart is implemented on backend tool_restart_message_listener: ToolRestartMessageListener, - openframe_client_update_listener: OpenFrameClientUpdateListener, tool_agent_update_listener: ToolAgentUpdateListener, client_uninstall_message_listener: ClientUninstallMessageListener, command_execution_listener: ExecutionListener, @@ -170,7 +163,6 @@ pub struct Client { machine_heartbeat_run_manager: MachineHeartbeatRunManager, result_outbox_run_manager: ResultOutboxRunManager, result_store: Arc, - update_handler_service: UpdateHandlerService, openframe_client_info_service: OpenFrameClientInfoService, last_known_good_service: LastKnownGoodService, // Services needed for log streaming initialization @@ -390,16 +382,10 @@ impl Client { OpenFrameClientInfoService::new(directory_manager.clone()) .context("Failed to initialize OpenFrame client info service")?; - // Initialize GitHub download service (used by update and installation services) + // Initialize GitHub download service (used by tool installation and update services) let github_download_service = GithubDownloadService::new(download_client.clone(), DmgExtractor::new()); - // Initialize update state and cleanup services (needed by update service) - let update_state_service = UpdateStateService::new(directory_manager.clone()) - .context("Failed to initialize update state service")?; - let update_cleanup_service = - UpdateCleanupService::new().context("Failed to initialize update cleanup service")?; - let last_known_good_service = LastKnownGoodService::new(directory_manager.clone()) .context("Failed to initialize last-known-good service")?; @@ -419,15 +405,6 @@ impl Client { tool_connection_service.clone(), ); - // Initialize OpenFrame client update service - let openframe_client_update_service = OpenFrameClientUpdateService::new( - openframe_client_info_service.clone(), - github_download_service.clone(), - update_state_service.clone(), - last_known_good_service.clone(), - tool_run_manager.clone(), - ); - // Initialize tool agent update service let tool_agent_update_service = ToolAgentUpdateService::new( github_download_service.clone(), @@ -467,14 +444,6 @@ impl Client { nats_connection_manager.clone(), tool_restart_service, config_service.clone(), - tool_run_manager.clone(), - ); - - // Initialize OpenFrame client update listener - let openframe_client_update_listener = OpenFrameClientUpdateListener::new( - nats_connection_manager.clone(), - openframe_client_update_service, - config_service.clone(), ); // Initialize tool agent update listener @@ -539,16 +508,6 @@ impl Client { let machine_heartbeat_run_manager = MachineHeartbeatRunManager::new(machine_heartbeat_publisher); - // Initialize update handler service - let update_handler_service = UpdateHandlerService::new( - update_state_service.clone(), - openframe_client_info_service.clone(), - update_cleanup_service.clone(), - last_known_good_service.clone(), - installed_agent_message_publisher.clone(), - config_service.clone(), - ); - Ok(Self { config, directory_manager, @@ -558,7 +517,6 @@ impl Client { tool_installation_message_listener, tool_uninstall_message_listener, tool_restart_message_listener, - openframe_client_update_listener, tool_agent_update_listener, client_uninstall_message_listener, command_execution_listener, @@ -571,7 +529,6 @@ impl Client { machine_heartbeat_run_manager, result_outbox_run_manager, result_store: result_store_for_recovery, - update_handler_service, openframe_client_info_service, last_known_good_service, initial_configuration_service, @@ -607,10 +564,6 @@ impl Client { error!("Failed to seed last-known-good anchor: {:#}", e); } - if let Err(e) = self.update_handler_service.record_boot_attempt().await { - error!("Failed to record boot attempt: {:#}", e); - } - self.initial_key_service.clone().ensure_initial_key().await; LogStreamingRunManager::new( @@ -631,12 +584,6 @@ impl Client { // Connect to NATS self.nats_connection_manager.connect().await?; - // Handle any pending update from previous run (after NATS is connected) - if let Err(e) = self.update_handler_service.handle_pending_update().await { - error!("Failed to handle pending update: {:#}", e); - // Continue startup even if update handling fails - don't block the client - } - // Start machine heartbeat run manager self.machine_heartbeat_run_manager.start(); @@ -648,9 +595,6 @@ impl Client { // TODO: uncomment when implemented on backend // self.tool_restart_message_listener.start().await?; - // Start OpenFrame client update listener in background - self.openframe_client_update_listener.start().await?; - // Start tool agent update listener in background self.tool_agent_update_listener.start().await?; diff --git a/clients/openframe-client/src/listener/.openframe_client_update_listener.md b/clients/openframe-client/src/listener/.openframe_client_update_listener.md deleted file mode 100644 index 50dc3a33d..000000000 --- a/clients/openframe-client/src/listener/.openframe_client_update_listener.md +++ /dev/null @@ -1,43 +0,0 @@ - -Subscribes to a NATS JetStream push consumer on the `CLIENT_UPDATE` stream, processes incoming client update messages, and automatically reconnects or retries on failure. - -## Key Components - -### `OpenFrameClientUpdateListener` - -The primary struct holding three dependencies: - -| Field | Type | Purpose | -|---|---|---| -| `nats_connection_manager` | `NatsConnectionManager` | Manages NATS client connections and reconnect signals | -| `openframe_client_update_service` | `OpenFrameClientUpdateService` | Processes deserialized update messages | -| `config_service` | `AgentConfigurationService` | Retrieves the local machine ID for consumer routing | - -### Key Methods - -- **`start()`** — Spawns a background `tokio::task` that calls `listen()` in an infinite loop, sleeping `RECONNECTION_DELAY_MS` between reconnect attempts. -- **`listen()`** — Acquires a NATS client, creates a durable push consumer, and drives a `tokio::select!` loop over incoming messages and reconnect signals. -- **`handle_message()`** — Deserializes the raw payload into `OpenFrameClientUpdateMessage`, delegates to `openframe_client_update_service.process_update()`, and ACKs on success; leaves the message unacked on failure for JetStream redelivery. -- **`create_consumer()`** — Attempts consumer creation with exponential backoff (`INITIAL_RETRY_DELAY_MS` doubling up to `MAX_RETRY_DELAY_MS`) across configurable cycles; falls back to fetching an existing durable consumer on name-collision errors. -- **`build_consumer_configuration()`** — Constructs a `push::Config` with `DeliverPolicy::New`, a machine-scoped durable name, and a wildcard filter subject (`machine.all.client-update`). - -## Usage Example - -```rust -let listener = OpenFrameClientUpdateListener::new( - nats_connection_manager, - openframe_client_update_service, - config_service, -); - -// Spawns a self-healing background task -let _handle = listener.start().await?; -``` - -## Subject Routing - -```text -Filter subject : machine.all.client-update -Deliver subject: machine..client-update.inbox -Durable name : machine__client-update_consumer_v2 -``` \ No newline at end of file diff --git a/clients/openframe-client/src/listener/client_update_gate.rs b/clients/openframe-client/src/listener/client_update_gate.rs index d5943ec41..bd3af81b6 100644 --- a/clients/openframe-client/src/listener/client_update_gate.rs +++ b/clients/openframe-client/src/listener/client_update_gate.rs @@ -1,31 +1,31 @@ use crate::config::update_config::PROGRESS_ACK_INTERVAL_SECS; -use crate::services::tool_run_manager::ToolRunManager; +use crate::platform::in_flight_client_update_phase; use async_nats::jetstream::{AckKind, Message}; use std::future::Future; use tokio::time::Duration; use tracing::{info, warn}; -pub async fn park_or_dispatch( - manager: ToolRunManager, - message: Message, - label: String, - dispatch: F, -) where +/// Parks any tool operation while openframe-client-updater is mid-swap (the +/// swap stops the client without a quiescence check), keeping the message +/// alive with Progress acks so the park never burns the max_deliver budget. +/// The probe's staleness cutoff unparks a wedged updater; a failed Progress +/// ack abandons the park and redelivery takes over. +pub async fn park_or_dispatch(message: Message, label: String, dispatch: F) +where F: FnOnce(Message) -> Fut + Send + 'static, Fut: Future + Send + 'static, { - if !manager.is_client_update_pending().await { + let Some(phase) = in_flight_client_update_phase() else { dispatch(message).await; return; - } + }; info!( - "Client update pending: parking {} (keeping it alive with Progress acks every {}s)", - label, PROGRESS_ACK_INTERVAL_SECS + "Client update in flight (updater phase: {phase}): parking {label} (keeping it alive with Progress acks every {PROGRESS_ACK_INTERVAL_SECS}s)" ); tokio::spawn(async move { - while manager.is_client_update_pending().await { + while in_flight_client_update_phase().is_some() { if let Err(e) = message.ack_with(AckKind::Progress).await { warn!( "Failed to send Progress ack for parked {}: {} — abandoning park, redelivery takes over", @@ -36,10 +36,7 @@ pub async fn park_or_dispatch( tokio::time::sleep(Duration::from_secs(PROGRESS_ACK_INTERVAL_SECS)).await; } - info!( - "Client update no longer pending: dispatching parked {}", - label - ); + info!("Client update no longer in flight: dispatching parked {label}"); dispatch(message).await; }); } diff --git a/clients/openframe-client/src/listener/mod.rs b/clients/openframe-client/src/listener/mod.rs index 76b986f35..69c61bd61 100644 --- a/clients/openframe-client/src/listener/mod.rs +++ b/clients/openframe-client/src/listener/mod.rs @@ -1,7 +1,6 @@ pub mod client_uninstall_message_listener; pub mod client_update_gate; pub mod execution_listener; -pub mod openframe_client_update_listener; pub mod tool_agent_update_listener; pub mod tool_installation_message_listener; pub mod tool_restart_message_listener; @@ -9,7 +8,6 @@ pub mod tool_uninstall_message_listener; pub use client_uninstall_message_listener::ClientUninstallMessageListener; pub use execution_listener::ExecutionListener; -pub use openframe_client_update_listener::OpenFrameClientUpdateListener; pub use tool_agent_update_listener::ToolAgentUpdateListener; pub use tool_installation_message_listener::ToolInstallationMessageListener; pub use tool_restart_message_listener::ToolRestartMessageListener; diff --git a/clients/openframe-client/src/listener/openframe_client_update_listener.rs b/clients/openframe-client/src/listener/openframe_client_update_listener.rs deleted file mode 100644 index 0d76410ba..000000000 --- a/clients/openframe-client/src/listener/openframe_client_update_listener.rs +++ /dev/null @@ -1,264 +0,0 @@ -use crate::config::update_config::{ - CONSUMER_ACK_WAIT_SECS, CONSUMER_CYCLE_PAUSE_MS, CONSUMER_MAX_DELIVER, - CONSUMER_RETRY_ATTEMPTS_PER_CYCLE, INITIAL_RETRY_DELAY_MS, MAX_RETRY_DELAY_MS, - RECONNECTION_DELAY_MS, -}; -use crate::models::openframe_client_update_message::OpenFrameClientUpdateMessage; -use crate::services::nats_connection_manager::NatsConnectionManager; -use crate::services::openframe_client_update_service::OpenFrameClientUpdateService; -use crate::services::AgentConfigurationService; -use anyhow::Result; -use async_nats::jetstream; -use async_nats::jetstream::consumer::push; -use async_nats::jetstream::consumer::DeliverPolicy; -use async_nats::jetstream::consumer::PushConsumer; -use async_nats::jetstream::Message; -use futures::StreamExt; -use tokio::time::Duration; -use tracing::{error, info, warn}; - -#[derive(Clone)] -pub struct OpenFrameClientUpdateListener { - pub nats_connection_manager: NatsConnectionManager, - pub openframe_client_update_service: OpenFrameClientUpdateService, - pub config_service: AgentConfigurationService, -} - -impl OpenFrameClientUpdateListener { - const STREAM_NAME: &'static str = "CLIENT_UPDATE"; - - pub fn new( - nats_connection_manager: NatsConnectionManager, - openframe_client_update_service: OpenFrameClientUpdateService, - config_service: AgentConfigurationService, - ) -> Self { - Self { - nats_connection_manager, - openframe_client_update_service, - config_service, - } - } - - /// Start listening for messages in a background task - pub async fn start(&self) -> Result> { - let listener = self.clone(); - let handle = tokio::spawn(async move { - loop { - info!("Starting OpenFrame client update listener..."); - match listener.listen().await { - Ok(_) => { - warn!("OpenFrame client update listener exited normally (unexpected)"); - } - Err(e) => { - error!("OpenFrame client update listener error: {:#}", e); - } - } - - info!( - "Reconnecting OpenFrame client update listener in {} seconds...", - RECONNECTION_DELAY_MS / 1000 - ); - tokio::time::sleep(Duration::from_millis(RECONNECTION_DELAY_MS)).await; - } - }); - Ok(handle) - } - - async fn listen(&self) -> Result<()> { - info!("Run OpenFrame client update message listener"); - let machine_id = self.config_service.get_machine_id()?; - - loop { - let client = self.nats_connection_manager.get_client().await?; - let mut reconnect_rx = self.nats_connection_manager.subscribe_reconnect(); - let js = jetstream::new((*client).clone()); - - let consumer = self.create_consumer(&js, &machine_id).await; - - info!("Start listening for OpenFrame client update messages"); - let mut messages = consumer.messages().await?; - - loop { - tokio::select! { - msg_result = messages.next() => { - match msg_result { - Some(Ok(message)) => { - if let Err(e) = self.handle_message(message).await { - error!("Failed to handle message: {:#}", e); - } - } - Some(Err(e)) => { - error!("Message stream error, recreating consumer: {:#}", e); - return Err(anyhow::anyhow!("Message stream error: {}", e)); - } - None => { - warn!("Message stream ended, rebinding consumer"); - break; - } - } - } - _ = reconnect_rx.recv() => { - info!("NATS reconnected, re-provisioning OpenFrame client update consumer"); - self.create_consumer(&js, &machine_id).await; - } - } - } - } - } - - async fn handle_message(&self, message: Message) -> Result<()> { - let payload = String::from_utf8_lossy(&message.payload); - info!("Received OpenFrame client update message: {:?}", payload); - - let client_update_message: OpenFrameClientUpdateMessage = - match serde_json::from_str(&payload) { - Ok(msg) => msg, - Err(e) => { - error!("Failed to parse client update message: {:#}", e); - if let Err(ack_err) = message.ack().await { - warn!("Failed to ack malformed message: {}", ack_err); - } - return Ok(()); - } - }; - - let version = client_update_message.version.clone(); - - match self - .openframe_client_update_service - .process_update(client_update_message) - .await - { - Ok(_) => { - info!( - "Acknowledging client update message for version: {}", - version - ); - message - .ack() - .await - .map_err(|e| anyhow::anyhow!("Failed to ack message: {}", e))?; - info!( - "Client update message acknowledged for version: {}", - version - ); - } - Err(e) => { - error!( - "Failed to process client update message for version {}: {:#}", - version, e - ); - info!( - "Leaving message unacked for potential redelivery: version {}", - version - ); - } - } - - Ok(()) - } - - async fn create_consumer(&self, js: &jetstream::Context, machine_id: &str) -> PushConsumer { - let consumer_configuration = Self::build_consumer_configuration(machine_id); - let mut cycle = 0u32; - - loop { - cycle += 1; - let mut delay_ms = INITIAL_RETRY_DELAY_MS; - - for attempt in 1..=CONSUMER_RETRY_ATTEMPTS_PER_CYCLE { - info!( - "Creating consumer for stream {} (cycle {}, attempt {}/{})", - Self::STREAM_NAME, - cycle, - attempt, - CONSUMER_RETRY_ATTEMPTS_PER_CYCLE - ); - - match js - .create_consumer_on_stream(consumer_configuration.clone(), Self::STREAM_NAME) - .await - { - Ok(consumer) => { - info!("Consumer ready for stream: {}", Self::STREAM_NAME); - return consumer; - } - Err(e) => { - let error_msg = format!("{:?}", e); - if error_msg.contains("consumer name already in use") - || error_msg.contains("10013") - { - warn!("Consumer already exists, attempting to get existing consumer"); - let durable_name = Self::build_durable_name(machine_id); - if let Ok(existing_consumer) = js - .get_consumer_from_stream(Self::STREAM_NAME, &durable_name) - .await - { - info!( - "Retrieved existing consumer for stream: {}", - Self::STREAM_NAME - ); - return existing_consumer; - } - } - - if attempt < CONSUMER_RETRY_ATTEMPTS_PER_CYCLE { - warn!( - "Failed to create consumer (cycle {}, attempt {}/{}): {:#}. Retrying in {} ms...", - cycle, attempt, CONSUMER_RETRY_ATTEMPTS_PER_CYCLE, e, delay_ms - ); - tokio::time::sleep(Duration::from_millis(delay_ms)).await; - delay_ms = (delay_ms * 2).min(MAX_RETRY_DELAY_MS); - } else { - warn!( - "Failed to create consumer (cycle {}, attempt {}/{}): {:#}", - cycle, attempt, CONSUMER_RETRY_ATTEMPTS_PER_CYCLE, e - ); - } - } - } - } - - info!( - "All {} attempts in cycle {} failed. Pausing {} seconds before next cycle...", - CONSUMER_RETRY_ATTEMPTS_PER_CYCLE, - cycle, - CONSUMER_CYCLE_PAUSE_MS / 1000 - ); - tokio::time::sleep(Duration::from_millis(CONSUMER_CYCLE_PAUSE_MS)).await; - } - } - - fn build_consumer_configuration(machine_id: &str) -> push::Config { - let filter_subject = Self::build_filter_subject(machine_id); - let deliver_subject = Self::build_deliver_subject(machine_id); - let durable_name = Self::build_durable_name(machine_id); - - info!( - "Consumer configuration - filter subject: {}, deliver subject: {}, durable name: {}", - filter_subject, deliver_subject, durable_name - ); - - push::Config { - filter_subject, - deliver_subject, - durable_name: Some(durable_name), - ack_wait: Duration::from_secs(CONSUMER_ACK_WAIT_SECS), - deliver_policy: DeliverPolicy::New, - max_deliver: CONSUMER_MAX_DELIVER, - ..Default::default() - } - } - - fn build_filter_subject(_machine_id: &str) -> String { - "machine.all.client-update".to_string() - } - - fn build_deliver_subject(machine_id: &str) -> String { - format!("machine.{}.client-update.inbox", machine_id) - } - - fn build_durable_name(machine_id: &str) -> String { - format!("machine_{}_client-update_consumer_v2", machine_id) - } -} diff --git a/clients/openframe-client/src/listener/tool_agent_update_listener.rs b/clients/openframe-client/src/listener/tool_agent_update_listener.rs index 607606630..37dbf0aab 100644 --- a/clients/openframe-client/src/listener/tool_agent_update_listener.rs +++ b/clients/openframe-client/src/listener/tool_agent_update_listener.rs @@ -127,17 +127,11 @@ impl ToolAgentUpdateListener { } }; - let tool_agent_id = tool_agent_update_message.tool_agent_id.clone(); - let listener = self.clone(); - park_or_dispatch( - self.tool_run_manager.clone(), - message, - format!("tool-update:{}", tool_agent_id), - move |msg| async move { - listener.dispatch(msg, tool_agent_update_message).await; - }, - ) + let label = format!("tool-update:{}", tool_agent_update_message.tool_agent_id); + park_or_dispatch(message, label, move |msg| async move { + listener.dispatch(msg, tool_agent_update_message).await; + }) .await; Ok(()) diff --git a/clients/openframe-client/src/listener/tool_installation_message_listener.rs b/clients/openframe-client/src/listener/tool_installation_message_listener.rs index 42ac2e75e..27fed0408 100644 --- a/clients/openframe-client/src/listener/tool_installation_message_listener.rs +++ b/clients/openframe-client/src/listener/tool_installation_message_listener.rs @@ -5,6 +5,7 @@ use crate::config::update_config::{ }; use crate::listener::client_update_gate::park_or_dispatch; use crate::models::tool_installation_message::ToolInstallationMessage; +use crate::platform::{in_flight_client_update_phase, UPDATER_TOOL_AGENT_ID}; use crate::services::nats_connection_manager::NatsConnectionManager; use crate::services::tool_installation_service::ToolInstallationService; use crate::services::tool_run_manager::ToolRunManager; @@ -127,17 +128,14 @@ impl ToolInstallationMessageListener { } }; - let tool_agent_id = tool_installation_message.tool_agent_id.clone(); - let listener = self.clone(); - park_or_dispatch( - self.tool_run_manager.clone(), - message, - format!("tool-installation:{}", tool_agent_id), - move |msg| async move { - listener.dispatch(msg, tool_installation_message).await; - }, - ) + let label = format!( + "tool-installation:{}", + tool_installation_message.tool_agent_id + ); + park_or_dispatch(message, label, move |msg| async move { + listener.dispatch(msg, tool_installation_message).await; + }) .await; Ok(()) @@ -146,6 +144,13 @@ impl ToolInstallationMessageListener { async fn dispatch(&self, message: Message, tool_installation_message: ToolInstallationMessage) { let tool_agent_id = tool_installation_message.tool_agent_id.clone(); + if tool_agent_id == UPDATER_TOOL_AGENT_ID { + if let Some(phase) = in_flight_client_update_phase() { + info!("Client update in flight (updater phase: {phase}), deferring updater installation for redelivery"); + return; + } + } + match self .tool_installation_service .install(tool_installation_message) diff --git a/clients/openframe-client/src/listener/tool_restart_message_listener.rs b/clients/openframe-client/src/listener/tool_restart_message_listener.rs index a0e3c7dcc..ba88ca6c5 100644 --- a/clients/openframe-client/src/listener/tool_restart_message_listener.rs +++ b/clients/openframe-client/src/listener/tool_restart_message_listener.rs @@ -5,10 +5,10 @@ use crate::config::update_config::{ }; use crate::listener::client_update_gate::park_or_dispatch; use crate::models::ToolRestartMessage; +use crate::platform::{in_flight_client_update_phase, UPDATER_TOOL_AGENT_ID}; use crate::services::nats_connection_manager::NatsConnectionManager; use crate::services::tool_restart_service::RestartOutcome; use crate::services::tool_restart_service::ToolRestartService; -use crate::services::tool_run_manager::ToolRunManager; use crate::services::AgentConfigurationService; use anyhow::Result; use async_nats::jetstream; @@ -24,7 +24,6 @@ pub struct ToolRestartMessageListener { nats_connection_manager: NatsConnectionManager, tool_restart_service: ToolRestartService, config_service: AgentConfigurationService, - tool_run_manager: ToolRunManager, } impl ToolRestartMessageListener { @@ -34,13 +33,11 @@ impl ToolRestartMessageListener { nats_connection_manager: NatsConnectionManager, tool_restart_service: ToolRestartService, config_service: AgentConfigurationService, - tool_run_manager: ToolRunManager, ) -> Self { Self { nats_connection_manager, tool_restart_service, config_service, - tool_run_manager, } } @@ -129,20 +126,22 @@ impl ToolRestartMessageListener { let listener = self.clone(); let label = format!("tool-restart:{}", tool_agent_id); - park_or_dispatch( - self.tool_run_manager.clone(), - message, - label, - move |msg| async move { - listener.dispatch(msg, tool_agent_id).await; - }, - ) + park_or_dispatch(message, label, move |msg| async move { + listener.dispatch(msg, tool_agent_id).await; + }) .await; Ok(()) } async fn dispatch(&self, message: Message, tool_agent_id: String) { + if tool_agent_id == UPDATER_TOOL_AGENT_ID { + if let Some(phase) = in_flight_client_update_phase() { + info!("Client update in flight (updater phase: {phase}), deferring updater restart for redelivery"); + return; + } + } + let ack_message = match self .tool_restart_service .restart_guarded(&tool_agent_id) diff --git a/clients/openframe-client/src/listener/tool_uninstall_message_listener.rs b/clients/openframe-client/src/listener/tool_uninstall_message_listener.rs index 170a01493..a50de254d 100644 --- a/clients/openframe-client/src/listener/tool_uninstall_message_listener.rs +++ b/clients/openframe-client/src/listener/tool_uninstall_message_listener.rs @@ -5,6 +5,7 @@ use crate::config::update_config::{ }; use crate::listener::client_update_gate::park_or_dispatch; use crate::models::ToolUninstallMessage; +use crate::platform::{in_flight_client_update_phase, UPDATER_TOOL_AGENT_ID}; use crate::services::nats_connection_manager::NatsConnectionManager; use crate::services::tool_run_manager::ToolRunManager; use crate::services::tool_uninstall_service::ToolUninstallService; @@ -125,17 +126,11 @@ impl ToolUninstallMessageListener { } }; - let tool_agent_id = uninstall_message.tool_agent_id.clone(); - let listener = self.clone(); - park_or_dispatch( - self.tool_run_manager.clone(), - message, - format!("tool-uninstall:{}", tool_agent_id), - move |msg| async move { - listener.dispatch(msg, uninstall_message).await; - }, - ) + let label = format!("tool-uninstall:{}", uninstall_message.tool_agent_id); + park_or_dispatch(message, label, move |msg| async move { + listener.dispatch(msg, uninstall_message).await; + }) .await; Ok(()) @@ -144,6 +139,13 @@ impl ToolUninstallMessageListener { async fn dispatch(&self, message: Message, uninstall_message: ToolUninstallMessage) { let tool_agent_id = uninstall_message.tool_agent_id.clone(); + if tool_agent_id == UPDATER_TOOL_AGENT_ID { + if let Some(phase) = in_flight_client_update_phase() { + info!("Client update in flight (updater phase: {phase}), deferring updater uninstall for redelivery"); + return; + } + } + let tool_lock = self.tool_run_manager.tool_lock(&tool_agent_id).await; let _guard = match tool_lock.try_lock() { Ok(guard) => guard, diff --git a/clients/openframe-client/src/logging/log_source.rs b/clients/openframe-client/src/logging/log_source.rs index 6f129d5d1..15293af37 100644 --- a/clients/openframe-client/src/logging/log_source.rs +++ b/clients/openframe-client/src/logging/log_source.rs @@ -13,6 +13,7 @@ use super::log_parser::{parse_log_line, LogDeduplicator, LogEntry}; pub enum LogSourceKind { Openframe, Meshcentral, + Updater, } impl fmt::Display for LogSourceKind { @@ -20,6 +21,7 @@ impl fmt::Display for LogSourceKind { match self { LogSourceKind::Openframe => write!(f, "openframe"), LogSourceKind::Meshcentral => write!(f, "meshcentral-agent"), + LogSourceKind::Updater => write!(f, "openframe-client-updater"), } } } diff --git a/clients/openframe-client/src/logging/nats_streaming.rs b/clients/openframe-client/src/logging/nats_streaming.rs index 326776fd1..865433a14 100644 --- a/clients/openframe-client/src/logging/nats_streaming.rs +++ b/clients/openframe-client/src/logging/nats_streaming.rs @@ -239,24 +239,52 @@ fn spawn_source_discovery( directory_manager: DirectoryManager, ) { tokio::spawn(async move { + let mut meshcentral_registered = false; + let mut updater_registered = false; + loop { tokio::time::sleep(Duration::from_secs(SOURCE_DISCOVERY_INTERVAL_SECS)).await; - match create_meshcentral_source(&installed_tools_service, &directory_manager).await { - Some(source) => { - info!("Meshcentral log source discovered, registering"); - if tx.send(source).await.is_err() { - return; + if !meshcentral_registered { + match create_meshcentral_source(&installed_tools_service, &directory_manager).await + { + Some(source) => { + info!("Meshcentral log source discovered, registering"); + if tx.send(source).await.is_err() { + return; + } + meshcentral_registered = true; + } + None => { + debug!( + "Meshcentral not installed yet, will retry in {}s", + SOURCE_DISCOVERY_INTERVAL_SECS + ); } - return; } - None => { - debug!( - "Meshcentral not installed yet, will retry in {}s", - SOURCE_DISCOVERY_INTERVAL_SECS - ); + } + + if !updater_registered { + match create_updater_source(&installed_tools_service, &directory_manager).await { + Some(source) => { + info!("Updater log source discovered, registering"); + if tx.send(source).await.is_err() { + return; + } + updater_registered = true; + } + None => { + debug!( + "Updater not installed yet, will retry in {}s", + SOURCE_DISCOVERY_INTERVAL_SECS + ); + } } } + + if meshcentral_registered && updater_registered { + return; + } } }); } @@ -283,6 +311,26 @@ async fn create_meshcentral_source( Some(Box::new(source)) } +async fn create_updater_source( + installed_tools_service: &InstalledToolsService, + directory_manager: &DirectoryManager, +) -> Option> { + let updater_id = LogSourceKind::Updater.to_string(); + + let tools = installed_tools_service.get_all().await.ok()?; + tools.into_iter().find(|t| t.tool_agent_id == updater_id)?; + + // {app_support_dir}/openframe-client-updater/openframe-client-updater.log — + // the path the updater binary writes to; a SERVICE-type tool has no + // executable_path, so presence in installed_tools is the discovery signal. + let tool_dir = directory_manager.app_support_dir().join(&updater_id); + let log_path = tool_dir.join(format!("{}.log", updater_id)); + let offset_path = directory_manager.secured_dir().join("updater_log_offset"); + + let source = FileLogSource::new(LogSourceKind::Updater, log_path, offset_path); + Some(Box::new(source)) +} + #[allow(clippy::too_many_arguments)] async fn log_streaming_loop( mut registry: LogSourceRegistry, diff --git a/clients/openframe-client/src/models/.openframe_client_update_message.md b/clients/openframe-client/src/models/.openframe_client_update_message.md deleted file mode 100644 index f7bffcdc4..000000000 --- a/clients/openframe-client/src/models/.openframe_client_update_message.md +++ /dev/null @@ -1,37 +0,0 @@ - -A serializable message struct representing a client update payload for the OpenFrame platform, carrying a target version string and a list of download configurations. - -## Key Components - -- **`OpenFrameClientUpdateMessage`** — Public struct with `camelCase` JSON serialization (via Serde), containing: - - `version: String` — The target client version string. - - `download_configurations: Vec` — Ordered list of [`DownloadConfiguration`](https://github.com/flamingo-stack/openframe-oss-lib/blob/main/src/download_configuration.rs) entries describing what to fetch for the update. - -## Usage Example - -```rust -use openframe_oss_lib::messages::OpenFrameClientUpdateMessage; -use openframe_oss_lib::download_configuration::DownloadConfiguration; - -let update_msg = OpenFrameClientUpdateMessage { - version: "1.4.2".to_string(), - download_configurations: vec![ - DownloadConfiguration { - // fields per DownloadConfiguration definition - }, - ], -}; - -// Serialize to JSON (keys emitted as camelCase) -let json = serde_json::to_string(&update_msg).unwrap(); -// {"version":"1.4.2","downloadConfigurations":[...]} - -// Deserialize from JSON -let parsed: OpenFrameClientUpdateMessage = - serde_json::from_str(&json).unwrap(); -``` - -## Notes - -- Derives `Clone` and `Debug` for convenient use in logging and message-passing contexts. -- The `#[serde(rename_all = "camelCase")]` attribute ensures `download_configurations` serializes as `downloadConfigurations`, matching typical JSON API conventions used by the OpenFrame platform. \ No newline at end of file diff --git a/clients/openframe-client/src/models/.update_state.md b/clients/openframe-client/src/models/.update_state.md deleted file mode 100644 index 991cdaff8..000000000 --- a/clients/openframe-client/src/models/.update_state.md +++ /dev/null @@ -1,57 +0,0 @@ - -Defines the data structures for tracking the lifecycle state of an in-progress software update, including phase transitions and boot attempt counting. - -## Key Components - -### `UpdatePhase` (enum) -Represents the sequential stages of an update operation: - -| Variant | Description | -|---|---| -| `Validating` | Initial phase; verifying update integrity | -| `Downloading` | Fetching update payload | -| `Extracting` | Unpacking downloaded content | -| `PreparingUpdater` | Staging the updater process | -| `UpdaterLaunched` | Updater process is running | -| `Completed` | Update finished successfully | -| `Verifying` | Post-update verification | -| `RolledBack` | Update failed; previous version restored | - -Serializes to/from `snake_case` JSON strings. - -### `UpdateState` (struct) -Holds the full snapshot of an active update: - -- `target_version` — semver string of the version being installed -- `phase` — current `UpdatePhase` -- `boot_attempts` — number of boot retries attempted (defaults to `0`) -- `started_at` — RFC 3339 timestamp of when the update began (optional) - -### `UpdateState::new(target_version)` -Constructs a fresh `UpdateState` in the `Validating` phase with zero boot attempts and the current UTC time as `started_at`. - -### `UpdateState::set_phase(&mut self, phase)` -Transitions the state to a new `UpdatePhase`. - -## Usage Example - -```rust -use update_state::{UpdateState, UpdatePhase}; - -// Start a new update -let mut state = UpdateState::new("2.4.1".to_string()); -assert_eq!(state.phase, UpdatePhase::Validating); - -// Advance through phases -state.set_phase(UpdatePhase::Downloading); -state.set_phase(UpdatePhase::Extracting); - -// Serialize to JSON for persistence -let json = serde_json::to_string(&state).unwrap(); -// {"target_version":"2.4.1","phase":"extracting","boot_attempts":0,"started_at":"2025-01-15T10:30:00Z"} - -// Deserialize back -let restored: UpdateState = serde_json::from_str(&json).unwrap(); -``` - -> **Source:** [`update_state.rs`](https://github.com/flamingo-stack/openframe-oss-lib/blob/main/update_state.rs) \ No newline at end of file diff --git a/clients/openframe-client/src/models/mod.rs b/clients/openframe-client/src/models/mod.rs index 74358a2b7..e39c74543 100644 --- a/clients/openframe-client/src/models/mod.rs +++ b/clients/openframe-client/src/models/mod.rs @@ -10,7 +10,6 @@ pub mod installed_agent_message; pub mod installed_tool; pub mod machine_heartbeat_message; pub mod openframe_client_info; -pub mod openframe_client_update_message; pub mod tool_agent_update_message; pub mod tool_connection; pub mod tool_connection_message; @@ -19,7 +18,6 @@ pub mod tool_installation_result; pub mod tool_restart_message; pub mod tool_uninstall_message; pub mod tool_version_overrides; -pub mod update_state; pub use execution::{ CommandMessage, ExecutionMessage, ExecutionRequest, PrivilegeLevel, RmmResult, ScriptEnvVar, @@ -38,7 +36,6 @@ pub use installed_agent_message::InstalledAgentMessage; pub use installed_tool::{Installation, InstalledAsset, InstalledTool, ToolRecordState}; pub use machine_heartbeat_message::MachineHeartbeatMessage; pub use openframe_client_info::OpenFrameClientInfo; -pub use openframe_client_update_message::OpenFrameClientUpdateMessage; pub use tool_agent_update_message::{AssetUpdate, ToolAgentUpdateMessage}; pub use tool_connection::ToolConnection; pub use tool_connection_message::ToolConnectionMessage; @@ -46,4 +43,3 @@ pub use tool_installation_message::ToolInstallationMessage; pub use tool_installation_result::ToolInstallationResult; pub use tool_restart_message::ToolRestartMessage; pub use tool_uninstall_message::ToolUninstallMessage; -pub use update_state::{UpdatePhase, UpdateState}; diff --git a/clients/openframe-client/src/models/openframe_client_update_message.rs b/clients/openframe-client/src/models/openframe_client_update_message.rs deleted file mode 100644 index 992774b57..000000000 --- a/clients/openframe-client/src/models/openframe_client_update_message.rs +++ /dev/null @@ -1,9 +0,0 @@ -use super::download_configuration::DownloadConfiguration; -use serde::{Deserialize, Serialize}; - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct OpenFrameClientUpdateMessage { - pub version: String, - pub download_configurations: Vec, -} diff --git a/clients/openframe-client/src/models/tool_version_overrides.rs b/clients/openframe-client/src/models/tool_version_overrides.rs index bcd5f8e21..19b6ab733 100644 --- a/clients/openframe-client/src/models/tool_version_overrides.rs +++ b/clients/openframe-client/src/models/tool_version_overrides.rs @@ -13,6 +13,9 @@ pub fn lookup(tool_key: &str) -> Option<&'static str> { #[cfg(feature = "osquery-version")] "osqueryd" => Some(env!("OSQUERY_VERSION")), + #[cfg(feature = "openframe-client-updater-version")] + "openframe-client-updater" => Some(env!("OPENFRAME_CLIENT_UPDATER_VERSION")), + _ => None, } } diff --git a/clients/openframe-client/src/models/update_state.rs b/clients/openframe-client/src/models/update_state.rs deleted file mode 100644 index 7f96f8a70..000000000 --- a/clients/openframe-client/src/models/update_state.rs +++ /dev/null @@ -1,43 +0,0 @@ -use serde::{Deserialize, Serialize}; - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum UpdatePhase { - Validating, - Downloading, - Extracting, - PreparingUpdater, - UpdaterLaunched, - Completed, - Verifying, - RolledBack, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct UpdateState { - pub target_version: String, - - /// Current phase - pub phase: UpdatePhase, - - #[serde(default)] - pub boot_attempts: u32, - - #[serde(default)] - pub started_at: Option, -} - -impl UpdateState { - pub fn new(target_version: String) -> Self { - Self { - target_version, - phase: UpdatePhase::Validating, - boot_attempts: 0, - started_at: Some(chrono::Utc::now().to_rfc3339()), - } - } - - pub fn set_phase(&mut self, phase: UpdatePhase) { - self.phase = phase; - } -} diff --git a/clients/openframe-client/src/platform/client_update_probe.rs b/clients/openframe-client/src/platform/client_update_probe.rs new file mode 100644 index 000000000..217efb785 --- /dev/null +++ b/clients/openframe-client/src/platform/client_update_probe.rs @@ -0,0 +1,36 @@ +use crate::platform::directories::get_secured_directory; + +pub const UPDATER_TOOL_AGENT_ID: &str = "openframe-client-updater"; + +const UPDATER_STATE_FILE_NAME: &str = "updater_state.json"; +const UPDATER_STATE_STALE_SECS: u64 = 30 * 60; +const IN_FLIGHT_UPDATER_PHASES: [&str; 8] = [ + "downloading", + "verifying", + "stopping_service", + "replacing_binary", + "starting_service", + "verifying_boot", + "observing", + "rolling_back", +]; + +/// The updater persists its CLIENT_UPDATE state machine to {secured}/updater_state.json; +/// a non-terminal phase there means stopping com.openframe.client-updater now would +/// interrupt a client swap. State files untouched for over 30 minutes are ignored so a +/// wedged updater that stopped clearing its state can still be repaired remotely. +pub fn in_flight_client_update_phase() -> Option { + let path = get_secured_directory().join(UPDATER_STATE_FILE_NAME); + let modified = std::fs::metadata(&path).ok()?.modified().ok()?; + if let Ok(age) = modified.elapsed() { + if age.as_secs() > UPDATER_STATE_STALE_SECS { + return None; + } + } + let raw = std::fs::read_to_string(&path).ok()?; + let state: serde_json::Value = serde_json::from_str(&raw).ok()?; + let phase = state.get("phase")?.as_str()?; + IN_FLIGHT_UPDATER_PHASES + .contains(&phase) + .then(|| phase.to_string()) +} diff --git a/clients/openframe-client/src/platform/mod.rs b/clients/openframe-client/src/platform/mod.rs index 76371fa6a..1ed21d263 100644 --- a/clients/openframe-client/src/platform/mod.rs +++ b/clients/openframe-client/src/platform/mod.rs @@ -1,4 +1,5 @@ pub mod binary_writer; +pub mod client_update_probe; pub mod console; pub mod directories; pub mod dmg_extractor; @@ -10,8 +11,6 @@ pub mod permissions; pub mod system_service; pub mod tool_updater; pub mod uninstall; -pub mod update_scripts; -pub mod updater_launcher; pub mod user_session; #[cfg(target_os = "windows")] @@ -25,6 +24,7 @@ pub mod windows_path_migration; // Re-export commonly used items pub use binary_writer::{set_executable_permissions, write_executable}; +pub use client_update_probe::{in_flight_client_update_phase, UPDATER_TOOL_AGENT_ID}; pub use console::configure_console; #[cfg(target_os = "macos")] pub use directories::{remove_app_bundle, remove_app_bundle_path}; diff --git a/clients/openframe-client/src/platform/update_scripts/.macos.md b/clients/openframe-client/src/platform/update_scripts/.macos.md deleted file mode 100644 index 188d0f09f..000000000 --- a/clients/openframe-client/src/platform/update_scripts/.macos.md +++ /dev/null @@ -1,36 +0,0 @@ - -Provides macOS-specific constants for the OpenFrame agent self-update mechanism: a launchd plist template and a bash update script. - -## Key Components - -- **`UPDATER_PLIST_TEMPLATE`** — A launchd `PropertyList` XML template for registering a one-shot updater job under `com.openframe.updater`. Accepts nine placeholder tokens (`{SCRIPT_PATH}`, `{BINARY_PATH}`, `{SERVICE_LABEL}`, `{TARGET_EXE}`, `{UPDATE_STATE_PATH}`, `{TARGET_VERSION}`, `{BOOT_MARKER_PATH}`, `{LKG_PATH}`, `{BOOT_MARKER_WAIT_SECS}`, `{ROLLBACK_ONLY}`) that must be substituted at runtime before writing to disk. - -- **`UPDATE_SCRIPT_MACOS`** — A bash script executed by the launchd job above. Handles the full update lifecycle: - - **Pre-swap validation** — checks binary presence, minimum size (100 KB), and service plist existence - - **Service stop/start** — uses `launchctl unload`/`load` with a 30-second timeout - - **Atomic swap** — moves the current binary to `.prev`, then copies the new binary into place - - **Boot verification** — polls a boot marker file up to `BOOT_MARKER_WAIT_SECS` (default 90 s) to confirm the correct version started - - **Rollback** — on any failure post-swap, restores from an LKG (last-known-good) path or `.prev`, restarts the service, and stamps the update state as `rolled_back` - - **Cleanup** — removes the staged binary and self-removes the updater plist via `launchctl remove` - -## Usage Example - -```rust -use std::fs; - -let plist = UPDATER_PLIST_TEMPLATE - .replace("{SCRIPT_PATH}", "/tmp/openframe_update.sh") - .replace("{BINARY_PATH}", "/tmp/openframe_new") - .replace("{SERVICE_LABEL}", "com.openframe.agent") - .replace("{TARGET_EXE}", "/usr/local/bin/openframe") - .replace("{UPDATE_STATE_PATH}", "/var/lib/openframe/update_state.json") - .replace("{TARGET_VERSION}", "1.2.3") - .replace("{BOOT_MARKER_PATH}", "/var/lib/openframe/boot_marker") - .replace("{LKG_PATH}", "/var/lib/openframe/openframe.lkg") - .replace("{BOOT_MARKER_WAIT_SECS}", "90") - .replace("{ROLLBACK_ONLY}", "0") - .replace("{TRANSCRIPT_PATH}", "/tmp/openframe_updater.log"); - -fs::write("/tmp/com.openframe.updater.plist", &plist)?; -fs::write("/tmp/openframe_update.sh", UPDATE_SCRIPT_MACOS)?; -``` \ No newline at end of file diff --git a/clients/openframe-client/src/platform/update_scripts/.mod.md b/clients/openframe-client/src/platform/update_scripts/.mod.md deleted file mode 100644 index dd4f958ff..000000000 --- a/clients/openframe-client/src/platform/update_scripts/.mod.md +++ /dev/null @@ -1,34 +0,0 @@ - -Platform-specific update script module that conditionally exposes OS-native update scripts and templates based on the compile target. - -## Key Components - -| Export | Platform | Description | -|---|---|---| -| `windows::UPDATE_SCRIPT_WINDOWS` | Windows | PowerShell update script constant | -| `macos::UPDATE_SCRIPT_MACOS` | macOS | Shell update script constant | -| `macos::UPDATER_PLIST_TEMPLATE` | macOS | LaunchAgent/launchd plist template constant | - -## Usage Example - -```rust -// Windows -#[cfg(target_os = "windows")] -use scripts::UPDATE_SCRIPT_WINDOWS; - -// macOS -#[cfg(target_os = "macos")] -use scripts::{UPDATE_SCRIPT_MACOS, UPDATER_PLIST_TEMPLATE}; -``` - -## ASCII Invariant - -All script constants are enforced to be **pure ASCII** via compile-time tests. This prevents a known Windows PowerShell 5.1 bug where BOM-less UTF-8 files are read as ANSI — causing multi-byte characters (e.g. em-dashes encoded as `0xE2 0x80 0x94`) to decode into smart quotes like `0x94`, which can silently terminate strings and structurally corrupt the script. - -The same constraint is applied to macOS scripts and plist templates for consistency. - -> **If you add or edit script content**, ensure no non-ASCII characters are introduced. The tests in `mod tests` will catch violations at CI time. - -## Source - -[`mod.rs` on GitHub](https://github.com/flamingo-stack/openframe-oss-lib/blob/main/mod.rs) \ No newline at end of file diff --git a/clients/openframe-client/src/platform/update_scripts/.windows.md b/clients/openframe-client/src/platform/update_scripts/.windows.md deleted file mode 100644 index 8a48fa532..000000000 --- a/clients/openframe-client/src/platform/update_scripts/.windows.md +++ /dev/null @@ -1,60 +0,0 @@ - -Defines an embedded PowerShell script used by the OpenFrame agent's self-update mechanism on Windows, exposed as a Rust string constant. - -## Key Components - -- **`UPDATE_SCRIPT_WINDOWS`** — A `&str` constant containing a complete PowerShell script that orchestrates stop-swap-start binary replacement for a Windows service, including rollback logic and boot verification. - -The embedded script accepts the following parameters: - -| Parameter | Purpose | -|---|---| -| `$ArchivePath` | Path to the downloaded `.zip` update archive | -| `$ServiceName` | Windows service name to stop/start | -| `$TargetExe` | Full path to the agent executable | -| `$UpdateStatePath` | JSON state file for phase tracking | -| `$TargetVersion` | Expected version string for boot verification | -| `$BootMarkerPath` | File written by the new binary on successful boot | -| `$LkgPath` | Last-known-good binary for rollback | -| `$TranscriptPath` | Optional PowerShell transcript log path | -| `$BootMarkerWaitSecs` | Timeout for boot marker check (default: 90s) | -| `$RollbackOnly` | Switch to trigger rollback without an update | - -## Usage Example - -```rust -use std::fs; - -// Write the script to a temp file and invoke it via PowerShell -let script_path = std::env::temp_dir().join("openframe_update.ps1"); -fs::write(&script_path, windows::UPDATE_SCRIPT_WINDOWS)?; - -std::process::Command::new("powershell") - .args([ - "-ExecutionPolicy", "Bypass", - "-File", script_path.to_str().unwrap(), - "-ArchivePath", archive_path, - "-ServiceName", "openframe-agent", - "-TargetExe", target_exe, - "-TargetVersion", &new_version, - ]) - .spawn()?; -``` - -## Update Flow - -```mermaid -graph TD - A[Validate Inputs] --> B[Stop Service] - B --> C[Extract Archive] - C --> D[Swap Binary] - D --> E[Start Service] - E --> F{Boot Marker OK?} - F -->|Yes| G[Set phase: verifying] - F -->|No| H[Rollback] - H --> I[Restore LKG or .prev] - I --> J[Restart Service] - J --> K[Set phase: rolled_back] -``` - -The script uses `$SwapReached` to gate rollback — if the failure occurs before the binary swap, the original executable is untouched and the service is simply restarted. \ No newline at end of file diff --git a/clients/openframe-client/src/platform/update_scripts/macos.rs b/clients/openframe-client/src/platform/update_scripts/macos.rs deleted file mode 100644 index 0a0d3de5a..000000000 --- a/clients/openframe-client/src/platform/update_scripts/macos.rs +++ /dev/null @@ -1,273 +0,0 @@ -//! macOS bash update script for self-update functionality - -pub const UPDATER_PLIST_TEMPLATE: &str = r#" - - - - Label - com.openframe.updater - ProgramArguments - - {SCRIPT_PATH} - {BINARY_PATH} - {SERVICE_LABEL} - {TARGET_EXE} - {UPDATE_STATE_PATH} - {TARGET_VERSION} - {BOOT_MARKER_PATH} - {LKG_PATH} - {BOOT_MARKER_WAIT_SECS} - {ROLLBACK_ONLY} - - RunAtLoad - - StandardOutPath - {TRANSCRIPT_PATH} - StandardErrorPath - {TRANSCRIPT_PATH} - -"#; - -pub const UPDATE_SCRIPT_MACOS: &str = r#"#!/bin/bash - -BINARY_PATH="$1" -SERVICE_LABEL="$2" -TARGET_EXE="$3" -UPDATE_STATE_PATH="$4" -TARGET_VERSION="$5" -BOOT_MARKER_PATH="$6" -LKG_PATH="$7" -BOOT_MARKER_WAIT_SECS="${8:-90}" -ROLLBACK_ONLY="${9:-0}" - -PLIST_PATH="/Library/LaunchDaemons/${SERVICE_LABEL}.plist" -PREV_PATH="${TARGET_EXE}.prev" - -log() { - echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) $*" -} - -set_update_phase() { - PHASE="$1" - if [ -n "$UPDATE_STATE_PATH" ] && [ -f "$UPDATE_STATE_PATH" ]; then - STATE_TMP="${UPDATE_STATE_PATH}.tmp" - if sed 's/"phase"[[:space:]]*:[[:space:]]*"[^"]*"/"phase": "'"$PHASE"'"/' "$UPDATE_STATE_PATH" > "$STATE_TMP" 2>/dev/null \ - && [ -f "$UPDATE_STATE_PATH" ] \ - && mv "$STATE_TMP" "$UPDATE_STATE_PATH" 2>/dev/null; then - log "Update state phase set to '$PHASE'" - else - rm -f "$STATE_TMP" 2>/dev/null - log "Failed to stamp update phase '$PHASE'" - fi - fi -} - -agent_uninstalled() { - [ "$ROLLBACK_ONLY" != "1" ] && [ -n "$UPDATE_STATE_PATH" ] && [ ! -f "$UPDATE_STATE_PATH" ] -} - -restore_reserve() { - if [ -n "$LKG_PATH" ] && [ -f "$LKG_PATH" ]; then - RESTORE_SOURCE="$LKG_PATH" - log "Restoring from last-known-good reserve: $LKG_PATH" - elif [ -f "$PREV_PATH" ]; then - RESTORE_SOURCE="$PREV_PATH" - log "Restoring from pre-swap copy: $PREV_PATH" - else - log "No reserve available for rollback (checked '$LKG_PATH' and '$PREV_PATH')" - return 1 - fi - rm -f "$TARGET_EXE" 2>/dev/null - cp "$RESTORE_SOURCE" "$TARGET_EXE" || return 1 - chmod 755 "$TARGET_EXE" 2>/dev/null - return 0 -} - -cleanup() { - if [ -n "$BINARY_PATH" ] && [ -f "$BINARY_PATH" ]; then - rm -f "$BINARY_PATH" 2>/dev/null - fi - # launchctl remove may terminate this script - keep it last - UPDATER_PLIST="/tmp/com.openframe.updater.plist" - if [ -f "$UPDATER_PLIST" ]; then - rm -f "$UPDATER_PLIST" 2>/dev/null - launchctl remove "com.openframe.updater" 2>/dev/null - fi -} - -service_loaded() { - launchctl list "$SERVICE_LABEL" >/dev/null 2>&1 -} - -stop_service() { - if service_loaded; then - launchctl unload "$PLIST_PATH" 2>/dev/null - fi - STOP_ELAPSED=0 - while service_loaded && [ $STOP_ELAPSED -lt 30 ]; do - sleep 1 - STOP_ELAPSED=$((STOP_ELAPSED + 1)) - done - if service_loaded; then - return 1 - fi - sleep 2 - return 0 -} - -fail_rollback() { - log "Updater failed: $1" - - if agent_uninstalled; then - log "Update state file is gone (agent uninstalled mid-update) - standing down without touching the service" - cleanup - exit 1 - fi - - stop_service - - RESTORED=0 - for RESTORE_ATTEMPT in 1 2 3; do - if restore_reserve; then - RESTORED=1 - break - fi - log "Restore attempt $RESTORE_ATTEMPT failed" - sleep 2 - done - - if [ $RESTORED -eq 1 ]; then - if launchctl load "$PLIST_PATH" 2>/dev/null; then - RB_ELAPSED=0 - while ! service_loaded && [ $RB_ELAPSED -lt 30 ]; do - sleep 1 - RB_ELAPSED=$((RB_ELAPSED + 1)) - done - if service_loaded; then - set_update_phase "rolled_back" - log "Rollback complete, service restarted" - else - log "Service did not come up after rollback" - fi - else - log "Failed to load service after rollback" - fi - else - log "All restore attempts failed" - launchctl load "$PLIST_PATH" 2>/dev/null - fi - - cleanup - exit 1 -} - -fail_preswap() { - log "Updater failed before the binary swap: $1" - - if agent_uninstalled; then - log "Update state file is gone (agent uninstalled mid-update) - standing down without touching the service" - cleanup - exit 1 - fi - - if ! service_loaded; then - launchctl load "$PLIST_PATH" 2>/dev/null && log "Service restarted with the untouched binary" - fi - cleanup - exit 1 -} - -log "Updater starting: target version '$TARGET_VERSION', target exe '$TARGET_EXE'" - -if [ "$ROLLBACK_ONLY" = "1" ]; then - if [ ! -f "$PLIST_PATH" ]; then - log "Service plist not found: $PLIST_PATH - nothing to roll back" - cleanup - exit 1 - fi - fail_rollback "Rollback-only mode requested" -fi - -# Validate inputs -if [ ! -f "$BINARY_PATH" ]; then - fail_preswap "New binary not found: $BINARY_PATH" -fi -if [ ! -f "$TARGET_EXE" ]; then - fail_preswap "Target executable not found: $TARGET_EXE" -fi - -BINARY_SIZE=$(stat -f%z "$BINARY_PATH" 2>/dev/null || stat -c%s "$BINARY_PATH" 2>/dev/null) -if [ "$BINARY_SIZE" -lt 102400 ]; then - fail_preswap "New binary too small ($BINARY_SIZE bytes), likely corrupted" -fi - -if [ ! -f "$PLIST_PATH" ]; then - fail_preswap "Service plist not found: $PLIST_PATH" -fi - -# Stop the service -if ! stop_service; then - fail_preswap "Service did not stop within 30 seconds" -fi - -if ! mv "$TARGET_EXE" "$PREV_PATH"; then - fail_preswap "Failed to move current binary to $PREV_PATH" -fi - -# Replace binary -if ! cp "$BINARY_PATH" "$TARGET_EXE"; then - fail_rollback "Failed to copy new binary into place" -fi -if ! chmod 755 "$TARGET_EXE"; then - fail_rollback "Failed to set executable permissions" -fi - -if [ -n "$BOOT_MARKER_PATH" ]; then - rm -f "$BOOT_MARKER_PATH" 2>/dev/null -fi - -# Start service -if ! launchctl load "$PLIST_PATH"; then - fail_rollback "Failed to load service" -fi - -sleep 3 -if ! service_loaded; then - fail_rollback "Service failed to start" -fi - -MARKER_OK=0 -if [ -n "$BOOT_MARKER_PATH" ] && [ -n "$TARGET_VERSION" ]; then - ELAPSED=0 - while [ $ELAPSED -lt "$BOOT_MARKER_WAIT_SECS" ]; do - if [ -f "$BOOT_MARKER_PATH" ]; then - MARKER_VERSION=$(cat "$BOOT_MARKER_PATH" 2>/dev/null | tr -d '[:space:]') - if [ "$MARKER_VERSION" = "$TARGET_VERSION" ]; then - MARKER_OK=1 - break - fi - if [ -n "$MARKER_VERSION" ]; then - log "Boot marker reports '$MARKER_VERSION', expected '$TARGET_VERSION' - wrong binary booted" - break - fi - fi - sleep 2 - ELAPSED=$((ELAPSED + 2)) - done -else - log "No boot marker path/target version provided, skipping boot check" - MARKER_OK=1 -fi - -if [ $MARKER_OK -ne 1 ]; then - fail_rollback "New binary did not report target version '$TARGET_VERSION' within $BOOT_MARKER_WAIT_SECS seconds" -fi - -log "Boot marker matched target version '$TARGET_VERSION'" - -set_update_phase "verifying" - -# Cleanup (removes temp binary and updater plist) -cleanup -exit 0 -"#; diff --git a/clients/openframe-client/src/platform/update_scripts/mod.rs b/clients/openframe-client/src/platform/update_scripts/mod.rs deleted file mode 100644 index 8291bef1d..000000000 --- a/clients/openframe-client/src/platform/update_scripts/mod.rs +++ /dev/null @@ -1,15 +0,0 @@ -#[cfg(target_os = "windows")] -pub mod windows; - -#[cfg(target_os = "macos")] -pub mod macos; - -#[cfg(target_os = "windows")] -pub use windows::UPDATE_SCRIPT_WINDOWS; - -#[cfg(target_os = "macos")] -pub use macos::{UPDATER_PLIST_TEMPLATE, UPDATE_SCRIPT_MACOS}; - -#[cfg(test)] -#[path = "mod_tests.rs"] -mod tests; diff --git a/clients/openframe-client/src/platform/update_scripts/mod_tests.rs b/clients/openframe-client/src/platform/update_scripts/mod_tests.rs deleted file mode 100644 index 22c0265ba..000000000 --- a/clients/openframe-client/src/platform/update_scripts/mod_tests.rs +++ /dev/null @@ -1,24 +0,0 @@ -// Windows PowerShell 5.1 reads BOM-less script files as ANSI: a multi-byte -// UTF-8 character can decode into a smart quote (e.g. 0x94 from an em-dash) -// that terminates a string early and structurally breaks the script. -#[cfg(target_os = "windows")] -#[test] -fn windows_update_script_is_ascii() { - assert!( - super::windows::UPDATE_SCRIPT_WINDOWS.is_ascii(), - "UPDATE_SCRIPT_WINDOWS must stay pure ASCII" - ); -} - -#[cfg(target_os = "macos")] -#[test] -fn macos_update_script_is_ascii() { - assert!( - super::macos::UPDATE_SCRIPT_MACOS.is_ascii(), - "UPDATE_SCRIPT_MACOS must stay pure ASCII" - ); - assert!( - super::macos::UPDATER_PLIST_TEMPLATE.is_ascii(), - "UPDATER_PLIST_TEMPLATE must stay pure ASCII" - ); -} diff --git a/clients/openframe-client/src/platform/update_scripts/windows.rs b/clients/openframe-client/src/platform/update_scripts/windows.rs deleted file mode 100644 index f70afc47e..000000000 --- a/clients/openframe-client/src/platform/update_scripts/windows.rs +++ /dev/null @@ -1,285 +0,0 @@ -//! Windows PowerShell update script for self-update functionality - -pub const UPDATE_SCRIPT_WINDOWS: &str = r#" -param( - [string]$ArchivePath, - [string]$ServiceName, - [string]$TargetExe, - [string]$UpdateStatePath, - [string]$TargetVersion, - [string]$BootMarkerPath, - [string]$LkgPath, - [string]$TranscriptPath, - [int]$BootMarkerWaitSecs = 90, - [switch]$RollbackOnly -) - -$ErrorActionPreference = 'Stop' - -if ($TranscriptPath) { - try { Start-Transcript -Path $TranscriptPath -Force | Out-Null } catch { } -} - -$PrevPath = "$TargetExe.prev" -$TempExtract = $null -$SwapReached = $false - -function Set-UpdatePhase { - param([string]$Phase) - if ($UpdateStatePath -and (Test-Path $UpdateStatePath)) { - try { - $stateContent = Get-Content -Path $UpdateStatePath -Raw | ConvertFrom-Json - $stateContent.phase = $Phase - $stateTmp = "$UpdateStatePath.tmp" - $stateContent | ConvertTo-Json -Depth 10 | Set-Content -Path $stateTmp -Force - Move-Item -Path $stateTmp -Destination $UpdateStatePath -Force - Write-Output "Update state phase set to '$Phase'" - } - catch { - Write-Output "Failed to stamp update phase '$Phase': $_" - } - } -} - -function Restore-Reserve { - if ($LkgPath -and (Test-Path $LkgPath)) { - $restoreSource = $LkgPath - Write-Output "Restoring from last-known-good reserve: $LkgPath" - } - elseif (Test-Path $PrevPath) { - $restoreSource = $PrevPath - Write-Output "Restoring from pre-swap copy: $PrevPath" - } - else { - throw "No reserve available for rollback (checked '$LkgPath' and '$PrevPath')" - } - if (Test-Path $TargetExe) { - try { Move-Item -Path $TargetExe -Destination "$TargetExe.bad" -Force -ErrorAction Stop } catch { } - } - Copy-Item -Path $restoreSource -Destination $TargetExe -Force -ErrorAction Stop - Remove-Item -Path "$TargetExe.bad" -Force -ErrorAction SilentlyContinue -} - -function Test-AgentUninstalled { - return (-not $RollbackOnly) -and $UpdateStatePath -and (-not (Test-Path $UpdateStatePath)) -} - -try { - Write-Output "Updater starting: target version '$TargetVersion', target exe '$TargetExe'" - - if ($RollbackOnly) { - $SwapReached = $true - throw "Rollback-only mode requested" - } - - # Validate inputs - if (-not (Test-Path $ArchivePath)) { - throw "Archive file not found: $ArchivePath" - } - if (-not (Test-Path $TargetExe)) { - throw "Target executable not found: $TargetExe" - } - - $archiveSize = (Get-Item $ArchivePath).Length - if ($archiveSize -lt 100KB) { - throw "Archive too small ($archiveSize bytes), likely corrupted" - } - - # Stop the service - $service = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue - if (-not $service) { - throw "Service not found: $ServiceName" - } - - if ($service.Status -ne 'Stopped') { - Stop-Service -Name $ServiceName -Force -ErrorAction Stop - } - - # Wait for service to fully stop - $timeout = 30 - $elapsed = 0 - while ((Get-Service -Name $ServiceName).Status -ne 'Stopped' -and $elapsed -lt $timeout) { - Start-Sleep -Seconds 1 - $elapsed++ - } - - if ($elapsed -ge $timeout) { - throw "Service did not stop within $timeout seconds" - } - - Start-Sleep -Seconds 2 - - # Extract archive - $TempExtract = Join-Path $env:TEMP "openframe-update-$(New-Guid)" - Expand-Archive -Path $ArchivePath -DestinationPath $TempExtract -Force -ErrorAction Stop - - # Find new executable - $NewExe = Get-ChildItem -Path $TempExtract -Filter "*.exe" -Recurse | Select-Object -First 1 - - if (-not $NewExe) { - throw "No executable found in archive" - } - - if ($NewExe.Length -lt 100KB) { - throw "Extracted executable too small, likely corrupted" - } - - # Replace binary - Move-Item -Path $TargetExe -Destination $PrevPath -Force -ErrorAction Stop - $SwapReached = $true - - Copy-Item -Path $NewExe.FullName -Destination $TargetExe -Force -ErrorAction Stop - - if ($BootMarkerPath -and (Test-Path $BootMarkerPath)) { - Remove-Item -Path $BootMarkerPath -Force -ErrorAction Stop - } - - # Start service - Start-Service -Name $ServiceName -ErrorAction Stop - - # Verify service started - Start-Sleep -Seconds 3 - $service = Get-Service -Name $ServiceName -ErrorAction Stop - - if ($service.Status -ne 'Running') { - throw "Service failed to start" - } - - $markerOk = $false - if ($BootMarkerPath -and $TargetVersion) { - $elapsed = 0 - while ($elapsed -lt $BootMarkerWaitSecs) { - if (Test-Path $BootMarkerPath) { - $markerVersion = Get-Content -Path $BootMarkerPath -Raw -ErrorAction SilentlyContinue - if ($markerVersion) { $markerVersion = $markerVersion.Trim() } - if ($markerVersion -eq $TargetVersion) { - $markerOk = $true - break - } - if ($markerVersion) { - Write-Output "Boot marker reports '$markerVersion', expected '$TargetVersion' - wrong binary booted" - break - } - } - Start-Sleep -Seconds 2 - $elapsed += 2 - } - } - else { - Write-Output "No boot marker path/target version provided, skipping boot check" - $markerOk = $true - } - - if (-not $markerOk) { - throw "New binary did not report target version '$TargetVersion' within $BootMarkerWaitSecs seconds" - } - - Write-Output "Boot marker matched target version '$TargetVersion'" - - Set-UpdatePhase -Phase "verifying" - - # Cleanup - Remove-Item -Path $ArchivePath -Force -ErrorAction SilentlyContinue - Remove-Item -Path $TempExtract -Recurse -Force -ErrorAction SilentlyContinue - - exit 0 -} -catch { - Write-Output "Updater failed: $_" - - if (Test-AgentUninstalled) { - Write-Output "Update state file is gone (agent uninstalled mid-update) - standing down without touching the service" - if ($TempExtract -and (Test-Path $TempExtract)) { - Remove-Item -Path $TempExtract -Recurse -Force -ErrorAction SilentlyContinue - } - if ($ArchivePath -and (Test-Path $ArchivePath)) { - Remove-Item -Path $ArchivePath -Force -ErrorAction SilentlyContinue - } - exit 1 - } - - if ($SwapReached) { - try { - $service = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue - if ($service -and $service.Status -ne 'Stopped') { - Stop-Service -Name $ServiceName -Force -ErrorAction SilentlyContinue - $rbStop = 0 - while ((Get-Service -Name $ServiceName).Status -ne 'Stopped' -and $rbStop -lt 30) { - Start-Sleep -Seconds 1 - $rbStop++ - } - Start-Sleep -Seconds 2 - } - - $restored = $false - for ($restoreAttempt = 1; $restoreAttempt -le 3; $restoreAttempt++) { - try { - Restore-Reserve - $restored = $true - break - } - catch { - Write-Output "Restore attempt $restoreAttempt failed: $_" - Start-Sleep -Seconds 2 - } - } - if (-not $restored) { - throw "All restore attempts failed" - } - - Start-Service -Name $ServiceName -ErrorAction Stop - $rbElapsed = 0 - while ((Get-Service -Name $ServiceName).Status -ne 'Running' -and $rbElapsed -lt 30) { - Start-Sleep -Seconds 1 - $rbElapsed++ - } - if ((Get-Service -Name $ServiceName).Status -ne 'Running') { - throw "Service did not reach Running state after rollback" - } - - Set-UpdatePhase -Phase "rolled_back" - Write-Output "Rollback complete, service restarted" - } - catch { - Write-Output "Rollback failed: $_" - try { - if ((Get-Service -Name $ServiceName -ErrorAction Stop).Status -ne 'Running') { - Start-Service -Name $ServiceName -ErrorAction Stop - Write-Output "Service restarted with the binary currently in place" - } - } - catch { - Write-Output "Failed to restart service after failed rollback: $_" - } - } - } - else { - Write-Output "Failure happened before the binary swap, no rollback needed" - try { - $service = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue - if ($service -and $service.Status -ne 'Running') { - Start-Service -Name $ServiceName -ErrorAction Stop - Write-Output "Service restarted with the untouched binary" - } - } - catch { - Write-Output "Failed to restart service after pre-swap failure: $_" - } - } - - # Cleanup temp files even on failure - if ($TempExtract -and (Test-Path $TempExtract)) { - Remove-Item -Path $TempExtract -Recurse -Force -ErrorAction SilentlyContinue - } - if ($ArchivePath -and (Test-Path $ArchivePath)) { - Remove-Item -Path $ArchivePath -Force -ErrorAction SilentlyContinue - } - - exit 1 -} -finally { - if ($TranscriptPath) { - try { Stop-Transcript | Out-Null } catch { } - } -} -"#; diff --git a/clients/openframe-client/src/platform/updater_launcher/.linux.md b/clients/openframe-client/src/platform/updater_launcher/.linux.md deleted file mode 100644 index df1ea0c54..000000000 --- a/clients/openframe-client/src/platform/updater_launcher/.linux.md +++ /dev/null @@ -1,27 +0,0 @@ - -Provides the Linux-specific implementation stub for launching the application updater, currently returning an unimplemented error with a systemd fallback recommendation. - -## Key Components - -- **`launch_updater(params: UpdaterParams) -> Result<()>`** — Async function intended to handle Linux updater launches. Currently a stub that logs an info message and returns an error directing users to use systemd for service restarts. - -## Usage Example - -```rust -use crate::updater::linux::launch_updater; -use crate::updater::UpdaterParams; - -let params = UpdaterParams { /* ... */ }; -match launch_updater(params).await { - Ok(_) => println!("Updater launched"), - Err(e) => eprintln!("Linux updater error: {}", e), - // Output: "Linux updater not yet implemented. Use systemd service restart instead." -} -``` - -## Notes - -- The `_params` argument is prefixed with `_` indicating it is intentionally unused pending full implementation. -- The intended implementation path is via **systemd** (noted in the `TODO` comments). -- See [`flamingo-stack/openframe-oss-lib`](https://github.com/flamingo-stack/openframe-oss-lib/blob/main/src/updater/linux.rs) for the current source. -- Contributions welcome via the [OpenMSP Slack community](https://join.slack.com/t/openmsp/shared_invite/zt-36bl7mx0h-3~U2nFH6nqHqoTPXMaHEHA). \ No newline at end of file diff --git a/clients/openframe-client/src/platform/updater_launcher/.macos.md b/clients/openframe-client/src/platform/updater_launcher/.macos.md deleted file mode 100644 index 293abafa2..000000000 --- a/clients/openframe-client/src/platform/updater_launcher/.macos.md +++ /dev/null @@ -1,38 +0,0 @@ - -Handles macOS-specific update launching by writing a bash updater script to a temp file and registering it as a one-shot `launchd` job, ensuring the update process survives the service being stopped. - -## Key Components - -- **`launch_updater(params: UpdaterParams) -> Result<()>`** — Async function that orchestrates the full macOS update launch sequence: - - Writes `UPDATE_SCRIPT_MACOS` to a UUID-named temp file with `0o755` permissions - - Renders `UPDATER_PLIST_TEMPLATE` by substituting all `UpdaterParams` fields (binary path, service label, target exe, state path, boot marker, LKG path, rollback flag, transcript path, and boot marker wait duration) - - Cleans up any leftover `com.openframe.updater` launchd job from a prior failed update - - Loads the rendered plist via `launchctl load` to register and start the one-shot job - -## Usage Example - -```rust -use crate::updater::macos::launch_updater; -use crate::updater::UpdaterParams; - -let params = UpdaterParams { - binary_path: "/usr/local/bin/openframe-agent".into(), - service_name: "com.openframe.agent".to_string(), - target_exe: "/usr/local/bin/openframe-agent".into(), - update_state_path: "/var/lib/openframe/update.state".to_string(), - target_version: "1.2.3".to_string(), - boot_marker_path: "/var/lib/openframe/boot.marker".into(), - lkg_path: "/var/lib/openframe/lkg".into(), - rollback_only: false, - transcript_path: "/var/log/openframe/update.log".into(), -}; - -launch_updater(params).await?; -``` - -## Notes - -- The launchd plist is written to the system temp directory as `com.openframe.updater.plist` -- The updater script filename is randomized with a UUID to avoid collisions across concurrent runs -- `launchctl remove` failures are intentionally silenced (`let _ = ...`) since the job may not exist -- Errors from `launchctl load` surface the raw stderr output for diagnostics \ No newline at end of file diff --git a/clients/openframe-client/src/platform/updater_launcher/.mod.md b/clients/openframe-client/src/platform/updater_launcher/.mod.md deleted file mode 100644 index 2e81ea1f1..000000000 --- a/clients/openframe-client/src/platform/updater_launcher/.mod.md +++ /dev/null @@ -1,43 +0,0 @@ - -Defines the cross-platform interface for launching the background updater process, providing a shared parameter struct and re-exporting the platform-specific `launch_updater` implementation. - -## Key Components - -### `UpdaterParams` -A struct encapsulating all arguments required to launch the updater process: - -| Field | Type | Description | -|---|---|---| -| `binary_path` | `PathBuf` | Path to the updater binary to execute | -| `target_exe` | `PathBuf` | Path to the application executable being updated | -| `service_name` | `String` | Name of the system service to manage | -| `update_state_path` | `String` | Path to the update state tracking file | -| `target_version` | `String` | Version string the updater should install | -| `boot_marker_path` | `PathBuf` | Path to the boot success marker file | -| `lkg_path` | `PathBuf` | Path to the last-known-good backup | -| `transcript_path` | `PathBuf` | Path for the updater's log/transcript output | -| `rollback_only` | `bool` | When `true`, skips update and performs rollback only | - -### `launch_updater` (re-export) -Platform-specific function re-exported from `windows`, `macos`, or `linux` submodules depending on the compile target. - -## Usage Example - -```rust -use launcher::UpdaterParams; -use std::path::PathBuf; - -let params = UpdaterParams { - binary_path: PathBuf::from("/usr/local/bin/updater"), - target_exe: PathBuf::from("/usr/local/bin/myapp"), - service_name: "myapp-service".to_string(), - update_state_path: "/var/lib/myapp/update_state.json".to_string(), - target_version: "2.1.0".to_string(), - boot_marker_path: PathBuf::from("/var/lib/myapp/boot.marker"), - lkg_path: PathBuf::from("/var/lib/myapp/lkg"), - transcript_path: PathBuf::from("/var/log/myapp/update.log"), - rollback_only: false, -}; - -launch_updater(params)?; -``` \ No newline at end of file diff --git a/clients/openframe-client/src/platform/updater_launcher/.windows.md b/clients/openframe-client/src/platform/updater_launcher/.windows.md deleted file mode 100644 index ca9db4f5a..000000000 --- a/clients/openframe-client/src/platform/updater_launcher/.windows.md +++ /dev/null @@ -1,33 +0,0 @@ - -Launches a detached PowerShell updater process on Windows by writing the update script to a temp file and spawning it with `CREATE_NO_WINDOW`. - -## Key Components - -- **`launch_updater(params: UpdaterParams) -> Result<()>`** — Async function that serializes the embedded PowerShell update script to a UUID-named temp file, resolves the PowerShell executable path, and spawns a detached child process passing all update parameters as named arguments. - -## Notable Implementation Details - -- **UTF-8 BOM prefix (`\u{FEFF}`)** — Prepended to the script file to prevent Windows PowerShell 5.1 from misreading UTF-8 content as ANSI, which could corrupt multi-byte characters and break script parsing. -- **`CREATE_NO_WINDOW` (0x08000000)** — Windows process creation flag applied via `CommandExt` to run the updater without a visible console window. -- **`-ExecutionPolicy Bypass -NoProfile`** — Ensures the script runs regardless of the system's PowerShell execution policy and without loading user profiles. -- **`-RollbackOnly` flag** — Conditionally appended when `params.rollback_only` is true, allowing the same script to handle both upgrade and rollback flows. - -## Usage Example - -```rust -use crate::updater::UpdaterParams; - -let params = UpdaterParams { - binary_path: "/tmp/openframe-v2.zip".into(), - service_name: "openframe".into(), - target_exe: "C:\\Program Files\\OpenFrame\\openframe.exe".into(), - update_state_path: "C:\\ProgramData\\OpenFrame\\update_state.json".into(), - target_version: "2.1.0".into(), - boot_marker_path: "C:\\ProgramData\\OpenFrame\\boot.marker".into(), - lkg_path: "C:\\ProgramData\\OpenFrame\\lkg".into(), - transcript_path: "C:\\ProgramData\\OpenFrame\\update.log".into(), - rollback_only: false, -}; - -launch_updater(params).await?; -``` \ No newline at end of file diff --git a/clients/openframe-client/src/platform/updater_launcher/linux.rs b/clients/openframe-client/src/platform/updater_launcher/linux.rs deleted file mode 100644 index 9127d6327..000000000 --- a/clients/openframe-client/src/platform/updater_launcher/linux.rs +++ /dev/null @@ -1,15 +0,0 @@ -use anyhow::{anyhow, Result}; -use tracing::info; - -use super::UpdaterParams; - -/// Launch updater on Linux -/// TODO: Implement using systemd -pub async fn launch_updater(_params: UpdaterParams) -> Result<()> { - info!("Launching Linux shell updater"); - - // TODO: Implement Linux updater with systemd - Err(anyhow!( - "Linux updater not yet implemented. Use systemd service restart instead." - )) -} diff --git a/clients/openframe-client/src/platform/updater_launcher/macos.rs b/clients/openframe-client/src/platform/updater_launcher/macos.rs deleted file mode 100644 index be99f24ad..000000000 --- a/clients/openframe-client/src/platform/updater_launcher/macos.rs +++ /dev/null @@ -1,92 +0,0 @@ -use anyhow::{anyhow, Context, Result}; -use std::os::unix::fs::PermissionsExt; -use std::process::Command; -use tracing::info; -use uuid::Uuid; - -use super::UpdaterParams; -use crate::config::update_config::BOOT_MARKER_WAIT_SECS; -use crate::platform::update_scripts::{UPDATER_PLIST_TEMPLATE, UPDATE_SCRIPT_MACOS}; - -/// Launch bash updater script on macOS -/// Creates a temporary launchd job to ensure the script survives service stop -pub async fn launch_updater(params: UpdaterParams) -> Result<()> { - info!("Launching macOS bash updater"); - - let script_path = std::env::temp_dir().join(format!("openframe-updater-{}.sh", Uuid::new_v4())); - - tokio::fs::write(&script_path, UPDATE_SCRIPT_MACOS) - .await - .context("Failed to write bash script")?; - - // Make script executable - let mut perms = std::fs::metadata(&script_path)?.permissions(); - perms.set_mode(0o755); - std::fs::set_permissions(&script_path, perms) - .context("Failed to set script executable permissions")?; - - info!("Bash script saved to: {}", script_path.display()); - - info!( - "Launching updater with: binary={}, service={}, target={}, state={}", - params.binary_path.display(), - params.service_name, - params.target_exe.display(), - params.update_state_path - ); - - // Create a temporary plist to run the update script as a one-shot launchd job - // This ensures the script survives when our service is stopped - let plist_path = std::env::temp_dir().join("com.openframe.updater.plist"); - - // Remove any leftover updater job from a previous failed update - let _ = Command::new("launchctl") - .arg("remove") - .arg("com.openframe.updater") - .output(); - - let plist_content = UPDATER_PLIST_TEMPLATE - .replace("{SCRIPT_PATH}", &script_path.to_string_lossy()) - .replace("{BINARY_PATH}", ¶ms.binary_path.to_string_lossy()) - .replace("{SERVICE_LABEL}", ¶ms.service_name) - .replace("{TARGET_EXE}", ¶ms.target_exe.to_string_lossy()) - .replace("{UPDATE_STATE_PATH}", ¶ms.update_state_path) - .replace("{TARGET_VERSION}", ¶ms.target_version) - .replace( - "{BOOT_MARKER_PATH}", - ¶ms.boot_marker_path.to_string_lossy(), - ) - .replace("{LKG_PATH}", ¶ms.lkg_path.to_string_lossy()) - .replace( - "{BOOT_MARKER_WAIT_SECS}", - &BOOT_MARKER_WAIT_SECS.to_string(), - ) - .replace( - "{ROLLBACK_ONLY}", - if params.rollback_only { "1" } else { "0" }, - ) - .replace( - "{TRANSCRIPT_PATH}", - ¶ms.transcript_path.to_string_lossy(), - ); - - std::fs::write(&plist_path, &plist_content).context("Failed to write updater plist")?; - - info!("Updater plist created at: {}", plist_path.display()); - - // Load the plist to start the updater job - let output = Command::new("launchctl") - .arg("load") - .arg(&plist_path) - .output() - .context("Failed to load updater plist")?; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(anyhow!("Failed to load updater plist: {}", stderr)); - } - - info!("macOS bash updater launched via launchd"); - - Ok(()) -} diff --git a/clients/openframe-client/src/platform/updater_launcher/mod.rs b/clients/openframe-client/src/platform/updater_launcher/mod.rs deleted file mode 100644 index 69992242c..000000000 --- a/clients/openframe-client/src/platform/updater_launcher/mod.rs +++ /dev/null @@ -1,29 +0,0 @@ -use std::path::PathBuf; - -/// Parameters needed to launch the updater -pub struct UpdaterParams { - pub binary_path: PathBuf, - pub target_exe: PathBuf, - pub service_name: String, - pub update_state_path: String, - pub target_version: String, - pub boot_marker_path: PathBuf, - pub lkg_path: PathBuf, - pub transcript_path: PathBuf, - pub rollback_only: bool, -} - -#[cfg(windows)] -mod windows; -#[cfg(windows)] -pub use windows::launch_updater; - -#[cfg(target_os = "macos")] -mod macos; -#[cfg(target_os = "macos")] -pub use macos::launch_updater; - -#[cfg(target_os = "linux")] -mod linux; -#[cfg(target_os = "linux")] -pub use linux::launch_updater; diff --git a/clients/openframe-client/src/platform/updater_launcher/windows.rs b/clients/openframe-client/src/platform/updater_launcher/windows.rs deleted file mode 100644 index c361e87fa..000000000 --- a/clients/openframe-client/src/platform/updater_launcher/windows.rs +++ /dev/null @@ -1,68 +0,0 @@ -use anyhow::{anyhow, Context, Result}; -use std::os::windows::process::CommandExt; -use std::process::Command; -use tracing::info; -use uuid::Uuid; - -use super::UpdaterParams; -use crate::config::update_config::BOOT_MARKER_WAIT_SECS; -use crate::platform::get_powershell_path; -use crate::platform::update_scripts::UPDATE_SCRIPT_WINDOWS; - -/// Launch PowerShell updater script on Windows -/// Uses CREATE_NO_WINDOW flag to run detached from console -pub async fn launch_updater(params: UpdaterParams) -> Result<()> { - info!("Launching Windows PowerShell updater"); - - // Save PowerShell script to temp file - let script_path = - std::env::temp_dir().join(format!("openframe-updater-{}.ps1", Uuid::new_v4())); - - // UTF-8 BOM: without it Windows PowerShell 5.1 reads the file as ANSI, and - // any multi-byte character can decode into a smart quote that breaks parsing. - tokio::fs::write(&script_path, format!("\u{FEFF}{}", UPDATE_SCRIPT_WINDOWS)) - .await - .context("Failed to write PowerShell script")?; - - info!("PowerShell script saved to: {}", script_path.display()); - - let ps_path = get_powershell_path().map_err(|e| anyhow!(e))?; - info!("Using PowerShell: {}", ps_path); - - let mut command = Command::new(&ps_path); - command - .arg("-ExecutionPolicy") - .arg("Bypass") - .arg("-NoProfile") - .arg("-File") - .arg(&script_path) - .arg("-ArchivePath") - .arg(¶ms.binary_path) - .arg("-ServiceName") - .arg(¶ms.service_name) - .arg("-TargetExe") - .arg(¶ms.target_exe) - .arg("-UpdateStatePath") - .arg(¶ms.update_state_path) - .arg("-TargetVersion") - .arg(¶ms.target_version) - .arg("-BootMarkerPath") - .arg(¶ms.boot_marker_path) - .arg("-LkgPath") - .arg(¶ms.lkg_path) - .arg("-TranscriptPath") - .arg(¶ms.transcript_path) - .arg("-BootMarkerWaitSecs") - .arg(BOOT_MARKER_WAIT_SECS.to_string()) - .creation_flags(0x08000000); // CREATE_NO_WINDOW - if params.rollback_only { - command.arg("-RollbackOnly"); - } - let child = command - .spawn() - .context("Failed to spawn PowerShell updater")?; - - info!("PowerShell updater launched (PID: {})", child.id()); - - Ok(()) -} diff --git a/clients/openframe-client/src/services/.openframe_client_update_service.md b/clients/openframe-client/src/services/.openframe_client_update_service.md deleted file mode 100644 index 6d00e11b1..000000000 --- a/clients/openframe-client/src/services/.openframe_client_update_service.md +++ /dev/null @@ -1,48 +0,0 @@ - -Orchestrates the full lifecycle of an OpenFrame client self-update: version validation, downgrade protection, binary download/extraction, archive staging, and updater process launch. - -## Key Components - -- **`OpenFrameClientUpdateService`** — Main service struct; holds injected sub-services and an `Arc>` guard to prevent concurrent updates. -- **`process_update(message)`** — Public entry point. Defers if a tool operation is in progress, acquires the update lock, delegates to `process_update_internal`, and always releases the lock. -- **`process_update_internal(message)`** — Validates semver format, enforces no-downgrade policy against the last-known-good anchor, persists `UpdateState`, sets `ClientUpdateStatus::Updating`, and calls `execute_update`. -- **`execute_update(message, update_state)`** — Resolves an OS-specific download config, downloads and extracts the binary, stages a temp ZIP archive, builds `UpdaterParams`, checks for late-arriving tool operations, and calls `updater_launcher::launch_updater`. - -## Usage Example - -```rust -let svc = OpenFrameClientUpdateService::new( - client_info_service, - github_download_service, - update_state_service, - last_known_good_service, - tool_run_manager, -); - -let message = OpenFrameClientUpdateMessage { - version: "1.4.2".to_string(), - download_configurations: vec![/* OS-specific configs */], -}; - -svc.process_update(message).await?; -``` - -## Update Phase Sequence - -```mermaid -graph TD - A[process_update] --> B{Tool op in progress?} - B -- Yes --> C[Defer / NATS redeliver] - B -- No --> D[Acquire update lock] - D --> E{Version already running?} - E -- Yes --> F[No-op] - E -- No --> G{Downgrade blocked?} - G -- Yes --> F - G -- No --> H[Downloading] - H --> I[Extracting] - I --> J[PreparingUpdater] - J --> K{Late tool op?} - K -- Yes --> C - K -- No --> L[UpdaterLaunched] - L --> M[updater_launcher::launch_updater] -``` \ No newline at end of file diff --git a/clients/openframe-client/src/services/.update_cleanup_service.md b/clients/openframe-client/src/services/.update_cleanup_service.md deleted file mode 100644 index e15a5c60b..000000000 --- a/clients/openframe-client/src/services/.update_cleanup_service.md +++ /dev/null @@ -1,38 +0,0 @@ - -Handles post-update housekeeping by removing stale backup binaries and temporary update artifacts left behind by the OpenFrame self-update process. - -## Key Components - -### `UpdateCleanupService` - -A cloneable service struct that holds the current executable path and exposes cleanup operations. - -| Method | Description | -|---|---| -| `new() -> Result` | Constructs the service, resolving the current executable path via `std::env::current_exe()` | -| `cleanup_all()` | Orchestrates all cleanup tasks; logs total files removed | -| `cleanup_all_old_backups()` | Scans the executable's directory for files matching `.backup.*` and deletes them | -| `cleanup_temp_update_leftovers()` | Scans the system temp directory for `openframe-update-*` directories and `openframe-updater-*.ps1` / `openframe-updater-*.sh` scripts older than `TEMP_LEFTOVER_MIN_AGE_SECS` | - -### Age Guard - -Temp artifacts newer than `crate::config::update_config::TEMP_LEFTOVER_MIN_AGE_SECS` are intentionally skipped to avoid racing with an in-progress update. - -## Usage Example - -```rust -use crate::services::update_cleanup_service::UpdateCleanupService; - -#[tokio::main] -async fn main() -> anyhow::Result<()> { - // Instantiate once at startup or post-update - let cleanup = UpdateCleanupService::new()?; - - // Run all cleanup tasks; errors are logged as warnings, not propagated - cleanup.cleanup_all().await; - - Ok(()) -} -``` - -> `cleanup_all()` is intentionally infallible — individual failures are emitted as `warn!` traces so a failed file removal never aborts the application startup flow. \ No newline at end of file diff --git a/clients/openframe-client/src/services/.update_handler_service.md b/clients/openframe-client/src/services/.update_handler_service.md deleted file mode 100644 index 069fd5ad9..000000000 --- a/clients/openframe-client/src/services/.update_handler_service.md +++ /dev/null @@ -1,48 +0,0 @@ - -Orchestrates the full lifecycle of an OpenFrame agent update — from tracking boot attempts and detecting crash loops, to confirming success or rolling back on failure. - -## Key Components - -### `UpdateHandlerService` - -The central service struct, cloneable, composing six injected dependencies: - -| Field | Role | -|---|---| -| `state_service` | Loads/saves/clears persistent `UpdateState` | -| `client_info_service` | Updates version bookkeeping and `ClientUpdateStatus` | -| `cleanup_service` | Removes update artifacts after resolution | -| `last_known_good_service` | Manages the LKG anchor, boot marker, and transcripts | -| `installed_agent_publisher` | Publishes NATS notifications post-update | -| `config_service` | Supplies the machine ID for NATS messages | - -### Public Methods - -- **`record_boot_attempt`** — Called on each service boot. Increments `boot_attempts` in state. If the count reaches `CRASH_LOOP_MAX_BOOT_ATTEMPTS`, marks the update failed and launches a reserve rollback (Windows/macOS only). -- **`handle_pending_update`** — Compares the running binary version (`OPENFRAME_VERSION`) against the target. Routes to `handle_success` on match, or `handle_failure` for unrecoverable phases. - -### Private Methods - -- **`handle_success`** — Promotes the LKG anchor, updates version bookkeeping, sets status to `Updated`, fires a NATS notification, prunes transcripts, and clears state. -- **`handle_failure`** — Sets status to `Failed`, runs cleanup, prunes transcripts, and clears state (the updater script owns binary restoration). -- **`launch_reserve_rollback`** — Assembles `UpdaterParams` pointing at the LKG reserve path and launches the updater in rollback-only mode. -- **`send_nats_notification`** — Retries NATS publish up to 5 times with 1-second back-off. - -## Usage Example - -```rust -let handler = UpdateHandlerService::new( - state_service, - client_info_service, - cleanup_service, - last_known_good_service, - installed_agent_publisher, - config_service, -); - -// On every service boot — crash-loop guard -handler.record_boot_attempt().await?; - -// After initialization — resolve any in-flight update -handler.handle_pending_update().await?; -``` \ No newline at end of file diff --git a/clients/openframe-client/src/services/.update_state_service.md b/clients/openframe-client/src/services/.update_state_service.md deleted file mode 100644 index b0a3dd270..000000000 --- a/clients/openframe-client/src/services/.update_state_service.md +++ /dev/null @@ -1,42 +0,0 @@ - -Manages persistence of update state to disk, providing atomic read/write/clear operations for tracking in-progress software updates across restarts. - -## Key Components - -**`UpdateStateService`** — Cloneable service struct that stores the path to `update_state.json` within the secured directory. - -| Method | Description | -|---|---| -| `new(directory_manager)` | Constructs the service, resolves the state file path, and ensures the secured directory exists | -| `load()` | Deserializes `UpdateState` from JSON; returns `None` if no file exists | -| `save(state)` | Atomically writes state via a `.json.tmp` temp file then renames into place | -| `clear()` | Removes the state file if present | -| `has_incomplete_update()` | Returns `true` if a state file exists (indicating a recovery is needed) | -| `get_state_file_path()` | Returns the resolved state file path as a `String` | - -## Usage Example - -```rust -use crate::services::update_state_service::UpdateStateService; -use crate::platform::directories::DirectoryManager; - -let dir_manager = DirectoryManager::new()?; -let service = UpdateStateService::new(dir_manager)?; - -// Persist update state before a risky operation -service.save(&update_state).await?; - -// On next startup, check if a prior update needs recovery -if service.has_incomplete_update().await? { - let state = service.load().await?.unwrap(); - // resume from state.phase ... -} - -// Clear state once the update completes successfully -service.clear().await?; -``` - -## Notes - -- **Atomic writes**: `save` writes to a `.json.tmp` sibling file before renaming, preventing partial writes from corrupting state on crash. -- State is stored under the `secured_dir` managed by [`DirectoryManager`](https://github.com/flamingo-stack/openframe-oss-lib/blob/main/src/platform/directories.rs). \ No newline at end of file diff --git a/clients/openframe-client/src/services/last_known_good_service.rs b/clients/openframe-client/src/services/last_known_good_service.rs index c5b409a93..ed6de1a1a 100644 --- a/clients/openframe-client/src/services/last_known_good_service.rs +++ b/clients/openframe-client/src/services/last_known_good_service.rs @@ -2,7 +2,7 @@ use crate::platform::directories::DirectoryManager; use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; use std::fs; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; use tracing::{debug, info, warn}; #[derive(Debug, Serialize, Deserialize)] @@ -14,7 +14,6 @@ struct LastKnownGood { pub struct LastKnownGoodService { anchor_file_path: PathBuf, boot_marker_path: PathBuf, - logs_dir: PathBuf, current_exe: PathBuf, reserve_path: PathBuf, } @@ -23,7 +22,6 @@ impl LastKnownGoodService { pub fn new(directory_manager: DirectoryManager) -> Result { let anchor_file_path = directory_manager.secured_dir().join("last_known_good.json"); let boot_marker_path = directory_manager.secured_dir().join("boot.marker"); - let logs_dir = directory_manager.logs_dir().to_path_buf(); directory_manager .ensure_directories() @@ -39,7 +37,6 @@ impl LastKnownGoodService { Ok(Self { anchor_file_path, boot_marker_path, - logs_dir, current_exe, reserve_path, }) @@ -150,14 +147,6 @@ impl LastKnownGoodService { } } - pub fn reserve_path(&self) -> &Path { - &self.reserve_path - } - - pub fn boot_marker_path(&self) -> &Path { - &self.boot_marker_path - } - pub async fn write_boot_marker(&self) -> Result<()> { let temp_path = self.boot_marker_path.with_extension("marker.tmp"); fs::write(&temp_path, env!("OPENFRAME_VERSION")) @@ -171,52 +160,4 @@ impl LastKnownGoodService { debug!("Boot marker written: {}", self.boot_marker_path.display()); Ok(()) } - - pub fn new_transcript_path(&self, target_version: &str) -> PathBuf { - self.logs_dir.join(format!( - "updater-{}-{}.log", - target_version, - chrono::Utc::now().format("%Y%m%d%H%M%S") - )) - } - - pub async fn prune_transcripts(&self, keep: usize) { - let entries = match fs::read_dir(&self.logs_dir) { - Ok(entries) => entries, - Err(e) => { - warn!("Failed to read logs dir for transcript pruning: {}", e); - return; - } - }; - - let mut transcripts: Vec<(std::time::SystemTime, PathBuf)> = entries - .filter_map(|e| e.ok()) - .filter(|e| { - let name = e.file_name().to_string_lossy().to_string(); - name.starts_with("updater-") && name.ends_with(".log") - }) - .filter_map(|e| { - e.metadata() - .ok() - .and_then(|m| m.modified().ok()) - .map(|modified| (modified, e.path())) - }) - .collect(); - - if transcripts.len() <= keep { - return; - } - - transcripts.sort_by_key(|t| std::cmp::Reverse(t.0)); // newest first - for (_, path) in transcripts.into_iter().skip(keep) { - match fs::remove_file(&path) { - Ok(_) => info!("Removed old updater transcript: {}", path.display()), - Err(e) => warn!( - "Failed to remove old updater transcript {}: {}", - path.display(), - e - ), - } - } - } } diff --git a/clients/openframe-client/src/services/mod.rs b/clients/openframe-client/src/services/mod.rs index 0ceb8a7c6..ee9bc9fff 100644 --- a/clients/openframe-client/src/services/mod.rs +++ b/clients/openframe-client/src/services/mod.rs @@ -20,7 +20,6 @@ pub mod mesh_self_heal_service; pub mod nats_connection_manager; pub mod nats_message_publisher; pub mod openframe_client_info_service; -pub mod openframe_client_update_service; pub mod registration_processor; pub mod result_outbox_run_manager; pub mod result_store; @@ -37,9 +36,6 @@ pub mod tool_restart_service; pub mod tool_run_manager; pub mod tool_uninstall_service; pub mod tool_url_params_resolver; -pub mod update_cleanup_service; -pub mod update_handler_service; -pub mod update_state_service; pub use agent_auth_service::AgentAuthService; pub use agent_configuration_service::AgentConfigurationService; @@ -59,7 +55,6 @@ pub use machine_heartbeat_run_manager::MachineHeartbeatRunManager; pub use nats_connection_manager::NatsConnectionManager; pub use nats_message_publisher::NatsMessagePublisher; pub use openframe_client_info_service::OpenFrameClientInfoService; -pub use openframe_client_update_service::OpenFrameClientUpdateService; pub use shared_token_service::SharedTokenService; pub use token_refresh_run_manager::TokenRefreshRunManager; pub use tool_agent_update_service::ToolAgentUpdateService; @@ -73,6 +68,3 @@ pub use tool_restart_service::{RestartOutcome, ToolRestartService}; pub use tool_run_manager::ToolRunManager; pub use tool_uninstall_service::{ToolUninstallService, UninstallOutcome}; pub use tool_url_params_resolver::ToolUrlParamsResolver; -pub use update_cleanup_service::UpdateCleanupService; -pub use update_handler_service::UpdateHandlerService; -pub use update_state_service::UpdateStateService; diff --git a/clients/openframe-client/src/services/openframe_client_update_service.rs b/clients/openframe-client/src/services/openframe_client_update_service.rs deleted file mode 100644 index 1bb4ad66f..000000000 --- a/clients/openframe-client/src/services/openframe_client_update_service.rs +++ /dev/null @@ -1,392 +0,0 @@ -use crate::config::update_config::ALLOW_DOWNGRADE; -use crate::models::openframe_client_info::ClientUpdateStatus; -use crate::models::openframe_client_update_message::OpenFrameClientUpdateMessage; -use crate::models::update_state::{UpdatePhase, UpdateState}; -use crate::platform::updater_launcher::{self, UpdaterParams}; -use crate::service::FULL_SERVICE_NAME; -use crate::services::github_download_service::GithubDownloadService; -use crate::services::last_known_good_service::LastKnownGoodService; -use crate::services::openframe_client_info_service::OpenFrameClientInfoService; -use crate::services::tool_run_manager::ToolRunManager; -use crate::services::update_state_service::UpdateStateService; -use anyhow::{anyhow, Context, Result}; -use semver::Version; -use std::path::PathBuf; -use std::sync::Arc; -use tokio::sync::Mutex; -use tracing::{error, info, warn}; -use uuid::Uuid; - -#[derive(Clone)] -pub struct OpenFrameClientUpdateService { - client_info_service: OpenFrameClientInfoService, - github_download_service: GithubDownloadService, - update_state_service: UpdateStateService, - last_known_good_service: LastKnownGoodService, - tool_run_manager: ToolRunManager, - /// Mutex to prevent concurrent updates (race condition protection) - update_in_progress: Arc>, -} - -impl OpenFrameClientUpdateService { - pub fn new( - client_info_service: OpenFrameClientInfoService, - github_download_service: GithubDownloadService, - update_state_service: UpdateStateService, - last_known_good_service: LastKnownGoodService, - tool_run_manager: ToolRunManager, - ) -> Self { - Self { - client_info_service, - github_download_service, - update_state_service, - last_known_good_service, - tool_run_manager, - update_in_progress: Arc::new(Mutex::new(false)), - } - } - - pub async fn process_update(&self, message: OpenFrameClientUpdateMessage) -> Result<()> { - let requested_version = message.version.trim(); - info!("Received update request for version: {}", requested_version); - - self.tool_run_manager.mark_client_update_pending().await; - - if self.tool_run_manager.any_tool_op_in_progress().await { - warn!("Tool operation in progress, deferring client update to version {} (will redeliver)", requested_version); - return Err(anyhow!( - "Tool operation in progress, deferring client update" - )); - } - - // 1. Check if update is already in progress (race condition protection) - { - let mut update_lock = self.update_in_progress.lock().await; - if *update_lock { - warn!( - "Update already in progress, ignoring duplicate request for version: {}", - requested_version - ); - return Err(anyhow!("Update already in progress")); - } - // Set flag to indicate update is starting - *update_lock = true; - info!("Acquired update lock for version: {}", requested_version); - } - - // Ensure lock is released on error or completion - let update_result = self.process_update_internal(message).await; - - // Release lock - { - let mut update_lock = self.update_in_progress.lock().await; - *update_lock = false; - info!("Released update lock"); - } - - update_result - } - - /// Internal update processing with version validation and safety checks - async fn process_update_internal(&self, message: OpenFrameClientUpdateMessage) -> Result<()> { - let requested_version = message.version.trim(); - - // 2. Validate version format - if !Self::is_valid_version(requested_version) { - error!("Invalid version format: {}", requested_version); - return Err(anyhow!("Invalid version format: {}", requested_version)); - } - - let requested_semver = Self::parse_version(requested_version) - .with_context(|| format!("Failed to parse requested version: {}", requested_version))?; - let canonical_version = requested_semver.to_string(); - - if canonical_version == env!("OPENFRAME_VERSION") { - info!( - "Already running version {}, ignoring update request", - canonical_version - ); - self.tool_run_manager.clear_client_update_pending().await; - return Ok(()); - } - - if !ALLOW_DOWNGRADE { - let anchor = match self.last_known_good_service.load().await { - Ok(anchor) => anchor, - Err(e) => { - warn!( - "Failed to load last-known-good anchor, skipping downgrade guard: {:#}", - e - ); - None - } - }; - if let Some(anchor) = anchor { - match Self::parse_version(&anchor) { - Ok(anchor_semver) if requested_semver < anchor_semver => { - warn!( - "refusing downgrade to {} — anchored at {}", - requested_version, anchor - ); - self.tool_run_manager.clear_client_update_pending().await; - return Ok(()); - } - Ok(_) => {} - Err(e) => warn!( - "Failed to parse last-known-good anchor '{}': {:#}", - anchor, e - ), - } - } - } - - // 4. Log current version for informational purposes - let client_info = self - .client_info_service - .get() - .await - .context("Failed to get current client info")?; - - if !client_info.current_version.is_empty() { - info!( - "Updating from version {} to {}", - client_info.current_version, requested_version - ); - } else { - info!( - "No current version set, installing version: {}", - requested_version - ); - } - - // 5. Create update state for tracking - let mut update_state = UpdateState::new(canonical_version.clone()); - self.update_state_service - .save(&update_state) - .await - .context("Failed to save initial update state")?; - - // 6. Set update status to updating - self.client_info_service - .set_update_status( - ClientUpdateStatus::Updating, - Some(canonical_version.clone()), - ) - .await - .context("Failed to set update status")?; - - info!("Starting update to version {}", requested_version); - - // Execute update with status rollback - let update_result = self.execute_update(&message, &mut update_state).await; - - // Handle errors: set status to Failed (cleanup already done in execute_update) - if let Err(ref e) = update_result { - error!("Update failed: {:#}", e); - - // Set status to Failed - if let Err(status_err) = self - .client_info_service - .set_update_status(ClientUpdateStatus::Failed, Some(canonical_version.clone())) - .await - { - error!("Failed to set update status to Failed: {:#}", status_err); - } - - info!("Update failed, NATS will retry"); - } - - update_result - } - - /// Execute the actual update process - async fn execute_update( - &self, - message: &OpenFrameClientUpdateMessage, - update_state: &mut UpdateState, - ) -> Result<()> { - // 1. Find the appropriate download configuration for current OS - let download_config = self - .github_download_service - .find_config_for_current_os(&message.download_configurations) - .context("Failed to find download configuration for current OS")?; - - info!( - "Using download configuration for OS: {}", - download_config.os - ); - - // 2. Download and extract binary using GithubDownloadService - update_state.set_phase(UpdatePhase::Downloading); - self.update_state_service.save(update_state).await?; - - let binary_bytes = match self - .github_download_service - .download_and_extract(download_config) - .await - { - Ok(bytes) => bytes, - Err(e) => { - error!("Download failed: {:#}", e); - // Clear update state - download failed, nothing to cleanup - self.update_state_service.clear().await?; - return Err(e.context("Failed to download and extract update")); - } - }; - - info!( - "Binary downloaded and extracted ({} bytes)", - binary_bytes.len() - ); - - self.tool_run_manager.mark_client_update_pending().await; - - // 3. Extract binary - update_state.set_phase(UpdatePhase::Extracting); - self.update_state_service.save(update_state).await?; - - // 4. Save binary to a temp archive for the updater - // Note: The updater expects a ZIP, so we create one with the binary - update_state.set_phase(UpdatePhase::PreparingUpdater); - self.update_state_service.save(update_state).await?; - - let archive_path = match self - .create_temp_archive(&binary_bytes, &download_config.target_file_name) - .await - { - Ok(path) => path, - Err(e) => { - error!("Failed to create archive: {:#}", e); - // Clear update state - archive creation failed - self.update_state_service.clear().await?; - return Err(e.context("Failed to create temporary archive")); - } - }; - - info!("Temporary archive created: {}", archive_path.display()); - - // 5. Launch update process (platform-specific) - update_state.set_phase(UpdatePhase::UpdaterLaunched); - self.update_state_service.save(update_state).await?; - - let current_exe = - std::env::current_exe().context("Failed to get current executable path")?; - - let params = UpdaterParams { - binary_path: archive_path.clone(), - target_exe: current_exe, - service_name: FULL_SERVICE_NAME.to_string(), - update_state_path: self.update_state_service.get_state_file_path(), - target_version: update_state.target_version.clone(), - boot_marker_path: self - .last_known_good_service - .boot_marker_path() - .to_path_buf(), - lkg_path: self.last_known_good_service.reserve_path().to_path_buf(), - transcript_path: self - .last_known_good_service - .new_transcript_path(&update_state.target_version), - rollback_only: false, - }; - - if self.tool_run_manager.any_tool_op_in_progress().await { - warn!("Tool operation started during client download, deferring client update (will redeliver)"); - if let Err(cleanup_err) = std::fs::remove_file(&archive_path) { - warn!("Failed to remove archive after deferring: {}", cleanup_err); - } - self.update_state_service.clear().await?; - return Err(anyhow!( - "Tool operation started during download, deferring client update" - )); - } - - let launch_result = updater_launcher::launch_updater(params).await; - - // If launch failed, cleanup archive and state - if let Err(e) = launch_result { - error!("Failed to launch updater: {:#}", e); - // Cleanup archive - if let Err(cleanup_err) = std::fs::remove_file(&archive_path) { - warn!( - "Failed to remove archive after launch failure: {}", - cleanup_err - ); - } - // Clear update state - self.update_state_service.clear().await?; - return Err(e); - } - - // Stop all tool run loops to prevent launching processes during shutdown. - self.tool_run_manager.signal_shutdown(); - - info!("Update process launched, service will be stopped by update script"); - Ok(()) - } - - /// Creates a temporary ZIP archive containing the binary for the updater script - #[cfg(windows)] - async fn create_temp_archive(&self, binary_bytes: &[u8], binary_name: &str) -> Result { - use std::io::Write; - use zip::write::{FileOptions, ZipWriter}; - - let temp_dir = std::env::temp_dir(); - let archive_path = temp_dir.join(format!("openframe-update-{}.zip", Uuid::new_v4())); - - let file = - std::fs::File::create(&archive_path).context("Failed to create temporary ZIP file")?; - - let mut zip = ZipWriter::new(file); - let options = FileOptions::default().compression_method(zip::CompressionMethod::Deflated); - - zip.start_file(binary_name, options) - .context("Failed to start file in ZIP")?; - - zip.write_all(binary_bytes) - .context("Failed to write binary to ZIP")?; - - zip.finish().context("Failed to finalize ZIP archive")?; - - Ok(archive_path) - } - - /// On Unix, we can directly write the binary (no ZIP needed) - #[cfg(unix)] - async fn create_temp_archive(&self, binary_bytes: &[u8], binary_name: &str) -> Result { - let temp_dir = std::env::temp_dir(); - let binary_path = temp_dir.join(format!( - "openframe-update-{}-{}", - Uuid::new_v4(), - binary_name - )); - - tokio::fs::write(&binary_path, binary_bytes) - .await - .context("Failed to write binary file")?; - - Ok(binary_path) - } - - /// Parse version string into semver Version - /// Supports formats like: "1.2.3", "v1.2.3", "1.2.3-beta", "1.2.3+build" - fn parse_version(version: &str) -> Result { - // Remove 'v' prefix if present - let version = version.trim().trim_start_matches('v'); - - Version::parse(version).with_context(|| format!("Failed to parse version: {}", version)) - } - - /// Validate version format (basic semver check) - fn is_valid_version(version: &str) -> bool { - !version.is_empty() - && version - .chars() - .next() - .map(|c| c.is_ascii_digit() || c == 'v') - .unwrap_or(false) - && version - .trim_start_matches('v') - .chars() - .all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '+') - } -} diff --git a/clients/openframe-client/src/services/tool_agent_update_service.rs b/clients/openframe-client/src/services/tool_agent_update_service.rs index 4eb08631f..e28734db8 100644 --- a/clients/openframe-client/src/services/tool_agent_update_service.rs +++ b/clients/openframe-client/src/services/tool_agent_update_service.rs @@ -2,8 +2,9 @@ use crate::clients::tool_agent_file_client::ToolAgentFileClient; use crate::models::tool_agent_update_message::{AssetUpdate, ToolAgentUpdateMessage}; use crate::models::{Installation, InstalledAsset, ToolRecordState}; use crate::platform::{ - binary_writer, clear_aside_binary, detect_actual_installation, needs_migration, run_migration, - run_update, DirectoryManager, ToolUpdaterDeps, + binary_writer, clear_aside_binary, detect_actual_installation, in_flight_client_update_phase, + needs_migration, run_migration, run_update, DirectoryManager, ToolUpdaterDeps, + UPDATER_TOOL_AGENT_ID, }; use crate::services::agent_configuration_service::AgentConfigurationService; use crate::services::tool_run_manager::ToolRunManager; @@ -12,7 +13,7 @@ use crate::services::InstalledAgentMessagePublisher; use crate::services::InstalledToolsService; use crate::services::ToolCommandParamsResolver; use crate::services::ToolKillService; -use anyhow::{Context, Result}; +use anyhow::{bail, Context, Result}; use tracing::{error, info, warn}; #[derive(Clone)] @@ -69,6 +70,12 @@ impl ToolAgentUpdateService { tool_agent_id, new_version ); + if tool_agent_id == UPDATER_TOOL_AGENT_ID { + if let Some(phase) = in_flight_client_update_phase() { + bail!("client update in flight (updater phase: {phase}) — deferring updater self-update to redelivery"); + } + } + // Check if tool is installed let mut installed_tool = match self .installed_tools_service diff --git a/clients/openframe-client/src/services/tool_run_manager.rs b/clients/openframe-client/src/services/tool_run_manager.rs index 6ac77848b..1e890275c 100644 --- a/clients/openframe-client/src/services/tool_run_manager.rs +++ b/clients/openframe-client/src/services/tool_run_manager.rs @@ -393,25 +393,6 @@ pub(crate) fn launch_process_in_target_session( } } -#[derive(Clone, Default)] -pub(crate) struct ClientUpdatePendingFlag { - since: Arc>>, -} - -impl ClientUpdatePendingFlag { - pub(crate) async fn mark(&self) { - *self.since.write().await = Some(std::time::Instant::now()); - } - - pub(crate) async fn is_pending(&self, ttl: Duration) -> bool { - matches!(*self.since.read().await, Some(since) if since.elapsed() < ttl) - } - - pub(crate) async fn clear(&self) { - *self.since.write().await = None; - } -} - #[derive(Clone)] pub struct ToolRunManager { installed_tools_service: InstalledToolsService, @@ -421,7 +402,6 @@ pub struct ToolRunManager { updating_tools: Arc>>, tool_locks: Arc>>>>, shutting_down: Arc, - client_update_pending: ClientUpdatePendingFlag, } impl ToolRunManager { @@ -438,7 +418,6 @@ impl ToolRunManager { updating_tools: Arc::new(RwLock::new(HashMap::new())), tool_locks: Arc::new(RwLock::new(HashMap::new())), shutting_down: Arc::new(AtomicBool::new(false)), - client_update_pending: ClientUpdatePendingFlag::default(), } } @@ -450,7 +429,6 @@ impl ToolRunManager { } /// Signal all run loops to stop launching new processes. - /// Called by self-update before the updater kills the service. pub fn signal_shutdown(&self) { self.shutting_down.store(true, Ordering::Release); info!("Tool run manager: shutdown signalled, no new launches will occur"); @@ -507,24 +485,6 @@ impl ToolRunManager { self.run().await } - pub async fn mark_client_update_pending(&self) { - self.client_update_pending.mark().await; - info!("Client update pending: new tool operations will be parked"); - } - - pub async fn is_client_update_pending(&self) -> bool { - self.client_update_pending - .is_pending(Duration::from_secs( - crate::config::update_config::CLIENT_UPDATE_PENDING_TTL_SECS, - )) - .await - } - - pub async fn clear_client_update_pending(&self) { - self.client_update_pending.clear().await; - info!("Client update no longer pending: parked tool operations released"); - } - pub async fn mark_updating(&self, tool_id: &str) { let mut map = self.updating_tools.write().await; let count = map.entry(tool_id.to_string()).or_insert(0); @@ -985,7 +945,3 @@ impl ToolRunManager { Ok(()) } } - -#[cfg(test)] -#[path = "tool_run_manager_tests.rs"] -mod tests; diff --git a/clients/openframe-client/src/services/tool_run_manager_tests.rs b/clients/openframe-client/src/services/tool_run_manager_tests.rs deleted file mode 100644 index 116fb1a60..000000000 --- a/clients/openframe-client/src/services/tool_run_manager_tests.rs +++ /dev/null @@ -1,51 +0,0 @@ -use super::ClientUpdatePendingFlag; -use std::time::Duration; - -const LONG_TTL: Duration = Duration::from_secs(3600); - -#[tokio::test] -async fn not_pending_before_first_mark() { - let flag = ClientUpdatePendingFlag::default(); - assert!(!flag.is_pending(LONG_TTL).await); -} - -#[tokio::test] -async fn pending_after_mark_within_ttl() { - let flag = ClientUpdatePendingFlag::default(); - flag.mark().await; - assert!(flag.is_pending(LONG_TTL).await); -} - -#[tokio::test] -async fn expired_when_ttl_elapsed() { - let flag = ClientUpdatePendingFlag::default(); - flag.mark().await; - assert!(!flag.is_pending(Duration::ZERO).await); -} - -#[tokio::test] -async fn remark_refreshes_the_ttl() { - let flag = ClientUpdatePendingFlag::default(); - flag.mark().await; - tokio::time::sleep(Duration::from_millis(30)).await; - assert!(!flag.is_pending(Duration::from_millis(10)).await); - flag.mark().await; - assert!(flag.is_pending(Duration::from_millis(10)).await); -} - -#[tokio::test] -async fn clones_share_state() { - let flag = ClientUpdatePendingFlag::default(); - let clone = flag.clone(); - clone.mark().await; - assert!(flag.is_pending(LONG_TTL).await); -} - -#[tokio::test] -async fn clear_releases_the_flag() { - let flag = ClientUpdatePendingFlag::default(); - flag.mark().await; - assert!(flag.is_pending(LONG_TTL).await); - flag.clear().await; - assert!(!flag.is_pending(LONG_TTL).await); -} diff --git a/clients/openframe-client/src/services/update_cleanup_service.rs b/clients/openframe-client/src/services/update_cleanup_service.rs deleted file mode 100644 index 424d61db3..000000000 --- a/clients/openframe-client/src/services/update_cleanup_service.rs +++ /dev/null @@ -1,118 +0,0 @@ -use anyhow::{Context, Result}; -use std::fs; -use std::path::PathBuf; -use tracing::{info, warn}; - -#[derive(Clone)] -pub struct UpdateCleanupService { - exe_path: PathBuf, -} - -impl UpdateCleanupService { - pub fn new() -> Result { - let exe_path = std::env::current_exe().context("Failed to get current executable path")?; - - Ok(Self { exe_path }) - } - - pub async fn cleanup_all(&self) { - let mut cleaned = 0; - - match self.cleanup_all_old_backups().await { - Ok(count) => cleaned += count, - Err(e) => warn!("Failed to cleanup old backups: {:#}", e), - } - - match self.cleanup_temp_update_leftovers().await { - Ok(count) => cleaned += count, - Err(e) => warn!("Failed to cleanup temp update leftovers: {:#}", e), - } - - if cleaned > 0 { - info!("Cleanup: removed {} old files", cleaned); - } - } - - async fn cleanup_all_old_backups(&self) -> Result { - let exe_dir = self - .exe_path - .parent() - .context("Failed to get executable directory")?; - - let exe_name = self - .exe_path - .file_name() - .context("Failed to get executable name")? - .to_string_lossy(); - - let backup_pattern = format!("{}.backup.", exe_name); - - let mut cleaned = 0; - if let Ok(entries) = fs::read_dir(exe_dir) { - for entry in entries.filter_map(|e| e.ok()) { - let file_name = entry.file_name().to_string_lossy().to_string(); - if file_name.starts_with(&backup_pattern) { - match fs::remove_file(entry.path()) { - Ok(_) => { - info!("Removed old backup: {}", entry.path().display()); - cleaned += 1; - } - Err(e) => { - warn!("Failed to remove backup {}: {}", entry.path().display(), e); - } - } - } - } - } - - Ok(cleaned) - } - - async fn cleanup_temp_update_leftovers(&self) -> Result { - let temp_dir = std::env::temp_dir(); - - let mut cleaned = 0; - if let Ok(entries) = fs::read_dir(&temp_dir) { - for entry in entries.filter_map(|e| e.ok()) { - let name = entry.file_name().to_string_lossy().to_string(); - let is_update_artifact = name.starts_with("openframe-update-") - || (name.starts_with("openframe-updater-") - && (name.ends_with(".ps1") || name.ends_with(".sh"))); - if !is_update_artifact { - continue; - } - - let too_fresh = entry - .metadata() - .ok() - .and_then(|m| m.modified().ok()) - .and_then(|t| t.elapsed().ok()) - .map(|age| { - age.as_secs() < crate::config::update_config::TEMP_LEFTOVER_MIN_AGE_SECS - }) - .unwrap_or(true); - if too_fresh { - continue; - } - - let path = entry.path(); - let result = if path.is_dir() { - fs::remove_dir_all(&path) - } else { - fs::remove_file(&path) - }; - match result { - Ok(_) => { - info!("Removed update leftover: {}", path.display()); - cleaned += 1; - } - Err(e) => { - warn!("Failed to remove update leftover {}: {}", path.display(), e); - } - } - } - } - - Ok(cleaned) - } -} diff --git a/clients/openframe-client/src/services/update_handler_service.rs b/clients/openframe-client/src/services/update_handler_service.rs deleted file mode 100644 index 3ee9acfa8..000000000 --- a/clients/openframe-client/src/services/update_handler_service.rs +++ /dev/null @@ -1,262 +0,0 @@ -use crate::config::update_config::{CRASH_LOOP_MAX_BOOT_ATTEMPTS, UPDATER_TRANSCRIPTS_KEPT}; -use crate::models::openframe_client_info::ClientUpdateStatus; -use crate::models::update_state::{UpdatePhase, UpdateState}; -use crate::platform::updater_launcher::{self, UpdaterParams}; -use crate::service::FULL_SERVICE_NAME; -use crate::services::agent_configuration_service::AgentConfigurationService; -use crate::services::installed_agent_message_publisher::InstalledAgentMessagePublisher; -use crate::services::last_known_good_service::LastKnownGoodService; -use crate::services::openframe_client_info_service::OpenFrameClientInfoService; -use crate::services::update_cleanup_service::UpdateCleanupService; -use crate::services::update_state_service::UpdateStateService; -use anyhow::Result; -use tracing::{info, warn}; -#[derive(Clone)] -pub struct UpdateHandlerService { - state_service: UpdateStateService, - client_info_service: OpenFrameClientInfoService, - cleanup_service: UpdateCleanupService, - last_known_good_service: LastKnownGoodService, - installed_agent_publisher: InstalledAgentMessagePublisher, - config_service: AgentConfigurationService, -} - -impl UpdateHandlerService { - pub fn new( - state_service: UpdateStateService, - client_info_service: OpenFrameClientInfoService, - cleanup_service: UpdateCleanupService, - last_known_good_service: LastKnownGoodService, - installed_agent_publisher: InstalledAgentMessagePublisher, - config_service: AgentConfigurationService, - ) -> Self { - Self { - state_service, - client_info_service, - cleanup_service, - last_known_good_service, - installed_agent_publisher, - config_service, - } - } - - pub async fn record_boot_attempt(&self) -> Result<()> { - let mut update_state = match self.state_service.load().await? { - Some(state) => state, - None => return Ok(()), - }; - - if !matches!( - update_state.phase, - UpdatePhase::UpdaterLaunched | UpdatePhase::Verifying - ) { - return Ok(()); - } - - if update_state.target_version == env!("OPENFRAME_VERSION") { - info!( - "Update state targets the running version {} — skipping crash-loop accounting", - update_state.target_version - ); - return Ok(()); - } - - if update_state.boot_attempts >= CRASH_LOOP_MAX_BOOT_ATTEMPTS { - warn!( - "Update to {} still unresolved after {} boots — treating as failed (crash-loop guard)", - update_state.target_version, update_state.boot_attempts - ); - let target_version = update_state.target_version.clone(); - self.handle_failure(update_state).await?; - self.launch_reserve_rollback(&target_version).await; - return Ok(()); - } - - update_state.boot_attempts += 1; - self.state_service.save(&update_state).await?; - info!( - "Update to {} unresolved, boot attempt {}/{}", - update_state.target_version, update_state.boot_attempts, CRASH_LOOP_MAX_BOOT_ATTEMPTS - ); - Ok(()) - } - - async fn launch_reserve_rollback(&self, target_version: &str) { - if !(cfg!(windows) || cfg!(target_os = "macos")) { - info!("Reserve rollback not implemented on this platform yet"); - return; - } - - let current_exe = match std::env::current_exe() { - Ok(exe) => exe, - Err(e) => { - warn!("Cannot resolve current exe for reserve rollback: {:#}", e); - return; - } - }; - - let params = UpdaterParams { - binary_path: std::path::PathBuf::new(), - target_exe: current_exe, - service_name: FULL_SERVICE_NAME.to_string(), - update_state_path: self.state_service.get_state_file_path(), - target_version: target_version.to_string(), - boot_marker_path: self - .last_known_good_service - .boot_marker_path() - .to_path_buf(), - lkg_path: self.last_known_good_service.reserve_path().to_path_buf(), - transcript_path: self.last_known_good_service.new_transcript_path("rollback"), - rollback_only: true, - }; - - match updater_launcher::launch_updater(params).await { - Ok(_) => info!("Reserve rollback launched (crash-loop guard), service will restart"), - Err(e) => warn!("Failed to launch reserve rollback: {:#}", e), - } - } - - pub async fn handle_pending_update(&self) -> Result<()> { - let update_state = match self.state_service.load().await? { - Some(state) => state, - None => return Ok(()), - }; - - info!( - "Found update state: version={}, phase={:?}, boot_attempts={}", - update_state.target_version, update_state.phase, update_state.boot_attempts - ); - - let running_version = env!("OPENFRAME_VERSION"); - if running_version == update_state.target_version { - return self.handle_success(update_state).await; - } - - match update_state.phase { - UpdatePhase::UpdaterLaunched | UpdatePhase::Verifying => { - info!( - "Update to {} not verified yet (running {}) — keeping state for next boot", - update_state.target_version, running_version - ); - Ok(()) - } - _ => self.handle_failure(update_state).await, - } - } - - async fn handle_success(&self, state: UpdateState) -> Result<()> { - info!( - "Update to {} succeeded (running binary matches target)", - state.target_version - ); - - match self - .last_known_good_service - .promote(&state.target_version) - .await - { - Ok(_) => info!("Last-known-good anchor raised to {}", state.target_version), - Err(e) => warn!( - "Failed to raise last-known-good anchor to {} (keeping previous anchor): {:#}", - state.target_version, e - ), - } - - if let Err(e) = self - .client_info_service - .update_version(state.target_version.clone()) - .await - { - warn!("Failed to update client version bookkeeping: {:#}", e); - } - - if let Err(e) = self - .client_info_service - .set_update_status( - ClientUpdateStatus::Updated, - Some(state.target_version.clone()), - ) - .await - { - warn!("Failed to set update status to Updated: {:#}", e); - } - - self.send_nats_notification(&state.target_version).await; - - self.cleanup_service.cleanup_all().await; - self.last_known_good_service - .prune_transcripts(UPDATER_TRANSCRIPTS_KEPT) - .await; - self.state_service.clear().await?; - - info!("Update completed, notified backend, cleaned up"); - Ok(()) - } - - async fn handle_failure(&self, state: UpdateState) -> Result<()> { - info!( - "Update to {} failed (phase: {:?}; binary restore is owned by the updater script)", - state.target_version, state.phase - ); - - if let Err(e) = self - .client_info_service - .set_update_status( - ClientUpdateStatus::Failed, - Some(state.target_version.clone()), - ) - .await - { - warn!("Failed to set update status to Failed: {:#}", e); - } - - self.cleanup_service.cleanup_all().await; - self.last_known_good_service - .prune_transcripts(UPDATER_TRANSCRIPTS_KEPT) - .await; - self.state_service.clear().await?; - - info!("Update marked as failed, NATS will retry"); - Ok(()) - } - - async fn send_nats_notification(&self, version: &str) { - match self.config_service.get_machine_id() { - Ok(machine_id) => { - for attempt in 1..=5 { - match self - .installed_agent_publisher - .publish( - machine_id.clone(), - "openframe-client".to_string(), - version.to_string(), - ) - .await - { - Ok(_) => { - info!( - "Successfully published NATS notification for update to {}", - version - ); - return; - } - Err(e) => { - if attempt < 5 { - warn!("Failed to publish NATS notification (attempt {}/5): {:#}. Retrying...", attempt, e); - tokio::time::sleep(tokio::time::Duration::from_secs(1)).await; - } else { - warn!( - "Failed to publish NATS notification after 5 attempts: {:#}", - e - ); - } - } - } - } - } - Err(e) => { - warn!("Failed to get machine_id for NATS notification: {:#}", e); - } - } - } -} diff --git a/clients/openframe-client/src/services/update_state_service.rs b/clients/openframe-client/src/services/update_state_service.rs deleted file mode 100644 index 92281c663..000000000 --- a/clients/openframe-client/src/services/update_state_service.rs +++ /dev/null @@ -1,102 +0,0 @@ -use crate::models::update_state::UpdateState; -use crate::platform::directories::DirectoryManager; -use anyhow::{Context, Result}; -use std::fs; -use std::path::PathBuf; -use tracing::{debug, info}; - -#[derive(Clone)] -pub struct UpdateStateService { - state_file_path: PathBuf, -} - -impl UpdateStateService { - pub fn new(directory_manager: DirectoryManager) -> Result { - let state_file_path = directory_manager.secured_dir().join("update_state.json"); - - directory_manager - .ensure_directories() - .with_context(|| "Failed to ensure secured directory exists")?; - - Ok(Self { state_file_path }) - } - - pub async fn load(&self) -> Result> { - info!( - "Checking for update state file at: {}", - self.state_file_path.display() - ); - - if !self.state_file_path.exists() { - info!( - "No update state file found at: {}", - self.state_file_path.display() - ); - return Ok(None); - } - - let json_content = fs::read_to_string(&self.state_file_path).with_context(|| { - format!( - "Failed to read update state file: {:?}", - self.state_file_path - ) - })?; - - info!("Read update state file content: {}", json_content); - - let state: UpdateState = serde_json::from_str(&json_content) - .context("Failed to deserialize update state from JSON")?; - - info!( - "Loaded update state for version: {}, phase: {:?}", - state.target_version, state.phase - ); - Ok(Some(state)) - } - - pub async fn save(&self, state: &UpdateState) -> Result<()> { - let json_content = serde_json::to_string_pretty(state) - .context("Failed to serialize update state to JSON")?; - - let temp_path = self.state_file_path.with_extension("json.tmp"); - fs::write(&temp_path, json_content) - .with_context(|| format!("Failed to write temp update state file: {:?}", temp_path))?; - fs::rename(&temp_path, &self.state_file_path).with_context(|| { - format!( - "Failed to move update state file into place: {:?}", - self.state_file_path - ) - })?; - - debug!( - "Saved update state for version: {}, phase: {:?}", - state.target_version, state.phase - ); - Ok(()) - } - - pub async fn clear(&self) -> Result<()> { - if self.state_file_path.exists() { - fs::remove_file(&self.state_file_path).with_context(|| { - format!( - "Failed to remove update state file: {:?}", - self.state_file_path - ) - })?; - - info!("Cleared update state"); - } - Ok(()) - } - - pub async fn has_incomplete_update(&self) -> Result { - match self.load().await? { - Some(_state) => Ok(true), // If state exists, recovery is needed - None => Ok(false), - } - } - - pub fn get_state_file_path(&self) -> String { - self.state_file_path.to_string_lossy().to_string() - } -} diff --git a/clients/openframe-client/src/updater.rs b/clients/openframe-client/src/updater.rs deleted file mode 100644 index ffb65c804..000000000 --- a/clients/openframe-client/src/updater.rs +++ /dev/null @@ -1,128 +0,0 @@ -use anyhow::Result; -use semver::Version; -use std::path::PathBuf; -use tempfile::TempDir; -use tracing::{error, info}; -use velopack::{sources::HttpSource, UpdateCheck, UpdateManager, VelopackApp}; - -#[allow(dead_code)] // update_channel/temp_dir retained for updater lifecycle -pub struct VelopackUpdater { - update_channel: String, - temp_dir: TempDir, - current_version: Version, - manager: UpdateManager, - update_url: Option, -} - -impl VelopackUpdater { - pub fn new( - current_version: Version, - update_channel: String, - update_url: Option, - ) -> Result { - info!( - "Initializing VelopackUpdater with version {} on channel {}", - current_version, update_channel - ); - - // Initialize VelopackApp - info!("Starting VelopackApp"); - VelopackApp::build().run(); - - let temp_dir = tempfile::tempdir()?; - info!( - "Created temporary directory at: {}", - temp_dir.path().display() - ); - - // If no update URL is provided, create a dummy manager that will always return no updates - let manager = if let Some(ref url) = update_url { - info!("Configuring update manager with URL: {}", url); - let source = HttpSource::new(url); - UpdateManager::new(source, None, None)? - } else { - info!("No update URL configured, updates will be disabled"); - let source = HttpSource::new("http://localhost"); // Dummy URL that won't be used - UpdateManager::new(source, None, None)? - }; - - Ok(Self { - update_channel, - temp_dir, - current_version, - manager, - update_url, - }) - } - - pub fn check_for_updates(&self) -> Result> { - if let Some(url) = &self.update_url { - info!("Checking for updates from {}", url); - match self.manager.check_for_updates()? { - UpdateCheck::UpdateAvailable(updates) => { - info!("Update available: {:?}", updates); - Ok(Some(UpdateInfo { - version: self.current_version.to_string(), - download_url: String::new(), - release_notes: String::new(), - })) - } - _ => { - info!("No updates available"); - Ok(None) - } - } - } else { - error!("No update URL configured"); - Ok(None) - } - } - - pub fn download_and_apply_update(&self, update_info: &UpdateInfo) -> Result { - info!( - "Downloading and applying update to version {}", - update_info.version - ); - - match self.manager.check_for_updates()? { - UpdateCheck::UpdateAvailable(updates) => { - info!("Downloading update..."); - self.manager.download_updates(&updates, None)?; - - info!("Applying update and preparing to restart..."); - self.manager.apply_updates_and_restart(&updates)?; - Ok(true) - } - _ => { - info!("No updates available to apply"); - Ok(false) - } - } - } - - pub fn restart_to_apply_update(&self) -> Result<()> { - info!("Attempting to restart to apply update"); - match self.manager.check_for_updates()? { - UpdateCheck::UpdateAvailable(updates) => { - info!("Update available, restarting to apply..."); - self.manager.apply_updates_and_restart(&updates)?; - } - _ => { - info!("No updates available to apply during restart"); - } - } - Ok(()) - } - - #[allow(dead_code)] // not currently called - fn get_download_path(&self) -> PathBuf { - self.temp_dir.path().to_path_buf() - } -} - -#[derive(Debug, Clone)] -pub struct UpdateInfo { - pub version: String, - pub download_url: String, - pub release_notes: String, -}