helios-remote/crates/server/src/api.rs

528 lines
18 KiB
Rust

use std::time::Duration;
use axum::{
extract::{Path, Query, State},
http::StatusCode,
response::IntoResponse,
Json,
};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use tracing::error;
use helios_common::protocol::{ClientMessage, ServerMessage};
use crate::AppState;
const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
// ── Response types ──────────────────────────────────────────────────────────
#[derive(Serialize)]
pub struct ErrorBody {
pub error: String,
}
fn not_found(label: &str) -> (StatusCode, Json<ErrorBody>) {
(
StatusCode::NOT_FOUND,
Json(ErrorBody {
error: format!("Device '{label}' not found or not connected"),
}),
)
}
fn timeout_error(label: &str, op: &str) -> (StatusCode, Json<ErrorBody>) {
(
StatusCode::GATEWAY_TIMEOUT,
Json(ErrorBody {
error: format!("Timed out waiting for client response (device='{label}', op='{op}')"),
}),
)
}
fn send_error(label: &str, op: &str) -> (StatusCode, Json<ErrorBody>) {
(
StatusCode::BAD_GATEWAY,
Json(ErrorBody {
error: format!("Failed to send command to client — may have disconnected (device='{label}', op='{op}')"),
}),
)
}
// ── Helper to send a command and await the response ─────────────────────────
async fn dispatch<F>(
state: &AppState,
label: &str,
op: &str,
make_msg: F,
) -> Result<ClientMessage, (StatusCode, Json<ErrorBody>)>
where
F: FnOnce(Uuid) -> ServerMessage,
{
dispatch_with_timeout(state, label, op, make_msg, REQUEST_TIMEOUT).await
}
async fn dispatch_with_timeout<F>(
state: &AppState,
label: &str,
op: &str,
make_msg: F,
timeout: Duration,
) -> Result<ClientMessage, (StatusCode, Json<ErrorBody>)>
where
F: FnOnce(Uuid) -> ServerMessage,
{
let tx = state
.sessions
.get_cmd_tx(label)
.ok_or_else(|| not_found(label))?;
let request_id = Uuid::new_v4();
let rx = state.sessions.register_pending(request_id);
let msg = make_msg(request_id);
tx.send(msg).await.map_err(|e| {
error!("Channel send failed for device={label}, op={op}: {e}");
send_error(label, op)
})?;
match tokio::time::timeout(timeout, rx).await {
Ok(Ok(response)) => Ok(response),
Ok(Err(_)) => Err(send_error(label, op)),
Err(_) => Err(timeout_error(label, op)),
}
}
// ── Handlers ─────────────────────────────────────────────────────────────────
/// GET /devices — list all connected clients
pub async fn list_devices(State(state): State<AppState>) -> Json<serde_json::Value> {
let devices = state.sessions.list();
Json(serde_json::json!({ "devices": devices }))
}
/// POST /devices/:label/screenshot — full screen screenshot
pub async fn request_screenshot(
Path(label): Path<String>,
State(state): State<AppState>,
) -> impl IntoResponse {
match dispatch(&state, &label, "screenshot", |rid| {
ServerMessage::ScreenshotRequest { request_id: rid }
}).await {
Ok(ClientMessage::ScreenshotResponse { image_base64, width, height, .. }) => (
StatusCode::OK,
Json(serde_json::json!({ "image_base64": image_base64, "width": width, "height": height })),
).into_response(),
Ok(ClientMessage::Error { message, .. }) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": message })),
).into_response(),
Ok(_) => (StatusCode::BAD_GATEWAY, Json(serde_json::json!({ "error": "Unexpected response" }))).into_response(),
Err(e) => e.into_response(),
}
}
/// POST /devices/:label/windows/:window_id/screenshot
pub async fn window_screenshot(
Path((label, window_id)): Path<(String, u64)>,
State(state): State<AppState>,
) -> impl IntoResponse {
match dispatch(&state, &label, "window_screenshot", |rid| {
ServerMessage::WindowScreenshotRequest { request_id: rid, window_id }
}).await {
Ok(ClientMessage::ScreenshotResponse { image_base64, width, height, .. }) => (
StatusCode::OK,
Json(serde_json::json!({ "image_base64": image_base64, "width": width, "height": height })),
).into_response(),
Ok(ClientMessage::Error { message, .. }) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": message })),
).into_response(),
Ok(_) => (StatusCode::BAD_GATEWAY, Json(serde_json::json!({ "error": "Unexpected response" }))).into_response(),
Err(e) => e.into_response(),
}
}
/// GET /devices/:label/logs?lines=100
pub async fn logs(
Path(label): Path<String>,
Query(params): Query<std::collections::HashMap<String, String>>,
State(state): State<AppState>,
) -> impl IntoResponse {
let lines: u32 = params.get("lines").and_then(|v| v.parse().ok()).unwrap_or(100);
match dispatch(&state, &label, "logs", |rid| {
ServerMessage::LogsRequest { request_id: rid, lines }
}).await {
Ok(ClientMessage::LogsResponse { content, log_path, .. }) => (
StatusCode::OK,
Json(serde_json::json!({ "content": content, "log_path": log_path, "lines": lines })),
).into_response(),
Ok(ClientMessage::Error { message, .. }) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": message })),
).into_response(),
Ok(_) => (StatusCode::BAD_GATEWAY, Json(serde_json::json!({ "error": "Unexpected response" }))).into_response(),
Err(e) => e.into_response(),
}
}
/// POST /devices/:label/exec
#[derive(Deserialize)]
pub struct ExecBody {
pub command: String,
pub timeout_ms: Option<u64>,
}
pub async fn request_exec(
Path(label): Path<String>,
State(state): State<AppState>,
Json(body): Json<ExecBody>,
) -> impl IntoResponse {
let server_timeout = body.timeout_ms
.map(|ms| Duration::from_millis(ms + 5_000))
.unwrap_or(REQUEST_TIMEOUT);
match dispatch_with_timeout(&state, &label, "exec", |rid| ServerMessage::ExecRequest {
request_id: rid,
command: body.command.clone(),
timeout_ms: body.timeout_ms,
}, server_timeout).await {
Ok(ClientMessage::ExecResponse { stdout, stderr, exit_code, .. }) => (
StatusCode::OK,
Json(serde_json::json!({ "stdout": stdout, "stderr": stderr, "exit_code": exit_code })),
).into_response(),
Ok(ClientMessage::Error { message, .. }) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": message })),
).into_response(),
Ok(_) => (StatusCode::BAD_GATEWAY, Json(serde_json::json!({ "error": "Unexpected response" }))).into_response(),
Err(e) => e.into_response(),
}
}
/// GET /devices/:label/windows
pub async fn list_windows(
Path(label): Path<String>,
State(state): State<AppState>,
) -> impl IntoResponse {
match dispatch(&state, &label, "list_windows", |rid| {
ServerMessage::ListWindowsRequest { request_id: rid }
}).await {
Ok(ClientMessage::ListWindowsResponse { windows, .. }) => (
StatusCode::OK,
Json(serde_json::json!({ "windows": windows })),
).into_response(),
Ok(ClientMessage::Error { message, .. }) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": message })),
).into_response(),
Ok(_) => (StatusCode::BAD_GATEWAY, Json(serde_json::json!({ "error": "Unexpected response" }))).into_response(),
Err(e) => e.into_response(),
}
}
/// POST /devices/:label/windows/minimize-all
pub async fn minimize_all(
Path(label): Path<String>,
State(state): State<AppState>,
) -> impl IntoResponse {
match dispatch(&state, &label, "minimize_all", |rid| {
ServerMessage::MinimizeAllRequest { request_id: rid }
}).await {
Ok(_) => (StatusCode::OK, Json(serde_json::json!({ "ok": true }))).into_response(),
Err(e) => e.into_response(),
}
}
/// POST /devices/:label/windows/:window_id/focus
pub async fn focus_window(
Path((label, window_id)): Path<(String, u64)>,
State(state): State<AppState>,
) -> impl IntoResponse {
match dispatch(&state, &label, "focus_window", |rid| {
ServerMessage::FocusWindowRequest { request_id: rid, window_id }
}).await {
Ok(_) => (StatusCode::OK, Json(serde_json::json!({ "ok": true }))).into_response(),
Err(e) => e.into_response(),
}
}
/// POST /devices/:label/windows/:window_id/maximize
pub async fn maximize_and_focus(
Path((label, window_id)): Path<(String, u64)>,
State(state): State<AppState>,
) -> impl IntoResponse {
match dispatch(&state, &label, "maximize_and_focus", |rid| {
ServerMessage::MaximizeAndFocusRequest { request_id: rid, window_id }
}).await {
Ok(_) => (StatusCode::OK, Json(serde_json::json!({ "ok": true }))).into_response(),
Err(e) => e.into_response(),
}
}
/// GET /version — server version (public, no auth)
pub async fn server_version() -> impl IntoResponse {
Json(serde_json::json!({
"commit": env!("GIT_COMMIT"),
}))
}
/// GET /devices/:label/version — client version
pub async fn client_version(
Path(label): Path<String>,
State(state): State<AppState>,
) -> impl IntoResponse {
match dispatch(&state, &label, "version", |rid| {
ServerMessage::VersionRequest { request_id: rid }
}).await {
Ok(ClientMessage::VersionResponse { version, commit, .. }) => (
StatusCode::OK,
Json(serde_json::json!({ "version": version, "commit": commit })),
).into_response(),
Ok(ClientMessage::Error { message, .. }) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": message })),
).into_response(),
Ok(_) => (StatusCode::BAD_GATEWAY, Json(serde_json::json!({ "error": "Unexpected response" }))).into_response(),
Err(e) => e.into_response(),
}
}
/// POST /devices/:label/upload
#[derive(Deserialize)]
pub struct UploadBody {
pub path: String,
pub content_base64: String,
}
pub async fn upload_file(
Path(label): Path<String>,
State(state): State<AppState>,
Json(body): Json<UploadBody>,
) -> impl IntoResponse {
match dispatch(&state, &label, "upload", |rid| ServerMessage::UploadRequest {
request_id: rid,
path: body.path.clone(),
content_base64: body.content_base64.clone(),
}).await {
Ok(_) => (StatusCode::OK, Json(serde_json::json!({ "ok": true }))).into_response(),
Err(e) => e.into_response(),
}
}
/// GET /devices/:label/download?path=...
#[derive(Deserialize)]
pub struct DownloadQuery {
pub path: String,
}
pub async fn download_file(
Path(label): Path<String>,
State(state): State<AppState>,
Query(query): Query<DownloadQuery>,
) -> impl IntoResponse {
match dispatch(&state, &label, "download", |rid| ServerMessage::DownloadRequest {
request_id: rid,
path: query.path.clone(),
}).await {
Ok(ClientMessage::DownloadResponse { content_base64, size, .. }) => (
StatusCode::OK,
Json(serde_json::json!({ "content_base64": content_base64, "size": size })),
).into_response(),
Ok(ClientMessage::Error { message, .. }) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": message })),
).into_response(),
Ok(_) => (StatusCode::BAD_GATEWAY, Json(serde_json::json!({ "error": "Unexpected response" }))).into_response(),
Err(e) => e.into_response(),
}
}
/// POST /devices/:label/run
#[derive(Deserialize)]
pub struct RunBody {
pub program: String,
#[serde(default)]
pub args: Vec<String>,
}
pub async fn run_program(
Path(label): Path<String>,
State(state): State<AppState>,
Json(body): Json<RunBody>,
) -> impl IntoResponse {
match dispatch(&state, &label, "run", |rid| ServerMessage::RunRequest {
request_id: rid,
program: body.program.clone(),
args: body.args.clone(),
}).await {
Ok(_) => (StatusCode::OK, Json(serde_json::json!({ "ok": true }))).into_response(),
Err(e) => e.into_response(),
}
}
/// GET /devices/:label/clipboard
pub async fn clipboard_get(
Path(label): Path<String>,
State(state): State<AppState>,
) -> impl IntoResponse {
match dispatch(&state, &label, "clipboard_get", |rid| {
ServerMessage::ClipboardGetRequest { request_id: rid }
}).await {
Ok(ClientMessage::ClipboardGetResponse { text, .. }) => (
StatusCode::OK,
Json(serde_json::json!({ "text": text })),
).into_response(),
Ok(ClientMessage::Error { message, .. }) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": message })),
).into_response(),
Ok(_) => (StatusCode::BAD_GATEWAY, Json(serde_json::json!({ "error": "Unexpected response" }))).into_response(),
Err(e) => e.into_response(),
}
}
/// POST /devices/:label/clipboard
#[derive(Deserialize)]
pub struct ClipboardSetBody {
pub text: String,
}
pub async fn clipboard_set(
Path(label): Path<String>,
State(state): State<AppState>,
Json(body): Json<ClipboardSetBody>,
) -> impl IntoResponse {
match dispatch(&state, &label, "clipboard_set", |rid| {
ServerMessage::ClipboardSetRequest { request_id: rid, text: body.text.clone() }
}).await {
Ok(_) => (StatusCode::OK, Json(serde_json::json!({ "ok": true }))).into_response(),
Err(e) => e.into_response(),
}
}
/// 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
pub async fn inform_user(
Path(label): Path<String>,
State(state): State<AppState>,
Json(body): Json<PromptBody>,
) -> impl IntoResponse {
match dispatch(&state, &label, "inform", |rid| ServerMessage::InformRequest {
request_id: rid,
message: body.message.clone(),
title: body.title.clone(),
}).await {
Ok(_) => (StatusCode::OK, Json(serde_json::json!({ "ok": true }))).into_response(),
Err(e) => e.into_response(),
}
}
/// POST /devices/:label/prompt
#[derive(Deserialize)]
pub struct PromptBody {
pub message: String,
pub title: Option<String>,
}
pub async fn prompt_user(
Path(label): Path<String>,
State(state): State<AppState>,
Json(body): Json<PromptBody>,
) -> impl IntoResponse {
match dispatch(&state, &label, "prompt", |rid| ServerMessage::PromptRequest {
request_id: rid,
message: body.message.clone(),
title: body.title.clone(),
}).await {
Ok(ClientMessage::PromptResponse { answer, .. }) => {
(StatusCode::OK, Json(serde_json::json!({ "ok": true, "answer": answer }))).into_response()
}
Ok(_) => (StatusCode::OK, Json(serde_json::json!({ "ok": true }))).into_response(),
Err(e) => e.into_response(),
}
}