feat: add update command to CLI, relay, and client
This commit is contained in:
parent
835d20f734
commit
6345209538
9 changed files with 302 additions and 0 deletions
4
.github/workflows/ci.yml
vendored
4
.github/workflows/ci.yml
vendored
|
|
@ -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 && \
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)]
|
||||||
|
|
|
||||||
|
|
@ -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"] }
|
||||||
|
|
|
||||||
|
|
@ -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>,
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue