From a57a95a98a893fb6a84b089f72d0aa3c9900066e Mon Sep 17 00:00:00 2001 From: Andrew Rioux Date: Mon, 24 Feb 2025 00:35:51 -0500 Subject: [PATCH] feat: added basic beacon instance management UI --- sparse-handler/src/lib.rs | 3 +- sparse-server/src/beacons/commands.rs | 18 +- sparse-server/src/beacons/instances.rs | 342 +++++++++++++++++++- sparse-server/src/beacons/sidebar.rs | 17 +- sparse-server/src/webserver.rs | 108 ++++++- sparse-server/style/beacons/_instances.scss | 15 + sparse-server/style/beacons/_sidebar.scss | 1 + 7 files changed, 477 insertions(+), 27 deletions(-) diff --git a/sparse-handler/src/lib.rs b/sparse-handler/src/lib.rs index b3ea4a4..6647dbf 100644 --- a/sparse-handler/src/lib.rs +++ b/sparse-handler/src/lib.rs @@ -11,11 +11,12 @@ pub mod error; mod router; -#[derive(Clone)] +#[derive(Clone, Debug)] #[non_exhaustive] pub enum BeaconEvent { NewBeacon(String), Checkin(String), + BeaconUpdate(String), BeaconCommandFinished(String, i64) } diff --git a/sparse-server/src/beacons/commands.rs b/sparse-server/src/beacons/commands.rs index 99d6567..2aabfdd 100644 --- a/sparse-server/src/beacons/commands.rs +++ b/sparse-server/src/beacons/commands.rs @@ -175,7 +175,10 @@ pub async fn issue_command( } #[component] -pub fn CommandForm(categories: Vec, beacon_id: Option) -> impl IntoView { +pub fn CommandForm( + categories: Vec, + beacon_info: Option<(String, String, sparse_actions::version::Version)> +) -> impl IntoView { let command_action = Action::new(move |data: &SendWrapper| { let data = data.clone(); async move { @@ -186,7 +189,7 @@ pub fn CommandForm(categories: Vec, beacon_id: Option) -> impl let (current_cmd, set_current_cmd) = signal("Exec".to_owned()); view! { - {(categories.is_empty() && beacon_id.is_none()) + {(categories.is_empty() && beacon_info.is_none()) .then(|| view! { "Missing categories! Cannot assign a command to a category if there are no categories" })} @@ -211,7 +214,7 @@ pub fn CommandForm(categories: Vec, beacon_id: Option) -> impl }>
"Issue new command" - {if let Some(bid) = beacon_id.clone() { + {if let Some((bid, _, _)) = beacon_info.clone() { Either::Left(view! { }) @@ -242,6 +245,13 @@ pub fn CommandForm(categories: Vec, beacon_id: Option) -> impl > {sparse_actions::actions::ACTION_BUILDERS .iter() + .filter(|b| beacon_info + .as_ref() + .map(|(_, os, vers)| { + *vers >= b.required_version() && + b.required_os().map(|osv| osv == os).unwrap_or(true) + }) + .unwrap_or(true)) .map(|b| view! { }) @@ -306,7 +316,7 @@ pub fn CommandsView() -> impl IntoView { }; Either::Right(view! { - + }) })} diff --git a/sparse-server/src/beacons/instances.rs b/sparse-server/src/beacons/instances.rs index 4cae020..eaecf23 100644 --- a/sparse-server/src/beacons/instances.rs +++ b/sparse-server/src/beacons/instances.rs @@ -1,10 +1,346 @@ -use leptos::prelude::*; +use leptos::{either::Either, prelude::*}; + +use leptos_router::params::Params; +use serde::{Serialize, Deserialize}; + +#[cfg(feature = "ssr")] +use {crate::db::user, leptos::server_fn::error::NoCustomError}; + +use sparse_actions::version::Version; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum BeaconViewEvent { + +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum BeaconClientMessage { + +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BeaconInstance { + beacon_id: String, + template_id: i64, + peer_ip: String, + nickname: String, + + cwd: String, + operating_system: String, + beacon_userent: String, + hostname: String, + + version: Version, + + config_id: Option, + + category_ids: Vec, +} + +#[server(prefix = "/api/instance", endpoint = "get")] +pub async fn get_instance_data(beacon_id: String) -> Result { + let user = user::get_auth_session().await?; + + if user.is_none() { + return Err(ServerFnError::::ServerError( + "You are not signed in!".to_owned(), + )); + } + + let db = crate::db::get_db()?; + + let instance = sqlx::query!( + r#"SELECT beacon_id, template_id, peer_ip, nickname, cwd, operating_system, + beacon_userent, hostname, version as "version: Version", config_id FROM beacon_instance + WHERE beacon_id = ?"#, + beacon_id + ) + .fetch_one(&db) + .await?; + + let category_ids = sqlx::query!( + "SELECT category_id FROM beacon_category_assignment WHERE beacon_id = ?", + beacon_id + ) + .fetch_all(&db) + .await? + .iter() + .map(|r| r.category_id) + .collect(); + + Ok(BeaconInstance { + beacon_id: instance.beacon_id, + template_id: instance.template_id, + peer_ip: instance.peer_ip, + nickname: instance.nickname, + + cwd: instance.cwd, + operating_system: instance.operating_system, + beacon_userent: instance.beacon_userent, + hostname: instance.hostname, + + version: instance.version, + + config_id: instance.config_id, + + category_ids, + }) +} + +#[server(prefix = "/api/instance", endpoint = "update")] +pub async fn update_instance_prefs( + beacon_id: String, + nickname: String, + config_id: i64, + new_category_id: i64, +) -> Result<(), ServerFnError> { + let config_id = (config_id > 0).then(|| config_id); + let new_category_id = (new_category_id > 0).then(|| new_category_id); + + let user = user::get_auth_session().await?; + + if user.is_none() { + return Err(ServerFnError::::ServerError( + "You are not signed in!".to_owned(), + )); + } + + let db = crate::db::get_db()?; + + sqlx::query!( + "UPDATE beacon_instance SET nickname = ?, config_id = ? + WHERE beacon_id = ?", + nickname, + config_id, + beacon_id + ) + .execute(&db) + .await?; + + if let Some(new_category_id) = new_category_id { + sqlx::query!( + "INSERT INTO beacon_category_assignment (beacon_id, category_id) VALUES (?, ?)", + beacon_id, + new_category_id + ) + .execute(&db) + .await?; + } + + let update_notifier = expect_context::>(); + update_notifier.send(sparse_handler::BeaconEvent::BeaconUpdate(beacon_id))?; + + Ok(()) +} + +#[server(prefix = "/api/instance", endpoint = "remove_category")] +pub async fn remove_category( + beacon_id: String, + category_id: i64 +) -> Result<(), ServerFnError> { + let user = user::get_auth_session().await?; + + if user.is_none() { + return Err(ServerFnError::::ServerError( + "You are not signed in!".to_owned(), + )); + } + + let db = crate::db::get_db()?; + + sqlx::query!( + "DELETE FROM beacon_category_assignment + WHERE beacon_id = ? AND category_id = ?", + beacon_id, + category_id + ) + .execute(&db) + .await?; + + let update_notifier = expect_context::>(); + update_notifier.send(sparse_handler::BeaconEvent::BeaconUpdate(beacon_id))?; + + Ok(()) +} + +#[derive(Clone, Params, PartialEq)] +struct InstanceParams { + id: String +} #[component] pub fn InstancesView() -> impl IntoView { - view! { -
+ let instance_id = leptos_router::hooks::use_params::(); + let update_instance = ServerAction::::new(); + let remove_category = ServerAction::::new(); + + let instance = Resource::new( + move || ( + update_instance.version().get(), + remove_category.version().get(), + instance_id.get().expect("could not extract ID from URL").id + ), + |(_, _, id)| get_instance_data(id) + ); + + let super::BeaconResources { + configs, + templates, + categories, + .. + } = expect_context(); + + view! { +
+ + {move || Suspend::new(async move { + let configs = match configs.await { + Ok(cs) => cs, + Err(e) => return Either::Left(view! { +

{"There was an error loading configs:".to_string()}

+

{format!("error: {}", e)}

+ }) + }; + + let templates = match templates.await { + Ok(cs) => cs, + Err(e) => return Either::Left(view! { +

{"There was an error loading configs:".to_string()}

+

{format!("error: {}", e)}

+ }) + }; + + let categories = match categories.await { + Ok(cs) => cs, + Err(e) => return Either::Left(view! { +

{"There was an error loading configs:".to_string()}

+

{format!("error: {}", e)}

+ }) + }; + + let instance = match instance.await { + Ok(inst) => inst, + Err(e) => return Either::Left(view! { +

{"There was an error loading configs:".to_string()}

+

{format!("error: {}", e)}

+ }) + }; + + let beacon_info = Some(( + instance.beacon_id.clone(), + instance.operating_system.clone(), + instance.version + )); + + Either::Right(view! { + + + + }) + })} +
} } + +#[component] +fn ManageInstance( + configs: Vec, + templates: Vec, + categories: Vec, + instance: BeaconInstance, + update_instance: ServerAction, + remove_category: ServerAction +) -> impl IntoView { + + view! { +

"Beacon instance"

+
"Beacon ID: " {instance.beacon_id.clone()}
+
"Peer IP: " {instance.peer_ip.clone()}
+
"Current working directory: " {instance.cwd.clone()}
+
"Operating system: " {instance.operating_system.clone()}
+
"User: " {instance.beacon_userent.clone()}
+
"Hostname: " {instance.hostname.clone()}
+
"Version: " {instance.version.to_string()}
+ {templates.iter().find(|t| t.template_id == instance.template_id).map(|t| view! { +
"Template: " {t.template_name.clone()}
+ })} +
"Categories:"
+
    + {categories + .iter() + .filter(|cat| instance.category_ids.contains(&cat.category_id)) + .map(|cat| view! { +
  • + {cat.category_name.clone()} + + +
  • + }) + .collect_view()} +
+ +
+ + "Beacon configuration preferences" + + + + + + +
+ +
+
+ } +} diff --git a/sparse-server/src/beacons/sidebar.rs b/sparse-server/src/beacons/sidebar.rs index ff59897..14a27b2 100644 --- a/sparse-server/src/beacons/sidebar.rs +++ b/sparse-server/src/beacons/sidebar.rs @@ -104,13 +104,13 @@ pub fn BeaconSidebar() -> impl IntoView { match m { SidebarEvents::BeaconList(bs) => { let mut bs = bs.to_vec(); - bs.sort_by_key(|b| b.last_checkin); + bs.sort_by_key(|b| std::cmp::Reverse(b.last_checkin)); current_beacons.set(Some(bs)); } SidebarEvents::NewBeacon(b) => { current_beacons.update(|bso| { if let Some(ref mut bs) = bso { - bs.push(b.clone()) + bs.insert(0, b.clone()) } }); } @@ -126,9 +126,15 @@ pub fn BeaconSidebar() -> impl IntoView { let Some(ref mut bs) = bs else { return; }; - if let Some(ref mut b) = bs.iter_mut().find(|b| b.beacon_id == *bid) { - b.last_checkin = chrono::Utc::now(); + let Some(bpos) = bs.iter().position(|b| b.beacon_id == *bid) else { + return; + }; + bs[bpos].last_checkin = chrono::Utc::now(); + let first = bs[bpos].clone(); + for i in (1..=bpos).rev() { + bs[i] = bs[i - 1].clone(); } + bs[0] = first; }), } }); @@ -558,7 +564,7 @@ pub fn BeaconSidebar() -> impl IntoView { each={ move || (partition.beacons)() } - key=|b| (b.beacon_id.clone(), b.last_checkin) + key=|b| (b.beacon_id.clone(), b.last_checkin, b.nickname.clone(), b.category_ids.len()) let:beacon >
@@ -574,6 +580,7 @@ pub fn BeaconSidebar() -> impl IntoView { Either::Right(view! { {nick} + " " "(" {beacon.beacon_id.clone()} ")" }) diff --git a/sparse-server/src/webserver.rs b/sparse-server/src/webserver.rs index 087216d..eaaf6e7 100644 --- a/sparse-server/src/webserver.rs +++ b/sparse-server/src/webserver.rs @@ -361,6 +361,20 @@ async fn handle_listener_events( use crate::beacons::sidebar::{CurrentBeaconInstance, SidebarEvents}; + struct CheckinResult { + beacon_id: String, + checkin_date: chrono::DateTime + } + + impl sqlx::FromRow<'_, SqliteRow> for CheckinResult { + fn from_row(row: &SqliteRow) -> sqlx::Result { + Ok(CheckinResult { + beacon_id: row.get("beacon_id"), + checkin_date: row.get("checkin_date") + }) + } + } + { let beacons = sqlx::query!( r#"SELECT beacon_id, template_id, peer_ip, nickname, cwd, operating_system, @@ -369,20 +383,6 @@ async fn handle_listener_events( .fetch_all(&state.db) .await?; - struct CheckinResult { - beacon_id: String, - checkin_date: chrono::DateTime - } - - impl sqlx::FromRow<'_, SqliteRow> for CheckinResult { - fn from_row(row: &SqliteRow) -> sqlx::Result { - Ok(CheckinResult { - beacon_id: row.get("beacon_id"), - checkin_date: row.get("checkin_date") - }) - } - } - let last_checkin: Vec = sqlx::query_as( "SELECT beacon_id, MAX(checkin_date) as checkin_date FROM beacon_checkin GROUP BY beacon_id" @@ -476,6 +476,49 @@ async fn handle_listener_events( let json = serde_json::to_string(&SidebarEvents::NewBeacon(beacon))?; socket.send(ws::Message::Text(json)).await?; } + Ok(BeaconEvent::BeaconUpdate(bid)) => { + let beacon = sqlx::query!( + r#"SELECT template_id, peer_ip, nickname, cwd, operating_system, beacon_userent, hostname, config_id, version as "version: Version" FROM beacon_instance + WHERE beacon_id = ?"#, + bid + ) + .fetch_one(&state.db) + .await?; + + let category_ids = sqlx::query!( + "SELECT category_id FROM beacon_category_assignment + WHERE beacon_id = ?", + bid + ) + .fetch_all(&state.db) + .await?; + + let check_in: CheckinResult = sqlx::query_as( + r#"SELECT beacon_id, MAX(checkin_date) as checkin_date FROM beacon_checkin + WHERE beacon_id = ?"#, + ) + .bind(&bid) + .fetch_one(&state.db) + .await?; + + let beacon = CurrentBeaconInstance { + beacon_id: bid, + template_id: beacon.template_id, + ip: beacon.peer_ip, + nickname: beacon.nickname, + cwd: beacon.cwd, + operating_system: beacon.operating_system, + userent: beacon.beacon_userent, + hostname: beacon.hostname, + config_id: beacon.config_id, + version: beacon.version, + last_checkin: check_in.checkin_date, + category_ids: category_ids.iter().map(|r| r.category_id).collect() + }; + + let json = serde_json::to_string(&SidebarEvents::BeaconUpdate(beacon.beacon_id.clone(), beacon))?; + socket.send(ws::Message::Text(json)).await?; + } Ok(_) => { // this event isn't meant for public announcement } @@ -486,6 +529,43 @@ async fn handle_listener_events( } } +pub async fn attach_to_beacon( + State(state): State, + cookie_jar: CookieJar, + ws: ws::WebSocketUpgrade +) -> axum::response::Response { + let user = match crate::db::user::get_auth_session_inner( + state.db.clone(), + cookie_jar + ).await { + Ok(u) => u, + Err(e) => { + tracing::warn!("Could not load user session: {e:?}"); + return axum::http::StatusCode::INTERNAL_SERVER_ERROR.into_response(); + } + }; + + if user.is_none() { + return axum::http::StatusCode::UNAUTHORIZED.into_response(); + } + + ws + .on_upgrade(move |socket: ws::WebSocket| async move { + if let Err(e) = handle_beacon_socket(socket, state).await { + tracing::warn!("Encountered error when handling beacon subscriber: {e}"); + }; + }) + .into_response() +} + +async fn handle_beacon_socket( + mut socket: ws::WebSocket, + state: AppState, +) -> Result<(), crate::error::Error> { + unimplemented!() +} + + pub async fn serve_web( management_address: SocketAddrV4, file_store: PathBuf, diff --git a/sparse-server/style/beacons/_instances.scss b/sparse-server/style/beacons/_instances.scss index e69de29..de7ee8b 100644 --- a/sparse-server/style/beacons/_instances.scss +++ b/sparse-server/style/beacons/_instances.scss @@ -0,0 +1,15 @@ +div.instance { + padding: 10px; + overflow-y: scroll; + + fieldset { + margin-top: 20px; + display: grid; + grid-template-columns: 400px 200px; + grid-row-gap: 10px; + + input, label { + margin: 10px; + } + } +} diff --git a/sparse-server/style/beacons/_sidebar.scss b/sparse-server/style/beacons/_sidebar.scss index 29c4ac5..862e144 100644 --- a/sparse-server/style/beacons/_sidebar.scss +++ b/sparse-server/style/beacons/_sidebar.scss @@ -68,6 +68,7 @@ aside.beacons { .beacon-instance-id a { text-decoration: none; + color: white; &:hover { text-decoration: underline;