All checks were successful
Admin-gated soft-delete + purge so operators can clear ghost machines/sessions (the ~15-rows-for-one-host accumulation) from the console. - migration 009: deleted_at on connect_sessions + connect_machines, with partial indexes WHERE deleted_at IS NULL. - DELETE /api/machines/:agent_id?purge=true and DELETE /api/sessions/:id?purge=true soft-delete the row and purge the in-memory session (remove_session); the non-purge path keeps the legacy hard-delete / live-only disconnect. POST /api/machines/bulk-remove handles multi-select (batch cap 500). All admin-gated (AdminUser -> 403; tightens the prior any-user delete) and audited to connect_session_events (actor + target + trusted client IP). - list/get queries filter deleted_at IS NULL so removed units leave the console; upsert revives (deleted_at = NULL) a genuinely-reconnecting machine. The keyed-reattach identity resolver (get_machine_by_id) is intentionally unfiltered. Dashboard removal UI is the A3b follow-up. 86 server tests pass; fmt/clippy/test clean. Implements specs/v2-stable-identity/plan.md Task 5 (server portion). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
233 lines
6.9 KiB
Rust
233 lines
6.9 KiB
Rust
//! REST API endpoints
|
|
|
|
pub mod auth;
|
|
pub mod auth_logout;
|
|
pub mod changelog;
|
|
pub mod downloads;
|
|
pub mod machine_keys;
|
|
pub mod releases;
|
|
pub mod removal;
|
|
pub mod sessions;
|
|
pub mod users;
|
|
|
|
use axum::{
|
|
extract::{Path, State},
|
|
Json,
|
|
};
|
|
use serde::{Deserialize, Serialize};
|
|
use uuid::Uuid;
|
|
|
|
use crate::db;
|
|
use crate::session::SessionManager;
|
|
|
|
/// Viewer info returned by API
|
|
#[derive(Debug, Serialize)]
|
|
pub struct ViewerInfoApi {
|
|
pub id: String,
|
|
pub name: String,
|
|
pub connected_at: String,
|
|
}
|
|
|
|
impl From<crate::session::ViewerInfo> for ViewerInfoApi {
|
|
fn from(v: crate::session::ViewerInfo) -> Self {
|
|
Self {
|
|
id: v.id,
|
|
name: v.name,
|
|
connected_at: v.connected_at.to_rfc3339(),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Session info returned by API
|
|
#[derive(Debug, Serialize)]
|
|
pub struct SessionInfo {
|
|
pub id: String,
|
|
pub agent_id: String,
|
|
pub agent_name: String,
|
|
pub started_at: String,
|
|
pub viewer_count: usize,
|
|
pub viewers: Vec<ViewerInfoApi>,
|
|
pub is_streaming: bool,
|
|
pub is_online: bool,
|
|
pub is_persistent: bool,
|
|
pub last_heartbeat: String,
|
|
pub os_version: Option<String>,
|
|
pub is_elevated: bool,
|
|
pub uptime_secs: i64,
|
|
pub display_count: i32,
|
|
pub agent_version: Option<String>,
|
|
/// Attended-consent state (Task 5): `not_required` | `pending` | `granted`
|
|
/// | `denied`. The dashboard can surface "awaiting consent" for an attended
|
|
/// session that has not yet been accepted on the managed machine.
|
|
pub consent_state: String,
|
|
}
|
|
|
|
impl From<crate::session::Session> for SessionInfo {
|
|
fn from(s: crate::session::Session) -> Self {
|
|
Self {
|
|
id: s.id.to_string(),
|
|
agent_id: s.agent_id,
|
|
agent_name: s.agent_name,
|
|
started_at: s.started_at.to_rfc3339(),
|
|
viewer_count: s.viewer_count,
|
|
viewers: s.viewers.into_iter().map(ViewerInfoApi::from).collect(),
|
|
is_streaming: s.is_streaming,
|
|
is_online: s.is_online,
|
|
is_persistent: s.is_persistent,
|
|
last_heartbeat: s.last_heartbeat.to_rfc3339(),
|
|
os_version: s.os_version,
|
|
is_elevated: s.is_elevated,
|
|
uptime_secs: s.uptime_secs,
|
|
display_count: s.display_count,
|
|
agent_version: s.agent_version,
|
|
consent_state: s.consent_state.as_db_str().to_string(),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// List all active sessions
|
|
#[allow(dead_code)] // TODO(native-remote-control): consumed by the integration API; see docs/specs/native-remote-control/
|
|
pub async fn list_sessions(State(sessions): State<SessionManager>) -> Json<Vec<SessionInfo>> {
|
|
let sessions = sessions.list_sessions().await;
|
|
Json(sessions.into_iter().map(SessionInfo::from).collect())
|
|
}
|
|
|
|
/// Get a specific session by ID
|
|
#[allow(dead_code)] // TODO(native-remote-control): consumed by the integration API; see docs/specs/native-remote-control/
|
|
pub async fn get_session(
|
|
State(sessions): State<SessionManager>,
|
|
Path(id): Path<String>,
|
|
) -> Result<Json<SessionInfo>, (axum::http::StatusCode, &'static str)> {
|
|
let session_id = Uuid::parse_str(&id)
|
|
.map_err(|_| (axum::http::StatusCode::BAD_REQUEST, "Invalid session ID"))?;
|
|
|
|
let session = sessions
|
|
.get_session(session_id)
|
|
.await
|
|
.ok_or((axum::http::StatusCode::NOT_FOUND, "Session not found"))?;
|
|
|
|
Ok(Json(SessionInfo::from(session)))
|
|
}
|
|
|
|
// ============================================================================
|
|
// Machine API Types
|
|
// ============================================================================
|
|
|
|
/// Machine info returned by API
|
|
#[derive(Debug, Serialize)]
|
|
pub struct MachineInfo {
|
|
pub id: String,
|
|
pub agent_id: String,
|
|
pub hostname: String,
|
|
pub os_version: Option<String>,
|
|
pub is_elevated: bool,
|
|
pub is_persistent: bool,
|
|
pub first_seen: String,
|
|
pub last_seen: String,
|
|
pub status: String,
|
|
}
|
|
|
|
impl From<db::machines::Machine> for MachineInfo {
|
|
fn from(m: db::machines::Machine) -> Self {
|
|
Self {
|
|
id: m.id.to_string(),
|
|
agent_id: m.agent_id,
|
|
hostname: m.hostname,
|
|
os_version: m.os_version,
|
|
is_elevated: m.is_elevated,
|
|
is_persistent: m.is_persistent,
|
|
first_seen: m.first_seen.to_rfc3339(),
|
|
last_seen: m.last_seen.to_rfc3339(),
|
|
status: m.status,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Session record for history
|
|
#[derive(Debug, Serialize)]
|
|
pub struct SessionRecord {
|
|
pub id: String,
|
|
pub started_at: String,
|
|
pub ended_at: Option<String>,
|
|
pub duration_secs: Option<i32>,
|
|
pub is_support_session: bool,
|
|
pub support_code: Option<String>,
|
|
pub status: String,
|
|
}
|
|
|
|
impl From<db::sessions::DbSession> for SessionRecord {
|
|
fn from(s: db::sessions::DbSession) -> Self {
|
|
Self {
|
|
id: s.id.to_string(),
|
|
started_at: s.started_at.to_rfc3339(),
|
|
ended_at: s.ended_at.map(|t| t.to_rfc3339()),
|
|
duration_secs: s.duration_secs,
|
|
is_support_session: s.is_support_session,
|
|
support_code: s.support_code,
|
|
status: s.status,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Event record for history
|
|
#[derive(Debug, Serialize)]
|
|
pub struct EventRecord {
|
|
pub id: i64,
|
|
pub session_id: Option<String>,
|
|
pub event_type: String,
|
|
pub timestamp: String,
|
|
pub viewer_id: Option<String>,
|
|
pub viewer_name: Option<String>,
|
|
pub details: Option<serde_json::Value>,
|
|
pub ip_address: Option<String>,
|
|
}
|
|
|
|
impl From<db::events::SessionEvent> for EventRecord {
|
|
fn from(e: db::events::SessionEvent) -> Self {
|
|
Self {
|
|
id: e.id,
|
|
session_id: e.session_id.map(|id| id.to_string()),
|
|
event_type: e.event_type,
|
|
timestamp: e.timestamp.to_rfc3339(),
|
|
viewer_id: e.viewer_id,
|
|
viewer_name: e.viewer_name,
|
|
details: e.details,
|
|
ip_address: e.ip_address,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Full machine history (for export)
|
|
#[derive(Debug, Serialize)]
|
|
pub struct MachineHistory {
|
|
pub machine: MachineInfo,
|
|
pub sessions: Vec<SessionRecord>,
|
|
pub events: Vec<EventRecord>,
|
|
pub exported_at: String,
|
|
}
|
|
|
|
/// Query parameters for machine deletion
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct DeleteMachineParams {
|
|
/// If true, send uninstall command to agent (if online). Legacy (non-purge) path.
|
|
#[serde(default)]
|
|
pub uninstall: bool,
|
|
/// If true, include history in response before deletion. Legacy (non-purge) path.
|
|
#[serde(default)]
|
|
pub export: bool,
|
|
/// If true, take the Task-5 SOFT-DELETE path: set `connect_machines.deleted_at`,
|
|
/// drop the live in-memory session, and audit the removal — instead of the legacy
|
|
/// hard delete. This is the operator-removal mechanism that purges ghost rows.
|
|
#[serde(default)]
|
|
pub purge: bool,
|
|
}
|
|
|
|
/// Response for machine deletion
|
|
#[derive(Debug, Serialize)]
|
|
pub struct DeleteMachineResponse {
|
|
pub success: bool,
|
|
pub message: String,
|
|
pub uninstall_sent: bool,
|
|
pub history: Option<MachineHistory>,
|
|
}
|