Implement robust auto-update system for GuruConnect agent
Features: - Agent checks for updates periodically (hourly) during idle - Admin can trigger immediate updates via dashboard "Update Agent" button - Silent updates with in-place binary replacement (no reboot required) - SHA-256 checksum verification before installation - Semantic version comparison Server changes: - New releases table for tracking available versions - GET /api/version endpoint for agent polling (unauthenticated) - POST /api/machines/:id/update endpoint for admin push updates - Release management API (/api/releases CRUD) - Track agent_version in machine status Agent changes: - New update.rs module with download/verify/install/restart logic - Handle ADMIN_UPDATE WebSocket command for push updates - --post-update flag for cleanup after successful update - Periodic update check in idle loop (persistent agents only) - agent_version included in AgentStatus messages Dashboard changes: - Version display in machine detail panel - "Update Agent" button for each connected machine 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
294
Cargo.lock
generated
294
Cargo.lock
generated
@@ -1228,9 +1228,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"r-efi",
|
||||
"wasip2",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1391,9 +1393,11 @@ dependencies = [
|
||||
"prost-build",
|
||||
"prost-types",
|
||||
"raw-window-handle",
|
||||
"reqwest",
|
||||
"ring",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"softbuffer",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
@@ -1426,7 +1430,7 @@ dependencies = [
|
||||
"prost",
|
||||
"prost-build",
|
||||
"prost-types",
|
||||
"rand",
|
||||
"rand 0.8.5",
|
||||
"ring",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -1599,6 +1603,24 @@ dependencies = [
|
||||
"pin-utils",
|
||||
"smallvec",
|
||||
"tokio",
|
||||
"want",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-rustls"
|
||||
version = "0.27.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58"
|
||||
dependencies = [
|
||||
"http",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tower-service",
|
||||
"webpki-roots",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1607,14 +1629,22 @@ version = "0.1.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "727805d60e7938b76b826a6ef209eb70eaa1812794f9424d4a4e2d740662df5f"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"bytes",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"hyper",
|
||||
"ipnet",
|
||||
"libc",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"socket2",
|
||||
"tokio",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1766,6 +1796,22 @@ dependencies = [
|
||||
"hashbrown 0.16.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ipnet"
|
||||
version = "2.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130"
|
||||
|
||||
[[package]]
|
||||
name = "iri-string"
|
||||
version = "0.7.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "is_terminal_polyfill"
|
||||
version = "1.70.2"
|
||||
@@ -1999,6 +2045,12 @@ version = "0.4.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
||||
|
||||
[[package]]
|
||||
name = "lru-slab"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
||||
|
||||
[[package]]
|
||||
name = "matchers"
|
||||
version = "0.2.0"
|
||||
@@ -2198,7 +2250,7 @@ dependencies = [
|
||||
"num-integer",
|
||||
"num-iter",
|
||||
"num-traits",
|
||||
"rand",
|
||||
"rand 0.8.5",
|
||||
"smallvec",
|
||||
"zeroize",
|
||||
]
|
||||
@@ -2685,7 +2737,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"rand_core",
|
||||
"rand_core 0.6.4",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
@@ -2989,6 +3041,61 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn"
|
||||
version = "0.11.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"cfg_aliases",
|
||||
"pin-project-lite",
|
||||
"quinn-proto",
|
||||
"quinn-udp",
|
||||
"rustc-hash",
|
||||
"rustls",
|
||||
"socket2",
|
||||
"thiserror 2.0.17",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn-proto"
|
||||
version = "0.11.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"getrandom 0.3.4",
|
||||
"lru-slab",
|
||||
"rand 0.9.2",
|
||||
"ring",
|
||||
"rustc-hash",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"slab",
|
||||
"thiserror 2.0.17",
|
||||
"tinyvec",
|
||||
"tracing",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn-udp"
|
||||
version = "0.5.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
|
||||
dependencies = [
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"socket2",
|
||||
"tracing",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.42"
|
||||
@@ -3011,8 +3118,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"rand_chacha",
|
||||
"rand_core",
|
||||
"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",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3022,7 +3139,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
|
||||
dependencies = [
|
||||
"ppv-lite86",
|
||||
"rand_core",
|
||||
"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",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3034,6 +3161,15 @@ 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.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "raw-window-handle"
|
||||
version = "0.6.2"
|
||||
@@ -3107,6 +3243,47 @@ version = "0.8.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58"
|
||||
|
||||
[[package]]
|
||||
name = "reqwest"
|
||||
version = "0.12.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-rustls",
|
||||
"hyper-util",
|
||||
"js-sys",
|
||||
"log",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"quinn",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_urlencoded",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower-http",
|
||||
"tower-service",
|
||||
"url",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"wasm-streams",
|
||||
"web-sys",
|
||||
"webpki-roots",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ring"
|
||||
version = "0.17.14"
|
||||
@@ -3134,13 +3311,19 @@ dependencies = [
|
||||
"num-traits",
|
||||
"pkcs1",
|
||||
"pkcs8",
|
||||
"rand_core",
|
||||
"rand_core 0.6.4",
|
||||
"signature",
|
||||
"spki",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "2.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
|
||||
|
||||
[[package]]
|
||||
name = "rustc_version"
|
||||
version = "0.4.1"
|
||||
@@ -3160,7 +3343,7 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys 0.4.15",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3176,6 +3359,41 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.35"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "533f54bc6a7d4f647e46ad909549eda97bf5afc1585190ef692b4286b198bd8f"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"rustls-webpki",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pki-types"
|
||||
version = "1.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "21e6f2ab2928ca4291b86736a8bd920a277a399bba1589409d72154ff87c1282"
|
||||
dependencies = [
|
||||
"web-time",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52"
|
||||
dependencies = [
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"untrusted",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.22"
|
||||
@@ -3388,7 +3606,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
|
||||
dependencies = [
|
||||
"digest",
|
||||
"rand_core",
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3636,7 +3854,7 @@ dependencies = [
|
||||
"memchr",
|
||||
"once_cell",
|
||||
"percent-encoding",
|
||||
"rand",
|
||||
"rand 0.8.5",
|
||||
"rsa",
|
||||
"serde",
|
||||
"sha1",
|
||||
@@ -3676,7 +3894,7 @@ dependencies = [
|
||||
"md-5",
|
||||
"memchr",
|
||||
"once_cell",
|
||||
"rand",
|
||||
"rand 0.8.5",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
@@ -3776,6 +3994,9 @@ name = "sync_wrapper"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "synstructure"
|
||||
@@ -4001,6 +4222,16 @@ dependencies = [
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-rustls"
|
||||
version = "0.26.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
|
||||
dependencies = [
|
||||
"rustls",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-stream"
|
||||
version = "0.1.17"
|
||||
@@ -4155,12 +4386,14 @@ dependencies = [
|
||||
"http-body-util",
|
||||
"http-range-header",
|
||||
"httpdate",
|
||||
"iri-string",
|
||||
"mime",
|
||||
"mime_guess",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
@@ -4261,6 +4494,12 @@ dependencies = [
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "try-lock"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
||||
|
||||
[[package]]
|
||||
name = "ttf-parser"
|
||||
version = "0.25.1"
|
||||
@@ -4280,7 +4519,7 @@ dependencies = [
|
||||
"httparse",
|
||||
"log",
|
||||
"native-tls",
|
||||
"rand",
|
||||
"rand 0.8.5",
|
||||
"sha1",
|
||||
"thiserror 1.0.69",
|
||||
"utf-8",
|
||||
@@ -4419,6 +4658,15 @@ dependencies = [
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "want"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e"
|
||||
dependencies = [
|
||||
"try-lock",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasi"
|
||||
version = "0.11.1+wasi-snapshot-preview1"
|
||||
@@ -4498,6 +4746,19 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-streams"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wayland-backend"
|
||||
version = "0.3.11"
|
||||
@@ -4627,6 +4888,15 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b2878ef029c47c6e8cf779119f20fcf52bde7ad42a731b2a304bc221df17571e"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "whoami"
|
||||
version = "1.6.1"
|
||||
|
||||
@@ -46,6 +46,10 @@ toml = "0.8"
|
||||
|
||||
# Crypto
|
||||
ring = "0.17"
|
||||
sha2 = "0.10"
|
||||
|
||||
# HTTP client for updates
|
||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream", "json"] }
|
||||
|
||||
# UUID
|
||||
uuid = { version = "1", features = ["v4", "serde"] }
|
||||
|
||||
@@ -23,6 +23,7 @@ mod session;
|
||||
mod startup;
|
||||
mod transport;
|
||||
mod tray;
|
||||
mod update;
|
||||
mod viewer;
|
||||
|
||||
pub mod proto {
|
||||
@@ -118,6 +119,10 @@ struct Cli {
|
||||
/// Enable verbose logging
|
||||
#[arg(short, long, global = true)]
|
||||
verbose: bool,
|
||||
|
||||
/// Internal flag: set after auto-update to trigger cleanup
|
||||
#[arg(long, hide = true)]
|
||||
post_update: bool,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
@@ -182,6 +187,12 @@ fn main() -> Result<()> {
|
||||
info!("GuruConnect {} ({})", build_info::short_version(), build_info::BUILD_TARGET);
|
||||
info!("Built: {} | Commit: {}", build_info::BUILD_TIMESTAMP, build_info::GIT_COMMIT_DATE);
|
||||
|
||||
// Handle post-update cleanup
|
||||
if cli.post_update {
|
||||
info!("Post-update mode: cleaning up old executable");
|
||||
update::cleanup_post_update();
|
||||
}
|
||||
|
||||
match cli.command {
|
||||
Some(Commands::Agent { code }) => {
|
||||
run_agent_mode(code)
|
||||
|
||||
@@ -47,6 +47,8 @@ use std::time::{Duration, Instant};
|
||||
const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(30);
|
||||
// Status report interval (60 seconds)
|
||||
const STATUS_INTERVAL: Duration = Duration::from_secs(60);
|
||||
// Update check interval (1 hour)
|
||||
const UPDATE_CHECK_INTERVAL: Duration = Duration::from_secs(3600);
|
||||
|
||||
/// Session manager handles the remote control session
|
||||
pub struct SessionManager {
|
||||
@@ -172,6 +174,7 @@ impl SessionManager {
|
||||
uptime_secs: self.start_time.elapsed().as_secs() as i64,
|
||||
display_count: self.get_display_count(),
|
||||
is_streaming: self.state == SessionState::Streaming,
|
||||
agent_version: crate::build_info::short_version(),
|
||||
};
|
||||
|
||||
let msg = Message {
|
||||
@@ -215,6 +218,7 @@ impl SessionManager {
|
||||
let mut last_heartbeat = Instant::now();
|
||||
let mut last_status = Instant::now();
|
||||
let mut last_frame_time = Instant::now();
|
||||
let mut last_update_check = Instant::now();
|
||||
let frame_interval = Duration::from_millis(1000 / self.config.capture.fps as u64);
|
||||
|
||||
// Main loop
|
||||
@@ -309,7 +313,7 @@ impl SessionManager {
|
||||
}
|
||||
|
||||
// Handle other messages (input events, disconnect, etc.)
|
||||
self.handle_message(msg)?;
|
||||
self.handle_message(msg).await?;
|
||||
}
|
||||
|
||||
// Check for outgoing chat messages
|
||||
@@ -347,6 +351,26 @@ impl SessionManager {
|
||||
}
|
||||
}
|
||||
|
||||
// Periodic update check (only for persistent agents, not support sessions)
|
||||
if self.config.support_code.is_none() && last_update_check.elapsed() >= UPDATE_CHECK_INTERVAL {
|
||||
last_update_check = Instant::now();
|
||||
let server_url = self.config.server_url.replace("/ws/agent", "").replace("wss://", "https://").replace("ws://", "http://");
|
||||
match crate::update::check_for_update(&server_url).await {
|
||||
Ok(Some(version_info)) => {
|
||||
tracing::info!("Update available: {} -> {}", crate::build_info::VERSION, version_info.latest_version);
|
||||
if let Err(e) = crate::update::perform_update(&version_info).await {
|
||||
tracing::error!("Auto-update failed: {}", e);
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
tracing::debug!("No update available");
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::debug!("Update check failed: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Longer sleep in idle mode to reduce CPU usage
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
@@ -401,7 +425,7 @@ impl SessionManager {
|
||||
}
|
||||
|
||||
/// Handle incoming message from server
|
||||
fn handle_message(&mut self, msg: Message) -> Result<()> {
|
||||
async fn handle_message(&mut self, msg: Message) -> Result<()> {
|
||||
match msg.payload {
|
||||
Some(message::Payload::MouseEvent(mouse)) => {
|
||||
if let Some(input) = self.input.as_mut() {
|
||||
@@ -468,7 +492,25 @@ impl SessionManager {
|
||||
return Err(anyhow::anyhow!("ADMIN_RESTART: {}", cmd.reason));
|
||||
}
|
||||
Some(AdminCommandType::AdminUpdate) => {
|
||||
tracing::info!("Update command received (not implemented)");
|
||||
tracing::info!("Update command received from server: {}", cmd.reason);
|
||||
// Trigger update check and perform update if available
|
||||
// The server URL is derived from the config
|
||||
let server_url = self.config.server_url.replace("/ws/agent", "").replace("wss://", "https://").replace("ws://", "http://");
|
||||
match crate::update::check_for_update(&server_url).await {
|
||||
Ok(Some(version_info)) => {
|
||||
tracing::info!("Update available: {} -> {}", crate::build_info::VERSION, version_info.latest_version);
|
||||
if let Err(e) = crate::update::perform_update(&version_info).await {
|
||||
tracing::error!("Update failed: {}", e);
|
||||
}
|
||||
// If we get here, the update failed (perform_update exits on success)
|
||||
}
|
||||
Ok(None) => {
|
||||
tracing::info!("Already running latest version");
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to check for updates: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
tracing::warn!("Unknown admin command: {}", cmd.command);
|
||||
|
||||
318
agent/src/update.rs
Normal file
318
agent/src/update.rs
Normal file
@@ -0,0 +1,318 @@
|
||||
//! Auto-update module for GuruConnect agent
|
||||
//!
|
||||
//! Handles checking for updates, downloading new versions, and performing
|
||||
//! in-place binary replacement with restart.
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use sha2::{Sha256, Digest};
|
||||
use std::path::PathBuf;
|
||||
use tracing::{info, warn, error};
|
||||
|
||||
use crate::build_info;
|
||||
|
||||
/// Version information from the server
|
||||
#[derive(Debug, Clone, serde::Deserialize)]
|
||||
pub struct VersionInfo {
|
||||
pub latest_version: String,
|
||||
pub download_url: String,
|
||||
pub checksum_sha256: String,
|
||||
pub is_mandatory: bool,
|
||||
pub release_notes: Option<String>,
|
||||
}
|
||||
|
||||
/// Update state tracking
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum UpdateState {
|
||||
Idle,
|
||||
Checking,
|
||||
Downloading,
|
||||
Verifying,
|
||||
Installing,
|
||||
Restarting,
|
||||
Failed,
|
||||
}
|
||||
|
||||
/// Check if an update is available
|
||||
pub async fn check_for_update(server_base_url: &str) -> Result<Option<VersionInfo>> {
|
||||
let url = format!("{}/api/version", server_base_url.trim_end_matches('/'));
|
||||
info!("Checking for updates at {}", url);
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.danger_accept_invalid_certs(true) // For self-signed certs in dev
|
||||
.build()?;
|
||||
|
||||
let response = client
|
||||
.get(&url)
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if response.status() == reqwest::StatusCode::NOT_FOUND {
|
||||
info!("No stable release available on server");
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(anyhow!("Version check failed: HTTP {}", response.status()));
|
||||
}
|
||||
|
||||
let version_info: VersionInfo = response.json().await?;
|
||||
|
||||
// Compare versions
|
||||
let current = build_info::VERSION;
|
||||
if is_newer_version(&version_info.latest_version, current) {
|
||||
info!(
|
||||
"Update available: {} -> {} (mandatory: {})",
|
||||
current, version_info.latest_version, version_info.is_mandatory
|
||||
);
|
||||
Ok(Some(version_info))
|
||||
} else {
|
||||
info!("Already running latest version: {}", current);
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
/// Simple semantic version comparison
|
||||
/// Returns true if `available` is newer than `current`
|
||||
fn is_newer_version(available: &str, current: &str) -> bool {
|
||||
// Strip any git hash suffix (e.g., "0.1.0-abc123" -> "0.1.0")
|
||||
let available_clean = available.split('-').next().unwrap_or(available);
|
||||
let current_clean = current.split('-').next().unwrap_or(current);
|
||||
|
||||
let parse_version = |s: &str| -> Vec<u32> {
|
||||
s.split('.')
|
||||
.filter_map(|p| p.parse().ok())
|
||||
.collect()
|
||||
};
|
||||
|
||||
let av = parse_version(available_clean);
|
||||
let cv = parse_version(current_clean);
|
||||
|
||||
// Compare component by component
|
||||
for i in 0..av.len().max(cv.len()) {
|
||||
let a = av.get(i).copied().unwrap_or(0);
|
||||
let c = cv.get(i).copied().unwrap_or(0);
|
||||
if a > c {
|
||||
return true;
|
||||
}
|
||||
if a < c {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Download update to temporary file
|
||||
pub async fn download_update(version_info: &VersionInfo) -> Result<PathBuf> {
|
||||
info!("Downloading update from {}", version_info.download_url);
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.danger_accept_invalid_certs(true)
|
||||
.build()?;
|
||||
|
||||
let response = client
|
||||
.get(&version_info.download_url)
|
||||
.timeout(std::time::Duration::from_secs(300)) // 5 minutes for large files
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(anyhow!("Download failed: HTTP {}", response.status()));
|
||||
}
|
||||
|
||||
// Get temp directory
|
||||
let temp_dir = std::env::temp_dir();
|
||||
let temp_path = temp_dir.join("guruconnect-update.exe");
|
||||
|
||||
// Download to file
|
||||
let bytes = response.bytes().await?;
|
||||
std::fs::write(&temp_path, &bytes)?;
|
||||
|
||||
info!("Downloaded {} bytes to {:?}", bytes.len(), temp_path);
|
||||
Ok(temp_path)
|
||||
}
|
||||
|
||||
/// Verify downloaded file checksum
|
||||
pub fn verify_checksum(file_path: &PathBuf, expected_sha256: &str) -> Result<bool> {
|
||||
info!("Verifying checksum...");
|
||||
|
||||
let contents = std::fs::read(file_path)?;
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(&contents);
|
||||
let result = hasher.finalize();
|
||||
let computed = format!("{:x}", result);
|
||||
|
||||
let matches = computed.eq_ignore_ascii_case(expected_sha256);
|
||||
|
||||
if matches {
|
||||
info!("Checksum verified: {}", computed);
|
||||
} else {
|
||||
error!("Checksum mismatch! Expected: {}, Got: {}", expected_sha256, computed);
|
||||
}
|
||||
|
||||
Ok(matches)
|
||||
}
|
||||
|
||||
/// Perform the actual update installation
|
||||
/// This renames the current executable and copies the new one in place
|
||||
pub fn install_update(temp_path: &PathBuf) -> Result<PathBuf> {
|
||||
info!("Installing update...");
|
||||
|
||||
// Get current executable path
|
||||
let current_exe = std::env::current_exe()?;
|
||||
let exe_dir = current_exe.parent()
|
||||
.ok_or_else(|| anyhow!("Cannot get executable directory"))?;
|
||||
|
||||
// Create paths for backup and new executable
|
||||
let backup_path = exe_dir.join("guruconnect.exe.old");
|
||||
|
||||
// Delete any existing backup
|
||||
if backup_path.exists() {
|
||||
if let Err(e) = std::fs::remove_file(&backup_path) {
|
||||
warn!("Could not remove old backup: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Rename current executable to .old (this works even while running)
|
||||
info!("Renaming current exe to backup: {:?}", backup_path);
|
||||
std::fs::rename(¤t_exe, &backup_path)?;
|
||||
|
||||
// Copy new executable to original location
|
||||
info!("Copying new exe to: {:?}", current_exe);
|
||||
std::fs::copy(temp_path, ¤t_exe)?;
|
||||
|
||||
// Clean up temp file
|
||||
let _ = std::fs::remove_file(temp_path);
|
||||
|
||||
info!("Update installed successfully");
|
||||
Ok(current_exe)
|
||||
}
|
||||
|
||||
/// Spawn new process and exit current one
|
||||
pub fn restart_with_new_version(exe_path: &PathBuf, args: &[String]) -> Result<()> {
|
||||
info!("Restarting with new version...");
|
||||
|
||||
// Build command with --post-update flag
|
||||
let mut cmd_args = vec!["--post-update".to_string()];
|
||||
cmd_args.extend(args.iter().cloned());
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200;
|
||||
const DETACHED_PROCESS: u32 = 0x00000008;
|
||||
|
||||
std::process::Command::new(exe_path)
|
||||
.args(&cmd_args)
|
||||
.creation_flags(CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS)
|
||||
.spawn()?;
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
std::process::Command::new(exe_path)
|
||||
.args(&cmd_args)
|
||||
.spawn()?;
|
||||
}
|
||||
|
||||
info!("New process spawned, exiting current process");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Clean up old executable after successful update
|
||||
pub fn cleanup_post_update() {
|
||||
let current_exe = match std::env::current_exe() {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
warn!("Could not get current exe path for cleanup: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let exe_dir = match current_exe.parent() {
|
||||
Some(d) => d,
|
||||
None => {
|
||||
warn!("Could not get executable directory for cleanup");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let backup_path = exe_dir.join("guruconnect.exe.old");
|
||||
|
||||
if backup_path.exists() {
|
||||
info!("Cleaning up old executable: {:?}", backup_path);
|
||||
match std::fs::remove_file(&backup_path) {
|
||||
Ok(_) => info!("Old executable removed successfully"),
|
||||
Err(e) => {
|
||||
warn!("Could not remove old executable (may be in use): {}", e);
|
||||
// On Windows, we might need to schedule deletion on reboot
|
||||
#[cfg(windows)]
|
||||
schedule_delete_on_reboot(&backup_path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Schedule file deletion on reboot (Windows)
|
||||
#[cfg(windows)]
|
||||
fn schedule_delete_on_reboot(path: &PathBuf) {
|
||||
use std::os::windows::ffi::OsStrExt;
|
||||
use windows::Win32::Storage::FileSystem::{MoveFileExW, MOVEFILE_DELAY_UNTIL_REBOOT};
|
||||
use windows::core::PCWSTR;
|
||||
|
||||
let path_wide: Vec<u16> = path.as_os_str()
|
||||
.encode_wide()
|
||||
.chain(std::iter::once(0))
|
||||
.collect();
|
||||
|
||||
unsafe {
|
||||
let result = MoveFileExW(
|
||||
PCWSTR(path_wide.as_ptr()),
|
||||
PCWSTR::null(),
|
||||
MOVEFILE_DELAY_UNTIL_REBOOT,
|
||||
);
|
||||
if result.is_ok() {
|
||||
info!("Scheduled {:?} for deletion on reboot", path);
|
||||
} else {
|
||||
warn!("Failed to schedule {:?} for deletion on reboot", path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Perform complete update process
|
||||
pub async fn perform_update(version_info: &VersionInfo) -> Result<()> {
|
||||
// Download
|
||||
let temp_path = download_update(version_info).await?;
|
||||
|
||||
// Verify
|
||||
if !verify_checksum(&temp_path, &version_info.checksum_sha256)? {
|
||||
let _ = std::fs::remove_file(&temp_path);
|
||||
return Err(anyhow!("Update verification failed: checksum mismatch"));
|
||||
}
|
||||
|
||||
// Install
|
||||
let exe_path = install_update(&temp_path)?;
|
||||
|
||||
// Restart
|
||||
// Get current args (without the current executable name)
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
restart_with_new_version(&exe_path, &args)?;
|
||||
|
||||
// Exit current process
|
||||
std::process::exit(0);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_version_comparison() {
|
||||
assert!(is_newer_version("0.2.0", "0.1.0"));
|
||||
assert!(is_newer_version("1.0.0", "0.9.9"));
|
||||
assert!(is_newer_version("0.1.1", "0.1.0"));
|
||||
assert!(!is_newer_version("0.1.0", "0.1.0"));
|
||||
assert!(!is_newer_version("0.1.0", "0.2.0"));
|
||||
assert!(is_newer_version("0.2.0-abc123", "0.1.0-def456"));
|
||||
}
|
||||
}
|
||||
@@ -276,6 +276,7 @@ message AgentStatus {
|
||||
int64 uptime_secs = 4;
|
||||
int32 display_count = 5;
|
||||
bool is_streaming = 6;
|
||||
string agent_version = 7; // Agent version (e.g., "0.1.0-abc123")
|
||||
}
|
||||
|
||||
// Server commands agent to uninstall itself
|
||||
@@ -287,7 +288,38 @@ message AdminCommand {
|
||||
enum AdminCommandType {
|
||||
ADMIN_UNINSTALL = 0; // Uninstall agent and remove from startup
|
||||
ADMIN_RESTART = 1; // Restart the agent process
|
||||
ADMIN_UPDATE = 2; // Download and install update (future)
|
||||
ADMIN_UPDATE = 2; // Download and install update
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Auto-Update Messages
|
||||
// ============================================================================
|
||||
|
||||
// Update command details (sent with AdminCommand or standalone)
|
||||
message UpdateInfo {
|
||||
string version = 1; // Target version (e.g., "0.2.0")
|
||||
string download_url = 2; // HTTPS URL to download new binary
|
||||
string checksum_sha256 = 3; // SHA-256 hash for verification
|
||||
bool mandatory = 4; // If true, agent must update immediately
|
||||
}
|
||||
|
||||
// Update status report (agent -> server)
|
||||
message UpdateStatus {
|
||||
string current_version = 1; // Current running version
|
||||
UpdateState state = 2; // Current update state
|
||||
string error_message = 3; // Error details if state is FAILED
|
||||
int32 progress_percent = 4; // Download progress (0-100)
|
||||
}
|
||||
|
||||
enum UpdateState {
|
||||
UPDATE_IDLE = 0; // No update in progress
|
||||
UPDATE_CHECKING = 1; // Checking for updates
|
||||
UPDATE_DOWNLOADING = 2; // Downloading new binary
|
||||
UPDATE_VERIFYING = 3; // Verifying checksum
|
||||
UPDATE_INSTALLING = 4; // Installing (rename/copy)
|
||||
UPDATE_RESTARTING = 5; // About to restart
|
||||
UPDATE_COMPLETE = 6; // Update successful (after restart)
|
||||
UPDATE_FAILED = 7; // Update failed
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -335,5 +367,9 @@ message Message {
|
||||
|
||||
// Admin commands (server -> agent)
|
||||
AdminCommand admin_command = 70;
|
||||
|
||||
// Auto-update messages
|
||||
UpdateInfo update_info = 75; // Server -> Agent: update available
|
||||
UpdateStatus update_status = 76; // Agent -> Server: update progress
|
||||
}
|
||||
}
|
||||
|
||||
35
server/migrations/003_auto_update.sql
Normal file
35
server/migrations/003_auto_update.sql
Normal file
@@ -0,0 +1,35 @@
|
||||
-- Migration: 003_auto_update.sql
|
||||
-- Purpose: Add auto-update infrastructure (releases table and machine version tracking)
|
||||
|
||||
-- ============================================================================
|
||||
-- Releases Table
|
||||
-- ============================================================================
|
||||
|
||||
-- Track available agent releases
|
||||
CREATE TABLE IF NOT EXISTS releases (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
version VARCHAR(32) NOT NULL UNIQUE,
|
||||
download_url TEXT NOT NULL,
|
||||
checksum_sha256 VARCHAR(64) NOT NULL,
|
||||
release_notes TEXT,
|
||||
is_stable BOOLEAN NOT NULL DEFAULT false,
|
||||
is_mandatory BOOLEAN NOT NULL DEFAULT false,
|
||||
min_version VARCHAR(32), -- Minimum version that can update to this
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Index for finding latest stable release
|
||||
CREATE INDEX IF NOT EXISTS idx_releases_stable ON releases(is_stable, created_at DESC);
|
||||
|
||||
-- ============================================================================
|
||||
-- Machine Version Tracking
|
||||
-- ============================================================================
|
||||
|
||||
-- Add version tracking columns to existing machines table
|
||||
ALTER TABLE connect_machines ADD COLUMN IF NOT EXISTS agent_version VARCHAR(32);
|
||||
ALTER TABLE connect_machines ADD COLUMN IF NOT EXISTS update_status VARCHAR(32);
|
||||
ALTER TABLE connect_machines ADD COLUMN IF NOT EXISTS last_update_check TIMESTAMPTZ;
|
||||
|
||||
-- Index for finding machines needing updates
|
||||
CREATE INDEX IF NOT EXISTS idx_machines_version ON connect_machines(agent_version);
|
||||
CREATE INDEX IF NOT EXISTS idx_machines_update_status ON connect_machines(update_status);
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
pub mod auth;
|
||||
pub mod users;
|
||||
pub mod releases;
|
||||
|
||||
use axum::{
|
||||
extract::{Path, State, Query},
|
||||
@@ -48,6 +49,7 @@ pub struct SessionInfo {
|
||||
pub is_elevated: bool,
|
||||
pub uptime_secs: i64,
|
||||
pub display_count: i32,
|
||||
pub agent_version: Option<String>,
|
||||
}
|
||||
|
||||
impl From<crate::session::Session> for SessionInfo {
|
||||
@@ -67,6 +69,7 @@ impl From<crate::session::Session> for SessionInfo {
|
||||
is_elevated: s.is_elevated,
|
||||
uptime_secs: s.uptime_secs,
|
||||
display_count: s.display_count,
|
||||
agent_version: s.agent_version,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
375
server/src/api/releases.rs
Normal file
375
server/src/api/releases.rs
Normal file
@@ -0,0 +1,375 @@
|
||||
//! Release management API endpoints (admin only)
|
||||
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
Json,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::auth::AdminUser;
|
||||
use crate::db;
|
||||
use crate::AppState;
|
||||
|
||||
use super::auth::ErrorResponse;
|
||||
|
||||
/// Release info response
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ReleaseInfo {
|
||||
pub id: String,
|
||||
pub version: String,
|
||||
pub download_url: String,
|
||||
pub checksum_sha256: String,
|
||||
pub release_notes: Option<String>,
|
||||
pub is_stable: bool,
|
||||
pub is_mandatory: bool,
|
||||
pub min_version: Option<String>,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
impl From<db::Release> for ReleaseInfo {
|
||||
fn from(r: db::Release) -> Self {
|
||||
Self {
|
||||
id: r.id.to_string(),
|
||||
version: r.version,
|
||||
download_url: r.download_url,
|
||||
checksum_sha256: r.checksum_sha256,
|
||||
release_notes: r.release_notes,
|
||||
is_stable: r.is_stable,
|
||||
is_mandatory: r.is_mandatory,
|
||||
min_version: r.min_version,
|
||||
created_at: r.created_at.to_rfc3339(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Version info for unauthenticated endpoint
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct VersionInfo {
|
||||
pub latest_version: String,
|
||||
pub download_url: String,
|
||||
pub checksum_sha256: String,
|
||||
pub is_mandatory: bool,
|
||||
pub release_notes: Option<String>,
|
||||
}
|
||||
|
||||
/// Create release request
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateReleaseRequest {
|
||||
pub version: String,
|
||||
pub download_url: String,
|
||||
pub checksum_sha256: String,
|
||||
pub release_notes: Option<String>,
|
||||
pub is_stable: bool,
|
||||
pub is_mandatory: bool,
|
||||
pub min_version: Option<String>,
|
||||
}
|
||||
|
||||
/// Update release request
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct UpdateReleaseRequest {
|
||||
pub release_notes: Option<String>,
|
||||
pub is_stable: bool,
|
||||
pub is_mandatory: bool,
|
||||
}
|
||||
|
||||
/// GET /api/version - Get latest version info (no auth required)
|
||||
pub async fn get_version(
|
||||
State(state): State<AppState>,
|
||||
) -> Result<Json<VersionInfo>, (StatusCode, Json<ErrorResponse>)> {
|
||||
let db = state.db.as_ref().ok_or_else(|| {
|
||||
(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
Json(ErrorResponse {
|
||||
error: "Database not available".to_string(),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
let release = db::get_latest_stable_release(db.pool())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Database error: {}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: "Failed to fetch version".to_string(),
|
||||
}),
|
||||
)
|
||||
})?
|
||||
.ok_or_else(|| {
|
||||
(
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(ErrorResponse {
|
||||
error: "No stable release available".to_string(),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(Json(VersionInfo {
|
||||
latest_version: release.version,
|
||||
download_url: release.download_url,
|
||||
checksum_sha256: release.checksum_sha256,
|
||||
is_mandatory: release.is_mandatory,
|
||||
release_notes: release.release_notes,
|
||||
}))
|
||||
}
|
||||
|
||||
/// GET /api/releases - List all releases (admin only)
|
||||
pub async fn list_releases(
|
||||
State(state): State<AppState>,
|
||||
_admin: AdminUser,
|
||||
) -> Result<Json<Vec<ReleaseInfo>>, (StatusCode, Json<ErrorResponse>)> {
|
||||
let db = state.db.as_ref().ok_or_else(|| {
|
||||
(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
Json(ErrorResponse {
|
||||
error: "Database not available".to_string(),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
let releases = db::get_all_releases(db.pool())
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Database error: {}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: "Failed to fetch releases".to_string(),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(Json(releases.into_iter().map(ReleaseInfo::from).collect()))
|
||||
}
|
||||
|
||||
/// POST /api/releases - Create new release (admin only)
|
||||
pub async fn create_release(
|
||||
State(state): State<AppState>,
|
||||
_admin: AdminUser,
|
||||
Json(request): Json<CreateReleaseRequest>,
|
||||
) -> Result<Json<ReleaseInfo>, (StatusCode, Json<ErrorResponse>)> {
|
||||
let db = state.db.as_ref().ok_or_else(|| {
|
||||
(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
Json(ErrorResponse {
|
||||
error: "Database not available".to_string(),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
// Validate version format (basic check)
|
||||
if request.version.is_empty() {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(ErrorResponse {
|
||||
error: "Version cannot be empty".to_string(),
|
||||
}),
|
||||
));
|
||||
}
|
||||
|
||||
// Validate checksum format (64 hex chars for SHA-256)
|
||||
if request.checksum_sha256.len() != 64
|
||||
|| !request.checksum_sha256.chars().all(|c| c.is_ascii_hexdigit())
|
||||
{
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(ErrorResponse {
|
||||
error: "Invalid SHA-256 checksum format (expected 64 hex characters)".to_string(),
|
||||
}),
|
||||
));
|
||||
}
|
||||
|
||||
// Validate URL
|
||||
if !request.download_url.starts_with("https://") {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(ErrorResponse {
|
||||
error: "Download URL must use HTTPS".to_string(),
|
||||
}),
|
||||
));
|
||||
}
|
||||
|
||||
// Check if version already exists
|
||||
if db::get_release_by_version(db.pool(), &request.version)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Database error: {}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: "Database error".to_string(),
|
||||
}),
|
||||
)
|
||||
})?
|
||||
.is_some()
|
||||
{
|
||||
return Err((
|
||||
StatusCode::CONFLICT,
|
||||
Json(ErrorResponse {
|
||||
error: "Version already exists".to_string(),
|
||||
}),
|
||||
));
|
||||
}
|
||||
|
||||
let release = db::create_release(
|
||||
db.pool(),
|
||||
&request.version,
|
||||
&request.download_url,
|
||||
&request.checksum_sha256,
|
||||
request.release_notes.as_deref(),
|
||||
request.is_stable,
|
||||
request.is_mandatory,
|
||||
request.min_version.as_deref(),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Failed to create release: {}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: "Failed to create release".to_string(),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
tracing::info!(
|
||||
"Created release: {} (stable={}, mandatory={})",
|
||||
release.version,
|
||||
release.is_stable,
|
||||
release.is_mandatory
|
||||
);
|
||||
|
||||
Ok(Json(ReleaseInfo::from(release)))
|
||||
}
|
||||
|
||||
/// GET /api/releases/:version - Get release by version (admin only)
|
||||
pub async fn get_release(
|
||||
State(state): State<AppState>,
|
||||
_admin: AdminUser,
|
||||
Path(version): Path<String>,
|
||||
) -> Result<Json<ReleaseInfo>, (StatusCode, Json<ErrorResponse>)> {
|
||||
let db = state.db.as_ref().ok_or_else(|| {
|
||||
(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
Json(ErrorResponse {
|
||||
error: "Database not available".to_string(),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
let release = db::get_release_by_version(db.pool(), &version)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Database error: {}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: "Database error".to_string(),
|
||||
}),
|
||||
)
|
||||
})?
|
||||
.ok_or_else(|| {
|
||||
(
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(ErrorResponse {
|
||||
error: "Release not found".to_string(),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(Json(ReleaseInfo::from(release)))
|
||||
}
|
||||
|
||||
/// PUT /api/releases/:version - Update release (admin only)
|
||||
pub async fn update_release(
|
||||
State(state): State<AppState>,
|
||||
_admin: AdminUser,
|
||||
Path(version): Path<String>,
|
||||
Json(request): Json<UpdateReleaseRequest>,
|
||||
) -> Result<Json<ReleaseInfo>, (StatusCode, Json<ErrorResponse>)> {
|
||||
let db = state.db.as_ref().ok_or_else(|| {
|
||||
(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
Json(ErrorResponse {
|
||||
error: "Database not available".to_string(),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
let release = db::update_release(
|
||||
db.pool(),
|
||||
&version,
|
||||
request.release_notes.as_deref(),
|
||||
request.is_stable,
|
||||
request.is_mandatory,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Database error: {}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: "Failed to update release".to_string(),
|
||||
}),
|
||||
)
|
||||
})?
|
||||
.ok_or_else(|| {
|
||||
(
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(ErrorResponse {
|
||||
error: "Release not found".to_string(),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
tracing::info!(
|
||||
"Updated release: {} (stable={}, mandatory={})",
|
||||
release.version,
|
||||
release.is_stable,
|
||||
release.is_mandatory
|
||||
);
|
||||
|
||||
Ok(Json(ReleaseInfo::from(release)))
|
||||
}
|
||||
|
||||
/// DELETE /api/releases/:version - Delete release (admin only)
|
||||
pub async fn delete_release(
|
||||
State(state): State<AppState>,
|
||||
_admin: AdminUser,
|
||||
Path(version): Path<String>,
|
||||
) -> Result<StatusCode, (StatusCode, Json<ErrorResponse>)> {
|
||||
let db = state.db.as_ref().ok_or_else(|| {
|
||||
(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
Json(ErrorResponse {
|
||||
error: "Database not available".to_string(),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
let deleted = db::delete_release(db.pool(), &version)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
tracing::error!("Database error: {}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: "Failed to delete release".to_string(),
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
if deleted {
|
||||
tracing::info!("Deleted release: {}", version);
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
} else {
|
||||
Err((
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(ErrorResponse {
|
||||
error: "Release not found".to_string(),
|
||||
}),
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ pub mod sessions;
|
||||
pub mod events;
|
||||
pub mod support_codes;
|
||||
pub mod users;
|
||||
pub mod releases;
|
||||
|
||||
use anyhow::Result;
|
||||
use sqlx::postgres::PgPoolOptions;
|
||||
@@ -19,6 +20,7 @@ pub use sessions::*;
|
||||
pub use events::*;
|
||||
pub use support_codes::*;
|
||||
pub use users::*;
|
||||
pub use releases::*;
|
||||
|
||||
/// Database connection pool wrapper
|
||||
#[derive(Clone)]
|
||||
|
||||
179
server/src/db/releases.rs
Normal file
179
server/src/db/releases.rs
Normal file
@@ -0,0 +1,179 @@
|
||||
//! Release management database operations
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Release record from database
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||
pub struct Release {
|
||||
pub id: Uuid,
|
||||
pub version: String,
|
||||
pub download_url: String,
|
||||
pub checksum_sha256: String,
|
||||
pub release_notes: Option<String>,
|
||||
pub is_stable: bool,
|
||||
pub is_mandatory: bool,
|
||||
pub min_version: Option<String>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Create a new release
|
||||
pub async fn create_release(
|
||||
pool: &PgPool,
|
||||
version: &str,
|
||||
download_url: &str,
|
||||
checksum_sha256: &str,
|
||||
release_notes: Option<&str>,
|
||||
is_stable: bool,
|
||||
is_mandatory: bool,
|
||||
min_version: Option<&str>,
|
||||
) -> Result<Release, sqlx::Error> {
|
||||
sqlx::query_as::<_, Release>(
|
||||
r#"
|
||||
INSERT INTO releases (version, download_url, checksum_sha256, release_notes, is_stable, is_mandatory, min_version)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING *
|
||||
"#,
|
||||
)
|
||||
.bind(version)
|
||||
.bind(download_url)
|
||||
.bind(checksum_sha256)
|
||||
.bind(release_notes)
|
||||
.bind(is_stable)
|
||||
.bind(is_mandatory)
|
||||
.bind(min_version)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Get the latest stable release
|
||||
pub async fn get_latest_stable_release(pool: &PgPool) -> Result<Option<Release>, sqlx::Error> {
|
||||
sqlx::query_as::<_, Release>(
|
||||
r#"
|
||||
SELECT * FROM releases
|
||||
WHERE is_stable = true
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
"#,
|
||||
)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Get a release by version
|
||||
pub async fn get_release_by_version(
|
||||
pool: &PgPool,
|
||||
version: &str,
|
||||
) -> Result<Option<Release>, sqlx::Error> {
|
||||
sqlx::query_as::<_, Release>("SELECT * FROM releases WHERE version = $1")
|
||||
.bind(version)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Get all releases (ordered by creation date, newest first)
|
||||
pub async fn get_all_releases(pool: &PgPool) -> Result<Vec<Release>, sqlx::Error> {
|
||||
sqlx::query_as::<_, Release>("SELECT * FROM releases ORDER BY created_at DESC")
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Update a release
|
||||
pub async fn update_release(
|
||||
pool: &PgPool,
|
||||
version: &str,
|
||||
release_notes: Option<&str>,
|
||||
is_stable: bool,
|
||||
is_mandatory: bool,
|
||||
) -> Result<Option<Release>, sqlx::Error> {
|
||||
sqlx::query_as::<_, Release>(
|
||||
r#"
|
||||
UPDATE releases SET
|
||||
release_notes = COALESCE($2, release_notes),
|
||||
is_stable = $3,
|
||||
is_mandatory = $4
|
||||
WHERE version = $1
|
||||
RETURNING *
|
||||
"#,
|
||||
)
|
||||
.bind(version)
|
||||
.bind(release_notes)
|
||||
.bind(is_stable)
|
||||
.bind(is_mandatory)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Delete a release
|
||||
pub async fn delete_release(pool: &PgPool, version: &str) -> Result<bool, sqlx::Error> {
|
||||
let result = sqlx::query("DELETE FROM releases WHERE version = $1")
|
||||
.bind(version)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(result.rows_affected() > 0)
|
||||
}
|
||||
|
||||
/// Update machine version info
|
||||
pub async fn update_machine_version(
|
||||
pool: &PgPool,
|
||||
agent_id: &str,
|
||||
agent_version: &str,
|
||||
) -> Result<(), sqlx::Error> {
|
||||
sqlx::query(
|
||||
r#"
|
||||
UPDATE connect_machines SET
|
||||
agent_version = $1,
|
||||
last_update_check = NOW()
|
||||
WHERE agent_id = $2
|
||||
"#,
|
||||
)
|
||||
.bind(agent_version)
|
||||
.bind(agent_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Update machine update status
|
||||
pub async fn update_machine_update_status(
|
||||
pool: &PgPool,
|
||||
agent_id: &str,
|
||||
update_status: &str,
|
||||
) -> Result<(), sqlx::Error> {
|
||||
sqlx::query(
|
||||
r#"
|
||||
UPDATE connect_machines SET
|
||||
update_status = $1
|
||||
WHERE agent_id = $2
|
||||
"#,
|
||||
)
|
||||
.bind(update_status)
|
||||
.bind(agent_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get machines that need updates (version < latest stable)
|
||||
pub async fn get_machines_needing_update(
|
||||
pool: &PgPool,
|
||||
latest_version: &str,
|
||||
) -> Result<Vec<String>, sqlx::Error> {
|
||||
// Note: This does simple string comparison which works for semver if formatted consistently
|
||||
// For production, you might want a more robust version comparison
|
||||
let rows: Vec<(String,)> = sqlx::query_as(
|
||||
r#"
|
||||
SELECT agent_id FROM connect_machines
|
||||
WHERE status = 'online'
|
||||
AND is_persistent = true
|
||||
AND (agent_version IS NULL OR agent_version < $1)
|
||||
"#,
|
||||
)
|
||||
.bind(latest_version)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
Ok(rows.into_iter().map(|(id,)| id).collect())
|
||||
}
|
||||
@@ -223,6 +223,15 @@ async fn main() -> Result<()> {
|
||||
.route("/api/machines/:agent_id", get(get_machine))
|
||||
.route("/api/machines/:agent_id", delete(delete_machine))
|
||||
.route("/api/machines/:agent_id/history", get(get_machine_history))
|
||||
.route("/api/machines/:agent_id/update", post(trigger_machine_update))
|
||||
|
||||
// REST API - Releases and Version
|
||||
.route("/api/version", get(api::releases::get_version)) // No auth - for agent polling
|
||||
.route("/api/releases", get(api::releases::list_releases))
|
||||
.route("/api/releases", post(api::releases::create_release))
|
||||
.route("/api/releases/:version", get(api::releases::get_release))
|
||||
.route("/api/releases/:version", put(api::releases::update_release))
|
||||
.route("/api/releases/:version", delete(api::releases::delete_release))
|
||||
|
||||
// HTML page routes (clean URLs)
|
||||
.route("/login", get(serve_login))
|
||||
@@ -472,6 +481,62 @@ async fn delete_machine(
|
||||
}))
|
||||
}
|
||||
|
||||
// Update trigger request
|
||||
#[derive(Deserialize)]
|
||||
struct TriggerUpdateRequest {
|
||||
/// Target version (optional, defaults to latest stable)
|
||||
version: Option<String>,
|
||||
}
|
||||
|
||||
/// Trigger update on a specific machine
|
||||
async fn trigger_machine_update(
|
||||
_user: AuthenticatedUser, // Require authentication
|
||||
State(state): State<AppState>,
|
||||
Path(agent_id): Path<String>,
|
||||
Json(request): Json<TriggerUpdateRequest>,
|
||||
) -> Result<impl IntoResponse, (StatusCode, &'static str)> {
|
||||
let db = state.db.as_ref()
|
||||
.ok_or((StatusCode::SERVICE_UNAVAILABLE, "Database not available"))?;
|
||||
|
||||
// Get the target release (either specified or latest stable)
|
||||
let release = if let Some(version) = request.version {
|
||||
db::releases::get_release_by_version(db.pool(), &version).await
|
||||
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Database error"))?
|
||||
.ok_or((StatusCode::NOT_FOUND, "Release version not found"))?
|
||||
} else {
|
||||
db::releases::get_latest_stable_release(db.pool()).await
|
||||
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "Database error"))?
|
||||
.ok_or((StatusCode::NOT_FOUND, "No stable release available"))?
|
||||
};
|
||||
|
||||
// Find session for this agent
|
||||
let session = state.sessions.get_session_by_agent(&agent_id).await
|
||||
.ok_or((StatusCode::NOT_FOUND, "Agent not found or offline"))?;
|
||||
|
||||
if !session.is_online {
|
||||
return Err((StatusCode::BAD_REQUEST, "Agent is offline"));
|
||||
}
|
||||
|
||||
// Send update command via WebSocket
|
||||
// For now, we send admin command - later we'll include UpdateInfo in the message
|
||||
let sent = state.sessions.send_admin_command(
|
||||
session.id,
|
||||
proto::AdminCommandType::AdminUpdate,
|
||||
&format!("Update to version {}", release.version),
|
||||
).await;
|
||||
|
||||
if sent {
|
||||
info!("Sent update command to agent {} (version {})", agent_id, release.version);
|
||||
|
||||
// Update machine update status in database
|
||||
let _ = db::releases::update_machine_update_status(db.pool(), &agent_id, "downloading").await;
|
||||
|
||||
Ok((StatusCode::OK, "Update command sent"))
|
||||
} else {
|
||||
Err((StatusCode::INTERNAL_SERVER_ERROR, "Failed to send update command"))
|
||||
}
|
||||
}
|
||||
|
||||
// Static page handlers
|
||||
async fn serve_login() -> impl IntoResponse {
|
||||
match tokio::fs::read_to_string("static/login.html").await {
|
||||
|
||||
@@ -308,6 +308,11 @@ async fn handle_agent_connection(
|
||||
}
|
||||
Some(proto::message::Payload::AgentStatus(status)) => {
|
||||
// Update session with agent status
|
||||
let agent_version = if status.agent_version.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(status.agent_version.clone())
|
||||
};
|
||||
sessions_status.update_agent_status(
|
||||
session_id,
|
||||
Some(status.os_version.clone()),
|
||||
@@ -315,9 +320,16 @@ async fn handle_agent_connection(
|
||||
status.uptime_secs,
|
||||
status.display_count,
|
||||
status.is_streaming,
|
||||
agent_version.clone(),
|
||||
).await;
|
||||
info!("Agent status update: {} - streaming={}, uptime={}s",
|
||||
status.hostname, status.is_streaming, status.uptime_secs);
|
||||
|
||||
// Update version in database if present
|
||||
if let (Some(ref db), Some(ref version)) = (&db, &agent_version) {
|
||||
let _ = crate::db::releases::update_machine_version(db.pool(), &agent_id, version).await;
|
||||
}
|
||||
|
||||
info!("Agent status update: {} - streaming={}, uptime={}s, version={:?}",
|
||||
status.hostname, status.is_streaming, status.uptime_secs, agent_version);
|
||||
}
|
||||
Some(proto::message::Payload::Heartbeat(_)) => {
|
||||
// Update heartbeat timestamp
|
||||
|
||||
@@ -47,6 +47,7 @@ pub struct Session {
|
||||
pub is_elevated: bool,
|
||||
pub uptime_secs: i64,
|
||||
pub display_count: i32,
|
||||
pub agent_version: Option<String>, // Agent software version
|
||||
}
|
||||
|
||||
/// Channel for sending frames from agent to viewers
|
||||
@@ -138,6 +139,7 @@ impl SessionManager {
|
||||
is_elevated: false,
|
||||
uptime_secs: 0,
|
||||
display_count: 1,
|
||||
agent_version: None,
|
||||
};
|
||||
|
||||
let session_data = SessionData {
|
||||
@@ -167,6 +169,7 @@ impl SessionManager {
|
||||
uptime_secs: i64,
|
||||
display_count: i32,
|
||||
is_streaming: bool,
|
||||
agent_version: Option<String>,
|
||||
) {
|
||||
let mut sessions = self.sessions.write().await;
|
||||
if let Some(session_data) = sessions.get_mut(&session_id) {
|
||||
@@ -179,6 +182,9 @@ impl SessionManager {
|
||||
session_data.info.is_elevated = is_elevated;
|
||||
session_data.info.uptime_secs = uptime_secs;
|
||||
session_data.info.display_count = display_count;
|
||||
if let Some(version) = agent_version {
|
||||
session_data.info.agent_version = Some(version);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -454,6 +460,7 @@ impl SessionManager {
|
||||
is_elevated: false,
|
||||
uptime_secs: 0,
|
||||
display_count: 1,
|
||||
agent_version: None,
|
||||
};
|
||||
|
||||
// Create placeholder channels (will be replaced on reconnect)
|
||||
|
||||
@@ -908,6 +908,7 @@
|
||||
const statusText = m.is_online ? 'Online' : 'Offline';
|
||||
const connectDisabled = m.is_online ? '' : 'disabled';
|
||||
const connectTitle = m.is_online ? '' : 'title="Agent is offline"';
|
||||
const versionText = m.agent_version || 'Unknown';
|
||||
|
||||
container.innerHTML =
|
||||
'<div class="detail-section">' +
|
||||
@@ -915,6 +916,7 @@
|
||||
'<div class="detail-row"><span class="detail-label">Status</span><span class="detail-value" style="color: ' + statusColor + ';">' + statusText + '</span></div>' +
|
||||
'<div class="detail-row"><span class="detail-label">Agent ID</span><span class="detail-value">' + m.agent_id.slice(0,8) + '...</span></div>' +
|
||||
'<div class="detail-row"><span class="detail-label">Session ID</span><span class="detail-value">' + m.id.slice(0,8) + '...</span></div>' +
|
||||
'<div class="detail-row"><span class="detail-label">Version</span><span class="detail-value">' + escapeHtml(versionText) + '</span></div>' +
|
||||
'<div class="detail-row"><span class="detail-label">Connected</span><span class="detail-value">' + started + '</span></div>' +
|
||||
'<div class="detail-row"><span class="detail-label">Viewers</span><span class="detail-value">' + m.viewer_count + '</span></div>' +
|
||||
'</div>' +
|
||||
@@ -923,6 +925,7 @@
|
||||
'<button class="btn btn-primary" style="width: 100%; margin-bottom: 8px;" onclick="connectToMachine(\'' + m.id + '\')" ' + connectDisabled + ' ' + connectTitle + '>Connect</button>' +
|
||||
'<button class="btn btn-outline" style="width: 100%; margin-bottom: 8px;" onclick="openChat(\'' + m.id + '\', \'' + (m.agent_name || 'Client').replace(/'/g, "\\'") + '\')" ' + connectDisabled + '>Chat</button>' +
|
||||
'<button class="btn btn-outline" style="width: 100%; margin-bottom: 8px;" disabled>Transfer Files</button>' +
|
||||
'<button class="btn btn-outline" style="width: 100%; margin-bottom: 8px;" onclick="triggerUpdate(\'' + m.agent_id + '\', \'' + (m.agent_name || m.agent_id).replace(/'/g, "\\'") + '\')" ' + connectDisabled + '>Update Agent</button>' +
|
||||
'<button class="btn btn-outline" style="width: 100%; color: hsl(0, 62.8%, 50%);" onclick="disconnectMachine(\'' + m.id + '\', \'' + (m.agent_name || m.agent_id).replace(/'/g, "\\'") + '\')">Disconnect</button>' +
|
||||
'</div>';
|
||||
}
|
||||
@@ -1043,6 +1046,25 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function triggerUpdate(agentId, machineName) {
|
||||
if (!confirm("Send update command to " + machineName + "?\n\nThe agent will download and install the latest version, then restart.")) return;
|
||||
try {
|
||||
const response = await fetch("/api/machines/" + agentId + "/update", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" }
|
||||
});
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
alert("Update command sent to " + machineName + ".\n\n" + (result.message || "Agent will update shortly."));
|
||||
} else {
|
||||
const errorText = await response.text();
|
||||
alert("Failed to trigger update: " + errorText);
|
||||
}
|
||||
} catch (err) {
|
||||
alert("Error triggering update: " + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh machines every 5 seconds
|
||||
loadMachines();
|
||||
setInterval(loadMachines, 5000);
|
||||
|
||||
Reference in New Issue
Block a user