feat: add update command to CLI, relay, and client

This commit is contained in:
Helios Agent 2026-03-06 12:16:10 +01:00
parent 835d20f734
commit 6345209538
No known key found for this signature in database
GPG key ID: C8259547CD8309B5
9 changed files with 302 additions and 0 deletions

View file

@ -123,6 +123,10 @@ jobs:
scp -i ~/.ssh/deploy_key \ scp -i ~/.ssh/deploy_key \
target/x86_64-unknown-linux-gnu/release/helios-remote-relay \ target/x86_64-unknown-linux-gnu/release/helios-remote-relay \
root@46.225.185.232:/opt/helios-remote/target/release/helios-remote-relay-new root@46.225.185.232:/opt/helios-remote/target/release/helios-remote-relay-new
# Also publish relay binary to download URL for self-update
scp -i ~/.ssh/deploy_key \
target/x86_64-unknown-linux-gnu/release/helios-remote-relay \
root@46.225.185.232:/var/www/helios-remote/helios-remote-relay-linux
ssh -i ~/.ssh/deploy_key root@46.225.185.232 \ ssh -i ~/.ssh/deploy_key root@46.225.185.232 \
"mv /opt/helios-remote/target/release/helios-remote-relay-new \ "mv /opt/helios-remote/target/release/helios-remote-relay-new \
/opt/helios-remote/target/release/helios-remote-relay && \ /opt/helios-remote/target/release/helios-remote-relay && \

View file

@ -337,6 +337,12 @@ enum Commands {
device: String, device: String,
}, },
/// Update all components (relay, client, CLI) if behind
Update {
/// Device label
device: String,
},
/// Fetch last N lines of client log file /// Fetch last N lines of client log file
Logs { Logs {
/// Device label /// Device label
@ -713,6 +719,139 @@ fn main() {
); );
} }
Commands::Update { device } => {
validate_label(&device);
// Fetch all three commits
let relay_commit = match reqwest::blocking::get(&format!("{}/version", cfg.base_url)) {
Ok(r) => r
.json::<Value>()
.ok()
.and_then(|v| v["commit"].as_str().map(String::from))
.unwrap_or_else(|| "?".into()),
Err(e) => format!("error: {}", e),
};
let client_commit = {
let data = req(&cfg, "GET", &format!("/devices/{}/version", device), None, 10);
data["commit"].as_str().unwrap_or("?").to_string()
};
let cli_commit = GIT_COMMIT;
println!(" relay {}", relay_commit);
println!(" cli {}", cli_commit);
println!(" client {}", client_commit);
let all_same = relay_commit == cli_commit && cli_commit == client_commit;
if all_same {
println!(" ✅ Already up to date (commit: {})", cli_commit);
return;
}
println!();
let mut updated_any = false;
// Update relay if needed
if relay_commit != cli_commit {
println!(" → Updating relay...");
let data = req(&cfg, "POST", "/relay/update", None, 15);
println!(" {}", data["message"].as_str().unwrap_or("triggered"));
updated_any = true;
}
// Update client if needed
if client_commit != cli_commit {
println!(" → Updating client on {}...", device);
let data = req(
&cfg,
"POST",
&format!("/devices/{}/update", device),
None,
65,
);
println!(
" {}",
data["message"].as_str().unwrap_or(
if data["success"].as_bool() == Some(true) { "triggered" } else { "failed" }
)
);
updated_any = true;
}
// Self-update CLI if needed
// (relay_commit is the "canonical" latest — if we differ from it, we're outdated)
if cli_commit != relay_commit {
println!(" → Updating CLI...");
#[cfg(target_os = "windows")]
let url = "https://agent-helios.me/downloads/helios-remote/helios-remote-cli-windows.exe";
#[cfg(not(target_os = "windows"))]
let url = "https://agent-helios.me/downloads/helios-remote/helios-remote-cli-linux";
let bytes = match reqwest::blocking::get(url) {
Ok(r) => match r.bytes() {
Ok(b) => b,
Err(e) => {
eprintln!("[helios-remote] CLI update: read failed: {}", e);
process::exit(1);
}
},
Err(e) => {
eprintln!("[helios-remote] CLI update: download failed: {}", e);
process::exit(1);
}
};
let exe = std::env::current_exe().unwrap_or_else(|e| {
eprintln!("[helios-remote] CLI update: current_exe failed: {}", e);
process::exit(1);
});
#[cfg(not(target_os = "windows"))]
{
let tmp = exe.with_extension("new");
std::fs::write(&tmp, &bytes).unwrap_or_else(|e| {
eprintln!("[helios-remote] CLI update: write failed: {}", e);
process::exit(1);
});
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o755));
std::fs::rename(&tmp, &exe).unwrap_or_else(|e| {
eprintln!("[helios-remote] CLI update: rename failed: {}", e);
process::exit(1);
});
println!(" CLI updated. Re-executing...");
use std::os::unix::process::CommandExt;
let args: Vec<String> = std::env::args().collect();
let err = std::process::Command::new(&exe).args(&args[1..]).exec();
eprintln!("[helios-remote] CLI re-exec failed: {}", err);
process::exit(1);
}
#[cfg(target_os = "windows")]
{
let tmp = exe.with_extension("update.exe");
std::fs::write(&tmp, &bytes).unwrap_or_else(|e| {
eprintln!("[helios-remote] CLI update: write failed: {}", e);
process::exit(1);
});
let old = exe.with_extension("old.exe");
let _ = std::fs::remove_file(&old);
let _ = std::fs::rename(&exe, &old);
std::fs::rename(&tmp, &exe).unwrap_or_else(|e| {
eprintln!("[helios-remote] CLI update: rename failed: {}", e);
process::exit(1);
});
println!(" CLI updated. Please restart the command.");
}
updated_any = true;
}
if updated_any {
println!();
println!(" ✅ Update(s) triggered.");
}
}
Commands::Logs { device, lines } => { Commands::Logs { device, lines } => {
validate_label(&device); validate_label(&device);
let data = req( let data = req(

View file

@ -25,6 +25,7 @@ png = "0.17"
futures-util = "0.3" futures-util = "0.3"
colored = "2" colored = "2"
scopeguard = "1" scopeguard = "1"
reqwest = { version = "0.12", features = ["json"] }
terminal_size = "0.3" terminal_size = "0.3"
unicode-width = "0.1" unicode-width = "0.1"

View file

@ -11,6 +11,8 @@ use tokio_tungstenite::{connect_async_tls_with_config, tungstenite::Message, Con
use base64::Engine; use base64::Engine;
use helios_common::{ClientMessage, ServerMessage}; use helios_common::{ClientMessage, ServerMessage};
#[allow(unused_imports)]
use reqwest;
use helios_common::protocol::{is_valid_label, sanitize_label}; use helios_common::protocol::{is_valid_label, sanitize_label};
mod display; mod display;
@ -661,6 +663,65 @@ async fn handle_message(
} }
} }
ServerMessage::UpdateRequest { request_id } => {
display::cmd_start("🔄", "update", "downloading...");
let exe = std::env::current_exe().ok();
tokio::spawn(async move {
// Give the response time to be sent before we restart
tokio::time::sleep(tokio::time::Duration::from_millis(800)).await;
let exe = match exe {
Some(p) => p,
None => {
display::err("", "update: could not determine current exe path");
return;
}
};
let url = "https://agent-helios.me/downloads/helios-remote/helios-remote-client-windows.exe";
let bytes = match reqwest::get(url).await {
Ok(r) => match r.bytes().await {
Ok(b) => b,
Err(e) => {
display::err("", &format!("update: read body failed: {e}"));
return;
}
},
Err(e) => {
display::err("", &format!("update: download failed: {e}"));
return;
}
};
// Write new binary to a temp path, then swap
let tmp = exe.with_extension("update.exe");
if let Err(e) = std::fs::write(&tmp, &bytes) {
display::err("", &format!("update: write failed: {e}"));
return;
}
// Rename current → .old, then tmp → current
let old = exe.with_extension("old.exe");
let _ = std::fs::remove_file(&old);
if let Err(e) = std::fs::rename(&exe, &old) {
display::err("", &format!("update: rename old failed: {e}"));
return;
}
if let Err(e) = std::fs::rename(&tmp, &exe) {
// Attempt rollback
let _ = std::fs::rename(&old, &exe);
display::err("", &format!("update: rename new failed: {e}"));
return;
}
display::cmd_done("🔄", "update", "", true, "updated — restarting");
// Restart
let _ = std::process::Command::new(&exe).spawn();
std::process::exit(0);
});
display::cmd_done("🔄", "update", "", true, "triggered");
ClientMessage::UpdateResponse {
request_id,
success: true,
message: "update triggered, client restarting...".into(),
}
}
ServerMessage::Ack { request_id } => { ServerMessage::Ack { request_id } => {
ClientMessage::Ack { request_id } ClientMessage::Ack { request_id }
} }

View file

@ -108,6 +108,8 @@ pub enum ServerMessage {
ClipboardGetRequest { request_id: Uuid }, ClipboardGetRequest { request_id: Uuid },
/// Set the contents of the client's clipboard /// Set the contents of the client's clipboard
ClipboardSetRequest { request_id: Uuid, text: String }, ClipboardSetRequest { request_id: Uuid, text: String },
/// Request client to self-update and restart
UpdateRequest { request_id: Uuid },
} }
/// Messages sent from the client to the relay server /// Messages sent from the client to the relay server
@ -163,6 +165,12 @@ pub enum ClientMessage {
ClipboardGetResponse { request_id: Uuid, text: String }, ClipboardGetResponse { request_id: Uuid, text: String },
/// Response to a prompt request /// Response to a prompt request
PromptResponse { request_id: Uuid, answer: String }, PromptResponse { request_id: Uuid, answer: String },
/// Response to an update request
UpdateResponse {
request_id: Uuid,
success: bool,
message: String,
},
} }
#[cfg(test)] #[cfg(test)]

View file

@ -22,3 +22,4 @@ tokio-tungstenite = "0.21"
futures-util = "0.3" futures-util = "0.3"
dashmap = "5" dashmap = "5"
anyhow = "1" anyhow = "1"
reqwest = { version = "0.12", features = ["json"] }

View file

@ -401,6 +401,91 @@ pub async fn clipboard_set(
} }
} }
/// POST /relay/update — self-update the relay binary and restart the service
pub async fn relay_update() -> impl IntoResponse {
tokio::spawn(async {
// Give the HTTP response time to be sent before we restart
tokio::time::sleep(Duration::from_millis(800)).await;
let url = "https://agent-helios.me/downloads/helios-remote/helios-remote-relay-linux";
let bytes = match reqwest::get(url).await {
Ok(r) => match r.bytes().await {
Ok(b) => b,
Err(e) => {
error!("relay update: failed to read response body: {e}");
return;
}
},
Err(e) => {
error!("relay update: download failed: {e}");
return;
}
};
let exe = match std::env::current_exe() {
Ok(p) => p,
Err(e) => {
error!("relay update: current_exe: {e}");
return;
}
};
let tmp = exe.with_extension("new");
if let Err(e) = std::fs::write(&tmp, &bytes) {
error!("relay update: write tmp failed: {e}");
return;
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o755));
}
if let Err(e) = std::fs::rename(&tmp, &exe) {
error!("relay update: rename failed: {e}");
return;
}
let _ = std::process::Command::new("systemctl")
.args(["restart", "helios-remote"])
.spawn();
});
(
axum::http::StatusCode::OK,
Json(serde_json::json!({ "ok": true, "message": "update triggered, relay restarting..." })),
)
}
/// POST /devices/:label/update — trigger client self-update
pub async fn client_update(
Path(label): Path<String>,
State(state): State<AppState>,
) -> impl IntoResponse {
match dispatch_with_timeout(&state, &label, "update", |rid| {
ServerMessage::UpdateRequest { request_id: rid }
}, Duration::from_secs(60)).await {
Ok(ClientMessage::UpdateResponse { success, message, .. }) => (
StatusCode::OK,
Json(serde_json::json!({ "success": success, "message": message })),
).into_response(),
Ok(ClientMessage::Ack { .. }) => (
StatusCode::OK,
Json(serde_json::json!({ "success": true, "message": "update acknowledged" })),
).into_response(),
Ok(ClientMessage::Error { message, .. }) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "success": false, "message": message })),
).into_response(),
Ok(_) => (
StatusCode::OK,
Json(serde_json::json!({ "success": true, "message": "acknowledged" })),
).into_response(),
Err(e) => e.into_response(),
}
}
/// POST /devices/:label/inform /// POST /devices/:label/inform
pub async fn inform_user( pub async fn inform_user(
Path(label): Path<String>, Path(label): Path<String>,

View file

@ -62,6 +62,8 @@ async fn main() -> anyhow::Result<()> {
.route("/devices/:label/run", post(api::run_program)) .route("/devices/:label/run", post(api::run_program))
.route("/devices/:label/clipboard", get(api::clipboard_get)) .route("/devices/:label/clipboard", get(api::clipboard_get))
.route("/devices/:label/clipboard", post(api::clipboard_set)) .route("/devices/:label/clipboard", post(api::clipboard_set))
.route("/relay/update", post(api::relay_update))
.route("/devices/:label/update", post(api::client_update))
.layer(middleware::from_fn_with_state(state.clone(), require_api_key)); .layer(middleware::from_fn_with_state(state.clone(), require_api_key));
let app = Router::new() let app = Router::new()

View file

@ -115,6 +115,7 @@ async fn handle_client_message(label: &str, msg: ClientMessage, state: &AppState
| ClientMessage::DownloadResponse { request_id, .. } | ClientMessage::DownloadResponse { request_id, .. }
| ClientMessage::ClipboardGetResponse { request_id, .. } | ClientMessage::ClipboardGetResponse { request_id, .. }
| ClientMessage::PromptResponse { request_id, .. } | ClientMessage::PromptResponse { request_id, .. }
| ClientMessage::UpdateResponse { request_id, .. }
| ClientMessage::Ack { request_id } | ClientMessage::Ack { request_id }
| ClientMessage::Error { request_id, .. } => { | ClientMessage::Error { request_id, .. } => {
let rid = *request_id; let rid = *request_id;