feat: added basic beacon instance management UI

This commit is contained in:
Andrew Rioux 2025-02-24 00:35:51 -05:00
parent 7778e9b454
commit a57a95a98a
Signed by: andrew.rioux
GPG Key ID: 9B8BAC47C17ABB94
7 changed files with 477 additions and 27 deletions

View File

@ -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)
}

View File

@ -175,7 +175,10 @@ pub async fn issue_command(
}
#[component]
pub fn CommandForm(categories: Vec<Category>, beacon_id: Option<String>) -> impl IntoView {
pub fn CommandForm(
categories: Vec<Category>,
beacon_info: Option<(String, String, sparse_actions::version::Version)>
) -> impl IntoView {
let command_action = Action::new(move |data: &SendWrapper<FormData>| {
let data = data.clone();
async move {
@ -186,7 +189,7 @@ pub fn CommandForm(categories: Vec<Category>, beacon_id: Option<String>) -> 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! {
<span class="error">"Missing categories! Cannot assign a command to a category if there are no categories"</span>
})}
@ -211,7 +214,7 @@ pub fn CommandForm(categories: Vec<Category>, beacon_id: Option<String>) -> impl
}>
<fieldset>
<legend>"Issue new command"</legend>
{if let Some(bid) = beacon_id.clone() {
{if let Some((bid, _, _)) = beacon_info.clone() {
Either::Left(view! {
<input name="target_beacon_id" type="hidden" value=bid />
})
@ -242,6 +245,13 @@ pub fn CommandForm(categories: Vec<Category>, beacon_id: Option<String>) -> 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! {
<option value=b.name()>{b.name()}</option>
})
@ -306,7 +316,7 @@ pub fn CommandsView() -> impl IntoView {
};
Either::Right(view! {
<CommandForm categories beacon_id=None />
<CommandForm categories beacon_info=None />
})
})}
</Suspense>

View File

@ -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<i64>,
category_ids: Vec<i64>,
}
#[server(prefix = "/api/instance", endpoint = "get")]
pub async fn get_instance_data(beacon_id: String) -> Result<BeaconInstance, ServerFnError> {
let user = user::get_auth_session().await?;
if user.is_none() {
return Err(ServerFnError::<NoCustomError>::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::<NoCustomError>::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::<tokio::sync::broadcast::Sender::<sparse_handler::BeaconEvent>>();
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::<NoCustomError>::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::<tokio::sync::broadcast::Sender::<sparse_handler::BeaconEvent>>();
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! {
<div>
let instance_id = leptos_router::hooks::use_params::<InstanceParams>();
let update_instance = ServerAction::<UpdateInstancePrefs>::new();
let remove_category = ServerAction::<RemoveCategory>::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! {
<div class="instance">
<Suspense fallback=|| view! {"Loading..."}>
{move || Suspend::new(async move {
let configs = match configs.await {
Ok(cs) => cs,
Err(e) => return Either::Left(view! {
<p>{"There was an error loading configs:".to_string()}</p>
<p>{format!("error: {}", e)}</p>
})
};
let templates = match templates.await {
Ok(cs) => cs,
Err(e) => return Either::Left(view! {
<p>{"There was an error loading configs:".to_string()}</p>
<p>{format!("error: {}", e)}</p>
})
};
let categories = match categories.await {
Ok(cs) => cs,
Err(e) => return Either::Left(view! {
<p>{"There was an error loading configs:".to_string()}</p>
<p>{format!("error: {}", e)}</p>
})
};
let instance = match instance.await {
Ok(inst) => inst,
Err(e) => return Either::Left(view! {
<p>{"There was an error loading configs:".to_string()}</p>
<p>{format!("error: {}", e)}</p>
})
};
let beacon_info = Some((
instance.beacon_id.clone(),
instance.operating_system.clone(),
instance.version
));
Either::Right(view! {
<ManageInstance
configs
instance
templates
categories=categories.clone()
update_instance
remove_category
/>
<super::commands::CommandForm
categories
beacon_info
/>
})
})}
</Suspense>
</div>
}
}
#[component]
fn ManageInstance(
configs: Vec<super::configs::BeaconConfig>,
templates: Vec<super::templates::BeaconTemplate>,
categories: Vec<super::categories::Category>,
instance: BeaconInstance,
update_instance: ServerAction<UpdateInstancePrefs>,
remove_category: ServerAction<RemoveCategory>
) -> impl IntoView {
view! {
<h3>"Beacon instance"</h3>
<div><span>"Beacon ID: "</span> {instance.beacon_id.clone()}</div>
<div><span>"Peer IP: "</span> {instance.peer_ip.clone()}</div>
<div><span>"Current working directory: "</span> {instance.cwd.clone()}</div>
<div><span>"Operating system: "</span> {instance.operating_system.clone()}</div>
<div><span>"User: "</span> {instance.beacon_userent.clone()}</div>
<div><span>"Hostname: "</span> {instance.hostname.clone()}</div>
<div><span>"Version: "</span> {instance.version.to_string()}</div>
{templates.iter().find(|t| t.template_id == instance.template_id).map(|t| view! {
<div><span>"Template: "</span> {t.template_name.clone()}</div>
})}
<div>"Categories:"</div>
<ul>
{categories
.iter()
.filter(|cat| instance.category_ids.contains(&cat.category_id))
.map(|cat| view! {
<li>
{cat.category_name.clone()}
<button
on:click={
let category_id = cat.category_id;
let beacon_id = instance.beacon_id.clone();
move |_| {
remove_category.dispatch(RemoveCategory {
beacon_id: beacon_id.clone(),
category_id
});
}
}
class="warning"
>
"Remove"
</button>
</li>
})
.collect_view()}
</ul>
<ActionForm action=update_instance>
<fieldset>
<input type="hidden" name="beacon_id" value=instance.beacon_id.clone()/>
<legend>"Beacon configuration preferences"</legend>
<label>"Nickname"</label>
<input name="nickname" value=instance.nickname.clone()/>
<label>"Override configuration"</label>
<select name="config_id">
<option value="-1">"---"</option>
{configs
.iter()
.map(|conf| view! {
<option
value=conf.config_id
selected=Some(conf.config_id) == instance.config_id
>
{conf.config_name.clone()}
</option>
})
.collect_view()}
</select>
<label>"Add new category"</label>
<select name="new_category_id">
<option value="-1">"---"</option>
{categories
.iter()
.filter(|cat| !instance.category_ids.contains(&cat.category_id))
.map(|cat| view! {
<option value=cat.category_id>
{cat.category_name.clone()}
</option>
})
.collect_view()}
</select>
<div></div>
<input type="submit" value="Submit"/>
</fieldset>
</ActionForm>
}
}

View File

@ -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
>
<div class="beacon-instance">
@ -574,6 +580,7 @@ pub fn BeaconSidebar() -> impl IntoView {
Either::Right(view! {
<A href=format!("/beacons/beacon/{}", &beacon.beacon_id)>
{nick}
" "
<span>"(" {beacon.beacon_id.clone()} ")"</span>
</A>
})

View File

@ -361,14 +361,6 @@ async fn handle_listener_events(
use crate::beacons::sidebar::{CurrentBeaconInstance, SidebarEvents};
{
let beacons = sqlx::query!(
r#"SELECT beacon_id, template_id, peer_ip, nickname, cwd, operating_system,
beacon_userent, hostname, config_id, version as "version: Version" FROM beacon_instance"#
)
.fetch_all(&state.db)
.await?;
struct CheckinResult {
beacon_id: String,
checkin_date: chrono::DateTime<chrono::Utc>
@ -383,6 +375,14 @@ async fn handle_listener_events(
}
}
{
let beacons = sqlx::query!(
r#"SELECT beacon_id, template_id, peer_ip, nickname, cwd, operating_system,
beacon_userent, hostname, config_id, version as "version: Version" FROM beacon_instance"#
)
.fetch_all(&state.db)
.await?;
let last_checkin: Vec<CheckinResult> = 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<AppState>,
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,

View File

@ -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;
}
}
}

View File

@ -68,6 +68,7 @@ aside.beacons {
.beacon-instance-id a {
text-decoration: none;
color: white;
&:hover {
text-decoration: underline;